cross-tab-worker-databus 0.21.10 → 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,22 @@
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
+
3
20
  ## [0.21.10] - 2026-09-23
4
21
 
5
22
  ### Fixed
@@ -7,7 +7,7 @@ import {
7
7
  publicationMetadata,
8
8
  selectWorkerBackend,
9
9
  serializeError
10
- } from "./chunk-L5P66THO.js";
10
+ } from "./chunk-UO57SLQD.js";
11
11
  import {
12
12
  CENTRIFUGE_INPUT_TYPE,
13
13
  CENTRIFUGE_OUTPUT_TYPE,
@@ -5369,9 +5369,13 @@ 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());
@@ -5445,8 +5449,13 @@ var PortReaper = class {
5445
5449
  return minHeartbeat;
5446
5450
  }
5447
5451
  /** Close sessions whose port has been silent longer than its timeout.
5448
- * Iterates a snapshot so closing a target (which mutates `targets`) during
5449
- * 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. */
5450
5459
  reap() {
5451
5460
  const now = this.now();
5452
5461
  let reapedAny = false;