@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/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.#throttle = new diagnostics.Repeats({ logger: context.logger });
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', { path: route.path, reason: refused });
216
- return reply
217
- .code(503)
218
- .header('retry-after', '5')
219
- .send({ error: 'UNAVAILABLE', reason: refused });
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
- if (!identity) {
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', { path: route.path });
225
- return reply.code(401).send({ error: 'UNAUTHORIZED' });
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', { path: route.path, reason: refused });
239
- return reply
240
- .code(503)
241
- .header('retry-after', '5')
242
- .send({ error: 'UNAVAILABLE', reason: refused });
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
- const headers = { ...HEADERS, ...allowed };
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: ${RELOAD_RETRY_MS}\n${frame(undefined, RELOAD_EVENT, data(JSON.stringify({ version: route.version })))}`);
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, this.#nextId(), event, json);
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
- add(this.#tenants, entry.tenant, entry);
361
- add(this.#members, key(entry.tenant, entry.member), entry);
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(this.#rooms, key(entry.tenant, room), entry);
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
- remove(this.#rooms, key(entry.tenant, room), entry);
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
- else
443
- entry.res.end();
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
- remove(this.#tenants, entry.tenant, entry);
453
- remove(this.#members, key(entry.tenant, entry.member), entry);
454
- for (const room of entry.rooms)
455
- remove(this.#rooms, key(entry.tenant, room), entry);
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 this.#rooms.get(key(target.tenant, target.room));
658
+ return index.rooms.get(target.room);
485
659
  if (target.member !== undefined)
486
- return this.#members.get(key(target.tenant, target.member));
487
- return this.#tenants.get(target.tenant);
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
- written += this.#writeAll(this.#select({ ...target, tenant }), target, shared);
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 (target.room !== undefined &&
513
- target.member !== undefined &&
514
- entry.member !== target.member)
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
- const error = new SseError('BUS_UNAVAILABLE', `Event bus ${this.options.eventBus} is ${bus.state}; the event was not sent.`);
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.member === undefined || entry.member === filter.member) &&
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
- start() {
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(), BUS_WATCH_MS);
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
- while (this.#tasks.size && !signal.aborted)
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
  }>;