@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/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.#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
+ });
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', { path: route.path, reason: refused });
214
- return reply
215
- .code(503)
216
- .header('retry-after', '5')
217
- .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' });
218
270
  }
219
271
  const identity = await route.authenticate(request);
220
- 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) {
221
286
  if (logger.isEnabled('debug'))
222
- logger.log('debug', 'SSE stream unauthorized', { path: route.path });
223
- 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' });
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', { path: route.path, reason: refused });
237
- return reply
238
- .code(503)
239
- .header('retry-after', '5')
240
- .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' });
241
330
  }
242
331
  const res = reply.raw;
243
- 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
+ };
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: ${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 })))}`);
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, this.#nextId(), event, json);
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
- else
450
- 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
+ });
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
- 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 });
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
- start() {
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(), BUS_WATCH_MS);
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
- 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)
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.1',
85
- core: '^0.4.0',
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],
@@ -0,0 +1 @@
1
+ export declare function transientBusFailure(cause: unknown): 'busy' | 'unavailable' | undefined;