@ultimat3/realtime 24.0.0 → 25.1.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 (88) hide show
  1. package/CLAUDE.md +61 -63
  2. package/README.md +131 -46
  3. package/package.json +9 -6
  4. package/src/advisory-lock.ts +10 -5
  5. package/src/boot.ts +14 -9
  6. package/src/changefeed-env.ts +8 -8
  7. package/src/changefeed.ts +42 -18
  8. package/src/channel-authz.ts +7 -7
  9. package/src/channel-describe.ts +1 -1
  10. package/src/channel-logs.ts +16 -25
  11. package/src/channel-presence.ts +3 -3
  12. package/src/channel-records.ts +3 -3
  13. package/src/channel-ref.ts +2 -2
  14. package/src/channel-registry.ts +2 -2
  15. package/src/channel.ts +8 -7
  16. package/src/client-channels.ts +2 -0
  17. package/src/client-frames.ts +9 -2
  18. package/src/client.ts +29 -2
  19. package/src/error-titles.ts +91 -0
  20. package/src/errors.ts +47 -90
  21. package/src/first-paint-hold.ts +49 -0
  22. package/src/idb-types.ts +2 -2
  23. package/src/index.ts +24 -6
  24. package/src/json.ts +16 -3
  25. package/src/live-contract.ts +14 -0
  26. package/src/live-definition.ts +12 -2
  27. package/src/live-deliver.ts +55 -0
  28. package/src/live-fanout.ts +81 -24
  29. package/src/live-query-options.ts +6 -0
  30. package/src/live-query.ts +66 -144
  31. package/src/live-reauth.ts +127 -0
  32. package/src/live-replicator.ts +28 -8
  33. package/src/live-resume.ts +44 -8
  34. package/src/live-rows.ts +32 -3
  35. package/src/live-spend.ts +50 -0
  36. package/src/local-store-idb.ts +5 -0
  37. package/src/matcher-bridge.ts +11 -5
  38. package/src/nats-client.ts +26 -4
  39. package/src/nats-lib-client.ts +36 -7
  40. package/src/nats-subscriptions.ts +63 -0
  41. package/src/nats-transport.ts +127 -15
  42. package/src/offline-queue.ts +5 -0
  43. package/src/page-errors.ts +8 -9
  44. package/src/page-outbox.ts +5 -5
  45. package/src/page-runtime-wait.ts +72 -0
  46. package/src/page-runtime.ts +32 -0
  47. package/src/page-socket.ts +7 -16
  48. package/src/page-store.ts +83 -16
  49. package/src/pg-advisory-lock.ts +9 -4
  50. package/src/pg-connection.ts +7 -1
  51. package/src/pg-replication.ts +6 -6
  52. package/src/pg-socket.ts +20 -3
  53. package/src/pg-values.ts +29 -14
  54. package/src/policy-gate.ts +7 -7
  55. package/src/presence.ts +2 -2
  56. package/src/principal-sockets.ts +75 -0
  57. package/src/query-window.ts +1 -1
  58. package/src/record-publisher.ts +178 -0
  59. package/src/record-store.ts +25 -4
  60. package/src/record-synced.ts +43 -1
  61. package/src/replication-errors.ts +3 -3
  62. package/src/replicator-envelope.ts +61 -11
  63. package/src/replicator-row.ts +1 -1
  64. package/src/replicator.ts +12 -23
  65. package/src/server.ts +36 -18
  66. package/src/socket-defaults.ts +20 -0
  67. package/src/socket.ts +14 -19
  68. package/src/subscription-book.ts +129 -71
  69. package/src/subscription-count.ts +151 -0
  70. package/src/sync-actor-change.ts +56 -0
  71. package/src/sync-bus-handlers.ts +131 -0
  72. package/src/sync-frames.ts +1 -1
  73. package/src/sync-node-bounds.ts +1 -1
  74. package/src/sync-node-contract.ts +16 -2
  75. package/src/sync-node.ts +115 -109
  76. package/src/sync-origin.ts +1 -1
  77. package/src/sync-protocol.ts +73 -13
  78. package/src/sync-upgrade.ts +39 -0
  79. package/src/type-pins.ts +9 -1
  80. package/src/use-channel.ts +19 -14
  81. package/src/use-connection.ts +3 -3
  82. package/src/use-mutation.ts +9 -16
  83. package/src/use-query.ts +11 -11
  84. package/src/use-record.ts +89 -10
  85. package/src/wire-channel.ts +5 -5
  86. package/src/wire-read.ts +2 -2
  87. package/src/idb-fake.ts +0 -133
  88. package/src/policy-fake.ts +0 -14
