@dokebi/sse 0.4.1 → 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 +31 -17
- 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 +243 -35
- 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,14 +1,15 @@
|
|
|
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
14
|
function add(map, name, entry) {
|
|
14
15
|
const set = map.get(name);
|
|
@@ -25,6 +26,19 @@ function remove(map, name, entry) {
|
|
|
25
26
|
if (!set.size)
|
|
26
27
|
map.delete(name);
|
|
27
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
|
+
}
|
|
28
42
|
function checkRoom(room) {
|
|
29
43
|
if (!text(room))
|
|
30
44
|
throw new SseError('INVALID_ROOM', 'Rooms are strings of 1 to 256 characters.');
|
|
@@ -48,6 +62,23 @@ function checkTarget(target) {
|
|
|
48
62
|
...(value.room === undefined ? {} : { room: value.room }),
|
|
49
63
|
});
|
|
50
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
|
+
}
|
|
51
82
|
/** Allow-list entries must be exact http(s) origins such as `https://app.example.com`. */
|
|
52
83
|
function parseOrigins(value) {
|
|
53
84
|
if (value === undefined)
|
|
@@ -99,6 +130,7 @@ export class Gateway {
|
|
|
99
130
|
web;
|
|
100
131
|
options;
|
|
101
132
|
bus;
|
|
133
|
+
busPayloadBytes;
|
|
102
134
|
api;
|
|
103
135
|
#entries = new Map();
|
|
104
136
|
/** tenant → its streams, by member and by room (see TenantIndex). */
|
|
@@ -122,12 +154,18 @@ export class Gateway {
|
|
|
122
154
|
#subscription;
|
|
123
155
|
#health;
|
|
124
156
|
#busUp;
|
|
125
|
-
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) {
|
|
126
160
|
this.context = context;
|
|
127
161
|
this.web = web;
|
|
128
162
|
this.options = options;
|
|
129
163
|
this.bus = bus;
|
|
130
|
-
this
|
|
164
|
+
this.busPayloadBytes = busPayloadBytes;
|
|
165
|
+
this.#throttle = new diagnostics.Repeats({
|
|
166
|
+
logger: context.logger,
|
|
167
|
+
intervalMs: this.options.logRepeatWindowMs,
|
|
168
|
+
});
|
|
131
169
|
if (health) {
|
|
132
170
|
this.#health = health.register('sse', { required: false });
|
|
133
171
|
const handle = this.#health;
|
|
@@ -136,7 +174,7 @@ export class Gateway {
|
|
|
136
174
|
const self = this;
|
|
137
175
|
this.api = Object.freeze({
|
|
138
176
|
route: (route) => this.#route(route),
|
|
139
|
-
publish: (target, event, data) => this.#publish(target, event, data),
|
|
177
|
+
publish: (target, event, data, options) => this.#publish(target, event, data, options),
|
|
140
178
|
connections: (filter) => this.#count(filter),
|
|
141
179
|
get bus() {
|
|
142
180
|
return self.options.eventBus;
|
|
@@ -169,9 +207,11 @@ export class Gateway {
|
|
|
169
207
|
(route.constraints !== undefined &&
|
|
170
208
|
(typeof route.constraints !== 'object' || route.constraints === null)))
|
|
171
209
|
throw new SseError('INVALID_ROUTE', 'Invalid SSE route.');
|
|
210
|
+
const config = parseConfig(route.config);
|
|
172
211
|
const registered = {
|
|
173
212
|
route: Object.freeze({ ...route }),
|
|
174
213
|
origins: parseOrigins(route.origins),
|
|
214
|
+
id: typeof config?.id === 'string' ? config.id : null,
|
|
175
215
|
};
|
|
176
216
|
this.web.route({
|
|
177
217
|
method: 'GET',
|
|
@@ -179,11 +219,14 @@ export class Gateway {
|
|
|
179
219
|
// A HEAD request would open a stream whose body Node discards: answer only GET.
|
|
180
220
|
exposeHeadRoute: false,
|
|
181
221
|
...(route.constraints ? { constraints: { ...route.constraints } } : {}),
|
|
222
|
+
// D-0140: dokebi-web records and checks `config.id` (describe, duplicates, route hooks).
|
|
223
|
+
...(config ? { config: { ...config } } : {}),
|
|
182
224
|
handler: (request, reply) => this.#open(request, reply, registered),
|
|
183
225
|
});
|
|
184
226
|
this.#routes++;
|
|
185
227
|
this.context.logger.log('debug', 'SSE route registered', {
|
|
186
228
|
path: route.path,
|
|
229
|
+
routeId: registered.id,
|
|
187
230
|
version: route.version ?? null,
|
|
188
231
|
origins: registered.origins === '*' ? '*' : registered.origins ? registered.origins.size : null,
|
|
189
232
|
});
|
|
@@ -204,23 +247,48 @@ export class Gateway {
|
|
|
204
247
|
if (logger.isEnabled('debug'))
|
|
205
248
|
logger.log('debug', 'SSE origin refused', {
|
|
206
249
|
path: route.path,
|
|
250
|
+
routeId: registered.id,
|
|
207
251
|
origin: String(request.headers.origin).slice(0, 200),
|
|
208
252
|
});
|
|
209
253
|
return reply.code(403).send({ error: 'ORIGIN_FORBIDDEN' });
|
|
210
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);
|
|
211
262
|
let refused = this.#refusal();
|
|
212
263
|
if (refused) {
|
|
213
|
-
logger.log('debug', 'SSE stream refused', {
|
|
214
|
-
|
|
215
|
-
.
|
|
216
|
-
|
|
217
|
-
|
|
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' });
|
|
218
270
|
}
|
|
219
271
|
const identity = await route.authenticate(request);
|
|
220
|
-
|
|
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) {
|
|
221
286
|
if (logger.isEnabled('debug'))
|
|
222
|
-
logger.log('debug', 'SSE stream unauthorized', {
|
|
223
|
-
|
|
287
|
+
logger.log('debug', 'SSE stream unauthorized', {
|
|
288
|
+
path: route.path,
|
|
289
|
+
routeId: registered.id,
|
|
290
|
+
});
|
|
291
|
+
return refuse(401, { error: 'UNAUTHORIZED' });
|
|
224
292
|
}
|
|
225
293
|
// An invalid identity is an application bug: dokebi-web answers 500 and logs it.
|
|
226
294
|
const rooms = this.#identity(identity);
|
|
@@ -231,16 +299,45 @@ export class Gateway {
|
|
|
231
299
|
.code(204)
|
|
232
300
|
.headers(allowed ?? {})
|
|
233
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
|
+
});
|
|
234
322
|
refused = this.#refusal();
|
|
235
323
|
if (refused) {
|
|
236
|
-
logger.log('debug', 'SSE stream refused', {
|
|
237
|
-
|
|
238
|
-
.
|
|
239
|
-
|
|
240
|
-
|
|
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' });
|
|
241
330
|
}
|
|
242
331
|
const res = reply.raw;
|
|
243
|
-
|
|
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
|
+
};
|
|
244
341
|
// D-0062: a stream of a seal host seals the data of every event, its first ones included.
|
|
245
342
|
const seal = this.web.sealOf(request);
|
|
246
343
|
const data = (json) => (seal ? seal.encode(json) : json);
|
|
@@ -254,7 +351,7 @@ export class Gateway {
|
|
|
254
351
|
});
|
|
255
352
|
reply.hijack();
|
|
256
353
|
res.writeHead(200, headers);
|
|
257
|
-
res.end(`retry: ${
|
|
354
|
+
res.end(`retry: ${this.options.reloadRetryMs}\n${frame(undefined, RELOAD_EVENT, data(JSON.stringify({ version: route.version })))}`);
|
|
258
355
|
return;
|
|
259
356
|
}
|
|
260
357
|
}
|
|
@@ -272,6 +369,16 @@ export class Gateway {
|
|
|
272
369
|
res.write(`retry: ${this.options.retryMs}\n${frame(undefined, OPEN_EVENT, data(JSON.stringify({ retryMs: this.options.retryMs })))}`);
|
|
273
370
|
const entry = this.#add(res, identity, route, rooms, lastEventId, seal);
|
|
274
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
|
+
}
|
|
275
382
|
res.on('error', (error) => {
|
|
276
383
|
entry.reason ??= 'WRITE_FAILED';
|
|
277
384
|
const code = diagnostics.errorCode(error, 'UNKNOWN');
|
|
@@ -331,6 +438,7 @@ export class Gateway {
|
|
|
331
438
|
reason: undefined,
|
|
332
439
|
closed: false,
|
|
333
440
|
stream: undefined,
|
|
441
|
+
endTimer: undefined,
|
|
334
442
|
};
|
|
335
443
|
entry.stream = Object.freeze({
|
|
336
444
|
id: entry.id,
|
|
@@ -345,10 +453,11 @@ export class Gateway {
|
|
|
345
453
|
get closed() {
|
|
346
454
|
return entry.closed || entry.reason !== undefined;
|
|
347
455
|
},
|
|
348
|
-
send: (event, data) => {
|
|
456
|
+
send: (event, data, options) => {
|
|
349
457
|
checkEvent(event);
|
|
458
|
+
const id = eventId(options) ?? this.#nextId();
|
|
350
459
|
const json = encodeData(data, this.options.maxEventBytes);
|
|
351
|
-
return this.#event(entry,
|
|
460
|
+
return this.#event(entry, id, event, json);
|
|
352
461
|
},
|
|
353
462
|
join: (room) => this.#join(entry, checkRoom(room)),
|
|
354
463
|
leave: (room) => this.#leave(entry, checkRoom(room)),
|
|
@@ -424,6 +533,17 @@ export class Gateway {
|
|
|
424
533
|
#write(entry, chunk) {
|
|
425
534
|
if (entry.closed || entry.reason !== undefined)
|
|
426
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
|
+
}
|
|
427
547
|
const res = entry.res;
|
|
428
548
|
// ServerResponse.writableLength counts what waits in the response and in its socket.
|
|
429
549
|
if (res.writableLength + chunk.byteLength > this.options.maxBufferedBytes) {
|
|
@@ -444,15 +564,54 @@ export class Gateway {
|
|
|
444
564
|
if (entry.closed || entry.reason !== undefined)
|
|
445
565
|
return;
|
|
446
566
|
entry.reason = reason;
|
|
447
|
-
if (destroy)
|
|
567
|
+
if (destroy) {
|
|
448
568
|
entry.res.destroy();
|
|
449
|
-
|
|
450
|
-
|
|
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
|
+
});
|
|
451
598
|
}
|
|
452
599
|
#closed(entry) {
|
|
453
600
|
if (entry.closed)
|
|
454
601
|
return;
|
|
455
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
|
+
}
|
|
456
615
|
const reason = entry.reason ?? 'CLIENT_CLOSED';
|
|
457
616
|
entry.reason = reason;
|
|
458
617
|
this.#entries.delete(entry.id);
|
|
@@ -549,22 +708,26 @@ export class Gateway {
|
|
|
549
708
|
}
|
|
550
709
|
return written;
|
|
551
710
|
}
|
|
552
|
-
async #publish(target, event, data) {
|
|
711
|
+
async #publish(target, event, data, options) {
|
|
553
712
|
if (this.#shut)
|
|
554
713
|
throw new SseError('CLOSED', 'dokebi-sse is closed: the application has stopped.');
|
|
555
714
|
const checked = checkTarget(target);
|
|
556
715
|
checkEvent(event);
|
|
716
|
+
const chosen = eventId(options);
|
|
557
717
|
const json = encodeData(data, this.options.maxEventBytes);
|
|
558
718
|
const bus = this.bus;
|
|
559
719
|
if (bus && bus.state !== 'ready') {
|
|
560
|
-
|
|
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 });
|
|
561
722
|
this.#throttle.log('error', 'SSE publish refused; bus unavailable', 'BUS_UNAVAILABLE', error, {
|
|
562
723
|
bus: this.options.eventBus,
|
|
563
724
|
state: bus.state,
|
|
564
725
|
});
|
|
565
726
|
throw error;
|
|
566
727
|
}
|
|
567
|
-
const id = this.#nextId();
|
|
728
|
+
const id = chosen ?? this.#nextId();
|
|
729
|
+
if (bus)
|
|
730
|
+
this.#checkBusSize(checked, event, id, json);
|
|
568
731
|
const local = this.#closing ? 0 : this.#deliver(checked, event, id, json);
|
|
569
732
|
if (bus) {
|
|
570
733
|
try {
|
|
@@ -583,8 +746,16 @@ export class Gateway {
|
|
|
583
746
|
id,
|
|
584
747
|
local,
|
|
585
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] } : {}),
|
|
586
758
|
});
|
|
587
|
-
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 });
|
|
588
759
|
}
|
|
589
760
|
}
|
|
590
761
|
if (this.context.logger.isEnabled('debug'))
|
|
@@ -600,6 +771,21 @@ export class Gateway {
|
|
|
600
771
|
});
|
|
601
772
|
return Object.freeze({ id, local });
|
|
602
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
|
+
}
|
|
603
789
|
/** A publication from the bus: delivered here unless this process published it. */
|
|
604
790
|
#fromBus(payload) {
|
|
605
791
|
const message = payload;
|
|
@@ -683,16 +869,36 @@ export class Gateway {
|
|
|
683
869
|
});
|
|
684
870
|
}
|
|
685
871
|
}
|
|
686
|
-
|
|
687
|
-
this.#frozen = true;
|
|
872
|
+
#ping() {
|
|
688
873
|
this.#heartbeat = setInterval(() => {
|
|
689
874
|
for (const entry of this.#entries.values())
|
|
690
875
|
this.#write(entry, PING);
|
|
691
876
|
}, this.options.heartbeatMs);
|
|
692
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();
|
|
693
899
|
if (this.bus) {
|
|
694
900
|
this.#busState();
|
|
695
|
-
this.#watch = setInterval(() => this.#busState(),
|
|
901
|
+
this.#watch = setInterval(() => this.#busState(), this.options.busWatchIntervalMs);
|
|
696
902
|
this.#watch.unref();
|
|
697
903
|
}
|
|
698
904
|
else
|
|
@@ -741,7 +947,9 @@ export class Gateway {
|
|
|
741
947
|
for (const entry of this.#entries.values())
|
|
742
948
|
entry.res.destroy();
|
|
743
949
|
}
|
|
744
|
-
|
|
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)
|
|
745
953
|
await delay(10);
|
|
746
954
|
this.#throttle.flush();
|
|
747
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
|
}>;
|
package/dist/options.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { defineOptions, option as o } from '@dokebi/core';
|
|
2
2
|
import { SseError } from './errors.js';
|
|
3
|
-
const int = (minimum, maximum, fallback, description) => o.integer({ minimum, maximum, default: fallback, description });
|
|
3
|
+
const int = (minimum, maximum, fallback, description, more = {}) => o.integer({ minimum, maximum, default: fallback, description, ...more });
|
|
4
|
+
/** D-0154: applied while the app runs (read per stream or per event; the ping timer restarts). */
|
|
5
|
+
const live = { reloadable: true };
|
|
4
6
|
/*
|
|
5
7
|
* Options of dokebi-sse (D-0063 §3): one declaration is the parser and, at build time,
|
|
6
8
|
* options.schema.json.
|
|
@@ -22,14 +24,26 @@ export const options = defineOptions({
|
|
|
22
24
|
default: false,
|
|
23
25
|
description: 'Start in a node:cluster worker without a nats bus (a deliberate single-worker cluster). Otherwise that start fails with LOCAL_IN_CLUSTER. The bus driver is read from dokebi-event-bus.buses in the application config.',
|
|
24
26
|
}),
|
|
25
|
-
retryMs: int(100, 3_600_000, 3000, "The retry: hint (browser reconnect delay), also the browser client's minimum backoff."),
|
|
26
|
-
heartbeatMs: int(10, 3_600_000, 15000, "How often a ': ping' comment is written to every stream. Proxy idle timeouts must be longer."),
|
|
27
|
-
maxConnections: int(1, 1_000_000, 1024, 'Open streams per process (per cluster worker); more get 503 CAPACITY.'),
|
|
28
|
-
maxBufferedBytes: int(1, 67_108_864, 1_048_576, 'Bytes a stream may have waiting to be sent; past it the stream is ended (warn SLOW_CONSUMER).'),
|
|
27
|
+
retryMs: int(100, 3_600_000, 3000, "The retry: hint (browser reconnect delay), also the browser client's minimum backoff.", live),
|
|
28
|
+
heartbeatMs: int(10, 3_600_000, 15000, "How often a ': ping' comment is written to every stream. Proxy idle timeouts must be longer.", live),
|
|
29
|
+
maxConnections: int(1, 1_000_000, 1024, 'Open streams per process (per cluster worker); more get 503 CAPACITY.', live),
|
|
30
|
+
maxBufferedBytes: int(1, 67_108_864, 1_048_576, 'Bytes a stream may have waiting to be sent; past it the stream is ended (warn SLOW_CONSUMER).', live),
|
|
29
31
|
maxRooms: int(0, 1024, 32, 'Rooms one stream may be in.'),
|
|
30
|
-
maxEventBytes: int(1, 16_777_216, 65_536, 'Largest JSON data of one event in bytes (EVENT_TOO_LARGE). Keep it below the bus maxPayloadBytes.'),
|
|
32
|
+
maxEventBytes: int(1, 16_777_216, 65_536, 'Largest JSON data of one event in bytes (EVENT_TOO_LARGE). Keep it below the bus maxPayloadBytes.', live),
|
|
31
33
|
shutdownTimeoutMs: int(1, 600_000, 2000, 'How long drain waits for streams to end after dokebi-restart before cutting them.'),
|
|
32
34
|
closeTimeoutMs: o.alias('shutdownTimeoutMs'),
|
|
35
|
+
endTimeoutMs: int(1, 600_000, 5000, 'How long a stream the server ended (stream.close(), revoked authorization, drain) may take to send its waiting bytes before it is destroyed (warn STREAM_END_TIMEOUT); frees the slot of a client that stopped reading.'),
|
|
36
|
+
busRetryAfterMs: o.object({
|
|
37
|
+
busy: int(1000, 3_600_000, 1000, 'Retry-After (ms, sent in whole seconds) of the 503 SSE_BUS_BUSY: the bus refused a publication at its pending limit.'),
|
|
38
|
+
unavailable: int(1000, 3_600_000, 5000, 'Retry-After (ms, sent in whole seconds) of the 503 SSE_BUS_UNAVAILABLE: the bus or its NATS connection is down or draining.'),
|
|
39
|
+
}, {
|
|
40
|
+
default: { busy: 1000, unavailable: 5000 },
|
|
41
|
+
description: 'Retry-After of the retryable 503 a route answers when a publish fails for a transient bus reason (D-0107); size it near how long the bus takes to recover.',
|
|
42
|
+
}),
|
|
43
|
+
beforeStreamTimeoutMs: int(100, 600_000, 5000, 'The longest wait for the work that must finish before a stream starts (the session save and auth cookies, D-0089); slower refuses the stream with 500 COMMIT_FAILED (D-0114).'),
|
|
44
|
+
reloadRetryMs: int(100, 86_400_000, 60_000, 'The retry: sent with dokebi-reload (a route whose version changed): a client that ignores the reload reconnects this slowly (D-0114).'),
|
|
45
|
+
busWatchIntervalMs: int(100, 60_000, 1000, 'How often the event bus state is read for the health check and its warn/info log; a bus outage shows within this long (D-0114).'),
|
|
46
|
+
logRepeatWindowMs: int(1000, 3_600_000, 10_000, 'Window of the repeated stream failure logs (a failed before-stream step, a failed write) in ms: the first is logged in full, the rest of the window are counted (D-0114).'),
|
|
33
47
|
});
|
|
34
48
|
/**
|
|
35
49
|
* Validates the plugin options (no accessors or Proxies are evaluated) and fills in defaults.
|
package/dist/plugin.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { definePlugin, optionalDependency, providerDependency, } from '@dokebi/core';
|
|
2
|
-
// Always installed with this plugin (peer, required in the manifest): the provider's own file
|
|
3
|
-
// holds the name, package and range (D-0063 §6).
|
|
4
|
-
import { dependency as web } from '@dokebi/web/dependency';
|
|
5
2
|
import { realtime } from '@dokebi/web/points';
|
|
6
3
|
import { SseError } from './errors.js';
|
|
7
4
|
import { Gateway } from './gateway.js';
|
|
8
5
|
import { options as declaration } from './options.js';
|
|
9
6
|
import { sseTransport } from './transport.js';
|
|
7
|
+
/**
|
|
8
|
+
* dokebi-web 0.4.3 or later: the shared before-hijack steps (`@dokebi/web/points`). A local
|
|
9
|
+
* descriptor instead of '@dokebi/web/dependency' (whose range stays ^0.4.0), as in dokebi-auth.
|
|
10
|
+
*/
|
|
11
|
+
const web = providerDependency({
|
|
12
|
+
name: 'dokebi-web',
|
|
13
|
+
version: '^0.4.3',
|
|
14
|
+
packageName: '@dokebi/web',
|
|
15
|
+
load: () => import('@dokebi/web'),
|
|
16
|
+
});
|
|
10
17
|
/** Only when `bus` is set; created from `app.config['dokebi-event-bus']` when not registered. */
|
|
11
18
|
const events = providerDependency({
|
|
12
19
|
name: 'dokebi-event-bus',
|
|
@@ -26,6 +33,18 @@ function configuredDriver(config, bus) {
|
|
|
26
33
|
const driver = value?.driver ?? value?.transport;
|
|
27
34
|
return typeof driver === 'string' ? driver : undefined;
|
|
28
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* The maxPayloadBytes of a bus as the application config says (default 1048576 when the bus is
|
|
38
|
+
* configured without it); undefined when the bus options were given in code only.
|
|
39
|
+
*/
|
|
40
|
+
function configuredPayloadBytes(config, bus) {
|
|
41
|
+
const options = config?.['dokebi-event-bus'];
|
|
42
|
+
const value = options?.buses?.[bus];
|
|
43
|
+
if (value === undefined || value === null || typeof value !== 'object')
|
|
44
|
+
return undefined;
|
|
45
|
+
const bytes = value.maxPayloadBytes;
|
|
46
|
+
return bytes === undefined ? 1_048_576 : typeof bytes === 'number' ? bytes : undefined;
|
|
47
|
+
}
|
|
29
48
|
class SseRuntime {
|
|
30
49
|
options;
|
|
31
50
|
#gateway;
|
|
@@ -57,7 +76,11 @@ class SseRuntime {
|
|
|
57
76
|
const bus = options.eventBus === undefined
|
|
58
77
|
? undefined
|
|
59
78
|
: context.getDependency(events).bus(options.eventBus);
|
|
60
|
-
const gateway = new Gateway(context, context.getDependency(web), options, bus, context.getOptionalDependency(health)
|
|
79
|
+
const gateway = new Gateway(context, context.getDependency(web), options, bus, context.getOptionalDependency(health),
|
|
80
|
+
// Read at each check: the bus limit reloads (D-0154).
|
|
81
|
+
options.eventBus === undefined
|
|
82
|
+
? undefined
|
|
83
|
+
: () => configuredPayloadBytes(context.app.config, options.eventBus));
|
|
61
84
|
context.defer(() => gateway.close());
|
|
62
85
|
this.#gateway = gateway;
|
|
63
86
|
await gateway.subscribe();
|
|
@@ -74,6 +97,11 @@ class SseRuntime {
|
|
|
74
97
|
start() {
|
|
75
98
|
this.#gateway?.start();
|
|
76
99
|
}
|
|
100
|
+
/** D-0154: the stream limits and the ping interval (see Gateway.reconfigure). */
|
|
101
|
+
reload({ options }) {
|
|
102
|
+
const next = Object.freeze({ ...options, eventBus: options.eventBus });
|
|
103
|
+
return { apply: () => this.#gateway?.reconfigure(next) };
|
|
104
|
+
}
|
|
77
105
|
async drain(context) {
|
|
78
106
|
await this.#gateway?.drain(context.drainSignal);
|
|
79
107
|
}
|
|
@@ -81,8 +109,8 @@ class SseRuntime {
|
|
|
81
109
|
/** Factory input: `{ app, options? }`. Options default to `app.config['dokebi-sse']`. */
|
|
82
110
|
export const createPlugin = definePlugin({
|
|
83
111
|
name: 'dokebi-sse',
|
|
84
|
-
version: '0.4.
|
|
85
|
-
core: '^0.4.
|
|
112
|
+
version: '0.4.2',
|
|
113
|
+
core: '^0.4.2',
|
|
86
114
|
// The declaration (not parseOptions) so core logs OPTION_DEPRECATED for old keys (bus, closeTimeoutMs).
|
|
87
115
|
options: declaration,
|
|
88
116
|
contributes: [realtime],
|
package/dist/retry.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function transientBusFailure(cause: unknown): 'busy' | 'unavailable' | undefined;
|