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.
- package/dist/adapters/index.d.ts +1 -1
- package/dist/adapters/index.d.ts.map +1 -1
- package/dist/adapters/websocket.d.ts +100 -1
- package/dist/adapters/websocket.d.ts.map +1 -1
- package/dist/adapters/websocket.js +428 -14
- package/dist/adapters/websocket.js.map +1 -1
- package/dist/channels/channel-manager.d.ts.map +1 -1
- package/dist/channels/channel-manager.js +265 -4
- package/dist/channels/channel-manager.js.map +1 -1
- package/dist/channels/history.d.ts +54 -0
- package/dist/channels/history.d.ts.map +1 -0
- package/dist/channels/history.js +78 -0
- package/dist/channels/history.js.map +1 -0
- package/dist/channels/index.d.ts +9 -1
- package/dist/channels/index.d.ts.map +1 -1
- package/dist/channels/index.js +8 -1
- package/dist/channels/index.js.map +1 -1
- package/dist/channels/recovery.d.ts +57 -0
- package/dist/channels/recovery.d.ts.map +1 -0
- package/dist/channels/recovery.js +58 -0
- package/dist/channels/recovery.js.map +1 -0
- package/dist/channels/rest-api.d.ts +32 -0
- package/dist/channels/rest-api.d.ts.map +1 -0
- package/dist/channels/rest-api.js +197 -0
- package/dist/channels/rest-api.js.map +1 -0
- package/dist/channels/ticket-store.d.ts +25 -0
- package/dist/channels/ticket-store.d.ts.map +1 -0
- package/dist/channels/ticket-store.js +70 -0
- package/dist/channels/ticket-store.js.map +1 -0
- package/dist/channels/types.d.ts +267 -5
- package/dist/channels/types.d.ts.map +1 -1
- package/dist/channels/types.js +15 -1
- package/dist/channels/types.js.map +1 -1
- package/dist/client/index.d.ts +39 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +531 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/reconnect.d.ts +33 -0
- package/dist/client/reconnect.d.ts.map +1 -0
- package/dist/client/reconnect.js +60 -0
- package/dist/client/reconnect.js.map +1 -0
- package/dist/client/types.d.ts +107 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +5 -0
- package/dist/client/types.js.map +1 -0
- package/dist/index.d.ts +9 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/middleware/interceptors/field-filter.d.ts +51 -0
- package/dist/middleware/interceptors/field-filter.d.ts.map +1 -0
- package/dist/middleware/interceptors/field-filter.js +81 -0
- package/dist/middleware/interceptors/field-filter.js.map +1 -0
- package/dist/middleware/interceptors/guard.d.ts +51 -0
- package/dist/middleware/interceptors/guard.d.ts.map +1 -0
- package/dist/middleware/interceptors/guard.js +45 -0
- package/dist/middleware/interceptors/guard.js.map +1 -0
- package/dist/middleware/interceptors/index.d.ts +4 -0
- package/dist/middleware/interceptors/index.d.ts.map +1 -1
- package/dist/middleware/interceptors/index.js +4 -0
- package/dist/middleware/interceptors/index.js.map +1 -1
- package/dist/resource-module/index.d.ts +44 -0
- package/dist/resource-module/index.d.ts.map +1 -0
- package/dist/resource-module/index.js +146 -0
- package/dist/resource-module/index.js.map +1 -0
- package/dist/resource-module/types.d.ts +109 -0
- package/dist/resource-module/types.d.ts.map +1 -0
- package/dist/resource-module/types.js +7 -0
- package/dist/resource-module/types.js.map +1 -0
- package/dist/resource-module/watch-helpers.d.ts +16 -0
- package/dist/resource-module/watch-helpers.d.ts.map +1 -0
- package/dist/resource-module/watch-helpers.js +65 -0
- package/dist/resource-module/watch-helpers.js.map +1 -0
- package/dist/server/types.d.ts +27 -0
- package/dist/server/types.d.ts.map +1 -1
- 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,
|
|
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
|
-
|
|
573
|
+
authenticateAndConnect(ws, req);
|
|
297
574
|
}).catch(() => { ws.close(1008, 'Policy Violation'); });
|
|
298
575
|
return;
|
|
299
576
|
}
|
|
300
|
-
|
|
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
|
-
|
|
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
|