@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.
Files changed (74) hide show
  1. package/CLAUDE.md +89 -102
  2. package/README.md +140 -20
  3. package/package.json +4 -4
  4. package/src/advisory-lock.ts +57 -0
  5. package/src/boot.ts +12 -7
  6. package/src/change-buffer.ts +96 -41
  7. package/src/changefeed-env.ts +2 -2
  8. package/src/changefeed.ts +39 -1
  9. package/src/channel-authz.ts +43 -12
  10. package/src/channel-latch.ts +52 -0
  11. package/src/channel-logs.ts +34 -4
  12. package/src/channel-records.ts +33 -2
  13. package/src/channel-sids.ts +37 -0
  14. package/src/channel.ts +143 -44
  15. package/src/client-channels.ts +10 -1
  16. package/src/client-contract.ts +4 -1
  17. package/src/client-frames.ts +34 -4
  18. package/src/client-heartbeat.ts +39 -9
  19. package/src/client.ts +1 -0
  20. package/src/cursor.ts +11 -0
  21. package/src/errors.ts +70 -19
  22. package/src/fanout.ts +13 -0
  23. package/src/index.ts +1 -1
  24. package/src/live-contract.ts +6 -2
  25. package/src/live-definition.ts +46 -11
  26. package/src/live-fanout.ts +70 -11
  27. package/src/live-floor.ts +35 -0
  28. package/src/live-query-options.ts +38 -0
  29. package/src/live-query.ts +119 -91
  30. package/src/live-refusal.ts +21 -0
  31. package/src/live-replicator.ts +6 -6
  32. package/src/live-resume.ts +74 -0
  33. package/src/live-tenant.ts +25 -0
  34. package/src/nats-client.ts +11 -2
  35. package/src/nats-open.ts +9 -0
  36. package/src/nats-transport.ts +23 -1
  37. package/src/offline-queue.ts +48 -5
  38. package/src/page-errors.ts +17 -0
  39. package/src/page-outbox.ts +121 -50
  40. package/src/pg-advisory-lock.ts +45 -1
  41. package/src/pg-auth.ts +9 -4
  42. package/src/pg-bytes.ts +4 -1
  43. package/src/pg-connection.ts +119 -21
  44. package/src/pg-entity-row.ts +27 -2
  45. package/src/pg-identity-tables.ts +25 -0
  46. package/src/pg-preflight.ts +50 -4
  47. package/src/pg-replication-row.ts +82 -0
  48. package/src/pg-replication.ts +44 -45
  49. package/src/pg-socket.ts +6 -4
  50. package/src/pg-tls.ts +18 -0
  51. package/src/pg-wire.ts +5 -3
  52. package/src/pgoutput.ts +19 -9
  53. package/src/presence.ts +85 -8
  54. package/src/query-window.ts +28 -7
  55. package/src/record-persister.ts +7 -2
  56. package/src/replication-errors.ts +8 -4
  57. package/src/replicator-envelope.ts +110 -0
  58. package/src/replicator-row.ts +75 -0
  59. package/src/replicator.ts +319 -186
  60. package/src/server.ts +11 -7
  61. package/src/socket-engine.ts +14 -2
  62. package/src/socket-idle.ts +3 -1
  63. package/src/socket-routes.ts +8 -3
  64. package/src/subscriber-gate.ts +11 -1
  65. package/src/sync-auth.ts +47 -2
  66. package/src/sync-frames.ts +73 -20
  67. package/src/sync-node-bounds.ts +15 -0
  68. package/src/sync-node-contract.ts +20 -2
  69. package/src/sync-node.ts +92 -17
  70. package/src/sync-origin.ts +26 -11
  71. package/src/sync-protocol.ts +13 -3
  72. package/src/sync-upgrade.ts +76 -8
  73. package/src/use-mutation.ts +33 -5
  74. package/src/wire-channel.ts +13 -4
@@ -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
- return {
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',
@@ -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 { healthzPayload, readyzPayload, reportError } from '@ultimat3/core';
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 besides the node's own host name — `APP_URL`'s, when the
53
- * page is served on another host than the node (`sync-origin.ts`).
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 json(healthzPayload());
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() ? json(payload) : json({ status: 503, body: payload.body });
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
- return wireErrorResponse(403, new SocketOriginRefusedError({ reason: origin.reason }));
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
  }
@@ -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 queued.enqueue({ key, name: mutator.name, input });
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: the write is the outbox's now, under the SAME idempotency key, and its
164
- // overlay stays on screen until the replay settles or refuses it. Only a page the boot opened
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 outbox.enqueue({ key, name: mutator.name, input });
197
+ await enqueue(outbox);
170
198
  return undefined;
171
199
  }
172
200
  if (failure === 'body') {
@@ -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: Record<string, string> = {};
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: Record<string, Readonly<Record<string, Row>>> = {};
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: Record<string, Row> = {};
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: Record<string, readonly string[]> = {};
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
+ }