statewire 0.8.0 → 0.9.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 (95) hide show
  1. package/README.md +2 -2
  2. package/dist/client/connection.d.ts +23 -19
  3. package/dist/client/connection.d.ts.map +1 -1
  4. package/dist/client/connection.js +143 -103
  5. package/dist/client/connection.js.map +1 -1
  6. package/dist/client/core.d.ts +30 -3
  7. package/dist/client/core.d.ts.map +1 -1
  8. package/dist/client/core.js +222 -259
  9. package/dist/client/core.js.map +1 -1
  10. package/dist/client/session.d.ts +34 -39
  11. package/dist/client/session.d.ts.map +1 -1
  12. package/dist/client/session.js +380 -373
  13. package/dist/client/session.js.map +1 -1
  14. package/dist/client/sessionStore.d.ts +1 -0
  15. package/dist/client/sessionStore.d.ts.map +1 -1
  16. package/dist/client/sessionStore.js.map +1 -1
  17. package/dist/client/timing.d.ts +1 -2
  18. package/dist/client/timing.d.ts.map +1 -1
  19. package/dist/client/timing.js +1 -5
  20. package/dist/client/timing.js.map +1 -1
  21. package/dist/host/draft.d.ts +2 -5
  22. package/dist/host/draft.d.ts.map +1 -1
  23. package/dist/host/draft.js.map +1 -1
  24. package/dist/host/host.d.ts +5 -8
  25. package/dist/host/host.d.ts.map +1 -1
  26. package/dist/host/host.js +23 -32
  27. package/dist/host/host.js.map +1 -1
  28. package/dist/host/socket.d.ts +3 -2
  29. package/dist/host/socket.d.ts.map +1 -1
  30. package/dist/host/socket.js +484 -424
  31. package/dist/host/socket.js.map +1 -1
  32. package/dist/host-internal.d.ts +4 -0
  33. package/dist/host-internal.d.ts.map +1 -1
  34. package/dist/host.d.ts +10 -10
  35. package/dist/host.d.ts.map +1 -1
  36. package/dist/host.js +2 -2
  37. package/dist/index.d.ts +1 -2
  38. package/dist/index.js +1 -2
  39. package/dist/statewire.d.ts +29 -24
  40. package/dist/statewire.d.ts.map +1 -1
  41. package/dist/statewire.js +8 -2
  42. package/dist/statewire.js.map +1 -1
  43. package/dist/transports/fake-sse.d.ts +1 -1
  44. package/dist/transports/fake-sse.d.ts.map +1 -1
  45. package/dist/transports/fake-sse.js +5 -2
  46. package/dist/transports/fake-sse.js.map +1 -1
  47. package/dist/transports/fixtures.d.ts +1 -2
  48. package/dist/transports/fixtures.d.ts.map +1 -1
  49. package/dist/transports/fixtures.js +5 -7
  50. package/dist/transports/fixtures.js.map +1 -1
  51. package/dist/transports/remote.d.ts +2 -1
  52. package/dist/transports/remote.d.ts.map +1 -1
  53. package/dist/transports/remote.js +10 -4
  54. package/dist/transports/remote.js.map +1 -1
  55. package/dist/transports/sse.d.ts +32 -46
  56. package/dist/transports/sse.d.ts.map +1 -1
  57. package/dist/transports/sse.js +56 -59
  58. package/dist/transports/sse.js.map +1 -1
  59. package/dist/transports/testing.d.ts +7 -5
  60. package/dist/transports/testing.d.ts.map +1 -1
  61. package/dist/transports/testing.js +41 -14
  62. package/dist/transports/testing.js.map +1 -1
  63. package/dist/transports/ws.d.ts +2 -3
  64. package/dist/transports/ws.d.ts.map +1 -1
  65. package/dist/transports/ws.js +25 -80
  66. package/dist/transports/ws.js.map +1 -1
  67. package/dist/types.d.ts +8 -8
  68. package/dist/types.d.ts.map +1 -1
  69. package/dist/types.js +3 -4
  70. package/dist/types.js.map +1 -1
  71. package/dist/wire/ops.d.ts +1 -5
  72. package/dist/wire/ops.d.ts.map +1 -1
  73. package/dist/wire/ops.js +1 -4
  74. package/dist/wire/ops.js.map +1 -1
  75. package/package.json +1 -1
  76. package/src/client/connection.ts +175 -128
  77. package/src/client/core.ts +287 -276
  78. package/src/client/session.ts +474 -480
  79. package/src/client/sessionStore.ts +1 -0
  80. package/src/client/timing.ts +0 -6
  81. package/src/host/draft.ts +1 -3
  82. package/src/host/host.ts +41 -58
  83. package/src/host/socket.ts +653 -448
  84. package/src/host-internal.ts +4 -0
  85. package/src/host.ts +13 -10
  86. package/src/index.ts +0 -1
  87. package/src/statewire.ts +38 -32
  88. package/src/transports/fake-sse.ts +2 -1
  89. package/src/transports/fixtures.ts +4 -9
  90. package/src/transports/remote.ts +12 -5
  91. package/src/transports/sse.ts +99 -115
  92. package/src/transports/testing.ts +47 -19
  93. package/src/transports/ws.ts +44 -115
  94. package/src/types.ts +15 -14
  95. package/src/wire/ops.ts +9 -16
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import { createTapRoot, flushTapSync, useResource } from "@assistant-ui/tap";
3
3
  import type { ResourceElement } from "@assistant-ui/tap";
4
- import { getCommandsHost, getEmitHost, getStateHost } from "./host";
4
+ import { getCommandsHost, getStateHost } from "./host";
5
5
  import type { CommandsHost, CommandsOutcome } from "./host";
6
6
  import { StatewireSendError } from "../types";
7
7
  import type { Statewire } from "../types";
@@ -9,17 +9,14 @@ import type { StatewireHost } from "../host";
9
9
  import type { StatewireHostInternal } from "../host-internal";
10
10
  import { encodeValue, reviveValue } from "../wire/meta";
11
11
  import { createMacrotaskPoster } from "../util/macrotask";
12
- import { randomId } from "../util/randomId";
13
12
  import type { StatewireMetaEntry } from "../wire/meta";
14
13
  import type { StatewirePath } from "../wire/ops";
15
- import type {
16
- StatewireEnvelope,
17
- StatewireResponse,
18
- } from "../client/connection";
14
+ import type { StatewireCmd, StatewireEnvelope } from "../client/connection";
19
15
 
20
16
  const CLIENT_ID_RE = /^[A-Za-z0-9._~-]{1,256}$/;
21
17
  const RESULT_TTL_MS = 30_000;
22
18
  const RESULT_LIMIT = 256;
19
+ const CLIENT_RECORD_LIMIT = 1024;
23
20
 
