Status: Implemented
Related Issue: #93 (Typed send missing in onOpen)
Scope: Core + Adapters + Docs
- Docs and examples show
onOpen(ctx) { ctx.send(...) }, but adapters currently expose{ data, ws }only. - ADR-035 says adapter hooks are observability-only and sync-only.
- Users need typed welcome/init messages and initial topic subscriptions on connect.
- Adapter-level
sendwould couple transports to router internals and bypass plugin/middleware pipelines.
Add router-level lifecycle hooks with full typed context:
- Add
router.onOpen(handler)androuter.onClose(handler)that receive capability-gated context (send,publish,topicswhen plugins installed). - Keep adapter hooks separate and minimal (
{ data, ws }, sync, observability-only). - Do not overload
serve({ onOpen })to avoid type conflicts and breaking changes.
- No typed
sendin adapter hooks. Adapters remain mechanical bridges per ADR-031/035. - No
serve()sugar. Users callrouter.onOpen()directly; this avoids type collisions and hidden indirection. - No short aliases.
router.open()/router.close()would collide withTestRouter.close(): Promise<void>and imply imperative actions rather than event registration.
- Preserves adapter-as-bridge invariant (ADR-031/035); no schema or behavior leaks into adapters.
- Delivers the DX users expect (typed welcome messages, initial subscriptions) without losing validation/pubsub/plugin behavior.
- Portable across adapters (Bun, Cloudflare, Node, etc.) because lifecycle lives in the router.
- Aligns code with existing spec examples and removes the doc/impl mismatch.
const router = createRouter()
.plugin(withZod())
.plugin(withPubSub({ adapter: memoryPubSub() }));
router.onOpen(async (ctx) => {
// Full typed context like message handlers
ctx.send(WelcomeMessage, { greeting: "Hello" });
if (ctx.data.userId) {
await ctx.topics.subscribe(`user:${ctx.data.userId}`);
}
});
router.onClose((ctx) => {
// publish still works (broadcast to others)
ctx.publish("presence", UserLeftMessage, { userId: ctx.data.userId });
// send is NOT available (socket already closing)
});
serve(router, { port: 3000 });serve(router, {
port: 3000,
// Adapter hooks: observability only, sync, minimal context
onOpen: ({ data, ws }) => {
console.log(`Connected: ${data.clientId}`);
metrics.increment("connections");
},
onClose: ({ data, ws }) => {
console.log(`Disconnected: ${data.clientId}`);
metrics.decrement("connections");
},
});Documentation note: All examples of welcome messages and topic subscriptions use router.onOpen(). Adapter hooks (serve({ onOpen })) are reserved for logging/metrics only.
OpenContext (capability-gated):
interface OpenContext<TContext, TExtensions> {
clientId: string;
data: TContext;
connectedAt: number; // Connection timestamp (ms since epoch)
ws: ServerWebSocket; // Escape hatch for raw socket access
assignData: (partial) => void; // Update connection data (e.g., upgrade anonymous → authenticated)
// Present when validation plugin installed:
send?: (schema, payload) => void;
// Present when pubsub plugin installed:
publish?: (topic, schema, payload) => Promise<PublishResult>;
topics?: { subscribe, unsubscribe, list, has };
}CloseContext (no send):
interface CloseContext<TContext, TExtensions> {
clientId: string;
data: TContext;
code?: number; // WebSocket close code
reason?: string; // Close reason
ws: ServerWebSocket; // Socket is CLOSING or CLOSED
// Present when pubsub plugin installed:
publish?: (topic, schema, payload) => Promise<PublishResult>;
topics?: { list, has }; // Read-only: no subscribe/unsubscribe (cleanup is automatic)
}Why no send in onClose?
By the time onClose fires, the WebSocket is CLOSING or CLOSED. Including send would create a foot-gun where users expect messages to deliver. publish is still useful to broadcast "user left" to others.
Why read-only topics in onClose?
Topic cleanup is automatic on connection close. The topics object in CloseContext is for debugging/metrics (checking what the connection was subscribed to). No subscribe/unsubscribe since the connection is terminating.
HTTP Upgrade Request
│
▼
authenticate(req) → returns TContext or undefined
│
▼
adapter.onUpgrade?(req) [sync, observability, before accept]
│
▼
WebSocket OPEN (Bun/Cloudflare level)
│
▼
router.onOpen(ctx) handlers [async, full context, AWAITED]
│ (can throw CloseError to reject)
▼
adapter.onOpen({ data, ws }) [sync, observability, ALWAYS fires]
│
▼
Message dispatch begins [only after onOpen completes]
│
▼
...connection active...
│
▼
Close notification received [socket entering CLOSING state]
│
▼
router.onClose(ctx) handlers [async, during close notification]
│
▼
adapter.onClose({ data, ws }) [sync, observability, ALWAYS fires]
│
▼
Connection fully torn down
Key guarantees:
router.onOpencompletes before message dispatch. Users can safely assume subscriptions are set up before handlers run.router.onCloseruns during close notification. The socket is at leastCLOSING, may already beCLOSED. You cannot rely on sending frames here—usepublishfor "user left" broadcasts.- Adapter hooks fire for all successfully upgraded connections. If upgrade fails or
authenticaterejects, there is noonOpen/onClose. - Adapter hooks always fire (for upgraded connections). Even if a lifecycle handler throws, adapter hooks run for observability.
- Ordering is deterministic. Router lifecycle runs first, then adapter hooks, in both open and close flows.
Lifecycle events use reserved system types that validation plugins skip:
// packages/core/src/schema/reserved.ts
export const SYSTEM_LIFECYCLE = {
OPEN: "$ws:open",
CLOSE: "$ws:close",
} as const;Namespace reservation: The $ws: prefix is reserved for internal system events. User-defined message types must not start with $ws:. Schema definition functions (message(), rpc()) enforce this at runtime:
// Throws: "Message type cannot start with '$ws:' (reserved for system events)"
const BadMessage = message("$ws:custom", { ... });Validation plugins check ctx.type and skip validation for system events:
if (typeof ctx.type === "string" && ctx.type.startsWith("$ws:")) {
return; // Skip validation for system events
}Plugins register lifecycle handlers via the normal router.onOpen() API during installation.
Ordering invariant: Lifecycle handlers run in registration order. To ensure plugins can set up infrastructure before user handlers run:
// Recommended: install plugins before registering lifecycle handlers
const router = createRouter()
.plugin(withZod()) // Plugin's onOpen registered here
.plugin(withPubSub(...)) // Plugin's onOpen registered here
.onOpen(userHandler); // User handler runs after plugin handlers// In withPubSub plugin
install(router) {
router.onOpen((ctx) => {
// Track connection for topic management (runs before user handlers)
clientRegistry.add(ctx.clientId, ctx.ws);
});
router.onClose((ctx) => {
// Clean up subscriptions (runs before user handlers)
clientRegistry.remove(ctx.clientId);
});
}Note: If user calls .onOpen() before .plugin(), user handlers will run before plugin handlers. This is usually not what you want. Document this ordering clearly.
Errors in lifecycle handlers flow through router.onError():
router.onError((err, ctx) => {
console.error(`Error in ${ctx?.type ?? "lifecycle"}:`, err);
});Custom close codes with CloseError:
import { CloseError } from "@ws-kit/core";
router.onOpen(async (ctx) => {
const user = await validateToken(ctx.data.token);
if (!user) {
// Close with custom code and reason
throw new CloseError(4401, "Invalid token");
}
});Behavior:
- Unhandled errors in
onOpenclose the connection with code 1011 (internal error). CloseErrorinonOpencloses with the specified code and reason.- Unhandled errors in
onCloseare logged but don't affect close (already closing). - Errors thrown from
router.onErroritself are caught and logged to console; they never propagate to the event loop. - Errors never crash the process.
// Base context (always present)
interface BaseOpenContext<TContext> {
clientId: string;
data: TContext;
connectedAt: number;
ws: ServerWebSocket;
assignData: (partial: Partial<TContext>) => void;
}
interface BaseCloseContext<TContext> {
clientId: string;
data: TContext;
code?: number;
reason?: string;
ws: ServerWebSocket;
}
// Capability-gated context derivation
type OpenContext<TContext, TExtensions> =
BaseOpenContext<TContext> &
(HasCapability<TExtensions, "validation"> extends true
? { send: SendFunction<TExtensions> }
: {}) &
(HasCapability<TExtensions, "pubsub"> extends true
? { publish: PublishFunction; topics: TopicsAPI }
: {});
type CloseContext<TContext, TExtensions> =
BaseCloseContext<TContext> &
(HasCapability<TExtensions, "pubsub"> extends true
? { publish: PublishFunction; topics: Pick<TopicsAPI, "list" | "has"> }
: {});
// Error context for lifecycle errors
interface LifecycleErrorContext {
type: "$ws:open" | "$ws:close";
clientId: string;
data: unknown;
}
// Router lifecycle methods (capability-gated via Omit pattern)
interface RouterCore<TContext> {
onOpen(handler: (ctx: BaseOpenContext<TContext>) => void | Promise<void>): this;
onClose(handler: (ctx: BaseCloseContext<TContext>) => void | Promise<void>): this;
onError(handler: (err: Error, ctx?: LifecycleErrorContext) => void): this;
}
// When plugins are installed, Omit base methods and add capability-gated versions
type RouterWithExtensions<TContext, TExtensions> =
Omit<RouterCore<TContext>, "onOpen" | "onClose"> & {
onOpen(handler: (ctx: OpenContext<TContext, TExtensions>) => void | Promise<void>): this;
onClose(handler: (ctx: CloseContext<TContext, TExtensions>) => void | Promise<void>): this;
onError(handler: (err: Error, ctx?: LifecycleErrorContext) => void): this;
};- Core types: Add
BaseOpenContext,BaseCloseContext,OpenContext,CloseContext,LifecycleErrorContexttopackages/core/src/context/lifecycle-context.ts. - System constants: Export
SYSTEM_LIFECYCLEfrompackages/core/src/schema/reserved.ts. - CloseError: Add
CloseErrorclass topackages/core/src/error.tsand export from package root. - Router API: Add
onOpen(),onClose(),onError()toRouterCoreandRouterImpl. - Namespace enforcement: Update
message()andrpc()to reject types starting with$ws:. - Lifecycle dispatch: In
handleOpen/handleClose, build full context viacreateOpenContext/createCloseContextwith system type, run handlers sequentially. - Validation bypass: Update
withZod/withValibotto skip validation for$ws:*types. - Capability gating: Use
Omitpattern in type helpers soctx.send/ctx.topicsappear based on installed plugins. - Tests: Add
packages/core/test/features/lifecycle-hooks.test.ts. - Docs: Update
docs/specs/router.md, add migration note. - Adapter hooks: Update adapters to ALWAYS fire
onOpen/onClosehooks for observability, even if router lifecycle throws.
This is additive with one minor breaking change:
- Existing adapter hooks (
serve({ onOpen })) continue to work exactly as before with{ data, ws }context. - New
router.onOpen()API is opt-in. - Users wanting typed sends/subscriptions call
router.onOpen()before passing toserve().
Breaking: The $ws: prefix is now reserved. Any existing schemas using this prefix will fail at definition time. This is expected to affect zero users, but technically requires a minor version bump.
Migration path:
// Before (raw ws.send, no type safety)
serve(router, {
onOpen: ({ ws }) => {
ws.send(JSON.stringify({ type: "WELCOME", payload: { greeting: "Hello" } }));
},
});
// After (typed, capability-aware)
router.onOpen((ctx) => {
ctx.send(WelcomeMessage, { greeting: "Hello" });
});
serve(router, { port: 3000 });router.onOpenruns after authenticate —ctx.datais populated.router.onOpengets capabilities —ctx.send/ctx.topicspresent when plugins installed; type tests assert this.- Messages wait for
onOpen— Incoming messages are not dispatched untilonOpencompletes. router.onCloseruns during close notification —ctx.publishsucceeds.onClosefires on abrupt disconnect — Even without graceful close frame.- Errors route to
router.onError— Lifecycle errors invoke error handler. - Error in
onOpencloses connection — Unhandled error closes with 1011;CloseErroruses custom code. - Error in
router.onErroris swallowed — Never propagates to event loop. - Handler ordering is registration order — Plugin handlers registered first run first.
- Adapter hooks fire even if lifecycle throws —
serve({ onOpen, onClose })callbacks still run. $ws:prefix rejected in schema —message("$ws:foo", {...})throws at definition time.- Hooks only fire for upgraded connections — Failed upgrade or auth rejection doesn't trigger hooks.
topics.subscribeinonOpenis awaited — This delays message dispatch but ensures subscriptions are set up. Document this trade-off in specs.- No short aliases —
router.open()/router.close()would collide withTestRouter.close()and imply imperative actions. Stick withonOpen/onClose.