@typokit/plugin-ws 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,749 @@
1
+ // @typokit/plugin-ws — WebSocket Support Plugin
2
+ //
3
+ // Schema-first WebSocket plugin following the same typed-contract pattern as REST routes.
4
+ // Provides type-safe channels with validated messages and build-time code generation.
5
+
6
+ import type { TypoKitPlugin, AppInstance, BuildPipeline } from "@typokit/core";
7
+ import type {
8
+ SchemaTypeMap,
9
+ BuildContext,
10
+ GeneratedOutput,
11
+ RequestContext,
12
+ SchemaChange,
13
+ TypeMetadata,
14
+ } from "@typokit/types";
15
+ import type { AppError } from "@typokit/errors";
16
+
17
+ // ─── WS Contract Types ──────────────────────────────────────
18
+
19
+ /** Describes message types for a single WebSocket channel */
20
+ export interface WsChannelContract {
21
+ /** Messages the server sends to connected clients */
22
+ serverToClient: unknown;
23
+ /** Messages the client sends to the server */
24
+ clientToServer: unknown;
25
+ }
26
+
27
+ /**
28
+ * Maps channel names to their typed message contracts.
29
+ * Users define this interface to describe their WS API.
30
+ *
31
+ * @example
32
+ * ```typescript
33
+ * interface MyChannels {
34
+ * "notifications": {
35
+ * serverToClient: { type: "alert"; message: string };
36
+ * clientToServer: { type: "subscribe"; topic: string };
37
+ * };
38
+ * }
39
+ * ```
40
+ */
41
+ export type WsChannels = Record<string, WsChannelContract>;
42
+
43
+ // ─── WS Handler Types ───────────────────────────────────────
44
+
45
+ /** Context passed to WS handler callbacks */
46
+ export interface WsHandlerContext {
47
+ /** Standard request context (includes logger, services, requestId) */
48
+ ctx: RequestContext;
49
+ /** Send a typed message to the connected client */
50
+ send(data: unknown): void;
51
+ /** Close the WebSocket connection */
52
+ close(code?: number, reason?: string): void;
53
+ /** Channel name this handler belongs to */
54
+ channel: string;
55
+ /** Connection-specific metadata store */
56
+ meta: Record<string, unknown>;
57
+ }
58
+
59
+ /** Handler callbacks for a single WS channel */
60
+ export interface WsChannelHandler<
61
+ TClientToServer = unknown,
62
+ TServerToClient = unknown,
63
+ > {
64
+ /** Called when a client connects to this channel */
65
+ onConnect?(context: WsHandlerContext): Promise<void> | void;
66
+ /** Called when a validated message is received from the client */
67
+ onMessage?(
68
+ context: WsHandlerContext & { data: TClientToServer },
69
+ ): Promise<void> | void;
70
+ /** Called when the client disconnects */
71
+ onDisconnect?(context: WsHandlerContext): Promise<void> | void;
72
+ /** Type marker for server-to-client messages (compile-time only) */
73
+ _serverToClient?: TServerToClient;
74
+ }
75
+
76
+ /** Maps channel names to their handler definitions */
77
+ export type WsHandlerDefs<TChannels extends WsChannels = WsChannels> = {
78
+ [K in keyof TChannels]: WsChannelHandler<
79
+ TChannels[K]["clientToServer"],
80
+ TChannels[K]["serverToClient"]
81
+ >;
82
+ };
83
+
84
+ // ─── WS Validator Types ─────────────────────────────────────
85
+
86
+ /** Validates an incoming message against the channel's clientToServer contract */
87
+ export type WsValidatorFn = (data: unknown) => {
88
+ valid: boolean;
89
+ errors?: string[];
90
+ };
91
+
92
+ /** Maps channel names to their validator functions */
93
+ export type WsValidatorMap = Record<string, WsValidatorFn>;
94
+
95
+ // ─── WS Connection Types ────────────────────────────────────
96
+
97
+ /** Represents a single WebSocket connection */
98
+ export interface WsConnection {
99
+ /** Unique connection ID */
100
+ id: string;
101
+ /** Channel this connection belongs to */
102
+ channel: string;
103
+ /** Send a message to this client */
104
+ send(data: unknown): void;
105
+ /** Close the connection */
106
+ close(code?: number, reason?: string): void;
107
+ /** Connection metadata */
108
+ meta: Record<string, unknown>;
109
+ /** Whether the connection is open */
110
+ isOpen: boolean;
111
+ }
112
+
113
+ // ─── WS Channel Info (Build-time) ───────────────────────────
114
+
115
+ /** Extracted channel contract metadata from the type map */
116
+ export interface WsChannelInfo {
117
+ name: string;
118
+ serverToClientType: string;
119
+ clientToServerType: string;
120
+ properties: {
121
+ serverToClient: TypeMetadata | null;
122
+ clientToServer: TypeMetadata | null;
123
+ };
124
+ }
125
+
126
+ // ─── Plugin Options ─────────────────────────────────────────
127
+
128
+ /** Options for the wsPlugin factory */
129
+ export interface WsPluginOptions {
130
+ /** Path prefix for WS upgrade endpoints (default: "/ws") */
131
+ pathPrefix?: string;
132
+ /** Maximum message size in bytes (default: 65536) */
133
+ maxMessageSize?: number;
134
+ /** Heartbeat interval in ms (default: 30000, 0 to disable) */
135
+ heartbeatInterval?: number;
136
+ /** Require authentication for WS connections (uses auth middleware) */
137
+ requireAuth?: boolean;
138
+ /** Custom validator map for message validation */
139
+ validators?: WsValidatorMap;
140
+ /** Channel handler definitions */
141
+ handlers?: WsHandlerDefs;
142
+ }
143
+
144
+ // ─── defineWsHandlers ───────────────────────────────────────
145
+
146
+ /**
147
+ * Define typed WebSocket handlers for a set of channels.
148
+ * Provides compile-time type checking for message types.
149
+ *
150
+ * @example
151
+ * ```typescript
152
+ * export default defineWsHandlers<MyChannels>({
153
+ * "notifications": {
154
+ * onConnect: async ({ ctx }) => { ... },
155
+ * onMessage: async ({ data, ctx }) => {
156
+ * // data is typed as MyChannels["notifications"]["clientToServer"]
157
+ * },
158
+ * onDisconnect: async ({ ctx }) => { ... },
159
+ * },
160
+ * });
161
+ * ```
162
+ */
163
+ export function defineWsHandlers<TChannels extends WsChannels>(
164
+ handlers: WsHandlerDefs<TChannels>,
165
+ ): WsHandlerDefs<TChannels> {
166
+ return handlers;
167
+ }
168
+
169
+ // ─── Build-Time Utilities ───────────────────────────────────
170
+
171
+ /**
172
+ * Extract WS channel contracts from the type map.
173
+ * Looks for types implementing the WsChannelContract pattern
174
+ * (types with serverToClient and clientToServer properties).
175
+ */
176
+ export function extractWsChannels(typeMap: SchemaTypeMap): WsChannelInfo[] {
177
+ const channels: WsChannelInfo[] = [];
178
+
179
+ for (const [typeName, meta] of Object.entries(typeMap)) {
180
+ const props = meta.properties;
181
+ if (!props) continue;
182
+
183
+ // Check if this type has the WsChannels pattern: keys mapping to
184
+ // objects with serverToClient and clientToServer
185
+ if (
186
+ meta.jsdoc?.["wsChannels"] === "true" ||
187
+ meta.jsdoc?.["ws"] === "true"
188
+ ) {
189
+ // This type is a WsChannels map — each property is a channel
190
+ for (const [channelName, channelProp] of Object.entries(props)) {
191
+ const channelType = typeMap[channelProp.type];
192
+ if (
193
+ channelType?.properties?.["serverToClient"] &&
194
+ channelType?.properties?.["clientToServer"]
195
+ ) {
196
+ channels.push({
197
+ name: channelName,
198
+ serverToClientType: channelType.properties["serverToClient"].type,
199
+ clientToServerType: channelType.properties["clientToServer"].type,
200
+ properties: {
201
+ serverToClient:
202
+ typeMap[channelType.properties["serverToClient"].type] ?? null,
203
+ clientToServer:
204
+ typeMap[channelType.properties["clientToServer"].type] ?? null,
205
+ },
206
+ });
207
+ }
208
+ }
209
+ }
210
+
211
+ // Also check if the type itself is a channel contract
212
+ if (props["serverToClient"] && props["clientToServer"]) {
213
+ // Use the JSDoc @channel tag or the type name as the channel name
214
+ const channelName = meta.jsdoc?.["channel"] ?? typeName;
215
+ channels.push({
216
+ name: channelName,
217
+ serverToClientType: props["serverToClient"].type,
218
+ clientToServerType: props["clientToServer"].type,
219
+ properties: {
220
+ serverToClient: typeMap[props["serverToClient"].type] ?? null,
221
+ clientToServer: typeMap[props["clientToServer"].type] ?? null,
222
+ },
223
+ });
224
+ }
225
+ }
226
+
227
+ return channels;
228
+ }
229
+
230
+ /**
231
+ * Generate validator code for WS channels.
232
+ * Produces a TypeScript file with runtime validation functions for incoming messages.
233
+ */
234
+ export function generateWsValidators(
235
+ channels: WsChannelInfo[],
236
+ outDir: string,
237
+ ): GeneratedOutput {
238
+ const lines: string[] = [
239
+ "// Auto-generated by @typokit/plugin-ws — do not edit",
240
+ "// Validates incoming WebSocket messages against channel contracts",
241
+ "",
242
+ "export type WsValidatorFn = (data: unknown) => { valid: boolean; errors?: string[] };",
243
+ "",
244
+ "export const wsValidators: Record<string, WsValidatorFn> = {",
245
+ ];
246
+
247
+ for (const channel of channels) {
248
+ lines.push(` "${channel.name}": (data: unknown) => {`);
249
+ lines.push(" if (data === null || data === undefined) {");
250
+ lines.push(
251
+ ' return { valid: false, errors: ["Message must not be null or undefined"] };',
252
+ );
253
+ lines.push(" }");
254
+ lines.push(' if (typeof data !== "object") {');
255
+ lines.push(
256
+ ' return { valid: false, errors: ["Message must be an object"] };',
257
+ );
258
+ lines.push(" }");
259
+
260
+ // If we have property metadata for clientToServer, generate property checks
261
+ if (channel.properties.clientToServer) {
262
+ const props = channel.properties.clientToServer.properties;
263
+ for (const [propName, propMeta] of Object.entries(props)) {
264
+ if (!propMeta.optional) {
265
+ lines.push(
266
+ ` if (!("${propName}" in (data as Record<string, unknown>))) {`,
267
+ );
268
+ lines.push(
269
+ ` return { valid: false, errors: ["Missing required field: ${propName}"] };`,
270
+ );
271
+ lines.push(" }");
272
+ }
273
+ }
274
+ }
275
+
276
+ lines.push(" return { valid: true };");
277
+ lines.push(" },");
278
+ }
279
+
280
+ lines.push("};");
281
+ lines.push("");
282
+
283
+ return {
284
+ filePath: `${outDir}/ws-validators.ts`,
285
+ content: lines.join("\n"),
286
+ overwrite: true,
287
+ };
288
+ }
289
+
290
+ /**
291
+ * Generate the WS route table mapping channel paths to metadata.
292
+ */
293
+ export function generateWsRouteTable(
294
+ channels: WsChannelInfo[],
295
+ outDir: string,
296
+ pathPrefix: string,
297
+ ): GeneratedOutput {
298
+ const lines: string[] = [
299
+ "// Auto-generated by @typokit/plugin-ws — do not edit",
300
+ "// WebSocket channel route table",
301
+ "",
302
+ "export interface WsRouteEntry {",
303
+ " channel: string;",
304
+ " path: string;",
305
+ " serverToClientType: string;",
306
+ " clientToServerType: string;",
307
+ "}",
308
+ "",
309
+ "export const wsRouteTable: WsRouteEntry[] = [",
310
+ ];
311
+
312
+ for (const channel of channels) {
313
+ lines.push(" {");
314
+ lines.push(` channel: "${channel.name}",`);
315
+ lines.push(` path: "${pathPrefix}/${channel.name}",`);
316
+ lines.push(` serverToClientType: "${channel.serverToClientType}",`);
317
+ lines.push(` clientToServerType: "${channel.clientToServerType}",`);
318
+ lines.push(" },");
319
+ }
320
+
321
+ lines.push("];");
322
+ lines.push("");
323
+
324
+ return {
325
+ filePath: `${outDir}/ws-route-table.ts`,
326
+ content: lines.join("\n"),
327
+ overwrite: true,
328
+ };
329
+ }
330
+
331
+ // ─── WS Connection Manager ─────────────────────────────────
332
+
333
+ /** Manages active WebSocket connections across all channels */
334
+ export class WsConnectionManager {
335
+ private connections = new Map<string, WsConnection>();
336
+ private channelConnections = new Map<string, Set<string>>();
337
+
338
+ /** Register a new connection */
339
+ add(connection: WsConnection): void {
340
+ this.connections.set(connection.id, connection);
341
+ let channelSet = this.channelConnections.get(connection.channel);
342
+ if (!channelSet) {
343
+ channelSet = new Set();
344
+ this.channelConnections.set(connection.channel, channelSet);
345
+ }
346
+ channelSet.add(connection.id);
347
+ }
348
+
349
+ /** Remove a connection */
350
+ remove(connectionId: string): WsConnection | undefined {
351
+ const conn = this.connections.get(connectionId);
352
+ if (conn) {
353
+ this.connections.delete(connectionId);
354
+ const channelSet = this.channelConnections.get(conn.channel);
355
+ if (channelSet) {
356
+ channelSet.delete(connectionId);
357
+ if (channelSet.size === 0) {
358
+ this.channelConnections.delete(conn.channel);
359
+ }
360
+ }
361
+ }
362
+ return conn;
363
+ }
364
+
365
+ /** Get a connection by ID */
366
+ get(connectionId: string): WsConnection | undefined {
367
+ return this.connections.get(connectionId);
368
+ }
369
+
370
+ /** Get all connections for a channel */
371
+ getByChannel(channel: string): WsConnection[] {
372
+ const ids = this.channelConnections.get(channel);
373
+ if (!ids) return [];
374
+ const results: WsConnection[] = [];
375
+ for (const id of ids) {
376
+ const conn = this.connections.get(id);
377
+ if (conn) results.push(conn);
378
+ }
379
+ return results;
380
+ }
381
+
382
+ /** Broadcast a message to all connections on a channel */
383
+ broadcast(channel: string, data: unknown): number {
384
+ const connections = this.getByChannel(channel);
385
+ let sent = 0;
386
+ for (const conn of connections) {
387
+ if (conn.isOpen) {
388
+ conn.send(data);
389
+ sent++;
390
+ }
391
+ }
392
+ return sent;
393
+ }
394
+
395
+ /** Get count of active connections */
396
+ get size(): number {
397
+ return this.connections.size;
398
+ }
399
+
400
+ /** Get count of connections on a specific channel */
401
+ channelSize(channel: string): number {
402
+ return this.channelConnections.get(channel)?.size ?? 0;
403
+ }
404
+
405
+ /** Close all connections */
406
+ closeAll(code?: number, reason?: string): void {
407
+ for (const conn of this.connections.values()) {
408
+ if (conn.isOpen) {
409
+ conn.close(code, reason);
410
+ }
411
+ }
412
+ this.connections.clear();
413
+ this.channelConnections.clear();
414
+ }
415
+ }
416
+
417
+ // ─── Message Validation ─────────────────────────────────────
418
+
419
+ /**
420
+ * Validate an incoming message against the channel's validator.
421
+ * Returns validation result with errors if invalid.
422
+ */
423
+ export function validateWsMessage(
424
+ channel: string,
425
+ data: unknown,
426
+ validators: WsValidatorMap,
427
+ ): { valid: boolean; errors?: string[] } {
428
+ const validator = validators[channel];
429
+ if (!validator) {
430
+ // No validator registered — accept all messages
431
+ return { valid: true };
432
+ }
433
+ return validator(data);
434
+ }
435
+
436
+ /**
437
+ * Parse a raw WebSocket message string into a typed object.
438
+ * Returns null if parsing fails.
439
+ */
440
+ export function parseWsMessage(raw: string | ArrayBuffer): {
441
+ data: unknown;
442
+ error?: string;
443
+ } {
444
+ if (raw instanceof ArrayBuffer) {
445
+ try {
446
+ const Decoder = (
447
+ globalThis as unknown as {
448
+ TextDecoder: new () => { decode(input: ArrayBuffer): string };
449
+ }
450
+ ).TextDecoder;
451
+ const text = new Decoder().decode(raw);
452
+ return { data: JSON.parse(text) };
453
+ } catch {
454
+ return { data: null, error: "Failed to decode binary message as JSON" };
455
+ }
456
+ }
457
+
458
+ if (typeof raw === "string") {
459
+ try {
460
+ return { data: JSON.parse(raw) };
461
+ } catch {
462
+ return { data: null, error: "Failed to parse message as JSON" };
463
+ }
464
+ }
465
+
466
+ return { data: null, error: "Unsupported message format" };
467
+ }
468
+
469
+ // ─── ID Generation ──────────────────────────────────────────
470
+
471
+ let connectionCounter = 0;
472
+
473
+ function generateConnectionId(): string {
474
+ const timestamp = Date.now().toString(36);
475
+ const counter = (connectionCounter++).toString(36);
476
+ const random = Math.random().toString(36).substring(2, 8);
477
+ return `ws_${timestamp}_${counter}_${random}`;
478
+ }
479
+
480
+ // ─── Plugin Factory ─────────────────────────────────────────
481
+
482
+ /**
483
+ * Create a WebSocket plugin that provides schema-first typed WebSocket channels.
484
+ *
485
+ * @example
486
+ * ```typescript
487
+ * import { createApp } from "@typokit/core";
488
+ * import { wsPlugin } from "@typokit/plugin-ws";
489
+ *
490
+ * const app = createApp({
491
+ * plugins: [wsPlugin({ pathPrefix: "/ws", requireAuth: true })],
492
+ * });
493
+ * ```
494
+ */
495
+ export function wsPlugin(options: WsPluginOptions = {}): TypoKitPlugin {
496
+ const pathPrefix = options.pathPrefix ?? "/ws";
497
+ const maxMessageSize = options.maxMessageSize ?? 65536;
498
+ const heartbeatInterval = options.heartbeatInterval ?? 30_000;
499
+ const requireAuth = options.requireAuth ?? false;
500
+ const validators: WsValidatorMap = { ...options.validators };
501
+ const handlers: WsHandlerDefs = options.handlers ?? {};
502
+
503
+ // Connection manager shared across the plugin
504
+ const connectionManager = new WsConnectionManager();
505
+
506
+ // WS channel info extracted at build time
507
+ let channelInfos: WsChannelInfo[] = [];
508
+
509
+ // Heartbeat timer
510
+ const _setInterval = (
511
+ globalThis as unknown as {
512
+ setInterval: (fn: () => void, ms: number) => number;
513
+ }
514
+ ).setInterval;
515
+ const _clearInterval = (
516
+ globalThis as unknown as { clearInterval: (id: number) => void }
517
+ ).clearInterval;
518
+ let heartbeatTimer: number | null = null;
519
+
520
+ const plugin: TypoKitPlugin = {
521
+ name: "plugin-ws",
522
+
523
+ onBuild(pipeline: BuildPipeline): void {
524
+ // After types are parsed, extract WebSocket channel contracts
525
+ pipeline.hooks.afterTypeParse.tap(
526
+ "ws-plugin",
527
+ (typeMap: SchemaTypeMap, _ctx: BuildContext) => {
528
+ channelInfos = extractWsChannels(typeMap);
529
+ },
530
+ );
531
+
532
+ // At emit phase, generate WS validators and route tables
533
+ pipeline.hooks.emit.tap(
534
+ "ws-plugin",
535
+ (outputs: GeneratedOutput[], ctx: BuildContext) => {
536
+ if (channelInfos.length > 0) {
537
+ outputs.push(
538
+ generateWsValidators(channelInfos, ctx.outDir),
539
+ generateWsRouteTable(channelInfos, ctx.outDir, pathPrefix),
540
+ );
541
+ }
542
+ },
543
+ );
544
+ },
545
+
546
+ async onStart(app: AppInstance): Promise<void> {
547
+ // Expose WS service for other plugins and handlers
548
+ app.services["_ws"] = {
549
+ /** Get the connection manager */
550
+ getConnectionManager: () => connectionManager,
551
+
552
+ /** Send a message to a specific connection */
553
+ send: (connectionId: string, data: unknown) => {
554
+ const conn = connectionManager.get(connectionId);
555
+ if (conn?.isOpen) {
556
+ conn.send(data);
557
+ return true;
558
+ }
559
+ return false;
560
+ },
561
+
562
+ /** Broadcast a message to all connections on a channel */
563
+ broadcast: (channel: string, data: unknown) => {
564
+ return connectionManager.broadcast(channel, data);
565
+ },
566
+
567
+ /** Get active connection count */
568
+ getConnectionCount: (channel?: string) => {
569
+ if (channel) return connectionManager.channelSize(channel);
570
+ return connectionManager.size;
571
+ },
572
+
573
+ /** Register a validator for a channel */
574
+ registerValidator: (channel: string, validator: WsValidatorFn) => {
575
+ validators[channel] = validator;
576
+ },
577
+
578
+ /** Register handlers for channels */
579
+ registerHandlers: (newHandlers: WsHandlerDefs) => {
580
+ Object.assign(handlers, newHandlers);
581
+ },
582
+
583
+ /** Get channel infos extracted at build time */
584
+ getChannelInfos: () => channelInfos,
585
+
586
+ /** Plugin config */
587
+ config: {
588
+ pathPrefix,
589
+ maxMessageSize,
590
+ heartbeatInterval,
591
+ requireAuth,
592
+ },
593
+ };
594
+ },
595
+
596
+ async onReady(_app: AppInstance): Promise<void> {
597
+ // Start heartbeat timer if configured
598
+ if (heartbeatInterval > 0) {
599
+ heartbeatTimer = _setInterval(() => {
600
+ // Ping all connections to keep them alive
601
+ for (const channel of Object.keys(handlers)) {
602
+ const connections = connectionManager.getByChannel(channel);
603
+ for (const conn of connections) {
604
+ if (!conn.isOpen) {
605
+ connectionManager.remove(conn.id);
606
+ }
607
+ }
608
+ }
609
+ }, heartbeatInterval);
610
+ }
611
+ },
612
+
613
+ onError(error: AppError, _ctx: RequestContext): void {
614
+ // Log WS-related errors for debugging
615
+ void error;
616
+ },
617
+
618
+ async onStop(_app: AppInstance): Promise<void> {
619
+ // Stop heartbeat
620
+ if (heartbeatTimer) {
621
+ _clearInterval(heartbeatTimer);
622
+ heartbeatTimer = null;
623
+ }
624
+
625
+ // Close all connections
626
+ connectionManager.closeAll(1001, "Server shutting down");
627
+ },
628
+
629
+ onSchemaChange(_changes: SchemaChange[]): void {
630
+ // Channel contracts may have changed — clear cached infos
631
+ // They'll be re-extracted on next build
632
+ channelInfos = [];
633
+ },
634
+ };
635
+
636
+ return plugin;
637
+ }
638
+
639
+ /**
640
+ * Handle an incoming WebSocket upgrade request.
641
+ * Validates the channel path, optionally checks auth, and registers the connection.
642
+ */
643
+ export function handleWsUpgrade(
644
+ channel: string,
645
+ connectionManager: WsConnectionManager,
646
+ handlers: WsHandlerDefs,
647
+ validators: WsValidatorMap,
648
+ sendFn: (data: unknown) => void,
649
+ closeFn: (code?: number, reason?: string) => void,
650
+ ctx: RequestContext,
651
+ ):
652
+ | {
653
+ connectionId: string;
654
+ onMessage: (raw: string | ArrayBuffer) => void;
655
+ onClose: () => void;
656
+ }
657
+ | { error: string; code: number } {
658
+ const handler = handlers[channel];
659
+ if (!handler) {
660
+ return { error: `Unknown channel: ${channel}`, code: 4004 };
661
+ }
662
+
663
+ const connectionId = generateConnectionId();
664
+
665
+ const connection: WsConnection = {
666
+ id: connectionId,
667
+ channel,
668
+ send: sendFn,
669
+ close: closeFn,
670
+ meta: {},
671
+ isOpen: true,
672
+ };
673
+
674
+ connectionManager.add(connection);
675
+
676
+ const handlerCtx: WsHandlerContext = {
677
+ ctx,
678
+ send: sendFn,
679
+ close: closeFn,
680
+ channel,
681
+ meta: connection.meta,
682
+ };
683
+
684
+ // Fire onConnect
685
+ if (handler.onConnect) {
686
+ try {
687
+ const result = handler.onConnect(handlerCtx);
688
+ if (result instanceof Promise) {
689
+ result.catch(() => {
690
+ connection.isOpen = false;
691
+ connectionManager.remove(connectionId);
692
+ closeFn(1011, "Connection handler error");
693
+ });
694
+ }
695
+ } catch {
696
+ connection.isOpen = false;
697
+ connectionManager.remove(connectionId);
698
+ return { error: "Connection handler error", code: 1011 };
699
+ }
700
+ }
701
+
702
+ return {
703
+ connectionId,
704
+ onMessage: (raw: string | ArrayBuffer) => {
705
+ const parsed = parseWsMessage(raw);
706
+ if (parsed.error) {
707
+ sendFn({ type: "error", message: parsed.error });
708
+ return;
709
+ }
710
+
711
+ // Validate against channel contract
712
+ const validation = validateWsMessage(channel, parsed.data, validators);
713
+ if (!validation.valid) {
714
+ sendFn({
715
+ type: "validation_error",
716
+ errors: validation.errors,
717
+ });
718
+ return;
719
+ }
720
+
721
+ // Dispatch to handler
722
+ if (handler.onMessage) {
723
+ try {
724
+ const msgCtx = { ...handlerCtx, data: parsed.data };
725
+ const result = handler.onMessage(msgCtx);
726
+ if (result instanceof Promise) {
727
+ result.catch(() => {
728
+ sendFn({ type: "error", message: "Message handler error" });
729
+ });
730
+ }
731
+ } catch {
732
+ sendFn({ type: "error", message: "Message handler error" });
733
+ }
734
+ }
735
+ },
736
+ onClose: () => {
737
+ connection.isOpen = false;
738
+ connectionManager.remove(connectionId);
739
+
740
+ if (handler.onDisconnect) {
741
+ try {
742
+ handler.onDisconnect(handlerCtx);
743
+ } catch {
744
+ // Swallow disconnect errors
745
+ }
746
+ }
747
+ },
748
+ };
749
+ }