@ultimat3/realtime 24.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 +61 -63
- package/README.md +131 -46
- package/package.json +9 -6
- package/src/advisory-lock.ts +10 -5
- package/src/boot.ts +14 -9
- package/src/changefeed-env.ts +8 -8
- package/src/changefeed.ts +42 -18
- package/src/channel-authz.ts +7 -7
- package/src/channel-describe.ts +1 -1
- package/src/channel-logs.ts +16 -25
- package/src/channel-presence.ts +3 -3
- package/src/channel-records.ts +3 -3
- package/src/channel-ref.ts +2 -2
- package/src/channel-registry.ts +2 -2
- package/src/channel.ts +8 -7
- package/src/client-channels.ts +2 -0
- package/src/client-frames.ts +9 -2
- package/src/client.ts +29 -2
- package/src/error-titles.ts +91 -0
- package/src/errors.ts +47 -90
- package/src/first-paint-hold.ts +49 -0
- package/src/idb-types.ts +2 -2
- package/src/index.ts +24 -6
- package/src/json.ts +16 -3
- package/src/live-contract.ts +14 -0
- package/src/live-definition.ts +12 -2
- package/src/live-deliver.ts +55 -0
- package/src/live-fanout.ts +81 -24
- package/src/live-query-options.ts +6 -0
- package/src/live-query.ts +66 -144
- 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 +26 -4
- package/src/nats-lib-client.ts +36 -7
- package/src/nats-subscriptions.ts +63 -0
- package/src/nats-transport.ts +127 -15
- package/src/offline-queue.ts +5 -0
- package/src/page-errors.ts +8 -9
- package/src/page-outbox.ts +5 -5
- 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 +9 -4
- package/src/pg-connection.ts +7 -1
- package/src/pg-replication.ts +6 -6
- package/src/pg-socket.ts +20 -3
- package/src/pg-values.ts +29 -14
- package/src/policy-gate.ts +7 -7
- package/src/presence.ts +2 -2
- package/src/principal-sockets.ts +75 -0
- package/src/query-window.ts +1 -1
- 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 +3 -3
- package/src/replicator-envelope.ts +61 -11
- package/src/replicator-row.ts +1 -1
- package/src/replicator.ts +12 -23
- package/src/server.ts +36 -18
- package/src/socket-defaults.ts +20 -0
- package/src/socket.ts +14 -19
- 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-bus-handlers.ts +131 -0
- package/src/sync-frames.ts +1 -1
- package/src/sync-node-bounds.ts +1 -1
- package/src/sync-node-contract.ts +16 -2
- package/src/sync-node.ts +115 -109
- package/src/sync-origin.ts +1 -1
- package/src/sync-protocol.ts +73 -13
- package/src/sync-upgrade.ts +39 -0
- 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 +9 -16
- package/src/use-query.ts +11 -11
- package/src/use-record.ts +89 -10
- package/src/wire-channel.ts +5 -5
- package/src/wire-read.ts +2 -2
- package/src/idb-fake.ts +0 -133
- 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
|
+
}
|
package/src/nats-transport.ts
CHANGED
|
@@ -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
|
|
4
|
-
//
|
|
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
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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
|
|
109
|
-
client
|
|
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:
|
|
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
|
|
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
|
-
|
|
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"
|
package/src/offline-queue.ts
CHANGED
|
@@ -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;
|
package/src/page-errors.ts
CHANGED
|
@@ -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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
}
|
package/src/page-outbox.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
+
}
|
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 =
|