@dokebi/sse 0.1.0 → 0.4.1

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,7 +1,7 @@
1
1
  import { randomBytes, randomUUID } from 'node:crypto';
2
2
  import { setTimeout as delay } from 'node:timers/promises';
3
+ import { diagnostics } from '@dokebi/core/libraries';
3
4
  import { SseError } from './errors.js';
4
- import { errorCode, LogThrottle } from './log.js';
5
5
  import { OPEN_EVENT, RELOAD_EVENT, RESTART_EVENT, } from './types.js';
6
6
  import { checkEvent, EVENT, encodeData, frame, HEADERS, ID, isLine, PING } from './wire.js';
7
7
  /** A client that ignores `dokebi-reload` comes back this slowly (EventSource honours `retry:`). */
@@ -10,7 +10,6 @@ const RELOAD_RETRY_MS = 60_000;
10
10
  const BUS_WATCH_MS = 1000;
11
11
  const MAX_TEXT = 256;
12
12
  const text = (value) => typeof value === 'string' && value.length > 0 && value.length <= MAX_TEXT;
13
- const key = (tenant, value) => `${tenant}\u0000${value}`;
14
13
  function add(map, name, entry) {
15
14
  const set = map.get(name);
16
15
  if (set)
@@ -102,11 +101,11 @@ export class Gateway {
102
101
  bus;
103
102
  api;
104
103
  #entries = new Map();
104
+ /** tenant → its streams, by member and by room (see TenantIndex). */
105
105
  #tenants = new Map();
106
- #members = new Map();
107
- #rooms = new Map();
108
106
  /** onOpen/onClose calls in progress, awaited by drain (bounded by the drain deadline). */
109
107
  #tasks = new Set();
108
+ /** Repeated hot-path events: the first at once, then one summary per interval (D-0047). */
110
109
  #throttle;
111
110
  /** Event ids: `<prefix>-<counter>`; the prefix tells processes apart, the counter only grows. */
112
111
  #prefix = randomBytes(4).toString('hex');
@@ -116,6 +115,8 @@ export class Gateway {
116
115
  #routes = 0;
117
116
  #frozen = false;
118
117
  #closing = false;
118
+ /** Cleanup ran (`close()`): the bus and the streams are gone, publish throws CLOSED. */
119
+ #shut = false;
119
120
  #heartbeat;
120
121
  #watch;
121
122
  #subscription;
@@ -126,7 +127,7 @@ export class Gateway {
126
127
  this.web = web;
127
128
  this.options = options;
128
129
  this.bus = bus;
129
- this.#throttle = new LogThrottle(context.logger);
130
+ this.#throttle = new diagnostics.Repeats({ logger: context.logger });
130
131
  if (health) {
131
132
  this.#health = health.register('sse', { required: false });
132
133
  const handle = this.#health;
@@ -138,7 +139,7 @@ export class Gateway {
138
139
  publish: (target, event, data) => this.#publish(target, event, data),
139
140
  connections: (filter) => this.#count(filter),
140
141
  get bus() {
141
- return self.options.bus;
142
+ return self.options.eventBus;
142
143
  },
143
144
  });
144
145
  }
@@ -150,7 +151,7 @@ export class Gateway {
150
151
  this.#subscription = subscription;
151
152
  await subscription.ready;
152
153
  this.context.logger.log('debug', 'SSE bus subscribed', {
153
- bus: this.options.bus,
154
+ bus: this.options.eventBus,
154
155
  event: this.options.busEvent,
155
156
  origin: this.#origin,
156
157
  });
@@ -273,10 +274,10 @@ export class Gateway {
273
274
  res.once('close', () => this.#closed(entry));
274
275
  res.on('error', (error) => {
275
276
  entry.reason ??= 'WRITE_FAILED';
276
- const code = errorCode(error, 'UNKNOWN');
277
+ const code = diagnostics.errorCode(error, 'UNKNOWN');
277
278
  // A client that went away mid-write is not a server failure.
278
279
  const gone = code === 'ECONNRESET' || code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED';
279
- this.#throttle.report(gone ? 'debug' : 'error', 'SSE stream write failed', 'STREAM_WRITE_FAILED', error, { path: route.path, reason: code });
280
+ this.#throttle.log(gone ? 'debug' : 'error', 'SSE stream write failed', 'STREAM_WRITE_FAILED', error, { path: route.path, reason: code });
280
281
  });
281
282
  if (logger.isEnabled('debug'))
282
283
  logger.log('debug', 'SSE stream opened', {
@@ -295,7 +296,7 @@ export class Gateway {
295
296
  await this.#task(Promise.resolve()
296
297
  .then(() => onOpen(entry.stream))
297
298
  .catch((error) => {
298
- this.#throttle.report('error', 'SSE onOpen failed; stream ended', 'SSE_ON_OPEN_FAILED', error, {
299
+ this.#throttle.log('error', 'SSE onOpen failed; stream ended', 'SSE_ON_OPEN_FAILED', error, {
299
300
  path: route.path,
300
301
  stream: entry.id,
301
302
  });
@@ -354,8 +355,13 @@ export class Gateway {
354
355
  close: () => this.#end(entry, 'SERVER_CLOSED'),
355
356
  });
356
357
  this.#entries.set(entry.id, entry);
357
- add(this.#tenants, entry.tenant, entry);
358
- add(this.#members, key(entry.tenant, entry.member), entry);
358
+ let index = this.#tenants.get(entry.tenant);
359
+ if (!index) {
360
+ index = { all: new Set(), members: new Map(), rooms: new Map() };
361
+ this.#tenants.set(entry.tenant, index);
362
+ }
363
+ index.all.add(entry);
364
+ add(index.members, entry.member, entry);
359
365
  for (const room of rooms)
360
366
  this.#join(entry, room);
361
367
  return entry;
@@ -365,8 +371,10 @@ export class Gateway {
365
371
  return;
366
372
  if (entry.rooms.size >= this.options.maxRooms)
367
373
  throw new SseError('ROOM_LIMIT', `A stream may be in ${this.options.maxRooms} rooms.`);
374
+ // An open entry is always indexed under its tenant (#add before any join, #closed after).
375
+ const index = this.#tenants.get(entry.tenant);
368
376
  entry.rooms.add(room);
369
- add(this.#rooms, key(entry.tenant, room), entry);
377
+ add(index.rooms, room, entry);
370
378
  if (this.context.logger.isEnabled('debug'))
371
379
  this.context.logger.log('debug', 'SSE room joined', {
372
380
  stream: entry.id,
@@ -377,7 +385,9 @@ export class Gateway {
377
385
  #leave(entry, room) {
378
386
  if (!entry.rooms.delete(room))
379
387
  return;
380
- remove(this.#rooms, key(entry.tenant, room), entry);
388
+ const index = this.#tenants.get(entry.tenant);
389
+ if (index)
390
+ remove(index.rooms, room, entry);
381
391
  if (this.context.logger.isEnabled('debug'))
382
392
  this.context.logger.log('debug', 'SSE room left', {
383
393
  stream: entry.id,
@@ -401,7 +411,7 @@ export class Gateway {
401
411
  chunk = Buffer.from(frame(id, event, entry.seal.encode(json)));
402
412
  }
403
413
  catch (error) {
404
- this.#throttle.report('error', 'SSE event seal failed; stream ended', 'SEAL_ENCODE_FAILED', error, {
414
+ this.#throttle.log('error', 'SSE event seal failed; stream ended', 'SEAL_ENCODE_FAILED', error, {
405
415
  stream: entry.id,
406
416
  path: entry.route.path,
407
417
  });
@@ -417,7 +427,7 @@ export class Gateway {
417
427
  const res = entry.res;
418
428
  // ServerResponse.writableLength counts what waits in the response and in its socket.
419
429
  if (res.writableLength + chunk.byteLength > this.options.maxBufferedBytes) {
420
- this.#throttle.report('warn', 'SSE slow consumer ended', 'SLOW_CONSUMER', undefined, {
430
+ this.#throttle.log('warn', 'SSE slow consumer ended', 'SLOW_CONSUMER', undefined, {
421
431
  stream: entry.id,
422
432
  path: entry.route.path,
423
433
  bufferedBytes: res.writableLength,
@@ -446,10 +456,15 @@ export class Gateway {
446
456
  const reason = entry.reason ?? 'CLIENT_CLOSED';
447
457
  entry.reason = reason;
448
458
  this.#entries.delete(entry.id);
449
- remove(this.#tenants, entry.tenant, entry);
450
- remove(this.#members, key(entry.tenant, entry.member), entry);
451
- for (const room of entry.rooms)
452
- remove(this.#rooms, key(entry.tenant, room), entry);
459
+ const index = this.#tenants.get(entry.tenant);
460
+ if (index) {
461
+ index.all.delete(entry);
462
+ remove(index.members, entry.member, entry);
463
+ for (const room of entry.rooms)
464
+ remove(index.rooms, room, entry);
465
+ if (!index.all.size)
466
+ this.#tenants.delete(entry.tenant);
467
+ }
453
468
  if (this.context.logger.isEnabled('debug'))
454
469
  this.context.logger.log('debug', 'SSE stream closed', {
455
470
  stream: entry.id,
@@ -464,7 +479,7 @@ export class Gateway {
464
479
  void this.#task(Promise.resolve()
465
480
  .then(() => onClose(entry.stream, reason))
466
481
  .catch((error) => {
467
- this.#throttle.report('error', 'SSE onClose failed', 'SSE_ON_CLOSE_FAILED', error, {
482
+ this.#throttle.log('error', 'SSE onClose failed', 'SSE_ON_CLOSE_FAILED', error, {
468
483
  path: entry.route.path,
469
484
  stream: entry.id,
470
485
  reason,
@@ -477,11 +492,14 @@ export class Gateway {
477
492
  return task.finally(() => this.#tasks.delete(task));
478
493
  }
479
494
  #select(target) {
495
+ const index = this.#tenants.get(target.tenant);
496
+ if (!index)
497
+ return undefined;
480
498
  if (target.room !== undefined)
481
- return this.#rooms.get(key(target.tenant, target.room));
499
+ return index.rooms.get(target.room);
482
500
  if (target.member !== undefined)
483
- return this.#members.get(key(target.tenant, target.member));
484
- return this.#tenants.get(target.tenant);
501
+ return index.members.get(target.member);
502
+ return index.all;
485
503
  }
486
504
  /** Writes one event to the matching streams of this process; returns how many took it. */
487
505
  #deliver(target, event, id, json) {
@@ -490,8 +508,14 @@ export class Gateway {
490
508
  let written = 0;
491
509
  const shared = { id, event, json, chunk: undefined };
492
510
  for (const tenant of [...this.#tenants.keys()])
493
- if (tenant.startsWith(target.tenantPrefix))
494
- written += this.#writeAll(this.#select({ ...target, tenant }), target, shared);
511
+ if (tenant.startsWith(target.tenantPrefix)) {
512
+ const exact = {
513
+ tenant,
514
+ ...(target.member === undefined ? {} : { member: target.member }),
515
+ ...(target.room === undefined ? {} : { room: target.room }),
516
+ };
517
+ written += this.#writeAll(this.#select(exact), exact, shared);
518
+ }
495
519
  return written;
496
520
  }
497
521
  const set = this.#select(target);
@@ -501,14 +525,22 @@ export class Gateway {
501
525
  }
502
526
  /**
503
527
  * Plain streams share one buffer, made at the first plain recipient; sealed streams each get
504
- * their own frame (D-0062, like sealed WebSocket broadcasts).
528
+ * their own frame (D-0062, like sealed WebSocket broadcasts). Every candidate is checked against
529
+ * the whole target, tenant first, right before the write: the index narrows, it never decides
530
+ * alone who gets an event (ISSUE-0006 A01).
505
531
  */
506
532
  #writeAll(set, target, shared) {
507
533
  let written = 0;
508
534
  for (const entry of set ?? []) {
509
- if (target.room !== undefined &&
510
- target.member !== undefined &&
511
- entry.member !== target.member)
535
+ if (entry.tenant !== target.tenant) {
536
+ // Unreachable while the index is right; a defect, never a reason to cross tenants.
537
+ this.#throttle.log('error', 'SSE index returned a stream of another tenant; skipped', 'SSE_INDEX_MISMATCH', undefined, { stream: entry.id, path: entry.route.path });
538
+ continue;
539
+ }
540
+ // #select picked the room (or member) index; the rest of the target narrows here.
541
+ if (target.member !== undefined && entry.member !== target.member)
542
+ continue;
543
+ if (target.room !== undefined && !entry.rooms.has(target.room))
512
544
  continue;
513
545
  if (!entry.seal)
514
546
  shared.chunk ??= Buffer.from(frame(shared.id, shared.event, shared.json));
@@ -518,14 +550,16 @@ export class Gateway {
518
550
  return written;
519
551
  }
520
552
  async #publish(target, event, data) {
553
+ if (this.#shut)
554
+ throw new SseError('CLOSED', 'dokebi-sse is closed: the application has stopped.');
521
555
  const checked = checkTarget(target);
522
556
  checkEvent(event);
523
557
  const json = encodeData(data, this.options.maxEventBytes);
524
558
  const bus = this.bus;
525
559
  if (bus && bus.state !== 'ready') {
526
- const error = new SseError('BUS_UNAVAILABLE', `Event bus ${this.options.bus} is ${bus.state}; the event was not sent.`);
527
- this.#throttle.report('error', 'SSE publish refused; bus unavailable', 'BUS_UNAVAILABLE', error, {
528
- bus: this.options.bus,
560
+ const error = new SseError('BUS_UNAVAILABLE', `Event bus ${this.options.eventBus} is ${bus.state}; the event was not sent.`);
561
+ this.#throttle.log('error', 'SSE publish refused; bus unavailable', 'BUS_UNAVAILABLE', error, {
562
+ bus: this.options.eventBus,
529
563
  state: bus.state,
530
564
  });
531
565
  throw error;
@@ -544,11 +578,11 @@ export class Gateway {
544
578
  });
545
579
  }
546
580
  catch (cause) {
547
- this.#throttle.report('error', 'SSE bus publish failed', 'BUS_PUBLISH_FAILED', cause, {
548
- bus: this.options.bus,
581
+ this.#throttle.log('error', 'SSE bus publish failed', 'BUS_PUBLISH_FAILED', cause, {
582
+ bus: this.options.eventBus,
549
583
  id,
550
584
  local,
551
- busCode: errorCode(cause, 'UNKNOWN'),
585
+ busCode: diagnostics.errorCode(cause, 'UNKNOWN'),
552
586
  });
553
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 });
554
588
  }
@@ -584,15 +618,15 @@ export class Gateway {
584
618
  target = checkTarget(message.target);
585
619
  }
586
620
  catch (error) {
587
- this.#throttle.report('error', 'SSE bus message rejected', 'BUS_MESSAGE_INVALID', error, {
588
- bus: this.options.bus,
621
+ this.#throttle.log('error', 'SSE bus message rejected', 'BUS_MESSAGE_INVALID', error, {
622
+ bus: this.options.eventBus,
589
623
  });
590
624
  return;
591
625
  }
592
626
  }
593
627
  if (!message || !target) {
594
- this.#throttle.report('error', 'SSE bus message rejected', 'BUS_MESSAGE_INVALID', undefined, {
595
- bus: this.options.bus,
628
+ this.#throttle.log('error', 'SSE bus message rejected', 'BUS_MESSAGE_INVALID', undefined, {
629
+ bus: this.options.eventBus,
596
630
  });
597
631
  return;
598
632
  }
@@ -619,7 +653,8 @@ export class Gateway {
619
653
  }
620
654
  let count = 0;
621
655
  for (const entry of set)
622
- if ((filter.member === undefined || entry.member === filter.member) &&
656
+ if ((filter.tenant === undefined || entry.tenant === filter.tenant) &&
657
+ (filter.member === undefined || entry.member === filter.member) &&
623
658
  (filter.room === undefined || entry.rooms.has(filter.room)) &&
624
659
  (filter.path === undefined || entry.route.path === filter.path))
625
660
  count++;
@@ -637,13 +672,13 @@ export class Gateway {
637
672
  if (up) {
638
673
  this.#health?.ready();
639
674
  if (!first)
640
- this.context.logger.log('info', 'SSE bus available again', { bus: this.options.bus });
675
+ this.context.logger.log('info', 'SSE bus available again', { bus: this.options.eventBus });
641
676
  }
642
677
  else {
643
678
  this.#health?.fail('BUS_UNAVAILABLE');
644
679
  this.context.logger.log('warn', 'SSE bus unavailable; publications fail', {
645
680
  code: 'BUS_UNAVAILABLE',
646
- bus: this.options.bus,
681
+ bus: this.options.eventBus,
647
682
  state: bus.state,
648
683
  });
649
684
  }
@@ -664,7 +699,7 @@ export class Gateway {
664
699
  this.#health?.ready();
665
700
  this.context.logger.log('debug', 'SSE gateway started', {
666
701
  routes: this.#routes,
667
- bus: this.options.bus ?? null,
702
+ bus: this.options.eventBus ?? null,
668
703
  heartbeatMs: this.options.heartbeatMs,
669
704
  retryMs: this.options.retryMs,
670
705
  maxConnections: this.options.maxConnections,
@@ -685,8 +720,8 @@ export class Gateway {
685
720
  await subscription.unsubscribe();
686
721
  }
687
722
  catch (error) {
688
- this.#throttle.report('error', 'SSE bus unsubscribe failed', 'BUS_UNSUBSCRIBE_FAILED', error, {
689
- bus: this.options.bus,
723
+ this.#throttle.log('error', 'SSE bus unsubscribe failed', 'BUS_UNSUBSCRIBE_FAILED', error, {
724
+ bus: this.options.eventBus,
690
725
  });
691
726
  }
692
727
  const json = JSON.stringify({ retryMs: this.options.retryMs });
@@ -695,7 +730,7 @@ export class Gateway {
695
730
  this.#event(entry, undefined, RESTART_EVENT, json, restart);
696
731
  this.#end(entry, 'DRAIN');
697
732
  }
698
- const deadline = performance.now() + this.options.closeTimeoutMs;
733
+ const deadline = performance.now() + this.options.shutdownTimeoutMs;
699
734
  while (this.#entries.size && performance.now() < deadline && !signal.aborted)
700
735
  await delay(10);
701
736
  if (this.#entries.size) {
@@ -725,6 +760,7 @@ export class Gateway {
725
760
  /** Cleanup: after a failed start no drain ran, so streams may still be open. */
726
761
  close() {
727
762
  this.#closing = true;
763
+ this.#shut = true;
728
764
  this.#timers();
729
765
  for (const entry of this.#entries.values())
730
766
  entry.res.destroy();
package/dist/install.d.ts CHANGED
@@ -7,7 +7,7 @@ interface Diagnostic {
7
7
  readonly optionPath?: readonly string[];
8
8
  readonly hint?: string;
9
9
  }
10
- /** `bus` set: publications cross processes through dokebi-event-bus, so it is required. */
10
+ /** `eventBus` set: publications cross processes through dokebi-event-bus, so it is required. */
11
11
  export declare function planInstall(context: InstallContext): {
12
12
  requires: string[];
13
13
  };
package/dist/install.js CHANGED
@@ -2,11 +2,13 @@
2
2
  // nothing and only reads the plain options it is given. Invalid options are left to the schema.
3
3
  const EVENT_BUS = 'dokebi-event-bus';
4
4
  function usesBus(options) {
5
- return (options !== null &&
6
- typeof options === 'object' &&
7
- typeof options.bus === 'string');
5
+ if (options === null || typeof options !== 'object')
6
+ return false;
7
+ const values = options;
8
+ // `bus` is the deprecated name of `eventBus` (D-0071), still accepted in 0.4.0.
9
+ return typeof values.eventBus === 'string' || typeof values.bus === 'string';
8
10
  }
9
- /** `bus` set: publications cross processes through dokebi-event-bus, so it is required. */
11
+ /** `eventBus` set: publications cross processes through dokebi-event-bus, so it is required. */
10
12
  export function planInstall(context) {
11
13
  return { requires: usesBus(context.options) ? [EVENT_BUS] : [] };
12
14
  }
@@ -18,7 +20,7 @@ export function check(context) {
18
20
  code: 'SSE_SINGLE_PROCESS',
19
21
  severity: 'warning',
20
22
  optionPath: ['allowInCluster'],
21
- hint: 'Without bus, publications reach one worker only; set bus to a nats event bus',
23
+ hint: 'Without eventBus, publications reach one worker only; set eventBus to a nats event bus',
22
24
  });
23
25
  return { requires: planInstall(context).requires, diagnostics };
24
26
  }
package/dist/options.d.ts CHANGED
@@ -3,7 +3,7 @@ export interface Options {
3
3
  * The dokebi-event-bus bus (`buses.<name>`) that carries publications to the other workers and
4
4
  * servers. Unset: publish reaches the streams of this process only.
5
5
  */
6
- readonly bus: string | undefined;
6
+ readonly eventBus: string | undefined;
7
7
  /** The bus event name publications travel under. */
8
8
  readonly busEvent: string;
9
9
  /** Lets a node:cluster worker start without a NATS bus (a deliberate single-worker cluster). */
@@ -21,7 +21,22 @@ export interface Options {
21
21
  /** Largest JSON `data` of one event, in bytes. */
22
22
  readonly maxEventBytes: number;
23
23
  /** How long drain waits for streams to end after `dokebi-restart` before cutting them. */
24
- readonly closeTimeoutMs: number;
24
+ readonly shutdownTimeoutMs: number;
25
25
  }
26
- /** Validates the plugin options (no accessors or Proxies are evaluated) and fills in defaults. */
26
+ export declare const options: import("@dokebi/core").OptionsDeclaration<{
27
+ readonly allowInCluster: boolean;
28
+ readonly busEvent: string;
29
+ readonly eventBus?: string;
30
+ readonly heartbeatMs: number;
31
+ readonly maxBufferedBytes: number;
32
+ readonly maxConnections: number;
33
+ readonly maxEventBytes: number;
34
+ readonly maxRooms: number;
35
+ readonly retryMs: number;
36
+ readonly shutdownTimeoutMs: number;
37
+ }>;
38
+ /**
39
+ * Validates the plugin options (no accessors or Proxies are evaluated) and fills in defaults.
40
+ * Fails with SseError `INVALID_OPTIONS` (paths in the message and `details.issues`, no values).
41
+ */
27
42
  export declare function parseOptions(input: unknown): Options;
package/dist/options.js CHANGED
@@ -1,63 +1,51 @@
1
- import { types } from 'node:util';
1
+ import { defineOptions, option as o } from '@dokebi/core';
2
2
  import { SseError } from './errors.js';
3
- const BUS = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
4
- /** The event-bus event name rule (dot-separated tokens starting with a letter). */
5
- const BUS_EVENT = /^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*$/;
6
- const LIMITS = {
7
- retryMs: [3000, 100, 3_600_000],
8
- heartbeatMs: [15000, 10, 3_600_000],
9
- maxConnections: [1024, 1, 1_000_000],
10
- maxBufferedBytes: [1_048_576, 1, 67_108_864],
11
- maxRooms: [32, 0, 1024],
12
- maxEventBytes: [65_536, 1, 16_777_216],
13
- closeTimeoutMs: [2000, 1, 600_000],
14
- };
15
- const KEYS = new Set(['bus', 'busEvent', 'allowInCluster', ...Object.keys(LIMITS)]);
16
- function invalid(message) {
17
- throw new SseError('INVALID_OPTIONS', message);
18
- }
19
- /** Validates the plugin options (no accessors or Proxies are evaluated) and fills in defaults. */
3
+ const int = (minimum, maximum, fallback, description) => o.integer({ minimum, maximum, default: fallback, description });
4
+ /*
5
+ * Options of dokebi-sse (D-0063 §3): one declaration is the parser and, at build time,
6
+ * options.schema.json.
7
+ */
8
+ export const options = defineOptions({
9
+ eventBus: o.string({
10
+ pattern: /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/,
11
+ description: 'A dokebi-event-bus bus (buses.<name>) that carries publications to every worker and server. Use a nats bus for more than one process. Unset: publish reaches this process only.',
12
+ }),
13
+ bus: o.alias('eventBus'),
14
+ busEvent: o.string({
15
+ maxLength: 128,
16
+ // The event-bus event name rule (dot-separated tokens starting with a letter).
17
+ pattern: /^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*$/,
18
+ default: 'dokebi.sse',
19
+ description: 'The event-bus event name publications travel under.',
20
+ }),
21
+ allowInCluster: o.boolean({
22
+ default: false,
23
+ 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
+ }),
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).'),
29
+ 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.'),
31
+ shutdownTimeoutMs: int(1, 600_000, 2000, 'How long drain waits for streams to end after dokebi-restart before cutting them.'),
32
+ closeTimeoutMs: o.alias('shutdownTimeoutMs'),
33
+ });
34
+ /**
35
+ * Validates the plugin options (no accessors or Proxies are evaluated) and fills in defaults.
36
+ * Fails with SseError `INVALID_OPTIONS` (paths in the message and `details.issues`, no values).
37
+ */
20
38
  export function parseOptions(input) {
21
- if (input === undefined)
22
- input = {};
23
- if (!input ||
24
- typeof input !== 'object' ||
25
- Array.isArray(input) ||
26
- types.isProxy(input) ||
27
- ![Object.prototype, null].includes(Object.getPrototypeOf(input)))
28
- invalid('SSE options must be a plain object.');
29
- const value = Object.create(null);
30
- for (const key of Reflect.ownKeys(input)) {
31
- const descriptor = Object.getOwnPropertyDescriptor(input, key);
32
- if (typeof key !== 'string' ||
33
- !descriptor ||
34
- !('value' in descriptor) ||
35
- !descriptor.enumerable)
36
- invalid('SSE options must hold plain data properties.');
37
- if (!KEYS.has(key))
38
- invalid(`Unknown SSE option: ${key}`);
39
- value[key] = descriptor.value;
40
- }
41
- const bus = value.bus;
42
- if (bus !== undefined && (typeof bus !== 'string' || !BUS.test(bus)))
43
- invalid('bus must be the name of a dokebi-event-bus bus.');
44
- const busEvent = value.busEvent ?? 'dokebi.sse';
45
- if (typeof busEvent !== 'string' || busEvent.length > 128 || !BUS_EVENT.test(busEvent))
46
- invalid('busEvent must be an event-bus event name such as dokebi.sse.');
47
- const allowInCluster = value.allowInCluster ?? false;
48
- if (typeof allowInCluster !== 'boolean')
49
- invalid('allowInCluster must be a boolean.');
50
- const limits = {};
51
- for (const [key, [fallback, min, max]] of Object.entries(LIMITS)) {
52
- const number = value[key] ?? fallback;
53
- if (typeof number !== 'number' || !Number.isSafeInteger(number) || number < min || number > max)
54
- invalid(`${key} must be an integer from ${min} to ${max}.`);
55
- limits[key] = number;
39
+ const result = options.check(input);
40
+ if (!result.ok) {
41
+ const shown = result.issues
42
+ .slice(0, 3)
43
+ .map((issue) => `${issue.path || 'options'} ${issue.message}`)
44
+ .join('; ');
45
+ throw new SseError('INVALID_OPTIONS', `Invalid SSE options: ${shown}`, {
46
+ details: { issues: result.issues },
47
+ });
56
48
  }
57
- return Object.freeze({
58
- bus,
59
- busEvent,
60
- allowInCluster,
61
- ...limits,
62
- });
49
+ // `eventBus` is always a key (undefined when unset), as before the declaration.
50
+ return Object.freeze({ ...result.value, eventBus: result.value.eventBus });
63
51
  }
package/dist/plugin.js CHANGED
@@ -1,30 +1,30 @@
1
- import cluster from 'node:cluster';
2
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
+ import { realtime } from '@dokebi/web/points';
3
6
  import { SseError } from './errors.js';
4
7
  import { Gateway } from './gateway.js';
5
- import { parseOptions } from './options.js';
6
- const web = providerDependency({
7
- name: 'dokebi-web',
8
- version: '^0.2.0',
9
- packageName: '@dokebi/web',
10
- load: () => import('@dokebi/web'),
11
- });
8
+ import { options as declaration } from './options.js';
9
+ import { sseTransport } from './transport.js';
12
10
  /** Only when `bus` is set; created from `app.config['dokebi-event-bus']` when not registered. */
13
11
  const events = providerDependency({
14
12
  name: 'dokebi-event-bus',
15
- version: '^0.1.0',
13
+ version: '^0.4.0',
16
14
  packageName: '@dokebi/event-bus',
17
15
  load: () => import('@dokebi/event-bus'),
18
16
  });
19
- const health = optionalDependency({ name: 'dokebi-health', version: '^0.1.0' });
17
+ const health = optionalDependency({ name: 'dokebi-health', version: '^0.4.0' });
20
18
  /**
21
- * The transport of a bus as dokebi-event-bus options in the application config name it.
22
- * The event-bus API does not expose it, so options given to that plugin in code are unknown here.
19
+ * The driver of a bus as dokebi-event-bus options in the application config name it (`driver`, or
20
+ * its deprecated name `transport`, D-0071). The event-bus API does not expose it, so options given
21
+ * to that plugin in code are unknown here.
23
22
  */
24
- function configuredTransport(config, bus) {
23
+ function configuredDriver(config, bus) {
25
24
  const options = config?.['dokebi-event-bus'];
26
- const transport = options?.buses?.[bus]?.transport;
27
- return typeof transport === 'string' ? transport : undefined;
25
+ const value = options?.buses?.[bus];
26
+ const driver = value?.driver ?? value?.transport;
27
+ return typeof driver === 'string' ? driver : undefined;
28
28
  }
29
29
  class SseRuntime {
30
30
  options;
@@ -35,32 +35,38 @@ class SseRuntime {
35
35
  async setup(context) {
36
36
  const started = performance.now();
37
37
  const { options } = this;
38
+ // core decides the role (D-0063 §4): a node:cluster worker is `worker`.
39
+ const worker = context.runtime.role === 'worker';
38
40
  // A publication must reach the streams of every worker. Without a NATS bus each worker
39
41
  // reaches only its own clients, which works by accident and fails silently in use.
40
- if (cluster.isWorker && !options.allowInCluster) {
41
- const transport = options.bus === undefined
42
+ if (worker && !options.allowInCluster) {
43
+ const driver = options.eventBus === undefined
42
44
  ? 'none'
43
- : (configuredTransport(context.app.config, options.bus) ?? 'unknown');
44
- if (transport !== 'nats') {
45
+ : (configuredDriver(context.app.config, options.eventBus) ?? 'unknown');
46
+ if (driver !== 'nats') {
45
47
  context.logger.log('error', 'SSE in a cluster worker without a NATS bus', {
46
48
  code: 'SSE_LOCAL_IN_CLUSTER',
47
- bus: options.bus ?? null,
48
- transport,
49
- fix: 'Set dokebi-sse.bus to a dokebi-event-bus bus with transport nats (configured in ' +
49
+ bus: options.eventBus ?? null,
50
+ driver,
51
+ fix: 'Set dokebi-sse.eventBus to a dokebi-event-bus bus with driver nats (configured in ' +
50
52
  'dokebi-event-bus.buses), or allowInCluster: true for a deliberate single-worker cluster',
51
53
  });
52
- throw new SseError('SSE_LOCAL_IN_CLUSTER', 'SSE publications would reach only this worker: use a nats bus (dokebi-sse.bus).');
54
+ throw new SseError('LOCAL_IN_CLUSTER', 'SSE publications would reach only this worker: use a nats bus (dokebi-sse.eventBus).');
53
55
  }
54
56
  }
55
- const bus = options.bus === undefined ? undefined : context.getDependency(events).bus(options.bus);
57
+ const bus = options.eventBus === undefined
58
+ ? undefined
59
+ : context.getDependency(events).bus(options.eventBus);
56
60
  const gateway = new Gateway(context, context.getDependency(web), options, bus, context.getOptionalDependency(health));
57
61
  context.defer(() => gateway.close());
58
62
  this.#gateway = gateway;
59
63
  await gateway.subscribe();
64
+ // D-0063: the `sse` transport for dokebi-app (inert until a plugin collects the point).
65
+ context.contribute(realtime, sseTransport(gateway.api, context.logger));
60
66
  context.logger.log('debug', 'SSE setup finished', {
61
- bus: options.bus ?? null,
67
+ bus: options.eventBus ?? null,
62
68
  busEvent: options.busEvent,
63
- cluster: cluster.isWorker ? 'worker' : 'none',
69
+ cluster: worker ? 'worker' : 'none',
64
70
  ms: Math.round(performance.now() - started),
65
71
  });
66
72
  return gateway.api;
@@ -75,16 +81,18 @@ class SseRuntime {
75
81
  /** Factory input: `{ app, options? }`. Options default to `app.config['dokebi-sse']`. */
76
82
  export const createPlugin = definePlugin({
77
83
  name: 'dokebi-sse',
78
- version: '0.1.0',
79
- core: '^0.2.0',
80
- options: parseOptions,
81
- dependencies: ({ options }) => options.bus === undefined ? [web, health] : [web, events, health],
82
- instance: ({ options }) => new SseRuntime(options),
84
+ version: '0.4.1',
85
+ core: '^0.4.0',
86
+ // The declaration (not parseOptions) so core logs OPTION_DEPRECATED for old keys (bus, closeTimeoutMs).
87
+ options: declaration,
88
+ contributes: [realtime],
89
+ dependencies: ({ options }) => options.eventBus === undefined ? [web, health] : [web, events, health],
90
+ instance: ({ options }) => new SseRuntime(Object.freeze({ ...options, eventBus: options.eventBus })),
83
91
  });
84
92
  /** Declares dokebi-sse as a required provider; it is created from this package when absent. */
85
93
  export const dependency = providerDependency({
86
94
  name: 'dokebi-sse',
87
95
  packageName: '@dokebi/sse',
88
- version: '^0.1.0',
96
+ version: '^0.4.0',
89
97
  load: () => import('./index.js'),
90
98
  });