@dokebi/sse 0.1.0 → 0.4.0

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:`). */
@@ -107,6 +107,7 @@ export class Gateway {
107
107
  #rooms = new Map();
108
108
  /** onOpen/onClose calls in progress, awaited by drain (bounded by the drain deadline). */
109
109
  #tasks = new Set();
110
+ /** Repeated hot-path events: the first at once, then one summary per interval (D-0047). */
110
111
  #throttle;
111
112
  /** Event ids: `<prefix>-<counter>`; the prefix tells processes apart, the counter only grows. */
112
113
  #prefix = randomBytes(4).toString('hex');
@@ -116,6 +117,8 @@ export class Gateway {
116
117
  #routes = 0;
117
118
  #frozen = false;
118
119
  #closing = false;
120
+ /** Cleanup ran (`close()`): the bus and the streams are gone, publish throws CLOSED. */
121
+ #shut = false;
119
122
  #heartbeat;
120
123
  #watch;
121
124
  #subscription;
@@ -126,7 +129,7 @@ export class Gateway {
126
129
  this.web = web;
127
130
  this.options = options;
128
131
  this.bus = bus;
129
- this.#throttle = new LogThrottle(context.logger);
132
+ this.#throttle = new diagnostics.Repeats({ logger: context.logger });
130
133
  if (health) {
131
134
  this.#health = health.register('sse', { required: false });
132
135
  const handle = this.#health;
@@ -138,7 +141,7 @@ export class Gateway {
138
141
  publish: (target, event, data) => this.#publish(target, event, data),
139
142
  connections: (filter) => this.#count(filter),
140
143
  get bus() {
141
- return self.options.bus;
144
+ return self.options.eventBus;
142
145
  },
143
146
  });
144
147
  }
@@ -150,7 +153,7 @@ export class Gateway {
150
153
  this.#subscription = subscription;
151
154
  await subscription.ready;
152
155
  this.context.logger.log('debug', 'SSE bus subscribed', {
153
- bus: this.options.bus,
156
+ bus: this.options.eventBus,
154
157
  event: this.options.busEvent,
155
158
  origin: this.#origin,
156
159
  });
@@ -273,10 +276,10 @@ export class Gateway {
273
276
  res.once('close', () => this.#closed(entry));
274
277
  res.on('error', (error) => {
275
278
  entry.reason ??= 'WRITE_FAILED';
276
- const code = errorCode(error, 'UNKNOWN');
279
+ const code = diagnostics.errorCode(error, 'UNKNOWN');
277
280
  // A client that went away mid-write is not a server failure.
278
281
  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 });
282
+ this.#throttle.log(gone ? 'debug' : 'error', 'SSE stream write failed', 'STREAM_WRITE_FAILED', error, { path: route.path, reason: code });
280
283
  });
281
284
  if (logger.isEnabled('debug'))
282
285
  logger.log('debug', 'SSE stream opened', {
@@ -295,7 +298,7 @@ export class Gateway {
295
298
  await this.#task(Promise.resolve()
296
299
  .then(() => onOpen(entry.stream))
297
300
  .catch((error) => {
298
- this.#throttle.report('error', 'SSE onOpen failed; stream ended', 'SSE_ON_OPEN_FAILED', error, {
301
+ this.#throttle.log('error', 'SSE onOpen failed; stream ended', 'SSE_ON_OPEN_FAILED', error, {
299
302
  path: route.path,
300
303
  stream: entry.id,
301
304
  });
@@ -401,7 +404,7 @@ export class Gateway {
401
404
  chunk = Buffer.from(frame(id, event, entry.seal.encode(json)));
402
405
  }
403
406
  catch (error) {
404
- this.#throttle.report('error', 'SSE event seal failed; stream ended', 'SEAL_ENCODE_FAILED', error, {
407
+ this.#throttle.log('error', 'SSE event seal failed; stream ended', 'SEAL_ENCODE_FAILED', error, {
405
408
  stream: entry.id,
406
409
  path: entry.route.path,
407
410
  });
@@ -417,7 +420,7 @@ export class Gateway {
417
420
  const res = entry.res;
418
421
  // ServerResponse.writableLength counts what waits in the response and in its socket.
419
422
  if (res.writableLength + chunk.byteLength > this.options.maxBufferedBytes) {
420
- this.#throttle.report('warn', 'SSE slow consumer ended', 'SLOW_CONSUMER', undefined, {
423
+ this.#throttle.log('warn', 'SSE slow consumer ended', 'SLOW_CONSUMER', undefined, {
421
424
  stream: entry.id,
422
425
  path: entry.route.path,
423
426
  bufferedBytes: res.writableLength,
@@ -464,7 +467,7 @@ export class Gateway {
464
467
  void this.#task(Promise.resolve()
465
468
  .then(() => onClose(entry.stream, reason))
466
469
  .catch((error) => {
467
- this.#throttle.report('error', 'SSE onClose failed', 'SSE_ON_CLOSE_FAILED', error, {
470
+ this.#throttle.log('error', 'SSE onClose failed', 'SSE_ON_CLOSE_FAILED', error, {
468
471
  path: entry.route.path,
469
472
  stream: entry.id,
470
473
  reason,
@@ -518,14 +521,16 @@ export class Gateway {
518
521
  return written;
519
522
  }
520
523
  async #publish(target, event, data) {
524
+ if (this.#shut)
525
+ throw new SseError('CLOSED', 'dokebi-sse is closed: the application has stopped.');
521
526
  const checked = checkTarget(target);
522
527
  checkEvent(event);
523
528
  const json = encodeData(data, this.options.maxEventBytes);
524
529
  const bus = this.bus;
525
530
  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,
531
+ const error = new SseError('BUS_UNAVAILABLE', `Event bus ${this.options.eventBus} is ${bus.state}; the event was not sent.`);
532
+ this.#throttle.log('error', 'SSE publish refused; bus unavailable', 'BUS_UNAVAILABLE', error, {
533
+ bus: this.options.eventBus,
529
534
  state: bus.state,
530
535
  });
531
536
  throw error;
@@ -544,11 +549,11 @@ export class Gateway {
544
549
  });
545
550
  }
546
551
  catch (cause) {
547
- this.#throttle.report('error', 'SSE bus publish failed', 'BUS_PUBLISH_FAILED', cause, {
548
- bus: this.options.bus,
552
+ this.#throttle.log('error', 'SSE bus publish failed', 'BUS_PUBLISH_FAILED', cause, {
553
+ bus: this.options.eventBus,
549
554
  id,
550
555
  local,
551
- busCode: errorCode(cause, 'UNKNOWN'),
556
+ busCode: diagnostics.errorCode(cause, 'UNKNOWN'),
552
557
  });
553
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 });
554
559
  }
@@ -584,15 +589,15 @@ export class Gateway {
584
589
  target = checkTarget(message.target);
585
590
  }
586
591
  catch (error) {
587
- this.#throttle.report('error', 'SSE bus message rejected', 'BUS_MESSAGE_INVALID', error, {
588
- bus: this.options.bus,
592
+ this.#throttle.log('error', 'SSE bus message rejected', 'BUS_MESSAGE_INVALID', error, {
593
+ bus: this.options.eventBus,
589
594
  });
590
595
  return;
591
596
  }
592
597
  }
593
598
  if (!message || !target) {
594
- this.#throttle.report('error', 'SSE bus message rejected', 'BUS_MESSAGE_INVALID', undefined, {
595
- bus: this.options.bus,
599
+ this.#throttle.log('error', 'SSE bus message rejected', 'BUS_MESSAGE_INVALID', undefined, {
600
+ bus: this.options.eventBus,
596
601
  });
597
602
  return;
598
603
  }
@@ -637,13 +642,13 @@ export class Gateway {
637
642
  if (up) {
638
643
  this.#health?.ready();
639
644
  if (!first)
640
- this.context.logger.log('info', 'SSE bus available again', { bus: this.options.bus });
645
+ this.context.logger.log('info', 'SSE bus available again', { bus: this.options.eventBus });
641
646
  }
642
647
  else {
643
648
  this.#health?.fail('BUS_UNAVAILABLE');
644
649
  this.context.logger.log('warn', 'SSE bus unavailable; publications fail', {
645
650
  code: 'BUS_UNAVAILABLE',
646
- bus: this.options.bus,
651
+ bus: this.options.eventBus,
647
652
  state: bus.state,
648
653
  });
649
654
  }
@@ -664,7 +669,7 @@ export class Gateway {
664
669
  this.#health?.ready();
665
670
  this.context.logger.log('debug', 'SSE gateway started', {
666
671
  routes: this.#routes,
667
- bus: this.options.bus ?? null,
672
+ bus: this.options.eventBus ?? null,
668
673
  heartbeatMs: this.options.heartbeatMs,
669
674
  retryMs: this.options.retryMs,
670
675
  maxConnections: this.options.maxConnections,
@@ -685,8 +690,8 @@ export class Gateway {
685
690
  await subscription.unsubscribe();
686
691
  }
687
692
  catch (error) {
688
- this.#throttle.report('error', 'SSE bus unsubscribe failed', 'BUS_UNSUBSCRIBE_FAILED', error, {
689
- bus: this.options.bus,
693
+ this.#throttle.log('error', 'SSE bus unsubscribe failed', 'BUS_UNSUBSCRIBE_FAILED', error, {
694
+ bus: this.options.eventBus,
690
695
  });
691
696
  }
692
697
  const json = JSON.stringify({ retryMs: this.options.retryMs });
@@ -695,7 +700,7 @@ export class Gateway {
695
700
  this.#event(entry, undefined, RESTART_EVENT, json, restart);
696
701
  this.#end(entry, 'DRAIN');
697
702
  }
698
- const deadline = performance.now() + this.options.closeTimeoutMs;
703
+ const deadline = performance.now() + this.options.shutdownTimeoutMs;
699
704
  while (this.#entries.size && performance.now() < deadline && !signal.aborted)
700
705
  await delay(10);
701
706
  if (this.#entries.size) {
@@ -725,6 +730,7 @@ export class Gateway {
725
730
  /** Cleanup: after a failed start no drain ran, so streams may still be open. */
726
731
  close() {
727
732
  this.#closing = true;
733
+ this.#shut = true;
728
734
  this.#timers();
729
735
  for (const entry of this.#entries.values())
730
736
  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.0',
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
  });
@@ -0,0 +1,13 @@
1
+ import type { Logger } from '@dokebi/core';
2
+ import type { WebRealtimeTransport } from '@dokebi/web/points';
3
+ import type { SseApi, SseNamespaceHandle, SseRoute } from './types.js';
4
+ /** The `sse` transport contributed to `dokebi-web:realtime` (D-0063 §5). */
5
+ export type SseTransport = WebRealtimeTransport<SseRoute, SseNamespaceHandle> & {
6
+ readonly kind: 'sse';
7
+ };
8
+ /**
9
+ * What dokebi-app needs to mount SSE routes of apps and modules and to publish per namespace,
10
+ * built on the public API: `mount` adds the scope's path, constraints and tenant rule to a
11
+ * route; `handle` prefixes targets with `<namespace>:`.
12
+ */
13
+ export declare function sseTransport(api: SseApi, logger: Logger): SseTransport;
@@ -0,0 +1,45 @@
1
+ import { SseError } from './errors.js';
2
+ const NAMESPACE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
3
+ /**
4
+ * What dokebi-app needs to mount SSE routes of apps and modules and to publish per namespace,
5
+ * built on the public API: `mount` adds the scope's path, constraints and tenant rule to a
6
+ * route; `handle` prefixes targets with `<namespace>:`.
7
+ */
8
+ export function sseTransport(api, logger) {
9
+ return Object.freeze({
10
+ kind: 'sse',
11
+ mount(route, scope) {
12
+ if (route === null || typeof route !== 'object' || typeof route.authenticate !== 'function')
13
+ throw new SseError('INVALID_ROUTE', 'Invalid SSE route.');
14
+ api.route({
15
+ ...route,
16
+ path: scope.path,
17
+ constraints: { ...(route.constraints ?? {}), ...scope.constraints },
18
+ authenticate: async (request) => {
19
+ const identity = await route.authenticate(request);
20
+ return identity ? scope.identity(identity) : undefined;
21
+ },
22
+ });
23
+ if (logger.isEnabled('debug'))
24
+ logger.log('debug', 'SSE route mounted by the realtime point', { path: scope.path });
25
+ },
26
+ handle(namespace) {
27
+ if (typeof namespace !== 'string' || !NAMESPACE.test(namespace))
28
+ throw new SseError('INVALID_TARGET', 'A realtime namespace is a name of 1 to 64 characters.');
29
+ const prefix = `${namespace}:`;
30
+ return Object.freeze({
31
+ namespace,
32
+ publish: (target, event, data) => {
33
+ if (target === null || typeof target !== 'object')
34
+ return Promise.reject(new SseError('INVALID_TARGET', 'A namespace target is { tenant?, member?, room? }.'));
35
+ const { tenant, member, room } = target;
36
+ return api.publish({
37
+ ...(tenant === undefined ? { tenantPrefix: prefix } : { tenant: prefix + tenant }),
38
+ ...(member === undefined ? {} : { member }),
39
+ ...(room === undefined ? {} : { room }),
40
+ }, event, data);
41
+ },
42
+ });
43
+ },
44
+ });
45
+ }
package/dist/types.d.ts CHANGED
@@ -126,6 +126,18 @@ export interface SseApi {
126
126
  /** The bus name, when publications cross processes. */
127
127
  readonly bus: string | undefined;
128
128
  }
129
+ /** Whom a namespace publication is for (D-0061): a tenant of the namespace, or all of them. */
130
+ export interface SseNamespaceTarget {
131
+ readonly tenant?: string;
132
+ readonly member?: string;
133
+ readonly room?: string;
134
+ }
135
+ /** The SSE operations on one realtime namespace (the tenants `<namespace>:<tenant>`). */
136
+ export interface SseNamespaceHandle {
137
+ readonly namespace: string;
138
+ /** `publish` with the tenant (or every tenant) of this namespace. */
139
+ publish(target: SseNamespaceTarget, event: string, data?: unknown): Promise<SsePublishResult>;
140
+ }
129
141
  /** The events the plugin itself sends; application event names may not start with `dokebi-`. */
130
142
  export declare const RELOAD_EVENT = "dokebi-reload";
131
143
  export declare const RESTART_EVENT = "dokebi-restart";
@@ -3,11 +3,17 @@
3
3
  "type": "object",
4
4
  "additionalProperties": false,
5
5
  "properties": {
6
- "bus": {
6
+ "eventBus": {
7
7
  "type": "string",
8
8
  "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$",
9
9
  "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."
10
10
  },
11
+ "bus": {
12
+ "type": "string",
13
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$",
14
+ "deprecated": true,
15
+ "description": "Deprecated name of eventBus; use that key."
16
+ },
11
17
  "busEvent": {
12
18
  "type": "string",
13
19
  "maxLength": 128,
@@ -18,7 +24,7 @@
18
24
  "allowInCluster": {
19
25
  "type": "boolean",
20
26
  "default": false,
21
- "description": "Start in a node:cluster worker without a nats bus (a deliberate single-worker cluster). Otherwise that start fails with SSE_LOCAL_IN_CLUSTER. The bus transport is read from dokebi-event-bus.buses in the application config."
27
+ "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."
22
28
  },
23
29
  "retryMs": {
24
30
  "type": "integer",
@@ -62,12 +68,19 @@
62
68
  "default": 65536,
63
69
  "description": "Largest JSON data of one event in bytes (EVENT_TOO_LARGE). Keep it below the bus maxPayloadBytes."
64
70
  },
65
- "closeTimeoutMs": {
71
+ "shutdownTimeoutMs": {
66
72
  "type": "integer",
67
73
  "minimum": 1,
68
74
  "maximum": 600000,
69
75
  "default": 2000,
70
76
  "description": "How long drain waits for streams to end after dokebi-restart before cutting them."
77
+ },
78
+ "closeTimeoutMs": {
79
+ "type": "integer",
80
+ "minimum": 1,
81
+ "maximum": 600000,
82
+ "deprecated": true,
83
+ "description": "Deprecated name of shutdownTimeoutMs; use that key."
71
84
  }
72
85
  }
73
86
  }