@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 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
- add(this.#tenants, entry.tenant, entry);
361
- add(this.#members, key(entry.tenant, entry.member), entry);
358
+ let index = this.#tenants.get(entry.tenant);
359
+ if (!index) {
360
+ index = { all: new Set(), members: new Map(), rooms: new Map() };
361
+ this.#tenants.set(entry.tenant, index);
362
+ }
363
+ index.all.add(entry);
364
+ add(index.members, entry.member, entry);
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(this.#rooms, key(entry.tenant, room), entry);
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
- remove(this.#rooms, key(entry.tenant, room), entry);
388
+ const index = this.#tenants.get(entry.tenant);
389
+ if (index)
390
+ remove(index.rooms, room, entry);
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
- remove(this.#tenants, entry.tenant, entry);
453
- remove(this.#members, key(entry.tenant, entry.member), entry);
454
- for (const room of entry.rooms)
455
- remove(this.#rooms, key(entry.tenant, room), entry);
459
+ const index = this.#tenants.get(entry.tenant);
460
+ if (index) {
461
+ index.all.delete(entry);
462
+ remove(index.members, entry.member, entry);
463
+ for (const room of entry.rooms)
464
+ remove(index.rooms, room, entry);
465
+ if (!index.all.size)
466
+ this.#tenants.delete(entry.tenant);
467
+ }
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 this.#rooms.get(key(target.tenant, target.room));
499
+ return index.rooms.get(target.room);
485
500
  if (target.member !== undefined)
486
- return this.#members.get(key(target.tenant, target.member));
487
- return this.#tenants.get(target.tenant);
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
- written += this.#writeAll(this.#select({ ...target, tenant }), target, shared);
511
+ if (tenant.startsWith(target.tenantPrefix)) {
512
+ const exact = {
513
+ tenant,
514
+ ...(target.member === undefined ? {} : { member: target.member }),
515
+ ...(target.room === undefined ? {} : { room: target.room }),
516
+ };
517
+ written += this.#writeAll(this.#select(exact), exact, shared);
518
+ }
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 (target.room !== undefined &&
513
- target.member !== undefined &&
514
- entry.member !== target.member)
535
+ if (entry.tenant !== target.tenant) {
536
+ // Unreachable while the index is right; a defect, never a reason to cross tenants.
537
+ this.#throttle.log('error', 'SSE index returned a stream of another tenant; skipped', 'SSE_INDEX_MISMATCH', undefined, { stream: entry.id, path: entry.route.path });
538
+ continue;
539
+ }
540
+ // #select picked the room (or member) index; the rest of the target narrows here.
541
+ if (target.member !== undefined && entry.member !== target.member)
542
+ continue;
543
+ if (target.room !== undefined && !entry.rooms.has(target.room))
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.member === undefined || entry.member === filter.member) &&
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.0',
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.0",
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.0",
73
- "@dokebi/event-bus": "0.4.0",
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.0",
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",