/** * WebSocket seam - portable types shared by `@nifrajs/core`'s `app.ws()` and every serving adapter * (Bun `listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler` on Workers). No runtime code here, so * it stays edge-safe and dependency-free; each adapter implements {@link NifraWebSocket} over its * runtime's native socket and dispatches to the handler. * * A WS upgrade can't go through `app.fetch` (which has no socket) - it's adapter-integrated. The * adapter calls `app.resolveWebSocketUpgrade(req)` (runs the route's `upgrade()` guard in a real * request context), then performs the runtime upgrade with the returned data. */ import type { InferOutput, StandardIssue, StandardSchemaV1 } from "../schema/standard.js"; import { type TransportCodecRegistry } from "../transport-codec.js"; type MaybePromise = T | Promise; /** A received frame, normalized across runtimes: text → `string`, binary → `Uint8Array`. */ export type WebSocketData = string | Uint8Array; /** The value `message` receives: the schema's validated output when a `messageSchema` is set on the * handler, otherwise the raw frame (`string | Uint8Array`). */ type WsMessageInput = Schema extends StandardSchemaV1 ? InferOutput : WebSocketData; /** The portable socket handed to WS lifecycle callbacks. Each adapter wraps its native socket. */ export interface NifraWebSocket { /** Send a text (`string`) or binary (`ArrayBuffer`/typed-array) frame. No-op once closed. */ send(data: string | ArrayBufferView | ArrayBuffer): void; /** Close the connection (optional code 1000-4999 + short reason). */ close(code?: number, reason?: string): void; /** `WebSocket.readyState` (0 CONNECTING · 1 OPEN · 2 CLOSING · 3 CLOSED). */ readonly readyState: number; /** Join a pub/sub topic - `app.publish(topic, data)` then reaches this connection. Idempotent. */ subscribe(topic: string): void; /** Leave a pub/sub topic. Idempotent. (All topics are dropped automatically on close.) */ unsubscribe(topic: string): void; /** Per-connection state - seeded by `upgrade()`, mutable for the connection's lifetime. */ data: Data; /** Escape hatch to the runtime's native socket (Bun `ServerWebSocket`, Web `WebSocket`, `ws`). */ readonly raw: unknown; } /** * In-process pub/sub for `ws.subscribe(topic)` + `app.publish(topic, data)`. **Single-instance only** - * topics live in this process's memory, so a multi-instance deploy (multiple servers behind a load * balancer) needs an external fan-out (Redis pub/sub, a Cloudflare Durable Object, NATS, …) bridged to * `app.publish`. The same registry instance is shared by an app's connections and its `publish`. */ export declare class TopicRegistry { private readonly topics; private readonly memberships; subscribe(topic: string, ws: NifraWebSocket): void; unsubscribe(topic: string, ws: NifraWebSocket): void; /** Drop every subscription for a connection - called on close so a registry never leaks dead sockets. */ unsubscribeAll(ws: NifraWebSocket): void; /** Send `data` to every connection subscribed to `topic`. Per-socket send errors are isolated. */ publish(topic: string, data: string | ArrayBufferView | ArrayBuffer): void; } /** * The request-context subset the `upgrade()` guard sees - the same lazy accessors a route handler's * `c` has (cookies/headers/env are read straight off the upgrade request). Structurally a slice of the * core `RawContext`, so the real context object satisfies it. */ export interface WebSocketContext { readonly req: Request; readonly params: Record; readonly query: unknown; readonly cookies: Readonly>; readonly env: Env; readonly signal: AbortSignal; readonly waitUntil: (promise: Promise) => void; boundedBody(maxBytes?: number): Promise; boundedJson(maxBytes?: number): Promise; } /** A WebSocket route's lifecycle. All callbacks optional; only `message` is needed for an echo. */ export interface WebSocketHandler { /** * Cross-site WebSocket hijacking (CSWSH) guard - checked BEFORE `upgrade()`. A browser does not * apply CORS to WebSocket handshakes and DOES send the page's cookies, so without an Origin check * any site can open an authenticated socket to your app. Set this to lock the route to known * origins: a string allow-list (exact `Origin` header match) or a predicate. A request whose * `Origin` doesn't match (or is absent, for the allow-list form) is rejected with `403` before any * per-connection work. * * **When omitted, the default is same-origin:** a cross-origin BROWSER handshake (an `Origin` header * whose host differs from the request's) is rejected with `403`. Non-browser clients (no `Origin`) and * same-origin browsers pass. Set this to allow specific cross-origin clients, or `() => true` for a * genuinely public socket. Non-browser clients can spoof `Origin`, so this is a browser-CSWSH defense, * not authentication - pair it with auth in `upgrade()`. */ allowedOrigins?: ReadonlyArray | ((origin: string | null) => boolean); /** * Runs in the HTTP request context **before** the upgrade (and after {@link allowedOrigins}) - the * place to authenticate or rate-limit. Return the initial per-connection `data` (→ `ws.data`), or a * `Response` to **reject** the upgrade (the client never connects). Omit to accept with * `data: undefined`. A thrown error rejects with a flat 500. */ upgrade?(c: WebSocketContext): MaybePromise; /** * Contract-first messages: a Standard Schema (`t`, zod, valibot, …) validating each **inbound** frame. * Text frames are parsed as JSON first. When set, `message` receives the validated, typed value; a * non-JSON or schema-invalid frame is routed to `onInvalidMessage` (or dropped if that's omitted). */ messageSchema?: Schema; /** * The **outbound** frame contract (server → client), a Standard Schema. Purely type-level: it types * the frames the typed client's `.ws()` handle receives, and documents what this route pushes. The * server does NOT runtime-validate its own sends by default - the inbound `messageSchema` guards the * trust boundary; outbound honesty is the handler author's code. Set {@link validateSend} to opt into * a synchronous runtime check of JSON text/UTF-8 binary frames. */ sendSchema?: Send; /** * Opt-in runtime enforcement for {@link sendSchema}. Invalid JSON, schema failures, and async * validators are dropped before the native socket's `send()` runs and reported to `error()` when it * exists. This is a development/contract check for the server's own output, not an inbound security * boundary. It is off by default and requires `sendSchema` at registration. */ validateSend?: boolean; /** Decode versioned transport frames before inbound schema validation. Omit for legacy JSON. */ transport?: { readonly registry: TransportCodecRegistry; readonly maxBytes?: number; }; open?(ws: NifraWebSocket): MaybePromise; message?(ws: NifraWebSocket, data: WsMessageInput): MaybePromise; /** Inbound frame that failed JSON parse or `messageSchema` validation (only fires when a schema is * set). `raw` is the original frame; `issues` are the Standard Schema issues (one synthetic issue for * a JSON parse failure). Omit to silently drop invalid frames. */ onInvalidMessage?(ws: NifraWebSocket, issues: ReadonlyArray, raw: WebSocketData): MaybePromise; close?(ws: NifraWebSocket, code: number, reason: string): MaybePromise; error?(ws: NifraWebSocket, error: unknown): MaybePromise; } /** * The outcome of `app.resolveWebSocketUpgrade(req)` - for serving adapters: * - `pass` - not a WS upgrade for a registered WS route; handle as a normal HTTP request. * - `reject` - a WS route matched but `upgrade()` rejected (or the path was malformed); return `response`. * - `upgrade` - perform the runtime upgrade, then dispatch the native socket's events to `handler`, * seeding `ws.data` with `data`. */ export type WebSocketUpgradeOutcome = { readonly kind: "pass"; } | { readonly kind: "reject"; readonly response: Response; } | { readonly kind: "upgrade"; readonly handler: WebSocketHandler; readonly data: unknown; /** The app's pub/sub registry - the adapter wires `ws.subscribe` + close-cleanup to it. */ readonly pubsub: TopicRegistry; /** The installed runtime's {@link attachWebSocket}, carried on the outcome so an adapter can wire * a standard socket without a static import of the WS implementation (which would defeat the * `.use(websocket())` tree-shaking). `@nifrajs/workers` may import `attachWebSocket` directly instead. */ readonly attach: WsAttach; readonly maxPayloadBytes?: number; }; /** The socket-wiring signature the upgrade outcome carries (the installed runtime's `attach`). */ export type WsAttach = (socket: StandardWebSocket, handler: WebSocketHandler, data: unknown, options: { openNow: boolean; pubsub: TopicRegistry; maxPayloadBytes?: number; }) => NifraWebSocket; /** * A standard server-side `WebSocket` - the half returned by Deno's `Deno.upgradeWebSocket` and the * Workers `WebSocketPair`. {@link attachWebSocket} wires one to a nifra handler, so the Deno and Workers * bridges share all the dispatch/normalization/error-isolation logic (only the upgrade call differs). */ export interface StandardWebSocket { send(data: string | ArrayBufferView | ArrayBuffer): void; close(code?: number, reason?: string): void; readonly readyState: number; binaryType?: string; addEventListener(type: string, listener: (event: never) => void): void; } /** * Wire a standard server-side `WebSocket` to a nifra {@link WebSocketHandler}, returning the portable * {@link NifraWebSocket}. Shared by the Deno and Workers bridges. `openNow` fires `open` immediately * (Workers, where the socket is already open after `accept()`); otherwise `open` waits for the socket's * `open` event (Deno). Lifecycle callbacks are error-isolated - a throw (or async rejection) routes to * `error()` and never tears the process down; binary frames are normalized to `Uint8Array`. */ export declare function attachWebSocket(socket: StandardWebSocket, handler: WebSocketHandler, data: unknown, options: { openNow: boolean; pubsub: TopicRegistry; maxPayloadBytes?: number; }): NifraWebSocket; type WebSocketSendPayload = string | ArrayBufferView | ArrayBuffer; /** * Build the socket's send function once. The disabled path returns the native sender directly, so * the default type-only contract adds no per-frame branch or schema work. Runtime validation is * deliberately synchronous: a socket `send()` cannot await an async Standard Schema without changing * the portable API, so an async result fails closed and is never sent. */ export declare function createWebSocketSender(send: (payload: WebSocketSendPayload) => void, schema: StandardSchemaV1 | undefined, enabled: boolean | undefined, transport: WebSocketHandler["transport"], onError: (error: unknown) => void): (payload: WebSocketSendPayload) => void; /** * If the handler declares a `messageSchema`, return a copy whose `message` validates each frame - * parse as JSON, run the Standard Schema, then call the user's `message` with the typed value, or * `onInvalidMessage` on failure. Returns the handler unchanged when no schema is set. Called once at * `app.ws()` registration, so every adapter dispatches validated messages with no per-adapter code. */ export declare function wrapWebSocketMessageValidation(handler: WebSocketHandler): WebSocketHandler; export {}; //# sourceMappingURL=websocket.d.ts.map