import type { IControllerBrowserVideoOffer, IControllerBrowserVideoDescription, IControllerBrowserVideoStatistics, IControllerBrowserViewState, IControllerBrowserViewport, TControllerBrowserViewOperation, } from './interfaces.js'; export const controllerBrowserTransportProtocol = 'agl-browser-v28' as const; export const controllerBrowserTransportEventContentType = 'application/vnd.agl.browser-events.v28' as const; export const controllerBrowserTransportOperationContentType = 'application/vnd.agl.browser-operations.v28' as const; export const controllerBrowserTransportMaximumJsonBytes = 64 * 1024; export const controllerBrowserTransportPairOpenTimeoutMs = 9_000; export const controllerBrowserTransportRecoveryGraceMs = 45_000; export const controllerBrowserTransportMaximumRecoveryAttempts = 6; export const controllerBrowserTransportMaximumPendingOperations = 64; export const controllerBrowserTransportMaximumQueuedOperationBytes = 256 * 1024; /** Bounded reliable signaling and control delivery. */ export const controllerBrowserTransportMaximumPendingEvents = 16; export const controllerBrowserTransportMaximumPendingEventBytes = 1024 * 1024; /** * Fatal error codes the controller sends on the event stream before it aborts * a view it closes itself, so the client can tell an intentional close from a * transport fault instead of spending its recovery budget on one. * `resource_changed` means the resource authority changed while it stays * available, so the client reopens from its * refreshed resource list; `view_closed` is an intentional close, such as the * resource being stopped, and raises nothing; `view_failed` is a close the * client cannot recover from and reports. The close code is always passed * explicitly, never inferred from the reason. */ export const controllerBrowserViewResourceChangedErrorCode = 'resource_changed' as const; export const controllerBrowserViewClosedErrorCode = 'view_closed' as const; export const controllerBrowserViewFailedErrorCode = 'view_failed' as const; export type TControllerBrowserViewCloseCode = | typeof controllerBrowserViewResourceChangedErrorCode | typeof controllerBrowserViewClosedErrorCode | typeof controllerBrowserViewFailedErrorCode; /** * Whether a fatal error code announces a close the controller made itself. * The transport hands a fatal packet to the event listener before it fails * the client, so the listener uses this to leave every announced close to the * failure handler that decides what each one means. */ export const isControllerBrowserViewCloseCode = ( codeArg: string, ): codeArg is TControllerBrowserViewCloseCode => ( codeArg === controllerBrowserViewResourceChangedErrorCode || codeArg === controllerBrowserViewClosedErrorCode || codeArg === controllerBrowserViewFailedErrorCode ); /** * Budget for the client's reopen cycle after a `resource_changed` close. The * cycle drives itself -- a reopened view can produce the next close -- so a * resource that revokes every view it hands out would otherwise close and * reopen at network speed forever. Attempts outside the window are free, so * an occasional reopen in a healthy session never spends the budget down. */ export const controllerBrowserViewMaximumReopenAttempts = 3; export const controllerBrowserViewReopenWindowMs = 30_000; interface IControllerBrowserTransportPacketBase { protocol: typeof controllerBrowserTransportProtocol; streamGeneration: number; } export interface IControllerBrowserOperationPacket extends IControllerBrowserTransportPacketBase { type: 'operation'; operationId: number; operation: TControllerBrowserViewOperation; } export interface IControllerBrowserCancelPacket extends IControllerBrowserTransportPacketBase { type: 'cancel'; operationId: number; } export type TControllerBrowserClientPacket = | IControllerBrowserOperationPacket | IControllerBrowserCancelPacket; export interface IControllerBrowserViewportResult { viewport: IControllerBrowserViewport; viewportRevision: number; } export type TControllerBrowserOperationResult = | { status: 'succeeded'; state?: IControllerBrowserViewState; viewportResult?: IControllerBrowserViewportResult; videoOffer?: IControllerBrowserVideoOffer; videoStatistics?: IControllerBrowserVideoStatistics; } | { status: 'failed'; error: { code: string; message: string } } | { status: 'cancelled' } | { status: 'superseded' }; export interface IControllerBrowserStatePacket extends IControllerBrowserTransportPacketBase { type: 'state'; state: IControllerBrowserViewState; } export interface IControllerBrowserErrorPacket extends IControllerBrowserTransportPacketBase { type: 'error'; error: { code: string; message: string; fatal: boolean; tabId?: string }; } export interface IControllerBrowserOperationResultPacket extends IControllerBrowserTransportPacketBase { type: 'operationResult'; operationId: number; result: TControllerBrowserOperationResult; } export type TControllerBrowserServerPacket = | IControllerBrowserStatePacket | IControllerBrowserErrorPacket | IControllerBrowserOperationResultPacket; export class ControllerBrowserTransportProtocolError extends Error { constructor(messageArg: string, optionsArg?: ErrorOptions) { super(messageArg, optionsArg); this.name = 'ControllerBrowserTransportProtocolError'; } } const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder('utf-8', { fatal: true }); /** Validate the video-only signaling envelope before it crosses a trust boundary. */ export const assertControllerBrowserVideoDescription = ( valueArg: unknown, typeArg: 'offer' | 'answer', ): IControllerBrowserVideoDescription => { const value = valueArg as Record | null; if (!value || typeof value !== 'object' || Array.isArray(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value)) || Object.keys(value).length !== 2 || !Object.hasOwn(value, 'type') || !Object.hasOwn(value, 'sdp') || value.type !== typeArg || typeof value.sdp !== 'string' || textEncoder.encode(value.sdp).byteLength > 48 * 1024 || !value.sdp.startsWith('v=0\r\n') || (value.sdp.match(/^m=/gm) ?? []).length !== 1 || !/^m=video /m.test(value.sdp)) { throw new ControllerBrowserTransportProtocolError('Video description must contain one video media section.'); } return { type: typeArg, sdp: value.sdp }; }; export const encodeControllerBrowserTransportPacket = ( packetArg: TControllerBrowserClientPacket | TControllerBrowserServerPacket, ): Uint8Array => { let serialized: string; try { serialized = JSON.stringify(packetArg); } catch (errorArg) { throw new ControllerBrowserTransportProtocolError( 'The browser transport packet is not JSON serializable.', { cause: errorArg }, ); } const bytes = textEncoder.encode(serialized); if (bytes.byteLength > controllerBrowserTransportMaximumJsonBytes) { throw new ControllerBrowserTransportProtocolError( 'The browser transport packet exceeds the JSON byte limit.', ); } return bytes; }; export const decodeControllerBrowserTransportPacket = (bytesArg: Uint8Array): unknown => { if (bytesArg.byteLength > controllerBrowserTransportMaximumJsonBytes) { throw new ControllerBrowserTransportProtocolError( 'The browser transport packet exceeds the JSON byte limit.', ); } try { return JSON.parse(textDecoder.decode(bytesArg)); } catch (errorArg) { throw new ControllerBrowserTransportProtocolError( 'The browser transport packet is not valid UTF-8 JSON.', { cause: errorArg }, ); } };