/**
* 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