From 1c032da3dfa49c62f37c909f6b691c5899e9f08f Mon Sep 17 00:00:00 2001 From: Alexander Zinn Date: Sun, 17 Aug 2025 07:19:39 -0400 Subject: [PATCH] expanded --- src/PCastApi.ts | 34 ++++++ src/index.ts | 27 +---- src/lang/assertUnreachable.ts | 2 +- src/net/http/HttpMethod.ts | 12 +-- src/net/http/HttpRequests.ts | 51 ++++++--- src/pcast/Channels.ts | 190 ++++++++++++++++++++++++++++++++-- src/pcast/IResponse.ts | 19 ++++ src/pcast/PCastRequests.ts | 26 +++++ src/pcast/Stream.ts | 33 ++++++ src/pcast/index.ts | 3 + 10 files changed, 347 insertions(+), 50 deletions(-) create mode 100644 src/pcast/IResponse.ts create mode 100644 src/pcast/PCastRequests.ts create mode 100644 src/pcast/Stream.ts create mode 100644 src/pcast/index.ts diff --git a/src/PCastApi.ts b/src/PCastApi.ts index e69de29..87a6a2f 100644 --- a/src/PCastApi.ts +++ b/src/PCastApi.ts @@ -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); + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 523f563..dab0fcb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,24 +1,5 @@ -import {HttpRequests} from './net/http/HttpRequests'; -import {Channels} from './pcast/Channels'; +import {PCastApi} from "./PCastApi"; -// coming soon.. -const applicationCredentials = { - id: 'phenixrts.com-alex.zinn', - 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); -}); +export * from './pcast'; +export {PCastApi}; +export default {PCastApi}; \ No newline at end of file diff --git a/src/lang/assertUnreachable.ts b/src/lang/assertUnreachable.ts index 1d26929..5c96a64 100644 --- a/src/lang/assertUnreachable.ts +++ b/src/lang/assertUnreachable.ts @@ -1,3 +1,3 @@ export default function assertUnreachable(x: never): never { throw new Error(`Unreachable code: ${x}`); -} \ No newline at end of file +} diff --git a/src/net/http/HttpMethod.ts b/src/net/http/HttpMethod.ts index 67bb495..a4d244a 100644 --- a/src/net/http/HttpMethod.ts +++ b/src/net/http/HttpMethod.ts @@ -1,8 +1,8 @@ // Replace entire file with simplified string enum export enum HttpMethod { - Get = 'GET', - Post = 'POST', - Put = 'PUT', - Patch = 'PATCH', - Delete = 'DELETE' -} \ No newline at end of file + GET = 'GET', + POST = 'POST', + PUT = 'PUT', + PATCH = 'PATCH', + DELETE = 'DELETE' +} diff --git a/src/net/http/HttpRequests.ts b/src/net/http/HttpRequests.ts index 30214f5..0a183af 100644 --- a/src/net/http/HttpRequests.ts +++ b/src/net/http/HttpRequests.ts @@ -1,6 +1,19 @@ import {HttpMethod} from './HttpMethod'; 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 { private readonly _baseUri: string; @@ -13,32 +26,36 @@ export class HttpRequests { this._requestTimeoutDuration = options.requestTimeoutDuration ?? defaultRequestTimeoutDurationInMilliseconds; } - public async request(method: HttpMethod, path: string, options?: RequestInit & {body?: Record | string}): Promise { + public async request(method: HttpMethod, path: string, options: RequestInit & {body?: Record | string} = {}): Promise { const abortController = new AbortController(); const abortSignal = abortController.signal; - - let requestOptions: RequestInit = { + const requestOptions: RequestInit = { headers: this._baseHeaders, - method: method.toString(), // Convert enum to string - signal: abortSignal + method: HttpMethod[method], // Convert enum to string + signal: abortSignal, + ...options }; - if (options?.body && method !== HttpMethod.Get) { - requestOptions.body = typeof options.body === 'string' ? options.body : JSON.stringify(options.body); + if (httpMethodsThatMustNotHaveBody.includes(method)) { + requestOptions.body = undefined; } return this.makeRequest(path, requestOptions, abortController, this._requestTimeoutDuration); } - private async makeRequest(path: string, options: RequestInit, abortController: AbortController, timeoutDuration: number): Promise { + private async makeRequest(path: string, options: RequestInit, abortController: AbortController, timeoutDuration: number): Promise { const requestTimeoutId = globalThis.setTimeout(() => abortController.abort(), timeoutDuration); try { const requestPath = `${this._baseUri}${path}`; const response = await fetch(requestPath, options); - + 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'); @@ -49,8 +66,18 @@ export class HttpRequests { return response.text() as T; } catch (e) { - console.error(e); - return; + if (e instanceof HttpRequestError) { + 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 { globalThis.clearTimeout(requestTimeoutId); } diff --git a/src/pcast/Channels.ts b/src/pcast/Channels.ts index d6ea71c..c7aed48 100644 --- a/src/pcast/Channels.ts +++ b/src/pcast/Channels.ts @@ -1,14 +1,188 @@ 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 { - private readonly _httpRequests: HttpRequests; +export type ChannelId = string; +export type Channel = { + options: string[]; + alias: string; + name: string; + description: string; + type: string; + streamKey: string; + created: string; + lastUpdated: string; + channelId: string; +}; - constructor(httpRequests: HttpRequests) { - this._httpRequests = httpRequests; - } +type GetChannelParams = { + alias?: string; + channelId?: string; +}; - public async getChannels() { - return this._httpRequests.request(HttpMethod.Get, '/channels'); +export class ChannelError extends Error { + constructor(message: string, public readonly code: string) { + super(message); + this.name = 'ChannelError'; + } +} + +export class Channels { + private readonly _httpRequests: PCastHttpRequests; + private readonly _channelsByAlias: Map = 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 { + // 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(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 { + const response = await this._httpRequests.request(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 { + 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(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(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 { + if (!channelId || channelId.trim().length === 0) { + throw new ChannelError('Channel ID cannot be empty', 'INVALID_CHANNEL_ID'); + } + + const response = await this._httpRequests.request(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 { + if (!channelId || channelId.trim().length === 0) { + throw new ChannelError('Channel ID cannot be empty', 'INVALID_CHANNEL_ID'); + } + + const response = await this._httpRequests.request(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; } } diff --git a/src/pcast/IResponse.ts b/src/pcast/IResponse.ts new file mode 100644 index 0000000..de7d719 --- /dev/null +++ b/src/pcast/IResponse.ts @@ -0,0 +1,19 @@ +import type { Channel } from './Channels'; + +export default interface IResponse { + 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[]; +} diff --git a/src/pcast/PCastRequests.ts b/src/pcast/PCastRequests.ts new file mode 100644 index 0000000..6a663b9 --- /dev/null +++ b/src/pcast/PCastRequests.ts @@ -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; + } +} \ No newline at end of file diff --git a/src/pcast/Stream.ts b/src/pcast/Stream.ts new file mode 100644 index 0000000..7838317 --- /dev/null +++ b/src/pcast/Stream.ts @@ -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>(HttpMethod.PUT, `/stream/publish/uri/${mediaType}`, { + body: JSON.stringify({ + token, + uri: mediaUri, + options: [] + }) + }); + + return response; + } + +} \ No newline at end of file diff --git a/src/pcast/index.ts b/src/pcast/index.ts new file mode 100644 index 0000000..b7a33cc --- /dev/null +++ b/src/pcast/index.ts @@ -0,0 +1,3 @@ +export * from './Channels'; +export * from './Stream'; +export * from './PCastRequests'; \ No newline at end of file