@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/AI.md +94 -78
- package/README.md +44 -40
- package/dist/errors.d.ts +6 -4
- package/dist/errors.js +24 -4
- package/dist/gateway.js +32 -26
- package/dist/install.d.ts +1 -1
- package/dist/install.js +7 -5
- package/dist/options.d.ts +18 -3
- package/dist/options.js +47 -59
- package/dist/plugin.js +40 -32
- package/dist/transport.d.ts +13 -0
- package/dist/transport.js +45 -0
- package/dist/types.d.ts +12 -0
- package/options.schema.json +16 -3
- package/package.json +39 -17
- package/dist/log.d.ts +0 -25
- package/dist/log.js +0 -102
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
527
|
-
this.#throttle.
|
|
528
|
-
bus: this.options.
|
|
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.
|
|
548
|
-
bus: this.options.
|
|
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.
|
|
588
|
-
bus: this.options.
|
|
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.
|
|
595
|
-
bus: this.options.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
689
|
-
bus: this.options.
|
|
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.
|
|
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
|
-
/** `
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
/** `
|
|
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
|
|
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
|
|
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
|
|
24
|
+
readonly shutdownTimeoutMs: number;
|
|
25
25
|
}
|
|
26
|
-
|
|
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 {
|
|
1
|
+
import { defineOptions, option as o } from '@dokebi/core';
|
|
2
2
|
import { SseError } from './errors.js';
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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 {
|
|
6
|
-
|
|
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.
|
|
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.
|
|
17
|
+
const health = optionalDependency({ name: 'dokebi-health', version: '^0.4.0' });
|
|
20
18
|
/**
|
|
21
|
-
* The
|
|
22
|
-
* The event-bus API does not expose it, so options given
|
|
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
|
|
23
|
+
function configuredDriver(config, bus) {
|
|
25
24
|
const options = config?.['dokebi-event-bus'];
|
|
26
|
-
const
|
|
27
|
-
|
|
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 (
|
|
41
|
-
const
|
|
42
|
+
if (worker && !options.allowInCluster) {
|
|
43
|
+
const driver = options.eventBus === undefined
|
|
42
44
|
? 'none'
|
|
43
|
-
: (
|
|
44
|
-
if (
|
|
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.
|
|
48
|
-
|
|
49
|
-
fix: 'Set dokebi-sse.
|
|
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('
|
|
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.
|
|
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.
|
|
67
|
+
bus: options.eventBus ?? null,
|
|
62
68
|
busEvent: options.busEvent,
|
|
63
|
-
cluster:
|
|
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.
|
|
79
|
-
core: '^0.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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.
|
|
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";
|
package/options.schema.json
CHANGED
|
@@ -3,11 +3,17 @@
|
|
|
3
3
|
"type": "object",
|
|
4
4
|
"additionalProperties": false,
|
|
5
5
|
"properties": {
|
|
6
|
-
"
|
|
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
|
|
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
|
-
"
|
|
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
|
}
|