@beignet/core
Version:
Core framework primitives for Beignet
176 lines (165 loc) • 5.32 kB
text/typescript
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)),
]);
}