raffel 1.0.9 → 1.0.10-next.22f4d4e

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.
Files changed (76) hide show
  1. package/dist/adapters/index.d.ts +1 -1
  2. package/dist/adapters/index.d.ts.map +1 -1
  3. package/dist/adapters/websocket.d.ts +100 -1
  4. package/dist/adapters/websocket.d.ts.map +1 -1
  5. package/dist/adapters/websocket.js +428 -14
  6. package/dist/adapters/websocket.js.map +1 -1
  7. package/dist/channels/channel-manager.d.ts.map +1 -1
  8. package/dist/channels/channel-manager.js +265 -4
  9. package/dist/channels/channel-manager.js.map +1 -1
  10. package/dist/channels/history.d.ts +54 -0
  11. package/dist/channels/history.d.ts.map +1 -0
  12. package/dist/channels/history.js +78 -0
  13. package/dist/channels/history.js.map +1 -0
  14. package/dist/channels/index.d.ts +9 -1
  15. package/dist/channels/index.d.ts.map +1 -1
  16. package/dist/channels/index.js +8 -1
  17. package/dist/channels/index.js.map +1 -1
  18. package/dist/channels/recovery.d.ts +57 -0
  19. package/dist/channels/recovery.d.ts.map +1 -0
  20. package/dist/channels/recovery.js +58 -0
  21. package/dist/channels/recovery.js.map +1 -0
  22. package/dist/channels/rest-api.d.ts +32 -0
  23. package/dist/channels/rest-api.d.ts.map +1 -0
  24. package/dist/channels/rest-api.js +197 -0
  25. package/dist/channels/rest-api.js.map +1 -0
  26. package/dist/channels/ticket-store.d.ts +25 -0
  27. package/dist/channels/ticket-store.d.ts.map +1 -0
  28. package/dist/channels/ticket-store.js +70 -0
  29. package/dist/channels/ticket-store.js.map +1 -0
  30. package/dist/channels/types.d.ts +267 -5
  31. package/dist/channels/types.d.ts.map +1 -1
  32. package/dist/channels/types.js +15 -1
  33. package/dist/channels/types.js.map +1 -1
  34. package/dist/client/index.d.ts +39 -0
  35. package/dist/client/index.d.ts.map +1 -0
  36. package/dist/client/index.js +531 -0
  37. package/dist/client/index.js.map +1 -0
  38. package/dist/client/reconnect.d.ts +33 -0
  39. package/dist/client/reconnect.d.ts.map +1 -0
  40. package/dist/client/reconnect.js +60 -0
  41. package/dist/client/reconnect.js.map +1 -0
  42. package/dist/client/types.d.ts +107 -0
  43. package/dist/client/types.d.ts.map +1 -0
  44. package/dist/client/types.js +5 -0
  45. package/dist/client/types.js.map +1 -0
  46. package/dist/index.d.ts +9 -4
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +10 -2
  49. package/dist/index.js.map +1 -1
  50. package/dist/middleware/interceptors/field-filter.d.ts +51 -0
  51. package/dist/middleware/interceptors/field-filter.d.ts.map +1 -0
  52. package/dist/middleware/interceptors/field-filter.js +81 -0
  53. package/dist/middleware/interceptors/field-filter.js.map +1 -0
  54. package/dist/middleware/interceptors/guard.d.ts +51 -0
  55. package/dist/middleware/interceptors/guard.d.ts.map +1 -0
  56. package/dist/middleware/interceptors/guard.js +45 -0
  57. package/dist/middleware/interceptors/guard.js.map +1 -0
  58. package/dist/middleware/interceptors/index.d.ts +4 -0
  59. package/dist/middleware/interceptors/index.d.ts.map +1 -1
  60. package/dist/middleware/interceptors/index.js +4 -0
  61. package/dist/middleware/interceptors/index.js.map +1 -1
  62. package/dist/resource-module/index.d.ts +44 -0
  63. package/dist/resource-module/index.d.ts.map +1 -0
  64. package/dist/resource-module/index.js +146 -0
  65. package/dist/resource-module/index.js.map +1 -0
  66. package/dist/resource-module/types.d.ts +109 -0
  67. package/dist/resource-module/types.d.ts.map +1 -0
  68. package/dist/resource-module/types.js +7 -0
  69. package/dist/resource-module/types.js.map +1 -0
  70. package/dist/resource-module/watch-helpers.d.ts +16 -0
  71. package/dist/resource-module/watch-helpers.d.ts.map +1 -0
  72. package/dist/resource-module/watch-helpers.js +65 -0
  73. package/dist/resource-module/watch-helpers.js.map +1 -0
  74. package/dist/server/types.d.ts +27 -0
  75. package/dist/server/types.d.ts.map +1 -1
  76. package/package.json +9 -1
