/** * RouterCore implementation. * * Public methods: * - use(mw) → Global middleware * - on(schema, handler) → Event handler (kind="event") * - rpc(schema, handler) → RPC handler (kind="rpc") [added by validation plugin] * - route(schema) → Per-route builder (fluent) * - merge(router, opts) → Combine routers (with conflict resolution) * - mount(prefix, router, opts) → Prefix schema types * - plugin(fn) → Widening capability host * - onError(fn) → Universal error sink * * Capability-gated: rpc(), publish(), subscribe() exist only when plugins add them. */ import type { ConnectionData, MinimalContext } from "../context/base-context.js"; import type { EventContext } from "../context/event-context.js"; import type { BaseCloseContext, BaseOpenContext, CloseContext, LifecycleErrorContext, OpenContext } from "../context/lifecycle-context.js"; import type { PubSubContext } from "../context/pubsub-context.js"; import type { RpcContext } from "../context/rpc-context.js"; import { LifecycleManager } from "../engine/lifecycle.js"; import { LimitsManager } from "../engine/limits-manager.js"; import type { ContextEnhancer } from "../internal.js"; import type { Plugin } from "../plugin/types.js"; import type { AnySchema, InferPayload, InferResponse, InferType, MessageDescriptor } from "../protocol/schema.js"; import type { ServerWebSocket } from "../ws/platform-adapter.js"; import { RouteTable } from "./route-table.js"; import { ROUTE_TABLE } from "./symbols.js"; import type { CreateRouterOptions, EventHandler, Middleware, PublishCapability, PublishError, PublishOptions, PublishRecord, PublishResult, RouteEntry, RouterObserver } from "./types.js"; export type { Plugin, PublishCapability, PublishError, PublishOptions, PublishResult, }; /** * Extracts the API type a plugin contributes. * @internal */ type InferPluginAPI

