// A channel over a websocket: the first message authorizes it and carries its arguments; after that, text frames are // JSON and binary frames bytes, each way. import type { Channel, ChannelMessage, JsonValue } from "@berry/api"; const AUTH_TIMEOUT_MS = 10_000; /** Why the server closed a channel it could not open, for the frontend's `messages` to fail with. */ export const REFUSED = 4400; const UNAUTHORIZED = 4001; export type ChannelOptions = { authorize(token: string): boolean; /** Hand the channel to whoever opens it; a rejection closes it with the message. */ open(args: JsonValue | undefined, channel: Channel, signal: AbortSignal): Promise; }; export function serveChannel(socket: WebSocket, { authorize, open }: ChannelOptions): void { socket.binaryType = "arraybuffer"; const closed = new AbortController(); let incoming!: ReadableStreamDefaultController; const messages = new ReadableStream({ start: (controller) => void (incoming = controller), }); const channel: Channel = { send(message) { if (socket.readyState !== WebSocket.OPEN) return; socket.send(message instanceof Uint8Array ? message : JSON.stringify(message)); }, messages, close: () => socket.close(1000), }; let authorized = false; const timeout = setTimeout(() => socket.close(UNAUTHORIZED, "unauthorized"), AUTH_TIMEOUT_MS); socket.addEventListener("message", (event) => { if (closed.signal.aborted) return; let message: ChannelMessage; try { message = typeof event.data === "string" ? JSON.parse(event.data) : new Uint8Array(event.data as ArrayBuffer); } catch { return socket.close(REFUSED, "malformed message"); } if (authorized) return incoming.enqueue(message); clearTimeout(timeout); const { token, args } = (message ?? {}) as { token?: unknown; args?: JsonValue }; if (typeof token !== "string" || !authorize(token)) { return socket.close(UNAUTHORIZED, "unauthorized"); } authorized = true; open(args, channel, closed.signal).catch((error) => { const reason = error instanceof Error ? error.message : String(error); // a close reason is at most 123 bytes const short = new TextDecoder().decode(new TextEncoder().encode(reason).slice(0, 120)); socket.close(REFUSED, short.replace(/\uFFFD$/, "")); }); }); socket.addEventListener("close", () => { clearTimeout(timeout); closed.abort(); try { incoming.close(); } catch { // already ended } }); }