import { log } from "@tensamin/shared/log"; import { z } from "zod"; import { RESPONSE_TIMEOUT } from "./values"; export const READY_STATE = { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3, } as const; const CLOSE_FRAME_LEN = 0xffff_ffff; const APPLICATION_CLOSE_CODE = 0; const APPLICATION_CLOSE_REASON = "ttp-close"; const MAX_REQUEST_ID = 0xffff_fffe; const BINARY_LOG_STORAGE_KEY = "ttp_logBinary"; const DATA_VALUE_KIND_BOOL_TRUE = 0x01; const DATA_VALUE_KIND_BOOL_FALSE = 0x02; const DATA_VALUE_KIND_NUMBER = 0x03; const DATA_VALUE_KIND_STRING = 0x04; const DATA_VALUE_KIND_ARRAY = 0x05; const DATA_VALUE_KIND_CONTAINER = 0x06; const DATA_VALUE_KIND_NULL = 0x07; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); type PrimitiveDataKind = "bool" | "number" | "string" | "container" | "null"; type DataKind = | PrimitiveDataKind | { array: PrimitiveDataKind | "container" | "null" }; type WebTransportCloseOptions = { closeCode?: number; reason?: string; }; type WebTransportLike = { ready: Promise; closed: Promise; createUnidirectionalStream(): Promise>; incomingUnidirectionalStreams: ReadableStream>; close(options?: WebTransportCloseOptions): void; }; type WebTransportGlobal = typeof globalThis & { WebTransport?: new (url: string) => WebTransportLike; }; type PendingRequest = { requestType: string; resolve: (value: TypedMessage) => void; reject: (reason: unknown) => void; timeoutId: ReturnType; }; type ActiveConnection = { transport: WebTransportLike; streamReader: ReadableStreamDefaultReader> | null; intentional: boolean; closeNotified: boolean; acceptLoopDone: Promise | null; resolveAcceptLoopDone: (() => void) | null; activeIncomingTasks: Set>; sendStream: WritableStream | null; sendWriter: WritableStreamDefaultWriter | null; }; /** * Represents non-fatal payload decoding failures for individual protocol messages. */ class RecoverableMessageDecodeError extends Error { readonly messageId: number; readonly messageType: string; readonly cause: unknown; /** * Creates a recoverable decode error associated with a specific message. * @param messageId Protocol message id that failed to decode. * @param messageType Protocol message type that failed to decode. * @param cause Original decode failure cause. */ constructor(messageId: number, messageType: string, cause: unknown) { super( `Failed to decode message payload for "${messageType}" (id=${messageId}): ${formatUnknownError(cause)}`, ); this.name = "RecoverableMessageDecodeError"; this.messageId = messageId; this.messageType = messageType; this.cause = cause; } } const COMMUNICATION_TYPES = [ "error", "error_protocol", "error_anonymous", "error_internal", "error_invalid_data", "error_invalid_user_id", "error_invalid_omikron_id", "error_not_found", "error_not_authenticated", "error_no_iota", "error_invalid_challenge", "error_invalid_secret", "error_invalid_private_key", "error_invalid_public_key", "error_no_user_id", "error_no_call_id", "error_invalid_call_id", "success", "shorten_link", "settings_save", "settings_load", "settings_list", "message", "message_state", "message_send", "message_live", "message_other_iota", "message_chunk", "messages_get", "push_notification", "read_notification", "get_notifications", "change_confirm", "confirm_receive", "confirm_read", "get_conversations", "get_states", "add_community", "remove_community", "get_communities", "challenge", "challenge_response", "register", "register_response", "identification", "identification_response", "register_iota", "register_iota_success", "ping", "pong", "add_conversation", "send_chat", "client_changed", "client_connected", "client_disconnected", "client_closed", "public_key", "private_key", "webrtc_sdp", "webrtc_ice", "start_stream", "end_stream", "watch_stream", "call_token", "call_invite", "call_disconnect_user", "call_timeout_user", "call_set_anonymous_joining", "end_call", "function", "update", "create_user", "rho_update", "user_connected", "user_disconnected", "iota_connected", "iota_disconnected", "sync_client_iota_status", "get_user_data", "get_iota_data", "iota_user_data", "change_user_data", "change_iota_data", "get_register", "complete_register_user", "complete_register_iota", "delete_user", "delete_iota", "start_register", "complete_register", ] as const; const DATA_TYPES = [ "error_type", "error_protocol", "accepted_ids", "uuid", "register_id", "link", "settings", "settings_name", "chat_partner_id", "chat_partner_name", "iota_id", "user_id", "user_ids", "iota_ids", "user_state", "user_states", "user_pings", "call_state", "screen_share", "private_key_hash", "accepted", "accepted_profiles", "denied_profiles", "content", "messages", "notifications", "send_time", "get_time", "get_variant", "shared_secret_own", "shared_secret_other", "shared_secret_sign", "shared_secret", "call_id", "call_token", "untill", "enabled", "start_date", "end_date", "receiver_id", "sender_id", "signature", "signed", "message", "message_state", "last_ping", "ping_iota", "ping_clients", "matches", "omikron", "offset", "amount", "position", "name", "path", "codec", "function", "payload", "result", "interactables", "want_to_watch", "watcher", "created_at", "username", "display", "avatar", "about", "status", "public_key", "sub_level", "sub_end", "community_address", "challenge", "community_title", "communities", "rho_connections", "user", "online_status", "omikron_id", "omikron_connections", "reset_token", "new_token", "call_invited", "call_members", "calls", "timeout", "has_admin", ] as const; const COMMUNICATION_TYPE_BY_NAME = createIndexMap(COMMUNICATION_TYPES); const DATA_TYPE_BY_NAME = createIndexMap(DATA_TYPES); const SCALAR_NUMBER_ARRAY_DATA_TYPES = new Set(["last_ping", "ping_iota"]); const dataKindByType = new Map(); registerDataKinds("number", [ "user_id", "sender_id", "register_id", "receiver_id", "call_id", "amount", "position", "offset", "timeout", "iota_id", "chat_partner_id", "untill", "start_date", "end_date", "omikron_id", "send_time", "sub_level", "sub_end", ]); registerDataKinds("string", [ "error_type", "username", "display", "avatar", "about", "public_key", "message", "content", "path", "codec", "function", "uuid", "link", "settings_name", "chat_partner_name", "user_state", "call_state", "private_key_hash", "name", "shared_secret_own", "shared_secret_other", "shared_secret_sign", "shared_secret", "message_state", "signature", "reset_token", "new_token", "call_token", "challenge", "online_status", ]); registerDataKinds({ array: "container" }, [ "messages", "communities", "rho_connections", "matches", "get_conversations", "get_communities", ]); registerDataKinds({ array: "number" }, [ "notifications", "iota_ids", "user_ids", "accepted_ids", "last_ping", "ping_iota", "get_time", "omikron_connections", ]); registerDataKinds("container", [ "settings", "user", "payload", "result", "ping_clients", "user_pings", ]); registerDataKinds("bool", [ "enabled", "signed", "accepted", "has_admin", "screen_share", ]); registerDataKinds({ array: "string" }, ["user_states"]); registerDataKinds("null", [ "error_protocol", "accepted_profiles", "denied_profiles", "get_variant", "omikron", "interactables", "want_to_watch", "watcher", "created_at", "status", "community_address", "community_title", "call_invited", "call_members", "calls", ]); for (const dataType of DATA_TYPES) { if (!dataKindByType.has(dataType)) { throw new Error(`Missing data kind mapping for "${dataType}"`); } } export type TypedMessage> = { id: number; type: string; data: TData; }; export type Message = TypedMessage; export type SchemaMap = Record< string, { request: z.ZodType; response: z.ZodType } >; type SendOptions = { id?: number; noResponse?: boolean; }; export type BoundSendFn = { ( type: K, data: z.input, options: { id?: number; noResponse: true }, ): Promise; ( type: K, data: z.input, options?: { id?: number; noResponse?: false }, ): Promise>>; }; export type PushHandler> = ( message: TypedMessage, ) => void; export type TransportCloseEvent = { error?: unknown; intentional: boolean; }; type TransportClientOptions = { url?: string; onReadyStateChange?: (readyState: number) => void; onClose?: (event: TransportCloseEvent) => void; }; export type TransportClient = { connect(url?: string): Promise; close(reason?: string): Promise; send: BoundSendFn; readyState(): number; subscribePush(handler: PushHandler): () => void; }; /** * Creates a typed transport client that validates request and response payloads. * @param schemas Protocol schema map for request/response validation. * @param options Optional transport lifecycle callbacks and default URL. * @returns Transport client API for connect, close, send, and push subscriptions. */ export function createTransportClient( schemas: T, options: TransportClientOptions = {}, ): TransportClient { const pending = new Map(); const pushHandlers = new Set(); let currentConnection: ActiveConnection | null = null; let currentReadyState: number = READY_STATE.CLOSED; let nextRequestId = 1; let configuredUrl = options.url; /** * Updates current ready state and emits lifecycle callbacks. * @param readyState New transport ready state value. * @returns Void. */ const setReadyState = (readyState: number) => { currentReadyState = readyState; options.onReadyStateChange?.(readyState); }; /** * Rejects all pending requests and clears timeout handles. * @param reason Rejection reason applied to all pending requests. * @returns Void. */ const rejectPending = (reason: unknown) => { for (const [id, request] of pending) { clearTimeout(request.timeoutId); request.reject(reason); pending.delete(id); } }; /** * Finalizes closed state for a connection and notifies listeners. * @param connection Closed connection object. * @param error Optional close error. * @returns Void. */ const notifyClosed = (connection: ActiveConnection, error?: unknown) => { if (connection.closeNotified) { return; } connection.closeNotified = true; if (currentConnection === connection) { currentConnection = null; } if (currentReadyState !== READY_STATE.CLOSED) { setReadyState(READY_STATE.CLOSED); } rejectPending(error ?? new Error("Transport closed")); options.onClose?.({ error, intentional: connection.intentional }); }; /** * Handles connection-level failures and routes them through close handling. * @param connection Connection that failed. * @param error Optional failure reason. * @returns Void. */ const handleConnectionFailure = ( connection: ActiveConnection, error?: unknown, ) => { if (currentConnection !== connection && connection.closeNotified) { return; } notifyClosed(connection, error); }; /** * Handles decoded incoming messages and resolves request promises or push listeners. * @param message Decoded incoming message. * @returns Void. */ const handleIncomingMessage = (message: TypedMessage) => { if (message.type !== "pong") { log(2, "Socket", "blue", "Received:", message.type, message.data, { id: message.id, }); } if (message.id !== 0) { const pendingRequest = pending.get(message.id); if (pendingRequest) { clearTimeout(pendingRequest.timeoutId); pending.delete(message.id); if (message.type.startsWith("error")) { pendingRequest.reject(message); return; } const schema = schemas[pendingRequest.requestType]; if (!schema) { pendingRequest.resolve(message); return; } const result = schema.response.safeParse(message.data); if (result.success) { pendingRequest.resolve({ ...message, data: result.data as Record, }); return; } log( 0, "Socket", "red", `Response validation failed for "${message.type}"`, result.error, message.data, ); pendingRequest.reject( new Error( `Response validation failed for "${message.type}": ${result.error.message}`, ), ); return; } } const schema = schemas[message.type]; if (schema) { const result = schema.response.safeParse(message.data); if (!result.success) { log( 0, "Socket", "red", `Push-event validation failed for "${message.type}"`, result.error, message.data, ); return; } message = { ...message, data: result.data as Record, }; } for (const handler of pushHandlers) { handler(message); } }; /** * Handles recoverable decode failures by rejecting only the affected request. * @param error Recoverable decode error details. * @returns Void. */ const handleRecoverableDecodeFailure = ( error: RecoverableMessageDecodeError, ) => { log(1, "Socket", "yellow", "Recoverable message decode failure", { id: error.messageId, type: error.messageType, error: error.message, }); if (error.messageId === 0) { return; } const pendingRequest = pending.get(error.messageId); if (!pendingRequest) { return; } clearTimeout(pendingRequest.timeoutId); pending.delete(error.messageId); pendingRequest.reject( new Error( `Failed to decode response for "${pendingRequest.requestType}": ${formatUnknownError(error.cause)}`, ), ); }; /** * Starts the incoming stream loop for a newly-opened connection. * @param connection Active connection instance. * @returns Void. */ const startIncomingLoop = (connection: ActiveConnection) => { connection.streamReader = connection.transport.incomingUnidirectionalStreams.getReader(); connection.acceptLoopDone = new Promise((resolve) => { connection.resolveAcceptLoopDone = resolve; }); void (async () => { try { while (!connection.closeNotified) { const streamReader = connection.streamReader; if (!streamReader) { break; } const readResult = await Promise.race([ streamReader.read().then((result) => ({ type: "stream" as const, result, })), connection.transport.closed .catch(() => undefined) .then(() => ({ type: "closed" as const })), ]); if (readResult.type !== "stream") { break; } const result = readResult.result; if (!result || result.done) { break; } const shouldDiscardFrames = connection.intentional || currentConnection !== connection; const task = (async () => { try { await processIncomingStream( result.value, connection, handleIncomingMessage, handleRecoverableDecodeFailure, handleConnectionFailure, shouldDiscardFrames, ); } catch (error) { log( 0, "Socket", "red", "Incoming transport stream failed", error, ); handleConnectionFailure(connection, error); } })(); connection.activeIncomingTasks.add(task); void task.finally(() => { connection.activeIncomingTasks.delete(task); }); } if ( !connection.intentional && !connection.closeNotified && currentConnection === connection ) { handleConnectionFailure( connection, new Error("Transport stream closed"), ); } } catch (error) { log(0, "Socket", "red", "Incoming stream accept loop failed", error); handleConnectionFailure(connection, error); } finally { connection.streamReader?.releaseLock(); connection.streamReader = null; const resolveAcceptLoopDone = connection.resolveAcceptLoopDone; connection.resolveAcceptLoopDone = null; resolveAcceptLoopDone?.(); } })(); }; /** * Awaits transport closed promise and forwards outcome to failure handling. * @param connection Active connection instance. * @returns Void. */ const awaitClosed = (connection: ActiveConnection) => { void connection.transport.closed .then(() => { handleConnectionFailure(connection); }) .catch((error) => { handleConnectionFailure(connection, error); }); }; /** * Opens a transport connection and starts incoming frame processing. * @param url Optional override transport URL. * @returns Promise that resolves when connection setup completes. */ const connect = async (url = configuredUrl) => { if (!url) { throw new Error("Transport URL is not configured"); } configuredUrl = url; if (currentConnection) { await close("reconnect"); } const WebTransportCtor = getWebTransportCtor(); const transport = new WebTransportCtor(url); const connection: ActiveConnection = { transport, streamReader: null, intentional: false, closeNotified: false, acceptLoopDone: null, resolveAcceptLoopDone: null, activeIncomingTasks: new Set(), sendStream: null, sendWriter: null, }; currentConnection = connection; setReadyState(READY_STATE.CONNECTING); awaitClosed(connection); try { await transport.ready; if (currentConnection !== connection) { return; } log(1, "Socket", "green", "Connected"); setReadyState(READY_STATE.OPEN); startIncomingLoop(connection); } catch (error) { log(0, "Socket", "red", "WebTransport connection failed", error); handleConnectionFailure(connection, error); throw error; } }; /** * Closes the current transport connection and sends a close sentinel frame. * @param reason Close reason sent to transport. * @returns Promise that resolves once close handling completes. */ const close = async (reason = APPLICATION_CLOSE_REASON) => { const connection = currentConnection; if (!connection) { setReadyState(READY_STATE.CLOSED); return; } connection.intentional = true; setReadyState(READY_STATE.CLOSING); const acceptLoopDone = connection.acceptLoopDone; rejectPending(new Error("Transport closed")); try { connection.sendWriter?.releaseLock(); await connection.sendStream?.abort(); } catch { // Ignore errors during stream abort } try { await writeCloseFrame(connection.transport); } catch (error) { log(1, "Socket", "yellow", "Failed to send close sentinel", error); } try { connection.transport.close({ closeCode: APPLICATION_CLOSE_CODE, reason, }); } catch { // Ignore close errors during shutdown. } try { await connection.transport.closed.catch(() => undefined); await acceptLoopDone; if (connection.activeIncomingTasks.size > 0) { await Promise.allSettled([...connection.activeIncomingTasks]); } } finally { notifyClosed(connection); } }; /** * Sends a typed protocol request over the current connection. * @param type Protocol message type. * @param input Optional request payload. * @param options Optional id and response behavior. * @returns Promise for response message or void when no response is expected. */ const send: BoundSendFn = (( type: string, input?: Record, options?: SendOptions, ): Promise => { if (!currentConnection || currentReadyState !== READY_STATE.OPEN) { return Promise.reject(new Error("Transport is not connected")); } const connection = currentConnection; try { const schema = schemas[type]; let payload: Record; if (schema) { const result = schema.request.safeParse(input ?? {}); if (!result.success) { log( 0, "Socket", "red", `Request validation failed for "${type}"`, result.error, ); return Promise.reject( new Error( `Request validation failed for "${type}": ${result.error.message}`, ), ); } payload = coercePayload(result.data); } else { payload = coercePayload(input ?? {}); } if (!options?.id && !options?.noResponse) { const array = new Uint32Array(1); crypto.getRandomValues(array); options = options ?? {}; options.id = array[0]; } if (type !== "ping") { log(2, "Socket", "purple", "Sent:", type, payload, { id: options.id }); } const expectsResponse = !options?.noResponse; const requestId = resolveRequestId( options.id, expectsResponse, pending, () => { const current = nextRequestId; nextRequestId = current >= MAX_REQUEST_ID ? 1 : current + 1; return current; }, ); const messageBytes = encodeCommunicationMessage({ id: requestId, type, data: payload, }); if (!expectsResponse) { return writeMessageOnPersistentStream(connection, messageBytes).catch( (error) => { handleConnectionFailure(connection, error); throw error; }, ); } return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { pending.delete(requestId); reject( new Error( `Request "${type}" timed out after ${RESPONSE_TIMEOUT}ms`, ), ); }, RESPONSE_TIMEOUT); pending.set(requestId, { requestType: type, resolve, reject, timeoutId, }); void writeMessageOnPersistentStream(connection, messageBytes).catch( (error) => { handleConnectionFailure(connection, error); clearTimeout(timeoutId); pending.delete(requestId); reject(error); }, ); }); } catch (error) { return Promise.reject(error); } }) as BoundSendFn; return { connect, close, send, readyState: () => currentReadyState, subscribePush(handler: PushHandler) { pushHandlers.add(handler); return () => { pushHandlers.delete(handler); }; }, }; } /** * Builds a normalized lookup map for protocol names by index. * @param values Ordered protocol names. * @returns Map from normalized name to array index. */ function createIndexMap(values: readonly string[]) { const map = new Map(); values.forEach((value, index) => { map.set(normalizeName(value), index); }); return map; } /** * Registers expected data kinds for protocol data type names. * @param kind Expected scalar or array kind for the provided names. * @param names Data type names to register. * @returns Void. */ function registerDataKinds(kind: DataKind, names: readonly string[]) { for (const name of names) { if (dataKindByType.has(name)) { throw new Error(`Duplicate data kind registration for "${name}"`); } dataKindByType.set(name, kind); } } /** * Returns the WebTransport constructor from the current runtime. * @returns WebTransport constructor. */ function getWebTransportCtor() { const ctor = (globalThis as WebTransportGlobal).WebTransport; if (!ctor) { throw new Error("WebTransport is not available in this environment"); } return ctor; } /** * Normalizes protocol names by lowercasing and removing underscores. * @param value Raw protocol name. * @returns Normalized protocol key. */ function normalizeName(value: string) { return value.toLowerCase().replaceAll("_", ""); } /** * Formats unknown errors into a stable log string. * @param error Unknown error value. * @returns Human-readable error description. */ function formatUnknownError(error: unknown) { if (error instanceof Error) { return error.message; } if (typeof error === "string") { return error; } try { return JSON.stringify(error); } catch { return String(error); } } /** * Returns whether raw transport binary logging is enabled in local storage. * @returns True when binary transport logs should be emitted. */ function isBinaryMessageLoggingEnabled() { try { return localStorage.getItem(BINARY_LOG_STORAGE_KEY) === "true"; } catch { return false; } } /** * Logs raw transport bytes when binary logging is enabled. * @param direction Message direction label. * @param payload Raw binary payload to log. * @returns Void. */ function logBinaryMessage( direction: "Incoming" | "Outgoing", payload: Uint8Array, ) { if (!isBinaryMessageLoggingEnabled()) { return; } console.log( `[Socket] ${direction} binary message (${payload.byteLength} bytes)`, payload, ); } /** * Ensures outbound message payloads are plain object records. * @param value Candidate payload. * @returns Payload as plain object record. */ function coercePayload(value: unknown): Record { if (!isPlainObject(value)) { throw new Error("Protocol payload must be a plain object"); } return value; } /** * Checks whether a value is a non-null, non-array object. * @param value Candidate value. * @returns True when the value is a plain object. */ function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** * Resolves a unique request id for transport messages. * @param requestedId Optional caller-provided request id. * @param expectsResponse Whether the request expects a response. * @param pending Map of currently pending requests. * @param nextId Function that returns the next candidate id. * @returns A request id valid for the current pending set. */ function resolveRequestId( requestedId: number | undefined, expectsResponse: boolean, pending: Map, nextId: () => number, ) { if (requestedId !== undefined) { validateRequestId(requestedId, expectsResponse); if (expectsResponse && pending.has(requestedId)) { throw new Error(`Request id ${requestedId} is already pending`); } return requestedId; } if (!expectsResponse) { return 0; } let attempts = 0; let candidate = nextId(); while (candidate === 0 || pending.has(candidate)) { candidate = nextId(); attempts += 1; if (attempts > MAX_REQUEST_ID) { throw new Error("Unable to allocate a free request id"); } } return candidate; } /** * Validates request id bounds and response semantics. * @param id Request id to validate. * @param expectsResponse Whether a response is expected for this request. * @returns Void. */ function validateRequestId(id: number, expectsResponse: boolean) { if (!Number.isInteger(id) || id < 0 || id > MAX_REQUEST_ID) { throw new Error(`Request id must be a u32 between 0 and ${MAX_REQUEST_ID}`); } if (expectsResponse && id === 0) { throw new Error("Request id 0 cannot be used when a response is expected"); } } /** * Writes a protocol message payload as a framed unidirectional transport stream. * Uses a persistent stream, and retries once if the stream was closed by the receiver. * @param connection Active connection instance. * @param payload Encoded message payload bytes. * @returns Promise that resolves when frame writing is complete. */ async function writeMessageOnPersistentStream( connection: ActiveConnection, payload: Uint8Array, ) { if (payload.byteLength >= CLOSE_FRAME_LEN) { throw new Error("Message too large for transport frame"); } const frame = new Uint8Array(4 + payload.byteLength); writeU32(frame, 0, payload.byteLength); frame.set(payload, 4); const writeAndCatch = async (): Promise => { try { if (!connection.sendStream || !connection.sendWriter) { connection.sendStream = await connection.transport.createUnidirectionalStream(); connection.sendWriter = connection.sendStream.getWriter(); } logBinaryMessage("Outgoing", frame); await connection.sendWriter.write(frame); return true; } catch { return false; } }; const firstResult = await writeAndCatch(); if (firstResult) return; // Retry once connection.sendWriter?.releaseLock(); connection.sendWriter = null; connection.sendStream = null; const secondResult = await writeAndCatch(); if (secondResult) return; connection.sendWriter = null; connection.sendStream = null; throw new Error("Transport stream closed during send"); } /** * Writes a close sentinel frame to the transport. * @param transport Active transport instance. * @returns Promise that resolves when the close frame is written. */ async function writeCloseFrame(transport: WebTransportLike) { const stream = await transport.createUnidirectionalStream(); const writer = stream.getWriter(); try { const frame = new Uint8Array(4); writeU32(frame, 0, CLOSE_FRAME_LEN); await writer.write(frame); await writer.close(); } finally { writer.releaseLock(); } } /** * Reads a byte stream and emits each framed protocol message it contains. * @param stream Incoming byte stream for a single unidirectional transport stream. * @param connection Active connection instance. * @param handleIncomingFrame Handler for decoded protocol messages. * @param handleDecodeFailure Handler for recoverable frame decode failures. * @param discardFrames Whether frames should be drained and discarded. * @returns True when the peer close sentinel was received. */ async function processIncomingStream( stream: ReadableStream, connection: ActiveConnection, handleIncomingFrame: (message: TypedMessage) => void, handleDecodeFailure: (error: RecoverableMessageDecodeError) => void, handleStreamFailure: (connection: ActiveConnection, error?: unknown) => void, discardFrames: boolean, ) { const reader = stream.getReader(); let bufferedBytes = new Uint8Array(0) as Uint8Array; let peerCloseDetected = false; try { while (true) { const { value, done } = await reader.read(); if (done) { break; } bufferedBytes = appendBytes(bufferedBytes, value); if (discardFrames || peerCloseDetected) { bufferedBytes = new Uint8Array(0) as Uint8Array; continue; } while (bufferedBytes.byteLength >= 4) { const declaredLength = readU32(bufferedBytes, 0); if (declaredLength === CLOSE_FRAME_LEN) { peerCloseDetected = true; bufferedBytes = new Uint8Array(0) as Uint8Array; try { connection.transport.close({ closeCode: APPLICATION_CLOSE_CODE, reason: APPLICATION_CLOSE_REASON, }); } catch { // Ignore close errors during peer shutdown. } handleStreamFailure( connection, new Error("Transport closed by peer"), ); break; } const expectedLength = 4 + declaredLength; if (bufferedBytes.byteLength < expectedLength) { break; } const frameBytes = bufferedBytes.subarray(0, expectedLength); bufferedBytes = bufferedBytes.subarray(expectedLength); logBinaryMessage("Incoming", frameBytes); try { handleIncomingFrame(decodeCommunicationMessage(frameBytes)); } catch (error) { if (error instanceof RecoverableMessageDecodeError) { handleDecodeFailure(error); } else { throw error; } } // Just like the backend, drop the stream after receiving exactly one incoming message! return peerCloseDetected; } } if (!discardFrames && !peerCloseDetected && bufferedBytes.byteLength > 0) { throw new Error("Received truncated transport frame"); } return peerCloseDetected; } finally { // We cancel the reader to signal the stream is naturally dropped, matching Rust's receiver behavior. reader.cancel().catch(() => {}); reader.releaseLock(); } } /** * Concatenates two byte arrays. * @param left Existing buffered bytes. * @param right Newly received bytes. * @returns Concatenated bytes. */ function appendBytes(left: Uint8Array, right: Uint8Array) { if (left.byteLength === 0) { return right; } const buffer = new Uint8Array( left.byteLength + right.byteLength, ) as Uint8Array; buffer.set(left, 0); buffer.set(right, left.byteLength); return buffer; } /** * Encodes a typed protocol message into the wire communication format. * @param message Typed message with id, type, and payload data. * @returns Encoded communication frame bytes. */ export function encodeCommunicationMessage( message: TypedMessage>, ) { const typeIndex = parseCommunicationType(message.type); const hasId = message.id !== 0; const dataBuffer = encodeContainerPayload(message.data, "payload"); const payloadLength = 2 + (hasId ? 4 : 0) + dataBuffer.byteLength; const buffer = new Uint8Array(4 + payloadLength); writeU32(buffer, 0, payloadLength); buffer[4] = typeIndex; buffer[5] = hasId ? 0b0000_0100 : 0; let offset = 6; if (hasId) { writeU32(buffer, offset, message.id); offset += 4; } buffer.set(dataBuffer, offset); return buffer; } /** * Decodes communication frame bytes into a typed protocol message. * @param frame Encoded communication frame bytes. * @returns Decoded typed protocol message. */ export function decodeCommunicationMessage(frame: Uint8Array): TypedMessage { const reader = new ByteReader(frame); const frameLength = reader.readU32(); if (frameLength !== frame.byteLength - 4) { throw new Error( `Communication frame length mismatch: expected ${frameLength}, received ${frame.byteLength - 4}`, ); } const payloadLength = reader.readU32(); if (payloadLength !== frameLength - 4) { throw new Error( `Communication payload length mismatch: expected ${payloadLength}, received ${frameLength - 4}`, ); } const typeIndex = reader.readU8(); const flags = reader.readU8(); const hasSender = (flags & 0b0000_0001) !== 0; const hasReceiver = (flags & 0b0000_0010) !== 0; const hasId = (flags & 0b0000_0100) !== 0; const id = hasId ? reader.readU32() : 0; if (hasSender) { reader.readU48(); } if (hasReceiver) { reader.readU48(); } const consumedHeaderBytes = 2 + (hasId ? 4 : 0) + (hasSender ? 6 : 0) + (hasReceiver ? 6 : 0); if (consumedHeaderBytes > payloadLength) { throw new Error("Communication header exceeds payload length"); } const messageType = COMMUNICATION_TYPES[typeIndex] ?? "error_protocol"; const dataLength = payloadLength - consumedHeaderBytes; let decodedData: Record; if (dataLength === 0) { decodedData = {}; } else { const dataReader = new ByteReader(reader.readBytes(dataLength)); try { decodedData = decodeContainerPayload(dataReader); } catch (error) { if (messageType.startsWith("error")) { return { id, type: messageType, data: {}, }; } else { throw new RecoverableMessageDecodeError(id, messageType, error); } } if (!dataReader.isAtEnd()) { throw new RecoverableMessageDecodeError( id, messageType, new Error("Trailing bytes found after communication data payload"), ); } } if (!reader.isAtEnd()) { throw new Error("Trailing bytes found after communication payload"); } return { id, type: messageType, data: decodedData, }; } /** * Resolves a communication type string to its protocol index. * @param type Protocol message type string. * @returns Numeric protocol type index. */ function parseCommunicationType(type: string) { const index = COMMUNICATION_TYPE_BY_NAME.get(normalizeName(type)); if (index === undefined) { throw new Error(`Unknown communication type "${type}"`); } return index; } /** * Parses a protocol data key into its index and canonical name. * @param type Raw protocol data key. * @returns Canonical data key metadata with index and normalized name. */ function parseDataType(type: string) { const index = DATA_TYPE_BY_NAME.get(normalizeName(type)); if (index === undefined) { throw new Error(`Unknown data type "${type}"`); } return { index, name: DATA_TYPES[index], }; } /** * Returns the expected value kind for a protocol data key. * @param type Canonical data key name. * @returns Expected data kind definition. */ function getExpectedKind(type: string) { const kind = dataKindByType.get(type); if (!kind) { throw new Error(`No data kind registered for "${type}"`); } return kind; } type EncodedDataValue = { kind: number; payload: Uint8Array; }; /** * Encodes a value according to the expected protocol kind. * @param kind Expected protocol kind. * @param value Candidate value to encode. * @param path Payload path used in validation errors. * @returns Encoded value marker and payload bytes. */ function encodeDataValueForKind( kind: DataKind, value: unknown, path: string, ): EncodedDataValue { if (typeof kind === "object") { return { kind: DATA_VALUE_KIND_ARRAY, payload: encodeArrayPayload(kind.array, value, path), }; } switch (kind) { case "bool": if (typeof value !== "boolean") { throw new Error(`Expected boolean at "${path}"`); } return { kind: value ? DATA_VALUE_KIND_BOOL_TRUE : DATA_VALUE_KIND_BOOL_FALSE, payload: new Uint8Array(0), }; case "number": return { kind: DATA_VALUE_KIND_NUMBER, payload: encodeNumberPayload(value, path), }; case "string": if (typeof value !== "string") { throw new Error(`Expected string at "${path}"`); } return { kind: DATA_VALUE_KIND_STRING, payload: textEncoder.encode(value), }; case "container": if (!isPlainObject(value)) { throw new Error(`Expected object at "${path}"`); } return { kind: DATA_VALUE_KIND_CONTAINER, payload: encodeContainerPayload(value, path), }; case "null": if (value !== null && value !== undefined) { throw new Error(`Expected null at "${path}"`); } return { kind: DATA_VALUE_KIND_NULL, payload: new Uint8Array(0), }; } } /** * Encodes a numeric payload as signed 64-bit big-endian bytes. * @param value Value expected to be a safe integer number. * @param path Payload path used in validation errors. * @returns Encoded i64 byte array. */ function encodeNumberPayload(value: unknown, path: string) { if ( typeof value !== "number" || !Number.isFinite(value) || !Number.isSafeInteger(value) ) { throw new Error(`Expected safe integer at "${path}"`); } const buffer = new Uint8Array(8); writeI64(buffer, 0, value); return buffer; } /** * Encodes an array payload where each item is tagged with a data marker. * @param innerKind Expected kind for array entries. * @param value Candidate array payload. * @param path Payload path used in validation errors. * @returns Encoded array payload bytes. */ function encodeArrayPayload( innerKind: PrimitiveDataKind | "container" | "null", value: unknown, path: string, ) { if (!Array.isArray(value)) { throw new Error(`Expected array at "${path}"`); } if (value.length > 0xffff) { throw new Error(`Array at "${path}" is too large for protocol encoding`); } const encodedItems = value.map((entry, index) => encodeDataValueForKind(innerKind, entry, `${path}[${index}]`), ); let totalLength = 2; for (const encodedItem of encodedItems) { totalLength += 1; if (!isBoolKindMarker(encodedItem.kind)) { if (encodedItem.payload.byteLength > 0xffff) { throw new Error( `Array item at "${path}" is too large for protocol encoding`, ); } totalLength += 2 + encodedItem.payload.byteLength; } } const buffer = new Uint8Array(totalLength); writeU16(buffer, 0, value.length); let offset = 2; for (const item of encodedItems) { buffer[offset] = item.kind; offset += 1; if (isBoolKindMarker(item.kind)) { continue; } writeU16(buffer, offset, item.payload.byteLength); offset += 2; buffer.set(item.payload, offset); offset += item.payload.byteLength; } return buffer; } /** * Encodes a keyed container payload into protocol key-index/value entries. * @param value Object payload to encode. * @param path Payload path used in validation errors. * @returns Encoded container payload bytes. */ function encodeContainerPayload(value: Record, path: string) { const normalizedEntries = new Map< string, { index: number; value: unknown } >(); for (const [rawKey, rawValue] of Object.entries(value)) { const parsedType = parseDataType(rawKey); normalizedEntries.set(parsedType.name, { index: parsedType.index, value: normalizeOutgoingValue(parsedType.name, rawValue), }); } const entries = [...normalizedEntries.entries()].sort( ([, left], [, right]) => left.index - right.index, ); if (entries.length > 0xffff) { throw new Error( `Container at "${path}" has too many entries for protocol encoding`, ); } const encodedEntries: Array<{ keyIndex: number; value: EncodedDataValue }> = []; let totalLength = 2; for (const [name, entry] of entries) { const expectedKind = getExpectedKind(name); const pathForEntry = `${path}.${name}`; const encodedValue = encodeDataValueForKind( expectedKind, entry.value, pathForEntry, ); if (isBoolKindMarker(encodedValue.kind)) { totalLength += 2; } else { if (encodedValue.payload.byteLength > 0xffff) { throw new Error( `Container entry "${pathForEntry}" is too large for protocol encoding`, ); } totalLength += 4 + encodedValue.payload.byteLength; } encodedEntries.push({ keyIndex: entry.index, value: encodedValue, }); } const buffer = new Uint8Array(totalLength); writeU16(buffer, 0, entries.length); let offset = 2; for (const entry of encodedEntries) { buffer[offset] = entry.value.kind; offset += 1; if (isBoolKindMarker(entry.value.kind)) { buffer[offset] = entry.keyIndex; offset += 1; continue; } writeU16(buffer, offset, entry.value.payload.byteLength); offset += 2; buffer[offset] = entry.keyIndex; offset += 1; buffer.set(entry.value.payload, offset); offset += entry.value.payload.byteLength; } return buffer; } /** * Decodes a value marker and payload bytes into a JavaScript value. * @param marker Protocol value marker. * @param payload Encoded payload bytes for the marker. * @returns Decoded JavaScript value. */ function decodeValuePayload(marker: number, payload: Uint8Array): unknown { const reader = new ByteReader(payload); switch (marker) { case DATA_VALUE_KIND_BOOL_TRUE: if (!reader.isAtEnd()) { throw new Error("Unexpected payload for boolean true value"); } return true; case DATA_VALUE_KIND_BOOL_FALSE: if (!reader.isAtEnd()) { throw new Error("Unexpected payload for boolean false value"); } return false; case DATA_VALUE_KIND_NUMBER: if (payload.byteLength !== 8) { throw new Error(`Invalid number payload length ${payload.byteLength}`); } return reader.readI64(); case DATA_VALUE_KIND_STRING: return textDecoder.decode(payload); case DATA_VALUE_KIND_ARRAY: return decodeArrayPayload(reader); case DATA_VALUE_KIND_CONTAINER: return decodeContainerPayload(reader); case DATA_VALUE_KIND_NULL: if (!reader.isAtEnd()) { throw new Error("Unexpected payload for null value"); } return null; default: throw new Error(`Unknown data value marker 0x${marker.toString(16)}`); } } /** * Decodes an encoded array payload from a byte reader. * @param reader Byte reader positioned at array payload start. * @returns Decoded array values. */ function decodeArrayPayload(reader: ByteReader) { const itemCount = reader.readU16(); const values: unknown[] = []; for (let index = 0; index < itemCount; index += 1) { const marker = reader.readU8(); if (isBoolKindMarker(marker)) { values.push(marker === DATA_VALUE_KIND_BOOL_TRUE); continue; } const payloadLength = reader.readU16(); const payload = reader.readBytes(payloadLength); values.push(decodeValuePayload(marker, payload)); } return values; } /** * Decodes an encoded keyed container payload from a byte reader. * @param reader Byte reader positioned at container payload start. * @returns Decoded object payload. */ function decodeContainerPayload(reader: ByteReader) { const entryCount = reader.readU16(); const value: Record = {}; for (let index = 0; index < entryCount; index += 1) { const marker = reader.readU8(); const payloadLength = isBoolKindMarker(marker) ? 0 : reader.readU16(); const keyIndex = reader.readU8(); const payload = isBoolKindMarker(marker) ? new Uint8Array(0) : reader.readBytes(payloadLength); const key = getDataTypeNameByIndex(keyIndex); if (!key) { continue; } const expectedKind = getExpectedKind(key); if (!isMarkerCompatibleWithKey(marker, expectedKind, key)) { throw new Error( `Unexpected marker 0x${marker.toString(16)} for data type "${key}"`, ); } value[key] = normalizeIncomingValue( key, decodeValuePayload(marker, payload), ); } return value; } /** * Resolves a canonical data key by protocol index. * @param index Protocol key index. * @returns Canonical protocol data key name. */ function getDataTypeNameByIndex(index: number) { return DATA_TYPES[index]; } /** * Checks whether a marker represents a boolean payload. * @param marker Protocol value marker. * @returns True when marker is boolean true or false. */ function isBoolKindMarker(marker: number) { return ( marker === DATA_VALUE_KIND_BOOL_TRUE || marker === DATA_VALUE_KIND_BOOL_FALSE ); } /** * Validates that a marker is compatible with an expected data kind. * @param marker Protocol value marker. * @param kind Expected protocol data kind. * @returns True when marker matches the expected kind. */ function isMarkerCompatibleWithKind(marker: number, kind: DataKind) { if (typeof kind === "object") { return marker === DATA_VALUE_KIND_ARRAY; } switch (kind) { case "bool": return isBoolKindMarker(marker); case "number": return marker === DATA_VALUE_KIND_NUMBER; case "string": return marker === DATA_VALUE_KIND_STRING; case "container": return marker === DATA_VALUE_KIND_CONTAINER; case "null": return marker === DATA_VALUE_KIND_NULL; } } /** * Validates marker compatibility for a specific key with scalar-array fallback support. * @param marker Protocol value marker. * @param kind Expected protocol data kind. * @param key Canonical protocol key name. * @returns True when marker is compatible for the key. */ function isMarkerCompatibleWithKey( marker: number, kind: DataKind, key: string, ) { if ( SCALAR_NUMBER_ARRAY_DATA_TYPES.has(key) && marker === DATA_VALUE_KIND_NUMBER ) { return true; } return isMarkerCompatibleWithKind(marker, kind); } /** * Normalizes outbound values for special scalar-array compatibility keys. * @param type Canonical protocol key name. * @param value Outbound value. * @returns Normalized outbound value. */ function normalizeOutgoingValue(type: string, value: unknown) { if (SCALAR_NUMBER_ARRAY_DATA_TYPES.has(type) && typeof value === "number") { return [value]; } return value; } /** * Normalizes inbound values for scalar-array compatibility keys. * @param type Canonical protocol key name. * @param value Inbound decoded value. * @returns Normalized inbound value. */ function normalizeIncomingValue(type: string, value: unknown) { if ( SCALAR_NUMBER_ARRAY_DATA_TYPES.has(type) && Array.isArray(value) && value.length === 1 && typeof value[0] === "number" ) { return value[0]; } return value; } /** * Writes a big-endian unsigned 16-bit integer to a byte buffer. * @param buffer Destination byte buffer. * @param offset Byte offset to write at. * @param value Unsigned 16-bit integer value. * @returns Void. */ function writeU16(buffer: Uint8Array, offset: number, value: number) { new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).setUint16( offset, value, false, ); } /** * Writes a big-endian unsigned 32-bit integer to a byte buffer. * @param buffer Destination byte buffer. * @param offset Byte offset to write at. * @param value Unsigned 32-bit integer value. * @returns Void. */ function writeU32(buffer: Uint8Array, offset: number, value: number) { new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).setUint32( offset, value, false, ); } /** * Writes a big-endian signed 64-bit integer to a byte buffer. * @param buffer Destination byte buffer. * @param offset Byte offset to write at. * @param value Signed integer value. * @returns Void. */ function writeI64(buffer: Uint8Array, offset: number, value: number) { new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).setBigInt64( offset, BigInt(value), false, ); } /** * Reads a big-endian unsigned 32-bit integer from a byte buffer. * @param buffer Source byte buffer. * @param offset Byte offset to read from. * @returns Unsigned 32-bit integer value. */ function readU32(buffer: Uint8Array, offset: number) { return new DataView( buffer.buffer, buffer.byteOffset, buffer.byteLength, ).getUint32(offset, false); } /** * Provides sequential big-endian reads over protocol byte buffers. */ class ByteReader { private readonly view: DataView; private offset = 0; private readonly bytes: Uint8Array; /** * Creates a byte reader over an immutable Uint8Array view. * @param bytes Source bytes to read from. */ constructor(bytes: Uint8Array) { this.bytes = bytes; this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); } /** * Reads one unsigned byte. * @returns Unsigned 8-bit integer. */ readU8() { this.ensureAvailable(1); const value = this.view.getUint8(this.offset); this.offset += 1; return value; } /** * Reads two bytes as big-endian unsigned 16-bit integer. * @returns Unsigned 16-bit integer. */ readU16() { this.ensureAvailable(2); const value = this.view.getUint16(this.offset, false); this.offset += 2; return value; } /** * Reads four bytes as big-endian unsigned 32-bit integer. * @returns Unsigned 32-bit integer. */ readU32() { this.ensureAvailable(4); const value = this.view.getUint32(this.offset, false); this.offset += 4; return value; } /** * Reads eight bytes as big-endian signed 64-bit integer. * @returns Safe integer representation of the decoded value. */ readI64() { this.ensureAvailable(8); const value = this.view.getBigInt64(this.offset, false); this.offset += 8; const numberValue = Number(value); if (!Number.isSafeInteger(numberValue)) { throw new Error( `Decoded number ${value.toString()} exceeds JS safe integer range`, ); } return numberValue; } /** * Reads six bytes as a big-endian unsigned 48-bit integer. * @returns Unsigned 48-bit integer represented as number. */ readU48() { this.ensureAvailable(6); const upper = this.view.getUint16(this.offset, false); const lower = this.view.getUint32(this.offset + 2, false); this.offset += 6; return upper * 2 ** 32 + lower; } /** * Reads a byte slice of the requested length. * @param length Number of bytes to read. * @returns View over the requested bytes. */ readBytes(length: number) { this.ensureAvailable(length); const value = this.bytes.subarray(this.offset, this.offset + length); this.offset += length; return value; } /** * Indicates whether all bytes have been consumed. * @returns True when reader offset is at buffer end. */ isAtEnd() { return this.offset === this.bytes.byteLength; } /** * Ensures that at least a specific number of bytes can still be read. * @param length Required available byte count. * @returns Void. */ private ensureAvailable(length: number) { if (this.offset + length > this.bytes.byteLength) { throw new Error("Unexpected end of protocol buffer"); } } }