@ultimat3/realtime 23.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 +118 -126
- package/README.md +229 -54
- package/package.json +9 -6
- package/src/advisory-lock.ts +62 -0
- package/src/boot.ts +25 -15
- package/src/change-buffer.ts +96 -41
- package/src/changefeed-env.ts +9 -9
- package/src/changefeed.ts +81 -19
- package/src/channel-authz.ts +48 -17
- package/src/channel-describe.ts +1 -1
- package/src/channel-latch.ts +52 -0
- package/src/channel-logs.ts +36 -15
- package/src/channel-presence.ts +3 -3
- package/src/channel-records.ts +35 -4
- package/src/channel-ref.ts +2 -2
- package/src/channel-registry.ts +2 -2
- package/src/channel-sids.ts +37 -0
- package/src/channel.ts +148 -48
- package/src/client-channels.ts +12 -1
- package/src/client-contract.ts +4 -1
- package/src/client-frames.ts +16 -3
- package/src/client-heartbeat.ts +39 -9
- package/src/client.ts +30 -2
- package/src/cursor.ts +11 -0
- package/src/error-titles.ts +91 -0
- package/src/errors.ts +113 -105
- package/src/fanout.ts +13 -0
- package/src/first-paint-hold.ts +49 -0
- package/src/idb-types.ts +2 -2
- package/src/index.ts +25 -7
- package/src/json.ts +16 -3
- package/src/live-contract.ts +14 -0
- package/src/live-definition.ts +34 -9
- package/src/live-deliver.ts +55 -0
- package/src/live-fanout.ts +122 -29
- package/src/live-floor.ts +35 -0
- package/src/live-query-options.ts +44 -0
- package/src/live-query.ts +96 -175
- 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 +37 -6
- package/src/nats-lib-client.ts +36 -7
- package/src/nats-subscriptions.ts +63 -0
- package/src/nats-transport.ts +149 -15
- package/src/offline-queue.ts +53 -5
- package/src/page-errors.ts +25 -9
- package/src/page-outbox.ts +125 -54
- 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 +54 -5
- package/src/pg-auth.ts +9 -4
- package/src/pg-bytes.ts +4 -1
- package/src/pg-connection.ts +126 -22
- package/src/pg-entity-row.ts +16 -0
- 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 +50 -51
- package/src/pg-socket.ts +26 -7
- package/src/pg-tls.ts +18 -0
- package/src/pg-values.ts +29 -14
- package/src/pg-wire.ts +5 -3
- package/src/pgoutput.ts +19 -9
- package/src/policy-gate.ts +7 -7
- package/src/presence.ts +87 -10
- package/src/principal-sockets.ts +75 -0
- package/src/query-window.ts +17 -5
- package/src/record-persister.ts +7 -2
- 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 +11 -7
- package/src/replicator-envelope.ts +160 -0
- package/src/replicator-row.ts +75 -0
- package/src/replicator.ts +313 -191
- package/src/server.ts +45 -23
- package/src/socket-defaults.ts +20 -0
- package/src/socket-engine.ts +14 -2
- package/src/socket-idle.ts +3 -1
- package/src/socket-routes.ts +8 -3
- package/src/socket.ts +14 -19
- package/src/subscriber-gate.ts +11 -1
- 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-auth.ts +47 -2
- package/src/sync-bus-handlers.ts +131 -0
- package/src/sync-frames.ts +74 -21
- package/src/sync-node-bounds.ts +16 -1
- package/src/sync-node-contract.ts +36 -4
- package/src/sync-node.ts +163 -82
- package/src/sync-origin.ts +27 -12
- package/src/sync-protocol.ts +86 -16
- package/src/sync-upgrade.ts +115 -8
- 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 +41 -20
- package/src/use-query.ts +11 -11
- package/src/use-record.ts +89 -10
- package/src/wire-channel.ts +18 -9
- package/src/wire-read.ts +2 -2
- package/src/idb-fake.ts +0 -133
- package/src/policy-fake.ts +0 -14
package/src/sync-protocol.ts
CHANGED
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
// patches, presence and refusals come down. A client write is HTTP (`useMutation`), never a frame —
|
|
3
3
|
// so there is one write path, with the action's authz, idempotency store and contract behind it.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
isWriteDigest,
|
|
7
|
+
renderThrowable,
|
|
8
|
+
stringField,
|
|
9
|
+
WRITE_DIGEST_LENGTH,
|
|
10
|
+
} from '@ultimat3/core/page';
|
|
6
11
|
import type {
|
|
7
12
|
ChannelEventsFrame,
|
|
8
13
|
ChannelRecordsFrame,
|
|
@@ -11,7 +16,7 @@ import type {
|
|
|
11
16
|
} from './channel-wire';
|
|
12
17
|
import type { LiveCursor } from './cursor';
|
|
13
18
|
import {
|
|
14
|
-
|
|
19
|
+
isParsedJsonObject,
|
|
15
20
|
isRow,
|
|
16
21
|
type JsonObject,
|
|
17
22
|
type JsonValue,
|
|
@@ -37,6 +42,8 @@ export interface WireError {
|
|
|
37
42
|
readonly cause: string;
|
|
38
43
|
readonly fix: string;
|
|
39
44
|
readonly docs?: string;
|
|
45
|
+
/** When a retry can succeed — a rate-limited subscribe's refill. Whole seconds, positive. */
|
|
46
|
+
readonly retryAfterSeconds?: number;
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
export interface PresenceMember {
|
|
@@ -80,6 +87,12 @@ export interface HelloFrame {
|
|
|
80
87
|
/** Server-assigned on the reply, `null` on the client's opening frame. */
|
|
81
88
|
readonly sessionId: string | null;
|
|
82
89
|
readonly actorId: string | null;
|
|
90
|
+
/**
|
|
91
|
+
* On the NODE's reply only: how often this client must beat, in ms — a third of the node's real
|
|
92
|
+
* presence ttl, and never slower than the node's idle budget allows. Additive and optional, so
|
|
93
|
+
* no version bump: a node that says nothing leaves the client on its own default.
|
|
94
|
+
*/
|
|
95
|
+
readonly heartbeatMs?: number;
|
|
83
96
|
}
|
|
84
97
|
|
|
85
98
|
export interface SubscribeFrame {
|
|
@@ -110,6 +123,12 @@ export interface SnapshotFrame {
|
|
|
110
123
|
* derives a key.
|
|
111
124
|
*/
|
|
112
125
|
readonly keys?: readonly string[];
|
|
126
|
+
/**
|
|
127
|
+
* The writes this server truth already holds, named as `PatchFrame.writes` names them — only
|
|
128
|
+
* writes behind a row in `rows`, so never one this subscriber may not see. Optional and additive:
|
|
129
|
+
* no `PROTOCOL_VERSION` bump, for the reason `entity` needs none.
|
|
130
|
+
*/
|
|
131
|
+
readonly writes?: readonly string[];
|
|
113
132
|
}
|
|
114
133
|
|
|
115
134
|
export interface PatchFrame {
|
|
@@ -118,6 +137,15 @@ export interface PatchFrame {
|
|
|
118
137
|
readonly sid: string;
|
|
119
138
|
readonly patches: readonly RowPatch[];
|
|
120
139
|
readonly lsn: string;
|
|
140
|
+
/**
|
|
141
|
+
* The writes that made these patches: `writeDigest` of each keyed request's idempotency key
|
|
142
|
+
* (`@ultimat3/core`), never the key, as `ChannelRecordsFrame.write` names one. A live patch names
|
|
143
|
+
* at most one; a delta resume names every write behind a patch it replays. Absent when no keyed
|
|
144
|
+
* request made any of them. The page that holds one pending settles it in the frame's own batch.
|
|
145
|
+
* Additive and optional: a decoder one deploy behind drops it, and a frame without it is read as
|
|
146
|
+
* it always was — so no `PROTOCOL_VERSION` bump.
|
|
147
|
+
*/
|
|
148
|
+
readonly writes?: readonly string[];
|
|
121
149
|
}
|
|
122
150
|
|
|
123
151
|
export interface AckFrame {
|
|
@@ -186,21 +214,25 @@ export function decode(raw: string | Uint8Array): Frame {
|
|
|
186
214
|
} catch {
|
|
187
215
|
throw fail('frame is not JSON');
|
|
188
216
|
}
|
|
189
|
-
if (!
|
|
217
|
+
if (!isParsedJsonObject(parsed)) throw fail('frame is not an object');
|
|
190
218
|
const version = parsed['v'];
|
|
191
219
|
if (version !== PROTOCOL_VERSION) {
|
|
192
220
|
throw new ProtocolVersionError({ got: version, expected: PROTOCOL_VERSION });
|
|
193
221
|
}
|
|
194
222
|
const kind = parsed['type'];
|
|
195
223
|
switch (kind) {
|
|
196
|
-
case 'hello':
|
|
197
|
-
|
|
224
|
+
case 'hello': {
|
|
225
|
+
const hello = {
|
|
198
226
|
type: 'hello',
|
|
199
227
|
v: PROTOCOL_VERSION,
|
|
200
228
|
buildId: str(parsed, 'buildId'),
|
|
201
229
|
sessionId: nullableStr(parsed, 'sessionId'),
|
|
202
230
|
actorId: nullableStr(parsed, 'actorId'),
|
|
203
|
-
};
|
|
231
|
+
} as const;
|
|
232
|
+
return parsed['heartbeatMs'] === undefined
|
|
233
|
+
? hello
|
|
234
|
+
: { ...hello, heartbeatMs: num(parsed, 'heartbeatMs') };
|
|
235
|
+
}
|
|
204
236
|
case 'subscribe':
|
|
205
237
|
return {
|
|
206
238
|
type: 'subscribe',
|
|
@@ -218,7 +250,9 @@ export function decode(raw: string | Uint8Array): Frame {
|
|
|
218
250
|
cursor: cursor(parsed['cursor']),
|
|
219
251
|
} as const;
|
|
220
252
|
const entity = nullableStr(parsed, 'entity');
|
|
221
|
-
const
|
|
253
|
+
const writes = writesOf(parsed, 'snapshot.writes');
|
|
254
|
+
const named = writes.length === 0 ? base : { ...base, writes };
|
|
255
|
+
const scoped = entity === null ? named : { ...named, entity };
|
|
222
256
|
if (parsed['keys'] === undefined) return scoped;
|
|
223
257
|
const keys = list(parsed, 'keys', FRAME_LIMITS.rows).map((key) => {
|
|
224
258
|
if (typeof key !== 'string') throw fail('snapshot.keys must hold strings');
|
|
@@ -227,14 +261,17 @@ export function decode(raw: string | Uint8Array): Frame {
|
|
|
227
261
|
if (keys.length !== base.rows.length) throw fail('snapshot.keys must pair with rows');
|
|
228
262
|
return { ...scoped, keys };
|
|
229
263
|
}
|
|
230
|
-
case 'patch':
|
|
231
|
-
|
|
264
|
+
case 'patch': {
|
|
265
|
+
const base = {
|
|
232
266
|
type: 'patch',
|
|
233
267
|
v: PROTOCOL_VERSION,
|
|
234
268
|
sid: str(parsed, 'sid'),
|
|
235
269
|
patches: list(parsed, 'patches', FRAME_LIMITS.patches).map(patch),
|
|
236
270
|
lsn: str(parsed, 'lsn'),
|
|
237
|
-
};
|
|
271
|
+
} as const;
|
|
272
|
+
const writes = writesOf(parsed, 'patch.writes');
|
|
273
|
+
return writes.length === 0 ? base : { ...base, writes };
|
|
274
|
+
}
|
|
238
275
|
case 'ack':
|
|
239
276
|
return {
|
|
240
277
|
type: 'ack',
|
|
@@ -275,7 +312,37 @@ export function toWireError(error: unknown): WireError {
|
|
|
275
312
|
const cause = stringField(error, 'cause') ?? renderThrowable(error);
|
|
276
313
|
const fix = stringField(error, 'fix') ?? 'x doctor realtime';
|
|
277
314
|
const docs = stringField(error, 'docs');
|
|
278
|
-
|
|
315
|
+
const base = docs === undefined ? { code, cause, fix } : { code, cause, fix, docs };
|
|
316
|
+
const retryAfterSeconds = retryHintOf(error);
|
|
317
|
+
return retryAfterSeconds === undefined ? base : { ...base, retryAfterSeconds };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* `meta.retryAfterSeconds`, the field `@ultimat3/http`'s `X_RATE_LIMITED` carries — read as totally
|
|
322
|
+
* as `stringField` reads the rest, because the throwable is app code's and its getters may throw.
|
|
323
|
+
*/
|
|
324
|
+
function retryHintOf(error: unknown): number | undefined {
|
|
325
|
+
try {
|
|
326
|
+
const meta: unknown = (error as { readonly meta?: unknown } | null)?.meta;
|
|
327
|
+
const held: unknown = isParsedJsonObject(meta) ? meta['retryAfterSeconds'] : undefined;
|
|
328
|
+
return typeof held === 'number' && Number.isFinite(held) && held > 0 ? held : undefined;
|
|
329
|
+
} catch {
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* `writes` on a `patch` or `snapshot`: digests only, capped at the patch ceiling (one write per
|
|
336
|
+
* patch at most; `snapshotFrame` caps what it sends to the same number). Absent, `null` and `[]`
|
|
337
|
+
* all name nothing, and read as the frame an older node sends.
|
|
338
|
+
*/
|
|
339
|
+
function writesOf(parsed: JsonObject, label: string): readonly string[] {
|
|
340
|
+
return list(parsed, 'writes', FRAME_LIMITS.patches, label).map((write) => {
|
|
341
|
+
if (!isWriteDigest(write)) {
|
|
342
|
+
throw fail(`${label} must hold ${WRITE_DIGEST_LENGTH}-character lowercase hex digests`);
|
|
343
|
+
}
|
|
344
|
+
return write;
|
|
345
|
+
});
|
|
279
346
|
}
|
|
280
347
|
|
|
281
348
|
function row(value: unknown): Row {
|
|
@@ -284,7 +351,7 @@ function row(value: unknown): Row {
|
|
|
284
351
|
}
|
|
285
352
|
|
|
286
353
|
function cursor(value: unknown): LiveCursor {
|
|
287
|
-
if (!
|
|
354
|
+
if (!isParsedJsonObject(value)) throw fail('cursor must be an object');
|
|
288
355
|
return {
|
|
289
356
|
qid: str(value, 'qid'),
|
|
290
357
|
lsn: str(value, 'lsn'),
|
|
@@ -297,7 +364,7 @@ function cursor(value: unknown): LiveCursor {
|
|
|
297
364
|
}
|
|
298
365
|
|
|
299
366
|
function patch(value: unknown): RowPatch {
|
|
300
|
-
if (!
|
|
367
|
+
if (!isParsedJsonObject(value)) throw fail('patch must be an object');
|
|
301
368
|
const base = {
|
|
302
369
|
op: pick(value, 'op', ['insert', 'update', 'delete'] as const),
|
|
303
370
|
id: str(value, 'id'),
|
|
@@ -310,7 +377,7 @@ function patch(value: unknown): RowPatch {
|
|
|
310
377
|
}
|
|
311
378
|
|
|
312
379
|
function target(value: unknown): SubscribeTarget {
|
|
313
|
-
if (!
|
|
380
|
+
if (!isParsedJsonObject(value)) throw fail('subscribe.target must be an object');
|
|
314
381
|
const kind = pick(value, 'kind', ['query', 'channel'] as const);
|
|
315
382
|
if (kind === 'channel') return channelTarget(value);
|
|
316
383
|
return {
|
|
@@ -324,7 +391,10 @@ function target(value: unknown): SubscribeTarget {
|
|
|
324
391
|
|
|
325
392
|
function wireError(value: unknown): WireError | null {
|
|
326
393
|
if (value === null || value === undefined) return null;
|
|
327
|
-
if (!
|
|
394
|
+
if (!isParsedJsonObject(value)) throw fail('ack.error must be an object or null');
|
|
328
395
|
const base = { code: str(value, 'code'), cause: str(value, 'cause'), fix: str(value, 'fix') };
|
|
329
|
-
|
|
396
|
+
const documented = value['docs'] === undefined ? base : { ...base, docs: str(value, 'docs') };
|
|
397
|
+
return value['retryAfterSeconds'] === undefined
|
|
398
|
+
? documented
|
|
399
|
+
: { ...documented, retryAfterSeconds: num(value, 'retryAfterSeconds') };
|
|
330
400
|
}
|
package/src/sync-upgrade.ts
CHANGED
|
@@ -2,12 +2,22 @@
|
|
|
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,
|
|
15
|
+
SocketLimitError,
|
|
8
16
|
SocketOriginRefusedError,
|
|
9
17
|
SocketUnauthenticatedError,
|
|
10
18
|
} from './errors';
|
|
19
|
+
import type { PrincipalSockets } from './principal-sockets';
|
|
20
|
+
import { principalFor } from './subscription-book';
|
|
11
21
|
import type { SyncAuthenticator, SyncGrant } from './sync-auth';
|
|
12
22
|
import { upgradeOrigin } from './sync-origin';
|
|
13
23
|
import { toWireError } from './sync-protocol';
|
|
@@ -26,11 +36,21 @@ export interface WsData {
|
|
|
26
36
|
* forever for having sent no query.
|
|
27
37
|
*/
|
|
28
38
|
readonly clientBuildId: string;
|
|
39
|
+
/**
|
|
40
|
+
* The caller's address, resolved here once (`clientAddressOf`); `null` when nothing can say.
|
|
41
|
+
* Optional only for a socket a host builds without this upgrade (a test harness).
|
|
42
|
+
*/
|
|
43
|
+
readonly clientAddress?: string | null;
|
|
29
44
|
}
|
|
30
45
|
|
|
31
46
|
/** Structural view of `Bun.serve`'s server object; keeps this module free of a Bun import. */
|
|
32
47
|
export interface UpgradeTarget {
|
|
33
48
|
upgrade(request: Request, options: { data: WsData }): boolean;
|
|
49
|
+
/**
|
|
50
|
+
* The peer's SOCKET address, as `Bun.serve`'s server answers it — what decides who is told the
|
|
51
|
+
* health detail. `null` when the host cannot say, which is told the verdict only.
|
|
52
|
+
*/
|
|
53
|
+
requestIP(request: Request): { readonly address: string } | null;
|
|
34
54
|
}
|
|
35
55
|
|
|
36
56
|
/**
|
|
@@ -49,10 +69,26 @@ export interface UpgradeDeps {
|
|
|
49
69
|
newSocketId(): string;
|
|
50
70
|
readonly authenticate?: SyncAuthenticator | undefined;
|
|
51
71
|
/**
|
|
52
|
-
* Exact origins a page may dial from
|
|
53
|
-
*
|
|
72
|
+
* Exact origins a page may dial from — `APP_URL`'s. Declared, they are the WHOLE list: the
|
|
73
|
+
* origin the node sees is `Host`-derived and is admitted only when nothing is declared
|
|
74
|
+
* (`sync-origin.ts`).
|
|
54
75
|
*/
|
|
55
76
|
readonly allowedOrigins?: readonly string[] | undefined;
|
|
77
|
+
/**
|
|
78
|
+
* Who the caller is, for keying an anonymous reader's rate limit: the deployment's own rule —
|
|
79
|
+
* `@ultimat3/http`'s `clientAddress()` under its `TRUSTED_PROXY_HOPS`, injected by the boot so
|
|
80
|
+
* this package reads no proxy header itself. Absent, the socket's address is the caller.
|
|
81
|
+
*/
|
|
82
|
+
readonly clientAddressOf?:
|
|
83
|
+
| ((request: Request, socketAddress: string | null) => string | null)
|
|
84
|
+
| undefined;
|
|
85
|
+
/** The `Host`-derived origin admitted beside a declared list too — `x dev` only. */
|
|
86
|
+
readonly admitReachedOrigin?: boolean | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* Peers told the whole health report — address classes or exact IP literals, the app's
|
|
89
|
+
* `http.healthDetailPeers`. Everyone else gets `{ state, ready, role }`.
|
|
90
|
+
*/
|
|
91
|
+
readonly healthDetailPeers: readonly string[];
|
|
56
92
|
/**
|
|
57
93
|
* Recorded BEFORE `server.upgrade`, because Bun runs `websocket.open` synchronously inside it
|
|
58
94
|
* (measured on bun 1.4.0) and `open` is where the node reads this grant to build the socket's
|
|
@@ -69,6 +105,11 @@ export interface UpgradeDeps {
|
|
|
69
105
|
* same "reserve, then release" shape `channel.ts` uses for a topic slot.
|
|
70
106
|
*/
|
|
71
107
|
onUngranted(socketId: string): void;
|
|
108
|
+
/**
|
|
109
|
+
* One principal's sockets on this node, keyed as the live-query principal is. Admitted right
|
|
110
|
+
* before `server.upgrade`; `onUngranted` and the socket's close give the slot back.
|
|
111
|
+
*/
|
|
112
|
+
readonly principals?: PrincipalSockets | undefined;
|
|
72
113
|
}
|
|
73
114
|
|
|
74
115
|
/**
|
|
@@ -83,16 +124,21 @@ export async function handleUpgrade(
|
|
|
83
124
|
const url = new URL(request.url);
|
|
84
125
|
// Health is the process's, readiness is this node's: a draining node stays healthy while it hands
|
|
85
126
|
// its sockets to the rest of the fleet.
|
|
86
|
-
if (url.pathname === '/healthz') return
|
|
127
|
+
if (url.pathname === '/healthz') return health(deps, request, server, healthzPayload());
|
|
87
128
|
if (url.pathname === '/readyz') {
|
|
88
129
|
const payload = readyzPayload({ deep: url.searchParams.get('deep') === '1' });
|
|
89
|
-
return deps.ready() ?
|
|
130
|
+
return health(deps, request, server, deps.ready() ? payload : { ...payload, status: 503 });
|
|
90
131
|
}
|
|
91
132
|
if (url.pathname !== deps.path) return new Response('not found', { status: 404 });
|
|
92
133
|
// 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
|
-
|
|
134
|
+
const origin = upgradeOrigin(request, url, deps.allowedOrigins ?? [], deps.admitReachedOrigin);
|
|
135
|
+
if (!origin.ok) {
|
|
136
|
+
const asked = { asked: request.headers.get('origin'), admitted: admittedList(deps) };
|
|
137
|
+
return wireErrorResponse(
|
|
138
|
+
403,
|
|
139
|
+
new SocketOriginRefusedError({ reason: origin.reason, ...asked }),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
96
142
|
// The count and readiness. Decided before `authenticate` so a full node costs no token service
|
|
97
143
|
// call.
|
|
98
144
|
if (deps.socketCount() >= deps.maxConnections || !deps.ready()) return shed(deps);
|
|
@@ -148,7 +194,18 @@ export async function handleUpgrade(
|
|
|
148
194
|
socketId: deps.newSocketId(),
|
|
149
195
|
// The node's own id is "not skewed until the hello says so", never "current forever".
|
|
150
196
|
clientBuildId: url.searchParams.get('build') ?? deps.buildId,
|
|
197
|
+
clientAddress: callerAddress(deps, request, server),
|
|
151
198
|
};
|
|
199
|
+
// Beside the count recheck and for its reason: no await between this and `server.upgrade`, so a
|
|
200
|
+
// herd of one principal parked in `authenticate` cannot all pass one count.
|
|
201
|
+
const principal = principalFor(grant?.actor ?? null, data.clientAddress ?? null);
|
|
202
|
+
if (deps.principals !== undefined && !deps.principals.admit(data.socketId, principal)) {
|
|
203
|
+
refund();
|
|
204
|
+
return wireErrorResponse(
|
|
205
|
+
429,
|
|
206
|
+
new SocketLimitError({ principal, limit: deps.principals.limitFor(principal) }),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
152
209
|
// Before the upgrade, never after: `server.upgrade` runs `websocket.open` synchronously and does
|
|
153
210
|
// not return until it has, so a grant recorded on the next line is one the socket was already
|
|
154
211
|
// built without.
|
|
@@ -177,6 +234,49 @@ export async function handleUpgrade(
|
|
|
177
234
|
return undefined;
|
|
178
235
|
}
|
|
179
236
|
|
|
237
|
+
/**
|
|
238
|
+
* What a proxy writes. Any one of them means the socket's address is the proxy's, not the
|
|
239
|
+
* caller's — and a proxy that writes none of them is indistinguishable from a direct peer, so a
|
|
240
|
+
* header-less proxy must not route the health paths at all.
|
|
241
|
+
*/
|
|
242
|
+
const FORWARDED_HEADERS = [
|
|
243
|
+
'forwarded',
|
|
244
|
+
'x-forwarded-for',
|
|
245
|
+
'x-forwarded-host',
|
|
246
|
+
'x-forwarded-proto',
|
|
247
|
+
'x-real-ip',
|
|
248
|
+
'via',
|
|
249
|
+
] as const;
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The STATUS is everyone's, which is all a probe reads. The body beyond the verdict is for a peer
|
|
253
|
+
* the app listed, on a DIRECT socket: these paths answer before the origin check, the accept
|
|
254
|
+
* budget and `authenticate`. This node declares no trusted proxy, so a request that says it was
|
|
255
|
+
* forwarded is never told the detail — a proxy on this box makes every caller's socket loopback.
|
|
256
|
+
*/
|
|
257
|
+
function health(
|
|
258
|
+
deps: UpgradeDeps,
|
|
259
|
+
request: Request,
|
|
260
|
+
server: UpgradeTarget,
|
|
261
|
+
payload: HealthPayload,
|
|
262
|
+
): Response {
|
|
263
|
+
const forwarded = FORWARDED_HEADERS.some((header) => request.headers.has(header));
|
|
264
|
+
const detailed =
|
|
265
|
+
!forwarded &&
|
|
266
|
+
healthPeerListed(deps.healthDetailPeers, server.requestIP(request)?.address ?? null);
|
|
267
|
+
return new Response(JSON.stringify(healthBody(payload.body, 'sync', detailed)), {
|
|
268
|
+
status: payload.status,
|
|
269
|
+
headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function callerAddress(deps: UpgradeDeps, request: Request, server: UpgradeTarget): string | null {
|
|
274
|
+
const socketAddress = server.requestIP(request)?.address ?? null;
|
|
275
|
+
return deps.clientAddressOf === undefined
|
|
276
|
+
? socketAddress
|
|
277
|
+
: deps.clientAddressOf(request, socketAddress);
|
|
278
|
+
}
|
|
279
|
+
|
|
180
280
|
/** Load shedding with a delay attached: refusing without one just moves the herd next door. */
|
|
181
281
|
function shed(deps: UpgradeDeps): Response {
|
|
182
282
|
return new Response('retry', {
|
|
@@ -185,6 +285,13 @@ function shed(deps: UpgradeDeps): Response {
|
|
|
185
285
|
});
|
|
186
286
|
}
|
|
187
287
|
|
|
288
|
+
/** What the refusal says this node admits: the declared list, and under `x dev` the reached-on origin. */
|
|
289
|
+
function admittedList(deps: UpgradeDeps): readonly string[] {
|
|
290
|
+
const declared = deps.allowedOrigins ?? [];
|
|
291
|
+
if (declared.length === 0 || deps.admitReachedOrigin !== true) return declared;
|
|
292
|
+
return [...declared, 'the origin it was reached on'];
|
|
293
|
+
}
|
|
294
|
+
|
|
188
295
|
function wireErrorResponse(status: number, error: unknown): Response {
|
|
189
296
|
return json({ status, body: { error: toWireError(error) } });
|
|
190
297
|
}
|
package/src/type-pins.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import type { AsyncState, Row } from '@ultimat3/core';
|
|
7
7
|
import type { LiveHandle, Unsubscribe } from './client-contract';
|
|
8
8
|
import type { QueryAccessor, QueryRef } from './use-query';
|
|
9
|
-
import type { RecordAccessor } from './use-record';
|
|
9
|
+
import type { RecordAccessor, useRecords } from './use-record';
|
|
10
10
|
|
|
11
11
|
/** Fails to compile when `T` is anything but `true`. The whole mechanism. */
|
|
12
12
|
type Assert<T extends true> = T;
|
|
@@ -31,6 +31,14 @@ export type _RecordAnswersAsyncState = Assert<
|
|
|
31
31
|
Equals<ReturnType<RecordAccessor<FeedRow>>, AsyncState<FeedRow | undefined>>
|
|
32
32
|
>;
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* `useRecords` takes its selection EXPLICITLY — a key list, or `{}` / `{ where, order }` for the
|
|
36
|
+
* whole type. A default turned a call that forgot its keys into "every record of the type".
|
|
37
|
+
*/
|
|
38
|
+
export type _UseRecordsSelectionRequired = Assert<
|
|
39
|
+
Equals<Parameters<typeof useRecords>['length'], 2>
|
|
40
|
+
>;
|
|
41
|
+
|
|
34
42
|
/**
|
|
35
43
|
* A query ref carries no server field: a `Query` VALUE in an island drags its whole read path into
|
|
36
44
|
* the bundle, so the ref is a name and two declared facts. A `sql` key is refused.
|
package/src/use-channel.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// A declared channel, held by a component. Every holder of one topic on the page shares ONE
|
|
2
|
-
// membership on the page socket
|
|
3
|
-
// `useQuery`), and only `events`
|
|
2
|
+
// membership on the page socket — the page runtime's, reached off the page, never bundled here;
|
|
3
|
+
// its `records` land in the store (read them with `useRecord` / `useQuery`), and only `events`
|
|
4
|
+
// and presence reach the handlers given here.
|
|
4
5
|
|
|
5
6
|
import type { ChannelHandlers, ChannelRef, ChannelState } from './client-channels';
|
|
6
|
-
import {
|
|
7
|
+
import { installedPage } from './page-store';
|
|
7
8
|
import { isServerRender, signalFor } from './reactivity';
|
|
8
9
|
import type { PresenceMember } from './sync-protocol';
|
|
9
10
|
|
|
@@ -32,7 +33,9 @@ export function useChannel<K extends string>(
|
|
|
32
33
|
[Symbol.dispose]: nothing,
|
|
33
34
|
});
|
|
34
35
|
}
|
|
35
|
-
const membership =
|
|
36
|
+
const membership = installedPage('useChannel')
|
|
37
|
+
.services.socket('useChannel')
|
|
38
|
+
.holdChannel(declared, params, handlers);
|
|
36
39
|
const [version, setVersion] = signal(0);
|
|
37
40
|
const off = membership.onChange(() => setVersion(version() + 1));
|
|
38
41
|
const read = (): ChannelState => {
|
|
@@ -70,16 +73,18 @@ export function usePresence<K extends string>(
|
|
|
70
73
|
}
|
|
71
74
|
const [version, setVersion] = signal(0);
|
|
72
75
|
let members = new Map<string, PresenceMember>();
|
|
73
|
-
const membership =
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (event.presence === '
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
76
|
+
const membership = installedPage('usePresence')
|
|
77
|
+
.services.socket('usePresence')
|
|
78
|
+
.holdChannel(declared, params, {
|
|
79
|
+
onPresence: (event) => {
|
|
80
|
+
if (event.presence === 'sync') members = new Map();
|
|
81
|
+
for (const member of event.members) {
|
|
82
|
+
if (event.presence === 'leave') members.delete(member.id);
|
|
83
|
+
else members.set(member.id, member);
|
|
84
|
+
}
|
|
85
|
+
setVersion(version() + 1);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
83
88
|
const read = (): readonly PresenceMember[] => {
|
|
84
89
|
version();
|
|
85
90
|
return [...members.values()];
|
package/src/use-connection.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// The page socket, as four getters: whether it is up, when it redials, and whether a newer build
|
|
2
2
|
// is live. Getters, never a snapshot, so a read inside a tracking scope stays live. Asking opens
|
|
3
|
-
// the socket — this hook IS a question about it.
|
|
3
|
+
// the socket — this hook IS a question about it — and the socket is the page runtime's.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { installedPage } from './page-store';
|
|
6
6
|
import { isServerRender, signalFor } from './reactivity';
|
|
7
7
|
|
|
8
8
|
export interface Connection extends Disposable {
|
|
@@ -33,7 +33,7 @@ const SERVER_RENDER: Connection = Object.freeze({
|
|
|
33
33
|
export function useConnection(): Connection {
|
|
34
34
|
const signal = signalFor('useConnection');
|
|
35
35
|
if (isServerRender()) return SERVER_RENDER;
|
|
36
|
-
const client =
|
|
36
|
+
const client = installedPage('useConnection').services.socket('useConnection');
|
|
37
37
|
const [version, setVersion] = signal(0);
|
|
38
38
|
const release = client.onStatus(() => setVersion(version() + 1));
|
|
39
39
|
return {
|
package/src/use-mutation.ts
CHANGED
|
@@ -1,20 +1,14 @@
|
|
|
1
1
|
// A client write, the one way: the mutator's optimistic twin into the store's OVERLAY (visible to
|
|
2
|
-
// every island before this returns), then its action over HTTP through core's one transport
|
|
3
|
-
// an idempotency key. The answer's records are adopted
|
|
4
|
-
// a refusal takes the overlay back.
|
|
2
|
+
// every island before this returns), then its action over HTTP through core's one transport — the
|
|
3
|
+
// page runtime's, called off the page — with an idempotency key. The answer's records are adopted
|
|
4
|
+
// before the overlay goes — no flicker — and a refusal takes the overlay back. No socket writes.
|
|
5
5
|
|
|
6
6
|
import type { ConflictPolicy } from '@ultimat3/core/page';
|
|
7
|
-
import {
|
|
8
|
-
actionPath,
|
|
9
|
-
clientTransport,
|
|
10
|
-
isSuperseded,
|
|
11
|
-
isUltimateError,
|
|
12
|
-
uuid,
|
|
13
|
-
} from '@ultimat3/core/page';
|
|
7
|
+
import { actionPath, isSuperseded, isUltimateError, pageClient, uuidV7 } from '@ultimat3/core/page';
|
|
14
8
|
import type { JsonValue } from './json';
|
|
15
9
|
import { type OutboxHandle, peekOutbox } from './outbox-slot';
|
|
16
|
-
import { ServerRenderLiveError } from './page-errors';
|
|
17
|
-
import { type PageWrites, pageRealtime } from './page-store';
|
|
10
|
+
import { OfflineQueueAbandonedError, ServerRenderLiveError } from './page-errors';
|
|
11
|
+
import { installedPage, type PageWrites, pageRealtime } from './page-store';
|
|
18
12
|
import { isServerRender, signalFor } from './reactivity';
|
|
19
13
|
import { carriedBy } from './record-store';
|
|
20
14
|
|
|
@@ -92,7 +86,7 @@ function watch(hook: string, writes: PageWrites | undefined): [() => number, ()
|
|
|
92
86
|
function restoreOverlays(mutator: MutatorLike): void {
|
|
93
87
|
const local = mutator.local;
|
|
94
88
|
if (local === undefined) return;
|
|
95
|
-
const page =
|
|
89
|
+
const page = installedPage('useMutation');
|
|
96
90
|
// The boot opens the outbox; after it, so a queue the previous load left is on disk to read.
|
|
97
91
|
void page.booted.then(async () => {
|
|
98
92
|
const outbox = peekOutbox();
|
|
@@ -126,8 +120,9 @@ export function useMutation(mutator: MutatorLike): Mutate {
|
|
|
126
120
|
const [version, release] = watch('useMutation', writes);
|
|
127
121
|
const call = async (input: JsonValue): Promise<unknown> => {
|
|
128
122
|
if (writes === undefined) throw new ServerRenderLiveError({ operation: 'useMutation()' });
|
|
129
|
-
const page =
|
|
130
|
-
const
|
|
123
|
+
const page = installedPage('useMutation');
|
|
124
|
+
const issued = pageClient().scope.epoch;
|
|
125
|
+
const key = `${mutator.name}:${uuidV7()}`;
|
|
131
126
|
const local = mutator.local;
|
|
132
127
|
// Called back through the mutator: `local` may be a method, and an unbound one loses `this`.
|
|
133
128
|
if (local !== undefined) {
|
|
@@ -138,9 +133,31 @@ export function useMutation(mutator: MutatorLike): Mutate {
|
|
|
138
133
|
// them over HTTP — a like queued offline and the unlike made once the network was back could
|
|
139
134
|
// otherwise land swapped. The replay sends the queue in order, this write last, under its key.
|
|
140
135
|
const queued = peekOutbox();
|
|
136
|
+
// The outbox may REFUSE: its queue's principal left mid-write (`X_OFFLINE_QUEUE_ABANDONED`),
|
|
137
|
+
// or the disk would not take it. Nothing will ever send this write then, so its twin comes
|
|
138
|
+
// off the screen and the caller is told — a rejected `mutate`, by code.
|
|
139
|
+
//
|
|
140
|
+
// And the hook has an await of its own — the POST — so WHOSE write this is was decided at the
|
|
141
|
+
// call (`issued`): asked for after a principal change, the outbox is the next principal's, and
|
|
142
|
+
// a write handed to it then would be queued, and sent, as them.
|
|
143
|
+
const enqueue = async (outbox: OutboxHandle): Promise<void> => {
|
|
144
|
+
try {
|
|
145
|
+
if (pageClient().scope.epoch !== issued) {
|
|
146
|
+
throw new OfflineQueueAbandonedError({ name: mutator.name });
|
|
147
|
+
}
|
|
148
|
+
await outbox.enqueue({ key, name: mutator.name, input });
|
|
149
|
+
} catch (error) {
|
|
150
|
+
page.store.drop(key);
|
|
151
|
+
// A principal change is nobody's failure to report; a disk that refused the write is.
|
|
152
|
+
if (!isUltimateError(error) || error.code !== 'X_OFFLINE_QUEUE_ABANDONED') {
|
|
153
|
+
writes.failed += 1;
|
|
154
|
+
}
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
141
158
|
if (queued !== undefined && queued.pending().length > 0) {
|
|
142
159
|
try {
|
|
143
|
-
await
|
|
160
|
+
await enqueue(queued);
|
|
144
161
|
void queued.replay().catch(() => undefined);
|
|
145
162
|
return undefined;
|
|
146
163
|
} finally {
|
|
@@ -151,7 +168,7 @@ export function useMutation(mutator: MutatorLike): Mutate {
|
|
|
151
168
|
/** The records the answer carried, `type:key` — what the overlay may be settled against. */
|
|
152
169
|
const carried = new Set<string>();
|
|
153
170
|
try {
|
|
154
|
-
output = await
|
|
171
|
+
output = await page.services.send({
|
|
155
172
|
method: 'POST',
|
|
156
173
|
url: actionPath(mutator.name),
|
|
157
174
|
body: input,
|
|
@@ -160,13 +177,17 @@ export function useMutation(mutator: MutatorLike): Mutate {
|
|
|
160
177
|
});
|
|
161
178
|
} catch (error) {
|
|
162
179
|
const failure = transportFailure(error);
|
|
163
|
-
// No response at all
|
|
164
|
-
//
|
|
180
|
+
// No response at all — which is NOT "the write did not land": the POST may have committed
|
|
181
|
+
// and lost its answer. The write is the outbox's now, under the SAME idempotency key, and
|
|
182
|
+
// that replay is safe only because `mutator()` refuses a declaration without
|
|
183
|
+
// `idempotent: true` (`X_MUTATOR_NOT_IDEMPOTENT`): the server reads the key and answers the
|
|
184
|
+
// replay from its idempotency store, so the handler never runs twice. The overlay stays on
|
|
185
|
+
// screen until the replay settles or refuses it. Only a page the boot opened
|
|
165
186
|
// an outbox on can promise that; with none (no boot, so nothing on this page persists) the
|
|
166
187
|
// write is refused like any other, never held in a memory queue a reload would silently lose.
|
|
167
188
|
const outbox = failure === 'network' ? await bootedOutbox(page) : undefined;
|
|
168
189
|
if (outbox !== undefined) {
|
|
169
|
-
await
|
|
190
|
+
await enqueue(outbox);
|
|
170
191
|
return undefined;
|
|
171
192
|
}
|
|
172
193
|
if (failure === 'body') {
|