@ultimat3/realtime 20.2.1 → 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.
- package/CLAUDE.md +300 -952
- package/README.md +192 -131
- package/package.json +7 -4
- package/src/apply-patches.ts +1 -1
- package/src/boot.ts +72 -0
- package/src/browser-socket.ts +42 -0
- package/src/changefeed.ts +14 -1
- package/src/channel-authz.ts +52 -0
- package/src/channel-bridge.ts +34 -0
- package/src/channel-decl.ts +155 -0
- package/src/channel-describe.ts +35 -0
- package/src/channel-gaps.ts +57 -0
- package/src/channel-logs.ts +134 -0
- package/src/channel-presence.ts +68 -0
- package/src/channel-records.ts +87 -0
- package/src/channel-ref.ts +83 -0
- package/src/channel-registry.ts +35 -0
- package/src/channel-render.ts +37 -0
- package/src/channel-ring.ts +75 -0
- package/src/channel-wire.ts +66 -0
- package/src/channel.ts +147 -157
- package/src/client-channels.ts +359 -0
- package/src/client-contract.ts +35 -65
- package/src/client-frames.ts +42 -110
- package/src/client.ts +150 -195
- package/src/cursor.ts +7 -2
- package/src/errors.ts +55 -101
- package/src/frame-lanes.ts +9 -5
- package/src/idb-fake.ts +133 -0
- package/src/idb-types.ts +48 -0
- package/src/index.ts +80 -75
- package/src/json.ts +5 -0
- package/src/live-contract.ts +5 -0
- package/src/live-definition.ts +15 -4
- package/src/live-fanout.ts +81 -6
- package/src/live-query.ts +11 -0
- package/src/live-record-type.ts +19 -0
- package/src/live-replicator.ts +160 -0
- package/src/live-rows.ts +70 -67
- package/src/local-store-idb.ts +324 -0
- package/src/matcher-bridge.ts +5 -0
- package/src/nats-fake.ts +10 -1
- package/src/nats-jetstream.ts +36 -14
- package/src/nats-transport.ts +2 -2
- package/src/offline-queue.ts +85 -39
- package/src/outbox-slot.ts +31 -0
- package/src/page-errors.ts +124 -0
- package/src/page-outbox.ts +312 -0
- package/src/page-socket.ts +139 -0
- package/src/page-store.ts +138 -0
- package/src/pg-entity-row.ts +37 -184
- package/src/pg-preflight.ts +24 -2
- package/src/pg-replication.ts +28 -8
- package/src/pg-wire.ts +51 -15
- package/src/pgoutput.ts +37 -2
- package/src/policy-fake.ts +14 -0
- package/src/presence.ts +17 -9
- package/src/query-window.ts +38 -21
- package/src/reactivity.ts +70 -0
- package/src/realtime-error.ts +1 -1
- package/src/record-await.ts +102 -0
- package/src/record-key.ts +34 -0
- package/src/record-names.ts +45 -0
- package/src/record-persister.ts +156 -0
- package/src/record-store.ts +364 -0
- package/src/record-synced.ts +100 -0
- package/src/record-tx.ts +145 -0
- package/src/replicator.ts +20 -4
- package/src/server.ts +10 -11
- package/src/socket-drops.ts +30 -0
- package/src/socket-engine.ts +344 -0
- package/src/socket-host.ts +225 -0
- package/src/socket-idle.ts +21 -0
- package/src/socket-port.ts +55 -0
- package/src/socket-routes.ts +170 -0
- package/src/socket.ts +91 -49
- package/src/subscriber-gate.ts +92 -3
- package/src/sync-auth.ts +2 -2
- package/src/sync-frames.ts +41 -114
- package/src/sync-meta.ts +42 -0
- package/src/sync-node-contract.ts +100 -0
- package/src/sync-node.ts +26 -114
- package/src/sync-protocol.ts +63 -212
- package/src/sync-worker.ts +12 -0
- package/src/thundering-herd.ts +31 -12
- package/src/transport-env.ts +55 -14
- package/src/type-pins.ts +30 -61
- package/src/use-channel.ts +88 -0
- package/src/use-connection.ts +59 -0
- package/src/use-mutation.ts +227 -0
- package/src/use-query.ts +260 -0
- package/src/use-record.ts +121 -0
- package/src/wire-channel.ts +116 -0
- package/src/wire-read.ts +86 -0
- package/src/wire-version.ts +44 -0
- package/src/client-mutations.ts +0 -114
- package/src/client-topics.ts +0 -54
- package/src/hooks.ts +0 -277
- package/src/identity-map.ts +0 -141
- package/src/local-store.ts +0 -241
- package/src/query-hook.ts +0 -56
- package/src/rebase.ts +0 -263
- package/src/server-render-client.ts +0 -96
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page's ONE outbox: writes a page could not send are queued in `OfflineQueue`, persisted in
|
|
3
|
+
* the page's durable store under the current principal, and replayed IN ORDER over HTTP — each to
|
|
4
|
+
* `actionPath(name)` through core's `clientTransport`, each with its own idempotency key, so a
|
|
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.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ClientScope } from '@ultimat3/core/page';
|
|
11
|
+
import {
|
|
12
|
+
actionPath,
|
|
13
|
+
classifyThrown,
|
|
14
|
+
clientTransport,
|
|
15
|
+
OUTBOX_DRAIN_MESSAGE,
|
|
16
|
+
onRescope,
|
|
17
|
+
pageClient,
|
|
18
|
+
} from '@ultimat3/core/page';
|
|
19
|
+
import type { LocalStore } from './local-store-idb';
|
|
20
|
+
import { pageLocalStore, scopeKey } from './local-store-idb';
|
|
21
|
+
import type { DrainReport, QueuedMutation, QueueState, QueueStore } from './offline-queue';
|
|
22
|
+
import { MemoryQueueStore, OfflineQueue, toQueueError } from './offline-queue';
|
|
23
|
+
import { OUTBOX_KEY, type OutboxEntry, type OutboxHandle, type OutboxHost } from './outbox-slot';
|
|
24
|
+
import { peekPageRealtime } from './page-store';
|
|
25
|
+
import { carriedBy } from './record-store';
|
|
26
|
+
|
|
27
|
+
export type { OutboxEntry } from './outbox-slot';
|
|
28
|
+
|
|
29
|
+
export interface PageOutbox extends OutboxHandle {
|
|
30
|
+
enqueue(entry: OutboxEntry): Promise<void>;
|
|
31
|
+
/** Sends everything queued, in order, stopping at the first write the network could not take. */
|
|
32
|
+
replay(): Promise<DrainReport>;
|
|
33
|
+
/** Writes not yet taken by the server. `0` until the store has opened. */
|
|
34
|
+
readonly size: number;
|
|
35
|
+
/**
|
|
36
|
+
* Those writes, in queue order — what `useMutation` re-applies as overlays after a reload, so a
|
|
37
|
+
* queued write is ON SCREEN until its replay settles or refuses it. Empty until `ready`.
|
|
38
|
+
*/
|
|
39
|
+
pending(): readonly OutboxEntry[];
|
|
40
|
+
/** Settles once the current principal's queue is open. */
|
|
41
|
+
readonly ready: Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The optimistic twins a replay settles or takes back — the page's record store. */
|
|
45
|
+
export interface OutboxOverlays {
|
|
46
|
+
settle(key: string, carried?: ReadonlySet<string>): void;
|
|
47
|
+
drop(key: string): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface OutboxOptions {
|
|
51
|
+
readonly local: LocalStore | Promise<LocalStore>;
|
|
52
|
+
readonly principal?: (() => ClientScope['principal']) | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* One write, over HTTP. Default: `clientTransport` POST to `actionPath(entry.name)`, adding every
|
|
55
|
+
* record the answer carried (`type:key`) to `carried` — what its overlay settles against.
|
|
56
|
+
*/
|
|
57
|
+
readonly send?: ((entry: OutboxEntry, carried: Set<string>) => Promise<unknown>) | undefined;
|
|
58
|
+
readonly overlays?: (() => OutboxOverlays | undefined) | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* Runs, and is awaited, before a write is queued. The boot hands it the record persister's
|
|
61
|
+
* `flush`: a queued write is replayed over the rows it touched, so those rows reach the disk no
|
|
62
|
+
* later than the write does — measured, a reload inside the persister's debounce came back with
|
|
63
|
+
* the write queued and the post it liked missing, and showed the old count.
|
|
64
|
+
*/
|
|
65
|
+
readonly beforeEnqueue?: (() => Promise<void>) | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const EMPTY: DrainReport = { sent: 0, collapsed: 0, remaining: 0, stoppedAt: null };
|
|
69
|
+
|
|
70
|
+
export function createOutbox(options: OutboxOptions): PageOutbox {
|
|
71
|
+
const principal =
|
|
72
|
+
options.principal ?? ((): ClientScope['principal'] => pageClient().scope.principal);
|
|
73
|
+
const send = options.send ?? sendOverHttp;
|
|
74
|
+
const overlays =
|
|
75
|
+
options.overlays ?? ((): OutboxOverlays | undefined => peekPageRealtime()?.store);
|
|
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;
|
|
79
|
+
|
|
80
|
+
const open = async (): Promise<void> => {
|
|
81
|
+
scope = scopeKey(principal());
|
|
82
|
+
queue = await OfflineQueue.open(queueStore(await options.local, scope));
|
|
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>();
|
|
92
|
+
try {
|
|
93
|
+
await send({ key: mutation.key, name: mutation.name, input: mutation.input }, carried);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
const kind = classifyThrown(error);
|
|
96
|
+
// The network, or a server asking to be asked again: stays queued, and the pass stops so
|
|
97
|
+
// nothing behind it overtakes it.
|
|
98
|
+
if (kind === 'retryable' || kind === 'retry-after') throw error;
|
|
99
|
+
// Anything else is the server's decision about this write — kept for the UI, never resent.
|
|
100
|
+
await current?.fail(mutation.key, toQueueError(error));
|
|
101
|
+
overlays()?.drop(mutation.key);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
await current?.ack(mutation.key);
|
|
105
|
+
const store = overlays();
|
|
106
|
+
try {
|
|
107
|
+
// Exactly as a live write settles: a row the answer did not carry keeps its overlay until
|
|
108
|
+
// the server's row for it arrives.
|
|
109
|
+
store?.settle(mutation.key, carried);
|
|
110
|
+
} catch {
|
|
111
|
+
// A custom merge that answered no row: the server's truth stands.
|
|
112
|
+
store?.drop(mutation.key);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
onRescope((_next, prev) => {
|
|
117
|
+
const gone = scopeKey(prev.principal);
|
|
118
|
+
ready = ready.then(async () => {
|
|
119
|
+
if (gone !== undefined) await (await options.local).wipe(gone);
|
|
120
|
+
await open();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const self: PageOutbox = {
|
|
125
|
+
enqueue: async (entry) => {
|
|
126
|
+
// A disk that refused the rows must not also cost the write: the intent still goes on disk.
|
|
127
|
+
await options.beforeEnqueue?.().catch(() => undefined);
|
|
128
|
+
await ready;
|
|
129
|
+
await queue?.enqueue(entry);
|
|
130
|
+
},
|
|
131
|
+
replay: () => {
|
|
132
|
+
// Single flight: a trigger that lands while a replay is running JOINS it. Open, `online`,
|
|
133
|
+
// the socket's reconnect and the service worker's drain arrive together, and each chaining
|
|
134
|
+
// a pass of its own sent the head of the queue once per trigger whenever a send failed —
|
|
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
|
+
}
|
|
142
|
+
const pass = (async (): Promise<DrainReport> => {
|
|
143
|
+
await ready;
|
|
144
|
+
if (queue === undefined) return EMPTY;
|
|
145
|
+
// Checked when the pass STARTS, whoever asked (the socket's reconnect asks too): an
|
|
146
|
+
// attempt the browser already knows cannot leave is a failed request on the wire and
|
|
147
|
+
// nothing more. `online` asks again.
|
|
148
|
+
if (knownOffline()) return { ...EMPTY, remaining: queue.pending().length };
|
|
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
|
+
});
|
|
158
|
+
})();
|
|
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;
|
|
169
|
+
};
|
|
170
|
+
running = pass;
|
|
171
|
+
pass.then(settled, () => settled());
|
|
172
|
+
return pass;
|
|
173
|
+
},
|
|
174
|
+
get size(): number {
|
|
175
|
+
return queue?.pending().length ?? 0;
|
|
176
|
+
},
|
|
177
|
+
pending: () => (queue?.pending() ?? []).map(({ key, name, input }) => ({ key, name, input })),
|
|
178
|
+
get ready(): Promise<void> {
|
|
179
|
+
return ready;
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
return self;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Persisted under the principal; an UNSCOPED page queues in memory only, and loses it on reload. */
|
|
186
|
+
function queueStore(local: LocalStore, scope: string | undefined): QueueStore {
|
|
187
|
+
if (scope === undefined) return new MemoryQueueStore();
|
|
188
|
+
return {
|
|
189
|
+
load: async (): Promise<QueueState> =>
|
|
190
|
+
(await local.queue(scope)) ?? { mutations: [], nextSeq: 1 },
|
|
191
|
+
write: (change) => local.writeQueue(scope, change),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
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>> };
|
|
239
|
+
function sendOverHttp(entry: OutboxEntry, carried: Set<string>): Promise<unknown> {
|
|
240
|
+
return clientTransport({
|
|
241
|
+
method: 'POST',
|
|
242
|
+
url: actionPath(entry.name),
|
|
243
|
+
body: entry.input,
|
|
244
|
+
idempotencyKey: entry.key,
|
|
245
|
+
onEnvelope: (envelope) => carriedBy(envelope, carried),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The page's outbox, created once per tab. In a browser it replays on open, on `online`, and on the
|
|
251
|
+
* service worker's drain message; with no `document` it is a memory queue that listens for nothing.
|
|
252
|
+
*/
|
|
253
|
+
export function pageOutbox(options: Pick<OutboxOptions, 'beforeEnqueue'> = {}): PageOutbox {
|
|
254
|
+
const host = globalThis as OutboxHost;
|
|
255
|
+
const existing = host[OUTBOX_KEY];
|
|
256
|
+
// Only this function writes the slot, so what it holds is always a `PageOutbox`.
|
|
257
|
+
if (existing !== undefined) return existing as PageOutbox;
|
|
258
|
+
const outbox = createOutbox({ local: pageLocalStore(), beforeEnqueue: options.beforeEnqueue });
|
|
259
|
+
Object.defineProperty(host, OUTBOX_KEY, { value: outbox, configurable: true });
|
|
260
|
+
if (Reflect.has(globalThis, 'document')) {
|
|
261
|
+
listenForDrain(outbox);
|
|
262
|
+
if (!knownOffline()) void outbox.replay().catch(() => undefined);
|
|
263
|
+
}
|
|
264
|
+
return outbox;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The two drain signals a page can receive: the service worker's `sync` (posted to every open tab
|
|
269
|
+
* as `OUTBOX_DRAIN_MESSAGE`), and the browser coming back `online`. A replay that fails leaves the
|
|
270
|
+
* queue as it was — the next signal tries again — so its rejection is not the listener's to raise.
|
|
271
|
+
*/
|
|
272
|
+
export function listenForDrain(outbox: Pick<PageOutbox, 'replay'>): () => void {
|
|
273
|
+
const replay = (): void => {
|
|
274
|
+
if (!knownOffline()) void outbox.replay().catch(() => undefined);
|
|
275
|
+
};
|
|
276
|
+
const worker: EventTarget | undefined = Reflect.get(
|
|
277
|
+
Reflect.get(globalThis, 'navigator') ?? {},
|
|
278
|
+
'serviceWorker',
|
|
279
|
+
);
|
|
280
|
+
const onMessage = (event: Event): void => {
|
|
281
|
+
const data: unknown = Reflect.get(event, 'data');
|
|
282
|
+
if (
|
|
283
|
+
typeof data === 'object' &&
|
|
284
|
+
data !== null &&
|
|
285
|
+
Reflect.get(data, 'type') === OUTBOX_DRAIN_MESSAGE
|
|
286
|
+
) {
|
|
287
|
+
replay();
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
worker?.addEventListener('message', onMessage);
|
|
291
|
+
globalThis.addEventListener('online', replay);
|
|
292
|
+
return () => {
|
|
293
|
+
worker?.removeEventListener('message', onMessage);
|
|
294
|
+
globalThis.removeEventListener('online', replay);
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The browser says it has no network. A replay then is an attempt that can only fail — and a
|
|
300
|
+
* failed attempt is still a request on the wire: measured, a reload taken offline replayed on open,
|
|
301
|
+
* that POST failed, and the real one followed on `online`, so one write went out twice. The
|
|
302
|
+
* `online` event is the replay's own trigger, so nothing is lost by waiting for it. Unknown (no
|
|
303
|
+
* `navigator`) is not offline.
|
|
304
|
+
*/
|
|
305
|
+
function knownOffline(): boolean {
|
|
306
|
+
const navigator: unknown = Reflect.get(globalThis, 'navigator');
|
|
307
|
+
return (
|
|
308
|
+
typeof navigator === 'object' &&
|
|
309
|
+
navigator !== null &&
|
|
310
|
+
Reflect.get(navigator, 'onLine') === false
|
|
311
|
+
);
|
|
312
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
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.
|
|
4
|
+
|
|
5
|
+
import { onRescope, pageClient } from '@ultimat3/core/page';
|
|
6
|
+
import { queryClientMethodFor } from '@ultimat3/query/client';
|
|
7
|
+
import { LiveClient } from './client';
|
|
8
|
+
import { DEFAULT_HEARTBEAT_MS } from './client-heartbeat';
|
|
9
|
+
import { peekOutbox } from './outbox-slot';
|
|
10
|
+
import { SyncUnconfiguredError } from './page-errors';
|
|
11
|
+
import { pageRealtime } from './page-store';
|
|
12
|
+
import {
|
|
13
|
+
openHost,
|
|
14
|
+
type RehostingHost,
|
|
15
|
+
rehosting,
|
|
16
|
+
type SocketHost,
|
|
17
|
+
type SocketHostOptions,
|
|
18
|
+
} from './socket-host';
|
|
19
|
+
import { pageSyncTarget, syncWorkerFromMeta } from './sync-meta';
|
|
20
|
+
|
|
21
|
+
/** Get-or-create, and connect on creation. `hook` names the caller in the refusal. */
|
|
22
|
+
export function pageSocket(hook: string): LiveClient {
|
|
23
|
+
const page = pageRealtime();
|
|
24
|
+
// Its one writer is below, so the stored value is always this class — from SOME bundle's copy,
|
|
25
|
+
// which is why it is read structurally and never checked with `instanceof`.
|
|
26
|
+
if (page.socket !== undefined) return page.socket as LiveClient;
|
|
27
|
+
// What the bootstrap passed, else what the document shell rendered into `<head>`.
|
|
28
|
+
const target = page.sync ?? pageSyncTarget();
|
|
29
|
+
if (target === undefined) throw new SyncUnconfiguredError({ hook });
|
|
30
|
+
let host = rehostingFor(pageClient().scope.principal ?? null, target.buildId);
|
|
31
|
+
const client = new LiveClient({
|
|
32
|
+
connect: () => host.socket(target),
|
|
33
|
+
buildId: target.buildId,
|
|
34
|
+
actorId: pageClient().scope.principal ?? null,
|
|
35
|
+
store: page.store,
|
|
36
|
+
// The channel's catch-up read is an ordinary query read: core's transport adopts its records.
|
|
37
|
+
catchUp: (query, params) => queryClientMethodFor(query, { baseUrl: '' })(params),
|
|
38
|
+
});
|
|
39
|
+
page.socket = client;
|
|
40
|
+
pageClient().socket = client;
|
|
41
|
+
// Every time the socket comes (back) up, the writes the network refused go out, in order, over
|
|
42
|
+
// HTTP — the outbox's own idempotency keys make a replay after a lost answer harmless.
|
|
43
|
+
let up = false;
|
|
44
|
+
client.onStatus(() => {
|
|
45
|
+
if (client.connected && !up)
|
|
46
|
+
void peekOutbox()
|
|
47
|
+
?.replay()
|
|
48
|
+
.catch(() => undefined);
|
|
49
|
+
up = client.connected;
|
|
50
|
+
});
|
|
51
|
+
// After the disk restore, so a restored record is shown before the first frame can race it.
|
|
52
|
+
void page.booted.then(() => client.connect());
|
|
53
|
+
// A new principal gets its own worker — never the previous principal's socket — and redials.
|
|
54
|
+
const offRescope = onRescope((next) => {
|
|
55
|
+
host.bye();
|
|
56
|
+
host = rehostingFor(next.principal ?? null, target.buildId);
|
|
57
|
+
client.connect();
|
|
58
|
+
});
|
|
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
|
+
};
|
|
70
|
+
const listens = typeof addEventListener === 'function';
|
|
71
|
+
if (listens) {
|
|
72
|
+
addEventListener('pagehide', bye);
|
|
73
|
+
addEventListener('pageshow', restored);
|
|
74
|
+
}
|
|
75
|
+
teardowns.add(() => {
|
|
76
|
+
offRescope();
|
|
77
|
+
if (listens) {
|
|
78
|
+
removeEventListener('pagehide', bye);
|
|
79
|
+
removeEventListener('pageshow', restored);
|
|
80
|
+
}
|
|
81
|
+
client.close();
|
|
82
|
+
host.bye();
|
|
83
|
+
if (page.socket === client) page.socket = undefined;
|
|
84
|
+
if (pageClient().socket === client) pageClient().socket = undefined;
|
|
85
|
+
});
|
|
86
|
+
return client;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Every socket this module built, as the undo of building it. A page builds one and never tears it
|
|
91
|
+
* down — the tab's end is its teardown — so only a test process, which builds one per case, calls
|
|
92
|
+
* `resetPageSocket`. Not on the barrel: an app has no page to reset.
|
|
93
|
+
*/
|
|
94
|
+
const teardowns = new Set<() => void>();
|
|
95
|
+
|
|
96
|
+
/** Where a page socket gets its host. `openHost` in production; a test hands in its own. */
|
|
97
|
+
let hosts: (options: SocketHostOptions) => SocketHost = openHost;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* For tests: unsubscribe, close and unseat every page socket built so far, so a case starts clean
|
|
101
|
+
* — and, optionally, build the next ones over `openHost` of the case's own. A case that counts
|
|
102
|
+
* dials through the real engine counts every engine alive in the process; one that counts HOSTS
|
|
103
|
+
* counts only what this module asked for, which is the one thing it owns.
|
|
104
|
+
*/
|
|
105
|
+
export function resetPageSocket(
|
|
106
|
+
options: { readonly openHost?: (options: SocketHostOptions) => SocketHost } = {},
|
|
107
|
+
): void {
|
|
108
|
+
for (const teardown of teardowns) teardown();
|
|
109
|
+
teardowns.clear();
|
|
110
|
+
hosts = options.openHost ?? openHost;
|
|
111
|
+
}
|
|
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
|
+
}
|
|
124
|
+
|
|
125
|
+
function hostFor(scope: string | null, buildId: string): SocketHost {
|
|
126
|
+
const workerUrl =
|
|
127
|
+
typeof document === 'undefined' || typeof location === 'undefined'
|
|
128
|
+
? undefined
|
|
129
|
+
: syncWorkerFromMeta(document, location.href);
|
|
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 });
|
|
139
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The page's realtime state: ONE record store and ONE sync target per tab, whichever island bundle
|
|
2
|
+
// asks first. On `globalThis` under a `Symbol.for` key, because every island is its own bundle and
|
|
3
|
+
// a module-scope singleton here is one store PER ISLAND — the bug this file exists to end.
|
|
4
|
+
|
|
5
|
+
import { CLIENT_SCOPE_META, onRescope, pageClient } from '@ultimat3/core/page';
|
|
6
|
+
import { RecordStore } from './record-store';
|
|
7
|
+
|
|
8
|
+
/** Where the page's one socket dials. Resolved on the server and handed to the island bootstrap. */
|
|
9
|
+
export interface SyncTarget {
|
|
10
|
+
/** `wss://…/_x/sync` (or `ws://` in dev) — the sync node's own URL. */
|
|
11
|
+
readonly url: string;
|
|
12
|
+
/** This build, so the node can tell a stale tab to reload rather than serve it a patch. */
|
|
13
|
+
readonly buildId: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Page-wide and shared by every bundle. Structural on purpose: no `instanceof` across copies. */
|
|
17
|
+
export interface PageRealtime {
|
|
18
|
+
readonly store: RecordStore;
|
|
19
|
+
sync: SyncTarget | undefined;
|
|
20
|
+
/** The socket client, once a live hook opened it. Typed by `page-socket.ts`, its one writer. */
|
|
21
|
+
socket: unknown;
|
|
22
|
+
/** Optimistic writes in flight across the page, and the ones the server refused. */
|
|
23
|
+
readonly writes: PageWrites;
|
|
24
|
+
/**
|
|
25
|
+
* Settles once the page's boot script (`@ultimat3/realtime/boot`) has put this principal's
|
|
26
|
+
* persisted records back. The socket connects after it, so a restored row is on screen before
|
|
27
|
+
* the first frame can race it. Resolved at once on a page that carries no boot script.
|
|
28
|
+
*/
|
|
29
|
+
readonly booted: Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Every optimistic write on the page, counted per mutator name, and who renders the counts. */
|
|
33
|
+
export interface PageWrites {
|
|
34
|
+
readonly pending: Map<string, number>;
|
|
35
|
+
failed: number;
|
|
36
|
+
readonly listeners: Set<() => void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const KEY: unique symbol = Symbol.for('ultimate.realtime');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Where the boot script leaves its promise. The boot is ONE page-level script, not code in every
|
|
43
|
+
* island: a read-only island carried the disk restore and the outbox (15.5 kB) for a job the page
|
|
44
|
+
* does once. Read per access, so an island that ran first still waits on a boot that started later.
|
|
45
|
+
*/
|
|
46
|
+
export const BOOT_KEY: unique symbol = Symbol.for('ultimate.page-boot');
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Set only while an island is waiting on a boot that has not started yet: the resolver of the
|
|
50
|
+
* promise already sitting under `BOOT_KEY`. The boot CLAIMS it (deletes it) and resolves it once
|
|
51
|
+
* the restore is done; a page whose boot never ran has it resolved by `load` instead.
|
|
52
|
+
*/
|
|
53
|
+
export const BOOT_RELEASE_KEY: unique symbol = Symbol.for('ultimate.page-boot.release');
|
|
54
|
+
|
|
55
|
+
export type BootHost = { [BOOT_KEY]?: Promise<void>; [BOOT_RELEASE_KEY]?: () => void };
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The boot's promise, or — when an island asks FIRST on a page whose boot is still coming — a
|
|
59
|
+
* promise the boot will settle. An island can hydrate before the deferred boot script has run
|
|
60
|
+
* (the idle callback fires while the parser waits on that script), and answering "already booted"
|
|
61
|
+
* then meant a reload offline rebuilt no overlay and replayed nothing: the write looked lost.
|
|
62
|
+
* A boot is coming exactly when the document carries the scope tag (the CLI renders the boot
|
|
63
|
+
* beside it) and has not finished loading; `load` settles it if the script never ran.
|
|
64
|
+
*/
|
|
65
|
+
export function bootedPromise(): Promise<void> {
|
|
66
|
+
const host = globalThis as BootHost;
|
|
67
|
+
const started = host[BOOT_KEY];
|
|
68
|
+
if (started !== undefined) return started;
|
|
69
|
+
if (!bootComing()) return Promise.resolve();
|
|
70
|
+
let release: () => void = () => undefined;
|
|
71
|
+
const waiting = new Promise<void>((resolve) => {
|
|
72
|
+
release = resolve;
|
|
73
|
+
});
|
|
74
|
+
Object.defineProperty(host, BOOT_KEY, { value: waiting, configurable: true });
|
|
75
|
+
Object.defineProperty(host, BOOT_RELEASE_KEY, { value: release, configurable: true });
|
|
76
|
+
addEventListener(
|
|
77
|
+
'load',
|
|
78
|
+
() => {
|
|
79
|
+
// Still unclaimed after every deferred script ran: this page's boot is not coming.
|
|
80
|
+
if (host[BOOT_RELEASE_KEY] !== release) return;
|
|
81
|
+
Reflect.deleteProperty(host, BOOT_RELEASE_KEY);
|
|
82
|
+
release();
|
|
83
|
+
},
|
|
84
|
+
{ once: true },
|
|
85
|
+
);
|
|
86
|
+
return waiting;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function bootComing(): boolean {
|
|
90
|
+
if (typeof document === 'undefined' || typeof addEventListener !== 'function') return false;
|
|
91
|
+
if (document.readyState === 'complete') return false;
|
|
92
|
+
// A partial `document` (a component test's stand-in) has no query surface: no tag, no boot.
|
|
93
|
+
if (typeof document.querySelector !== 'function') return false;
|
|
94
|
+
return document.querySelector(`meta[name="${CLIENT_SCOPE_META}"]`) !== null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type Host = { [KEY]?: PageRealtime };
|
|
98
|
+
|
|
99
|
+
/** Get-or-create. Installs the store as core's `RecordSink`, so HTTP answers adopt into it. */
|
|
100
|
+
export function pageRealtime(): PageRealtime {
|
|
101
|
+
const host = globalThis as Host;
|
|
102
|
+
const existing = host[KEY];
|
|
103
|
+
if (existing !== undefined) return existing;
|
|
104
|
+
const store = new RecordStore();
|
|
105
|
+
const created: PageRealtime = {
|
|
106
|
+
store,
|
|
107
|
+
sync: undefined,
|
|
108
|
+
socket: undefined,
|
|
109
|
+
writes: { pending: new Map(), failed: 0, listeners: new Set() },
|
|
110
|
+
get booted(): Promise<void> {
|
|
111
|
+
return bootedPromise();
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
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
|
+
return created;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The page state when some island already made it — never creates one (a server render must not). */
|
|
122
|
+
export function peekPageRealtime(): PageRealtime | undefined {
|
|
123
|
+
return (globalThis as Host)[KEY];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Whether this page holds a socket — `false` on a server render and before any live hook ran.
|
|
128
|
+
* The guard a component with a static fallback asks (an offline banner, an update prompt). Here,
|
|
129
|
+
* not beside the socket, so asking it costs an island none of the connection lifecycle.
|
|
130
|
+
*/
|
|
131
|
+
export function hasPageSocket(): boolean {
|
|
132
|
+
return peekPageRealtime()?.socket !== undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The page's record store. */
|
|
136
|
+
export function pageStore(): RecordStore {
|
|
137
|
+
return pageRealtime().store;
|
|
138
|
+
}
|