@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/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 +83 -47
- 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:`). */
|
|
@@ -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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
358
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
remove(
|
|
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.
|
|
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
|
|
499
|
+
return index.rooms.get(target.room);
|
|
482
500
|
if (target.member !== undefined)
|
|
483
|
-
return
|
|
484
|
-
return
|
|
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
|
-
|
|
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 (
|
|
510
|
-
|
|
511
|
-
entry.
|
|
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.
|
|
527
|
-
this.#throttle.
|
|
528
|
-
bus: this.options.
|
|
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.
|
|
548
|
-
bus: this.options.
|
|
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.
|
|
588
|
-
bus: this.options.
|
|
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.
|
|
595
|
-
bus: this.options.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
689
|
-
bus: this.options.
|
|
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.
|
|
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
|
-
/** `
|
|
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.1
|
|
79
|
-
core: '^0.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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.
|
|
96
|
+
version: '^0.4.0',
|
|
89
97
|
load: () => import('./index.js'),
|
|
90
98
|
});
|