@@ -10,7 +10,9 @@ import { mergeContextSeeds } from '../types/index.js';
10
10
  import { createAbortableContextAsync } from '../utils/context-utils.js';
11
11
  import { createLogger } from '../utils/logger.js';
12
12
  import { extractMetadataFromHeaders, mergeMetadata, sanitizeMetadataRecord, } from '../utils/header-metadata.js';
13
- import { createChannelManager, isChannelMessage, } from '../channels/index.js';
13
+ import { createChannelManager, isChannelMessage, isRecoverMessage, } from '../channels/index.js';
14
+ import { createMemoryRecoveryStore, generateRecoveryToken, } from '../channels/recovery.js';
15
+ import { createMemoryTicketStore } from '../channels/ticket-store.js';
14
16
  import { checkWebSocketConnectionFilter, } from './utils/connection-filter.js';
15
17
  const logger = createLogger('ws-adapter');
16
18
  /**
@@ -25,11 +27,50 @@ export function createWebSocketAdapter(router, options) {
25
27
  let wss = null;
26
28
  let heartbeatTimer = null;
27
29
  const clients = new Map();
30
+ // Backpressure config
31
+ const bpMaxBuffered = options.backpressure?.maxBufferedAmount ?? 1024 * 1024;
32
+ const bpStrategy = options.backpressure?.strategy ?? 'drop';
33
+ // Compression config
34
+ const perMessageDeflate = (() => {
35
+ if (!options.compression)
36
+ return false;
37
+ if (options.compression === true) {
38
+ return { threshold: 1024, zlibDeflateOptions: { level: 1 } };
39
+ }
40
+ return {
41
+ threshold: options.compression.threshold ?? 1024,
42
+ zlibDeflateOptions: { level: options.compression.level ?? 1 },
43
+ };
44
+ })();
45
+ // Auth config
46
+ const authConfig = options.auth;
47
+ // Ensure ticket store exists for ticket mode
48
+ if (authConfig?.mode === 'ticket' && !authConfig.ticketStore) {
49
+ authConfig.ticketStore = createMemoryTicketStore();
50
+ }
51
+ // Recovery store (only when channels + recovery are enabled)
52
+ const recoveryStore = options.channels && options.recovery?.enabled
53
+ ? options.recovery.store ?? createMemoryRecoveryStore({ ttl: options.recovery.ttl })
54
+ : null;
55
+ /** Map socketId → recoveryToken (sent to client on connect) */
56
+ const clientRecoveryTokens = new Map();
28
57
  // Create channel manager if channels are enabled
29
58
  const channelManager = options.channels
30
59
  ? createChannelManager(options.channels, (socketId, message) => {
31
60
  const client = clients.get(socketId);
32
61
  if (client && client.ws.readyState === WebSocket.OPEN) {
62
+ // Backpressure check on channel sends
63
+ if (options.backpressure && client.ws.bufferedAmount > bpMaxBuffered) {
64
+ if (bpStrategy === 'disconnect') {
65
+ options.backpressure.onSlowConsumer?.(socketId, client.ws.bufferedAmount);
66
+ client.ws.close(1008, 'Slow consumer');
67
+ }
68
+ else {
69
+ options.backpressure.onSlowConsumer?.(socketId, client.ws.bufferedAmount);
70
+ // Drop silently
71
+ }
72
+ return;
73
+ }
33
74
  client.ws.send(JSON.stringify(message));
34
75
  }
35
76
  })
