UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

176 lines (165 loc) 5.32 kB
import type { StandardSchemaV1 } from "@standard-schema/spec"; import { eventTransportValuesEqual, toEventTransportValue, } from "../events/transport.js"; /** Browser-visible channel contract. Authorization belongs in a server binding. */ export interface ChannelDefinition< Name extends string = string, Params extends StandardSchemaV1< unknown, Record<string, string> > = StandardSchemaV1<unknown, Record<string, string>>, Events extends Record<string, StandardSchemaV1> = Record< string, StandardSchemaV1 >, > { readonly kind: "channel"; readonly name: Name; readonly params: Params; readonly events: Events; } export type InferChannelParams<C extends ChannelDefinition> = StandardSchemaV1.InferOutput<C["params"]>; export type InferChannelEvent<C extends ChannelDefinition> = { [Event in keyof C["events"] & string]: { event: Event; data: StandardSchemaV1.InferOutput<C["events"][Event]>; }; }[keyof C["events"] & string]; /** Protocol defaults and safety limits shared by clients, endpoints, and providers. */ export const broadcastLimits = Object.freeze({ subscriptions: 20, requestBytes: 8_192, payloadBytes: 65_536, bufferedBytes: 1_048_576, defaultLifetimeMs: 60_000, maxLifetimeMs: 3_600_000, lifetimeGraceMs: 5_000, heartbeatMs: 25_000, readyTimeoutMs: 10_000, }); /** Invalid channel configuration or untrusted protocol input. */ export class BroadcastValidationError extends Error { constructor(message: string) { super(message); this.name = "BroadcastValidationError"; } } const namePattern = /^[a-zA-Z][a-zA-Z0-9_.-]{0,127}$/; export function defineChannel< const Name extends string, Params extends StandardSchemaV1<unknown, Record<string, string>>, const Events extends Record<string, StandardSchemaV1>, >( name: Name, options: { params: Params; events: Events }, ): ChannelDefinition<Name, Params, Events> { if (!namePattern.test(name)) throw new BroadcastValidationError("Invalid broadcast channel name"); const events = Object.entries(options.events); if (!events.length || events.some(([event]) => !namePattern.test(event))) { throw new BroadcastValidationError( `Channel ${name} needs valid event names`, ); } return Object.freeze({ kind: "channel", name, params: options.params, events: Object.freeze({ ...options.events }), }); } /** Validate both sides of a transport boundary; schema transforms must be stable. */ async function parseStable( schema: StandardSchemaV1, input: unknown, label: string, ): Promise<unknown> { try { const parsed = await schema["~standard"].validate(input); if (parsed.issues) throw new BroadcastValidationError(`Invalid ${label}`); const canonical = toEventTransportValue(label, parsed.value); const second = await schema["~standard"].validate(canonical); if ( second.issues || !eventTransportValuesEqual( canonical, toEventTransportValue(label, second.value), ) ) { throw new BroadcastValidationError( `Unstable transport schema for ${label}`, ); } return canonical; } catch (error) { if (error instanceof BroadcastValidationError) throw error; throw new BroadcastValidationError(`Invalid ${label}`); } } /** Provider and transport boundary validation. Uses parsed, canonical parameters. */ export async function parseChannelParams<C extends ChannelDefinition>( channel: C, input: unknown, ): Promise<InferChannelParams<C>> { const parsed = await parseStable( channel.params, input, `${channel.name} parameters`, ); if ( !parsed || typeof parsed !== "object" || Array.isArray(parsed) || Object.values(parsed).some((value) => typeof value !== "string") ) { throw new BroadcastValidationError( `Channel ${channel.name} parameters must be a flat string record`, ); } if ( new TextEncoder().encode(JSON.stringify(parsed)).byteLength > broadcastLimits.requestBytes ) { throw new BroadcastValidationError( `Channel ${channel.name} parameters are too large`, ); } return parsed as InferChannelParams<C>; } /** Provider and browser boundary validation. Event names select their schema. */ export async function parseChannelEvent<C extends ChannelDefinition>( channel: C, input: { event: string; data: unknown }, ): Promise<InferChannelEvent<C>> { if (!Object.hasOwn(channel.events, input.event)) throw new BroadcastValidationError( `Unknown event on channel ${channel.name}`, ); const data = await parseStable( channel.events[input.event], input.data, `${channel.name}.${input.event}`, ); if ( new TextEncoder().encode(JSON.stringify(data)).byteLength > broadcastLimits.payloadBytes ) { throw new BroadcastValidationError( `Channel ${channel.name} payload is too large`, ); } return { event: input.event, data } as InferChannelEvent<C>; } /** Collision-free identity independent of parameter property order. Parse parameters first. */ export function channelKey( channel: string, params: Readonly<Record<string, string>>, ): string { return JSON.stringify([ channel, Object.entries(params).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), ]); }