@ultimat3/realtime 22.15.0 → 24.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 +89 -102
- package/README.md +140 -20
- package/package.json +4 -4
- package/src/advisory-lock.ts +57 -0
- package/src/boot.ts +12 -7
- package/src/change-buffer.ts +96 -41
- package/src/changefeed-env.ts +2 -2
- package/src/changefeed.ts +39 -1
- package/src/channel-authz.ts +43 -12
- package/src/channel-latch.ts +52 -0
- package/src/channel-logs.ts +34 -4
- package/src/channel-records.ts +33 -2
- package/src/channel-sids.ts +37 -0
- package/src/channel.ts +143 -44
- package/src/client-channels.ts +10 -1
- package/src/client-contract.ts +4 -1
- package/src/client-frames.ts +34 -4
- package/src/client-heartbeat.ts +39 -9
- package/src/client.ts +1 -0
- package/src/cursor.ts +11 -0
- package/src/errors.ts +70 -19
- package/src/fanout.ts +13 -0
- package/src/index.ts +1 -1
- package/src/live-contract.ts +6 -2
- package/src/live-definition.ts +46 -11
- package/src/live-fanout.ts +70 -11
- package/src/live-floor.ts +35 -0
- package/src/live-query-options.ts +38 -0
- package/src/live-query.ts +119 -91
- package/src/live-refusal.ts +21 -0
- package/src/live-replicator.ts +6 -6
- package/src/live-resume.ts +74 -0
- package/src/live-tenant.ts +25 -0
- package/src/nats-client.ts +11 -2
- package/src/nats-open.ts +9 -0
- package/src/nats-transport.ts +23 -1
- package/src/offline-queue.ts +48 -5
- package/src/page-errors.ts +17 -0
- package/src/page-outbox.ts +121 -50
- package/src/pg-advisory-lock.ts +45 -1
- package/src/pg-auth.ts +9 -4
- package/src/pg-bytes.ts +4 -1
- package/src/pg-connection.ts +119 -21
- package/src/pg-entity-row.ts +27 -2
- package/src/pg-identity-tables.ts +25 -0
- package/src/pg-preflight.ts +50 -4
- package/src/pg-replication-row.ts +82 -0
- package/src/pg-replication.ts +44 -45
- package/src/pg-socket.ts +6 -4
- package/src/pg-tls.ts +18 -0
- package/src/pg-wire.ts +5 -3
- package/src/pgoutput.ts +19 -9
- package/src/presence.ts +85 -8
- package/src/query-window.ts +28 -7
- package/src/record-persister.ts +7 -2
- package/src/replication-errors.ts +8 -4
- package/src/replicator-envelope.ts +110 -0
- package/src/replicator-row.ts +75 -0
- package/src/replicator.ts +319 -186
- package/src/server.ts +11 -7
- package/src/socket-engine.ts +14 -2
- package/src/socket-idle.ts +3 -1
- package/src/socket-routes.ts +8 -3
- package/src/subscriber-gate.ts +11 -1
- package/src/sync-auth.ts +47 -2
- package/src/sync-frames.ts +73 -20
- package/src/sync-node-bounds.ts +15 -0
- package/src/sync-node-contract.ts +20 -2
- package/src/sync-node.ts +92 -17
- package/src/sync-origin.ts +26 -11
- package/src/sync-protocol.ts +13 -3
- package/src/sync-upgrade.ts +76 -8
- package/src/use-mutation.ts +33 -5
- package/src/wire-channel.ts +13 -4
package/src/sync-protocol.ts
CHANGED
|
@@ -80,6 +80,12 @@ export interface HelloFrame {
|
|
|
80
80
|
/** Server-assigned on the reply, `null` on the client's opening frame. */
|
|
81
81
|
readonly sessionId: string | null;
|
|
82
82
|
readonly actorId: string | null;
|
|
83
|
+
/**
|
|
84
|
+
* On the NODE's reply only: how often this client must beat, in ms — a third of the node's real
|
|
85
|
+
* presence ttl, and never slower than the node's idle budget allows. Additive and optional, so
|
|
86
|
+
* no version bump: a node that says nothing leaves the client on its own default.
|
|
87
|
+
*/
|
|
88
|
+
readonly heartbeatMs?: number;
|
|
83
89
|
}
|
|
84
90
|
|
|
85
91
|
export interface SubscribeFrame {
|
|
@@ -193,14 +199,18 @@ export function decode(raw: string | Uint8Array): Frame {
|
|
|
193
199
|
}
|
|
194
200
|
const kind = parsed['type'];
|
|
195
201
|
switch (kind) {
|
|
196
|
-
case 'hello':
|
|
197
|
-
|
|
202
|
+
case 'hello': {
|
|
203
|
+
const hello = {
|
|
198
204
|
type: 'hello',
|
|
199
205
|
v: PROTOCOL_VERSION,
|
|
200
206
|
buildId: str(parsed, 'buildId'),
|
|
201
207
|
sessionId: nullableStr(parsed, 'sessionId'),
|
|
202
208
|
actorId: nullableStr(parsed, 'actorId'),
|
|
203
|
-
};
|
|
209
|
+
} as const;
|
|
210
|
+
return parsed['heartbeatMs'] === undefined
|
|
211
|
+
? hello
|
|
212
|
+
: { ...hello, heartbeatMs: num(parsed, 'heartbeatMs') };
|
|
213
|
+
}
|
|
204
214
|
case 'subscribe':
|
|
205
215
|
return {
|
|
206
216
|
type: 'subscribe',
|
package/src/sync-upgrade.ts
CHANGED
|
@@ -2,7 +2,14 @@
|
|
|
2
2
|
// from `sync-node.ts` because deciding whether a request becomes a websocket is a different job
|
|
3
3
|
// from what the socket then does — the same line `sync-frames.ts` and `sync-listen.ts` already draw.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
type HealthPayload,
|
|
7
|
+
healthBody,
|
|
8
|
+
healthPeerListed,
|
|
9
|
+
healthzPayload,
|
|
10
|
+
readyzPayload,
|
|
11
|
+
reportError,
|
|
12
|
+
} from '@ultimat3/core';
|
|
6
13
|
import {
|
|
7
14
|
SocketAuthUnavailableError,
|
|
8
15
|
SocketOriginRefusedError,
|
|
@@ -31,6 +38,11 @@ export interface WsData {
|
|
|
31
38
|
/** Structural view of `Bun.serve`'s server object; keeps this module free of a Bun import. */
|
|
32
39
|
export interface UpgradeTarget {
|
|
33
40
|
upgrade(request: Request, options: { data: WsData }): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* The peer's SOCKET address, as `Bun.serve`'s server answers it — what decides who is told the
|
|
43
|
+
* health detail. `null` when the host cannot say, which is told the verdict only.
|
|
44
|
+
*/
|
|
45
|
+
requestIP(request: Request): { readonly address: string } | null;
|
|
34
46
|
}
|
|
35
47
|
|
|
36
48
|
/**
|
|
@@ -49,10 +61,18 @@ export interface UpgradeDeps {
|
|
|
49
61
|
newSocketId(): string;
|
|
50
62
|
readonly authenticate?: SyncAuthenticator | undefined;
|
|
51
63
|
/**
|
|
52
|
-
* Exact origins a page may dial from
|
|
53
|
-
*
|
|
64
|
+
* Exact origins a page may dial from — `APP_URL`'s. Declared, they are the WHOLE list: the
|
|
65
|
+
* origin the node sees is `Host`-derived and is admitted only when nothing is declared
|
|
66
|
+
* (`sync-origin.ts`).
|
|
54
67
|
*/
|
|
55
68
|
readonly allowedOrigins?: readonly string[] | undefined;
|
|
69
|
+
/** The `Host`-derived origin admitted beside a declared list too — `x dev` only. */
|
|
70
|
+
readonly admitReachedOrigin?: boolean | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* Peers told the whole health report — address classes or exact IP literals, the app's
|
|
73
|
+
* `http.healthDetailPeers`. Everyone else gets `{ state, ready, role }`.
|
|
74
|
+
*/
|
|
75
|
+
readonly healthDetailPeers: readonly string[];
|
|
56
76
|
/**
|
|
57
77
|
* Recorded BEFORE `server.upgrade`, because Bun runs `websocket.open` synchronously inside it
|
|
58
78
|
* (measured on bun 1.4.0) and `open` is where the node reads this grant to build the socket's
|
|
@@ -83,16 +103,21 @@ export async function handleUpgrade(
|
|
|
83
103
|
const url = new URL(request.url);
|
|
84
104
|
// Health is the process's, readiness is this node's: a draining node stays healthy while it hands
|
|
85
105
|
// its sockets to the rest of the fleet.
|
|
86
|
-
if (url.pathname === '/healthz') return
|
|
106
|
+
if (url.pathname === '/healthz') return health(deps, request, server, healthzPayload());
|
|
87
107
|
if (url.pathname === '/readyz') {
|
|
88
108
|
const payload = readyzPayload({ deep: url.searchParams.get('deep') === '1' });
|
|
89
|
-
return deps.ready() ?
|
|
109
|
+
return health(deps, request, server, deps.ready() ? payload : { ...payload, status: 503 });
|
|
90
110
|
}
|
|
91
111
|
if (url.pathname !== deps.path) return new Response('not found', { status: 404 });
|
|
92
112
|
// First, and before anything is spent: a foreign page is refused whatever the node's load.
|
|
93
|
-
const origin = upgradeOrigin(request, url, deps.allowedOrigins ?? []);
|
|
94
|
-
if (!origin.ok)
|
|
95
|
-
|
|
113
|
+
const origin = upgradeOrigin(request, url, deps.allowedOrigins ?? [], deps.admitReachedOrigin);
|
|
114
|
+
if (!origin.ok) {
|
|
115
|
+
const asked = { asked: request.headers.get('origin'), admitted: admittedList(deps) };
|
|
116
|
+
return wireErrorResponse(
|
|
117
|
+
403,
|
|
118
|
+
new SocketOriginRefusedError({ reason: origin.reason, ...asked }),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
96
121
|
// The count and readiness. Decided before `authenticate` so a full node costs no token service
|
|
97
122
|
// call.
|
|
98
123
|
if (deps.socketCount() >= deps.maxConnections || !deps.ready()) return shed(deps);
|
|
@@ -177,6 +202,42 @@ export async function handleUpgrade(
|
|
|
177
202
|
return undefined;
|
|
178
203
|
}
|
|
179
204
|
|
|
205
|
+
/**
|
|
206
|
+
* What a proxy writes. Any one of them means the socket's address is the proxy's, not the
|
|
207
|
+
* caller's — and a proxy that writes none of them is indistinguishable from a direct peer, so a
|
|
208
|
+
* header-less proxy must not route the health paths at all.
|
|
209
|
+
*/
|
|
210
|
+
const FORWARDED_HEADERS = [
|
|
211
|
+
'forwarded',
|
|
212
|
+
'x-forwarded-for',
|
|
213
|
+
'x-forwarded-host',
|
|
214
|
+
'x-forwarded-proto',
|
|
215
|
+
'x-real-ip',
|
|
216
|
+
'via',
|
|
217
|
+
] as const;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The STATUS is everyone's, which is all a probe reads. The body beyond the verdict is for a peer
|
|
221
|
+
* the app listed, on a DIRECT socket: these paths answer before the origin check, the accept
|
|
222
|
+
* budget and `authenticate`. This node declares no trusted proxy, so a request that says it was
|
|
223
|
+
* forwarded is never told the detail — a proxy on this box makes every caller's socket loopback.
|
|
224
|
+
*/
|
|
225
|
+
function health(
|
|
226
|
+
deps: UpgradeDeps,
|
|
227
|
+
request: Request,
|
|
228
|
+
server: UpgradeTarget,
|
|
229
|
+
payload: HealthPayload,
|
|
230
|
+
): Response {
|
|
231
|
+
const forwarded = FORWARDED_HEADERS.some((header) => request.headers.has(header));
|
|
232
|
+
const detailed =
|
|
233
|
+
!forwarded &&
|
|
234
|
+
healthPeerListed(deps.healthDetailPeers, server.requestIP(request)?.address ?? null);
|
|
235
|
+
return new Response(JSON.stringify(healthBody(payload.body, 'sync', detailed)), {
|
|
236
|
+
status: payload.status,
|
|
237
|
+
headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
180
241
|
/** Load shedding with a delay attached: refusing without one just moves the herd next door. */
|
|
181
242
|
function shed(deps: UpgradeDeps): Response {
|
|
182
243
|
return new Response('retry', {
|
|
@@ -185,6 +246,13 @@ function shed(deps: UpgradeDeps): Response {
|
|
|
185
246
|
});
|
|
186
247
|
}
|
|
187
248
|
|
|
249
|
+
/** What the refusal says this node admits: the declared list, and under `x dev` the reached-on origin. */
|
|
250
|
+
function admittedList(deps: UpgradeDeps): readonly string[] {
|
|
251
|
+
const declared = deps.allowedOrigins ?? [];
|
|
252
|
+
if (declared.length === 0 || deps.admitReachedOrigin !== true) return declared;
|
|
253
|
+
return [...declared, 'the origin it was reached on'];
|
|
254
|
+
}
|
|
255
|
+
|
|
188
256
|
function wireErrorResponse(status: number, error: unknown): Response {
|
|
189
257
|
return json({ status, body: { error: toWireError(error) } });
|
|
190
258
|
}
|
package/src/use-mutation.ts
CHANGED
|
@@ -9,11 +9,12 @@ import {
|
|
|
9
9
|
clientTransport,
|
|
10
10
|
isSuperseded,
|
|
11
11
|
isUltimateError,
|
|
12
|
+
pageClient,
|
|
12
13
|
uuid,
|
|
13
14
|
} from '@ultimat3/core/page';
|
|
14
15
|
import type { JsonValue } from './json';
|
|
15
16
|
import { type OutboxHandle, peekOutbox } from './outbox-slot';
|
|
16
|
-
import { ServerRenderLiveError } from './page-errors';
|
|
17
|
+
import { OfflineQueueAbandonedError, ServerRenderLiveError } from './page-errors';
|
|
17
18
|
import { type PageWrites, pageRealtime } from './page-store';
|
|
18
19
|
import { isServerRender, signalFor } from './reactivity';
|
|
19
20
|
import { carriedBy } from './record-store';
|
|
@@ -127,6 +128,7 @@ export function useMutation(mutator: MutatorLike): Mutate {
|
|
|
127
128
|
const call = async (input: JsonValue): Promise<unknown> => {
|
|
128
129
|
if (writes === undefined) throw new ServerRenderLiveError({ operation: 'useMutation()' });
|
|
129
130
|
const page = pageRealtime();
|
|
131
|
+
const issued = pageClient().scope.epoch;
|
|
130
132
|
const key = `${mutator.name}:${uuid()}`;
|
|
131
133
|
const local = mutator.local;
|
|
132
134
|
// Called back through the mutator: `local` may be a method, and an unbound one loses `this`.
|
|
@@ -138,9 +140,31 @@ export function useMutation(mutator: MutatorLike): Mutate {
|
|
|
138
140
|
// them over HTTP — a like queued offline and the unlike made once the network was back could
|
|
139
141
|
// otherwise land swapped. The replay sends the queue in order, this write last, under its key.
|
|
140
142
|
const queued = peekOutbox();
|
|
143
|
+
// The outbox may REFUSE: its queue's principal left mid-write (`X_OFFLINE_QUEUE_ABANDONED`),
|
|
144
|
+
// or the disk would not take it. Nothing will ever send this write then, so its twin comes
|
|
145
|
+
// off the screen and the caller is told — a rejected `mutate`, by code.
|
|
146
|
+
//
|
|
147
|
+
// And the hook has an await of its own — the POST — so WHOSE write this is was decided at the
|
|
148
|
+
// call (`issued`): asked for after a principal change, the outbox is the next principal's, and
|
|
149
|
+
// a write handed to it then would be queued, and sent, as them.
|
|
150
|
+
const enqueue = async (outbox: OutboxHandle): Promise<void> => {
|
|
151
|
+
try {
|
|
152
|
+
if (pageClient().scope.epoch !== issued) {
|
|
153
|
+
throw new OfflineQueueAbandonedError({ name: mutator.name });
|
|
154
|
+
}
|
|
155
|
+
await outbox.enqueue({ key, name: mutator.name, input });
|
|
156
|
+
} catch (error) {
|
|
157
|
+
page.store.drop(key);
|
|
158
|
+
// A principal change is nobody's failure to report; a disk that refused the write is.
|
|
159
|
+
if (!isUltimateError(error) || error.code !== 'X_OFFLINE_QUEUE_ABANDONED') {
|
|
160
|
+
writes.failed += 1;
|
|
161
|
+
}
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
141
165
|
if (queued !== undefined && queued.pending().length > 0) {
|
|
142
166
|
try {
|
|
143
|
-
await
|
|
167
|
+
await enqueue(queued);
|
|
144
168
|
void queued.replay().catch(() => undefined);
|
|
145
169
|
return undefined;
|
|
146
170
|
} finally {
|
|
@@ -160,13 +184,17 @@ export function useMutation(mutator: MutatorLike): Mutate {
|
|
|
160
184
|
});
|
|
161
185
|
} catch (error) {
|
|
162
186
|
const failure = transportFailure(error);
|
|
163
|
-
// No response at all
|
|
164
|
-
//
|
|
187
|
+
// No response at all — which is NOT "the write did not land": the POST may have committed
|
|
188
|
+
// and lost its answer. The write is the outbox's now, under the SAME idempotency key, and
|
|
189
|
+
// that replay is safe only because `mutator()` refuses a declaration without
|
|
190
|
+
// `idempotent: true` (`X_MUTATOR_NOT_IDEMPOTENT`): the server reads the key and answers the
|
|
191
|
+
// replay from its idempotency store, so the handler never runs twice. The overlay stays on
|
|
192
|
+
// screen until the replay settles or refuses it. Only a page the boot opened
|
|
165
193
|
// an outbox on can promise that; with none (no boot, so nothing on this page persists) the
|
|
166
194
|
// write is refused like any other, never held in a memory queue a reload would silently lose.
|
|
167
195
|
const outbox = failure === 'network' ? await bootedOutbox(page) : undefined;
|
|
168
196
|
if (outbox !== undefined) {
|
|
169
|
-
await
|
|
197
|
+
await enqueue(outbox);
|
|
170
198
|
return undefined;
|
|
171
199
|
}
|
|
172
200
|
if (failure === 'body') {
|
package/src/wire-channel.ts
CHANGED
|
@@ -65,7 +65,7 @@ export function channelTarget(value: JsonObject): ChannelSubscribeTarget {
|
|
|
65
65
|
`channel params carry ${entries.length}, over the limit of ${FRAME_LIMITS.channelParams}`,
|
|
66
66
|
);
|
|
67
67
|
}
|
|
68
|
-
const params
|
|
68
|
+
const params = bare<string>();
|
|
69
69
|
for (const [key, param] of entries) {
|
|
70
70
|
if (typeof param !== 'string') throw fail(`channel param "${key}" must be a string`);
|
|
71
71
|
params[key] = param;
|
|
@@ -87,10 +87,10 @@ function sinceOf(value: unknown): ChannelSince {
|
|
|
87
87
|
function adoptOf(value: unknown): ChannelAdopt {
|
|
88
88
|
if (!isJsonObject(value)) throw fail('records.adopt must be an object');
|
|
89
89
|
let total = 0;
|
|
90
|
-
const out
|
|
90
|
+
const out = bare<Readonly<Record<string, Row>>>();
|
|
91
91
|
for (const [type, keyed] of Object.entries(value)) {
|
|
92
92
|
if (!isJsonObject(keyed)) throw fail(`records.adopt.${type} must be an object`);
|
|
93
|
-
const rows
|
|
93
|
+
const rows = bare<Row>();
|
|
94
94
|
for (const [key, row] of Object.entries(keyed)) {
|
|
95
95
|
total += 1;
|
|
96
96
|
if (total > FRAME_LIMITS.rows) {
|
|
@@ -105,7 +105,7 @@ function adoptOf(value: unknown): ChannelAdopt {
|
|
|
105
105
|
|
|
106
106
|
function removeOf(value: unknown): ChannelRemove {
|
|
107
107
|
if (!isJsonObject(value)) throw fail('records.remove must be an object');
|
|
108
|
-
const out
|
|
108
|
+
const out = bare<readonly string[]>();
|
|
109
109
|
for (const type of Object.keys(value)) {
|
|
110
110
|
out[type] = list(value, type, FRAME_LIMITS.rows, `records.remove.${type}`).map((key) => {
|
|
111
111
|
if (typeof key !== 'string') throw fail(`records.remove.${type} must hold strings`);
|
|
@@ -114,3 +114,12 @@ function removeOf(value: unknown): ChannelRemove {
|
|
|
114
114
|
}
|
|
115
115
|
return out;
|
|
116
116
|
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A map keyed by WIRE data — a record type, a record key, a channel param. Null-prototype, as the
|
|
120
|
+
* encoder's are (`channel-render.ts`): on a plain object `out['__proto__'] = rows` sets the map's
|
|
121
|
+
* prototype instead of a key, so that entry was dropped and every other read went through it.
|
|
122
|
+
*/
|
|
123
|
+
function bare<V>(): Record<string, V> {
|
|
124
|
+
return Object.create(null) as Record<string, V>;
|
|
125
|
+
}
|