@@ -50,12 +91,31 @@ export function createWebSocketAdapter(router, options) {
50
91
  },
51
92
  };
52
93
  }
94
+ /**
95
+ * Check backpressure before sending
96
+ */
97
+ function checkBackpressure(client) {
98
+ if (!options.backpressure)
99
+ return true;
100
+ if (client.ws.bufferedAmount <= bpMaxBuffered)
101
+ return true;
102
+ if (bpStrategy === 'disconnect') {
103
+ options.backpressure.onSlowConsumer?.(client.id, client.ws.bufferedAmount);
104
+ client.ws.close(1008, 'Slow consumer');
105
+ }
106
+ else {
107
+ options.backpressure.onSlowConsumer?.(client.id, client.ws.bufferedAmount);
108
+ }
109
+ return false;
110
+ }
53
111
  /**
54
112
  * Send a raw message to client (for channel responses)
55
113
  */
56
114
  function sendRawMessage(client, message) {
57
115
  if (client.ws.readyState !== WebSocket.OPEN)
58
116
  return;
117
+ if (!checkBackpressure(client))
118
+ return;
59
119
  client.ws.send(JSON.stringify(message));
60
120
  }
61
121
  /**
@@ -68,10 +128,10 @@ export function createWebSocketAdapter(router, options) {
68
128
  return false;
69
129
  const messageType = parsed.type;
70
130
  const metadata = mergeMetadata(client.connectionMetadata, sanitizeMetadataRecord(parsed.metadata));
71
- const ctx = await createAbortableContextAsync(sid(), mergeContextSeeds(buildWebSocketSeed(client, metadata, parsed), await options.contextFactory?.(client.ws, client.request)), new AbortController());
131
+ const ctx = await createAbortableContextAsync(sid(), mergeContextSeeds(mergeContextSeeds(buildWebSocketSeed(client, metadata, parsed), client.authSeed), await options.contextFactory?.(client.ws, client.request)), new AbortController());
72
132
  if (messageType === 'subscribe') {
73
133
  const msg = parsed;
74
- const result = await channelManager.subscribe(client.id, msg.channel, ctx);
134
+ const result = await channelManager.subscribe(client.id, msg.channel, ctx, msg.since);
75
135
  if (result.success) {
76
136
  sendRawMessage(client, {
77
137
  id: msg.id,
@@ -79,6 +139,10 @@ export function createWebSocketAdapter(router, options) {
79
139
  channel: msg.channel,
80
140
  members: result.members,
81
141
  });
142
+ // Replay history after subscribed response (if since is provided)
143
+ if (msg.since) {
144
+ channelManager.replayHistory(client.id, msg.channel, msg.since.seq, msg.since.epoch);
145
+ }
82
146
  }
83
147
  else {
84
148
  sendRawMessage(client, {
@@ -114,7 +178,7 @@ export function createWebSocketAdapter(router, options) {
114
178
  });
115
179
  return true;
116
180
  }
117
- // Check onPublish hook if provided
181
+ // Check onPublish hook if provided (ChannelOptions.onPublish — authorization)
118
182
  if (options.channels?.onPublish) {
119
183
  const allowed = await options.channels.onPublish(client.id, msg.channel, msg.event, msg.data, ctx);
120
184
  if (!allowed) {
@@ -128,8 +192,75 @@ export function createWebSocketAdapter(router, options) {
128
192
  return true;
129
193
  }
130
194
  }
195
+ // Apply transform if configured
196
+ let finalData = msg.data;
197
+ if (options.channels?.transform) {
198
+ const clientInfo = channelManager.getClient(client.id);
199
+ const transformed = await options.channels.transform(msg.channel, msg.event, msg.data, { socketId: client.id, userId: clientInfo?.userId });
200
+ if (transformed === null) {
201
+ // Message dropped by transform
202
+ return true;
203
+ }
204
+ finalData = transformed;
205
+ }
131
206
  // Broadcast to all subscribers except sender
132
- channelManager.broadcast(msg.channel, msg.event, msg.data, client.id);
207
+ channelManager.broadcast(msg.channel, msg.event, finalData, client.id);
208
+ // Lifecycle hook: onPublish (notification, not authorization)
209
+ if (options.channels?.hooks?.onPublish) {
210
+ Promise.resolve(options.channels.hooks.onPublish(client.id, msg.channel, msg.event, finalData)).catch(() => { });
211
+ }
212
+ return true;
213
+ }
214
+ // ─── Batch Subscribe ─────────────────────────────────────────────────────
215
+ if (messageType === 'subscribe:batch') {
216
+ const msg = parsed;
217
+ const results = {};
218
+ for (const entry of msg.channels) {
219
+ const result = await channelManager.subscribe(client.id, entry.channel, ctx, entry.since);
220
+ results[entry.channel] = result;
221
+ if (result.success && entry.since) {
222
+ channelManager.replayHistory(client.id, entry.channel, entry.since.seq, entry.since.epoch);
223
+ }
224
+ }
225
+ sendRawMessage(client, {
226
+ id: msg.id,
227
+ type: 'subscribed:batch',
228
+ results,
229
+ });
230
+ return true;
231
+ }
232
+ // ─── Batch Publish ───────────────────────────────────────────────────────
233
+ if (messageType === 'publish:batch') {
234
+ const msg = parsed;
235
+ for (const entry of msg.messages) {
236
+ if (!channelManager.isSubscribed(client.id, entry.channel))
237
+ continue;
238
+ // Check onPublish hook
239
+ if (options.channels?.onPublish) {
240
+ const allowed = await options.channels.onPublish(client.id, entry.channel, entry.event, entry.data, ctx);
241
+ if (!allowed)
242
+ continue;
243
+ }
244
+ // Apply transform
245
+ let finalData = entry.data;
246
+ if (options.channels?.transform) {
247
+ const clientInfo = channelManager.getClient(client.id);
248
+ const transformed = await options.channels.transform(entry.channel, entry.event, entry.data, { socketId: client.id, userId: clientInfo?.userId });
249
+ if (transformed === null)
250
+ continue;
251
+ finalData = transformed;
252
+ }
253
+ channelManager.broadcast(entry.channel, entry.event, finalData, client.id);
254
+ if (options.channels?.hooks?.onPublish) {
255
+ Promise.resolve(options.channels.hooks.onPublish(client.id, entry.channel, entry.event, finalData)).catch(() => { });
256
+ }
257
+ }
258
+ return true;
259
+ }
260
+ // ─── Typing Indicators ───────────────────────────────────────────────────
261
+ if (messageType === 'typing') {
262
+ const msg = parsed;
263
+ channelManager.handleTyping(client.id, msg.channel, msg.isTyping);
133
264
  return true;
134
265
  }
135
266
  return false;
@@ -138,6 +269,18 @@ export function createWebSocketAdapter(router, options) {
138
269
  * Handle incoming message from client
139
270
  */
