feat: add ESLint configuration, update package.json scripts, and create example files for Bun integration

This commit is contained in:
2025-08-20 13:20:59 -04:00
parent 15a6f1ac81
commit b12aef225a
24 changed files with 464 additions and 128 deletions

View File

@@ -1,19 +1,23 @@
import {Channels, Streams, type ApplicationCredentials, PCastHttpRequests} from './pcast';
import {Channels, Streams, type ApplicationCredentials, PCastHttpRequests, Reporting} from './pcast';
export class PCastApi {
private readonly _pcastUri: string;
private readonly _applicationCredentials: ApplicationCredentials;
private readonly _pcastHttpRequests: PCastHttpRequests;
private readonly _channels: Channels;
private readonly _streams: Streams;
private readonly _reporting: Reporting;
constructor(pcastUri: string, applicationCredentials: ApplicationCredentials) {
if (pcastUri.split('/').at(-1) !== 'pcast') {
this._pcastUri = `${pcastUri}/pcast`;
} else {
this._pcastUri = pcastUri;
}
// in `src/PCastApi.ts` constructor
const normalized = pcastUri.replace(/\/+$/, '');
this._pcastUri = normalized.endsWith('/pcast') ? normalized : `${normalized}/pcast`;
this._applicationCredentials = applicationCredentials;
this._pcastHttpRequests = new PCastHttpRequests(this._pcastUri, this._applicationCredentials);
this._channels = new Channels(this._pcastHttpRequests);
this._streams = new Streams(this._pcastHttpRequests);
this._reporting = new Reporting(this._pcastHttpRequests);
}
get pcastUri(): string {
@@ -25,10 +29,14 @@ export class PCastApi {
}
get channels(): Channels {
return new Channels(this._pcastHttpRequests);
return this._channels;
}
get streams(): Streams {
return new Streams(this._pcastHttpRequests);
return this._streams;
}
get reporting(): Reporting {
return this._reporting;
}
}

View File

@@ -1,5 +1,7 @@
import {PCastApi} from './PCastApi';
import {ReportKind} from './pcast/ReportKind';
import {ViewingReportKind} from './pcast/ViewingReportKind';
export * from './pcast';
export type {ReportKind, ViewingReportKind};
export {PCastApi};
export default {PCastApi};
export default PCastApi;

View File

@@ -38,6 +38,10 @@ export class HttpRequests {
if (httpMethodsThatMustNotHaveBody.includes(method)) {
requestOptions.body = undefined;
} else {
if (requestOptions.body && typeof requestOptions.body !== 'string') {
requestOptions.body = JSON.stringify(requestOptions.body);
}
}
return this.makeRequest<T>(path, requestOptions, abortController, this._requestTimeoutDuration);
@@ -48,6 +52,7 @@ export class HttpRequests {
try {
const requestPath = `${this._baseUri}${path}`;
const response = await fetch(requestPath, options);
if (!response.ok) {

View File

@@ -14,6 +14,22 @@ export type Channel = {
lastUpdated: string;
channelId: string;
};
export type ChannelAlias = string;
export type Member = {
sessionId: string;
screenName: string;
role: string;
streams: [
{
type: string;
uri: string;
audioState: string;
videoState: string;
}
],
state: string;
lastUpdate: number;
}
type GetChannelParams = {
alias?: string;
@@ -32,7 +48,7 @@ export class ChannelError extends Error {
export class Channels {
private readonly _httpRequests: PCastHttpRequests;
private readonly _channelsByAlias: Map<ChannelId, Channel> = new Map();
private readonly _channelsByAlias: Map<ChannelAlias, Channel> = new Map();
private _initialized = false;
constructor(pcastHttpRequests: PCastHttpRequests) {
@@ -48,14 +64,11 @@ export class Channels {
return;
}
console.log('[Channels] [initialize] Populating local cache with [%o] channels', channelsList.length);
for (let channel of channelsList) {
for (const 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);
}
@@ -103,8 +116,6 @@ export class Channels {
throw new ChannelError('Invalid response format - missing channels data', 'INVALID_RESPONSE');
}
console.log('[Channels] [list] Response [%o]', response);
return response.channels;
}
@@ -123,35 +134,26 @@ export class Channels {
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');
}
const channelList = await this.list();
// Update cache
this._channelsByAlias.set(alias, response.channel);
return response.channel;
return channelList.find(channel => channel.alias === alias);
}
if (channelId) {
const response = await this._httpRequests.request<ChannelResponse>(HttpMethod.GET, `/channel/${encodeURIComponent(channelId)}`);
const channelList = await this.list();
if (!response.channel) {
throw new ChannelError(`Invalid response format for channel ID: ${channelId}`, 'INVALID_RESPONSE');
}
return response.channel;
return channelList.find(channel => channel.channelId === channelId);
}
}
public async getChannelPublisherCount(channelId: string): Promise<number> {
const response = await this._httpRequests.request<string>(HttpMethod.GET, `/channel/${encodeURIComponent(channelId)}/publishers/count`);
return parseInt(response);
return parseInt(response, 10);
}
public async getMembers(channelId: string): Promise<Channel[]> {
public async getMembers(channelId: string): Promise<Member[]> {
if (!channelId || channelId.trim().length === 0) {
throw new ChannelError('Channel ID cannot be empty', 'INVALID_CHANNEL_ID');
}

View File

@@ -1,27 +1,19 @@
import type {Channel} from './Channels';
import type {Channel, Member} from './Channels';
export default interface IResponse<Key extends string, T> {
export default interface IResponse<T> {
status: string;
[key: string]: T | string;
}
// More specific response types for better type safety
export interface ChannelResponse extends IResponse<'channel', Channel> {
export interface ChannelResponse extends IResponse<Channel> {
channel: Channel;
}
export interface ChannelsResponse extends IResponse<'channels', Channel[]> {
export interface ChannelsResponse extends IResponse<Channel[]> {
channels: Channel[];
}
export interface MembersResponse extends IResponse<'members', Channel[]> {
members: Channel[];
}
export interface PublishingReportResponse extends IResponse<'publishingReport', string> {
publishingReport: string;
}
export interface ViewingReportResponse extends IResponse<'viewingReport', string> {
viewingReport: string;
export interface MembersResponse extends IResponse<Member[]> {
members: Member[];
}

View File

@@ -9,10 +9,15 @@ export class PCastHttpRequests extends HttpRequests {
private readonly _tenancy: string;
constructor(baseUri: string, applicationCredentials: ApplicationCredentials, options: {requestTimeoutDuration?: number} = {}) {
const credentials = `${applicationCredentials.id}:${applicationCredentials.secret}`;
const basic = typeof btoa === 'function'
? btoa(credentials)
: Buffer.from(credentials, 'utf-8').toString('base64');
const baseHeaders = new Headers({
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: `Basic ${btoa(`${applicationCredentials.id}:${applicationCredentials.secret}`)}`
Authorization: `Basic ${basic}`
});
super(baseUri, baseHeaders, options);

34
src/pcast/ReportKind.ts Normal file
View File

@@ -0,0 +1,34 @@
import assertUnreachable from '../lang/assertUnreachable';
export enum ReportKind {
Publishing = 0,
Viewing = 1
}
export type ReportKindType = 'Publishing' | 'Viewing';
export class ReportKindMapping {
public static convertReportKindTypeToReportKind(reportKindType: ReportKindType): ReportKind {
switch (reportKindType) {
case 'Publishing':
return ReportKind.Publishing;
case 'Viewing':
return ReportKind.Viewing;
default:
assertUnreachable(reportKindType);
}
}
public static convertReportKindToReportKindType(reportKind: ReportKind): ReportKindType {
switch (reportKind) {
case ReportKind.Publishing:
return 'Publishing';
case ReportKind.Viewing:
return 'Viewing';
default:
assertUnreachable(reportKind);
}
}
}

View File

@@ -1,40 +1,8 @@
import {HttpMethod} from '../net/http/HttpMethod';
import type {PCastHttpRequests} from './PCastRequests';
import type {PublishingReportResponse, ViewingReportResponse} from './IResponse';
import assertUnreachable from '../lang/assertUnreachable';
export enum ReportKind {
Publishing = 0,
Viewing = 1
}
export type ReportKindType = 'Publishing' | 'Viewing';
export class ReportKindMapping {
public static convertReportKindTypeToReportKind(reportKindType: ReportKindType): ReportKind {
switch (reportKindType) {
case 'Publishing':
return ReportKind.Publishing;
case 'Viewing':
return ReportKind.Viewing;
default:
assertUnreachable(reportKindType);
}
}
public static convertReportKindToReportKindType(reportKind: ReportKind): ReportKindType {
switch (reportKind) {
case ReportKind.Publishing:
return 'Publishing';
case ReportKind.Viewing:
return 'Viewing';
default:
assertUnreachable(reportKind);
}
}
}
import {ReportKind} from './ReportKind';
import { ViewingReportKind, ViewingReportKindMapping } from './ViewingReportKind';
export type PublishingReportOptions = {
applicationIds?: string[];
@@ -48,13 +16,6 @@ export type PublishingReportOptions = {
end: string;
};
export enum ViewingReportKind {
RealTime = 0,
HLS = 1,
DASH = 2
}
export type ViewingReportKindType = 'RealTime' | 'HLS' | 'DASH';
export type ViewingReportOptions = {
kind: ViewingReportKind;
@@ -80,21 +41,18 @@ export class Reporting {
}
public async generateReport<ReportOptions extends PublishingReportOptions | ViewingReportOptions>(kind: ReportKind, options: ReportOptions): Promise<string> {
console.log('[Reporting] generateReport [%o]', ReportKindMapping.convertReportKindToReportKindType(kind));
if (kind === ReportKind.Publishing) {
return this.requestPublishingReport(options as PublishingReportOptions);
switch (kind) {
case ReportKind.Publishing:
return this.requestPublishingReport(options as PublishingReportOptions);
case ReportKind.Viewing:
return this.requestViewingReport(options as ViewingReportOptions);
default:
assertUnreachable(kind);
}
if (kind === ReportKind.Viewing) {
return this.requestViewingReport(options as ViewingReportOptions);
}
throw new Error(`[Reporting] Unsupported report kind: ${kind}`);
}
public async requestPublishingReport(options: PublishingReportOptions): Promise<string> {
if (!(options.start || options.end)) {
if (!options.start || !options.end) {
throw new Error('[Reporting] [requestPublishingReport] requires a start and end Date');
}
const publishingReportOptions = {
@@ -104,30 +62,23 @@ export class Reporting {
const requestPublishingOptions = {
body: JSON.stringify({publishingReport: publishingReportOptions})
};
const response = await this._httpRequests.request<PublishingReportResponse>(HttpMethod.PUT, '/pcast/reporting/publishing', requestPublishingOptions);
const response = await this._httpRequests.request<string>(HttpMethod.PUT, '/reporting/publishing', requestPublishingOptions);
if (!response.publishingReport) {
throw new Error('[Reporting] [requestPublishingReport] Invalid response format - missing publishingReport data');
}
return response.publishingReport;
return response;
}
private async requestViewingReport(options: ViewingReportOptions): Promise<string> {
const viewingReportOptions = {
...options
...options,
kind: ViewingReportKindMapping.convertViewingReportKindToViewingReportKindType(options.kind)
};
const requestViewingOptions = {
body: JSON.stringify({viewingReport: viewingReportOptions})
};
const response = await this._httpRequests.request<ViewingReportResponse>(HttpMethod.PUT, '/pcast/reporting/viewing', requestViewingOptions);
if (!response.viewingReport) {
throw new Error('[Reporting] [requestViewingReport] Invalid response format - missing viewingReport data');
}
return response.viewingReport;
const response = await this._httpRequests.request<string>(HttpMethod.PUT, '/reporting/viewing', requestViewingOptions);
return response;
}
}

View File

@@ -16,7 +16,7 @@ export class Streams {
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}`, {
const response = await this._httpRequests.request<IResponse<string>>(HttpMethod.PUT, `/stream/publish/uri/${mediaType}`, {
body: JSON.stringify({
token,
uri: mediaUri,

View File

@@ -0,0 +1,37 @@
import assertUnreachable from "../lang/assertUnreachable";
export enum ViewingReportKind {
RealTime = 0,
HLS = 1,
DASH = 2
}
export type ViewingReportKindType = 'RealTime' | 'HLS' | 'DASH';
export class ViewingReportKindMapping {
public static convertViewingReportKindTypeToViewingReportKind(viewingReportKindType: ViewingReportKindType): ViewingReportKind {
switch (viewingReportKindType) {
case 'RealTime':
return ViewingReportKind.RealTime;
case 'HLS':
return ViewingReportKind.HLS;
case 'DASH':
return ViewingReportKind.DASH;
default:
assertUnreachable(viewingReportKindType);
}
}
public static convertViewingReportKindToViewingReportKindType(viewingReportKind: ViewingReportKind): ViewingReportKindType {
switch (viewingReportKind) {
case ViewingReportKind.RealTime:
return 'RealTime';
case ViewingReportKind.HLS:
return 'HLS';
case ViewingReportKind.DASH:
return 'DASH';
default:
assertUnreachable(viewingReportKind);
}
}
}

View File

@@ -1,3 +1,4 @@
export * from './Channels';
export * from './Stream';
export * from './Reporting';
export * from './PCastRequests';
export * from './Stream';