@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.
@@ -0,0 +1,198 @@
1
+ import type { TypoKitPlugin } from "@typokit/core";
2
+ import type { SchemaTypeMap, GeneratedOutput, RequestContext, TypeMetadata } from "@typokit/types";
3
+ /** Describes message types for a single WebSocket channel */
4
+ export interface WsChannelContract {
5
+ /** Messages the server sends to connected clients */
6
+ serverToClient: unknown;
7
+ /** Messages the client sends to the server */
8
+ clientToServer: unknown;
9
+ }
10
+ /**
11
+ * Maps channel names to their typed message contracts.
12
+ * Users define this interface to describe their WS API.
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * interface MyChannels {
17
+ * "notifications": {
18
+ * serverToClient: { type: "alert"; message: string };
19
+ * clientToServer: { type: "subscribe"; topic: string };
20
+ * };
21
+ * }
22
+ * ```
23
+ */
24
+ export type WsChannels = Record<string, WsChannelContract>;
25
+ /** Context passed to WS handler callbacks */
26
+ export interface WsHandlerContext {
27
+ /** Standard request context (includes logger, services, requestId) */
28
+ ctx: RequestContext;
29
+ /** Send a typed message to the connected client */
30
+ send(data: unknown): void;
31
+ /** Close the WebSocket connection */
32
+ close(code?: number, reason?: string): void;
33
+ /** Channel name this handler belongs to */
34
+ channel: string;
35
+ /** Connection-specific metadata store */
36
+ meta: Record<string, unknown>;
37
+ }
38
+ /** Handler callbacks for a single WS channel */
39
+ export interface WsChannelHandler<TClientToServer = unknown, TServerToClient = unknown> {
40
+ /** Called when a client connects to this channel */
41
+ onConnect?(context: WsHandlerContext): Promise<void> | void;
42
+ /** Called when a validated message is received from the client */
43
+ onMessage?(context: WsHandlerContext & {
44
+ data: TClientToServer;
45
+ }): Promise<void> | void;
46
+ /** Called when the client disconnects */
47
+ onDisconnect?(context: WsHandlerContext): Promise<void> | void;
48
+ /** Type marker for server-to-client messages (compile-time only) */
49
+ _serverToClient?: TServerToClient;
50
+ }
51
+ /** Maps channel names to their handler definitions */
52
+ export type WsHandlerDefs<TChannels extends WsChannels = WsChannels> = {
53
+ [K in keyof TChannels]: WsChannelHandler<TChannels[K]["clientToServer"], TChannels[K]["serverToClient"]>;
54
+ };
55
+ /** Validates an incoming message against the channel's clientToServer contract */
56
+ export type WsValidatorFn = (data: unknown) => {
57
+ valid: boolean;
58
+ errors?: string[];
59
+ };
60
+ /** Maps channel names to their validator functions */
61
+ export type WsValidatorMap = Record<string, WsValidatorFn>;
62
+ /** Represents a single WebSocket connection */
63
+ export interface WsConnection {
64
+ /** Unique connection ID */
65
+ id: string;
66
+ /** Channel this connection belongs to */
67
+ channel: string;
68
+ /** Send a message to this client */
69
+ send(data: unknown): void;
70
+ /** Close the connection */
71
+ close(code?: number, reason?: string): void;
72
+ /** Connection metadata */
73
+ meta: Record<string, unknown>;
74
+ /** Whether the connection is open */
75
+ isOpen: boolean;
76
+ }
77
+ /** Extracted channel contract metadata from the type map */
78
+ export interface WsChannelInfo {
79
+ name: string;
80
+ serverToClientType: string;
81
+ clientToServerType: string;
82
+ properties: {
83
+ serverToClient: TypeMetadata | null;
84
+ clientToServer: TypeMetadata | null;
85
+ };
86
+ }
87
+ /** Options for the wsPlugin factory */
88
+ export interface WsPluginOptions {
89
+ /** Path prefix for WS upgrade endpoints (default: "/ws") */
90
+ pathPrefix?: string;
91
+ /** Maximum message size in bytes (default: 65536) */
92
+ maxMessageSize?: number;
93
+ /** Heartbeat interval in ms (default: 30000, 0 to disable) */
94
+ heartbeatInterval?: number;
95
+ /** Require authentication for WS connections (uses auth middleware) */
96
+ requireAuth?: boolean;
97
+ /** Custom validator map for message validation */
98
+ validators?: WsValidatorMap;
99
+ /** Channel handler definitions */
100
+ handlers?: WsHandlerDefs;
101
+ }
102
+ /**
103
+ * Define typed WebSocket handlers for a set of channels.
104
+ * Provides compile-time type checking for message types.
105
+ *
106
+ * @example
107
+ * ```typescript
108
+ * export default defineWsHandlers<MyChannels>({
109
+ * "notifications": {
110
+ * onConnect: async ({ ctx }) => { ... },
111
+ * onMessage: async ({ data, ctx }) => {
112
+ * // data is typed as MyChannels["notifications"]["clientToServer"]
113
+ * },
114
+ * onDisconnect: async ({ ctx }) => { ... },
115
+ * },
116
+ * });
117
+ * ```
118
+ */
119
+ export declare function defineWsHandlers<TChannels extends WsChannels>(handlers: WsHandlerDefs<TChannels>): WsHandlerDefs<TChannels>;
120
+ /**
121
+ * Extract WS channel contracts from the type map.
122
+ * Looks for types implementing the WsChannelContract pattern
123
+ * (types with serverToClient and clientToServer properties).
124
+ */
125
+ export declare function extractWsChannels(typeMap: SchemaTypeMap): WsChannelInfo[];
126
+ /**
127
+ * Generate validator code for WS channels.
128
+ * Produces a TypeScript file with runtime validation functions for incoming messages.
129
+ */
130
+ export declare function generateWsValidators(channels: WsChannelInfo[], outDir: string): GeneratedOutput;
131
+ /**
132
+ * Generate the WS route table mapping channel paths to metadata.
133
+ */
134
+ export declare function generateWsRouteTable(channels: WsChannelInfo[], outDir: string, pathPrefix: string): GeneratedOutput;
135
+ /** Manages active WebSocket connections across all channels */
136
+ export declare class WsConnectionManager {
137
+ private connections;
138
+ private channelConnections;
139
+ /** Register a new connection */
140
+ add(connection: WsConnection): void;
141
+ /** Remove a connection */
142
+ remove(connectionId: string): WsConnection | undefined;
143
+ /** Get a connection by ID */
144
+ get(connectionId: string): WsConnection | undefined;
145
+ /** Get all connections for a channel */
146
+ getByChannel(channel: string): WsConnection[];
147
+ /** Broadcast a message to all connections on a channel */
148
+ broadcast(channel: string, data: unknown): number;
149
+ /** Get count of active connections */
150
+ get size(): number;
151
+ /** Get count of connections on a specific channel */
152
+ channelSize(channel: string): number;
153
+ /** Close all connections */
154
+ closeAll(code?: number, reason?: string): void;
155
+ }
156
+ /**
157
+ * Validate an incoming message against the channel's validator.
158
+ * Returns validation result with errors if invalid.
159
+ */
160
+ export declare function validateWsMessage(channel: string, data: unknown, validators: WsValidatorMap): {
161
+ valid: boolean;
162
+ errors?: string[];
163
+ };
164
+ /**
165
+ * Parse a raw WebSocket message string into a typed object.
166
+ * Returns null if parsing fails.
167
+ */
168
+ export declare function parseWsMessage(raw: string | ArrayBuffer): {
169
+ data: unknown;
170
+ error?: string;
171
+ };
172
+ /**
173
+ * Create a WebSocket plugin that provides schema-first typed WebSocket channels.
174
+ *
175
+ * @example
176
+ * ```typescript
177
+ * import { createApp } from "@typokit/core";
178
+ * import { wsPlugin } from "@typokit/plugin-ws";
179
+ *
180
+ * const app = createApp({
181
+ * plugins: [wsPlugin({ pathPrefix: "/ws", requireAuth: true })],
182
+ * });
183
+ * ```
184
+ */
185
+ export declare function wsPlugin(options?: WsPluginOptions): TypoKitPlugin;
186
+ /**
187
+ * Handle an incoming WebSocket upgrade request.
188
+ * Validates the channel path, optionally checks auth, and registers the connection.
189
+ */
190
+ export declare function handleWsUpgrade(channel: string, connectionManager: WsConnectionManager, handlers: WsHandlerDefs, validators: WsValidatorMap, sendFn: (data: unknown) => void, closeFn: (code?: number, reason?: string) => void, ctx: RequestContext): {
191
+ connectionId: string;
192
+ onMessage: (raw: string | ArrayBuffer) => void;
193
+ onClose: () => void;
194
+ } | {
195
+ error: string;
196
+ code: number;
197
+ };
198
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js ADDED
@@ -0,0 +1,490 @@
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
+ // ─── defineWsHandlers ───────────────────────────────────────
6
+ /**
7
+ * Define typed WebSocket handlers for a set of channels.
8
+ * Provides compile-time type checking for message types.
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * export default defineWsHandlers<MyChannels>({
13
+ * "notifications": {
14
+ * onConnect: async ({ ctx }) => { ... },
15
+ * onMessage: async ({ data, ctx }) => {
16
+ * // data is typed as MyChannels["notifications"]["clientToServer"]
17
+ * },
18
+ * onDisconnect: async ({ ctx }) => { ... },
19
+ * },
20
+ * });
21
+ * ```
22
+ */
23
+ export function defineWsHandlers(handlers) {
24
+ return handlers;
25
+ }
26
+ // ─── Build-Time Utilities ───────────────────────────────────
27
+ /**
28
+ * Extract WS channel contracts from the type map.
29
+ * Looks for types implementing the WsChannelContract pattern
30
+ * (types with serverToClient and clientToServer properties).
31
+ */
32
+ export function extractWsChannels(typeMap) {
33
+ const channels = [];
34
+ for (const [typeName, meta] of Object.entries(typeMap)) {
35
+ const props = meta.properties;
36
+ if (!props)
37
+ continue;
38
+ // Check if this type has the WsChannels pattern: keys mapping to
39
+ // objects with serverToClient and clientToServer
40
+ if (meta.jsdoc?.["wsChannels"] === "true" ||
41
+ meta.jsdoc?.["ws"] === "true") {
42
+ // This type is a WsChannels map — each property is a channel
43
+ for (const [channelName, channelProp] of Object.entries(props)) {
44
+ const channelType = typeMap[channelProp.type];
45
+ if (channelType?.properties?.["serverToClient"] &&
46
+ channelType?.properties?.["clientToServer"]) {
47
+ channels.push({
48
+ name: channelName,
49
+ serverToClientType: channelType.properties["serverToClient"].type,
50
+ clientToServerType: channelType.properties["clientToServer"].type,
51
+ properties: {
52
+ serverToClient: typeMap[channelType.properties["serverToClient"].type] ?? null,
53
+ clientToServer: typeMap[channelType.properties["clientToServer"].type] ?? null,
54
+ },
55
+ });
56
+ }
57
+ }
58
+ }
59
+ // Also check if the type itself is a channel contract
60
+ if (props["serverToClient"] && props["clientToServer"]) {
61
+ // Use the JSDoc @channel tag or the type name as the channel name
62
+ const channelName = meta.jsdoc?.["channel"] ?? typeName;
63
+ channels.push({
64
+ name: channelName,
65
+ serverToClientType: props["serverToClient"].type,
66
+ clientToServerType: props["clientToServer"].type,
67
+ properties: {
68
+ serverToClient: typeMap[props["serverToClient"].type] ?? null,
69
+ clientToServer: typeMap[props["clientToServer"].type] ?? null,
70
+ },
71
+ });
72
+ }
73
+ }
74
+ return channels;
75
+ }
76
+ /**
77
+ * Generate validator code for WS channels.
78
+ * Produces a TypeScript file with runtime validation functions for incoming messages.
79
+ */
80
+ export function generateWsValidators(channels, outDir) {
81
+ const lines = [
82
+ "// Auto-generated by @typokit/plugin-ws — do not edit",
83
+ "// Validates incoming WebSocket messages against channel contracts",
84
+ "",
85
+ "export type WsValidatorFn = (data: unknown) => { valid: boolean; errors?: string[] };",
86
+ "",
87
+ "export const wsValidators: Record<string, WsValidatorFn> = {",
88
+ ];
89
+ for (const channel of channels) {
90
+ lines.push(` "${channel.name}": (data: unknown) => {`);
91
+ lines.push(" if (data === null || data === undefined) {");
92
+ lines.push(' return { valid: false, errors: ["Message must not be null or undefined"] };');
93
+ lines.push(" }");
94
+ lines.push(' if (typeof data !== "object") {');
95
+ lines.push(' return { valid: false, errors: ["Message must be an object"] };');
96
+ lines.push(" }");
97
+ // If we have property metadata for clientToServer, generate property checks
98
+ if (channel.properties.clientToServer) {
99
+ const props = channel.properties.clientToServer.properties;
100
+ for (const [propName, propMeta] of Object.entries(props)) {
101
+ if (!propMeta.optional) {
102
+ lines.push(` if (!("${propName}" in (data as Record<string, unknown>))) {`);
103
+ lines.push(` return { valid: false, errors: ["Missing required field: ${propName}"] };`);
104
+ lines.push(" }");
105
+ }
106
+ }
107
+ }
108
+ lines.push(" return { valid: true };");
109
+ lines.push(" },");
110
+ }
111
+ lines.push("};");
112
+ lines.push("");
113
+ return {
114
+ filePath: `${outDir}/ws-validators.ts`,
115
+ content: lines.join("\n"),
116
+ overwrite: true,
117
+ };
118
+ }
119
+ /**
120
+ * Generate the WS route table mapping channel paths to metadata.
121
+ */
122
+ export function generateWsRouteTable(channels, outDir, pathPrefix) {
123
+ const lines = [
124
+ "// Auto-generated by @typokit/plugin-ws — do not edit",
125
+ "// WebSocket channel route table",
126
+ "",
127
+ "export interface WsRouteEntry {",
128
+ " channel: string;",
129
+ " path: string;",
130
+ " serverToClientType: string;",
131
+ " clientToServerType: string;",
132
+ "}",
133
+ "",
134
+ "export const wsRouteTable: WsRouteEntry[] = [",
135
+ ];
136
+ for (const channel of channels) {
137
+ lines.push(" {");
138
+ lines.push(` channel: "${channel.name}",`);
139
+ lines.push(` path: "${pathPrefix}/${channel.name}",`);
140
+ lines.push(` serverToClientType: "${channel.serverToClientType}",`);
141
+ lines.push(` clientToServerType: "${channel.clientToServerType}",`);
142
+ lines.push(" },");
143
+ }
144
+ lines.push("];");
145
+ lines.push("");
146
+ return {
147
+ filePath: `${outDir}/ws-route-table.ts`,
148
+ content: lines.join("\n"),
149
+ overwrite: true,
150
+ };
151
+ }
152
+ // ─── WS Connection Manager ─────────────────────────────────
153
+ /** Manages active WebSocket connections across all channels */
154
+ export class WsConnectionManager {
155
+ connections = new Map();
156
+ channelConnections = new Map();
157
+ /** Register a new connection */
158
+ add(connection) {
159
+ this.connections.set(connection.id, connection);
160
+ let channelSet = this.channelConnections.get(connection.channel);
161
+ if (!channelSet) {
162
+ channelSet = new Set();
163
+ this.channelConnections.set(connection.channel, channelSet);
164
+ }
165
+ channelSet.add(connection.id);
166
+ }
167
+ /** Remove a connection */
168
+ remove(connectionId) {
169
+ const conn = this.connections.get(connectionId);
170
+ if (conn) {
171
+ this.connections.delete(connectionId);
172
+ const channelSet = this.channelConnections.get(conn.channel);
173
+ if (channelSet) {
174
+ channelSet.delete(connectionId);
175
+ if (channelSet.size === 0) {
176
+ this.channelConnections.delete(conn.channel);
177
+ }
178
+ }
179
+ }
180
+ return conn;
181
+ }
182
+ /** Get a connection by ID */
183
+ get(connectionId) {
184
+ return this.connections.get(connectionId);
185
+ }
186
+ /** Get all connections for a channel */
187
+ getByChannel(channel) {
188
+ const ids = this.channelConnections.get(channel);
189
+ if (!ids)
190
+ return [];
191
+ const results = [];
192
+ for (const id of ids) {
193
+ const conn = this.connections.get(id);
194
+ if (conn)
195
+ results.push(conn);
196
+ }
197
+ return results;
198
+ }
199
+ /** Broadcast a message to all connections on a channel */
200
+ broadcast(channel, data) {
201
+ const connections = this.getByChannel(channel);
202
+ let sent = 0;
203
+ for (const conn of connections) {
204
+ if (conn.isOpen) {
205
+ conn.send(data);
206
+ sent++;
207
+ }
208
+ }
209
+ return sent;
210
+ }
211
+ /** Get count of active connections */
212
+ get size() {
213
+ return this.connections.size;
214
+ }
215
+ /** Get count of connections on a specific channel */
216
+ channelSize(channel) {
217
+ return this.channelConnections.get(channel)?.size ?? 0;
218
+ }
219
+ /** Close all connections */
220
+ closeAll(code, reason) {
221
+ for (const conn of this.connections.values()) {
222
+ if (conn.isOpen) {
223
+ conn.close(code, reason);
224
+ }
225
+ }
226
+ this.connections.clear();
227
+ this.channelConnections.clear();
228
+ }
229
+ }
230
+ // ─── Message Validation ─────────────────────────────────────
231
+ /**
232
+ * Validate an incoming message against the channel's validator.
233
+ * Returns validation result with errors if invalid.
234
+ */
235
+ export function validateWsMessage(channel, data, validators) {
236
+ const validator = validators[channel];
237
+ if (!validator) {
238
+ // No validator registered — accept all messages
239
+ return { valid: true };
240
+ }
241
+ return validator(data);
242
+ }
243
+ /**
244
+ * Parse a raw WebSocket message string into a typed object.
245
+ * Returns null if parsing fails.
246
+ */
247
+ export function parseWsMessage(raw) {
248
+ if (raw instanceof ArrayBuffer) {
249
+ try {
250
+ const Decoder = globalThis.TextDecoder;
251
+ const text = new Decoder().decode(raw);
252
+ return { data: JSON.parse(text) };
253
+ }
254
+ catch {
255
+ return { data: null, error: "Failed to decode binary message as JSON" };
256
+ }
257
+ }
258
+ if (typeof raw === "string") {
259
+ try {
260
+ return { data: JSON.parse(raw) };
261
+ }
262
+ catch {
263
+ return { data: null, error: "Failed to parse message as JSON" };
264
+ }
265
+ }
266
+ return { data: null, error: "Unsupported message format" };
267
+ }
268
+ // ─── ID Generation ──────────────────────────────────────────
269
+ let connectionCounter = 0;
270
+ function generateConnectionId() {
271
+ const timestamp = Date.now().toString(36);
272
+ const counter = (connectionCounter++).toString(36);
273
+ const random = Math.random().toString(36).substring(2, 8);
274
+ return `ws_${timestamp}_${counter}_${random}`;
275
+ }
276
+ // ─── Plugin Factory ─────────────────────────────────────────
277
+ /**
278
+ * Create a WebSocket plugin that provides schema-first typed WebSocket channels.
279
+ *
280
+ * @example
281
+ * ```typescript
282
+ * import { createApp } from "@typokit/core";
283
+ * import { wsPlugin } from "@typokit/plugin-ws";
284
+ *
285
+ * const app = createApp({
286
+ * plugins: [wsPlugin({ pathPrefix: "/ws", requireAuth: true })],
287
+ * });
288
+ * ```
289
+ */
290
+ export function wsPlugin(options = {}) {
291
+ const pathPrefix = options.pathPrefix ?? "/ws";
292
+ const maxMessageSize = options.maxMessageSize ?? 65536;
293
+ const heartbeatInterval = options.heartbeatInterval ?? 30_000;
294
+ const requireAuth = options.requireAuth ?? false;
295
+ const validators = { ...options.validators };
296
+ const handlers = options.handlers ?? {};
297
+ // Connection manager shared across the plugin
298
+ const connectionManager = new WsConnectionManager();
299
+ // WS channel info extracted at build time
300
+ let channelInfos = [];
301
+ // Heartbeat timer
302
+ const _setInterval = globalThis.setInterval;
303
+ const _clearInterval = globalThis.clearInterval;
304
+ let heartbeatTimer = null;
305
+ const plugin = {
306
+ name: "plugin-ws",
307
+ onBuild(pipeline) {
308
+ // After types are parsed, extract WebSocket channel contracts
309
+ pipeline.hooks.afterTypeParse.tap("ws-plugin", (typeMap, _ctx) => {
310
+ channelInfos = extractWsChannels(typeMap);
311
+ });
312
+ // At emit phase, generate WS validators and route tables
313
+ pipeline.hooks.emit.tap("ws-plugin", (outputs, ctx) => {
314
+ if (channelInfos.length > 0) {
315
+ outputs.push(generateWsValidators(channelInfos, ctx.outDir), generateWsRouteTable(channelInfos, ctx.outDir, pathPrefix));
316
+ }
317
+ });
318
+ },
319
+ async onStart(app) {
320
+ // Expose WS service for other plugins and handlers
321
+ app.services["_ws"] = {
322
+ /** Get the connection manager */
323
+ getConnectionManager: () => connectionManager,
324
+ /** Send a message to a specific connection */
325
+ send: (connectionId, data) => {
326
+ const conn = connectionManager.get(connectionId);
327
+ if (conn?.isOpen) {
328
+ conn.send(data);
329
+ return true;
330
+ }
331
+ return false;
332
+ },
333
+ /** Broadcast a message to all connections on a channel */
334
+ broadcast: (channel, data) => {
335
+ return connectionManager.broadcast(channel, data);
336
+ },
337
+ /** Get active connection count */
338
+ getConnectionCount: (channel) => {
339
+ if (channel)
340
+ return connectionManager.channelSize(channel);
341
+ return connectionManager.size;
342
+ },
343
+ /** Register a validator for a channel */
344
+ registerValidator: (channel, validator) => {
345
+ validators[channel] = validator;
346
+ },
347
+ /** Register handlers for channels */
348
+ registerHandlers: (newHandlers) => {
349
+ Object.assign(handlers, newHandlers);
350
+ },
351
+ /** Get channel infos extracted at build time */
352
+ getChannelInfos: () => channelInfos,
353
+ /** Plugin config */
354
+ config: {
355
+ pathPrefix,
356
+ maxMessageSize,
357
+ heartbeatInterval,
358
+ requireAuth,
359
+ },
360
+ };
361
+ },
362
+ async onReady(_app) {
363
+ // Start heartbeat timer if configured
364
+ if (heartbeatInterval > 0) {
365
+ heartbeatTimer = _setInterval(() => {
366
+ // Ping all connections to keep them alive
367
+ for (const channel of Object.keys(handlers)) {
368
+ const connections = connectionManager.getByChannel(channel);
369
+ for (const conn of connections) {
370
+ if (!conn.isOpen) {
371
+ connectionManager.remove(conn.id);
372
+ }
373
+ }
374
+ }
375
+ }, heartbeatInterval);
376
+ }
377
+ },
378
+ onError(error, _ctx) {
379
+ // Log WS-related errors for debugging
380
+ void error;
381
+ },
382
+ async onStop(_app) {
383
+ // Stop heartbeat
384
+ if (heartbeatTimer) {
385
+ _clearInterval(heartbeatTimer);
386
+ heartbeatTimer = null;
387
+ }
388
+ // Close all connections
389
+ connectionManager.closeAll(1001, "Server shutting down");
390
+ },
391
+ onSchemaChange(_changes) {
392
+ // Channel contracts may have changed — clear cached infos
393
+ // They'll be re-extracted on next build
394
+ channelInfos = [];
395
+ },
396
+ };
397
+ return plugin;
398
+ }
399
+ /**
400
+ * Handle an incoming WebSocket upgrade request.
401
+ * Validates the channel path, optionally checks auth, and registers the connection.
402
+ */
403
+ export function handleWsUpgrade(channel, connectionManager, handlers, validators, sendFn, closeFn, ctx) {
404
+ const handler = handlers[channel];
405
+ if (!handler) {
406
+ return { error: `Unknown channel: ${channel}`, code: 4004 };
407
+ }
408
+ const connectionId = generateConnectionId();
409
+ const connection = {
410
+ id: connectionId,
411
+ channel,
412
+ send: sendFn,
413
+ close: closeFn,
414
+ meta: {},
415
+ isOpen: true,
416
+ };
417
+ connectionManager.add(connection);
418
+ const handlerCtx = {
419
+ ctx,
420
+ send: sendFn,
421
+ close: closeFn,
422
+ channel,
423
+ meta: connection.meta,
424
+ };
425
+ // Fire onConnect
426
+ if (handler.onConnect) {
427
+ try {
428
+ const result = handler.onConnect(handlerCtx);
429
+ if (result instanceof Promise) {
430
+ result.catch(() => {
431
+ connection.isOpen = false;
432
+ connectionManager.remove(connectionId);
433
+ closeFn(1011, "Connection handler error");
434
+ });
435
+ }
436
+ }
437
+ catch {
438
+ connection.isOpen = false;
439
+ connectionManager.remove(connectionId);
440
+ return { error: "Connection handler error", code: 1011 };
441
+ }
442
+ }
443
+ return {
444
+ connectionId,
445
+ onMessage: (raw) => {
446
+ const parsed = parseWsMessage(raw);
447
+ if (parsed.error) {
448
+ sendFn({ type: "error", message: parsed.error });
449
+ return;
450
+ }
451
+ // Validate against channel contract
452
+ const validation = validateWsMessage(channel, parsed.data, validators);
453
+ if (!validation.valid) {
454
+ sendFn({
455
+ type: "validation_error",
456
+ errors: validation.errors,
457
+ });
458
+ return;
459
+ }
460
+ // Dispatch to handler
461
+ if (handler.onMessage) {
462
+ try {
463
+ const msgCtx = { ...handlerCtx, data: parsed.data };
464
+ const result = handler.onMessage(msgCtx);
465
+ if (result instanceof Promise) {
466
+ result.catch(() => {
467
+ sendFn({ type: "error", message: "Message handler error" });
468
+ });
469
+ }
470
+ }
471
+ catch {
472
+ sendFn({ type: "error", message: "Message handler error" });
473
+ }
474
+ }
475
+ },
476
+ onClose: () => {
477
+ connection.isOpen = false;
478
+ connectionManager.remove(connectionId);
479
+ if (handler.onDisconnect) {
480
+ try {
481
+ handler.onDisconnect(handlerCtx);
482
+ }
483
+ catch {
484
+ // Swallow disconnect errors
485
+ }
486
+ }
487
+ },
488
+ };
489
+ }
490
+ //# sourceMappingURL=index.js.map