24
21
  type WireOp = {
25
22
  op: string;
@@ -31,17 +28,18 @@ type WireOp = {
31
28
  type Admission = {
32
29
  seq: number;
33
30
  method: string;
34
- acked: boolean;
35
- pendingSent: boolean;
31
+ applied: boolean;
32
+ appliedSent: boolean;
36
33
  settled: boolean;
37
- release: () => void;
38
34
  };
39
35
 
40
36
  type ClientRec = {
41
37
  id: string;
42
38
  lastSeq: number;
39
+ ctxSeq: number;
40
+ touched: number;
43
41
  context: Record<string, unknown> | null;
44
- results: Map<number, { ts: number; res: StatewireResponse }>;
42
+ results: Map<number, { ts: number; cmd: StatewireCmd }>;
45
43
  inflight: Map<number, Admission>;
46
44
  };
47
45
 
@@ -49,69 +47,140 @@ type Conn = {
49
47
  clientId: string;
50
48
  socket: StatewireHostInternal.Socket;
51
49
  open: boolean;
50
+ sawFrame: boolean;
52
51
  lastSentAt: number;
53
52
  };
54
53
 
55
- type Frame = {
56
- method?: string;
54
+ // ─── frame parsing ──────────────────────────────────────
55
+
56
+ const violation = (reason: string): never => {
57
+ throw Object.assign(new Error(reason), { name: "StatewireViolation" });
58
+ };
59
+
60
+ const violationReason = (error: unknown): string | null =>
61
+ error instanceof Error && error.name === "StatewireViolation"
62
+ ? error.message
63
+ : null;
64
+
65
+ type CtxStatement = { seq: number; value: Record<string, unknown> };
66
+ type CmdStatement = {
57
67
  seq: number;
58
- params: unknown[];
59
- context?: unknown;
68
+ method: string;
69
+ params: unknown;
70
+ meta?: unknown;
60
71
  };
61
72
 
62
- const parseFrame = (raw: string): Frame | null => {
63
- let frame: unknown;
64
- try {
65
- frame = JSON.parse(raw);
66
- } catch {
67
- return null;
68
- }
69
- if (typeof frame !== "object" || frame === null || Array.isArray(frame)) {
70
- return null;
71
- }
72
- const { method, seq, params, meta, context } = frame as Record<
73
- string,
74
- unknown
75
- >;
76
- if (method === undefined && context === undefined) return null;
77
- if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 1) return null;
78
- if (method === undefined) {
79
- return { seq, params: [], ...(context !== undefined && { context }) };
80
- }
81
- if (typeof method !== "string") return null;
82
- if (!Array.isArray(params)) return null;
83
- if (meta !== undefined) {
84
- try {
85
- reviveValue(params, meta);
86
- } catch {
87
- return null;
73
+ type ParsedFrame = {
74
+ headers: Record<string, string> | null;
75
+ ctx: CtxStatement[];
76
+ cmd: CmdStatement[];
77
+ };
78
+
79
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
80
+ typeof value === "object" && value !== null && !Array.isArray(value);
81
+
82
+ const parseLane = (value: unknown): Record<string, unknown>[] => {
83
+ if (!Array.isArray(value)) return violation("malformed frame");
84
+ if (value.length === 0) return violation("empty lane");
85
+ const statements: Record<string, unknown>[] = [];
86
+ let prev: number | null = null;
87
+ for (const statement of value) {
88
+ if (!isPlainObject(statement)) return violation("malformed statement");
89
+ const seq = statement["seq"];
90
+ if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 1) {
91
+ return violation("malformed statement");
88
92
  }
93
+ if (prev !== null && seq !== prev + 1) return violation("malformed frame");
94
+ prev = seq;
95
+ statements.push(statement);
89
96
  }
90
- return { method, seq, params, ...(context !== undefined && { context }) };
97
+ return statements;
91
98
  };
92
99
 
93
- /** v1 context submissions are exactly one root replace with an object value. */
94
- const contextValue = (context: unknown): Record<string, unknown> => {
95
- if (!Array.isArray(context) || context.length !== 1) {
100
+ /** v1 ctx statements state the context whole: one root replace with an object value. */
101
+ const contextValue = (ops: unknown): Record<string, unknown> => {
102
+ if (!Array.isArray(ops) || ops.length !== 1) {
96
103
  throw new Error("context must be a single-op array");
97
104
  }
98
- const op = context[0] as Record<string, unknown> | null;
105
+ const op = ops[0] as Record<string, unknown> | null;
99
106
  if (
100
- typeof op !== "object" ||
101
- op === null ||
107
+ !isPlainObject(op) ||
102
108
  op["op"] !== "replace" ||
103
109
  !Array.isArray(op["path"]) ||
104
110
  op["path"].length !== 0
105
111
  ) {
106
112
  throw new Error("context op must be a root replace");
107
113
  }
108
- const value = op["value"];
109
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
114
+ const value =
115
+ op["meta"] !== undefined
116
+ ? reviveValue(op["value"], op["meta"])
117
+ : op["value"];
118
+ if (!isPlainObject(value)) {
110
119
  throw new Error("context value must be an object");
111
120
  }
112
- return value as Record<string, unknown>;
121
+ return value;
122
+ };
123
+
124
+ const parseFrame = (raw: string, first: boolean): ParsedFrame => {
125
+ let frame: unknown;
126
+ try {
127
+ frame = JSON.parse(raw);
128
+ } catch {
129
+ return violation("malformed frame");
130
+ }
131
+ if (!isPlainObject(frame)) return violation("malformed frame");
132
+ if (
133
+ frame["headers"] === undefined &&
134
+ frame["ctx"] === undefined &&
135
+ frame["cmd"] === undefined
136
+ ) {
137
+ return violation("malformed frame");
138
+ }
139
+ let headers: Record<string, string> | null = null;
140
+ if (frame["headers"] !== undefined) {
141
+ if (!first) return violation("mid-session headers");
142
+ const raw = frame["headers"];
143
+ if (
144
+ !isPlainObject(raw) ||
145
+ Object.values(raw).some((value) => typeof value !== "string")
146
+ ) {
147
+ return violation("malformed headers");
148
+ }
149
+ headers = raw as Record<string, string>;
150
+ }
151
+ const ctx: CtxStatement[] = [];
152
+ if (frame["ctx"] !== undefined) {
153
+ for (const statement of parseLane(frame["ctx"])) {
154
+ try {
155
+ ctx.push({
156
+ seq: statement["seq"] as number,
157
+ value: contextValue(statement["ops"]),
158
+ });
159
+ } catch {
160
+ return violation("invalid-context");
161
+ }
162
+ }
163
+ }
164
+ const cmd: CmdStatement[] = [];
165
+ if (frame["cmd"] !== undefined) {
166
+ for (const statement of parseLane(frame["cmd"])) {
167
+ const method = statement["method"];
168
+ if (typeof method !== "string" || !("params" in statement)) {
169
+ return violation("malformed statement");
170
+ }
171
+ cmd.push({
172
+ seq: statement["seq"] as number,
173
+ method,
174
+ params: statement["params"],
175
+ ...(statement["meta"] !== undefined && { meta: statement["meta"] }),
176
+ });
177
+ }
178
+ }
179
+ return { headers, ctx, cmd };
113
180
  };
114
181
 
182
+ // ─── verdict encoding ───────────────────────────────────
183
+
115
184
  const encodePayload = (
116
185
  payload: unknown,
117
186
  ): { payload?: unknown; meta?: StatewireMetaEntry[] } => {
@@ -120,47 +189,43 @@ const encodePayload = (
120
189
  return { payload: value, ...(meta !== undefined && { meta }) };
121
190
  };
122
191
 
123
- const responseOf = (
124
- adm: Admission,
125
- outcome: CommandsOutcome,
126
- ): StatewireResponse => {
192
+ const cmdOf = (adm: Admission, outcome: CommandsOutcome): StatewireCmd => {
127
193
  if (outcome.type === "accepted") {
128
- return {
129
- seq: adm.seq,
130
- type: "accepted",
131
- ...encodePayload(outcome.payload),
132
- };
194
+ return { seq: adm.seq, type: "result", ...encodePayload(outcome.payload) };
133
195
  }
134
196
  const failure =
135
197
  outcome.error instanceof StatewireSendError ? outcome.error.failure : null;
136
198
  switch (failure?.fate) {
137
- case "unknown-command":
199
+ case "failed":
138
200
  return {
139
201
  seq: adm.seq,
140
- type: "rejected",
141
- code: "unknown-command",
142
- message: `unknown method: '${adm.method}'`,
202
+ type: "failed",
203
+ ...(failure.message !== undefined && { message: failure.message }),
143
204
  };
144
- case "invalid-params":
145
- return {
146
- seq: adm.seq,
147
- type: "rejected",
148
- code: "invalid-params",
149
- message: failure.message,
150
- };
151
- case "crashed":
152
- return { seq: adm.seq, type: "crashed" };
153
- case "rejected":
205
+ case "rejected": {
206
+ if (failure.code !== undefined) {
207
+ const message =
208
+ failure.code === "unknown-command"
209
+ ? `unknown method: '${adm.method}'`
210
+ : failure.message;
211
+ return {
212
+ seq: adm.seq,
213
+ type: "rejected",
214
+ code: failure.code,
215
+ ...(message !== undefined && { message }),
216
+ };
217
+ }
154
218
  return {
155
219
  seq: adm.seq,
156
220
  type: "rejected",
157
- message: failure.message,
221
+ ...(failure.message !== undefined && { message: failure.message }),
158
222
  ...encodePayload(failure.payload),
159
223
  };
224
+ }
160
225
  default:
161
226
  return {
162
227
  seq: adm.seq,
163
- type: "rejected",
228
+ type: "failed",
164
229
  message:
165
230
  outcome.error instanceof Error
166
231
  ? outcome.error.message
@@ -169,425 +234,564 @@ const responseOf = (
169
234
  }
170
235
  };
171
236
 
172
- const createSocketHost = (
173
- options?: StatewireHostInternal.SocketHostOptions,
174
- ) => {
175
- const heartbeatMs = options?.heartbeatIntervalMs ?? 15_000;
176
-
177
- const clients = new Map<string, ClientRec>();
178
- const conns = new Set<Conn>();
179
- let pendingOps: WireOp[] = [];
180
- let dirtyAcks = new Set<string>();
181
- let pendingRes = new Map<string, StatewireResponse[]>();
182
- let inbox: Promise<void> = Promise.resolve();
183
- let finished: StatewireHost.Fin | null = null;
184
- let disposed = false;
185
- let flushScheduled = false;
186
- let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
187
-
188
- let stateHost: StatewireHost.StateHost | null = null;
189
- let commandsHost: CommandsHost | null = null;
190
- let emitHost: ReturnType<typeof getEmitHost> | null = null;
191
- let current: Statewire<unknown> | null = null;
237
+ // ─── host ───────────────────────────────────────────────
238
+
239
+ type Host = {
240
+ heartbeatMs: number;
241
+ clientRecordLimit: number;
242
+ finOnIdle: boolean;
243
+ clients: Map<string, ClientRec>;
244
+ conns: Set<Conn>;
245
+ pendingOps: WireOp[];
246
+ pendingApplied: Map<string, number[]>;
247
+ pendingCmds: Map<string, StatewireCmd[]>;
248
+ idleWire: boolean;
249
+ finished: StatewireHost.Fin | null;
250
+ disposed: boolean;
251
+ flushScheduled: boolean;
252
+ heartbeatTimer: ReturnType<typeof setInterval> | null;
253
+ stateHost: StatewireHost.StateHost | null;
254
+ commandsHost: CommandsHost | null;
255
+ current: Statewire<unknown> | null;
256
+ post: () => void;
257
+ connect: (
258
+ clientId: string,
259
+ socket: StatewireHostInternal.Socket,
260
+ ) => StatewireHostInternal.SocketConnection;
261
+ finish: (fin: StatewireHost.Fin) => void;
262
+ };
192
263
 
193
- const post = createMacrotaskPoster(() => {
194
- flushScheduled = false;
195
- flush();
196
- });
197
- const scheduleFlush = () => {
198
- if (flushScheduled || disposed) return;
199
- flushScheduled = true;
200
- post();
201
- };
264
+ const scheduleFlush = (h: Host) => {
265
+ if (h.flushScheduled || h.disposed) return;
266
+ h.flushScheduled = true;
267
+ h.post();
268
+ };
202
269
 
203
- const recordOp = (op: {
270
+ const recordOp = (
271
+ h: Host,
272
+ op: {
204
273
  op: string;
205
274
  path?: StatewirePath;
206
275
  value?: unknown;
207
- }) => {
208
- if (op.op === "replace" || op.op === "add" || op.op === "event") {
209
- const { value, meta } = encodeValue(op.value);
210
- pendingOps.push({ ...op, value, ...(meta !== undefined && { meta }) });
211
- } else {
212
- pendingOps.push(op);
213
- }
214
- scheduleFlush();
215
- };
276
+ },
277
+ ) => {
278
+ if (op.op === "replace" || op.op === "add") {
279
+ const { value, meta } = encodeValue(op.value);
280
+ h.pendingOps.push({ ...op, value, ...(meta !== undefined && { meta }) });
281
+ } else {
282
+ h.pendingOps.push(op);
283
+ }
284
+ scheduleFlush(h);
285
+ };
216
286
 
217
- const attach = (next: Statewire<unknown>) => {
218
- if (next === current) return;
219
- current = next;
220
- const nextState =
221
- typeof next.state === "object" && next.state !== null
222
- ? getStateHost(next.state)
223
- : undefined;
224
- const nextCommands = getCommandsHost(next.commands);
225
- const nextEmit = getEmitHost(next.emit);
226
- if (!nextState || !nextCommands?.submit || !nextEmit) {
227
- throw new Error(
228
- "StatewireSocketHost requires a statewire/host transport (useStatewireState with object state, useStatewireCommands, useStatewireEmit)",
229
- );
230
- }
231
- if (nextState !== stateHost) {
232
- stateHost?.setOnOp(null);
233
- stateHost = nextState;
234
- stateHost.setOnOp(recordOp);
235
- }
236
- commandsHost = nextCommands;
237
- if (nextEmit !== emitHost) {
238
- emitHost?.setSink(null);
239
- emitHost = nextEmit;
240
- emitHost.setSink((event) => recordOp({ op: "event", value: event }));
241
- }
242
- };
287
+ const attach = (h: Host, next: Statewire<unknown>) => {
288
+ if (next === h.current) return;
289
+ h.current = next;
290
+ const nextState =
291
+ typeof next.state === "object" && next.state !== null
292
+ ? getStateHost(next.state)
293
+ : undefined;
294
+ const nextCommands = getCommandsHost(next.commands);
295
+ if (!nextState || !nextCommands?.submit) {
296
+ throw new Error(
297
+ "StatewireSocketHost requires a statewire/host transport (useStatewireState with object state, useStatewireCommands)",
298
+ );
299
+ }
300
+ if (nextState !== h.stateHost) {
301
+ h.stateHost?.setOnOp(null);
302
+ h.stateHost = nextState;
303
+ h.stateHost.setOnOp((op) => recordOp(h, op));
304
+ }
305
+ h.commandsHost = nextCommands;
306
+ };
243
307
 
244
- const closeConn = (conn: Conn, code?: number, reason?: string) => {
245
- if (!conn.open) return;
246
- conn.open = false;
247
- conns.delete(conn);
248
- try {
249
- conn.socket.close(code, reason);
250
- } catch {
251
- /* peer already gone */
252
- }
253
- if (conns.size === 0 && heartbeatTimer !== null) {
254
- clearInterval(heartbeatTimer);
255
- heartbeatTimer = null;
256
- }
257
- };
308
+ const closeConn = (h: Host, conn: Conn, code?: number, reason?: string) => {
309
+ if (!conn.open) return;
310
+ conn.open = false;
311
+ h.conns.delete(conn);
312
+ try {
313
+ conn.socket.close(code, reason);
314
+ } catch {
315
+ /* peer already gone */
316
+ }
317
+ if (h.conns.size === 0 && h.heartbeatTimer !== null) {
318
+ clearInterval(h.heartbeatTimer);
319
+ h.heartbeatTimer = null;
320
+ }
321
+ };
258
322
 
259
- const sendTo = (conn: Conn, data: string) => {
260
- if (!conn.open) return;
261
- conn.lastSentAt = Date.now();
262
- try {
263
- conn.socket.send(data);
264
- } catch {
265
- closeConn(conn);
323
+ const sendTo = (h: Host, conn: Conn, data: string) => {
324
+ if (!conn.open) return;
325
+ conn.lastSentAt = Date.now();
326
+ try {
327
+ conn.socket.send(data);
328
+ } catch {
329
+ closeConn(h, conn);
330
+ }
331
+ };
332
+
333
+ const ensureHeartbeat = (h: Host) => {
334
+ if (h.heartbeatTimer !== null || h.heartbeatMs <= 0) return;
335
+ h.heartbeatTimer = setInterval(() => {
336
+ const now = Date.now();
337
+ for (const conn of [...h.conns]) {
338
+ if (now - conn.lastSentAt >= h.heartbeatMs) sendTo(h, conn, "{}");
266
339
  }
267
- };
340
+ }, h.heartbeatMs);
341
+ (h.heartbeatTimer as unknown as { unref?: () => void }).unref?.();
342
+ };
268
343
 
269
- const ensureHeartbeat = () => {
270
- if (heartbeatTimer !== null || heartbeatMs <= 0) return;
271
- heartbeatTimer = setInterval(() => {
272
- const now = Date.now();
273
- for (const conn of [...conns]) {
274
- if (now - conn.lastSentAt >= heartbeatMs) sendTo(conn, "{}");
275
- }
276
- }, heartbeatMs);
277
- (heartbeatTimer as unknown as { unref?: () => void }).unref?.();
278
- };
344
+ const setHeartbeatMs = (h: Host, ms: number) => {
345
+ if (ms === h.heartbeatMs) return;
346
+ h.heartbeatMs = ms;
347
+ if (h.heartbeatTimer !== null) {
348
+ clearInterval(h.heartbeatTimer);
349
+ h.heartbeatTimer = null;
350
+ ensureHeartbeat(h);
351
+ }
352
+ };
279
353
 
280
- const flush = (fin?: StatewireHost.Fin) => {
281
- flushTapSync(() => {});
282
- stateHost?.takeChanged();
283
- for (const rec of clients.values()) {
284
- for (const adm of rec.inflight.values()) {
285
- if (adm.acked && !adm.settled && !adm.pendingSent) {
286
- adm.pendingSent = true;
287
- queueRes(rec.id, { seq: adm.seq, type: "pending" });
288
- }
354
+ const idleLevel = (h: Host): boolean => {
355
+ for (const rec of h.clients.values()) {
356
+ if (rec.inflight.size > 0) return false;
357
+ }
358
+ return true;
359
+ };
360
+
361
+ const flush = (h: Host, fin?: StatewireHost.Fin) => {
362
+ flushTapSync(() => {});
363
+ h.stateHost?.takeChanged();
364
+ for (const rec of h.clients.values()) {
365
+ for (const adm of rec.inflight.values()) {
366
+ if (adm.applied && !adm.settled && !adm.appliedSent) {
367
+ adm.appliedSent = true;
368
+ queueApplied(h, rec.id, adm.seq);
289
369
  }
290
370
  }
291
- if (
292
- pendingOps.length === 0 &&
293
- dirtyAcks.size === 0 &&
294
- pendingRes.size === 0 &&
295
- fin === undefined
296
- ) {
297
- return;
371
+ }
372
+ const level = idleLevel(h);
373
+ const edge = level !== h.idleWire;
374
+ if (
375
+ h.pendingOps.length === 0 &&
376
+ h.pendingApplied.size === 0 &&
377
+ h.pendingCmds.size === 0 &&
378
+ fin === undefined &&
379
+ !edge
380
+ ) {
381
+ return;
382
+ }
383
+ h.idleWire = level;
384
+ if (fin === undefined && edge && level && h.finOnIdle) {
385
+ fin = { reason: "idle" };
386
+ }
387
+ const ops = h.pendingOps;
388
+ const applied = h.pendingApplied;
389
+ const cmds = h.pendingCmds;
390
+ h.pendingOps = [];
391
+ h.pendingApplied = new Map();
392
+ h.pendingCmds = new Map();
393
+ // Applied entries anchor to their effects: piggybacked on the ops packet
394
+ // when one is flushing, standalone otherwise. Terminal verdicts ride their
395
+ // own packet.
396
+ for (const conn of [...h.conns]) {
397
+ const appliedEntries: StatewireCmd[] = (
398
+ applied.get(conn.clientId) ?? []
399
+ ).map((seq) => ({ seq, type: "applied" }));
400
+ if (ops.length > 0) {
401
+ const packet: StatewireEnvelope = {
402
+ ops,
403
+ ...(appliedEntries.length > 0 && { cmd: appliedEntries }),
404
+ ...(edge && { idle: level }),
405
+ };
406
+ sendTo(h, conn, JSON.stringify(packet));
407
+ } else if (appliedEntries.length > 0) {
408
+ sendTo(h, conn, JSON.stringify({ cmd: appliedEntries }));
298
409
  }
299
- const ops = pendingOps;
300
- const acks = dirtyAcks;
301
- const res = pendingRes;
302
- pendingOps = [];
303
- dirtyAcks = new Set();
304
- pendingRes = new Map();
305
- for (const conn of [...conns]) {
306
- const envelope: StatewireEnvelope = {};
307
- if (ops.length > 0) envelope.ops = ops;
308
- const clientRes = res.get(conn.clientId);
309
- if (clientRes !== undefined) envelope.res = clientRes;
310
- if (acks.has(conn.clientId)) {
311
- const rec = clients.get(conn.clientId);
312
- if (rec !== undefined) envelope.ack = rec.lastSeq;
313
- }
314
- if (fin !== undefined) envelope.fin = fin;
315
- if (Object.keys(envelope).length === 0) continue;
316
- sendTo(conn, JSON.stringify(envelope));
317
- if (fin !== undefined) closeConn(conn);
410
+ const verdicts = cmds.get(conn.clientId) ?? [];
411
+ if (verdicts.length > 0) {
412
+ sendTo(h, conn, JSON.stringify({ cmd: verdicts }));
318
413
  }
319
- };
320
-
321
- const queueRes = (clientId: string, response: StatewireResponse) => {
322
- const list = pendingRes.get(clientId);
323
- if (list) list.push(response);
324
- else pendingRes.set(clientId, [response]);
325
- };
326
-
327
- const advance = (rec: ClientRec, seq: number) => {
328
- rec.lastSeq = seq;
329
- dirtyAcks.add(rec.id);
330
- };
331
-
332
- const retain = (rec: ClientRec, response: StatewireResponse) => {
333
- const now = Date.now();
334
- rec.results.set(response.seq, { ts: now, res: response });
335
- const newest = Math.max(...rec.results.keys());
336
- for (const [seq, entry] of rec.results) {
337
- if (seq !== newest && now - entry.ts > RESULT_TTL_MS) {
338
- rec.results.delete(seq);
339
- }
414
+ if (edge && ops.length === 0) {
415
+ sendTo(h, conn, JSON.stringify({ idle: level }));
340
416
  }
341
- while (rec.results.size > RESULT_LIMIT) {
342
- rec.results.delete(Math.min(...rec.results.keys()));
417
+ if (fin !== undefined) {
418
+ sendTo(h, conn, JSON.stringify({ fin }));
419
+ closeConn(h, conn);
343
420
  }
344
- };
421
+ }
422
+ };
345
423
 
346
- const duplicateAnswer = (rec: ClientRec, seq: number): StatewireResponse => {
347
- const cached = rec.results.get(seq);
348
- if (cached) {
349
- const newest = Math.max(...rec.results.keys());
350
- if (seq === newest || Date.now() - cached.ts <= RESULT_TTL_MS) {
351
- return cached.res;
352
- }
424
+ const queueApplied = (h: Host, clientId: string, seq: number) => {
425
+ const list = h.pendingApplied.get(clientId);
426
+ if (list) list.push(seq);
427
+ else h.pendingApplied.set(clientId, [seq]);
428
+ scheduleFlush(h);
429
+ };
430
+
431
+ const queueCmd = (h: Host, clientId: string, cmd: StatewireCmd) => {
432
+ const list = h.pendingCmds.get(clientId);
433
+ if (list) list.push(cmd);
434
+ else h.pendingCmds.set(clientId, [cmd]);
435
+ scheduleFlush(h);
436
+ };
437
+
438
+ const retain = (rec: ClientRec, cmd: StatewireCmd) => {
439
+ const now = Date.now();
440
+ rec.results.set(cmd.seq, { ts: now, cmd });
441
+ const newest = Math.max(...rec.results.keys());
442
+ for (const [seq, entry] of rec.results) {
443
+ if (seq !== newest && now - entry.ts > RESULT_TTL_MS) {
353
444
  rec.results.delete(seq);
354
445
  }
355
- if (rec.inflight.has(seq)) return { seq, type: "pending" };
356
- return { seq, type: "result-unavailable" };
357
- };
446
+ }
447
+ while (rec.results.size > RESULT_LIMIT) {
448
+ rec.results.delete(Math.min(...rec.results.keys()));
449
+ }
450
+ };
358
451
 
359
- const settleResponse = (
360
- rec: ClientRec,
361
- adm: Admission,
362
- response: StatewireResponse,
363
- ) => {
364
- if (adm.settled) return;
365
- adm.settled = true;
366
- rec.inflight.delete(adm.seq);
367
- retain(rec, response);
368
- if (!adm.acked) {
369
- adm.acked = true;
370
- advance(rec, adm.seq);
371
- }
372
- if (
373
- adm.pendingSent ||
374
- response.type !== "accepted" ||
375
- response.payload !== undefined
376
- ) {
377
- queueRes(rec.id, response);
452
+ /** Cmd-lane watermark: the last statement admitted, settled or not. */
453
+ const admittedThrough = (rec: ClientRec): number =>
454
+ Math.max(rec.lastSeq, ...rec.inflight.keys());
455
+
456
+ // A re-asked seq never re-runs: the stored verdict re-emits, an applied
457
+ // inflight run re-answers applied; one not yet applied answers nothing (its
458
+ // answers ride the stream when ready), an evicted verdict answers expired.
459
+ const reemit = (h: Host, rec: ClientRec, seq: number) => {
460
+ const cached = rec.results.get(seq);
461
+ if (cached) {
462
+ const newest = Math.max(...rec.results.keys());
463
+ if (seq === newest || Date.now() - cached.ts <= RESULT_TTL_MS) {
464
+ queueCmd(h, rec.id, cached.cmd);
465
+ return;
378
466
  }
379
- scheduleFlush();
380
- adm.release();
381
- };
467
+ rec.results.delete(seq);
468
+ }
469
+ const adm = rec.inflight.get(seq);
470
+ if (adm) {
471
+ if (adm.applied) queueApplied(h, rec.id, seq);
472
+ return;
473
+ }
474
+ queueCmd(h, rec.id, { seq, type: "expired" });
475
+ };
382
476
 
383
- const settleWire = (
384
- rec: ClientRec,
385
- adm: Admission,
386
- outcome: CommandsOutcome,
387
- ) => settleResponse(rec, adm, responseOf(adm, outcome));
477
+ const settleCmd = (
478
+ h: Host,
479
+ rec: ClientRec,
480
+ adm: Admission,
481
+ cmd: StatewireCmd,
482
+ ) => {
483
+ if (adm.settled) return;
484
+ adm.settled = true;
485
+ rec.inflight.delete(adm.seq);
486
+ retain(rec, cmd);
487
+ rec.lastSeq = Math.max(rec.lastSeq, adm.seq);
488
+ adm.applied = true;
489
+ queueCmd(h, rec.id, cmd);
490
+ };
388
491
 
389
- const callerOf = (rec: ClientRec): StatewireHost.Caller => ({
390
- clientId: rec.id,
391
- get context() {
392
- return rec.context;
393
- },
394
- get isConnected() {
395
- if (clients.get(rec.id) !== rec) return false;
396
- for (const conn of conns) {
397
- if (conn.clientId === rec.id && conn.open) return true;
398
- }
399
- return false;
400
- },
492
+ const settleWire = (
493
+ h: Host,
494
+ rec: ClientRec,
495
+ adm: Admission,
496
+ outcome: CommandsOutcome,
497
+ ) => settleCmd(h, rec, adm, cmdOf(adm, outcome));
498
+
499
+ const callerOf = (h: Host, rec: ClientRec): StatewireHost.Caller => ({
500
+ clientId: rec.id,
501
+ get context() {
502
+ return rec.context;
503
+ },
504
+ get isConnected() {
505
+ if (h.clients.get(rec.id) !== rec) return false;
506
+ for (const conn of h.conns) {
507
+ if (conn.clientId === rec.id && conn.open) return true;
508
+ }
509
+ return false;
510
+ },
511
+ });
512
+
513
+ const rejectStatement = (
514
+ h: Host,
515
+ rec: ClientRec,
516
+ adm: Admission,
517
+ message: string,
518
+ ) =>
519
+ settleCmd(h, rec, adm, {
520
+ seq: adm.seq,
521
+ type: "rejected",
522
+ code: "invalid-params",
523
+ message,
401
524
  });
402
525
 
403
- const admit = (conn: Conn, frame: Frame): Promise<void> | void => {
404
- if (!conn.open || disposed || finished !== null) return;
405
- const rec = clients.get(conn.clientId)!;
406
- if (frame.seq <= rec.lastSeq || rec.inflight.has(frame.seq)) {
407
- queueRes(rec.id, duplicateAnswer(rec, frame.seq));
408
- scheduleFlush();
409
- return;
410
- }
411
- if (frame.seq > rec.lastSeq + 1) {
412
- closeConn(
413
- conn,
414
- 1008,
415
- `seq gap: expected ${rec.lastSeq + 1}, got ${frame.seq}`,
526
+ const admitCmd = (h: Host, rec: ClientRec, statement: CmdStatement) => {
527
+ const adm: Admission = {
528
+ seq: statement.seq,
529
+ method: statement.method,
530
+ applied: false,
531
+ appliedSent: false,
532
+ settled: false,
533
+ };
534
+ rec.inflight.set(statement.seq, adm);
535
+ if (!Array.isArray(statement.params)) {
536
+ rejectStatement(h, rec, adm, "params must be an array");
537
+ return;
538
+ }
539
+ let params = statement.params;
540
+ if (statement.meta !== undefined) {
541
+ try {
542
+ params = reviveValue(params, statement.meta) as unknown[];
543
+ } catch (error) {
544
+ rejectStatement(
545
+ h,
546
+ rec,
547
+ adm,
548
+ `invalid params: ${(error as Error).message}`,
416
549
  );
417
550
  return;
418
551
  }
419
- const adm: Admission = {
420
- seq: frame.seq,
421
- method: frame.method ?? "",
422
- acked: false,
423
- pendingSent: false,
424
- settled: false,
425
- release: () => {},
426
- };
427
- if (frame.context !== undefined) {
428
- let value: Record<string, unknown>;
429
- try {
430
- value = contextValue(frame.context);
431
- } catch (error) {
432
- rec.inflight.set(frame.seq, adm);
433
- settleResponse(rec, adm, {
434
- seq: frame.seq,
435
- type: "rejected",
436
- code: "invalid-context",
437
- message: `invalid context: ${(error as Error).message}`,
438
- });
439
- return;
440
- }
441
- rec.context = value;
442
- if (frame.method === undefined) {
443
- rec.inflight.set(frame.seq, adm);
444
- settleResponse(rec, adm, { seq: frame.seq, type: "accepted" });
445
- return;
446
- }
447
- }
448
- const released = new Promise<void>((resolve) => {
449
- adm.release = resolve;
450
- });
451
- rec.inflight.set(frame.seq, adm);
452
- commandsHost!.submit!({
453
- method: frame.method!,
454
- params: frame.params,
455
- clientId: rec.id,
456
- caller: callerOf(rec),
457
- onAck: () => {
458
- if (adm.settled || adm.acked) return;
459
- adm.acked = true;
460
- advance(rec, adm.seq);
461
- scheduleFlush();
462
- adm.release();
463
- },
464
- onSettle: (outcome) => settleWire(rec, adm, outcome),
465
- }).catch((error: unknown) => {
466
- if (!adm.settled) settleWire(rec, adm, { type: "rejected", error });
467
- });
468
- return released;
469
- };
470
-
471
- const message = (conn: Conn, data: string) => {
472
- if (!conn.open || disposed || finished !== null) return;
473
- const frame = parseFrame(data);
474
- if (frame === null) {
475
- closeConn(conn, 1008, "invalid frame");
552
+ }
553
+ if (statement.method === "statewire/commands") {
554
+ if (params.length > 0) {
555
+ rejectStatement(h, rec, adm, "statewire/commands takes no params");
476
556
  return;
477
557
  }
478
- inbox = inbox.then(() => admit(conn, frame));
479
- };
558
+ const names = [
559
+ ...(h.commandsHost?.methods?.() ?? []),
560
+ "statewire/commands",
561
+ ].sort();
562
+ settleCmd(h, rec, adm, { seq: adm.seq, type: "result", payload: names });
563
+ return;
564
+ }
565
+ h.commandsHost!.submit!({
566
+ method: statement.method,
567
+ params,
568
+ clientId: rec.id,
569
+ caller: callerOf(h, rec),
570
+ onApplied: () => {
571
+ if (adm.settled || adm.applied) return;
572
+ adm.applied = true;
573
+ rec.lastSeq = Math.max(rec.lastSeq, adm.seq);
574
+ scheduleFlush(h);
575
+ },
576
+ onSettle: (outcome) => settleWire(h, rec, adm, outcome),
577
+ }).catch((error: unknown) => {
578
+ if (!adm.settled) settleWire(h, rec, adm, { type: "rejected", error });
579
+ });
580
+ };
480
581
 
481
- const connect = (
482
- clientId: string,
483
- socket: StatewireHostInternal.Socket,
484
- ): StatewireHostInternal.SocketConnection => {
485
- const dead: StatewireHostInternal.SocketConnection = {
486
- clientId,
487
- message: () => {},
488
- close: () => {},
489
- };
490
- const refuse = (code: number, reason: string) => {
491
- try {
492
- socket.close(code, reason);
493
- } catch {
494
- /* peer already gone */
495
- }
496
- return dead;
497
- };
498
- if (disposed) return refuse(1001, "disposed");
499
- if (!CLIENT_ID_RE.test(clientId)) return refuse(1008, "invalid client id");
500
- flush();
501
- if (finished !== null) {
502
- try {
503
- socket.send(JSON.stringify({ fin: finished }));
504
- } catch {
505
- /* peer already gone */
506
- }
507
- return refuse(1000, "finished");
582
+ // One admission law, both lanes: absorbed statements answer from the record,
583
+ // then the new suffix is admitted whole or the frame is refused whole via a
584
+ // {syn} stating both watermarks, nothing consumed. A gap is never a close.
585
+ const admitFrame = (h: Host, conn: Conn, frame: ParsedFrame) => {
586
+ const rec = h.clients.get(conn.clientId)!;
587
+ rec.touched = Date.now();
588
+ const through = admittedThrough(rec);
589
+ for (const statement of frame.cmd) {
590
+ if (statement.seq <= through) reemit(h, rec, statement.seq);
591
+ }
592
+ const ctxNew = frame.ctx.filter((s) => s.seq > rec.ctxSeq);
593
+ const cmdNew = frame.cmd.filter((s) => s.seq > through);
594
+ if (
595
+ (ctxNew.length > 0 && ctxNew[0]!.seq !== rec.ctxSeq + 1) ||
596
+ (cmdNew.length > 0 && cmdNew[0]!.seq !== through + 1)
597
+ ) {
598
+ sendTo(
599
+ h,
600
+ conn,
601
+ JSON.stringify({ syn: { seq: through, ctxSeq: rec.ctxSeq } }),
602
+ );
603
+ return;
604
+ }
605
+ for (const statement of ctxNew) {
606
+ rec.context = statement.value;
607
+ rec.ctxSeq = statement.seq;
608
+ }
609
+ for (const statement of cmdNew) admitCmd(h, rec, statement);
610
+ if (cmdNew.length > 0) {
611
+ // Memory host: admitted means retained — dur rides the admitting flush.
612
+ queueCmd(h, rec.id, { seq: cmdNew.at(-1)!.seq, dur: true });
613
+ }
614
+ scheduleFlush(h);
615
+ };
616
+
617
+ const message = (h: Host, conn: Conn, data: string) => {
618
+ if (!conn.open || h.disposed || h.finished !== null) return;
619
+ const first = !conn.sawFrame;
620
+ conn.sawFrame = true;
621
+ let frame: ParsedFrame;
622
+ try {
623
+ frame = parseFrame(data, first);
624
+ } catch (error) {
625
+ const reason = violationReason(error);
626
+ if (reason === null) throw error;
627
+ closeConn(h, conn, 1008, reason);
628
+ return;
629
+ }
630
+ if (frame.ctx.length === 0 && frame.cmd.length === 0) return;
631
+ admitFrame(h, conn, frame);
632
+ };
633
+
634
+ const gcClients = (h: Host) => {
635
+ let excess = h.clients.size - h.clientRecordLimit;
636
+ if (excess <= 0) return;
637
+ const live = new Set([...h.conns].map((conn) => conn.clientId));
638
+ const oldest = [...h.clients.values()].sort((a, b) => a.touched - b.touched);
639
+ for (const rec of oldest) {
640
+ if (excess <= 0) return;
641
+ if (live.has(rec.id) || rec.inflight.size > 0) continue;
642
+ h.clients.delete(rec.id);
643
+ excess -= 1;
644
+ }
645
+ };
646
+
647
+ const connect = (
648
+ h: Host,
649
+ clientId: string,
650
+ socket: StatewireHostInternal.Socket,
651
+ ): StatewireHostInternal.SocketConnection => {
652
+ const dead: StatewireHostInternal.SocketConnection = {
653
+ clientId,
654
+ message: () => {},
655
+ close: () => {},
656
+ };
657
+ const refuse = (code: number, reason: string) => {
658
+ try {
659
+ socket.close(code, reason);
660
+ } catch {
661
+ /* peer already gone */
508
662
  }
509
- const known = clients.has(clientId);
510
- const rec = known
511
- ? clients.get(clientId)!
512
- : (() => {
513
- const created: ClientRec = {
514
- id: clientId,
515
- lastSeq: 0,
516
- context: null,
517
- results: new Map(),
518
- inflight: new Map(),
519
- };
520
- clients.set(clientId, created);
521
- return created;
522
- })();
523
- for (const other of [...conns]) {
524
- if (other.clientId !== clientId) continue;
525
- sendTo(other, JSON.stringify({ fin: { reason: "superseded" } }));
526
- closeConn(other);
663
+ return dead;
664
+ };
665
+ if (h.disposed) return refuse(1001, "disposed");
666
+ if (!CLIENT_ID_RE.test(clientId)) return refuse(1008, "invalid client id");
667
+ flush(h);
668
+ if (h.finished !== null) {
669
+ try {
670
+ socket.send(JSON.stringify({ fin: h.finished }));
671
+ } catch {
672
+ /* peer already gone */
527
673
  }
528
- const conn: Conn = { clientId, socket, open: true, lastSentAt: 0 };
529
- conns.add(conn);
530
- ensureHeartbeat();
531
- const { value, meta } = encodeValue(stateHost!.snapshot());
532
- const snapshot: StatewireEnvelope = {
533
- ops: [
534
- {
535
- op: "replace",
536
- path: [],
537
- value,
538
- ...(meta !== undefined && { meta }),
539
- },
540
- ],
541
- syn: { lastSeq: known ? rec.lastSeq : -1, lease: randomId() },
542
- };
543
- sendTo(conn, JSON.stringify(snapshot));
544
- return {
545
- clientId,
546
- message: (data) => message(conn, data),
547
- close: () => closeConn(conn),
548
- };
674
+ return refuse(1000, "finished");
675
+ }
676
+ const known = h.clients.has(clientId);
677
+ const rec = known
678
+ ? h.clients.get(clientId)!
679
+ : (() => {
680
+ const created: ClientRec = {
681
+ id: clientId,
682
+ lastSeq: 0,
683
+ ctxSeq: 0,
684
+ touched: Date.now(),
685
+ context: null,
686
+ results: new Map(),
687
+ inflight: new Map(),
688
+ };
689
+ h.clients.set(clientId, created);
690
+ return created;
691
+ })();
692
+ rec.touched = Date.now();
693
+ for (const other of [...h.conns]) {
694
+ if (other.clientId !== clientId) continue;
695
+ sendTo(h, other, JSON.stringify({ fin: { reason: "superseded" } }));
696
+ closeConn(h, other);
697
+ }
698
+ const conn: Conn = {
699
+ clientId,
700
+ socket,
701
+ open: true,
702
+ sawFrame: false,
703
+ lastSentAt: 0,
549
704
  };
550
-
551
- const finish = (fin: StatewireHost.Fin) => {
552
- if (finished !== null || disposed) return;
553
- finished = fin;
554
- flush(fin);
705
+ h.conns.add(conn);
706
+ gcClients(h);
707
+ ensureHeartbeat(h);
708
+ const { value, meta } = encodeValue(h.stateHost!.snapshot());
709
+ const hello: StatewireEnvelope = {
710
+ ops: [
711
+ {
712
+ op: "replace",
713
+ path: [],
714
+ value,
715
+ ...(meta !== undefined && { meta }),
716
+ },
717
+ ],
718
+ syn: {
719
+ seq: admittedThrough(rec),
720
+ ctxSeq: rec.ctxSeq,
721
+ record: known ? "resumed" : "fresh",
722
+ ...(h.idleWire && { idle: true }),
723
+ },
555
724
  };
556
-
725
+ sendTo(h, conn, JSON.stringify(hello));
557
726
  return {
558
- connect,
559
- finish,
560
- attach,
561
- resume: () => {
562
- disposed = false;
563
- stateHost?.setOnOp(recordOp);
564
- emitHost?.setSink((event) => recordOp({ op: "event", value: event }));
565
- },
566
- suspend: () => {
567
- disposed = true;
568
- if (heartbeatTimer !== null) {
569
- clearInterval(heartbeatTimer);
570
- heartbeatTimer = null;
571
- }
572
- stateHost?.setOnOp(null);
573
- emitHost?.setSink(null);
574
- },
727
+ clientId,
728
+ message: (data) => message(h, conn, data),
729
+ close: () => closeConn(h, conn),
575
730
  };
576
731
  };
577
732
 
733
+ const finish = (h: Host, fin: StatewireHost.Fin) => {
734
+ if (h.finished !== null || h.disposed) return;
735
+ h.finished = fin;
736
+ flush(h, fin);
737
+ };
738
+
739
+ const resume = (h: Host) => {
740
+ h.disposed = false;
741
+ h.stateHost?.setOnOp((op) => recordOp(h, op));
742
+ };
743
+
744
+ const suspend = (h: Host) => {
745
+ h.disposed = true;
746
+ if (h.heartbeatTimer !== null) {
747
+ clearInterval(h.heartbeatTimer);
748
+ h.heartbeatTimer = null;
749
+ }
750
+ h.stateHost?.setOnOp(null);
751
+ };
752
+
578
753
  const useSocketHost = (
579
754
  transport: ResourceElement<Statewire>,
580
755
  options?: StatewireHostInternal.SocketHostOptions,
581
756
  ): Pick<StatewireHostInternal.SocketHost, "connect" | "finish"> => {
582
- const [h] = useState(() => createSocketHost(options));
757
+ const [h] = useState((): Host => {
758
+ const host: Host = {
759
+ heartbeatMs: 15_000,
760
+ clientRecordLimit: CLIENT_RECORD_LIMIT,
761
+ finOnIdle: false,
762
+ clients: new Map(),
763
+ conns: new Set(),
764
+ pendingOps: [],
765
+ pendingApplied: new Map(),
766
+ pendingCmds: new Map(),
767
+ idleWire: true,
768
+ finished: null,
769
+ disposed: false,
770
+ flushScheduled: false,
771
+ heartbeatTimer: null,
772
+ stateHost: null,
773
+ commandsHost: null,
774
+ current: null,
775
+ post: createMacrotaskPoster(() => {
776
+ host.flushScheduled = false;
777
+ flush(host);
778
+ }),
779
+ connect: (clientId, socket) => connect(host, clientId, socket),
780
+ finish: (fin) => finish(host, fin),
781
+ };
782
+ return host;
783
+ });
784
+ setHeartbeatMs(h, options?.heartbeatIntervalMs ?? 15_000);
785
+ h.clientRecordLimit = options?.clientRecordLimit ?? CLIENT_RECORD_LIMIT;
786
+ h.finOnIdle = options?.finOnIdle ?? false;
583
787
  const value = useResource(transport) as Statewire<unknown>;
584
- h.attach(value);
788
+ attach(h, value);
585
789
  useEffect(() => {
586
- h.attach(value);
790
+ attach(h, value);
587
791
  });
588
792
  useEffect(() => {
589
- h.resume();
590
- return () => h.suspend();
793
+ resume(h);
794
+ return () => suspend(h);
591
795
  }, [h]);
592
796
  return h;
593
797
  };
@@ -595,8 +799,9 @@ const useSocketHost = (
595
799
  /**
596
800
  * Transport-agnostic WS binding over a statewire/host transport:
597
801
  * feed it sockets (anything with `send`/`close`) and raw text frames, and it
598
- * speaks the Statewire envelope protocol — snapshot-first attach with `syn`,
599
- * delta/ack/res envelopes, per-client-id supersede, and `fin` on finish.
802
+ * speaks the Statewire packet protocol — snapshot-first hello with `syn`,
803
+ * statement-lane client frames under one admission law, `{cmd}` answer
804
+ * arrays, per-client-id supersede, idle edges, and `fin` on finish.
600
805
  */
601
806
  export const StatewireSocketHost = (
602
807
  transport: ResourceElement<Statewire>,