= 0 extends 1 & P ? any : P extends Plugin ? Api : never; export interface RouterCore { use(mw: Middleware): this; on(schema: MessageDescriptor, handler: EventHandler): this; route(schema: S): RouteBuilder; merge(other: Router, opts?: { onConflict?: "error" | "skip" | "replace"; }): this; mount(prefix: string, other: Router, opts?: { onConflict?: "error" | "skip" | "replace"; }): this; /** * Apply a plugin to extend the router's capabilities. * Uses this-aware inference to preserve existing extensions through chaining. * * @typeParam E - Current extensions (inferred from this) * @typeParam P - Plugin type */ plugin>(this: Router, plugin: P): RouterWithExtensions>; /** * Register a handler for connection open events. * * Handlers run after authentication and before message dispatch begins. * Use this for welcome messages, initial subscriptions, and connection setup. * * Context is capability-gated: * - Base: clientId, data, connectedAt, ws, assignData * - With validation plugin: adds send() * - With pubsub plugin: adds publish(), topics * * Handlers run in registration order. Errors close the connection * with code 1011 (internal error) unless CloseError is thrown. * * @example * ```ts * router.onOpen(async (ctx) => { * ctx.send(WelcomeMessage, { greeting: "Hello" }); * await ctx.topics.subscribe(`user:${ctx.data.userId}`); * }); * ``` */ onOpen(handler: (ctx: BaseOpenContext) => void | Promise): this; /** * Register a handler for connection close events. * * Handlers run during close notification (socket is CLOSING or CLOSED). * Use this for cleanup, "user left" broadcasts, and metrics. * * Context is capability-gated: * - Base: clientId, data, code, reason, ws * - No send() (socket is closing) * - With pubsub plugin: adds publish(), topics (read-only) * * Handlers run in registration order. Errors are logged but don't * affect the close operation (connection is already terminating). * * @example * ```ts * router.onClose((ctx) => { * ctx.publish("presence", UserLeftMessage, { userId: ctx.data.userId }); * }); * ``` */ onClose(handler: (ctx: BaseCloseContext) => void | Promise): this; onError(fn: (err: unknown, ctx: MinimalContext | LifecycleErrorContext | null) => void): this; /** * Observe key lifecycle events for testing and monitoring plugins. * * Callbacks are called synchronously in registration order. Exceptions are logged * and swallowed to prevent one bad observer from affecting others. Re-entrancy * is safe (observer list is snapshotted at dispatch time). * * @param observer Partial observer with optional hooks * @returns Unsubscribe function for cleanup * * @example * ```typescript * const off = router.observe({ * onPublish: (rec) => console.log(`Published to ${rec.topic}`), * onError: (err) => console.error(`Error: ${err.message}`), * }); * // ... later, unsubscribe: * off(); * ``` */ observe(observer: Partial>): () => void; /** * Platform-agnostic WebSocket handler interface. * * Provides the contract for platform adapters (Bun, Cloudflare, Node.js, etc.) * to delegate WebSocket lifecycle events to the router. * * @example * ```typescript * // In adapter handler * const { fetch, websocket } = createBunHandler(router); * // Internally calls: router.websocket.open(ws), router.websocket.message(ws, data), etc. * ``` */ readonly websocket: { open(ws: ServerWebSocket): Promise; message(ws: ServerWebSocket, data: string | ArrayBuffer): Promise; close(ws: ServerWebSocket, code?: number, reason?: string): Promise; }; } /** * Router = RouterCore + plugin-contributed APIs. * * TExtensions is an object type representing all APIs added by plugins. * Plugins use definePlugin to add their extensions. * Type is automatically widened: each .plugin(p) call intersects new APIs. * * Per ADR-028, Router uses pure structural composition. Plugin APIs * (rpc, publish, topics, etc.) are included directly via TExtensions. * Plugins contribute their full API through their TPluginApi type parameter. * * @example * ```typescript * // Base (no plugins): * Router → RouterCore * * // After withZod (adds validation API): * Router * → RouterCore & { validation: true, rpc(), ... } * * // After both withZod and withPubSub: * Router * → RouterCore & { rpc(), publish(), topics, ... } * ``` */ export interface Router extends Omit, "plugin" | "use" | "on" | "onError" | "onOpen" | "onClose" | "merge" | "mount"> { /** * Register global middleware. Preserves extension types through fluent chaining. */ use(mw: Middleware): RouterWithExtensions; /** * Register an event handler. Preserves extension types through fluent chaining. */ on(schema: MessageDescriptor, handler: EventHandler): RouterWithExtensions; /** * Register an error handler. Preserves extension types through fluent chaining. */ onError(fn: (err: unknown, ctx: MinimalContext | LifecycleErrorContext | null) => void): RouterWithExtensions; /** * Register a handler for connection open events. * Context is capability-gated based on installed plugins. * Preserves extension types through fluent chaining. */ onOpen(handler: (ctx: OpenContext) => void | Promise): RouterWithExtensions; /** * Register a handler for connection close events. * Context is capability-gated based on installed plugins (no send). * Preserves extension types through fluent chaining. */ onClose(handler: (ctx: CloseContext) => void | Promise): RouterWithExtensions; /** * Merge routes from another router. Preserves extension types through fluent chaining. */ merge(other: Router, opts?: { onConflict?: "error" | "skip" | "replace"; }): RouterWithExtensions; /** * Mount routes from another router with a prefix. Preserves extension types through fluent chaining. */ mount(prefix: string, other: Router, opts?: { onConflict?: "error" | "skip" | "replace"; }): RouterWithExtensions; /** * Apply a plugin to extend the router's capabilities. * Uses this-aware inference to preserve existing extensions through chaining. * * @typeParam E - Current extensions (inferred from this) * @typeParam P - Plugin type */ plugin>(this: Router, plugin: P): RouterWithExtensions>; } /** * Capability detection helper. * * Checks for capability markers in two forms: * 1. Modern: `__caps: { validation: true }` or `__caps: { pubsub: true }` * 2. Legacy: `{ validation: true }` or `{ pubsub: true }` at top level * * This allows plugins to migrate to __caps gradually while maintaining * backwards compatibility with existing boolean markers. */ type HasCapability = T extends { __caps: infer C; } ? C extends Record ? true : false : T extends Record ? true : false; /** * Full Router type with extensions and capability-gated APIs applied. * * Combines Router interface with: * - Direct extensions from plugins (minus internal markers) * - Capability-gated APIs based on extension markers: * - { validation: true } or { __caps: { validation: true } } → ValidationAPI * - { pubsub: true } or { __caps: { pubsub: true } } → PubSubAPI * * Note: We only omit `__caps` and `validation` (boolean marker). * The `pubsub` property is preserved if it's a runtime object (tap/init/shutdown). * * This is the type returned by plugin() and definePlugin(). */ export type RouterWithExtensions = Router & Omit & (HasCapability extends true ? ValidationAPI : {}) & (HasCapability extends true ? PubSubAPI : {}); /** * Validation API appears when withZod() or withValibot() is plugged. * It overloads `on()` and `rpc()` with type-safe handlers. * * TContext is the per-connection data type. * TExtensions captures installed plugin capabilities - when pubsub is present, * handlers receive PubSubContext methods (ctx.publish, ctx.topics). */ export interface ValidationAPI { on(schema: S, handler: (ctx: EventContext> & (HasCapability extends true ? PubSubContext : {}) & { type: InferType; }) => void): this; rpc(schema: S, handler: (ctx: RpcContext, InferResponse> & (HasCapability extends true ? PubSubContext : {}) & { type: InferType; }) => void): this; } /** * Pub/Sub API appears when withPubSub() is plugged. * * Enables publish-subscribe messaging with the following contract: * - `publish()` returns a `PublishResult` discriminated union (never throws for runtime errors) * - `topics` provides introspection and subscription management * * @see {@link PublishResult} for detailed success/failure semantics * @see {@link PublishOptions} for publish configuration options */ export interface PubSubAPI { /** * Publish a message to a topic, optionally to multiple subscribers. * * **Never throws for runtime conditions.** All expected failures (validation, ACL denial, * backpressure, connection closed) return `{ok: false}` with an error code and `retryable` hint, * enabling predictable result-based error handling. Only programmer errors at startup throw. * * **Success** returns a discriminated union with `ok: true`: * - `capability`: Trust level of the subscriber count ("exact", "estimate", or "unknown") * - `matched?`: Subscriber count (omitted if capability is "unknown") * * **Failure** returns a discriminated union with `ok: false`: * - `error`: Canonical error code ("VALIDATION", "ACL_PUBLISH", "BACKPRESSURE", etc.) * - `retryable`: Whether safe to retry with backoff (true for BACKPRESSURE, CONNECTION_CLOSED, etc.) * - `adapter?`: Name of adapter that rejected (e.g., "redis", "inmemory") * - `details?`: Structured context (e.g., `{ feature: "excludeSelf" }` for UNSUPPORTED) * - `cause?`: Underlying error cause, following Error.cause conventions * * **Error Remediation:** * - Non-retryable (VALIDATION, ACL_PUBLISH, PAYLOAD_TOO_LARGE, UNSUPPORTED, STATE): * Log and skip; fix the code/config before retrying. * - Retryable (BACKPRESSURE, CONNECTION_CLOSED, ADAPTER_ERROR): * Queue for retry with exponential backoff; check `details.transient` for hints. * * @param topic — Topic name (e.g., "chat:room:123"). Must exist in topics.list() or be created via topics.subscribe(). * @param schema — Message descriptor for type inference and validation. * @param payload — Message payload. Will be validated against schema if validation plugin is active. * @param opts — Optional publish configuration (partitionKey for sharding, excludeSelf, meta). * * @returns `PublishResult` discriminated union describing success or failure. * * @example * ```ts * const result = await ctx.publish("chat:room:1", ChatMessage, { text: "hello" }); * * if (result.ok) { * console.log(`Delivered to ${result.matched ?? "?"} subscribers (${result.capability})`); * } else if (result.retryable) { * // Transient: queue for retry * retryQueue.push({ topic: "chat:room:1", payload: { text: "hello" } }); * } else { * // Permanent: log and skip * logger.error(`Publish failed: ${result.error}`, result.details); * } * ``` * * @see {@link PublishOptions} for configuration details * @see {@link PublishError} for error codes and remediation * @see {@link PublishCapability} for subscriber count trust levels */ publish(topic: string, schema: MessageDescriptor, payload: unknown, opts?: PublishOptions): Promise; /** * Topic introspection and subscription management. * * Allows handlers to inspect active topics, subscribe to new ones, and unsubscribe. */ topics: { /** * Get all active topic names for this connection. */ list(): readonly string[]; /** * Check if this connection is subscribed to a topic. */ has(topic: string): boolean; }; } /** * Per-route builder (fluent interface): * router.route(schema).use(mw).use(mw2).on(handler) */ export interface RouteBuilder { use(mw: Middleware): this; on(handler: S extends AnySchema ? (ctx: EventContext> & { type: InferType; }) => void : EventHandler): void; } /** * Read-only route index for plugins. * Plugins only need schema lookups; they should never mutate routes. * @internal */ export interface ReadonlyRouteIndex { get(type: string): { schema: MessageDescriptor; } | undefined; has(type: string): boolean; list(): readonly { type: string; schema: MessageDescriptor; }[]; } /** * Extract a read-only route index from a router. * This is the preferred way for plugins to access schema lookups. * Uses the internal symbol to work across bundle boundaries. * @internal */ export declare function getRouteIndex(router: Router): ReadonlyRouteIndex; /** * RouterImpl implementation. * Stores global middleware, per-route handlers (via registry), and error hooks. * @internal */ export declare class RouterImpl implements RouterCore { private globalMiddlewares; private routes; private lifecycle; private limitsManager; private pluginHost; private connData; private wsToClientId; private observers; private _wsBridge; /** * Context enhancers: pure functions that extend context after creation. * Each entry has the function, priority (lower runs first), and registration order. * @internal */ private contextEnhancers; /** * Next order for enhancers (for stable registration order). * @internal */ private nextEnhancerOrder; private warnIncompleteRpc; private limitsConfig?; constructor(options?: CreateRouterOptions); /** * Get warning configuration for incomplete RPC handlers. * @internal */ getWarnIncompleteRpc(): boolean; /** * Register a context enhancer. * Enhancers run in priority order, then registration order. * @internal For use by plugins via getRouterPluginAPI() */ addContextEnhancer(enhancer: ContextEnhancer, opts?: { priority?: number; }): void; /** * Get sorted enhancers (by priority, then order). * @internal */ private getSortedEnhancers; /** * Get a read-only view of the route registry for plugins. * @internal For use by plugins via getRouterPluginAPI() */ getRouteRegistryForInternals(): ReadonlyMap; /** * Platform-agnostic WebSocket handler interface. * * Used by adapters (Bun, Cloudflare, Node.js) to delegate connection * lifecycle events to the router. This decouples the router from * specific platform APIs while providing a consistent contract. * * **Usage** (in adapter handlers): * ```ts * const handler = { * async open(ws) { await router.websocket.open(ws); }, * async message(ws, data) { await router.websocket.message(ws, data); }, * async close(ws, code, reason) { await router.websocket.close(ws, code, reason); } * }; * ``` * * Memoized for zero-overhead access (bridge created once per router instance). * * @internal */ get websocket(): { open(ws: ServerWebSocket): Promise; message(ws: ServerWebSocket, data: string | ArrayBuffer): Promise; close(ws: ServerWebSocket, code?: number, reason?: string): Promise; }; use(mw: Middleware): this; on(schema: MessageDescriptor, handler: EventHandler): this; /** * Register an RPC handler. Runtime is always available, but the public API is * type-gated by validation plugins via the { validation: true } capability. */ rpc(schema: MessageDescriptor & { response?: MessageDescriptor; }, handler: EventHandler): this; route(schema: S): RouteBuilder; /** * Register a route (called by RouteBuilder after middleware chain is set). * @internal */ registerRoute(entry: RouteEntry): void; merge(other: Router, opts?: { onConflict?: "error" | "skip" | "replace"; }): this; mount(prefix: string, other: Router, opts?: { onConflict?: "error" | "skip" | "replace"; }): this; plugin>(this: Router, plugin: P): RouterWithExtensions>; onError(fn: (err: unknown, ctx: MinimalContext | LifecycleErrorContext | null) => void): this; onOpen(handler: (ctx: BaseOpenContext) => void | Promise): this; onClose(handler: (ctx: BaseCloseContext) => void | Promise): this; observe(observer: Partial>): () => void; /** * Notify all registered observers of an event. * Uses snapshot-based dispatch for safe re-entrancy. * Swallows observer errors to prevent cascades. * @internal */ private notifyObservers; /** * Expose internal route table via symbol. * This is the ONLY sanctioned way to access the mutable route table. * Works across bundle boundaries without instanceof brittleness. * * Plugins should use getRouteIndex() helper instead to access schemas read-only. * @internal */ [ROUTE_TABLE](): RouteTable; /** * Extract route table from another router via symbol accessor. * Works across multiple bundle copies (monorepo, playgrounds, etc.). * @internal */ private extractRouteTable; /** * Get route table from another router (for merge/mount operations). * Delegates to extractRouteTable() for symbol-based access. * @internal */ private getRouteTable; /** * Get internal lifecycle manager (used by plugins and testing). * @internal */ getInternalLifecycle(): LifecycleManager; /** * Get global middleware array (used by dispatch and testing). * @internal */ getGlobalMiddlewares(): readonly Middleware[]; /** * Get limits configuration (used by dispatch). * @internal */ getLimitsConfig(): CreateRouterOptions["limits"] | undefined; /** * Get limits manager (used by dispatch for in-flight tracking). * @internal */ getLimitsManager(): LimitsManager; /** * Get the set of capabilities added by plugins. * Useful for runtime feature detection (though type-level gating is preferred). * @internal */ getCapabilities(): Readonly; /** * Get or initialize per-connection data from WeakMap. * Ensures connection data persists across all messages on the same socket. * @internal */ private getOrInitData; /** * Get or create a stable client ID for a WebSocket. * Assigns UUID on first call, then returns the same ID for subsequent calls. * @internal */ private getOrCreateClientId; /** * Get the client ID for a WebSocket (if assigned). * Used by plugins to map ws ↔ clientId. * @internal */ getClientId(ws: ServerWebSocket): string | undefined; /** * Notify observers of an error. Called by dispatch and error handlers. * @internal */ notifyError(err: unknown, meta?: { clientId?: string; type?: string; }): void; /** * Notify observers of a published message. Called by pubsub plugin after publishing. * @internal */ notifyPublish(record: PublishRecord): void; /** * Create a context from raw dispatch parameters. * This is a minimal implementation; validation plugins will extend it. * @internal */ createContext(params: { clientId: string; ws: ServerWebSocket; type: string; payload?: unknown; meta?: Record; receivedAt?: number; }): Promise>; /** * Handle connection open. * Marks connection as active, runs lifecycle handlers, and notifies observers. * Called by adapters on WebSocket upgrade. * Idempotent; safe to call multiple times. * * Lifecycle flow: * 1. Merge initial data from adapter (auth context) * 2. Run internal handlers (plugins for infrastructure setup) * 3. Build full context for router-level handlers * 4. Run router-level handlers (user code) with capability-gated context * 5. Notify observers * * Errors in router-level handlers: * - CloseError: Close connection with specified code/reason * - Other errors: Close with 1011 (internal error), notify onError handlers * * @param ws - WebSocket connection */ handleOpen(ws: ServerWebSocket): Promise; /** * Create capability-gated context for onOpen handlers. * @internal */ private createOpenContext; /** * Handle incoming message frame. * Single entry point for inbound frames: parse, validate, dispatch through router pipeline. * * Never throws. All errors (parse, validation, handler, middleware) flow to router.onError() * via the lifecycle manager. * * @param ws - WebSocket connection * @param rawFrame - Raw message (string or ArrayBuffer, typically UTF-8 JSON) */ handleMessage(ws: ServerWebSocket, rawFrame: string | ArrayBuffer): Promise; /** * Handle connection close. * Runs lifecycle handlers, cleans up per-connection data, and notifies observers. * Called by adapters on WebSocket close/error. * Idempotent; safe to call multiple times. * * Lifecycle flow: * 1. Run router-level handlers (user code) with capability-gated context * 2. Run internal handlers (plugins for cleanup) * 3. Clean up connection data * 4. Notify observers * * Errors in handlers are logged but don't affect close (connection is terminating). * * @param ws - WebSocket connection * @param code - WebSocket close code (optional, e.g., 1000 for normal close) * @param reason - Close reason (optional) */ handleClose(ws: ServerWebSocket, code?: number, reason?: string): Promise; /** * Create capability-gated context for onClose handlers. * Note: No send() method since socket is CLOSING/CLOSED. * Topics are read-only (list, has only) since cleanup is automatic. * @internal */ private createCloseContext; } //# sourceMappingURL=router.d.ts.map