cross-tab-worker-databus 0.21.9 → 0.21.11

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/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  ## [Unreleased]
2
2
 
3
+
4
+ ## [0.21.11] - 2026-09-23
5
+
6
+ ### Documentation
7
+ - Two comments in `src/workers/port-reaper.ts` described mechanisms the code does not have, and both were corrected against a measurement rather than a re-read. `touch()`'s early return claimed to stop a late PING "resurrecting" a removed port in the reaper's tracking — deleting the guard instead passes the whole suite, because `reap()` iterates `targets`, so the only difference is one stale `lastSeenAt` row per dead port; the comment now says that, and says why the guard is still worth having (it is what keeps the three maps holding exactly the tracked ports, which is the enumeration `reap()`'s `??` fallbacks rest on). And `reap()`'s `Array.from(…)` snapshot claimed to prevent "skipping a subsequent entry or visiting one twice" — `Map` iteration is already deletion-safe, iterating the live map passes every test, and the one case a snapshot changes (a re-entrant `register()` during a teardown) is neutralised by `register()` stamping `lastSeenAt` with the current time. It stays, now labelled defensive rather than load-bearing. No behaviour change; these comments are bundled into `dist/centrifuge.shared.worker.js`, which is why they are recorded rather than edited silently.
8
+ - Recorded so the next pass does not repeat the work: a seeded interleaving harness for this class was written, run, and deleted. Four invariants over register/touch/configure/remove/advance with an injected clock explored 2,000 seeds green in 331 ms (~6,000 seeds/s), then twelve mutants showed it killed none that the 19 existing unit tests do not also kill — nine die to both, `schedule()`'s idempotency guard dies only to the unit tests' `expect(sets).toEqual([])`, and the two survivors are the map-hygiene and snapshot cases above. An interleaving sweep feels like more coverage because it visits more states; the question that decides it is whether any mutant dies to it alone. `AGENTS.md` carries that rule.
9
+ - `docs/configuration.md` and `docs/zh/configuration.md` said the replay persistence retry delays "grow exponentially and are capped". Only the *growth* is capped. `withPersistenceRetry()` seeds its first wait from `replay.persistenceRetry.backoffMs` and applies `Math.min(delay * 2, 1_600)` **after** that wait, so a configured `backoffMs` above the ceiling is waited in full and the schedule then falls to it — with `backoffMs: 5000` the waits are 5000, 1600, 1600, which does not grow at all. `assertPersistenceRetryOptions` accepts any non-negative finite number, so this is a reachable configuration rather than a misuse. The sentence now says which of the two the ceiling bounds and names the measured sequence. Reading the old one the other way — clamping the first wait as well — was considered and rejected: it would silently ignore a value the integrator set, in the opposite direction from what `backoffMs` is documented to mean. No behaviour change.
10
+ - `docs/architecture.md` claimed, in six sentences (each mirrored in Chinese), that coordination traffic "never touch[es] localStorage" and that "publications are not written to localStorage". Under the opt-in `channelFallback: 'storage-event'` the opposite is structural: the envelope *is* the frame, so every `CONTROL` — plaintext topic and `PUBLISH` payload included — is written whole under `cross-tab-worker-databus:channel:*` and stays until the channel closes, which after a tab crash means indefinitely. Four other docs files already stated that trade-off; architecture.md was the outlier, and it is the file a security or data-residency review reads. It also carried a `WorkerRecord` block that omitted `protocolVersion` and `throughput` under a sentence asserting the block "matches the current protocol"; placed `rememberTopic()` on *every* inbound `CONTROL` message (it runs only on those surviving the `topicKey` pairing check, so an invalid pair cannot grow the map); said `confirmedAt` is written "after the owner processes the control message" (it is stamped before the action reaches the transport, which is precisely why an unconfirmed route gets re-sent); and attributed the storage-event envelope's delivery to "a monotonic sequence" when `environment.ts` names the sender nonce as the part that separates two tabs whose first frame is each `seq=1`.
11
+ - The capabilities matrix held the same shape twice more. "Does not persist URLs, raw Topic names, credentials, or publication payloads" is true of the coordination records and false of the exported `createIndexedDbReplayPersistence`, whose object store uses the plaintext topic as its `keyPath` and holds payloads in its rows; and "Durable Messages | Not Implemented | The SDK does not persist business payloads" conflated two capabilities that go opposite ways — outbound publish commands are indeed never persisted, inbound replay history is. That row is now two, each with its own status. The storage-recovery row said a failing key is dropped "without blocking other queued keys", while `flush()` `break`s at the first failure, so everything queued behind one poisoned key waits through four backoff passes (50+100+200+400 ms) before the pass that drops it.
12
+ - `docs/getting-started.md` sold `workerMode: 'shared'` as letting same-origin Tabs "reuse a single connection". Each connecting port gets its own `CentrifugeSession` and therefore its own WebSocket — that per-port isolation is the reason closing one tab leaves the others subscribed — so N Tabs are still N server connections and N channel subscriptions; what shared mode saves is the Worker process. For anyone writing a transport, `docs/transports.md` pointed at the wrong callback and the wrong guard: the recovery cooldown is driven by `onStatus('error')`, while `onError` only records and notifies, so a backend that reports a dead socket through `onError` alone is never reopened; and the generation counter is compared only by the asynchronous credential bridge, because `stop()`/`onWorkerFailed()` remove a superseded backend's listeners before it moves on. The same file's interface block omitted `publish`'s metadata argument, the optional `publishBatch`, and the two diagnostics labels, its Worker unions omitted `TOKEN_REQUEST`/`TOKEN_RESPONSE`/`TOKEN_ERROR`/`SESSION_REAPED`, and its wire sketch omitted the `publishBatch` frame that both the bundled transport and the demo server speak. The demo also defaults to its own local endpoint, not the public Centrifugo URL the page listed as the default (and which its own next paragraph already assumed was local), and the entry-point list omitted the `/vue` export plus the fourth hook each adapter exports. Corrected in both languages.
13
+
14
+ ### Tests
15
+ - The one test that claimed to pin the retry ceiling never reached it. `honors the backoff delay between attempts, capped by the retry ceiling` advanced 50 ms then 100 ms from a `backoffMs` of 50, so the ceiling in its name was doing nothing and the sentence above had no witness in either direction. It is now `doubles the backoff delay between attempts`, which is what its assertions show, and two new tests carry the cap: `caps the doubled delay at the retry ceiling and holds it there` walks the whole schedule 50 → 100 → 200 → 400 → 800 → 1600 → 1600 with a 1 ms boundary probe on each step, and `waits the configured backoffMs verbatim even when it exceeds the ceiling` checks that a 5000 ms `backoffMs` is not clamped. Three mutants, each measured: deleting the cap kills **both** new tests (`expected 7 to be 8` on the last row, `expected 2 to be 3` on the third), clamping the initial delay kills only the verbatim test, and deleting the doubling kills only the ceiling test — which is the evidence that the two pin different halves and neither is a spare.
16
+
17
+ - `handleControlMessage`'s route check is `route && route.workerId !== this.workerId`, so a topic whose route cannot be read is **accepted**; three descriptions claimed the stricter "only when the route names the receiver". `accepts a CONTROL/SUBSCRIBE that has no durable route to check, then sweeps it` now pins both halves of what actually makes that safe — `confirmRoute` writes nothing without a route to stamp, so no durable ownership is minted, and the next reconcile withdraws the assignment. Measured: the strict reading fails this test *and two* pre-existing load-weighting tests that already depended on the tolerance, and deleting the sweep's route check fails only the new one.
18
+ - `stores the frame verbatim, which is the trade-off the docs price the fallback at` asserts what storage ends up holding after one `PUBLISH` frame on a storage-event channel — the topic plaintext and the payload, both present, and both gone after `close()`. No delivery test can see this class of claim, which is how six sentences in `architecture.md` stayed wrong while every fallback test in the suite stayed green.
19
+
20
+ ## [0.21.10] - 2026-09-23
21
+
22
+ ### Fixed
23
+ - `heartbeatIntervalMs: Infinity` did the opposite of what it documented, in shared mode. It is accepted by `assertHeartbeatInterval`, appears in `docs/configuration.md`'s option table as disabling the PING heartbeat "entirely", and does stop the PINGs — pinned by `disables PING entirely when heartbeatIntervalMs is Infinity` — and the transport *advertises* the choice, because `buildInitMessage()` includes `heartbeatIntervalMs` whenever it differs from the default. `PortReaper.setTimeout()` then ran that value through the same `Number.isFinite` guard as `NaN`, `0` and negatives, fell back to the 10 s default, and so installed a **30 s session timeout on the one port guaranteed never to send another message**. The configuration whose stated purpose is "the reaper is not needed" therefore guaranteed a reap 30 s after connecting, with no heartbeat left to prevent the next one. Now `Infinity` means "never judged silent" while `NaN`/`0`/negative keep the fallback, because those three are rejected by the transport constructor and can only reach the Worker from a main thread that is not this library — `Infinity` is the one non-finite value the public API both accepts and acts on. One new test kills three mutants at three different assertions (`never reaps a port whose heartbeat was disabled with Infinity`): the shipped behaviour fails `exempt.port.closed` after ten simulated minutes; a variant where an exempt port clears the reaper's interval fails the *second* tab's `normal.port.closed`, which is the per-port half of the contract; and a variant where the exempt port simply leaves the reaper's tracking maps fails the follow-up `dispose()` assertion, which the pre-existing dispose tests cannot supply because they never pass `Infinity`. That third mutant is worth the detail, because before this test existed it appeared to be caught already — by `falls back to the default interval for non-positive or NaN heartbeat configs`, whose bad-value loop calls `+Infinity` and then `-Infinity` on the same port, so the last call silently undid the exemption. An ordering accident, not an assertion, so `+Infinity` has been taken out of that list with a note saying why. One further assertion in the existing PING test pins the wire half — `INIT` must carry the `Infinity` — because a reaper-side fix alone would leave the main thread unable to say *why* it disabled reaping, and measured, normalising non-finite values out of `INIT` fails it. The old malformed-INIT test's comment had been listing `Infinity` among the malformed payloads, which is how two green tests hid one defect: a test that asserts a fallback is not evidence about which inputs deserve one. Whole-tree coverage unchanged at 99.01 / 96.90 / 99.26 / 99.69 over **891** tests, and `port-reaper.ts` still reports three zero-count branch arms — the two `??` fallbacks in `reap()` and a `dispose()` guard — so the arm this fix adds is exercised.
24
+
25
+ ### Documentation
26
+ - Two sentences in the shipped `docs/configuration.md` and its Chinese counterpart said things the code does not do. **Shutdown cleanup** claimed that "when the SharedWorker shuts down, `PortReaper.dispose()` stops the timer and closes/stops every still-tracked session". `dispose()` has no Worker-side caller: `dispose` appears exactly once in `dist/centrifuge.shared.worker.js`, as the method definition, and no invocation of it exists anywhere in `dist/`. That is the same fact the reaper's own header records about the port — a `MessagePort` has no close event, so there is no "the SharedWorker is shutting down" signal for a hook to hang on — and the bullet now says so and names the two paths a port actually leaves tracking through. And the heartbeat and session-timeout bullets now state what `Infinity` does to the *reaper* rather than only to the PINGs, in both languages, together with the cost of choosing it: a tab configured that way and then crashed leaves its session and its WebSocket in place, because the one thing that would have noticed was switched off. The same sentence reached `docs/api.md` / `docs/zh/api.md`, whose option entries said only that `Infinity` "disables heartbeats". The **Failure isolation** bullet, written before 0.21.9's announcement, now covers `notify()` as well. No further behaviour change and no coverage movement.
27
+
28
+
29
+ ### Tests
30
+ - One test in the IndexedDB replay harness passed while breaking the gate. Its three loads fail on real 0/15/40 ms timers, and the third rejection was asserted *after* a healthy reopen round trip — so on a runner where that round trip takes longer than 40 ms, the promise rejects while nothing is attached to it, and `pnpm test:coverage` exits 1 with `Unhandled Rejection: Error: transaction aborted` alongside a report of **34 passed** and a checkmark on every test. Observed locally at 1-minute load average 86–157; the same file at lower load had been clean all along, which is what makes this runner-dependent rather than deterministic. The three assertions are now created next to the three calls and awaited where the ordering belongs, and the causal link was measured on the real file with the round trip forced to 200 ms: attached at the assertion it exits 1 with the unhandled block and 34/34 passing, attached up front the same run exits 0. Nothing in `src/` is implicated — the orphaned promise is one the test itself created and had not yet awaited — and `AGENTS.md` gained the rule, because "every checkmark green, the run red" reads like infrastructure until someone attaches the exit code to it.
31
+
3
32
  ## [0.21.9] - 2026-09-23
4
33
 
5
34
  ### Fixed
@@ -7,7 +7,7 @@ import {
7
7
  publicationMetadata,
8
8
  selectWorkerBackend,
9
9
  serializeError
10
- } from "./chunk-75ZDU7VG.js";
10
+ } from "./chunk-UO57SLQD.js";
11
11
  import {
12
12
  CENTRIFUGE_INPUT_TYPE,
13
13
  CENTRIFUGE_OUTPUT_TYPE,
@@ -5369,20 +5369,30 @@ var PortReaper = class {
5369
5369
  this.sessionTimeoutMs.set(port, DEFAULT_SESSION_TIMEOUT_MS);
5370
5370
  this.schedule();
5371
5371
  }
5372
- /** Record activity on a port (any incoming message). No-op for untracked
5373
- * ports — a late PING from a port that was already removed/STOP'd must not
5374
- * resurrect it in the reaper's tracking maps. */
5372
+ /** Record activity on a port (any incoming message). The early return is what
5373
+ * keeps a late PING from a removed port out of `lastSeenAt`: `reap()` iterates
5374
+ * `targets`, so a row here would be unreachable rather than dangerous — measured,
5375
+ * deleting the guard passes every test in the suite, because the only difference is
5376
+ * one stale map entry per dead port. It stays as the cheap invariant that the three
5377
+ * maps hold exactly the tracked ports, which is what `reap()`'s `??` fallbacks and
5378
+ * this file's tracking contract both lean on. */
5375
5379
  touch(port) {
5376
5380
  if (!this.targets.has(port)) return;
5377
5381
  this.lastSeenAt.set(port, this.now());
5378
5382
  }
5379
5383
  /** Override a port's session timeout from its INIT heartbeat config.
5380
- * A non-finite or non-positive value falls back to the default so a bad
5381
- * payload cannot degenerate the reaper into a busy loop or silence it.
5382
- * No-op for untracked ports (e.g. setTimeout arrives after remove/STOP). */
5384
+ * `Infinity` is the documented way to disable the PING heartbeat, and it means
5385
+ * this port can never be judged silent: `startHeartbeat()` sends nothing at all
5386
+ * once configured that way, so a timeout would be a death sentence with no
5387
+ * possible appeal. A non-finite-or-non-positive *other* value falls back to the
5388
+ * default so a bad payload cannot degenerate the reaper into a busy loop or
5389
+ * silence it — those three (NaN, 0, negative) are rejected by
5390
+ * `assertHeartbeatInterval` at the transport constructor and so can only arrive
5391
+ * from a main thread that is not this library. No-op for untracked ports (e.g.
5392
+ * setTimeout arrives after remove/STOP). */
5383
5393
  setTimeout(port, heartbeatIntervalMs) {
5384
5394
  if (!this.targets.has(port)) return;
5385
- const safe = Number.isFinite(heartbeatIntervalMs) && heartbeatIntervalMs > 0 ? heartbeatIntervalMs : DEFAULT_HEARTBEAT_INTERVAL_MS;
5395
+ const safe = heartbeatIntervalMs === Infinity ? Infinity : Number.isFinite(heartbeatIntervalMs) && heartbeatIntervalMs > 0 ? heartbeatIntervalMs : DEFAULT_HEARTBEAT_INTERVAL_MS;
5386
5396
  this.sessionTimeoutMs.set(port, safe * DEFAULT_SESSION_TIMEOUT_MULTIPLIER);
5387
5397
  this.schedule();
5388
5398
  }
@@ -5439,8 +5449,13 @@ var PortReaper = class {
5439
5449
  return minHeartbeat;
5440
5450
  }
5441
5451
  /** Close sessions whose port has been silent longer than its timeout.
5442
- * Iterates a snapshot so closing a target (which mutates `targets`) during
5443
- * the loop cannot skip a subsequent entry or visit one twice. */
5452
+ * Iterates a snapshot. That is defensive rather than load-bearing: `Map`
5453
+ * iteration in JS is deletion-safe, so removing the current entry cannot skip the
5454
+ * next one, and the only case a snapshot changes is a re-entrant `register()` from
5455
+ * inside a target's own teardown — which a fresh port would survive anyway, because
5456
+ * `register()` stamps `lastSeenAt` with the current time and a zero-age port is
5457
+ * never past its timeout. Measured: iterating the live map instead passes every
5458
+ * test, so do not read this line as pinned. */
5444
5459
  reap() {
5445
5460
  const now = this.now();
5446
5461
  let reapedAny = false;