@dokebi/sse 0.4.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 +1 -1
- package/dist/gateway.js +51 -21
- package/dist/plugin.js +1 -1
- package/package.json +4 -4
package/AI.md
CHANGED
|
@@ -51,7 +51,7 @@ sse.connections({ tenant, room }); // the count on this wo
|
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
- `route` works only in setup (`FROZEN` afterwards). `authenticate` and `onOpen` run on every reconnect.
|
|
54
|
-
- Targets: `{tenant}` everyone, `{tenant, member}` all tabs of one person, `{tenant, room}` a room, `{tenant, room, member}` that person in the room. Strings are 1 to 256 characters.
|
|
54
|
+
- Targets: `{tenant}` everyone, `{tenant, member}` all tabs of one person, `{tenant, room}` a room, `{tenant, room, member}` that person in the room. Strings are 1 to 256 characters (any characters). A target only ever reaches streams of its own tenant.
|
|
55
55
|
- Prefix targets (D-0061): with `tenantPrefix` instead of `tenant` (exactly one of them, otherwise `INVALID_TARGET`), every tenant whose name starts with that prefix (with the same `member` and `room` rules). Bus messages carry the same target, and each process scans its own tenant index once. @dokebi/app's namespaces `<ns>:` use it.
|
|
56
56
|
- Event names: `^[A-Za-z][A-Za-z0-9._:-]{0,63}$`, not starting with `dokebi-` (reserved for the plugin). data is one line of JSON (`undefined` becomes `null`); functions, BigInt and cycles are `INVALID_DATA`.
|
|
57
57
|
- `publish` order: check target, name and data → when the bus is not `ready`, `BUS_UNAVAILABLE` (nothing is sent anywhere, error log) → write to this process's streams → publish on the bus. A failed bus publish is `BUS_PUBLISH_FAILED` (`cause`, `id`, `local`; this process already received it, other places are unknown; the error log has the full error). Whether to send again is the caller's choice (duplicates possible).
|
package/dist/gateway.js
CHANGED
|
@@ -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,9 +101,8 @@ 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();
|
|
110
108
|
/** Repeated hot-path events: the first at once, then one summary per interval (D-0047). */
|
|
@@ -357,8 +355,13 @@ export class Gateway {
|
|
|
357
355
|
close: () => this.#end(entry, 'SERVER_CLOSED'),
|
|
358
356
|
});
|
|
359
357
|
this.#entries.set(entry.id, entry);
|
|
360
|
-
|
|
361
|
-
|
|
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);
|
|
362
365
|
for (const room of rooms)
|
|
363
366
|
this.#join(entry, room);
|
|
364
367
|
return entry;
|
|
@@ -368,8 +371,10 @@ export class Gateway {
|
|
|
368
371
|
return;
|
|
369
372
|
if (entry.rooms.size >= this.options.maxRooms)
|
|
370
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);
|
|
371
376
|
entry.rooms.add(room);
|
|
372
|
-
add(
|
|
377
|
+
add(index.rooms, room, entry);
|
|
373
378
|
if (this.context.logger.isEnabled('debug'))
|
|
374
379
|
this.context.logger.log('debug', 'SSE room joined', {
|
|
375
380
|
stream: entry.id,
|
|
@@ -380,7 +385,9 @@ export class Gateway {
|
|
|
380
385
|
#leave(entry, room) {
|
|
381
386
|
if (!entry.rooms.delete(room))
|
|
382
387
|
return;
|
|
383
|
-
|
|
388
|
+
const index = this.#tenants.get(entry.tenant);
|
|
389
|
+
if (index)
|
|
390
|
+
remove(index.rooms, room, entry);
|
|
384
391
|
if (this.context.logger.isEnabled('debug'))
|
|
385
392
|
this.context.logger.log('debug', 'SSE room left', {
|
|
386
393
|
stream: entry.id,
|
|
@@ -449,10 +456,15 @@ export class Gateway {
|
|
|
449
456
|
const reason = entry.reason ?? 'CLIENT_CLOSED';
|
|
450
457
|
entry.reason = reason;
|
|
451
458
|
this.#entries.delete(entry.id);
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
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
|
+
}
|
|
456
468
|
if (this.context.logger.isEnabled('debug'))
|
|
457
469
|
this.context.logger.log('debug', 'SSE stream closed', {
|
|
458
470
|
stream: entry.id,
|
|
@@ -480,11 +492,14 @@ export class Gateway {
|
|
|
480
492
|
return task.finally(() => this.#tasks.delete(task));
|
|
481
493
|
}
|
|
482
494
|
#select(target) {
|
|
495
|
+
const index = this.#tenants.get(target.tenant);
|
|
496
|
+
if (!index)
|
|
497
|
+
return undefined;
|
|
483
498
|
if (target.room !== undefined)
|
|
484
|
-
return
|
|
499
|
+
return index.rooms.get(target.room);
|
|
485
500
|
if (target.member !== undefined)
|
|
486
|
-
return
|
|
487
|
-
return
|
|
501
|
+
return index.members.get(target.member);
|
|
502
|
+
return index.all;
|
|
488
503
|
}
|
|
489
504
|
/** Writes one event to the matching streams of this process; returns how many took it. */
|
|
490
505
|
#deliver(target, event, id, json) {
|
|
@@ -493,8 +508,14 @@ export class Gateway {
|
|
|
493
508
|
let written = 0;
|
|
494
509
|
const shared = { id, event, json, chunk: undefined };
|
|
495
510
|
for (const tenant of [...this.#tenants.keys()])
|
|
496
|
-
if (tenant.startsWith(target.tenantPrefix))
|
|
497
|
-
|
|
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
|
+
}
|
|
498
519
|
return written;
|
|
499
520
|
}
|
|
500
521
|
const set = this.#select(target);
|
|
@@ -504,14 +525,22 @@ export class Gateway {
|
|
|
504
525
|
}
|
|
505
526
|
/**
|
|
506
527
|
* Plain streams share one buffer, made at the first plain recipient; sealed streams each get
|
|
507
|
-
* 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).
|
|
508
531
|
*/
|
|
509
532
|
#writeAll(set, target, shared) {
|
|
510
533
|
let written = 0;
|
|
511
534
|
for (const entry of set ?? []) {
|
|
512
|
-
if (
|
|
513
|
-
|
|
514
|
-
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))
|
|
515
544
|
continue;
|
|
516
545
|
if (!entry.seal)
|
|
517
546
|
shared.chunk ??= Buffer.from(frame(shared.id, shared.event, shared.json));
|
|
@@ -624,7 +653,8 @@ export class Gateway {
|
|
|
624
653
|
}
|
|
625
654
|
let count = 0;
|
|
626
655
|
for (const entry of set)
|
|
627
|
-
if ((filter.
|
|
656
|
+
if ((filter.tenant === undefined || entry.tenant === filter.tenant) &&
|
|
657
|
+
(filter.member === undefined || entry.member === filter.member) &&
|
|
628
658
|
(filter.room === undefined || entry.rooms.has(filter.room)) &&
|
|
629
659
|
(filter.path === undefined || entry.route.path === filter.path))
|
|
630
660
|
count++;
|
package/dist/plugin.js
CHANGED
|
@@ -81,7 +81,7 @@ class SseRuntime {
|
|
|
81
81
|
/** Factory input: `{ app, options? }`. Options default to `app.config['dokebi-sse']`. */
|
|
82
82
|
export const createPlugin = definePlugin({
|
|
83
83
|
name: 'dokebi-sse',
|
|
84
|
-
version: '0.4.
|
|
84
|
+
version: '0.4.1',
|
|
85
85
|
core: '^0.4.0',
|
|
86
86
|
// The declaration (not parseOptions) so core logs OPTION_DEPRECATED for old keys (bus, closeTimeoutMs).
|
|
87
87
|
options: declaration,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dokebi/sse",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Server-sent events for @dokebi/web: authenticated streams, rooms, cross-worker fan-out over @dokebi/event-bus, and a browser client",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dokebi",
|
|
@@ -69,11 +69,11 @@
|
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@dokebi/cluster": "0.4.0",
|
|
72
|
-
"@dokebi/config": "0.4.
|
|
73
|
-
"@dokebi/event-bus": "0.4.
|
|
72
|
+
"@dokebi/config": "0.4.1",
|
|
73
|
+
"@dokebi/event-bus": "0.4.1",
|
|
74
74
|
"@dokebi/health": "0.4.0",
|
|
75
75
|
"@dokebi/nats": "0.4.0",
|
|
76
|
-
"@dokebi/web": "0.4.
|
|
76
|
+
"@dokebi/web": "0.4.2",
|
|
77
77
|
"react": "19.3.0",
|
|
78
78
|
"react-test-renderer": "19.3.0",
|
|
79
79
|
"vite": "8.3.4",
|