@dokebi/sse 0.4.0 → 0.4.2
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/AI.md +32 -18
- package/README.md +3 -2
- package/dist/browser/sse-client.js +88 -87
- package/dist/client/index.js +24 -5
- package/dist/client/react.js +3 -1
- package/dist/errors.d.ts +6 -1
- package/dist/errors.js +10 -1
- package/dist/gateway.d.ts +11 -2
- package/dist/gateway.js +294 -56
- package/dist/options.d.ts +30 -0
- package/dist/options.js +20 -6
- package/dist/plugin.js +34 -6
- package/dist/retry.d.ts +1 -0
- package/dist/retry.js +25 -0
- package/dist/transport.js +6 -3
- package/dist/types.d.ts +33 -4
- package/options.schema.json +67 -0
- package/package.json +11 -11
package/dist/gateway.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
2
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
3
|
import { diagnostics } from '@dokebi/core/libraries';
|
|
4
|
+
// D-0089: the before-hijack steps (the session save, auth cookies) of @dokebi/web, run before the head.
|
|
5
|
+
import { runBeforeHijack } from '@dokebi/web/points';
|
|
4
6
|
import { SseError } from './errors.js';
|
|
7
|
+
import { transientBusFailure } from './retry.js';
|
|
5
8
|
import { OPEN_EVENT, RELOAD_EVENT, RESTART_EVENT, } from './types.js';
|
|
6
9
|
import { checkEvent, EVENT, encodeData, frame, HEADERS, ID, isLine, PING } from './wire.js';
|
|
7
|
-
/** A client that ignores `dokebi-reload` comes back this slowly (EventSource honours `retry:`). */
|
|
8
|
-
const RELOAD_RETRY_MS = 60_000;
|
|
9
|
-
/** How often the bus state is read for health and the warn/info log of a change. */
|
|
10
|
-
const BUS_WATCH_MS = 1000;
|
|
11
10
|
const MAX_TEXT = 256;
|
|
11
|
+
/** Room for everything of a bus message but its data: the target (3 x 256 characters, escaped), ids. */
|
|
12
|
+
const ENVELOPE_ROOM = 8192;
|
|
12
13
|
const text = (value) => typeof value === 'string' && value.length > 0 && value.length <= MAX_TEXT;
|
|
13
|
-
const key = (tenant, value) => `${tenant}\u0000${value}`;
|
|
14
14
|
function add(map, name, entry) {
|
|
15
15
|
const set = map.get(name);
|
|
16
16
|
if (set)
|
|
@@ -26,6 +26,19 @@ function remove(map, name, entry) {
|
|
|
26
26
|
if (!set.size)
|
|
27
27
|
map.delete(name);
|
|
28
28
|
}
|
|
29
|
+
/** The app's own event id (`options.id`), checked; undefined lets the plugin number the event. */
|
|
30
|
+
function eventId(options) {
|
|
31
|
+
if (options === undefined)
|
|
32
|
+
return undefined;
|
|
33
|
+
if (options === null || typeof options !== 'object')
|
|
34
|
+
throw new SseError('INVALID_EVENT_ID', 'Send options are an object such as { id: "42" }.');
|
|
35
|
+
const { id } = options;
|
|
36
|
+
if (id === undefined)
|
|
37
|
+
return undefined;
|
|
38
|
+
if (typeof id !== 'string' || !ID.test(id))
|
|
39
|
+
throw new SseError('INVALID_EVENT_ID', 'An event id is 1 to 64 of A-Z a-z 0-9 and -.');
|
|
40
|
+
return id;
|
|
41
|
+
}
|
|
29
42
|
function checkRoom(room) {
|
|
30
43
|
if (!text(room))
|
|
31
44
|
throw new SseError('INVALID_ROOM', 'Rooms are strings of 1 to 256 characters.');
|
|
@@ -49,6 +62,23 @@ function checkTarget(target) {
|
|
|
49
62
|
...(value.room === undefined ? {} : { room: value.room }),
|
|
50
63
|
});
|
|
51
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* The route config (D-0140): `{ id }` only, handed to dokebi-web as the Fastify route config.
|
|
67
|
+
* The id value is checked by dokebi-web at start, the same check as a web route's (one rule).
|
|
68
|
+
*/
|
|
69
|
+
function parseConfig(value) {
|
|
70
|
+
if (value === undefined)
|
|
71
|
+
return undefined;
|
|
72
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
73
|
+
throw new SseError('INVALID_ROUTE', "SSE route config is an object such as { id: 'live' }.");
|
|
74
|
+
const values = value;
|
|
75
|
+
const keys = Object.keys(values).filter((key) => values[key] !== undefined);
|
|
76
|
+
const other = keys.find((key) => key !== 'id');
|
|
77
|
+
if (other !== undefined)
|
|
78
|
+
throw new SseError('INVALID_ROUTE', `SSE route config takes only id, not ${JSON.stringify(other.slice(0, 64))}.`);
|
|
79
|
+
// Typed as the route config declares it; a value that is not a valid id fails the start.
|
|
80
|
+
return keys.length ? Object.freeze({ id: values.id }) : undefined;
|
|
81
|
+
}
|
|
52
82
|
/** Allow-list entries must be exact http(s) origins such as `https://app.example.com`. */
|
|
53
83
|
function parseOrigins(value) {
|
|
54
84
|
if (value === undefined)
|
|
@@ -100,11 +130,11 @@ export class Gateway {
|
|
|
100
130
|
web;
|
|
101
131
|
options;
|
|
102
132
|
bus;
|
|
133
|
+
busPayloadBytes;
|
|
103
134
|
api;
|
|
104
135
|
#entries = new Map();
|
|
136
|
+
/** tenant → its streams, by member and by room (see TenantIndex). */
|
|
105
137
|
#tenants = new Map();
|
|
106
|
-
#members = new Map();
|
|
107
|
-
#rooms = new Map();
|
|
108
138
|
/** onOpen/onClose calls in progress, awaited by drain (bounded by the drain deadline). */
|
|
109
139
|
#tasks = new Set();
|
|
110
140
|
/** Repeated hot-path events: the first at once, then one summary per interval (D-0047). */
|
|
@@ -124,12 +154,18 @@ export class Gateway {
|
|
|
124
154
|
#subscription;
|
|
125
155
|
#health;
|
|
126
156
|
#busUp;
|
|
127
|
-
constructor(context, web, options, bus, health
|
|
157
|
+
constructor(context, web, options, bus, health,
|
|
158
|
+
/** The bus's maxPayloadBytes as the app configuration says now; undefined when unknown. */
|
|
159
|
+
busPayloadBytes = () => undefined) {
|
|
128
160
|
this.context = context;
|
|
129
161
|
this.web = web;
|
|
130
162
|
this.options = options;
|
|
131
163
|
this.bus = bus;
|
|
132
|
-
this
|
|
164
|
+
this.busPayloadBytes = busPayloadBytes;
|
|
165
|
+
this.#throttle = new diagnostics.Repeats({
|
|
166
|
+
logger: context.logger,
|
|
167
|
+
intervalMs: this.options.logRepeatWindowMs,
|
|
168
|
+
});
|
|
133
169
|
if (health) {
|
|
134
170
|
this.#health = health.register('sse', { required: false });
|
|
135
171
|
const handle = this.#health;
|
|
@@ -138,7 +174,7 @@ export class Gateway {
|
|
|
138
174
|
const self = this;
|
|
139
175
|
this.api = Object.freeze({
|
|
140
176
|
route: (route) => this.#route(route),
|
|
141
|
-
publish: (target, event, data) => this.#publish(target, event, data),
|
|
177
|
+
publish: (target, event, data, options) => this.#publish(target, event, data, options),
|
|
142
178
|
connections: (filter) => this.#count(filter),
|
|
143
179
|
get bus() {
|
|
144
180
|
return self.options.eventBus;
|
|
@@ -171,9 +207,11 @@ export class Gateway {
|
|
|
171
207
|
(route.constraints !== undefined &&
|
|
172
208
|
(typeof route.constraints !== 'object' || route.constraints === null)))
|
|
173
209
|
throw new SseError('INVALID_ROUTE', 'Invalid SSE route.');
|
|
210
|
+
const config = parseConfig(route.config);
|
|
174
211
|
const registered = {
|
|
175
212
|
route: Object.freeze({ ...route }),
|
|
176
213
|
origins: parseOrigins(route.origins),
|
|
214
|
+
id: typeof config?.id === 'string' ? config.id : null,
|
|
177
215
|
};
|
|
178
216
|
this.web.route({
|
|
179
217
|
method: 'GET',
|
|
@@ -181,11 +219,14 @@ export class Gateway {
|
|
|
181
219
|
// A HEAD request would open a stream whose body Node discards: answer only GET.
|
|
182
220
|
exposeHeadRoute: false,
|
|
183
221
|
...(route.constraints ? { constraints: { ...route.constraints } } : {}),
|
|
222
|
+
// D-0140: dokebi-web records and checks `config.id` (describe, duplicates, route hooks).
|
|
223
|
+
...(config ? { config: { ...config } } : {}),
|
|
184
224
|
handler: (request, reply) => this.#open(request, reply, registered),
|
|
185
225
|
});
|
|
186
226
|
this.#routes++;
|
|
187
227
|
this.context.logger.log('debug', 'SSE route registered', {
|
|
188
228
|
path: route.path,
|
|
229
|
+
routeId: registered.id,
|
|
189
230
|
version: route.version ?? null,
|
|
190
231
|
origins: registered.origins === '*' ? '*' : registered.origins ? registered.origins.size : null,
|
|
191
232
|
});
|
|
@@ -206,23 +247,48 @@ export class Gateway {
|
|
|
206
247
|
if (logger.isEnabled('debug'))
|
|
207
248
|
logger.log('debug', 'SSE origin refused', {
|
|
208
249
|
path: route.path,
|
|
250
|
+
routeId: registered.id,
|
|
209
251
|
origin: String(request.headers.origin).slice(0, 200),
|
|
210
252
|
});
|
|
211
253
|
return reply.code(403).send({ error: 'ORIGIN_FORBIDDEN' });
|
|
212
254
|
}
|
|
255
|
+
// A listed foreign origin reads the refusals too: the browser client's probe is a
|
|
256
|
+
// credentialed cross-origin fetch, and without these headers the browser turns a 401 into a
|
|
257
|
+
// network error, so the client would reconnect forever instead of stopping.
|
|
258
|
+
const refuse = (status, body, headers = {}) => reply
|
|
259
|
+
.code(status)
|
|
260
|
+
.headers({ ...allowed, ...headers })
|
|
261
|
+
.send(body);
|
|
213
262
|
let refused = this.#refusal();
|
|
214
263
|
if (refused) {
|
|
215
|
-
logger.log('debug', 'SSE stream refused', {
|
|
216
|
-
|
|
217
|
-
.
|
|
218
|
-
|
|
219
|
-
|
|
264
|
+
logger.log('debug', 'SSE stream refused', {
|
|
265
|
+
path: route.path,
|
|
266
|
+
routeId: registered.id,
|
|
267
|
+
reason: refused,
|
|
268
|
+
});
|
|
269
|
+
return refuse(503, { error: 'UNAVAILABLE', reason: refused }, { 'retry-after': '5' });
|
|
220
270
|
}
|
|
221
271
|
const identity = await route.authenticate(request);
|
|
222
|
-
|
|
272
|
+
let revoked;
|
|
273
|
+
try {
|
|
274
|
+
revoked = identity?.authorization?.valid() === false;
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
// As on every write (#write): a lease that cannot answer is a server failure, not a 401
|
|
278
|
+
// (which would stop the browser client for good).
|
|
279
|
+
this.#throttle.log('error', 'SSE authorization failed', 'SSE_AUTHORIZATION_FAILED', error, {
|
|
280
|
+
path: route.path,
|
|
281
|
+
routeId: registered.id,
|
|
282
|
+
});
|
|
283
|
+
return refuse(500, { error: 'AUTHORIZATION_FAILED' });
|
|
284
|
+
}
|
|
285
|
+
if (!identity || revoked) {
|
|
223
286
|
if (logger.isEnabled('debug'))
|
|
224
|
-
logger.log('debug', 'SSE stream unauthorized', {
|
|
225
|
-
|
|
287
|
+
logger.log('debug', 'SSE stream unauthorized', {
|
|
288
|
+
path: route.path,
|
|
289
|
+
routeId: registered.id,
|
|
290
|
+
});
|
|
291
|
+
return refuse(401, { error: 'UNAUTHORIZED' });
|
|
226
292
|
}
|
|
227
293
|
// An invalid identity is an application bug: dokebi-web answers 500 and logs it.
|
|
228
294
|
const rooms = this.#identity(identity);
|
|
@@ -233,16 +299,45 @@ export class Gateway {
|
|
|
233
299
|
.code(204)
|
|
234
300
|
.headers(allowed ?? {})
|
|
235
301
|
.send();
|
|
302
|
+
// The reply is hijacked below, so Fastify writes none of its headers and runs no onSend hook:
|
|
303
|
+
// the session save and auth cookies happen now (D-0089), or the stream is refused.
|
|
304
|
+
const started = performance.now();
|
|
305
|
+
let committed;
|
|
306
|
+
try {
|
|
307
|
+
const timeoutMs = this.options.beforeStreamTimeoutMs;
|
|
308
|
+
const waiting = runBeforeHijack(request, reply, timeoutMs, () => new Error(`Work before the stream did not finish within ${timeoutMs} ms (beforeStreamTimeoutMs)`));
|
|
309
|
+
if (waiting)
|
|
310
|
+
committed = await waiting;
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
this.#throttle.log('error', 'SSE stream refused: saving the request state failed', 'SSE_COMMIT_FAILED', error, { path: route.path });
|
|
314
|
+
return refuse(500, { error: 'COMMIT_FAILED' });
|
|
315
|
+
}
|
|
316
|
+
if (committed !== undefined && logger.isEnabled('debug'))
|
|
317
|
+
logger.log('debug', 'SSE request state saved before the stream', {
|
|
318
|
+
path: route.path,
|
|
319
|
+
steps: committed,
|
|
320
|
+
ms: Math.round(performance.now() - started),
|
|
321
|
+
});
|
|
236
322
|
refused = this.#refusal();
|
|
237
323
|
if (refused) {
|
|
238
|
-
logger.log('debug', 'SSE stream refused', {
|
|
239
|
-
|
|
240
|
-
.
|
|
241
|
-
|
|
242
|
-
|
|
324
|
+
logger.log('debug', 'SSE stream refused', {
|
|
325
|
+
path: route.path,
|
|
326
|
+
routeId: registered.id,
|
|
327
|
+
reason: refused,
|
|
328
|
+
});
|
|
329
|
+
return refuse(503, { error: 'UNAVAILABLE', reason: refused }, { 'retry-after': '5' });
|
|
243
330
|
}
|
|
244
331
|
const res = reply.raw;
|
|
245
|
-
|
|
332
|
+
// Set-Cookie on the reply (authenticate, a hook, the steps above) goes out with the stream.
|
|
333
|
+
const cookies = reply.getHeader('set-cookie');
|
|
334
|
+
const headers = {
|
|
335
|
+
...HEADERS,
|
|
336
|
+
...allowed,
|
|
337
|
+
...(cookies === undefined
|
|
338
|
+
? {}
|
|
339
|
+
: { 'set-cookie': typeof cookies === 'number' ? String(cookies) : cookies }),
|
|
340
|
+
};
|
|
246
341
|
// D-0062: a stream of a seal host seals the data of every event, its first ones included.
|
|
247
342
|
const seal = this.web.sealOf(request);
|
|
248
343
|
const data = (json) => (seal ? seal.encode(json) : json);
|
|
@@ -256,7 +351,7 @@ export class Gateway {
|
|
|
256
351
|
});
|
|
257
352
|
reply.hijack();
|
|
258
353
|
res.writeHead(200, headers);
|
|
259
|
-
res.end(`retry: ${
|
|
354
|
+
res.end(`retry: ${this.options.reloadRetryMs}\n${frame(undefined, RELOAD_EVENT, data(JSON.stringify({ version: route.version })))}`);
|
|
260
355
|
return;
|
|
261
356
|
}
|
|
262
357
|
}
|
|
@@ -274,6 +369,16 @@ export class Gateway {
|
|
|
274
369
|
res.write(`retry: ${this.options.retryMs}\n${frame(undefined, OPEN_EVENT, data(JSON.stringify({ retryMs: this.options.retryMs })))}`);
|
|
275
370
|
const entry = this.#add(res, identity, route, rooms, lastEventId, seal);
|
|
276
371
|
res.once('close', () => this.#closed(entry));
|
|
372
|
+
if (identity.authorization) {
|
|
373
|
+
// A lease that cannot be subscribed to could not be revoked later: the stream ends now.
|
|
374
|
+
try {
|
|
375
|
+
entry.unsubscribeAuthorization = identity.authorization.subscribe(() => this.#end(entry, 'SERVER_CLOSED'));
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
this.#throttle.log('error', 'SSE authorization subscription failed; stream ended', 'SSE_AUTHORIZATION_FAILED', error, { path: route.path, stream: entry.id });
|
|
379
|
+
this.#end(entry, 'SERVER_CLOSED');
|
|
380
|
+
}
|
|
381
|
+
}
|
|
277
382
|
res.on('error', (error) => {
|
|
278
383
|
entry.reason ??= 'WRITE_FAILED';
|
|
279
384
|
const code = diagnostics.errorCode(error, 'UNKNOWN');
|
|
@@ -333,6 +438,7 @@ export class Gateway {
|
|
|
333
438
|
reason: undefined,
|
|
334
439
|
closed: false,
|
|
335
440
|
stream: undefined,
|
|
441
|
+
endTimer: undefined,
|
|
336
442
|
};
|
|
337
443
|
entry.stream = Object.freeze({
|
|
338
444
|
id: entry.id,
|
|
@@ -347,18 +453,24 @@ export class Gateway {
|
|
|
347
453
|
get closed() {
|
|
348
454
|
return entry.closed || entry.reason !== undefined;
|
|
349
455
|
},
|
|
350
|
-
send: (event, data) => {
|
|
456
|
+
send: (event, data, options) => {
|
|
351
457
|
checkEvent(event);
|
|
458
|
+
const id = eventId(options) ?? this.#nextId();
|
|
352
459
|
const json = encodeData(data, this.options.maxEventBytes);
|
|
353
|
-
return this.#event(entry,
|
|
460
|
+
return this.#event(entry, id, event, json);
|
|
354
461
|
},
|
|
355
462
|
join: (room) => this.#join(entry, checkRoom(room)),
|
|
356
463
|
leave: (room) => this.#leave(entry, checkRoom(room)),
|
|
357
464
|
close: () => this.#end(entry, 'SERVER_CLOSED'),
|
|
358
465
|
});
|
|
359
466
|
this.#entries.set(entry.id, entry);
|
|
360
|
-
|
|
361
|
-
|
|
467
|
+
let index = this.#tenants.get(entry.tenant);
|
|
468
|
+
if (!index) {
|
|
469
|
+
index = { all: new Set(), members: new Map(), rooms: new Map() };
|
|
470
|
+
this.#tenants.set(entry.tenant, index);
|
|
471
|
+
}
|
|
472
|
+
index.all.add(entry);
|
|
473
|
+
add(index.members, entry.member, entry);
|
|
362
474
|
for (const room of rooms)
|
|
363
475
|
this.#join(entry, room);
|
|
364
476
|
return entry;
|
|
@@ -368,8 +480,10 @@ export class Gateway {
|
|
|
368
480
|
return;
|
|
369
481
|
if (entry.rooms.size >= this.options.maxRooms)
|
|
370
482
|
throw new SseError('ROOM_LIMIT', `A stream may be in ${this.options.maxRooms} rooms.`);
|
|
483
|
+
// An open entry is always indexed under its tenant (#add before any join, #closed after).
|
|
484
|
+
const index = this.#tenants.get(entry.tenant);
|
|
371
485
|
entry.rooms.add(room);
|
|
372
|
-
add(
|
|
486
|
+
add(index.rooms, room, entry);
|
|
373
487
|
if (this.context.logger.isEnabled('debug'))
|
|
374
488
|
this.context.logger.log('debug', 'SSE room joined', {
|
|
375
489
|
stream: entry.id,
|
|
@@ -380,7 +494,9 @@ export class Gateway {
|
|
|
380
494
|
#leave(entry, room) {
|
|
381
495
|
if (!entry.rooms.delete(room))
|
|
382
496
|
return;
|
|
383
|
-
|
|
497
|
+
const index = this.#tenants.get(entry.tenant);
|
|
498
|
+
if (index)
|
|
499
|
+
remove(index.rooms, room, entry);
|
|
384
500
|
if (this.context.logger.isEnabled('debug'))
|
|
385
501
|
this.context.logger.log('debug', 'SSE room left', {
|
|
386
502
|
stream: entry.id,
|
|
@@ -417,6 +533,17 @@ export class Gateway {
|
|
|
417
533
|
#write(entry, chunk) {
|
|
418
534
|
if (entry.closed || entry.reason !== undefined)
|
|
419
535
|
return false;
|
|
536
|
+
try {
|
|
537
|
+
if (entry.identity.authorization?.valid() === false) {
|
|
538
|
+
this.#end(entry, 'SERVER_CLOSED');
|
|
539
|
+
return false;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
catch (error) {
|
|
543
|
+
this.#throttle.log('error', 'SSE authorization failed', 'SSE_AUTHORIZATION_FAILED', error);
|
|
544
|
+
this.#end(entry, 'SERVER_CLOSED');
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
420
547
|
const res = entry.res;
|
|
421
548
|
// ServerResponse.writableLength counts what waits in the response and in its socket.
|
|
422
549
|
if (res.writableLength + chunk.byteLength > this.options.maxBufferedBytes) {
|
|
@@ -437,22 +564,66 @@ export class Gateway {
|
|
|
437
564
|
if (entry.closed || entry.reason !== undefined)
|
|
438
565
|
return;
|
|
439
566
|
entry.reason = reason;
|
|
440
|
-
if (destroy)
|
|
567
|
+
if (destroy) {
|
|
441
568
|
entry.res.destroy();
|
|
442
|
-
|
|
443
|
-
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
entry.res.end();
|
|
572
|
+
// end() sends its last chunk behind the bytes still waiting; a client that stopped reading
|
|
573
|
+
// never takes them, so 'close' would not come and the stream would keep its slot. Bounded.
|
|
574
|
+
if (entry.closed)
|
|
575
|
+
return;
|
|
576
|
+
const endTimeoutMs = this.options.endTimeoutMs;
|
|
577
|
+
entry.endTimer = setTimeout(() => {
|
|
578
|
+
entry.endTimer = undefined;
|
|
579
|
+
if (entry.closed)
|
|
580
|
+
return;
|
|
581
|
+
this.#throttle.log('warn', 'SSE ended stream destroyed; client not reading', 'STREAM_END_TIMEOUT', undefined, {
|
|
582
|
+
stream: entry.id,
|
|
583
|
+
path: entry.route.path,
|
|
584
|
+
reason,
|
|
585
|
+
bufferedBytes: entry.res.writableLength,
|
|
586
|
+
endTimeoutMs,
|
|
587
|
+
});
|
|
588
|
+
entry.res.destroy();
|
|
589
|
+
}, endTimeoutMs);
|
|
590
|
+
entry.endTimer.unref();
|
|
591
|
+
if (this.context.logger.isEnabled('debug'))
|
|
592
|
+
this.context.logger.log('debug', 'SSE stream ending', {
|
|
593
|
+
stream: entry.id,
|
|
594
|
+
reason,
|
|
595
|
+
bufferedBytes: entry.res.writableLength,
|
|
596
|
+
endTimeoutMs,
|
|
597
|
+
});
|
|
444
598
|
}
|
|
445
599
|
#closed(entry) {
|
|
446
600
|
if (entry.closed)
|
|
447
601
|
return;
|
|
448
602
|
entry.closed = true;
|
|
603
|
+
if (entry.endTimer) {
|
|
604
|
+
clearTimeout(entry.endTimer);
|
|
605
|
+
entry.endTimer = undefined;
|
|
606
|
+
}
|
|
607
|
+
// A failing unsubscribe must not keep the stream indexed (it would count against
|
|
608
|
+
// maxConnections) nor escape from the response's 'close' event.
|
|
609
|
+
try {
|
|
610
|
+
entry.unsubscribeAuthorization?.();
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
this.#throttle.log('error', 'SSE authorization unsubscribe failed', 'SSE_AUTHORIZATION_FAILED', error, { path: entry.route.path, stream: entry.id });
|
|
614
|
+
}
|
|
449
615
|
const reason = entry.reason ?? 'CLIENT_CLOSED';
|
|
450
616
|
entry.reason = reason;
|
|
451
617
|
this.#entries.delete(entry.id);
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
remove(
|
|
618
|
+
const index = this.#tenants.get(entry.tenant);
|
|
619
|
+
if (index) {
|
|
620
|
+
index.all.delete(entry);
|
|
621
|
+
remove(index.members, entry.member, entry);
|
|
622
|
+
for (const room of entry.rooms)
|
|
623
|
+
remove(index.rooms, room, entry);
|
|
624
|
+
if (!index.all.size)
|
|
625
|
+
this.#tenants.delete(entry.tenant);
|
|
626
|
+
}
|
|
456
627
|
if (this.context.logger.isEnabled('debug'))
|
|
457
628
|
this.context.logger.log('debug', 'SSE stream closed', {
|
|
458
629
|
stream: entry.id,
|
|
@@ -480,11 +651,14 @@ export class Gateway {
|
|
|
480
651
|
return task.finally(() => this.#tasks.delete(task));
|
|
481
652
|
}
|
|
482
653
|
#select(target) {
|
|
654
|
+
const index = this.#tenants.get(target.tenant);
|
|
655
|
+
if (!index)
|
|
656
|
+
return undefined;
|
|
483
657
|
if (target.room !== undefined)
|
|
484
|
-
return
|
|
658
|
+
return index.rooms.get(target.room);
|
|
485
659
|
if (target.member !== undefined)
|
|
486
|
-
return
|
|
487
|
-
return
|
|
660
|
+
return index.members.get(target.member);
|
|
661
|
+
return index.all;
|
|
488
662
|
}
|
|
489
663
|
/** Writes one event to the matching streams of this process; returns how many took it. */
|
|
490
664
|
#deliver(target, event, id, json) {
|
|
@@ -493,8 +667,14 @@ export class Gateway {
|
|
|
493
667
|
let written = 0;
|
|
494
668
|
const shared = { id, event, json, chunk: undefined };
|
|
495
669
|
for (const tenant of [...this.#tenants.keys()])
|
|
496
|
-
if (tenant.startsWith(target.tenantPrefix))
|
|
497
|
-
|
|
670
|
+
if (tenant.startsWith(target.tenantPrefix)) {
|
|
671
|
+
const exact = {
|
|
672
|
+
tenant,
|
|
673
|
+
...(target.member === undefined ? {} : { member: target.member }),
|
|
674
|
+
...(target.room === undefined ? {} : { room: target.room }),
|
|
675
|
+
};
|
|
676
|
+
written += this.#writeAll(this.#select(exact), exact, shared);
|
|
677
|
+
}
|
|
498
678
|
return written;
|
|
499
679
|
}
|
|
500
680
|
const set = this.#select(target);
|
|
@@ -504,14 +684,22 @@ export class Gateway {
|
|
|
504
684
|
}
|
|
505
685
|
/**
|
|
506
686
|
* Plain streams share one buffer, made at the first plain recipient; sealed streams each get
|
|
507
|
-
* their own frame (D-0062, like sealed WebSocket broadcasts).
|
|
687
|
+
* their own frame (D-0062, like sealed WebSocket broadcasts). Every candidate is checked against
|
|
688
|
+
* the whole target, tenant first, right before the write: the index narrows, it never decides
|
|
689
|
+
* alone who gets an event (ISSUE-0006 A01).
|
|
508
690
|
*/
|
|
509
691
|
#writeAll(set, target, shared) {
|
|
510
692
|
let written = 0;
|
|
511
693
|
for (const entry of set ?? []) {
|
|
512
|
-
if (
|
|
513
|
-
|
|
514
|
-
entry.
|
|
694
|
+
if (entry.tenant !== target.tenant) {
|
|
695
|
+
// Unreachable while the index is right; a defect, never a reason to cross tenants.
|
|
696
|
+
this.#throttle.log('error', 'SSE index returned a stream of another tenant; skipped', 'SSE_INDEX_MISMATCH', undefined, { stream: entry.id, path: entry.route.path });
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
// #select picked the room (or member) index; the rest of the target narrows here.
|
|
700
|
+
if (target.member !== undefined && entry.member !== target.member)
|
|
701
|
+
continue;
|
|
702
|
+
if (target.room !== undefined && !entry.rooms.has(target.room))
|
|
515
703
|
continue;
|
|
516
704
|
if (!entry.seal)
|
|
517
705
|
shared.chunk ??= Buffer.from(frame(shared.id, shared.event, shared.json));
|
|
@@ -520,22 +708,26 @@ export class Gateway {
|
|
|
520
708
|
}
|
|
521
709
|
return written;
|
|
522
710
|
}
|
|
523
|
-
async #publish(target, event, data) {
|
|
711
|
+
async #publish(target, event, data, options) {
|
|
524
712
|
if (this.#shut)
|
|
525
713
|
throw new SseError('CLOSED', 'dokebi-sse is closed: the application has stopped.');
|
|
526
714
|
const checked = checkTarget(target);
|
|
527
715
|
checkEvent(event);
|
|
716
|
+
const chosen = eventId(options);
|
|
528
717
|
const json = encodeData(data, this.options.maxEventBytes);
|
|
529
718
|
const bus = this.bus;
|
|
530
719
|
if (bus && bus.state !== 'ready') {
|
|
531
|
-
|
|
720
|
+
// Transient: the request may pass once the bus is back (503 + Retry-After, D-0107 §6).
|
|
721
|
+
const error = new SseError('BUS_UNAVAILABLE', `Event bus ${this.options.eventBus} is ${bus.state}; the event was not sent.`, { retryAfterMs: this.options.busRetryAfterMs.unavailable });
|
|
532
722
|
this.#throttle.log('error', 'SSE publish refused; bus unavailable', 'BUS_UNAVAILABLE', error, {
|
|
533
723
|
bus: this.options.eventBus,
|
|
534
724
|
state: bus.state,
|
|
535
725
|
});
|
|
536
726
|
throw error;
|
|
537
727
|
}
|
|
538
|
-
const id = this.#nextId();
|
|
728
|
+
const id = chosen ?? this.#nextId();
|
|
729
|
+
if (bus)
|
|
730
|
+
this.#checkBusSize(checked, event, id, json);
|
|
539
731
|
const local = this.#closing ? 0 : this.#deliver(checked, event, id, json);
|
|
540
732
|
if (bus) {
|
|
541
733
|
try {
|
|
@@ -554,8 +746,16 @@ export class Gateway {
|
|
|
554
746
|
id,
|
|
555
747
|
local,
|
|
556
748
|
busCode: diagnostics.errorCode(cause, 'UNKNOWN'),
|
|
749
|
+
transient: transientBusFailure(cause) ?? false,
|
|
750
|
+
});
|
|
751
|
+
// A full or down bus is transient (503 + Retry-After); anything else stays 500 (D-0107 §6).
|
|
752
|
+
const transient = transientBusFailure(cause);
|
|
753
|
+
throw new SseError('BUS_PUBLISH_FAILED', 'The event reached this process, but publishing it on the bus failed; other processes may not have it.', {
|
|
754
|
+
cause,
|
|
755
|
+
id,
|
|
756
|
+
local,
|
|
757
|
+
...(transient ? { retryAfterMs: this.options.busRetryAfterMs[transient] } : {}),
|
|
557
758
|
});
|
|
558
|
-
throw new SseError('BUS_PUBLISH_FAILED', 'The event reached this process, but publishing it on the bus failed; other processes may not have it.', { cause, id, local });
|
|
559
759
|
}
|
|
560
760
|
}
|
|
561
761
|
if (this.context.logger.isEnabled('debug'))
|
|
@@ -571,6 +771,21 @@ export class Gateway {
|
|
|
571
771
|
});
|
|
572
772
|
return Object.freeze({ id, local });
|
|
573
773
|
}
|
|
774
|
+
/**
|
|
775
|
+
* The bus message (the data JSON inside a JSON envelope, escaped again) must fit the bus's
|
|
776
|
+
* maxPayloadBytes, or the bus refuses it after this process already wrote the event: refused
|
|
777
|
+
* here instead, before any stream gets it (EVENT_TOO_LARGE). Measured only when the data could
|
|
778
|
+
* be too large (escaping at most doubles it), so the usual event costs nothing more.
|
|
779
|
+
*/
|
|
780
|
+
#checkBusSize(target, event, id, json) {
|
|
781
|
+
const limit = this.busPayloadBytes();
|
|
782
|
+
if (limit === undefined || 2 * Buffer.byteLength(json) + ENVELOPE_ROOM <= limit)
|
|
783
|
+
return;
|
|
784
|
+
const bytes = Buffer.byteLength(JSON.stringify({ v: 1, id, origin: this.#origin, target, event, data: json }));
|
|
785
|
+
if (bytes <= limit)
|
|
786
|
+
return;
|
|
787
|
+
throw new SseError('EVENT_TOO_LARGE', `The event's bus message is ${bytes} bytes; the event bus takes ${limit} (maxPayloadBytes). Nothing was sent.`);
|
|
788
|
+
}
|
|
574
789
|
/** A publication from the bus: delivered here unless this process published it. */
|
|
575
790
|
#fromBus(payload) {
|
|
576
791
|
const message = payload;
|
|
@@ -624,7 +839,8 @@ export class Gateway {
|
|
|
624
839
|
}
|
|
625
840
|
let count = 0;
|
|
626
841
|
for (const entry of set)
|
|
627
|
-
if ((filter.
|
|
842
|
+
if ((filter.tenant === undefined || entry.tenant === filter.tenant) &&
|
|
843
|
+
(filter.member === undefined || entry.member === filter.member) &&
|
|
628
844
|
(filter.room === undefined || entry.rooms.has(filter.room)) &&
|
|
629
845
|
(filter.path === undefined || entry.route.path === filter.path))
|
|
630
846
|
count++;
|
|
@@ -653,16 +869,36 @@ export class Gateway {
|
|
|
653
869
|
});
|
|
654
870
|
}
|
|
655
871
|
}
|
|
656
|
-
|
|
657
|
-
this.#frozen = true;
|
|
872
|
+
#ping() {
|
|
658
873
|
this.#heartbeat = setInterval(() => {
|
|
659
874
|
for (const entry of this.#entries.values())
|
|
660
875
|
this.#write(entry, PING);
|
|
661
876
|
}, this.options.heartbeatMs);
|
|
662
877
|
this.#heartbeat.unref();
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* D-0154: reloaded stream limits (retryMs, maxConnections, maxBufferedBytes, maxEventBytes) for
|
|
881
|
+
* the next stream or event, and the ping interval (the timer starts again).
|
|
882
|
+
*/
|
|
883
|
+
reconfigure(next) {
|
|
884
|
+
const restart = this.#heartbeat !== undefined && next.heartbeatMs !== this.options.heartbeatMs;
|
|
885
|
+
this.options = next;
|
|
886
|
+
if (restart) {
|
|
887
|
+
clearInterval(this.#heartbeat);
|
|
888
|
+
this.#ping();
|
|
889
|
+
}
|
|
890
|
+
this.context.logger.log('debug', 'SSE options reloaded', {
|
|
891
|
+
code: 'SSE_RELOADED',
|
|
892
|
+
heartbeatMs: next.heartbeatMs,
|
|
893
|
+
maxConnections: next.maxConnections,
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
start() {
|
|
897
|
+
this.#frozen = true;
|
|
898
|
+
this.#ping();
|
|
663
899
|
if (this.bus) {
|
|
664
900
|
this.#busState();
|
|
665
|
-
this.#watch = setInterval(() => this.#busState(),
|
|
901
|
+
this.#watch = setInterval(() => this.#busState(), this.options.busWatchIntervalMs);
|
|
666
902
|
this.#watch.unref();
|
|
667
903
|
}
|
|
668
904
|
else
|
|
@@ -711,7 +947,9 @@ export class Gateway {
|
|
|
711
947
|
for (const entry of this.#entries.values())
|
|
712
948
|
entry.res.destroy();
|
|
713
949
|
}
|
|
714
|
-
|
|
950
|
+
// A destroyed response emits 'close' later, and only then is its onClose a task: wait for
|
|
951
|
+
// the streams to be gone as well as for the tasks, or a cut stream's onClose outlives drain.
|
|
952
|
+
while ((this.#entries.size || this.#tasks.size) && !signal.aborted)
|
|
715
953
|
await delay(10);
|
|
716
954
|
this.#throttle.flush();
|
|
717
955
|
logger.log('debug', 'SSE drain finished', {
|
package/dist/options.d.ts
CHANGED
|
@@ -22,16 +22,46 @@ export interface Options {
|
|
|
22
22
|
readonly maxEventBytes: number;
|
|
23
23
|
/** How long drain waits for streams to end after `dokebi-restart` before cutting them. */
|
|
24
24
|
readonly shutdownTimeoutMs: number;
|
|
25
|
+
/**
|
|
26
|
+
* How long a stream the server ended may take to send what it still holds before it is
|
|
27
|
+
* destroyed (a client that stopped reading would otherwise keep its slot, D-0107 §2).
|
|
28
|
+
*/
|
|
29
|
+
readonly endTimeoutMs: number;
|
|
30
|
+
/**
|
|
31
|
+
* The Retry-After of the 503 a route answers when a publish fails because the bus is full
|
|
32
|
+
* (`busy`, SSE_BUS_BUSY) or down or draining (`unavailable`, SSE_BUS_UNAVAILABLE), D-0107 §6.
|
|
33
|
+
*/
|
|
34
|
+
readonly busRetryAfterMs: {
|
|
35
|
+
readonly busy: number;
|
|
36
|
+
readonly unavailable: number;
|
|
37
|
+
};
|
|
38
|
+
/** The longest wait for the work before a stream (a session save), D-0114. */
|
|
39
|
+
readonly beforeStreamTimeoutMs: number;
|
|
40
|
+
/** The `retry:` of the `dokebi-reload` frame (a client that ignores it reconnects this slowly). */
|
|
41
|
+
readonly reloadRetryMs: number;
|
|
42
|
+
/** How often the bus state is read for health and its log. */
|
|
43
|
+
readonly busWatchIntervalMs: number;
|
|
44
|
+
/** Window of the repeated stream failure logs. */
|
|
45
|
+
readonly logRepeatWindowMs: number;
|
|
25
46
|
}
|
|
26
47
|
export declare const options: import("@dokebi/core").OptionsDeclaration<{
|
|
27
48
|
readonly allowInCluster: boolean;
|
|
49
|
+
readonly beforeStreamTimeoutMs: number;
|
|
28
50
|
readonly busEvent: string;
|
|
51
|
+
readonly busRetryAfterMs: {
|
|
52
|
+
readonly busy: number;
|
|
53
|
+
readonly unavailable: number;
|
|
54
|
+
};
|
|
55
|
+
readonly busWatchIntervalMs: number;
|
|
56
|
+
readonly endTimeoutMs: number;
|
|
29
57
|
readonly eventBus?: string;
|
|
30
58
|
readonly heartbeatMs: number;
|
|
59
|
+
readonly logRepeatWindowMs: number;
|
|
31
60
|
readonly maxBufferedBytes: number;
|
|
32
61
|
readonly maxConnections: number;
|
|
33
62
|
readonly maxEventBytes: number;
|
|
34
63
|
readonly maxRooms: number;
|
|
64
|
+
readonly reloadRetryMs: number;
|
|
35
65
|
readonly retryMs: number;
|
|
36
66
|
readonly shutdownTimeoutMs: number;
|
|
37
67
|
}>;
|