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