@ultimat3/realtime 21.0.0 → 22.0.0

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.
Files changed (47) hide show
  1. package/CLAUDE.md +293 -1009
  2. package/README.md +78 -12
  3. package/package.json +4 -4
  4. package/src/changefeed.ts +7 -1
  5. package/src/channel-authz.ts +23 -4
  6. package/src/channel-decl.ts +16 -5
  7. package/src/channel-describe.ts +7 -5
  8. package/src/channel-logs.ts +19 -1
  9. package/src/channel-records.ts +8 -0
  10. package/src/client-channels.ts +75 -5
  11. package/src/client.ts +14 -2
  12. package/src/cursor.ts +5 -0
  13. package/src/errors.ts +21 -0
  14. package/src/idb-fake.ts +24 -4
  15. package/src/idb-types.ts +7 -0
  16. package/src/index.ts +0 -1
  17. package/src/live-definition.ts +5 -1
  18. package/src/live-fanout.ts +51 -2
  19. package/src/live-query.ts +11 -0
  20. package/src/live-replicator.ts +160 -0
  21. package/src/local-store-idb.ts +89 -15
  22. package/src/matcher-bridge.ts +5 -0
  23. package/src/nats-fake.ts +10 -1
  24. package/src/nats-jetstream.ts +36 -14
  25. package/src/nats-transport.ts +2 -2
  26. package/src/offline-queue.ts +76 -21
  27. package/src/page-outbox.ts +80 -10
  28. package/src/page-socket.ts +39 -8
  29. package/src/pg-entity-row.ts +37 -184
  30. package/src/pg-preflight.ts +24 -2
  31. package/src/pg-replication.ts +19 -6
  32. package/src/pg-wire.ts +51 -15
  33. package/src/policy-fake.ts +14 -0
  34. package/src/query-window.ts +35 -21
  35. package/src/replicator.ts +13 -3
  36. package/src/server.ts +8 -3
  37. package/src/socket-drops.ts +30 -0
  38. package/src/socket-engine.ts +15 -3
  39. package/src/socket-host.ts +103 -4
  40. package/src/socket-idle.ts +21 -0
  41. package/src/socket.ts +41 -38
  42. package/src/subscriber-gate.ts +92 -3
  43. package/src/sync-node.ts +2 -7
  44. package/src/thundering-herd.ts +12 -11
  45. package/src/transport-env.ts +55 -14
  46. package/src/use-mutation.ts +13 -0
  47. package/src/use-query.ts +10 -5
@@ -181,8 +181,16 @@ export async function kvGet(
181
181
  }
182
182
 
183
183
  /**
184
- * Every current value under a wildcard, in one request. A batch direct read answers with the
185
- * messages and then an empty `204 EOB`; a prefix nobody has written answers `404` and nothing else.
184
+ * Every current value under a wildcard. A batch direct read answers at most `batch` messages and
185
+ * then an empty `204 EOB`; a range nobody has written answers `404` and nothing else.
186
+ *
187
+ * PAGED, on the last sequence read, and by `next_by_subj` rather than `multi_last`. It was one
188
+ * `multi_last` batch, and that fails twice past 1,000 members: the batch truncated the set — and
189
+ * `sweep()`, which differences the full set, announced a `leave` for every member past the cut —
190
+ * and a real nats-server (2.11, measured) refuses a `multi_last` matching more than 1,024 subjects
191
+ * outright with `413 Too Many Results`, which the old loop skipped as a marker and returned an
192
+ * EMPTY set. The bucket keeps one message per subject (`max_msgs_per_subject: 1`), so walking every
193
+ * message under the filter IS the last value per key. A page shorter than `batch` is the last.
186
194
  */