140
271
  async function handleMessage(client, data) {
272
+ // Custom protocol hook — intercept before any Raffel processing
273
+ if (options.onMessage) {
274
+ const sendFn = (msg) => sendRawMessage(client, msg);
275
+ try {
276
+ const handled = await options.onMessage(client.id, data, sendFn);
277
+ if (handled)
278
+ return;
279
+ }
280
+ catch (err) {
281
+ logger.error({ err, clientId: client.id }, 'onMessage hook error');
282
+ }
283
+ }
141
284
  let envelope;
142
285
  try {
143
286
  // Parse JSON
@@ -146,6 +289,52 @@ export function createWebSocketAdapter(router, options) {
146
289
  if (handleCancelMessage(client, parsed)) {
147
290
  return;
148
291
  }
292
+ // Handle auth:refresh
293
+ if (parsed.type === 'auth:refresh') {
294
+ await handleAuthRefresh(client, parsed);
295
+ return;
296
+ }
297
+ // Handle recovery message
298
+ if (recoveryStore && channelManager && isRecoverMessage(parsed)) {
299
+ const session = recoveryStore.get(parsed.recoveryToken);
300
+ if (session) {
301
+ recoveryStore.delete(parsed.recoveryToken);
302
+ // Re-register with previous user info
303
+ const existingClient = channelManager.getClient(client.id);
304
+ if (!existingClient) {
305
+ channelManager.registerClient(client.id, {
306
+ userId: session.userId,
307
+ data: session.metadata,
308
+ });
309
+ }
310
+ // Recover channel subscriptions
311
+ channelManager.recoverClient(session.socketId, client.id, session.channels);
312
+ // Re-join groups
313
+ for (const groupName of session.groups) {
314
+ channelManager.joinGroup(groupName, client.id);
315
+ }
316
+ // Generate new recovery token for this session
317
+ const newToken = generateRecoveryToken();
318
+ clientRecoveryTokens.set(client.id, newToken);
319
+ sendRawMessage(client, {
320
+ type: 'connection:recovered',
321
+ socketId: client.id,
322
+ recoveryToken: newToken,
323
+ channels: session.channels.map((c) => c.name),
324
+ groups: session.groups,
325
+ });
326
+ logger.info({ clientId: client.id, oldSocketId: session.socketId }, 'Client recovered');
327
+ }
328
+ else {
329
+ sendRawMessage(client, {
330
+ type: 'error',
331
+ code: 'RECOVERY_FAILED',
332
+ status: 404,
333
+ message: 'Recovery token not found or expired',
334
+ });
335
+ }
336
+ return;
337
+ }
149
338
  // Check if this is a channel message
150
339
  if (await handleChannelMessage(client, parsed)) {
151
340
  return;
@@ -159,8 +348,8 @@ export function createWebSocketAdapter(router, options) {
159
348
  const messageId = parsed.id !== undefined ? String(parsed.id) : sid();
160
349
  const requestId = incomingMetadata['x-request-id'] ?? messageId;
161
350
  const abortController = new AbortController();
162
- // Build context
163
- const ctx = await createAbortableContextAsync(requestId, mergeContextSeeds(buildWebSocketSeed(client, incomingMetadata, parsed.payload ?? {}), await options.contextFactory?.(client.ws, client.request)), abortController);
351
+ // Build context (merge: ws seed → auth seed → contextFactory)
352
+ const ctx = await createAbortableContextAsync(requestId, mergeContextSeeds(mergeContextSeeds(buildWebSocketSeed(client, incomingMetadata, parsed.payload ?? {}), client.authSeed), await options.contextFactory?.(client.ws, client.request)), abortController);
164
353
  const deadline = incomingMetadata['x-deadline']
165
354
  ? Number.parseInt(incomingMetadata['x-deadline'], 10)
166
355
  : NaN;
@@ -232,6 +421,8 @@ export function createWebSocketAdapter(router, options) {
232
421
  function sendEnvelope(client, envelope) {
233
422
  if (client.ws.readyState !== WebSocket.OPEN)
234
423
  return;
424
+ if (!checkBackpressure(client))
425
+ return;
235
426
  const message = JSON.stringify({
236
427
  id: envelope.id,
237
428
  procedure: envelope.procedure,
@@ -278,10 +469,96 @@ export function createWebSocketAdapter(router, options) {
278
469
  }
279
470
  return true;
280
471
  }
472
+ /**
473
+ * Handle auth:refresh message — update auth context mid-connection
474
+ */
475
+ async function handleAuthRefresh(client, parsed) {
476
+ const token = parsed.token;
477
+ const msgId = parsed.id;
478
+ if (!token) {
479
+ sendRawMessage(client, { id: msgId, type: 'error', code: 'INVALID_TOKEN', status: 400, message: 'Token required' });
480
+ return;
481
+ }
482
+ if (!authConfig?.refreshToken) {
483
+ sendRawMessage(client, { id: msgId, type: 'error', code: 'NOT_SUPPORTED', status: 501, message: 'Token refresh not configured' });
484
+ return;
485
+ }
486
+ try {
487
+ const newSeed = await authConfig.refreshToken(token);
488
+ if (!newSeed) {
489
+ sendRawMessage(client, { id: msgId, type: 'error', code: 'AUTH_FAILED', status: 401, message: 'Invalid refresh token' });
490
+ return;
491
+ }
492
+ // Update connection metadata with new auth info
493
+ if (newSeed.input?.metadata) {
494
+ client.connectionMetadata = { ...client.connectionMetadata, ...newSeed.input.metadata };
495
+ }
496
+ logger.info({ clientId: client.id }, 'Auth token refreshed');
497
+ sendRawMessage(client, { id: msgId, type: 'auth:refreshed' });
498
+ }
499
+ catch (err) {
500
+ logger.error({ err, clientId: client.id }, 'Auth refresh error');
501
+ sendRawMessage(client, { id: msgId, type: 'error', code: 'AUTH_FAILED', status: 401, message: 'Token refresh failed' });
502
+ }
503
+ }
504
+ /**
505
+ * Extract auth token from upgrade request
506
+ */
507
+ function extractAuthToken(req) {
508
+ // Custom extractor
509
+ if (authConfig?.extractToken) {
510
+ return authConfig.extractToken(req);
511
+ }
512
+ // Default: ?ticket=xxx or ?token=xxx from query string
513
+ const url = new URL(req.url || '/', 'http://localhost');
514
+ const ticket = url.searchParams.get('ticket');
515
+ if (ticket)
516
+ return ticket;
517
+ const token = url.searchParams.get('token');
518
+ if (token)
519
+ return token;
520
+ // Authorization: Bearer xxx header
521
+ const authHeader = req.headers['authorization'];
522
+ if (authHeader?.startsWith('Bearer ')) {
523
+ return authHeader.slice(7);
524
+ }
525
+ return undefined;
526
+ }
527
+ /**
528
+ * Validate connection auth — returns context seed or null (reject)
529
+ */
530
+ async function validateConnectionAuth(req) {
531
+ if (!authConfig)
532
+ return {}; // No auth configured → allow all
533
+ const token = extractAuthToken(req);
534
+ if (!token)
535
+ return null; // No token → reject
536
+ if (authConfig.mode === 'ticket') {
537
+ const store = authConfig.ticketStore;
538
+ const ticket = await store.consume(token);
539
+ if (!ticket)
540
+ return null; // Invalid/expired/used ticket
541
+ return {
542
+ auth: {
543
+ authenticated: true,
544
+ principal: ticket.userId,
545
+ principalId: ticket.userId,
546
+ claims: ticket.metadata,
547
+ },
548
+ };
549
+ }
550
+ if (authConfig.mode === 'bearer' || authConfig.mode === 'custom') {
551
+ if (!authConfig.validateToken)
552
+ return null;
553
+ return await authConfig.validateToken(token);
554
+ }
555
+ return {};
556
+ }
281
557
  /**
282
558
  * Handle new client connection
283
559
  */
284
560
  function handleConnection(ws, req) {
561
+ // Connection filter
285
562
  if (options.filter) {
286
563
  const filter = options.filter;
287
564
  const host = req.socket.remoteAddress ?? '';
@@ -293,16 +570,36 @@ export function createWebSocketAdapter(router, options) {
293
570
  ws.close(1008, 'Policy Violation');
294
571
  return;
295
572
  }
296
- _doConnect(ws, req);
573
+ authenticateAndConnect(ws, req);
297
574
  }).catch(() => { ws.close(1008, 'Policy Violation'); });
298
575
  return;
299
576
  }
300
- _doConnect(ws, req);
577
+ authenticateAndConnect(ws, req);
578
+ }
579
+ /**
580
+ * Authenticate and then connect
581
+ */
582
+ function authenticateAndConnect(ws, req) {
583
+ if (!authConfig) {
584
+ _doConnect(ws, req);
585
+ return;
586
+ }
587
+ validateConnectionAuth(req).then((seed) => {
588
+ if (!seed) {
589
+ logger.warn({ remoteAddress: req.socket.remoteAddress }, 'WebSocket auth rejected');
590
+ ws.close(1008, 'Authentication required');
591
+ return;
592
+ }
593
+ _doConnect(ws, req, seed);
594
+ }).catch((err) => {
595
+ logger.error({ err }, 'WebSocket auth error');
596
+ ws.close(1011, 'Authentication error');
597
+ });
301
598
  }
302
599
  /**
303
- * Perform the actual client connection setup (after filter passes).
600
+ * Perform the actual client connection setup (after filter + auth passes).
304
601
  */
305
- function _doConnect(ws, req) {
602
+ function _doConnect(ws, req, authSeed) {
306
603
  const clientId = sid();
307
604
  const client = {
308
605
  id: clientId,
@@ -312,12 +609,29 @@ export function createWebSocketAdapter(router, options) {
312
609
  connectionMetadata: extractMetadataFromHeaders(req.headers),
313
610
  activeStreams: new Map(),
314
611
  activeRequests: new Map(),
612
+ connectedAt: Date.now(),
315
613
  };
614
+ // Store auth seed for context building
615
+ if (authSeed) {
616
+ client.authSeed = authSeed;
617
+ }
316
618
  clients.set(clientId, client);
317
619
  logger.info({ clientId, remoteAddress: req.socket.remoteAddress }, 'Client connected');
318
620
  // Register client in channel manager inventory
319
621
  if (channelManager) {
320
- channelManager.registerClient(clientId);
622
+ const userId = authSeed?.auth?.principalId
623
+ ?? (typeof authSeed?.auth?.principal === 'string' ? authSeed.auth.principal : undefined);
624
+ channelManager.registerClient(clientId, { userId: userId ?? undefined });
625
+ // Send recovery token if recovery is enabled
626
+ if (recoveryStore) {
627
+ const token = generateRecoveryToken();
628
+ clientRecoveryTokens.set(clientId, token);
629
+ sendRawMessage(client, {
630
+ type: 'connection:established',
631
+ socketId: clientId,
632
+ recoveryToken: token,
633
+ });
634
+ }
321
635
  // Lifecycle hook: onConnect
322
636
  if (options.channels?.hooks?.onConnect) {
323
637
  const headers = client.connectionMetadata;
@@ -330,6 +644,13 @@ export function createWebSocketAdapter(router, options) {
330
644
  });
331
645
  }
332
646
  }
647
+ // Custom protocol: onConnection hook
648
+ if (options.onConnection) {
649
+ const sendFn = (msg) => sendRawMessage(client, msg);
650
+ Promise.resolve(options.onConnection(clientId, sendFn, req)).catch((err) => {
651
+ logger.warn({ err, clientId }, 'onConnection hook error');
652
+ });
653
+ }
333
654
  // Message handler
334
655
  ws.on('message', (data) => {
335
656
  handleMessage(client, data).catch((err) => {
@@ -344,6 +665,33 @@ export function createWebSocketAdapter(router, options) {
344
665
  ws.on('close', (code, reason) => {
345
666
  const reasonStr = reason.toString();
346
667
  logger.info({ clientId, code, reason: reasonStr }, 'Client disconnected');
668
+ // Save recovery session before removing client
669
+ if (recoveryStore && channelManager) {
670
+ const token = clientRecoveryTokens.get(clientId);
671
+ if (token) {
672
+ const subs = channelManager.getSubscriptions(clientId);
673
+ const clientInfo = channelManager.getClient(clientId);
674
+ const groups = channelManager.getClientGroups(clientId).map((g) => g.name);
675
+ // Build channel list with last seen seq (from channel state)
676
+ const channelSeqs = subs.map((name) => {
677
+ // We can't access internal channel state from outside, so we use 0
678
+ // The channel manager's recoverClient will replay from the stored seq
679
+ return { name, lastSeq: 0 };
680
+ });
681
+ const ttl = options.recovery?.ttl ?? 120_000;
682
+ const session = {
683
+ recoveryToken: token,
684
+ socketId: clientId,
685
+ userId: clientInfo?.userId,
686
+ channels: channelSeqs,
687
+ groups,
688
+ metadata: clientInfo?.data ?? {},
689
+ expiresAt: Date.now() + ttl,
690
+ };
691
+ recoveryStore.save(session);
692
+ clientRecoveryTokens.delete(clientId);
693
+ }
694
+ }
347
695
  // Remove client from inventory (also unsubscribes, cleans rooms/groups)
348
696
  if (channelManager) {
349
697
  channelManager.removeClient(clientId);
@@ -361,6 +709,12 @@ export function createWebSocketAdapter(router, options) {
361
709
  else {
362
710
  // No channel manager — still unsubscribe
363
711
  }
712
+ // Custom protocol: onClose hook
713
+ if (options.onClose) {
714
+ Promise.resolve(options.onClose(clientId, code, reasonStr)).catch((err) => {
715
+ logger.warn({ err, clientId }, 'onClose hook error');
716
+ });
717
+ }
364
718
  // Cancel active streams
365
719
  for (const controller of client.activeStreams.values()) {
366
720
  controller.abort('Client disconnected');
@@ -396,8 +750,8 @@ export function createWebSocketAdapter(router, options) {
396
750
  async start() {
397
751
  return new Promise((resolve, reject) => {
398
752
  wss = new WebSocketServer(sharedServer
399
- ? { server: sharedServer, path, maxPayload: maxPayloadSize }
400
- : { port: port, host, path, maxPayload: maxPayloadSize });
753
+ ? { server: sharedServer, path, maxPayload: maxPayloadSize, perMessageDeflate }
754
+ : { port: port, host, path, maxPayload: maxPayloadSize, perMessageDeflate });
401
755
  wss.on('connection', handleConnection);
402
756
  wss.on('error', (err) => {
403
757
  logger.error({ err }, 'WebSocket server error');
@@ -452,6 +806,66 @@ export function createWebSocketAdapter(router, options) {
452
806
  get channels() {
453
807
  return channelManager;
454
808
  },
809
+ // ─── Low-Level API ─────────────────────────────────────────────
810
+ send(socketId, message) {
811
+ const client = clients.get(socketId);
812
+ if (!client || client.ws.readyState !== WebSocket.OPEN)
813
+ return;
814
+ if (!checkBackpressure(client))
815
+ return;
816
+ client.ws.send(JSON.stringify(message));
817
+ },
818
+ sendRaw(socketId, data) {
819
+ const client = clients.get(socketId);
820
+ if (!client || client.ws.readyState !== WebSocket.OPEN)
821
+ return;
822
+ if (!checkBackpressure(client))
823
+ return;
824
+ client.ws.send(data);
825
+ },
826
+ broadcast(message, except) {
827
+ const json = JSON.stringify(message);
828
+ for (const [id, client] of clients) {
829
+ if (id === except)
830
+ continue;
831
+ if (client.ws.readyState !== WebSocket.OPEN)
832
+ continue;
833
+ if (!checkBackpressure(client))
834
+ continue;
835
+ client.ws.send(json);
836
+ }
837
+ },
838
+ getClient(socketId) {
839
+ const client = clients.get(socketId);
840
+ if (!client)
841
+ return undefined;
842
+ return {
843
+ id: client.id,
844
+ remoteAddress: client.request.socket.remoteAddress,
845
+ metadata: { ...client.connectionMetadata },
846
+ authSeed: client.authSeed,
847
+ connectedAt: client.connectedAt,
848
+ };
849
+ },
850
+ getClients() {
851
+ const result = [];
852
+ for (const client of clients.values()) {
853
+ result.push({
854
+ id: client.id,
855
+ remoteAddress: client.request.socket.remoteAddress,
856
+ metadata: { ...client.connectionMetadata },
857
+ authSeed: client.authSeed,
858
+ connectedAt: Date.now(),
859
+ });
860
+ }
861
+ return result;
862
+ },
863
+ disconnect(socketId, code, reason) {
864
+ const client = clients.get(socketId);
865
+ if (!client)
866
+ return;
867
+ client.ws.close(code ?? 1000, reason ?? 'Disconnected by server');
868
+ },
455
869
  };
456
870
  }
457
871
  //# sourceMappingURL=websocket.js.map