@@ -0,0 +1,63 @@
1
+ // Single responsibility: the transport's kept list of live subscriptions, so a client the library
2
+ // closed for good can be replaced and every subscription bound again on the new one. The library's
3
+ // own reconnect never reaches this list — it re-establishes the SAME client's subscriptions — so a
4
+ // subscription is bound once per client and a re-bind can never double a delivery.
5
+
6
+ import type { NatsClient, NatsMessageHandler, NatsSubscription } from './nats-client';
7
+
8
+ interface Kept {
9
+ readonly subject: string;
10
+ readonly deliver: NatsMessageHandler;
11
+ /** The binding on the current client; `undefined` once that client is gone or the bind failed. */
12
+ live: NatsSubscription | undefined;
13
+ }
14
+
15
+ /** A refused bind, handed back rather than thrown: one bad subject must not strand the others. */
16
+ export interface BindFailure {
17
+ readonly subject: string;
18
+ readonly error: unknown;
19
+ }
20
+
21
+ export class NatsSubscriptions {
22
+ readonly #kept = new Set<Kept>();
23
+
24
+ /**
25
+ * Binds on `client` first and keeps it only once bound: a subscribe the client refused is the
26
+ * caller's error, and keeping it would re-try a refusal on every re-dial. Returns the release.
27
+ */
28
+ add(
29
+ client: NatsClient,
30
+ subject: string,
31
+ deliver: NatsMessageHandler,
32
+ bind: (call: () => NatsSubscription) => NatsSubscription,
33
+ ): () => void {
34
+ const kept: Kept = { subject, deliver, live: undefined };
35
+ kept.live = bind(() => client.subscribe(subject, deliver));
36
+ this.#kept.add(kept);
37
+ return () => {
38
+ if (!this.#kept.delete(kept)) return;
39
+ const live = kept.live;
40
+ kept.live = undefined;
41
+ live?.unsubscribe();
42
+ };
43
+ }
44
+
45
+ /** The client is gone, and every binding with it. Nothing is unsubscribed: there is no wire. */
46
+ orphan(): void {
47
+ for (const kept of this.#kept) kept.live = undefined;
48
+ }
49
+
50
+ /** Every kept subscription, bound on a NEW client. Returns the ones it refused. */
51
+ bindAll(client: NatsClient): readonly BindFailure[] {
52
+ const failures: BindFailure[] = [];
53
+ for (const kept of this.#kept) {
54
+ if (kept.live !== undefined) continue;
55
+ try {
56
+ kept.live = client.subscribe(kept.subject, kept.deliver);
57
+ } catch (error) {
58
+ failures.push({ subject: kept.subject, error });
59
+ }
60
+ }
61
+ return failures;
62
+ }
63
+ }
@@ -1,7 +1,8 @@
1
1
  // Single responsibility: the production `Transport` — core NATS for fanout, JetStream KV for the
2
2
  // shared presence sets. The client underneath owns the wire and the reconnect, including
3
- // re-establishing subscriptions, which is what makes a `sync` node stateless: a lost connection is
4
- // re-dialled and re-subscribed underneath the caller, and this file keeps no socket state at all.
3
+ // re-establishing subscriptions, which is what makes a `sync` node stateless. The one loss it does
4
+ // not own is its own end: a client closed for good once its reconnect budget is spent is replaced
5
+ // here, on our backoff, and every kept subscription is bound again on the new one.
5
6
 
6
7
  import {
7
8
  type Clock,
@@ -18,6 +19,7 @@ import { parseNatsUrl } from './nats-client';
18
19
  import { ensureKvBucket } from './nats-jetstream';
19
20
  import { NatsKvSet } from './nats-kv';
20
21
  import { openNatsClient } from './nats-open';
22
+ import { NatsSubscriptions } from './nats-subscriptions';
21
23
  import { type BackoffPolicy, defaultBackoff, policyDelay, type Rng } from './thundering-herd';
22
24
 
23
25
  const encoder = new TextEncoder();
@@ -55,6 +57,10 @@ export class NatsTransport implements Transport {
55
57
  #dialing: Promise<NatsClient> | undefined;
56
58
  #retries = 0;
57
59
  #closed = false;
60
+ /** Set while a client the library gave up on is being replaced; the attempt in flight. */
61
+ #redialing: number | undefined;
62
+ #wakeRedial: (() => void) | undefined;
63
+ readonly #subscriptions = new NatsSubscriptions();
58
64
  readonly #reconnectListeners = new Set<() => void>();
59
65
 
60
66
  constructor(options: NatsTransportOptions) {
@@ -98,23 +104,26 @@ export class NatsTransport implements Transport {
98
104
  }
99
105
 
100
106
  /**
101
- * The subscription is the client's to keep: it survives a drop and comes back with the reconnect,
102
- * so there is no intent map here to re-bind from — and therefore no way for a re-bind to run
103
- * twice and double every change on the subject.
107
+ * A drop is the client's to recover: the subscription comes back with the library's reconnect,
108
+ * untouched by this file. It is also KEPT here, for the one loss the library does not recover —
109
+ * a client closed for good — and bound once on its replacement, never twice on one client.
104
110
  */
105
111
  async subscribe(subject: string, handler: TransportHandler): Promise<TransportSubscription> {
106
112
  const client = await this.#ensure();
107
113
  // Same seam as `publish`: a permissions violation on the subject is refused here, not later.
108
- const live = this.#translating(`subscribe to ${subject}`, () =>
109
- client.subscribe(subject, (message) => {
114
+ const release = this.#subscriptions.add(
115
+ client,
116
+ subject,
117
+ (message) => {
110
118
  try {
111
119
  handler(decoder.decode(message.payload), message.subject);
112
120
  } catch (error) {
113
121
  this.#report(error, message.subject);
114
122
  }
115
- }),
123
+ },
124
+ (call) => this.#translating(`subscribe to ${subject}`, call),
116
125
  );
117
- return { subject, unsubscribe: () => live.unsubscribe() };
126
+ return { subject, unsubscribe: release };
118
127
  }
119
128
 
120
129
  /**
@@ -132,6 +141,7 @@ export class NatsTransport implements Transport {
132
141
 
133
142
  async close(): Promise<void> {
134
143
  this.#closed = true;
144
+ this.#wakeRedial?.();
135
145
  const client = this.#client;
136
146
  this.#client = undefined;
137
147
  await client?.close();
@@ -141,7 +151,9 @@ export class NatsTransport implements Transport {
141
151
  * One dial, shared by every caller that races it. A client that has already been handed out is
142
152
  * reused whatever its state: while it is reconnecting the library is re-establishing that same
143
153
  * connection and its subscriptions, and a second dial alongside it would double every delivery.
144
- * A budget that ran out is a readiness failure, not a reason to start an unbounded retry here.
154
+ * A client closed for good is not handed out: `#lost` clears it and `#redial` replaces it, and a
155
+ * caller that lands meanwhile is refused at once rather than parked behind a dial that may take
156
+ * the library's whole connect budget.
145
157
  */
146
158
  #ensure(): Promise<NatsClient> {
147
159
  if (this.#closed) {
@@ -151,6 +163,19 @@ export class NatsTransport implements Transport {
151
163
  }
152
164
  const current = this.#client;
153
165
  if (current !== undefined) return Promise.resolve(current);
166
+ if (this.#redialing !== undefined) {
167
+ return Promise.reject(
168
+ new TransportUnavailableError({
169
+ transport: this.name,
170
+ reason: `the connection closed once its reconnect budget was spent, and re-dial attempt ${this.#redialing} is in progress`,
171
+ }),
172
+ );
173
+ }
174
+ return this.#dialOnce();
175
+ }
176
+
177
+ /** One dial, shared by every caller that races it. */
178
+ #dialOnce(): Promise<NatsClient> {
154
179
  this.#dialing ??= this.#dial().finally(() => {
155
180
  this.#dialing = undefined;
156
181
  });
@@ -164,6 +189,10 @@ export class NatsTransport implements Transport {
164
189
  * leaking one per retry.
165
190
  */
166
191
  async #dial(): Promise<NatsClient> {
192
+ // The client is only known once the dial resolves, and the library may give up on it before
193
+ // the bucket is asserted: both cases are caught by identity, never by a flag on the transport.
194
+ let opened: NatsClient | undefined;
195
+ let closedEarly = false;
167
196
  const client = await this.#connect({
168
197
  url: this.#options.url,
169
198
  name: 'ultimate',
@@ -173,9 +202,19 @@ export class NatsTransport implements Transport {
173
202
  reconnectDelay: () => policyDelay(this.#backoff, ++this.#retries, this.#rng),
174
203
  onError: (error) => this.#report(error, this.name),
175
204
  onReconnect: () => this.#recovered(),
205
+ onClosed: () => {
206
+ if (opened === undefined) closedEarly = true;
207
+ else this.#lost(opened);
208
+ },
176
209
  });
177
210
  try {
178
211
  await this.#ensureBucket(client);
212
+ if (closedEarly) {
213
+ throw new TransportUnavailableError({
214
+ transport: this.name,
215
+ reason: 'the connection closed for good while the KV bucket was being asserted',
216
+ });
217
+ }
179
218
  // `close()` can land while a dial is in flight, and it only closes what it can see:
180
219
  // publishing now would leave a connection open that nothing will ever close again.
181
220
  if (this.#closed) {
@@ -188,11 +227,76 @@ export class NatsTransport implements Transport {
188
227
  await client.close();
189
228
  throw error;
190
229
  }
230
+ opened = client;
191
231
  this.#client = client;
192
232
  this.#retries = 0;
193
233
  return client;
194
234
  }
195
235
 
236
+ /**
237
+ * The library spent its reconnect budget and closed the client. Handing it out again is a node
238
+ * that refuses every publish until somebody restarts it — and the liveness probe never asks the
239
+ * bus — so it is forgotten here and replaced in the background, subscriptions included.
240
+ */
241
+ #lost(client: NatsClient): void {
242
+ if (this.#closed || this.#client !== client) return;
243
+ this.#client = undefined;
244
+ this.#subscriptions.orphan();
245
+ this.#report(
246
+ new TransportUnavailableError({
247
+ transport: this.name,
248
+ reason: 'the connection closed once its reconnect budget was spent; re-dialling',
249
+ }),
250
+ this.name,
251
+ );
252
+ void this.#redial();
253
+ }
254
+
255
+ /**
256
+ * Re-dial until a client is up or the transport is closed. Bounded by our backoff between
257
+ * attempts (capped at the policy's `maxMs`), and each attempt is itself a library dial with its
258
+ * own budget, so a long outage costs one dial per interval rather than a spin. A dial that lands
259
+ * re-binds every kept subscription and is announced: changes published in the gap are gone.
260
+ */
261
+ async #redial(): Promise<void> {
262
+ if (this.#redialing !== undefined) return;
263
+ try {
264
+ for (let attempt = 1; !this.#closed && this.#client === undefined; attempt += 1) {
265
+ this.#redialing = attempt;
266
+ try {
267
+ const client = await this.#dialOnce();
268
+ // Coded like a first subscribe's refusal; the subscription stays kept for the next client.
269
+ for (const failure of this.#subscriptions.bindAll(client)) {
270
+ this.#report(
271
+ this.#coded(`re-subscribe to ${failure.subject}`, failure.error),
272
+ failure.subject,
273
+ );
274
+ }
275
+ this.#announce();
276
+ } catch (error) {
277
+ if (this.#closed) return;
278
+ this.#report(error, this.name);
279
+ await this.#pause(policyDelay(this.#backoff, attempt, this.#rng));
280
+ }
281
+ }
282
+ } finally {
283
+ this.#redialing = undefined;
284
+ }
285
+ }
286
+
287
+ /** A wait `close()` can cut short, so a closed transport never holds a timer for `maxMs`. */
288
+ #pause(ms: number): Promise<void> {
289
+ return new Promise((resolve) => {
290
+ const done = (): void => {
291
+ clearTimeout(timer);
292
+ this.#wakeRedial = undefined;
293
+ resolve();
294
+ };
295
+ const timer = setTimeout(done, ms);
296
+ this.#wakeRedial = done;
297
+ });
298
+ }
299
+
196
300
  #ensureBucket(client: NatsClient): Promise<void> {
197
301
  return ensureKvBucket(
198
302
  client,
@@ -216,6 +320,10 @@ export class NatsTransport implements Transport {
216
320
  if (client === undefined) return;
217
321
  // Not awaited: the library calls this from its status loop. The failure has one place to go.
218
322
  void this.#ensureBucket(client).catch((error: unknown) => this.#report(error, this.name));
323
+ this.#announce();
324
+ }
325
+
326
+ #announce(): void {
219
327
  for (const listener of [...this.#reconnectListeners]) {
220
328
  try {
221
329
  listener();
@@ -236,14 +344,18 @@ export class NatsTransport implements Transport {
236
344
  try {
237
345
  return call();
238
346
  } catch (error) {
239
- if (isUltimateError(error)) throw error;
240
- throw new TransportUnavailableError({
241
- transport: this.name,
242
- reason: `${what} was refused: ${renderThrowable(error)}`,
243
- });
347
+ throw this.#coded(what, error);
244
348
  }
245
349
  }
246
350
 
351
+ #coded(what: string, error: unknown): unknown {
352
+ if (isUltimateError(error)) return error;
353
+ return new TransportUnavailableError({
354
+ transport: this.name,
355
+ reason: `${what} was refused: ${renderThrowable(error)}`,
356
+ });
357
+ }
358
+
247
359
  /**
248
360
  * Every background failure lands here — a lost connection, a throwing subscriber, an exhausted
249
361
  * reconnect. Dropping it when the caller passed no handler is what turns "no changes arrive"
@@ -69,6 +69,11 @@ export class MemoryQueueStore implements QueueStore {
69
69
  }
70
70
  }
71
71
 
72
+ /** The one way to build the in-process queue store; the class is a type in the barrel only (`X_FACTORY_NAME_SPELLING`). */
73
+ export function memoryQueueStore(): MemoryQueueStore {
74
+ return new MemoryQueueStore();
75
+ }
76
+
72
77
  export interface DrainReport {
73
78
  readonly sent: number;
74
79
  readonly collapsed: number;
@@ -1,9 +1,8 @@
1
1
  // The refusals a BROWSER can reach — the page store, the hooks, the socket's wire check, the local
2
- // store. Apart from `errors.ts` for bytes: that module registers the whole code table at import,
3
- // and an island that renders one record has no use for sixty titles. The codes stay in `errors.ts`
4
- // (its `registerErrorCodes()` is what `package.json`'s `sideEffects` names, anchored by the
5
- // barrel); in a browser that loaded no table a code titles itself from its name, and `code`,
6
- // `cause` and `fix` — what a reader acts on — are unchanged. This module runs nothing at import.
2
+ // store. Apart from `errors.ts` for bytes: that module holds the sync node's refusal classes, which
3
+ // no island constructs. The codes and their titles are `error-titles.ts` (the module
4
+ // `package.json`'s `sideEffects` names, imported bare by the barrel), so a code constructed here is
5
+ // titled wherever the barrel was reached. This module runs nothing at import.
7
6
 
8
7
  import { renderFixShellArg } from '@ultimat3/core/page';
9
8
  import { RealtimeError } from './realtime-error';
@@ -36,8 +35,8 @@ export class RebaseConflictError extends RealtimeError {
36
35
  }
37
36
 
38
37
  /**
39
- * A hook ran IN A BROWSER in an island bundle whose bootstrap never called `installRealtime()`.
40
- * Per BUNDLE, not per page: every island carries its own copy of solid-js, so the signal factory a
38
+ * A hook ran IN A BROWSER in an island bundle whose bootstrap never called `installRealtime()`,
39
+ * or before the page runtime (`page-runtime.ts`, carried by the page boot) was installed. Per BUNDLE, not per page: every island carries its own copy of solid-js, so the signal factory a
41
40
  * hook renders through has to be that island's own — the page-wide store cannot hold one.
42
41
  *
43
42
  * A server render is deliberately not this error: no DOM means no reactive runtime to install,
@@ -47,8 +46,8 @@ export class RealtimeUninstalledError extends RealtimeError {
47
46
  constructor(args: { hook: string }) {
48
47
  super({
49
48
  code: 'X_REALTIME_UNINSTALLED',
50
- cause: `${args.hook}() ran in a browser island whose bundle never called installRealtime()`,
51
- fix: "x build # the island bootstrap installs it; a hand-built island calls installRealtime({ signal: createSignal }) from '@ultimat3/realtime' before its first render",
49
+ cause: `${args.hook}() ran in a browser island whose bundle never called installRealtime(), or on a page whose runtime (@ultimat3/realtime/boot) never loaded`,
50
+ fix: "x build # the island bootstrap installs it; a hand-built island imports '@ultimat3/realtime/boot' (the page runtime) and calls installRealtime({ signal: createSignal }) from '@ultimat3/realtime' before its first render",
52
51
  });
53
52
  }
54
53
  }
@@ -17,7 +17,7 @@ import {
17
17
  import type { LocalStore } from './local-store-idb';
18
18
  import { pageLocalStore, scopeKey } from './local-store-idb';
19
19
  import type { DrainReport, QueuedMutation, QueueState, QueueStore } from './offline-queue';
20
- import { MemoryQueueStore, OfflineQueue, toQueueError } from './offline-queue';
20
+ import { memoryQueueStore, OfflineQueue, toQueueError } from './offline-queue';
21
21
  import { OUTBOX_KEY, type OutboxEntry, type OutboxHandle, type OutboxHost } from './outbox-slot';
22
22
  import { LocalStoreUnavailableError, OfflineQueueAbandonedError } from './page-errors';
23
23
  import { peekPageRealtime } from './page-store';
@@ -74,7 +74,7 @@ interface Held {
74
74
  readonly scope: string | undefined;
75
75
  }
76
76
 
77
- export function createOutbox(options: OutboxOptions): PageOutbox {
77
+ export function localOutbox(options: OutboxOptions): PageOutbox {
78
78
  const principal =
79
79
  options.principal ?? ((): ClientScope['principal'] => pageClient().scope.principal);
80
80
  const send = options.send ?? sendOverHttp;
@@ -103,7 +103,7 @@ export function createOutbox(options: OutboxOptions): PageOutbox {
103
103
  queue = await OfflineQueue.open(queueStore(await options.local, scope));
104
104
  } catch (error) {
105
105
  unavailable('the outbox could not be opened', error);
106
- queue = await OfflineQueue.open(new MemoryQueueStore());
106
+ queue = await OfflineQueue.open(memoryQueueStore());
107
107
  }
108
108
  // The principal left while the disk was being read: this queue is the one that left with it,
109
109
  // and the handler that saw the change has already chained the next open.
@@ -255,7 +255,7 @@ export function createOutbox(options: OutboxOptions): PageOutbox {
255
255
 
256
256
  /** Persisted under the principal; an UNSCOPED page queues in memory only, and loses it on reload. */
257
257
  function queueStore(local: LocalStore, scope: string | undefined): QueueStore {
258
- if (scope === undefined) return new MemoryQueueStore();
258
+ if (scope === undefined) return memoryQueueStore();
259
259
  return {
260
260
  load: async (): Promise<QueueState> =>
261
261
  (await local.queue(scope)) ?? { mutations: [], nextSeq: 1 },
@@ -326,7 +326,7 @@ export function pageOutbox(options: Pick<OutboxOptions, 'beforeEnqueue'> = {}):
326
326
  const existing = host[OUTBOX_KEY];
327
327
  // Only this function writes the slot, so what it holds is always a `PageOutbox`.
328
328
  if (existing !== undefined) return existing as PageOutbox;
329
- const outbox = createOutbox({ local: pageLocalStore(), beforeEnqueue: options.beforeEnqueue });
329
+ const outbox = localOutbox({ local: pageLocalStore(), beforeEnqueue: options.beforeEnqueue });
330
330
  Object.defineProperty(host, OUTBOX_KEY, { value: outbox, configurable: true });
331
331
  if (Reflect.has(globalThis, 'document')) {
332
332
  listenForDrain(outbox);
@@ -0,0 +1,72 @@
1
+ // What a realtime island waits on before its first hook runs: the page runtime, installed ONCE per
2
+ // page. A document the server rendered for a principal carries it in the page boot (a deferred
3
+ // classic script); every other document — a static export, an anonymous page, an app with no sync
4
+ // node — gets it from the runtime chunk the island loads itself. Never both, never twice.
5
+
6
+ import { deferredScriptsPending, peekPageRealtime } from './page-store';
7
+
8
+ export interface AwaitPageRuntimeOptions {
9
+ /**
10
+ * The path every page boot is served under (`/_x/page-boot/`). A `<script>` whose `src` starts
11
+ * with it is the boot this document carries, and the runtime is coming with it.
12
+ */
13
+ readonly boot: string;
14
+ /** Imports the runtime chunk — only on a page whose boot is absent, or never ran. */
15
+ readonly load: () => Promise<unknown>;
16
+ }
17
+
18
+ type Listenable = {
19
+ addEventListener(type: string, listener: () => void, options?: { once?: boolean }): void;
20
+ };
21
+
22
+ const installed = (): boolean => {
23
+ const page = peekPageRealtime();
24
+ return page?.store !== undefined && page.services !== undefined;
25
+ };
26
+
27
+ /**
28
+ * Resolves once the page runtime is installed. An island can be imported while the parser still
29
+ * waits on the deferred boot (the idle callback fires first), so a boot that may still run is
30
+ * waited for — its `load`, its `error`, or DOMContentLoaded, which fires after every deferred
31
+ * script — and a page it never installed the runtime on loads the chunk. Never on a FUTURE event
32
+ * once the deferred scripts are done: a boot that failed before the island asked fires none again.
33
+ */
34
+ export async function awaitPageRuntime(options: AwaitPageRuntimeOptions): Promise<void> {
35
+ if (installed()) return;
36
+ const boot = bootElement(options.boot);
37
+ if (boot !== undefined) await firstOf(boot);
38
+ if (!installed()) await options.load();
39
+ }
40
+
41
+ /** The page boot's `<script>` while it may still run; `undefined` once it cannot, or with none. */
42
+ function bootElement(prefix: string): Listenable | undefined {
43
+ const doc: unknown = Reflect.get(globalThis, 'document');
44
+ if (typeof doc !== 'object' || doc === null) return undefined;
45
+ // Every deferred script has run: a boot that installed nothing failed, and is not coming.
46
+ if (!deferredScriptsPending()) return undefined;
47
+ const query: unknown = Reflect.get(doc, 'querySelector');
48
+ // A partial document (a component test's stand-in) has no query surface: no tag, no boot.
49
+ if (typeof query !== 'function') return undefined;
50
+ const found: unknown = Reflect.apply(query, doc, [`script[src^="${prefix}"]`]);
51
+ return isListenable(found) ? found : undefined;
52
+ }
53
+
54
+ function isListenable(value: unknown): value is Listenable {
55
+ return (
56
+ typeof value === 'object' &&
57
+ value !== null &&
58
+ typeof Reflect.get(value, 'addEventListener') === 'function'
59
+ );
60
+ }
61
+
62
+ /** The boot ran, failed, or every deferred script is done — whichever comes first. */
63
+ function firstOf(boot: Listenable): Promise<void> {
64
+ return new Promise<void>((resolve) => {
65
+ const done = (): void => resolve();
66
+ boot.addEventListener('load', done, { once: true });
67
+ boot.addEventListener('error', done, { once: true });
68
+ const doc: unknown = Reflect.get(globalThis, 'document');
69
+ if (isListenable(doc)) doc.addEventListener('DOMContentLoaded', done, { once: true });
70
+ if (typeof addEventListener === 'function') addEventListener('load', done, { once: true });
71
+ });
72
+ }
@@ -0,0 +1,32 @@
1
+ // The page runtime: the ONE record store, the socket stack, the query client and core's transport
2
+ // behind every hook, installed once per page onto the page object (`page-store.ts`). Never in an
3
+ // island: the page boot carries it where the server renders one, and the CLI builds it as the
4
+ // runtime chunk an island loads where it does not (`packages/cli/src/island-realtime.ts`).
5
+
6
+ import { clientTransport, onRescope, pageClient } from '@ultimat3/core/page';
7
+ import { queryClientMethodFor } from '@ultimat3/query/client';
8
+ import { pageSocket } from './page-socket';
9
+ import { type InstalledPage, pageRealtime } from './page-store';
10
+ import { RecordStore } from './record-store';
11
+
12
+ /**
13
+ * Get-or-install, idempotent: the boot and the runtime chunk may both run on one page (a boot that
14
+ * arrived after its island gave up on it), and whichever runs second adopts what the first built.
15
+ * Installs the store as core's `RecordSink`, so every HTTP answer's records land in it.
16
+ */
17
+ export function installPageRuntime(): InstalledPage {
18
+ const page = pageRealtime();
19
+ const store = page.store;
20
+ if (store !== undefined && page.services !== undefined) return page as InstalledPage;
21
+ const created = store ?? new RecordStore();
22
+ page.store = created;
23
+ page.services = {
24
+ socket: pageSocket,
25
+ read: (name) => queryClientMethodFor(name, { baseUrl: '' }),
26
+ send: clientTransport,
27
+ };
28
+ pageClient().store = created;
29
+ // A new principal sees nothing of the previous one: every record goes, in memory, at once.
30
+ onRescope(() => created.clear());
31
+ return page as InstalledPage;
32
+ }
@@ -1,6 +1,6 @@
1
- // The page's ONE socket: built on the first live or channel hook, by whichever island bundle asks
2
- // first, and shared by every other one through the page state. A form-only island never imports
3
- // this module, so it ships none of the connection lifecycle.
1
+ // The page's ONE socket: built on the first live or channel hook, and shared by every island
2
+ // through the page state. Part of the page runtime (`page-runtime.ts`), so no island bundle carries
3
+ // the connection lifecycle: a hook reaches it as `services.socket`, off the page object.
4
4
 
5
5
  import { onRescope, pageClient } from '@ultimat3/core/page';
6
6
  import { queryClientMethodFor } from '@ultimat3/query/client';
@@ -8,7 +8,7 @@ import { LiveClient } from './client';
8
8
  import { DEFAULT_HEARTBEAT_MS } from './client-heartbeat';
9
9
  import { peekOutbox } from './outbox-slot';
10
10
  import { SyncUnconfiguredError } from './page-errors';
11
- import { pageRealtime } from './page-store';
11
+ import { installedPage } from './page-store';
12
12
  import {
13
13
  openHost,
14
14
  type RehostingHost,
@@ -20,7 +20,7 @@ import { pageSyncTarget, syncWorkerFromMeta } from './sync-meta';
20
20
 
21
21
  /** Get-or-create, and connect on creation. `hook` names the caller in the refusal. */
22
22
  export function pageSocket(hook: string): LiveClient {
23
- const page = pageRealtime();
23
+ const page = installedPage(hook);
24
24
  // Its one writer is below, so the stored value is always this class — from SOME bundle's copy,
25
25
  // which is why it is read structurally and never checked with `instanceof`.
26
26
  if (page.socket !== undefined) return page.socket as LiveClient;
@@ -110,17 +110,8 @@ export function resetPageSocket(
110
110
  hosts = options.openHost ?? openHost;
111
111
  }
112
112
 
113
- /**
114
- * Whether this page holds a socket — `false` on a server render and before any live hook ran.
115
- * The guard a component with a static fallback asks (an offline banner, an update prompt).
116
- */
117
- export function hasPageSocket(): boolean {
118
- const host = globalThis as { [key: symbol]: unknown };
119
- const page = host[Symbol.for('ultimate.realtime')];
120
- return (
121
- typeof page === 'object' && page !== null && (page as { socket?: unknown }).socket !== undefined
122
- );
123
- }
113
+ /** Where `hasPageSocket` lives, re-exported for the callers that reach it from here. */
114
+ export { hasPageSocket } from './page-store';
124
115
 
125
116
  function hostFor(scope: string | null, buildId: string): SocketHost {
126
117
  const workerUrl =