@ultimat3/realtime 20.2.0 → 21.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 +186 -122
- package/README.md +121 -126
- 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 +7 -0
- package/src/channel-authz.ts +33 -0
- package/src/channel-bridge.ts +34 -0
- package/src/channel-decl.ts +144 -0
- package/src/channel-describe.ts +33 -0
- package/src/channel-gaps.ts +57 -0
- package/src/channel-logs.ts +116 -0
- package/src/channel-presence.ts +68 -0
- package/src/channel-records.ts +79 -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 +289 -0
- package/src/client-contract.ts +35 -65
- package/src/client-frames.ts +42 -110
- package/src/client.ts +138 -195
- package/src/cursor.ts +2 -2
- package/src/errors.ts +34 -101
- package/src/frame-lanes.ts +9 -5
- package/src/idb-fake.ts +113 -0
- package/src/idb-types.ts +41 -0
- package/src/index.ts +80 -74
- package/src/json.ts +5 -0
- package/src/live-contract.ts +5 -0
- package/src/live-definition.ts +10 -3
- package/src/live-fanout.ts +30 -4
- package/src/live-record-type.ts +19 -0
- package/src/live-rows.ts +70 -67
- package/src/local-store-idb.ts +250 -0
- package/src/offline-queue.ts +9 -18
- package/src/outbox-slot.ts +31 -0
- package/src/page-errors.ts +124 -0
- package/src/page-outbox.ts +242 -0
- package/src/page-socket.ts +108 -0
- package/src/page-store.ts +138 -0
- package/src/pg-replication.ts +9 -2
- package/src/pgoutput.ts +37 -2
- package/src/presence.ts +17 -9
- package/src/query-window.ts +3 -0
- 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 +7 -1
- package/src/server.ts +2 -8
- package/src/socket-engine.ts +332 -0
- package/src/socket-host.ts +126 -0
- package/src/socket-port.ts +55 -0
- package/src/socket-routes.ts +170 -0
- package/src/socket.ts +51 -12
- 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 +24 -107
- package/src/sync-protocol.ts +63 -212
- package/src/sync-worker.ts +12 -0
- package/src/thundering-herd.ts +19 -1
- 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 +214 -0
- package/src/use-query.ts +255 -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,31 @@
|
|
|
1
|
+
// The page's outbox as an ISLAND reaches it: read off the page, never built. The page boot
|
|
2
|
+
// (`boot.ts`) is the one module that constructs it — IndexedDB, the queue, the drain listeners —
|
|
3
|
+
// so a writing island ships this reader and none of that (~7.5 kB it would otherwise carry).
|
|
4
|
+
|
|
5
|
+
import type { JsonValue } from './json';
|
|
6
|
+
|
|
7
|
+
export interface OutboxEntry {
|
|
8
|
+
/** The idempotency key the write was first attempted under — the SAME key on every replay. */
|
|
9
|
+
readonly key: string;
|
|
10
|
+
/** The mutator's action name; the replay POSTs to `actionPath(name)`. */
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly input: JsonValue;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** What a hook asks of the outbox the boot opened: queue a write, list them, replay them. */
|
|
16
|
+
export interface OutboxHandle {
|
|
17
|
+
enqueue(entry: OutboxEntry): Promise<void>;
|
|
18
|
+
replay(): Promise<unknown>;
|
|
19
|
+
pending(): readonly OutboxEntry[];
|
|
20
|
+
/** Settles once the current principal's queue is open. */
|
|
21
|
+
readonly ready: Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Where the page keeps it: shared by every bundle on the page, as the record store is. */
|
|
25
|
+
export const OUTBOX_KEY: unique symbol = Symbol.for('ultimate.outbox');
|
|
26
|
+
export type OutboxHost = { [OUTBOX_KEY]?: OutboxHandle };
|
|
27
|
+
|
|
28
|
+
/** The outbox the page boot opened, or `undefined` on a page with no boot (nothing persisted). */
|
|
29
|
+
export function peekOutbox(): OutboxHandle | undefined {
|
|
30
|
+
return (globalThis as OutboxHost)[OUTBOX_KEY];
|
|
31
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// The refusals a BROWSER can reach — the page store, the hooks, the socket's wire check, the local
|
|
2
|
+
// store. Apart from `errors.ts` for bytes: that module registers the whole code table at import,
|
|
3
|
+
// and an island that renders one record has no use for sixty titles. The codes stay in `errors.ts`
|
|
4
|
+
// (its `registerErrorCodes()` is what `package.json`'s `sideEffects` names, anchored by the
|
|
5
|
+
// barrel); in a browser that loaded no table a code titles itself from its name, and `code`,
|
|
6
|
+
// `cause` and `fix` — what a reader acts on — are unchanged. This module runs nothing at import.
|
|
7
|
+
|
|
8
|
+
import { renderFixShellArg } from '@ultimat3/core/page';
|
|
9
|
+
import { RealtimeError } from './realtime-error';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Client and server disagree on the wire format — a version mismatch or a malformed frame.
|
|
13
|
+
* Both are the same class of bug (a peer speaking a shape we do not have), so both get one code.
|
|
14
|
+
*/
|
|
15
|
+
export class ProtocolVersionError extends RealtimeError {
|
|
16
|
+
constructor(args: { got: unknown; expected: number; detail?: string }) {
|
|
17
|
+
super({
|
|
18
|
+
code: 'X_PROTOCOL_VERSION',
|
|
19
|
+
cause:
|
|
20
|
+
args.detail ??
|
|
21
|
+
`frame protocol version ${String(args.got)} is not the server version ${args.expected}`,
|
|
22
|
+
fix: 'x build && redeploy the client; the sync node sends `update-available` before it drains',
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A rebase could not be resolved: `custom(merge)` returned nothing, or the base row vanished. */
|
|
28
|
+
export class RebaseConflictError extends RealtimeError {
|
|
29
|
+
constructor(args: { key: string; entity: string; reason: string }) {
|
|
30
|
+
super({
|
|
31
|
+
code: 'X_REBASE_CONFLICT',
|
|
32
|
+
cause: `mutation ${args.key} on ${args.entity} could not be rebased: ${args.reason}`,
|
|
33
|
+
fix: "set conflict: 'server-wins' on the mutator, or return a row from custom(merge)",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
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
|
|
41
|
+
* hook renders through has to be that island's own — the page-wide store cannot hold one.
|
|
42
|
+
*
|
|
43
|
+
* A server render is deliberately not this error: no DOM means no reactive runtime to install,
|
|
44
|
+
* and the hooks answer the honest server-render state instead.
|
|
45
|
+
*/
|
|
46
|
+
export class RealtimeUninstalledError extends RealtimeError {
|
|
47
|
+
constructor(args: { hook: string }) {
|
|
48
|
+
super({
|
|
49
|
+
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",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A live hook needed the page's one socket, and nothing told this page where the sync node is. */
|
|
57
|
+
export class SyncUnconfiguredError extends RealtimeError {
|
|
58
|
+
constructor(args: { hook: string }) {
|
|
59
|
+
super({
|
|
60
|
+
code: 'X_SYNC_UNCONFIGURED',
|
|
61
|
+
cause: `${args.hook}() needs the page socket, and installRealtime() was given no sync target`,
|
|
62
|
+
fix: "x build # the island bootstrap passes it; a hand-built island calls installRealtime({ signal: createSignal, sync: { url, buildId } }) from '@ultimat3/realtime'",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A row reached the record store it cannot hold: not an object, or under no key. Dropped and
|
|
69
|
+
* reported, never partially merged — a keyless row would overwrite another record.
|
|
70
|
+
*/
|
|
71
|
+
export class RecordRejectedError extends RealtimeError {
|
|
72
|
+
constructor(args: { type: string; reason: string }) {
|
|
73
|
+
super({
|
|
74
|
+
code: 'X_RECORD_REJECTED',
|
|
75
|
+
cause: `a ${args.type === '' ? 'record' : `"${args.type}" record`} was rejected by the page's record store: ${args.reason}`,
|
|
76
|
+
fix: `x entities describe ${renderFixShellArg(args.type, '<entity>')} --json # the primary key every row of it must carry; return whole rows from the handler that built this one`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* IndexedDB would not open (a private window, storage disabled), so the page keeps records and its
|
|
83
|
+
* outbox in memory. A WARNING, never thrown: a page must not break because it cannot remember, so
|
|
84
|
+
* `openLocalStore` hands this to its `warn` once and carries on.
|
|
85
|
+
*/
|
|
86
|
+
export class LocalStoreUnavailableError extends RealtimeError {
|
|
87
|
+
constructor(args: { reason: string }) {
|
|
88
|
+
super({
|
|
89
|
+
code: 'X_LOCAL_STORE_UNAVAILABLE',
|
|
90
|
+
cause: `the page's durable store could not open (${args.reason}), so records and queued writes live in memory and are lost on reload`,
|
|
91
|
+
fix: 'nothing to do in the app — allow site storage in the browser (a private window blocks it) and reload',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Something that can only mean "talk to the socket" ran on the server client — a mutation, a
|
|
98
|
+
* publish, a topic subscription, a dial. There is no socket during a server render and there never
|
|
99
|
+
* will be one: the document is built and sent, and the browser opens the connection.
|
|
100
|
+
*
|
|
101
|
+
* A refusal rather than a silent no-op, because both alternatives are worse. Queueing it would
|
|
102
|
+
* hold one process-wide queue on behalf of whichever request happened to render, and dropping it
|
|
103
|
+
* would make a write that never happened look like one that did.
|
|
104
|
+
*/
|
|
105
|
+
export class ServerRenderLiveError extends RealtimeError {
|
|
106
|
+
constructor(args: { operation: string }) {
|
|
107
|
+
super({
|
|
108
|
+
code: 'X_LIVE_SERVER_RENDER',
|
|
109
|
+
cause: `${args.operation} ran during a server render, where this app has no live socket`,
|
|
110
|
+
fix: 'move the call into an island: x g island <route-dir> --at <route-dir>, import from its mount(), declare island({ src })',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A resume cursor cannot be honoured and no snapshot path was supplied. */
|
|
116
|
+
export class CursorStaleError extends RealtimeError {
|
|
117
|
+
constructor(args: { qid: string; lsn: string; reason: string }) {
|
|
118
|
+
super({
|
|
119
|
+
code: 'X_CURSOR_STALE',
|
|
120
|
+
cause: `cursor for query ${args.qid} at lsn ${args.lsn} cannot be resumed: ${args.reason}`,
|
|
121
|
+
fix: 'pass `snapshot` to resumeFrom() so the fallback path can re-snapshot instead of failing',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
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
|
+
|
|
78
|
+
const open = async (): Promise<void> => {
|
|
79
|
+
queue = await OfflineQueue.open(queueStore(await options.local, scopeKey(principal())));
|
|
80
|
+
};
|
|
81
|
+
let ready = open();
|
|
82
|
+
let running: Promise<DrainReport> | undefined;
|
|
83
|
+
|
|
84
|
+
const deliver = async (mutation: QueuedMutation): Promise<void> => {
|
|
85
|
+
const current = queue;
|
|
86
|
+
const carried = new Set<string>();
|
|
87
|
+
try {
|
|
88
|
+
await send({ key: mutation.key, name: mutation.name, input: mutation.input }, carried);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const kind = classifyThrown(error);
|
|
91
|
+
// The network, or a server asking to be asked again: stays queued, and the pass stops so
|
|
92
|
+
// nothing behind it overtakes it.
|
|
93
|
+
if (kind === 'retryable' || kind === 'retry-after') throw error;
|
|
94
|
+
// Anything else is the server's decision about this write — kept for the UI, never resent.
|
|
95
|
+
await current?.fail(mutation.key, toQueueError(error));
|
|
96
|
+
overlays()?.drop(mutation.key);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
await current?.ack(mutation.key);
|
|
100
|
+
const store = overlays();
|
|
101
|
+
try {
|
|
102
|
+
// Exactly as a live write settles: a row the answer did not carry keeps its overlay until
|
|
103
|
+
// the server's row for it arrives.
|
|
104
|
+
store?.settle(mutation.key, carried);
|
|
105
|
+
} catch {
|
|
106
|
+
// A custom merge that answered no row: the server's truth stands.
|
|
107
|
+
store?.drop(mutation.key);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
onRescope((_next, prev) => {
|
|
112
|
+
const gone = scopeKey(prev.principal);
|
|
113
|
+
ready = ready.then(async () => {
|
|
114
|
+
if (gone !== undefined) await (await options.local).wipe(gone);
|
|
115
|
+
await open();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
enqueue: async (entry) => {
|
|
121
|
+
// A disk that refused the rows must not also cost the write: the intent still goes on disk.
|
|
122
|
+
await options.beforeEnqueue?.().catch(() => undefined);
|
|
123
|
+
await ready;
|
|
124
|
+
await queue?.enqueue(entry);
|
|
125
|
+
},
|
|
126
|
+
replay: () => {
|
|
127
|
+
// Single flight: a trigger that lands while a replay is running JOINS it. Open, `online`,
|
|
128
|
+
// the socket's reconnect and the service worker's drain arrive together, and each chaining
|
|
129
|
+
// a pass of its own sent the head of the queue once per trigger whenever a send failed —
|
|
130
|
+
// one write, several POSTs. What a joined trigger would have sent is still queued for the
|
|
131
|
+
// next one; nothing is dropped.
|
|
132
|
+
if (running !== undefined) return running;
|
|
133
|
+
const pass = (async (): Promise<DrainReport> => {
|
|
134
|
+
await ready;
|
|
135
|
+
if (queue === undefined) return EMPTY;
|
|
136
|
+
// Checked when the pass STARTS, whoever asked (the socket's reconnect asks too): an
|
|
137
|
+
// attempt the browser already knows cannot leave is a failed request on the wire and
|
|
138
|
+
// nothing more. `online` asks again.
|
|
139
|
+
if (knownOffline()) return { ...EMPTY, remaining: queue.pending().length };
|
|
140
|
+
return queue.drain(deliver);
|
|
141
|
+
})();
|
|
142
|
+
const settled = (): void => {
|
|
143
|
+
if (running === pass) running = undefined;
|
|
144
|
+
};
|
|
145
|
+
running = pass;
|
|
146
|
+
pass.then(settled, settled);
|
|
147
|
+
return pass;
|
|
148
|
+
},
|
|
149
|
+
get size(): number {
|
|
150
|
+
return queue?.pending().length ?? 0;
|
|
151
|
+
},
|
|
152
|
+
pending: () => (queue?.pending() ?? []).map(({ key, name, input }) => ({ key, name, input })),
|
|
153
|
+
get ready(): Promise<void> {
|
|
154
|
+
return ready;
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Persisted under the principal; an UNSCOPED page queues in memory only, and loses it on reload. */
|
|
160
|
+
function queueStore(local: LocalStore, scope: string | undefined): QueueStore {
|
|
161
|
+
if (scope === undefined) return new MemoryQueueStore();
|
|
162
|
+
return {
|
|
163
|
+
load: async (): Promise<QueueState> =>
|
|
164
|
+
(await local.queue(scope)) ?? { mutations: [], nextSeq: 1 },
|
|
165
|
+
save: (state) => local.saveQueue(scope, state),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function sendOverHttp(entry: OutboxEntry, carried: Set<string>): Promise<unknown> {
|
|
170
|
+
return clientTransport({
|
|
171
|
+
method: 'POST',
|
|
172
|
+
url: actionPath(entry.name),
|
|
173
|
+
body: entry.input,
|
|
174
|
+
idempotencyKey: entry.key,
|
|
175
|
+
onEnvelope: (envelope) => carriedBy(envelope, carried),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The page's outbox, created once per tab. In a browser it replays on open, on `online`, and on the
|
|
181
|
+
* service worker's drain message; with no `document` it is a memory queue that listens for nothing.
|
|
182
|
+
*/
|
|
183
|
+
export function pageOutbox(options: Pick<OutboxOptions, 'beforeEnqueue'> = {}): PageOutbox {
|
|
184
|
+
const host = globalThis as OutboxHost;
|
|
185
|
+
const existing = host[OUTBOX_KEY];
|
|
186
|
+
// Only this function writes the slot, so what it holds is always a `PageOutbox`.
|
|
187
|
+
if (existing !== undefined) return existing as PageOutbox;
|
|
188
|
+
const outbox = createOutbox({ local: pageLocalStore(), beforeEnqueue: options.beforeEnqueue });
|
|
189
|
+
Object.defineProperty(host, OUTBOX_KEY, { value: outbox, configurable: true });
|
|
190
|
+
if (Reflect.has(globalThis, 'document')) {
|
|
191
|
+
listenForDrain(outbox);
|
|
192
|
+
if (!knownOffline()) void outbox.replay().catch(() => undefined);
|
|
193
|
+
}
|
|
194
|
+
return outbox;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The two drain signals a page can receive: the service worker's `sync` (posted to every open tab
|
|
199
|
+
* as `OUTBOX_DRAIN_MESSAGE`), and the browser coming back `online`. A replay that fails leaves the
|
|
200
|
+
* queue as it was — the next signal tries again — so its rejection is not the listener's to raise.
|
|
201
|
+
*/
|
|
202
|
+
export function listenForDrain(outbox: Pick<PageOutbox, 'replay'>): () => void {
|
|
203
|
+
const replay = (): void => {
|
|
204
|
+
if (!knownOffline()) void outbox.replay().catch(() => undefined);
|
|
205
|
+
};
|
|
206
|
+
const worker: EventTarget | undefined = Reflect.get(
|
|
207
|
+
Reflect.get(globalThis, 'navigator') ?? {},
|
|
208
|
+
'serviceWorker',
|
|
209
|
+
);
|
|
210
|
+
const onMessage = (event: Event): void => {
|
|
211
|
+
const data: unknown = Reflect.get(event, 'data');
|
|
212
|
+
if (
|
|
213
|
+
typeof data === 'object' &&
|
|
214
|
+
data !== null &&
|
|
215
|
+
Reflect.get(data, 'type') === OUTBOX_DRAIN_MESSAGE
|
|
216
|
+
) {
|
|
217
|
+
replay();
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
worker?.addEventListener('message', onMessage);
|
|
221
|
+
globalThis.addEventListener('online', replay);
|
|
222
|
+
return () => {
|
|
223
|
+
worker?.removeEventListener('message', onMessage);
|
|
224
|
+
globalThis.removeEventListener('online', replay);
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The browser says it has no network. A replay then is an attempt that can only fail — and a
|
|
230
|
+
* failed attempt is still a request on the wire: measured, a reload taken offline replayed on open,
|
|
231
|
+
* that POST failed, and the real one followed on `online`, so one write went out twice. The
|
|
232
|
+
* `online` event is the replay's own trigger, so nothing is lost by waiting for it. Unknown (no
|
|
233
|
+
* `navigator`) is not offline.
|
|
234
|
+
*/
|
|
235
|
+
function knownOffline(): boolean {
|
|
236
|
+
const navigator: unknown = Reflect.get(globalThis, 'navigator');
|
|
237
|
+
return (
|
|
238
|
+
typeof navigator === 'object' &&
|
|
239
|
+
navigator !== null &&
|
|
240
|
+
Reflect.get(navigator, 'onLine') === false
|
|
241
|
+
);
|
|
242
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
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 { peekOutbox } from './outbox-slot';
|
|
9
|
+
import { SyncUnconfiguredError } from './page-errors';
|
|
10
|
+
import { pageRealtime } from './page-store';
|
|
11
|
+
import { openHost, type SocketHost, type SocketHostOptions } from './socket-host';
|
|
12
|
+
import { pageSyncTarget, syncWorkerFromMeta } from './sync-meta';
|
|
13
|
+
|
|
14
|
+
/** Get-or-create, and connect on creation. `hook` names the caller in the refusal. */
|
|
15
|
+
export function pageSocket(hook: string): LiveClient {
|
|
16
|
+
const page = pageRealtime();
|
|
17
|
+
// Its one writer is below, so the stored value is always this class — from SOME bundle's copy,
|
|
18
|
+
// which is why it is read structurally and never checked with `instanceof`.
|
|
19
|
+
if (page.socket !== undefined) return page.socket as LiveClient;
|
|
20
|
+
// What the bootstrap passed, else what the document shell rendered into `<head>`.
|
|
21
|
+
const target = page.sync ?? pageSyncTarget();
|
|
22
|
+
if (target === undefined) throw new SyncUnconfiguredError({ hook });
|
|
23
|
+
let host = hostFor(pageClient().scope.principal ?? null);
|
|
24
|
+
const client = new LiveClient({
|
|
25
|
+
connect: () => host.socket(target),
|
|
26
|
+
buildId: target.buildId,
|
|
27
|
+
actorId: pageClient().scope.principal ?? null,
|
|
28
|
+
store: page.store,
|
|
29
|
+
// The channel's catch-up read is an ordinary query read: core's transport adopts its records.
|
|
30
|
+
catchUp: (query, params) => queryClientMethodFor(query, { baseUrl: '' })(params),
|
|
31
|
+
});
|
|
32
|
+
page.socket = client;
|
|
33
|
+
pageClient().socket = client;
|
|
34
|
+
// Every time the socket comes (back) up, the writes the network refused go out, in order, over
|
|
35
|
+
// HTTP — the outbox's own idempotency keys make a replay after a lost answer harmless.
|
|
36
|
+
let up = false;
|
|
37
|
+
client.onStatus(() => {
|
|
38
|
+
if (client.connected && !up)
|
|
39
|
+
void peekOutbox()
|
|
40
|
+
?.replay()
|
|
41
|
+
.catch(() => undefined);
|
|
42
|
+
up = client.connected;
|
|
43
|
+
});
|
|
44
|
+
// After the disk restore, so a restored record is shown before the first frame can race it.
|
|
45
|
+
void page.booted.then(() => client.connect());
|
|
46
|
+
// A new principal gets its own worker — never the previous principal's socket — and redials.
|
|
47
|
+
const offRescope = onRescope((next) => {
|
|
48
|
+
host.bye();
|
|
49
|
+
host = hostFor(next.principal ?? null);
|
|
50
|
+
client.connect();
|
|
51
|
+
});
|
|
52
|
+
const bye = (): void => host.bye();
|
|
53
|
+
const listens = typeof addEventListener === 'function';
|
|
54
|
+
if (listens) addEventListener('pagehide', bye);
|
|
55
|
+
teardowns.add(() => {
|
|
56
|
+
offRescope();
|
|
57
|
+
if (listens) removeEventListener('pagehide', bye);
|
|
58
|
+
client.close();
|
|
59
|
+
host.bye();
|
|
60
|
+
if (page.socket === client) page.socket = undefined;
|
|
61
|
+
if (pageClient().socket === client) pageClient().socket = undefined;
|
|
62
|
+
});
|
|
63
|
+
return client;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Every socket this module built, as the undo of building it. A page builds one and never tears it
|
|
68
|
+
* down — the tab's end is its teardown — so only a test process, which builds one per case, calls
|
|
69
|
+
* `resetPageSocket`. Not on the barrel: an app has no page to reset.
|
|
70
|
+
*/
|
|
71
|
+
const teardowns = new Set<() => void>();
|
|
72
|
+
|
|
73
|
+
/** Where a page socket gets its host. `openHost` in production; a test hands in its own. */
|
|
74
|
+
let hosts: (options: SocketHostOptions) => SocketHost = openHost;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* For tests: unsubscribe, close and unseat every page socket built so far, so a case starts clean
|
|
78
|
+
* — and, optionally, build the next ones over `openHost` of the case's own. A case that counts
|
|
79
|
+
* dials through the real engine counts every engine alive in the process; one that counts HOSTS
|
|
80
|
+
* counts only what this module asked for, which is the one thing it owns.
|
|
81
|
+
*/
|
|
82
|
+
export function resetPageSocket(
|
|
83
|
+
options: { readonly openHost?: (options: SocketHostOptions) => SocketHost } = {},
|
|
84
|
+
): void {
|
|
85
|
+
for (const teardown of teardowns) teardown();
|
|
86
|
+
teardowns.clear();
|
|
87
|
+
hosts = options.openHost ?? openHost;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether this page holds a socket — `false` on a server render and before any live hook ran.
|
|
92
|
+
* The guard a component with a static fallback asks (an offline banner, an update prompt).
|
|
93
|
+
*/
|
|
94
|
+
export function hasPageSocket(): boolean {
|
|
95
|
+
const host = globalThis as { [key: symbol]: unknown };
|
|
96
|
+
const page = host[Symbol.for('ultimate.realtime')];
|
|
97
|
+
return (
|
|
98
|
+
typeof page === 'object' && page !== null && (page as { socket?: unknown }).socket !== undefined
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function hostFor(scope: string | null): SocketHost {
|
|
103
|
+
const workerUrl =
|
|
104
|
+
typeof document === 'undefined' || typeof location === 'undefined'
|
|
105
|
+
? undefined
|
|
106
|
+
: syncWorkerFromMeta(document, location.href);
|
|
107
|
+
return hosts({ workerUrl, scope });
|
|
108
|
+
}
|
|
@@ -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
|
+
}
|