187
195
  export async function kvLast(
188
196
  client: NatsClient,
@@ -191,19 +199,33 @@ export async function kvLast(
191
199
  batch = 1_000,
192
200
  ): Promise<readonly KvRecord[]> {
193
201
  const subject = `$JS.API.DIRECT.GET.${kvStream(bucket)}`;
194
- const body = { multi_last: [kvSubject(bucket, filter)], batch };
195
- const replies = await client.requestMany(subject, encoder.encode(JSON.stringify(body)), {
196
- until: (message) => message.status === STATUS_EOB || message.status === STATUS_NOT_FOUND,
197
- });
198
- const records: KvRecord[] = [];
199
- for (const reply of replies) {
200
- // A status on a batch reply is a marker, never a value — the terminator is filtered by `until`,
201
- // and anything else the server slips in (a `408` heartbeat) carries no message to read.
202
- if (reply.status !== 0) continue;
203
- const record = recordOf(reply, bucket);
204
- if (record) records.push(record);
202
+ const byKey = new Map<string, KvRecord>();
203
+ let from = 1;
204
+ for (;;) {
205
+ const body = { seq: from, next_by_subj: kvSubject(bucket, filter), batch };
206
+ const replies = await client.requestMany(subject, encoder.encode(JSON.stringify(body)), {
207
+ until: (message) => message.status === STATUS_EOB || message.status === STATUS_NOT_FOUND,
208
+ });
209
+ let last = 0;
210
+ let read = 0;
211
+ for (const reply of replies) {
212
+ // The terminators are filtered by `until`. Any OTHER status — a `408` timeout, a `503` — is
213
+ // a batch that did not finish, and reading it as the whole set is the truncation above.
214
+ if (reply.status !== 0) {
215
+ throw new TransportUnavailableError({
216
+ transport: 'nats',
217
+ reason: `${subject} answered status ${reply.status} mid-batch, so the set read was incomplete`,
218
+ });
219
+ }
220
+ read += 1;
221
+ const seq = Number.parseInt(reply.header('Nats-Sequence') ?? '', 10);
222
+ if (Number.isSafeInteger(seq) && seq > last) last = seq;
223
+ const record = recordOf(reply, bucket);
224
+ if (record) byKey.set(record.key, record);
225
+ }
226
+ if (read < batch || last === 0) return [...byKey.values()];
227
+ from = last + 1;
205
228
  }
206
- return records;
207
229
  }
208
230
 
209
231
  /** A KV write is a publish that waits for JetStream's ack — a lost put must not read as stored. */
@@ -18,7 +18,7 @@ import { parseNatsUrl } from './nats-client';
18
18
  import { ensureKvBucket } from './nats-jetstream';
19
19
  import { NatsKvSet } from './nats-kv';
20
20
  import { openNatsClient } from './nats-lib-client';
21
- import { type BackoffPolicy, backoffDelay, defaultBackoff, type Rng } from './thundering-herd';
21
+ import { type BackoffPolicy, defaultBackoff, policyDelay, type Rng } from './thundering-herd';
22
22
 
23
23
  const encoder = new TextEncoder();
24
24
  const decoder = new TextDecoder();
@@ -156,7 +156,7 @@ export class NatsTransport implements Transport {
156
156
  maxReconnectAttempts: this.#attempts,
157
157
  // The library retries; the spread is ours, so a cluster restart does not bring every node
158
158
  // back on the same millisecond.
159
- reconnectDelay: () => backoffDelay(this.#retries++, this.#backoff, this.#rng),
159
+ reconnectDelay: () => policyDelay(this.#backoff, ++this.#retries, this.#rng),
160
160
  onError: (error) => this.#report(error, this.name),
161
161
  onReconnect: () => this.#recovered(),
162
162
  });
@@ -33,21 +33,38 @@ export interface QueueState {
33
33
  readonly nextSeq: number;
34
34
  }
35
35
 
36
+ /**
37
+ * One change to the durable queue: these entries written BY KEY, these keys deleted, and the
38
+ * sequence floor. Never the whole queue — two tabs of one user share the store, and a whole-queue
39
+ * save let the last tab to save erase the other's queued write.
40
+ */
41
+ export interface QueueChange {
42
+ readonly puts: readonly QueuedMutation[];
43
+ readonly deletes: readonly string[];
44
+ readonly nextSeq: number;
45
+ }
46
+
36
47
  /** Durability seam: IndexedDB in the browser (`page-outbox.ts`), memory in tests. */
37
48
  export interface QueueStore {
38
49
  load(): Promise<QueueState>;
39
- save(state: QueueState): Promise<void>;
50
+ write(change: QueueChange): Promise<void>;
40
51
  }
41
52
 
42
53
  export class MemoryQueueStore implements QueueStore {
43
- #state: QueueState = { mutations: [], nextSeq: 1 };
54
+ readonly #mutations = new Map<string, QueuedMutation>();
55
+ #nextSeq = 1;
44
56
 
45
57
  async load(): Promise<QueueState> {
46
- return this.#state;
58
+ return {
59
+ mutations: [...this.#mutations.values()].map((m) => ({ ...m })),
60
+ nextSeq: this.#nextSeq,
61
+ };
47
62
  }
48
63
 
49
- async save(state: QueueState): Promise<void> {
50
- this.#state = { mutations: state.mutations.map((m) => ({ ...m })), nextSeq: state.nextSeq };
64
+ async write(change: QueueChange): Promise<void> {
65
+ for (const key of change.deletes) this.#mutations.delete(key);
66
+ for (const mutation of change.puts) this.#mutations.set(mutation.key, { ...mutation });
67
+ this.#nextSeq = Math.max(this.#nextSeq, change.nextSeq);
51
68
  }
52
69
  }
53
70
 
@@ -81,9 +98,37 @@ export class OfflineQueue {
81
98
  this.#nextSeq = state.nextSeq;
82
99
  }
83
100
 
84
- /** Rehydrates from durable storage, so a reload resumes the same queue with the same sequence. */
101
+ /**
102
+ * Rehydrates from durable storage, so a reload resumes the same queue with the same sequence.
103
+ *
104
+ * An `inflight` entry on disk belonged to a page that is gone, so it goes back to `pending`. Left
105
+ * as it was, `#sendable` skipped it forever — no ack was coming to a page that no longer exists —
106
+ * and every later write overtook it. The replay carries its idempotency key, so a write the old
107
+ * page did get through is answered from the action's idempotency store, never applied twice.
108
+ */
85
109
  static async open(store: QueueStore): Promise<OfflineQueue> {
86
- return new OfflineQueue(store, await store.load());
110
+ const queue = new OfflineQueue(store, await store.load());
111
+ await queue.#reclaimInflight();
112
+ return queue;
113
+ }
114
+
115
+ /**
116
+ * Re-reads the durable queue, which another tab of the same user may have written since this one
117
+ * opened. Call ONLY while this queue is the one draining — `page-outbox.ts` holds a Web Lock for
118
+ * exactly that — because it reclaims every `inflight` entry as `pending`: with the lock held, no
119
+ * other pass can have one on the wire.
120
+ */
121
+ async reload(): Promise<void> {
122
+ const state = await this.#store.load();
123
+ this.#mutations = state.mutations.map((mutation) => ({ ...mutation }));
124
+ this.#nextSeq = Math.max(this.#nextSeq, state.nextSeq);
125
+ await this.#reclaimInflight();
126
+ }
127
+
128
+ async #reclaimInflight(): Promise<void> {
129
+ const reclaimed = this.#mutations.filter((mutation) => mutation.status === 'inflight');
130
+ for (const mutation of reclaimed) mutation.status = 'pending';
131
+ if (reclaimed.length > 0) await this.#persist(reclaimed);
87
132
  }
88
133
 
89
134
  get size(): number {
@@ -133,6 +178,8 @@ export class OfflineQueue {
133
178
  // entry is dropped and this one takes a new sequence at the back of the queue.
134
179
  // By identity: `existing` IS the entry for this key, found above — no second key comparison.
135
180
  if (existing) this.#mutations = this.#mutations.filter((entry) => entry !== existing);
181
+ // Another tab of the same user may have taken sequence numbers since this one loaded.
182
+ this.#nextSeq = Math.max(this.#nextSeq, (await this.#store.load()).nextSeq);
136
183
  const mutation: QueuedMutation = {
137
184
  key: args.key,
138
185
  seq: this.#nextSeq,
@@ -145,7 +192,7 @@ export class OfflineQueue {
145
192
  };
146
193
  this.#nextSeq += 1;
147
194
  this.#mutations.push(mutation);
148
- await this.#persist();
195
+ await this.#persist([mutation]);
149
196
  return mutation;
150
197
  }
151
198
 
@@ -190,12 +237,14 @@ export class OfflineQueue {
190
237
  async requeueInflight(): Promise<number> {
191
238
  this.#epoch += 1;
192
239
  let returned = 0;
240
+ const back: QueuedMutation[] = [];
193
241
  for (const mutation of this.#mutations) {
194
242
  if (mutation.status !== 'inflight') continue;
195
243
  mutation.status = 'pending';
196
244
  returned += 1;
245
+ back.push(mutation);
197
246
  }
198
- if (returned > 0) await this.#persist();
247
+ if (returned > 0) await this.#persist(back);
199
248
  return returned;
200
249
  }
201
250
 
@@ -206,7 +255,7 @@ export class OfflineQueue {
206
255
  mutation.status = 'acked';
207
256
  // By identity: `find` already matched it, and a second comparison of the key is the same question.
208
257
  this.#mutations = this.#mutations.filter((entry) => entry !== mutation);
209
- await this.#persist();
258
+ await this.#persist([], [key]);
210
259
  }
211
260
 
212
261
  /** Terminal failure (policy denial, validation): kept for the UI, never retried blindly. */
@@ -215,7 +264,7 @@ export class OfflineQueue {
215
264
  if (!mutation) return;
216
265
  mutation.status = 'failed';
217
266
  mutation.error = error;
218
- await this.#persist();
267
+ await this.#persist([mutation]);
219
268
  }
220
269
 
221
270
  /**
@@ -252,6 +301,7 @@ export class OfflineQueue {
252
301
  return { sent: 0, collapsed: this.#collapsed, remaining: 0, stoppedAt: null };
253
302
  }
254
303
  let sent = 0;
304
+ const touched: QueuedMutation[] = [];
255
305
  for (const mutation of sendable) {
256
306
  // The connection this pass was draining into is gone, and `requeueInflight` has already
257
307
  // handed back what was on it. Everything left stays `pending` for the pass the next
@@ -269,6 +319,7 @@ export class OfflineQueue {
269
319
  if (!this.#stillSendable(mutation)) continue;
270
320
  mutation.status = 'inflight';
271
321
  mutation.attempts += 1;
322
+ touched.push(mutation);
272
323
  try {
273
324
  await send(mutation);
274
325
  // Stays `inflight`. `send` resolving means the frame reached a socket — a browser
@@ -280,7 +331,7 @@ export class OfflineQueue {
280
331
  } catch (error) {
281
332
  mutation.status = 'pending';
282
333
  mutation.error = toQueueError(error);
283
- await this.#persist();
334
+ await this.#persist([mutation]);
284
335
  return {
285
336
  sent,
286
337
  collapsed: this.#collapsed,
@@ -289,7 +340,9 @@ export class OfflineQueue {
289
340
  };
290
341
  }
291
342
  }
292
- await this.#persist();
343
+ // Only what this pass touched AND the queue still holds: an entry the server settled during the
344
+ // pass was already deleted by `ack`, and writing it back would resurrect it.
345
+ await this.#persist(touched.filter((mutation) => this.#mutations.includes(mutation)));
293
346
  return {
294
347
  sent,
295
348
  collapsed: this.#collapsed,
@@ -299,19 +352,21 @@ export class OfflineQueue {
299
352
  }
300
353
 
301
354
  async clear(): Promise<void> {
355
+ const keys = this.#mutations.map((mutation) => mutation.key);
302
356
  this.#mutations = [];
303
- await this.#persist();
357
+ await this.#persist([], keys);
304
358
  }
305
359
 
306
360
  /**
307
- * A snapshot, never the live entries. `save` is a durable write — OPFS, IndexedDB — and it is
308
- * allowed to await before it reads. Handed the array itself, a store that resolves after the next
309
- * pass has moved on persists a status that was never true when it was called; `inflight` is the
310
- * one a reload cannot recover from, because `#sendable` skips it and no ack is coming.
361
+ * Snapshots, never the live entries. `write` is a durable write — IndexedDB — and it is allowed
362
+ * to await before it reads. Handed an entry itself, a store that resolves after the next pass has
363
+ * moved on persists a status that was never true when it was called. BY KEY, never the whole
364
+ * queue: a second tab's entries are not this tab's to overwrite or delete.
311
365
  */
312
- async #persist(): Promise<void> {
313
- await this.#store.save({
314
- mutations: this.#mutations.map((mutation) => ({ ...mutation })),
366
+ async #persist(puts: readonly QueuedMutation[], deletes: readonly string[] = []): Promise<void> {
367
+ await this.#store.write({
368
+ puts: puts.map((mutation) => ({ ...mutation })),
369
+ deletes,
315
370
  nextSeq: this.#nextSeq,
316
371
  });
317
372
  }
@@ -74,12 +74,17 @@ export function createOutbox(options: OutboxOptions): PageOutbox {
74
74
  const overlays =
75
75
  options.overlays ?? ((): OutboxOverlays | undefined => peekPageRealtime()?.store);
76
76
  let queue: OfflineQueue | undefined;
77
+ /** The scope the open queue belongs to — what the drain lock is named after. */
78
+ let scope: string | undefined;
77
79
 
78
80
  const open = async (): Promise<void> => {
79
- queue = await OfflineQueue.open(queueStore(await options.local, scopeKey(principal())));
81
+ scope = scopeKey(principal());
82
+ queue = await OfflineQueue.open(queueStore(await options.local, scope));
80
83
  };
81
84
  let ready = open();
82
85
  let running: Promise<DrainReport> | undefined;
86
+ /** A trigger landed while a pass ran: one more pass follows it, never one per trigger. */
87
+ let again = false;
83
88
 
84
89
  const deliver = async (mutation: QueuedMutation): Promise<void> => {
85
90
  const current = queue;
@@ -116,7 +121,7 @@ export function createOutbox(options: OutboxOptions): PageOutbox {
116
121
  });
117
122
  });
118
123
 
119
- return {
124
+ const self: PageOutbox = {
120
125
  enqueue: async (entry) => {
121
126
  // A disk that refused the rows must not also cost the write: the intent still goes on disk.
122
127
  await options.beforeEnqueue?.().catch(() => undefined);
@@ -127,9 +132,13 @@ export function createOutbox(options: OutboxOptions): PageOutbox {
127
132
  // Single flight: a trigger that lands while a replay is running JOINS it. Open, `online`,
128
133
  // the socket's reconnect and the service worker's drain arrive together, and each chaining
129
134
  // a pass of its own sent the head of the queue once per trigger whenever a send failed —
130
- // one write, several POSTs. What a joined trigger would have sent is still queued for the
131
- // next one; nothing is dropped.
132
- if (running !== undefined) return running;
135
+ // one write, several POSTs. What the running pass could not have seen — a write queued
136
+ // after it re-read the store — gets exactly ONE follow-up pass, however many triggers
137
+ // joined: `again` is a flag, never a count.
138
+ if (running !== undefined) {
139
+ again = true;
140
+ return running;
141
+ }
133
142
  const pass = (async (): Promise<DrainReport> => {
134
143
  await ready;
135
144
  if (queue === undefined) return EMPTY;
@@ -137,13 +146,29 @@ export function createOutbox(options: OutboxOptions): PageOutbox {
137
146
  // attempt the browser already knows cannot leave is a failed request on the wire and
138
147
  // nothing more. `online` asks again.
139
148
  if (knownOffline()) return { ...EMPTY, remaining: queue.pending().length };
140
- return queue.drain(deliver);
149
+ const draining = queue;
150
+ // One tab drains a principal's outbox at a time, and it drains what EVERY tab queued: the
151
+ // queue is re-read under the lock, so a write another tab made since this one opened is
152
+ // sent too, and an `inflight` entry — which only a pass holding this lock could have put
153
+ // on the wire, and it is over — goes back to `pending`.
154
+ return await exclusive(`ultimate-outbox:${scope ?? 'memory'}`, async () => {
155
+ await draining.reload();
156
+ return await draining.drain(deliver);
157
+ });
141
158
  })();
142
- const settled = (): void => {
143
- if (running === pass) running = undefined;
159
+ const settled = (report?: DrainReport): void => {
160
+ if (running !== pass) return;
161
+ running = undefined;
162
+ // Only after a pass that reached the end: one stopped by a failure leaves the rest for
163
+ // the next trigger, as it always did.
164
+ if (again && report !== undefined && report.stoppedAt === null) {
165
+ again = false;
166
+ void self.replay().catch(() => undefined);
167
+ }
168
+ again = false;
144
169
  };
145
170
  running = pass;
146
- pass.then(settled, settled);
171
+ pass.then(settled, () => settled());
147
172
  return pass;
148
173
  },
149
174
  get size(): number {
@@ -154,6 +179,7 @@ export function createOutbox(options: OutboxOptions): PageOutbox {
154
179
  return ready;
155
180
  },
156
181
  };
182
+ return self;
157
183
  }
158
184
 
159
185
  /** Persisted under the principal; an UNSCOPED page queues in memory only, and loses it on reload. */
@@ -162,10 +188,54 @@ function queueStore(local: LocalStore, scope: string | undefined): QueueStore {
162
188
  return {
163
189
  load: async (): Promise<QueueState> =>
164
190
  (await local.queue(scope)) ?? { mutations: [], nextSeq: 1 },
165
- save: (state) => local.saveQueue(scope, state),
191
+ write: (change) => local.writeQueue(scope, change),
166
192
  };
167
193
  }
168
194
 
195
+ /** The slice of the Web Locks API a drain needs. */
196
+ interface LockManagerLike {
197
+ request<T>(name: string, callback: () => Promise<T>): Promise<T>;
198
+ }
199
+
200
+ /**
201
+ * `work` under the browser's Web Lock named `name`, so two tabs of one user never drain one outbox
202
+ * at once — they would each send the head of the queue. With no `navigator.locks` the same
203
+ * exclusion is kept inside this realm, on a chain held on `globalThis` (every island bundle carries
204
+ * its own copy of this module): tabs cannot be excluded there, but two outboxes in one page can.
205
+ */
206
+ function exclusive<T>(name: string, work: () => Promise<T>): Promise<T> {
207
+ const navigator: unknown = Reflect.get(globalThis, 'navigator');
208
+ const locks: unknown =
209
+ typeof navigator === 'object' && navigator !== null
210
+ ? Reflect.get(navigator, 'locks')
211
+ : undefined;
212
+ if (
213
+ typeof locks === 'object' &&
214
+ locks !== null &&
215
+ typeof Reflect.get(locks, 'request') === 'function'
216
+ ) {
217
+ return (locks as LockManagerLike).request(name, work);
218
+ }
219
+ const host = globalThis as LockHost;
220
+ const chains = host[LOCAL_LOCKS] ?? new Map<string, Promise<unknown>>();
221
+ if (host[LOCAL_LOCKS] === undefined) {
222
+ Object.defineProperty(host, LOCAL_LOCKS, { value: chains, configurable: true });
223
+ }
224
+ const ahead = chains.get(name) ?? Promise.resolve();
225
+ const turn = ahead.then(work, work);
226
+ const tail = turn.then(
227
+ () => undefined,
228
+ () => undefined,
229
+ );
230
+ chains.set(name, tail);
231
+ void tail.then(() => {
232
+ if (chains.get(name) === tail) chains.delete(name);
233
+ });
234
+ return turn;
235
+ }
236
+
237
+ const LOCAL_LOCKS: unique symbol = Symbol.for('ultimate.outbox-locks');
238
+ type LockHost = { [LOCAL_LOCKS]?: Map<string, Promise<unknown>> };
169
239
  function sendOverHttp(entry: OutboxEntry, carried: Set<string>): Promise<unknown> {
170
240
  return clientTransport({
171
241
  method: 'POST',
@@ -5,10 +5,17 @@
5
5
  import { onRescope, pageClient } from '@ultimat3/core/page';
6
6
  import { queryClientMethodFor } from '@ultimat3/query/client';
7
7
  import { LiveClient } from './client';
8
+ import { DEFAULT_HEARTBEAT_MS } from './client-heartbeat';
8
9
  import { peekOutbox } from './outbox-slot';
9
10
  import { SyncUnconfiguredError } from './page-errors';
10
11
  import { pageRealtime } from './page-store';
11
- import { openHost, type SocketHost, type SocketHostOptions } from './socket-host';
12
+ import {
13
+ openHost,
14
+ type RehostingHost,
15
+ rehosting,
16
+ type SocketHost,
17
+ type SocketHostOptions,
18
+ } from './socket-host';
12
19
  import { pageSyncTarget, syncWorkerFromMeta } from './sync-meta';
13
20
 
14
21
  /** Get-or-create, and connect on creation. `hook` names the caller in the refusal. */
@@ -20,7 +27,7 @@ export function pageSocket(hook: string): LiveClient {
20
27
  // What the bootstrap passed, else what the document shell rendered into `<head>`.
21
28
  const target = page.sync ?? pageSyncTarget();
22
29
  if (target === undefined) throw new SyncUnconfiguredError({ hook });
23
- let host = hostFor(pageClient().scope.principal ?? null);
30
+ let host = rehostingFor(pageClient().scope.principal ?? null, target.buildId);
24
31
  const client = new LiveClient({
25
32
  connect: () => host.socket(target),
26
33
  buildId: target.buildId,
@@ -46,15 +53,31 @@ export function pageSocket(hook: string): LiveClient {
46
53
  // A new principal gets its own worker — never the previous principal's socket — and redials.
47
54
  const offRescope = onRescope((next) => {
48
55
  host.bye();
49
- host = hostFor(next.principal ?? null);
56
+ host = rehostingFor(next.principal ?? null, target.buildId);
50
57
  client.connect();
51
58
  });
52
- const bye = (): void => host.bye();
59
+ // `bye` only for a page that is really going: a `pagehide` into the back/forward cache is a page
60
+ // that may come back, and one that said bye came back to a port the engine had released.
61
+ const bye = (event: Event): void => {
62
+ if (Reflect.get(event, 'persisted') !== true) host.bye();
63
+ };
64
+ // ...and a page restored from that cache re-hosts rather than trusting the port it left with.
65
+ const restored = (event: Event): void => {
66
+ if (Reflect.get(event, 'persisted') !== true) return;
67
+ host.rehost();
68
+ client.connect();
69
+ };
53
70
  const listens = typeof addEventListener === 'function';
54
- if (listens) addEventListener('pagehide', bye);
71
+ if (listens) {
72
+ addEventListener('pagehide', bye);
73
+ addEventListener('pageshow', restored);
74
+ }
55
75
  teardowns.add(() => {
56
76
  offRescope();
57
- if (listens) removeEventListener('pagehide', bye);
77
+ if (listens) {
78
+ removeEventListener('pagehide', bye);
79
+ removeEventListener('pageshow', restored);
80
+ }
58
81
  client.close();
59
82
  host.bye();
60
83
  if (page.socket === client) page.socket = undefined;
@@ -99,10 +122,18 @@ export function hasPageSocket(): boolean {
99
122
  );
100
123
  }
101
124
 
102
- function hostFor(scope: string | null): SocketHost {
125
+ function hostFor(scope: string | null, buildId: string): SocketHost {
103
126
  const workerUrl =
104
127
  typeof document === 'undefined' || typeof location === 'undefined'
105
128
  ? undefined
106
129
  : syncWorkerFromMeta(document, location.href);
107
- return hosts({ workerUrl, scope });
130
+ return hosts({ workerUrl, scope, buildId });
131
+ }
132
+
133
+ /**
134
+ * The host for one principal, re-made when an `open` goes unanswered for two heartbeats — a port
135
+ * the engine reaped, or a worker that died — rather than leaving the tab dialling a dead port.
136
+ */
137
+ function rehostingFor(scope: string | null, buildId: string): RehostingHost {
138
+ return rehosting(() => hostFor(scope, buildId), { openTimeoutMs: 2 * DEFAULT_HEARTBEAT_MS });
108
139
  }