expanded
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
import { Channels, Streams, type ApplicationCredentials, PCastHttpRequests} from "./pcast";
|
||||||
|
|
||||||
|
export class PCastApi {
|
||||||
|
private readonly _pcastUri: string;
|
||||||
|
private readonly _applicationCredentials: ApplicationCredentials;
|
||||||
|
private readonly _pcastHttpRequests: PCastHttpRequests;
|
||||||
|
|
||||||
|
constructor(pcastUri: string, applicationCredentials: ApplicationCredentials) {
|
||||||
|
if (pcastUri.split('/').at(-1) !== 'pcast') {
|
||||||
|
this._pcastUri = `${pcastUri}/pcast`;
|
||||||
|
} else {
|
||||||
|
this._pcastUri = pcastUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._applicationCredentials = applicationCredentials;
|
||||||
|
this._pcastHttpRequests = new PCastHttpRequests(this._pcastUri, this._applicationCredentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
get pcastUri(): string {
|
||||||
|
return this._pcastUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
get applicationCredentials(): ApplicationCredentials {
|
||||||
|
return this._applicationCredentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
get channels(): Channels {
|
||||||
|
return new Channels(this._pcastHttpRequests);
|
||||||
|
}
|
||||||
|
|
||||||
|
get streams(): Streams {
|
||||||
|
return new Streams(this._pcastHttpRequests);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/index.ts
27
src/index.ts
@@ -1,24 +1,5 @@
|
|||||||
import {HttpRequests} from './net/http/HttpRequests';
|
import {PCastApi} from "./PCastApi";
|
||||||
import {Channels} from './pcast/Channels';
|
|
||||||
|
|
||||||
// coming soon..
|
export * from './pcast';
|
||||||
const applicationCredentials = {
|
export {PCastApi};
|
||||||
id: 'phenixrts.com-alex.zinn',
|
export default {PCastApi};
|
||||||
secret: 'AMAsDzr.dIuGMZ.Zu52Dt~MQvP!DZwYg'
|
|
||||||
};
|
|
||||||
const pcastUri = 'https://pcast-stg.phenixrts.com';
|
|
||||||
|
|
||||||
const httpRequests = new HttpRequests(
|
|
||||||
`${pcastUri}/pcast`,
|
|
||||||
new Headers({
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Accept: 'application/json',
|
|
||||||
Authorization: `Basic ${btoa(`${applicationCredentials.id}:${applicationCredentials.secret}`)}`
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const channels = new Channels(httpRequests);
|
|
||||||
|
|
||||||
channels.getChannels().then(channels => {
|
|
||||||
console.log(channels);
|
|
||||||
});
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
export default function assertUnreachable(x: never): never {
|
export default function assertUnreachable(x: never): never {
|
||||||
throw new Error(`Unreachable code: ${x}`);
|
throw new Error(`Unreachable code: ${x}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// Replace entire file with simplified string enum
|
// Replace entire file with simplified string enum
|
||||||
export enum HttpMethod {
|
export enum HttpMethod {
|
||||||
Get = 'GET',
|
GET = 'GET',
|
||||||
Post = 'POST',
|
POST = 'POST',
|
||||||
Put = 'PUT',
|
PUT = 'PUT',
|
||||||
Patch = 'PATCH',
|
PATCH = 'PATCH',
|
||||||
Delete = 'DELETE'
|
DELETE = 'DELETE'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
import {HttpMethod} from './HttpMethod';
|
import {HttpMethod} from './HttpMethod';
|
||||||
|
|
||||||
const defaultRequestTimeoutDurationInMilliseconds = 30_000;
|
const defaultRequestTimeoutDurationInMilliseconds = 30_000;
|
||||||
|
const httpMethodsThatMustNotHaveBody = [HttpMethod.GET]; // Head, Options
|
||||||
|
|
||||||
|
export class HttpRequestError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly status?: number,
|
||||||
|
public readonly statusText?: string,
|
||||||
|
public readonly originalError?: unknown
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'HttpRequestError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class HttpRequests {
|
export class HttpRequests {
|
||||||
private readonly _baseUri: string;
|
private readonly _baseUri: string;
|
||||||
@@ -13,32 +26,36 @@ export class HttpRequests {
|
|||||||
this._requestTimeoutDuration = options.requestTimeoutDuration ?? defaultRequestTimeoutDurationInMilliseconds;
|
this._requestTimeoutDuration = options.requestTimeoutDuration ?? defaultRequestTimeoutDurationInMilliseconds;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async request<T>(method: HttpMethod, path: string, options?: RequestInit & {body?: Record<string, unknown> | string}): Promise<T | void> {
|
public async request<T>(method: HttpMethod, path: string, options: RequestInit & {body?: Record<string, unknown> | string} = {}): Promise<T> {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const abortSignal = abortController.signal;
|
const abortSignal = abortController.signal;
|
||||||
|
const requestOptions: RequestInit = {
|
||||||
let requestOptions: RequestInit = {
|
|
||||||
headers: this._baseHeaders,
|
headers: this._baseHeaders,
|
||||||
method: method.toString(), // Convert enum to string
|
method: HttpMethod[method], // Convert enum to string
|
||||||
signal: abortSignal
|
signal: abortSignal,
|
||||||
|
...options
|
||||||
};
|
};
|
||||||
|
|
||||||
if (options?.body && method !== HttpMethod.Get) {
|
if (httpMethodsThatMustNotHaveBody.includes(method)) {
|
||||||
requestOptions.body = typeof options.body === 'string' ? options.body : JSON.stringify(options.body);
|
requestOptions.body = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.makeRequest<T>(path, requestOptions, abortController, this._requestTimeoutDuration);
|
return this.makeRequest<T>(path, requestOptions, abortController, this._requestTimeoutDuration);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async makeRequest<T>(path: string, options: RequestInit, abortController: AbortController, timeoutDuration: number): Promise<T | void> {
|
private async makeRequest<T>(path: string, options: RequestInit, abortController: AbortController, timeoutDuration: number): Promise<T> {
|
||||||
const requestTimeoutId = globalThis.setTimeout(() => abortController.abort(), timeoutDuration);
|
const requestTimeoutId = globalThis.setTimeout(() => abortController.abort(), timeoutDuration);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const requestPath = `${this._baseUri}${path}`;
|
const requestPath = `${this._baseUri}${path}`;
|
||||||
const response = await fetch(requestPath, options);
|
const response = await fetch(requestPath, options);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status [${response.status}] ${response.statusText}`);
|
throw new HttpRequestError(
|
||||||
|
`HTTP error! status [${response.status}] ${response.statusText}`,
|
||||||
|
response.status,
|
||||||
|
response.statusText
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseContentType = response.headers.get('content-type');
|
const responseContentType = response.headers.get('content-type');
|
||||||
@@ -49,8 +66,18 @@ export class HttpRequests {
|
|||||||
|
|
||||||
return response.text() as T;
|
return response.text() as T;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
if (e instanceof HttpRequestError) {
|
||||||
return;
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e instanceof Error) {
|
||||||
|
if (e.name === 'AbortError') {
|
||||||
|
throw new HttpRequestError(`Request timeout after ${timeoutDuration}ms`, undefined, undefined, e);
|
||||||
|
}
|
||||||
|
throw new HttpRequestError(`Request failed: ${e.message}`, undefined, undefined, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HttpRequestError(`Unknown request error: ${String(e)}`, undefined, undefined, e);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.clearTimeout(requestTimeoutId);
|
globalThis.clearTimeout(requestTimeoutId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,188 @@
|
|||||||
import {HttpMethod} from '../net/http/HttpMethod';
|
import {HttpMethod} from '../net/http/HttpMethod';
|
||||||
import type {HttpRequests} from '../net/http/HttpRequests';
|
import type {PCastHttpRequests} from './PCastRequests';
|
||||||
|
import type {ChannelResponse, ChannelsResponse, MembersResponse} from './IResponse';
|
||||||
|
|
||||||
export class Channels {
|
export type ChannelId = string;
|
||||||
private readonly _httpRequests: HttpRequests;
|
export type Channel = {
|
||||||
|
options: string[];
|
||||||
|
alias: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
type: string;
|
||||||
|
streamKey: string;
|
||||||
|
created: string;
|
||||||
|
lastUpdated: string;
|
||||||
|
channelId: string;
|
||||||
|
};
|
||||||
|
|
||||||
constructor(httpRequests: HttpRequests) {
|
type GetChannelParams = {
|
||||||
this._httpRequests = httpRequests;
|
alias?: string;
|
||||||
}
|
channelId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
public async getChannels() {
|
export class ChannelError extends Error {
|
||||||
return this._httpRequests.request(HttpMethod.Get, '/channels');
|
constructor(message: string, public readonly code: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ChannelError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Channels {
|
||||||
|
private readonly _httpRequests: PCastHttpRequests;
|
||||||
|
private readonly _channelsByAlias: Map<ChannelId, Channel> = new Map();
|
||||||
|
private _initialized = false;
|
||||||
|
|
||||||
|
constructor(pcastHttpRequests: PCastHttpRequests) {
|
||||||
|
this._httpRequests = pcastHttpRequests;
|
||||||
|
this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initialize() {
|
||||||
|
try {
|
||||||
|
const channelsList = await this.list();
|
||||||
|
if (!channelsList) {
|
||||||
|
console.warn('[Channels] Failed to initialize cache - no channels returned');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Channels] [initialize] Populating local cache with [%o] channels', channelsList.length);
|
||||||
|
|
||||||
|
for (let channel of channelsList) {
|
||||||
|
this._channelsByAlias.set(channel.alias, channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._initialized = true;
|
||||||
|
console.log('[Channels] [initialize] Cache populated successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Channels] Failed to initialize cache:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async create(name: string, description: string, channelOptions: string[] = []): Promise<Channel> {
|
||||||
|
// Input validation
|
||||||
|
if (!name || name.trim().length === 0) {
|
||||||
|
throw new ChannelError('Channel name cannot be empty', 'INVALID_NAME');
|
||||||
|
}
|
||||||
|
if (!description || description.trim().length === 0) {
|
||||||
|
throw new ChannelError('Channel description cannot be empty', 'INVALID_DESCRIPTION');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(channelOptions)) {
|
||||||
|
throw new ChannelError('Channel options must be an array', 'INVALID_OPTIONS');
|
||||||
|
}
|
||||||
|
|
||||||
|
const createChannelRequestBody = {
|
||||||
|
channel: {
|
||||||
|
name: name.trim(),
|
||||||
|
alias: name.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
options: channelOptions
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await this._httpRequests.request<ChannelResponse>(HttpMethod.PUT, '/channel', {
|
||||||
|
body: JSON.stringify(createChannelRequestBody)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.channel) {
|
||||||
|
throw new ChannelError('Invalid response format - missing channel data', 'INVALID_RESPONSE');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache with new channel
|
||||||
|
this._channelsByAlias.set(response.channel.alias, response.channel);
|
||||||
|
|
||||||
|
return response.channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async list(): Promise<Channel[]> {
|
||||||
|
const response = await this._httpRequests.request<ChannelsResponse>(HttpMethod.GET, '/channels');
|
||||||
|
|
||||||
|
if (!response.channels) {
|
||||||
|
throw new ChannelError('Invalid response format - missing channels data', 'INVALID_RESPONSE');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Channels] [list] Response [%o]', response);
|
||||||
|
|
||||||
|
return response.channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get({alias, channelId}: GetChannelParams): Promise<Channel | undefined> {
|
||||||
|
if (!alias && !channelId) {
|
||||||
|
throw new ChannelError('Either alias or channelId must be provided', 'MISSING_PARAMETER');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alias) {
|
||||||
|
// Check cache first
|
||||||
|
if (this._channelsByAlias.has(alias)) {
|
||||||
|
return this._channelsByAlias.get(alias);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this._httpRequests.request<ChannelResponse>(HttpMethod.GET, `/channel/${encodeURIComponent(alias)}`);
|
||||||
|
|
||||||
|
if (!response.channel) {
|
||||||
|
throw new ChannelError(`Invalid response format for channel: ${alias}`, 'INVALID_RESPONSE');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
this._channelsByAlias.set(alias, response.channel);
|
||||||
|
return response.channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channelId) {
|
||||||
|
const response = await this._httpRequests.request<ChannelResponse>(HttpMethod.GET, `/channel/${encodeURIComponent(channelId)}`);
|
||||||
|
|
||||||
|
if (!response.channel) {
|
||||||
|
throw new ChannelError(`Invalid response format for channel ID: ${channelId}`, 'INVALID_RESPONSE');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getMembers(channelId: string): Promise<Channel[]> {
|
||||||
|
if (!channelId || channelId.trim().length === 0) {
|
||||||
|
throw new ChannelError('Channel ID cannot be empty', 'INVALID_CHANNEL_ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this._httpRequests.request<MembersResponse>(HttpMethod.GET, `/channel/${encodeURIComponent(channelId)}/members`);
|
||||||
|
|
||||||
|
if (!response.members) {
|
||||||
|
throw new ChannelError(`Invalid response format for channel members: ${channelId}`, 'INVALID_RESPONSE');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.members;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(channelId: string): Promise<Channel> {
|
||||||
|
if (!channelId || channelId.trim().length === 0) {
|
||||||
|
throw new ChannelError('Channel ID cannot be empty', 'INVALID_CHANNEL_ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this._httpRequests.request<ChannelResponse>(HttpMethod.DELETE, `/channel/${encodeURIComponent(channelId)}`);
|
||||||
|
|
||||||
|
if (!response.channel) {
|
||||||
|
throw new ChannelError(`Invalid response format for deleted channel: ${channelId}`, 'INVALID_RESPONSE');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from cache if it exists
|
||||||
|
const deletedChannel = response.channel;
|
||||||
|
if (this._channelsByAlias.has(deletedChannel.alias)) {
|
||||||
|
this._channelsByAlias.delete(deletedChannel.alias);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deletedChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache management methods
|
||||||
|
public isInitialized(): boolean {
|
||||||
|
return this._initialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearCache(): void {
|
||||||
|
this._channelsByAlias.clear();
|
||||||
|
this._initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCacheSize(): number {
|
||||||
|
return this._channelsByAlias.size;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/pcast/IResponse.ts
Normal file
19
src/pcast/IResponse.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Channel } from './Channels';
|
||||||
|
|
||||||
|
export default interface IResponse<Key extends string, T> {
|
||||||
|
status: string;
|
||||||
|
[key: string]: T | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// More specific response types for better type safety
|
||||||
|
export interface ChannelResponse extends IResponse<'channel', Channel> {
|
||||||
|
channel: Channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelsResponse extends IResponse<'channels', Channel[]> {
|
||||||
|
channels: Channel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MembersResponse extends IResponse<'members', Channel[]> {
|
||||||
|
members: Channel[];
|
||||||
|
}
|
||||||
26
src/pcast/PCastRequests.ts
Normal file
26
src/pcast/PCastRequests.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
|
||||||
|
import { HttpRequests} from "../net/http/HttpRequests";
|
||||||
|
|
||||||
|
export type ApplicationCredentials = {
|
||||||
|
id: string;
|
||||||
|
secret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PCastHttpRequests extends HttpRequests {
|
||||||
|
private readonly _tenancy: string;
|
||||||
|
|
||||||
|
constructor(baseUri: string, applicationCredentials: ApplicationCredentials, options: {requestTimeoutDuration?: number} = {}) {
|
||||||
|
const baseHeaders = new Headers({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Basic ${btoa(`${applicationCredentials.id}:${applicationCredentials.secret}`)}`
|
||||||
|
});
|
||||||
|
super(baseUri, baseHeaders, options);
|
||||||
|
|
||||||
|
this._tenancy = applicationCredentials.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get tenancy(): string {
|
||||||
|
return this._tenancy;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/pcast/Stream.ts
Normal file
33
src/pcast/Stream.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
|
||||||
|
import { HttpMethod } from "../net/http/HttpMethod";
|
||||||
|
import type IResponse from "./IResponse";
|
||||||
|
import type { PCastHttpRequests } from "./PCastRequests";
|
||||||
|
|
||||||
|
export class Streams {
|
||||||
|
private readonly _httpRequests: PCastHttpRequests;
|
||||||
|
|
||||||
|
constructor(httpRequests: PCastHttpRequests) {
|
||||||
|
this._httpRequests = httpRequests;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public async publishUri(mediaUri: string, token: string) {
|
||||||
|
const mediaType = mediaUri.split('.')?[mediaUri.at(-1)];
|
||||||
|
|
||||||
|
if (!mediaType) {
|
||||||
|
throw new Error('Invalid media URI no media type found');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const response = await this._httpRequests.request<IResponse<'publishUri', string>>(HttpMethod.PUT, `/stream/publish/uri/${mediaType}`, {
|
||||||
|
body: JSON.stringify({
|
||||||
|
token,
|
||||||
|
uri: mediaUri,
|
||||||
|
options: []
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
3
src/pcast/index.ts
Normal file
3
src/pcast/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './Channels';
|
||||||
|
export * from './Stream';
|
||||||
|
export * from './PCastRequests';
|
||||||
Reference in New Issue
Block a user