@ultimat3/realtime 23.0.0 → 25.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.
- package/CLAUDE.md +118 -126
- package/README.md +229 -54
- package/package.json +9 -6
- package/src/advisory-lock.ts +62 -0
- package/src/boot.ts +25 -15
- package/src/change-buffer.ts +96 -41
- package/src/changefeed-env.ts +9 -9
- package/src/changefeed.ts +81 -19
- package/src/channel-authz.ts +48 -17
- package/src/channel-describe.ts +1 -1
- package/src/channel-latch.ts +52 -0
- package/src/channel-logs.ts +36 -15
- package/src/channel-presence.ts +3 -3
- package/src/channel-records.ts +35 -4
- package/src/channel-ref.ts +2 -2
- package/src/channel-registry.ts +2 -2
- package/src/channel-sids.ts +37 -0
- package/src/channel.ts +148 -48
- package/src/client-channels.ts +12 -1
- package/src/client-contract.ts +4 -1
- package/src/client-frames.ts +16 -3
- package/src/client-heartbeat.ts +39 -9
- package/src/client.ts +30 -2
- package/src/cursor.ts +11 -0
- package/src/error-titles.ts +91 -0
- package/src/errors.ts +113 -105
- package/src/fanout.ts +13 -0
- package/src/first-paint-hold.ts +49 -0
- package/src/idb-types.ts +2 -2
- package/src/index.ts +25 -7
- package/src/json.ts +16 -3
- package/src/live-contract.ts +14 -0
- package/src/live-definition.ts +34 -9
- package/src/live-deliver.ts +55 -0
- package/src/live-fanout.ts +122 -29
- package/src/live-floor.ts +35 -0
- package/src/live-query-options.ts +44 -0
- package/src/live-query.ts +96 -175
- package/src/live-reauth.ts +127 -0
- package/src/live-replicator.ts +28 -8
- package/src/live-resume.ts +44 -8
- package/src/live-rows.ts +32 -3
- package/src/live-spend.ts +50 -0
- package/src/local-store-idb.ts +5 -0
- package/src/matcher-bridge.ts +11 -5
- package/src/nats-client.ts +37 -6
- package/src/nats-lib-client.ts +36 -7
- package/src/nats-subscriptions.ts +63 -0
- package/src/nats-transport.ts +149 -15
- package/src/offline-queue.ts +53 -5
- package/src/page-errors.ts +25 -9
- package/src/page-outbox.ts +125 -54
- package/src/page-runtime-wait.ts +72 -0
- package/src/page-runtime.ts +32 -0
- package/src/page-socket.ts +7 -16
- package/src/page-store.ts +83 -16
- package/src/pg-advisory-lock.ts +54 -5
- package/src/pg-auth.ts +9 -4
- package/src/pg-bytes.ts +4 -1
- package/src/pg-connection.ts +126 -22
- package/src/pg-entity-row.ts +16 -0
- package/src/pg-identity-tables.ts +25 -0
- package/src/pg-preflight.ts +50 -4
- package/src/pg-replication-row.ts +82 -0
- package/src/pg-replication.ts +50 -51
- package/src/pg-socket.ts +26 -7
- package/src/pg-tls.ts +18 -0
- package/src/pg-values.ts +29 -14
- package/src/pg-wire.ts +5 -3
- package/src/pgoutput.ts +19 -9
- package/src/policy-gate.ts +7 -7
- package/src/presence.ts +87 -10
- package/src/principal-sockets.ts +75 -0
- package/src/query-window.ts +17 -5
- package/src/record-persister.ts +7 -2
- package/src/record-publisher.ts +178 -0
- package/src/record-store.ts +25 -4
- package/src/record-synced.ts +43 -1
- package/src/replication-errors.ts +11 -7
- package/src/replicator-envelope.ts +160 -0
- package/src/replicator-row.ts +75 -0
- package/src/replicator.ts +313 -191
- package/src/server.ts +45 -23
- package/src/socket-defaults.ts +20 -0
- package/src/socket-engine.ts +14 -2
- package/src/socket-idle.ts +3 -1
- package/src/socket-routes.ts +8 -3
- package/src/socket.ts +14 -19
- package/src/subscriber-gate.ts +11 -1
- package/src/subscription-book.ts +129 -71
- package/src/subscription-count.ts +151 -0
- package/src/sync-actor-change.ts +56 -0
- package/src/sync-auth.ts +47 -2
- package/src/sync-bus-handlers.ts +131 -0
- package/src/sync-frames.ts +74 -21
- package/src/sync-node-bounds.ts +16 -1
- package/src/sync-node-contract.ts +36 -4
- package/src/sync-node.ts +163 -82
- package/src/sync-origin.ts +27 -12
- package/src/sync-protocol.ts +86 -16
- package/src/sync-upgrade.ts +115 -8
- package/src/type-pins.ts +9 -1
- package/src/use-channel.ts +19 -14
- package/src/use-connection.ts +3 -3
- package/src/use-mutation.ts +41 -20
- package/src/use-query.ts +11 -11
- package/src/use-record.ts +89 -10
- package/src/wire-channel.ts +18 -9
- package/src/wire-read.ts +2 -2
- package/src/idb-fake.ts +0 -133
- package/src/policy-fake.ts +0 -14
package/src/page-outbox.ts
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The page's ONE outbox: writes a page could not send
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* replay after a lost response is answered from the action's idempotency store and never applied
|
|
6
|
-
* twice. Replayed on open, on `online`, and on the service worker's `OUTBOX_DRAIN_MESSAGE`.
|
|
7
|
-
* A principal change wipes the previous principal's queue: its writes are never sent as the next.
|
|
2
|
+
* The page's ONE outbox: writes a page could not send, persisted under the current principal and
|
|
3
|
+
* replayed IN ORDER over HTTP, each under its own idempotency key. AT LEAST once — an entry leaves
|
|
4
|
+
* the disk when its ack is written. A principal change abandons the pass and wipes the queue.
|
|
8
5
|
*/
|
|
9
6
|
|
|
10
|
-
import type { ClientScope } from '@ultimat3/core/page';
|
|
7
|
+
import type { ClientScope, UltimateError } from '@ultimat3/core/page';
|
|
11
8
|
import {
|
|
12
9
|
actionPath,
|
|
13
10
|
classifyThrown,
|
|
@@ -15,12 +12,14 @@ import {
|
|
|
15
12
|
OUTBOX_DRAIN_MESSAGE,
|
|
16
13
|
onRescope,
|
|
17
14
|
pageClient,
|
|
15
|
+
renderThrowable,
|
|
18
16
|
} from '@ultimat3/core/page';
|
|
19
17
|
import type { LocalStore } from './local-store-idb';
|
|
20
18
|
import { pageLocalStore, scopeKey } from './local-store-idb';
|
|
21
19
|
import type { DrainReport, QueuedMutation, QueueState, QueueStore } from './offline-queue';
|
|
22
|
-
import {
|
|
20
|
+
import { memoryQueueStore, OfflineQueue, toQueueError } from './offline-queue';
|
|
23
21
|
import { OUTBOX_KEY, type OutboxEntry, type OutboxHandle, type OutboxHost } from './outbox-slot';
|
|
22
|
+
import { LocalStoreUnavailableError, OfflineQueueAbandonedError } from './page-errors';
|
|
24
23
|
import { peekPageRealtime } from './page-store';
|
|
25
24
|
import { carriedBy } from './record-store';
|
|
26
25
|
|
|
@@ -63,70 +62,142 @@ export interface OutboxOptions {
|
|
|
63
62
|
* the write queued and the post it liked missing, and showed the old count.
|
|
64
63
|
*/
|
|
65
64
|
readonly beforeEnqueue?: (() => Promise<void>) | undefined;
|
|
65
|
+
/** Where a disk that refused the queue is said, by code. Default `console.warn`. */
|
|
66
|
+
readonly warn?: ((error: UltimateError) => void) | undefined;
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
const EMPTY: DrainReport = { sent: 0, collapsed: 0, remaining: 0, stoppedAt: null };
|
|
69
70
|
|
|
70
|
-
|
|
71
|
+
/** One principal's open queue, and the scope the drain lock is named after. */
|
|
72
|
+
interface Held {
|
|
73
|
+
readonly queue: OfflineQueue;
|
|
74
|
+
readonly scope: string | undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function localOutbox(options: OutboxOptions): PageOutbox {
|
|
71
78
|
const principal =
|
|
72
79
|
options.principal ?? ((): ClientScope['principal'] => pageClient().scope.principal);
|
|
73
80
|
const send = options.send ?? sendOverHttp;
|
|
74
81
|
const overlays =
|
|
75
82
|
options.overlays ?? ((): OutboxOverlays | undefined => peekPageRealtime()?.store);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
83
|
+
/**
|
|
84
|
+
* The open queue AND the scope it belongs to, as one value: a pass captures both when it starts,
|
|
85
|
+
* so nothing it does later can be read off a principal that arrived since.
|
|
86
|
+
*/
|
|
87
|
+
let held: Held | undefined;
|
|
88
|
+
/**
|
|
89
|
+
* Bumped by every principal change, SYNCHRONOUSLY. Anything here that resolves a queue after an
|
|
90
|
+
* await compares it first: the answer to "whose queue" must be the one from before the await.
|
|
91
|
+
*/
|
|
92
|
+
let epoch = 0;
|
|
93
|
+
const warn = options.warn ?? ((error: UltimateError): void => console.warn(error));
|
|
94
|
+
const unavailable = (what: string, error: unknown): void =>
|
|
95
|
+
warn(new LocalStoreUnavailableError({ reason: `${what}: ${renderThrowable(error)}` }));
|
|
79
96
|
|
|
97
|
+
/** Never rejects: a queue the disk would not open is a memory queue, said once by code. */
|
|
80
98
|
const open = async (): Promise<void> => {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
let ready = open();
|
|
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;
|
|
88
|
-
|
|
89
|
-
const deliver = async (mutation: QueuedMutation): Promise<void> => {
|
|
90
|
-
const current = queue;
|
|
91
|
-
const carried = new Set<string>();
|
|
99
|
+
const at = epoch;
|
|
100
|
+
const scope = scopeKey(principal());
|
|
101
|
+
let queue: OfflineQueue;
|
|
92
102
|
try {
|
|
93
|
-
|
|
103
|
+
queue = await OfflineQueue.open(queueStore(await options.local, scope));
|
|
94
104
|
} catch (error) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
105
|
+
unavailable('the outbox could not be opened', error);
|
|
106
|
+
queue = await OfflineQueue.open(memoryQueueStore());
|
|
107
|
+
}
|
|
108
|
+
// The principal left while the disk was being read: this queue is the one that left with it,
|
|
109
|
+
// and the handler that saw the change has already chained the next open.
|
|
110
|
+
if (at !== epoch) {
|
|
111
|
+
queue.abandon();
|
|
102
112
|
return;
|
|
103
113
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
114
|
+
held?.queue.abandon();
|
|
115
|
+
held = { queue, scope };
|
|
116
|
+
};
|
|
117
|
+
let ready = open();
|
|
118
|
+
/** The current principal's queue: `ready` is re-made by a rescope, so it is re-read until still. */
|
|
119
|
+
const current = async (): Promise<Held | undefined> => {
|
|
120
|
+
for (;;) {
|
|
121
|
+
const opening = ready;
|
|
122
|
+
await opening;
|
|
123
|
+
if (opening === ready) return held;
|
|
113
124
|
}
|
|
114
125
|
};
|
|
126
|
+
let running: Promise<DrainReport> | undefined;
|
|
127
|
+
/** A trigger landed while a pass ran: one more pass follows it, never one per trigger. */
|
|
128
|
+
let again = false;
|
|
129
|
+
|
|
130
|
+
const deliver =
|
|
131
|
+
(queue: OfflineQueue) =>
|
|
132
|
+
async (mutation: QueuedMutation): Promise<void> => {
|
|
133
|
+
const carried = new Set<string>();
|
|
134
|
+
try {
|
|
135
|
+
await send({ key: mutation.key, name: mutation.name, input: mutation.input }, carried);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
const kind = classifyThrown(error);
|
|
138
|
+
// The network, or a server asking to be asked again: stays queued, and the pass stops so
|
|
139
|
+
// nothing behind it overtakes it. So does a queue whose principal left mid-send.
|
|
140
|
+
if (kind === 'retryable' || kind === 'retry-after' || queue.abandoned) throw error;
|
|
141
|
+
// Anything else is the server's decision about this write — kept for the UI, never resent.
|
|
142
|
+
await queue.fail(mutation.key, toQueueError(error));
|
|
143
|
+
overlays()?.drop(mutation.key);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
// The principal changed while this was on the wire: its store and its twins are already
|
|
147
|
+
// gone, and what is on the page now is somebody else's.
|
|
148
|
+
if (queue.abandoned) return;
|
|
149
|
+
await queue.ack(mutation.key);
|
|
150
|
+
const store = overlays();
|
|
151
|
+
try {
|
|
152
|
+
// Exactly as a live write settles: a row the answer did not carry keeps its overlay until
|
|
153
|
+
// the server's row for it arrives.
|
|
154
|
+
store?.settle(mutation.key, carried);
|
|
155
|
+
} catch {
|
|
156
|
+
// A custom merge that answered no row: the server's truth stands.
|
|
157
|
+
store?.drop(mutation.key);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
115
160
|
|
|
116
161
|
onRescope((_next, prev) => {
|
|
117
162
|
const gone = scopeKey(prev.principal);
|
|
163
|
+
// SYNCHRONOUSLY, before anything is awaited: a pass parked inside `send` resumes into the next
|
|
164
|
+
// principal's session, and the wipe below reaches the disk but never the pass. Abandoned, it
|
|
165
|
+
// sends nothing more and writes nothing back.
|
|
166
|
+
held?.queue.abandon();
|
|
167
|
+
held = undefined;
|
|
168
|
+
epoch += 1;
|
|
169
|
+
// The single-flight slot is the principal's too: left set, the next principal's trigger JOINED
|
|
170
|
+
// the abandoned pass — handed its report, another principal's key in it — and its own queue
|
|
171
|
+
// was not drained until some later trigger. `settled` only clears a slot that is still its own.
|
|
172
|
+
running = undefined;
|
|
173
|
+
again = false;
|
|
118
174
|
ready = ready.then(async () => {
|
|
119
|
-
|
|
175
|
+
try {
|
|
176
|
+
if (gone !== undefined) await (await options.local).wipe(gone);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
// Still on disk, under the scope that left: unreachable from this principal, and the next
|
|
179
|
+
// boot's `wipeOthers` takes it. Said, never thrown — a rejection here used to poison the
|
|
180
|
+
// chain, so every later enqueue, replay and rescope on this tab failed with it.
|
|
181
|
+
unavailable("the previous principal's outbox could not be wiped", error);
|
|
182
|
+
}
|
|
120
183
|
await open();
|
|
121
184
|
});
|
|
122
185
|
});
|
|
123
186
|
|
|
124
187
|
const self: PageOutbox = {
|
|
125
188
|
enqueue: async (entry) => {
|
|
189
|
+
// WHOSE queue is decided now, before anything is awaited: resolved after the flush below,
|
|
190
|
+
// a write issued under one principal and parked there was queued — and sent — as the next.
|
|
191
|
+
// A queue still opening is waited for, and belongs to this call only if nobody left since.
|
|
192
|
+
const at = epoch;
|
|
193
|
+
const mine = held ?? (await current());
|
|
194
|
+
if (mine === undefined || at !== epoch) {
|
|
195
|
+
throw new OfflineQueueAbandonedError({ name: entry.name });
|
|
196
|
+
}
|
|
126
197
|
// A disk that refused the rows must not also cost the write: the intent still goes on disk.
|
|
127
198
|
await options.beforeEnqueue?.().catch(() => undefined);
|
|
128
|
-
|
|
129
|
-
await queue
|
|
199
|
+
// Abandoned meanwhile, it refuses by the same code.
|
|
200
|
+
await mine.queue.enqueue(entry);
|
|
130
201
|
},
|
|
131
202
|
replay: () => {
|
|
132
203
|
// Single flight: a trigger that lands while a replay is running JOINS it. Open, `online`,
|
|
@@ -140,20 +211,19 @@ export function createOutbox(options: OutboxOptions): PageOutbox {
|
|
|
140
211
|
return running;
|
|
141
212
|
}
|
|
142
213
|
const pass = (async (): Promise<DrainReport> => {
|
|
143
|
-
await
|
|
144
|
-
if (
|
|
214
|
+
const now = await current();
|
|
215
|
+
if (now === undefined) return EMPTY;
|
|
145
216
|
// Checked when the pass STARTS, whoever asked (the socket's reconnect asks too): an
|
|
146
217
|
// attempt the browser already knows cannot leave is a failed request on the wire and
|
|
147
218
|
// nothing more. `online` asks again.
|
|
148
|
-
if (knownOffline()) return { ...EMPTY, remaining: queue.pending().length };
|
|
149
|
-
const draining = queue;
|
|
219
|
+
if (knownOffline()) return { ...EMPTY, remaining: now.queue.pending().length };
|
|
150
220
|
// One tab drains a principal's outbox at a time, and it drains what EVERY tab queued: the
|
|
151
221
|
// queue is re-read under the lock, so a write another tab made since this one opened is
|
|
152
222
|
// sent too, and an `inflight` entry — which only a pass holding this lock could have put
|
|
153
223
|
// on the wire, and it is over — goes back to `pending`.
|
|
154
|
-
return await exclusive(`ultimate-outbox:${scope ?? 'memory'}`, async () => {
|
|
155
|
-
await
|
|
156
|
-
return await
|
|
224
|
+
return await exclusive(`ultimate-outbox:${now.scope ?? 'memory'}`, async () => {
|
|
225
|
+
await now.queue.reload();
|
|
226
|
+
return await now.queue.drain(deliver(now.queue));
|
|
157
227
|
});
|
|
158
228
|
})();
|
|
159
229
|
const settled = (report?: DrainReport): void => {
|
|
@@ -172,9 +242,10 @@ export function createOutbox(options: OutboxOptions): PageOutbox {
|
|
|
172
242
|
return pass;
|
|
173
243
|
},
|
|
174
244
|
get size(): number {
|
|
175
|
-
return queue
|
|
245
|
+
return held?.queue.pending().length ?? 0;
|
|
176
246
|
},
|
|
177
|
-
pending: () =>
|
|
247
|
+
pending: () =>
|
|
248
|
+
(held?.queue.pending() ?? []).map(({ key, name, input }) => ({ key, name, input })),
|
|
178
249
|
get ready(): Promise<void> {
|
|
179
250
|
return ready;
|
|
180
251
|
},
|
|
@@ -184,7 +255,7 @@ export function createOutbox(options: OutboxOptions): PageOutbox {
|
|
|
184
255
|
|
|
185
256
|
/** Persisted under the principal; an UNSCOPED page queues in memory only, and loses it on reload. */
|
|
186
257
|
function queueStore(local: LocalStore, scope: string | undefined): QueueStore {
|
|
187
|
-
if (scope === undefined) return
|
|
258
|
+
if (scope === undefined) return memoryQueueStore();
|
|
188
259
|
return {
|
|
189
260
|
load: async (): Promise<QueueState> =>
|
|
190
261
|
(await local.queue(scope)) ?? { mutations: [], nextSeq: 1 },
|
|
@@ -255,7 +326,7 @@ export function pageOutbox(options: Pick<OutboxOptions, 'beforeEnqueue'> = {}):
|
|
|
255
326
|
const existing = host[OUTBOX_KEY];
|
|
256
327
|
// Only this function writes the slot, so what it holds is always a `PageOutbox`.
|
|
257
328
|
if (existing !== undefined) return existing as PageOutbox;
|
|
258
|
-
const outbox =
|
|
329
|
+
const outbox = localOutbox({ local: pageLocalStore(), beforeEnqueue: options.beforeEnqueue });
|
|
259
330
|
Object.defineProperty(host, OUTBOX_KEY, { value: outbox, configurable: true });
|
|
260
331
|
if (Reflect.has(globalThis, 'document')) {
|
|
261
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
|
+
}
|
package/src/page-socket.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// The page's ONE socket: built on the first live or channel hook, by
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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 =
|
package/src/page-store.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
// The page's realtime state
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The page's realtime state as every island READS it: one object per tab on `globalThis` under a
|
|
2
|
+
// `Symbol.for` key, because every island is its own bundle and a module-scope singleton here is
|
|
3
|
+
// one store PER ISLAND. Thin by design — the store, the socket and the transport behind it are the
|
|
4
|
+
// page runtime's (`page-runtime.ts`), shipped once per page and never inside an island (#505).
|
|
4
5
|
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
6
|
+
import type { clientTransport } from '@ultimat3/core/page';
|
|
7
|
+
import { CLIENT_SCOPE_META } from '@ultimat3/core/page';
|
|
8
|
+
import type { queryClientMethodFor } from '@ultimat3/query/client';
|
|
9
|
+
import type { LiveClient } from './client';
|
|
10
|
+
import { RealtimeUninstalledError } from './page-errors';
|
|
11
|
+
import type { RecordStore } from './record-store';
|
|
7
12
|
|
|
8
13
|
/** Where the page's one socket dials. Resolved on the server and handed to the island bootstrap. */
|
|
9
14
|
export interface SyncTarget {
|
|
@@ -15,7 +20,12 @@ export interface SyncTarget {
|
|
|
15
20
|
|
|
16
21
|
/** Page-wide and shared by every bundle. Structural on purpose: no `instanceof` across copies. */
|
|
17
22
|
export interface PageRealtime {
|
|
18
|
-
|
|
23
|
+
/**
|
|
24
|
+
* The page's ONE record store — `undefined` until the page runtime is installed (the page boot,
|
|
25
|
+
* or the runtime chunk an island loads where no boot is rendered). A hook reads it through
|
|
26
|
+
* `installedPage`, which refuses by code rather than answering a store nobody shares.
|
|
27
|
+
*/
|
|
28
|
+
store: RecordStore | undefined;
|
|
19
29
|
sync: SyncTarget | undefined;
|
|
20
30
|
/** The socket client, once a live hook opened it. Typed by `page-socket.ts`, its one writer. */
|
|
21
31
|
socket: unknown;
|
|
@@ -27,8 +37,30 @@ export interface PageRealtime {
|
|
|
27
37
|
* the first frame can race it. Resolved at once on a page that carries no boot script.
|
|
28
38
|
*/
|
|
29
39
|
readonly booted: Promise<void>;
|
|
40
|
+
/** What a hook calls instead of bundling it — installed with the store, by the page runtime. */
|
|
41
|
+
services: PageServices | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The runtime's half of every hook: the work whose code would otherwise ship in each island. An
|
|
46
|
+
* island calls these off the page object, so the socket stack, the query client and core's
|
|
47
|
+
* transport are in the page runtime once, however many islands the page renders.
|
|
48
|
+
*/
|
|
49
|
+
export interface PageServices {
|
|
50
|
+
/** The page's one socket, opened on the first ask. `hook` names the caller in a refusal. */
|
|
51
|
+
socket(hook: string): LiveClient;
|
|
52
|
+
/** A non-live query read over HTTP — `@ultimat3/query`'s client, through core's transport. */
|
|
53
|
+
read(name: string): ReturnType<typeof queryClientMethodFor>;
|
|
54
|
+
/** Core's one transport, for a write's POST. */
|
|
55
|
+
send: typeof clientTransport;
|
|
30
56
|
}
|
|
31
57
|
|
|
58
|
+
/** The page once its runtime is installed: what every hook works against. */
|
|
59
|
+
export type InstalledPage = PageRealtime & {
|
|
60
|
+
readonly store: RecordStore;
|
|
61
|
+
readonly services: PageServices;
|
|
62
|
+
};
|
|
63
|
+
|
|
32
64
|
/** Every optimistic write on the page, counted per mutator name, and who renders the counts. */
|
|
33
65
|
export interface PageWrites {
|
|
34
66
|
readonly pending: Map<string, number>;
|
|
@@ -86,9 +118,32 @@ export function bootedPromise(): Promise<void> {
|
|
|
86
118
|
return waiting;
|
|
87
119
|
}
|
|
88
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Whether a deferred `<script>` of this document may still run. Not past `loading`, yes; past
|
|
123
|
+
* `complete`, no. In between (`interactive`) the deferred scripts run, then DOMContentLoaded fires —
|
|
124
|
+
* and `readyState` stays `interactive` until every image has loaded, so the navigation timing's
|
|
125
|
+
* `domContentLoadedEventStart` is what says they have all run (a boot that 404'd among them). A
|
|
126
|
+
* document with no such reading is assumed still running them: a caller then waits on DOMContentLoaded.
|
|
127
|
+
*/
|
|
128
|
+
export function deferredScriptsPending(): boolean {
|
|
129
|
+
if (typeof document === 'undefined') return false;
|
|
130
|
+
const state: unknown = document.readyState;
|
|
131
|
+
if (state === 'complete') return false;
|
|
132
|
+
if (state !== 'interactive') return true;
|
|
133
|
+
const timing: unknown =
|
|
134
|
+
typeof performance === 'undefined' || typeof performance.getEntriesByType !== 'function'
|
|
135
|
+
? undefined
|
|
136
|
+
: performance.getEntriesByType('navigation')[0];
|
|
137
|
+
const started: unknown =
|
|
138
|
+
typeof timing === 'object' && timing !== null
|
|
139
|
+
? Reflect.get(timing, 'domContentLoadedEventStart')
|
|
140
|
+
: undefined;
|
|
141
|
+
return !(typeof started === 'number' && started > 0);
|
|
142
|
+
}
|
|
143
|
+
|
|
89
144
|
function bootComing(): boolean {
|
|
90
145
|
if (typeof document === 'undefined' || typeof addEventListener !== 'function') return false;
|
|
91
|
-
if (
|
|
146
|
+
if (!deferredScriptsPending()) return false;
|
|
92
147
|
// A partial `document` (a component test's stand-in) has no query surface: no tag, no boot.
|
|
93
148
|
if (typeof document.querySelector !== 'function') return false;
|
|
94
149
|
return document.querySelector(`meta[name="${CLIENT_SCOPE_META}"]`) !== null;
|
|
@@ -96,25 +151,25 @@ function bootComing(): boolean {
|
|
|
96
151
|
|
|
97
152
|
type Host = { [KEY]?: PageRealtime };
|
|
98
153
|
|
|
99
|
-
/**
|
|
154
|
+
/**
|
|
155
|
+
* Get-or-create the page object — never its runtime: an island that asks before the runtime is
|
|
156
|
+
* installed (`installRealtime` seating a sync target) gets the object the runtime then fills.
|
|
157
|
+
*/
|
|
100
158
|
export function pageRealtime(): PageRealtime {
|
|
101
159
|
const host = globalThis as Host;
|
|
102
160
|
const existing = host[KEY];
|
|
103
161
|
if (existing !== undefined) return existing;
|
|
104
|
-
const store = new RecordStore();
|
|
105
162
|
const created: PageRealtime = {
|
|
106
|
-
store,
|
|
163
|
+
store: undefined,
|
|
107
164
|
sync: undefined,
|
|
108
165
|
socket: undefined,
|
|
109
166
|
writes: { pending: new Map(), failed: 0, listeners: new Set() },
|
|
110
167
|
get booted(): Promise<void> {
|
|
111
168
|
return bootedPromise();
|
|
112
169
|
},
|
|
170
|
+
services: undefined,
|
|
113
171
|
};
|
|
114
172
|
Object.defineProperty(host, KEY, { value: created, configurable: true });
|
|
115
|
-
pageClient().store = store;
|
|
116
|
-
// A new principal sees nothing of the previous one: every record goes, in memory, at once.
|
|
117
|
-
onRescope(() => store.clear());
|
|
118
173
|
return created;
|
|
119
174
|
}
|
|
120
175
|
|
|
@@ -123,6 +178,18 @@ export function peekPageRealtime(): PageRealtime | undefined {
|
|
|
123
178
|
return (globalThis as Host)[KEY];
|
|
124
179
|
}
|
|
125
180
|
|
|
181
|
+
/**
|
|
182
|
+
* The page with its runtime installed, or `X_REALTIME_UNINSTALLED` naming `hook`. An island `x
|
|
183
|
+
* build` wrapped never sees the refusal — its bootstrap awaits the runtime before any hook runs.
|
|
184
|
+
*/
|
|
185
|
+
export function installedPage(hook: string): InstalledPage {
|
|
186
|
+
const page = pageRealtime();
|
|
187
|
+
if (page.store === undefined || page.services === undefined) {
|
|
188
|
+
throw new RealtimeUninstalledError({ hook });
|
|
189
|
+
}
|
|
190
|
+
return page as InstalledPage;
|
|
191
|
+
}
|
|
192
|
+
|
|
126
193
|
/**
|
|
127
194
|
* Whether this page holds a socket — `false` on a server render and before any live hook ran.
|
|
128
195
|
* The guard a component with a static fallback asks (an offline banner, an update prompt). Here,
|
|
@@ -132,7 +199,7 @@ export function hasPageSocket(): boolean {
|
|
|
132
199
|
return peekPageRealtime()?.socket !== undefined;
|
|
133
200
|
}
|
|
134
201
|
|
|
135
|
-
/** The page's record store. */
|
|
136
|
-
export function pageStore(): RecordStore {
|
|
137
|
-
return
|
|
202
|
+
/** The page's record store, from its installed runtime. */
|
|
203
|
+
export function pageStore(hook = 'pageStore'): RecordStore {
|
|
204
|
+
return installedPage(hook).store;
|
|
138
205
|
}
|