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,54 +1,85 @@
1
1
  import { StatewireSendError } from "../types.js";
2
- import { getCommandsHost, getEmitHost, getStateHost } from "./host.js";
2
+ import { getCommandsHost, getStateHost } from "./host.js";
3
3
  import { encodeValue, reviveValue } from "../wire/meta.js";
4
4
  import { createMacrotaskPoster } from "../util/macrotask.js";
5
- import { randomId } from "../util/randomId.js";
6
- import { c } from "@assistant-ui/tap/react-shim/compiler-runtime";
7
5
  import { useEffect, useState } from "@assistant-ui/tap/react-shim";
8
6
  import { createTapRoot, flushTapSync, useResource } from "@assistant-ui/tap";
9
7
  //#region src/host/socket.ts
10
8
  const CLIENT_ID_RE = /^[A-Za-z0-9._~-]{1,256}$/;
11
9
  const RESULT_TTL_MS = 3e4;
12
10
  const RESULT_LIMIT = 256;
13
- const parseFrame = (raw) => {
11
+ const CLIENT_RECORD_LIMIT = 1024;
12
+ const violation = (reason) => {
13
+ throw Object.assign(new Error(reason), { name: "StatewireViolation" });
14
+ };
15
+ const violationReason = (error) => error instanceof Error && error.name === "StatewireViolation" ? error.message : null;
16
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
17
+ const parseLane = (value) => {
18
+ if (!Array.isArray(value)) return violation("malformed frame");
19
+ if (value.length === 0) return violation("empty lane");
20
+ const statements = [];
21
+ let prev = null;
22
+ for (const statement of value) {
23
+ if (!isPlainObject(statement)) return violation("malformed statement");
24
+ const seq = statement["seq"];
25
+ if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 1) return violation("malformed statement");
26
+ if (prev !== null && seq !== prev + 1) return violation("malformed frame");
27
+ prev = seq;
28
+ statements.push(statement);
29
+ }
30
+ return statements;
31
+ };
32
+ /** v1 ctx statements state the context whole: one root replace with an object value. */
33
+ const contextValue = (ops) => {
34
+ if (!Array.isArray(ops) || ops.length !== 1) throw new Error("context must be a single-op array");
35
+ const op = ops[0];
36
+ if (!isPlainObject(op) || op["op"] !== "replace" || !Array.isArray(op["path"]) || op["path"].length !== 0) throw new Error("context op must be a root replace");
37
+ const value = op["meta"] !== void 0 ? reviveValue(op["value"], op["meta"]) : op["value"];
38
+ if (!isPlainObject(value)) throw new Error("context value must be an object");
39
+ return value;
40
+ };
41
+ const parseFrame = (raw, first) => {
14
42
  let frame;
15
43
  try {
16
44
  frame = JSON.parse(raw);
17
45
  } catch {
18
- return null;
46
+ return violation("malformed frame");
19
47
  }
20
- if (typeof frame !== "object" || frame === null || Array.isArray(frame)) return null;
21
- const { method, seq, params, meta, context } = frame;
22
- if (method === void 0 && context === void 0) return null;
23
- if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 1) return null;
24
- if (method === void 0) return {
25
- seq,
26
- params: [],
27
- ...context !== void 0 && { context }
28
- };
29
- if (typeof method !== "string") return null;
30
- if (!Array.isArray(params)) return null;
31
- if (meta !== void 0) try {
32
- reviveValue(params, meta);
48
+ if (!isPlainObject(frame)) return violation("malformed frame");
49
+ if (frame["headers"] === void 0 && frame["ctx"] === void 0 && frame["cmd"] === void 0) return violation("malformed frame");
50
+ let headers = null;
51
+ if (frame["headers"] !== void 0) {
52
+ if (!first) return violation("mid-session headers");
53
+ const raw = frame["headers"];
54
+ if (!isPlainObject(raw) || Object.values(raw).some((value) => typeof value !== "string")) return violation("malformed headers");
55
+ headers = raw;
56
+ }
57
+ const ctx = [];
58
+ if (frame["ctx"] !== void 0) for (const statement of parseLane(frame["ctx"])) try {
59
+ ctx.push({
60
+ seq: statement["seq"],
61
+ value: contextValue(statement["ops"])
62
+ });
33
63
  } catch {
34
- return null;
64
+ return violation("invalid-context");
65
+ }
66
+ const cmd = [];
67
+ if (frame["cmd"] !== void 0) for (const statement of parseLane(frame["cmd"])) {
68
+ const method = statement["method"];
69
+ if (typeof method !== "string" || !("params" in statement)) return violation("malformed statement");
70
+ cmd.push({
71
+ seq: statement["seq"],
72
+ method,
73
+ params: statement["params"],
74
+ ...statement["meta"] !== void 0 && { meta: statement["meta"] }
75
+ });
35
76
  }
36
77
  return {
37
- method,
38
- seq,
39
- params,
40
- ...context !== void 0 && { context }
78
+ headers,
79
+ ctx,
80
+ cmd
41
81
  };
42
82
  };
43
- /** v1 context submissions are exactly one root replace with an object value. */
44
- const contextValue = (context) => {
45
- if (!Array.isArray(context) || context.length !== 1) throw new Error("context must be a single-op array");
46
- const op = context[0];
47
- if (typeof op !== "object" || op === null || op["op"] !== "replace" || !Array.isArray(op["path"]) || op["path"].length !== 0) throw new Error("context op must be a root replace");
48
- const value = op["value"];
49
- if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("context value must be an object");
50
- return value;
51
- };
52
83
  const encodePayload = (payload) => {
53
84
  if (payload === void 0) return {};
54
85
  const { value, meta } = encodeValue(payload);
@@ -57,437 +88,466 @@ const encodePayload = (payload) => {
57
88
  ...meta !== void 0 && { meta }
58
89
  };
59
90
  };
60
- const responseOf = (adm, outcome) => {
91
+ const cmdOf = (adm, outcome) => {
61
92
  if (outcome.type === "accepted") return {
62
93
  seq: adm.seq,
63
- type: "accepted",
94
+ type: "result",
64
95
  ...encodePayload(outcome.payload)
65
96
  };
66
97
  const failure = outcome.error instanceof StatewireSendError ? outcome.error.failure : null;
67
98
  switch (failure?.fate) {
68
- case "unknown-command": return {
69
- seq: adm.seq,
70
- type: "rejected",
71
- code: "unknown-command",
72
- message: `unknown method: '${adm.method}'`
73
- };
74
- case "invalid-params": return {
75
- seq: adm.seq,
76
- type: "rejected",
77
- code: "invalid-params",
78
- message: failure.message
79
- };
80
- case "crashed": return {
99
+ case "failed": return {
81
100
  seq: adm.seq,
82
- type: "crashed"
83
- };
84
- case "rejected": return {
85
- seq: adm.seq,
86
- type: "rejected",
87
- message: failure.message,
88
- ...encodePayload(failure.payload)
101
+ type: "failed",
102
+ ...failure.message !== void 0 && { message: failure.message }
89
103
  };
104
+ case "rejected":
105
+ if (failure.code !== void 0) {
106
+ const message = failure.code === "unknown-command" ? `unknown method: '${adm.method}'` : failure.message;
107
+ return {
108
+ seq: adm.seq,
109
+ type: "rejected",
110
+ code: failure.code,
111
+ ...message !== void 0 && { message }
112
+ };
113
+ }
114
+ return {
115
+ seq: adm.seq,
116
+ type: "rejected",
117
+ ...failure.message !== void 0 && { message: failure.message },
118
+ ...encodePayload(failure.payload)
119
+ };
90
120
  default: return {
91
121
  seq: adm.seq,
92
- type: "rejected",
122
+ type: "failed",
93
123
  message: outcome.error instanceof Error ? outcome.error.message : String(outcome.error)
94
124
  };
95
125
  }
96
126
  };
97
- const createSocketHost = (options) => {
98
- const heartbeatMs = options?.heartbeatIntervalMs ?? 15e3;
99
- const clients = /* @__PURE__ */ new Map();
100
- const conns = /* @__PURE__ */ new Set();
101
- let pendingOps = [];
102
- let dirtyAcks = /* @__PURE__ */ new Set();
103
- let pendingRes = /* @__PURE__ */ new Map();
104
- let inbox = Promise.resolve();
105
- let finished = null;
106
- let disposed = false;
107
- let flushScheduled = false;
108
- let heartbeatTimer = null;
109
- let stateHost = null;
110
- let commandsHost = null;
111
- let emitHost = null;
112
- let current = null;
113
- const post = createMacrotaskPoster(() => {
114
- flushScheduled = false;
115
- flush();
116
- });
117
- const scheduleFlush = () => {
118
- if (flushScheduled || disposed) return;
119
- flushScheduled = true;
120
- post();
121
- };
122
- const recordOp = (op) => {
123
- if (op.op === "replace" || op.op === "add" || op.op === "event") {
124
- const { value, meta } = encodeValue(op.value);
125
- pendingOps.push({
126
- ...op,
127
- value,
128
- ...meta !== void 0 && { meta }
129
- });
130
- } else pendingOps.push(op);
131
- scheduleFlush();
132
- };
133
- const attach = (next) => {
134
- if (next === current) return;
135
- current = next;
136
- const nextState = typeof next.state === "object" && next.state !== null ? getStateHost(next.state) : void 0;
137
- const nextCommands = getCommandsHost(next.commands);
138
- const nextEmit = getEmitHost(next.emit);
139
- if (!nextState || !nextCommands?.submit || !nextEmit) throw new Error("StatewireSocketHost requires a statewire/host transport (useStatewireState with object state, useStatewireCommands, useStatewireEmit)");
140
- if (nextState !== stateHost) {
141
- stateHost?.setOnOp(null);
142
- stateHost = nextState;
143
- stateHost.setOnOp(recordOp);
144
- }
145
- commandsHost = nextCommands;
146
- if (nextEmit !== emitHost) {
147
- emitHost?.setSink(null);
148
- emitHost = nextEmit;
149
- emitHost.setSink((event) => recordOp({
150
- op: "event",
151
- value: event
152
- }));
153
- }
154
- };
155
- const closeConn = (conn, code, reason) => {
156
- if (!conn.open) return;
157
- conn.open = false;
158
- conns.delete(conn);
159
- try {
160
- conn.socket.close(code, reason);
161
- } catch {}
162
- if (conns.size === 0 && heartbeatTimer !== null) {
163
- clearInterval(heartbeatTimer);
164
- heartbeatTimer = null;
165
- }
166
- };
167
- const sendTo = (conn, data) => {
168
- if (!conn.open) return;
169
- conn.lastSentAt = Date.now();
170
- try {
171
- conn.socket.send(data);
172
- } catch {
173
- closeConn(conn);
174
- }
175
- };
176
- const ensureHeartbeat = () => {
177
- if (heartbeatTimer !== null || heartbeatMs <= 0) return;
178
- heartbeatTimer = setInterval(() => {
179
- const now = Date.now();
180
- for (const conn of [...conns]) if (now - conn.lastSentAt >= heartbeatMs) sendTo(conn, "{}");
181
- }, heartbeatMs);
182
- heartbeatTimer.unref?.();
183
- };
184
- const flush = (fin) => {
185
- flushTapSync(() => {});
186
- stateHost?.takeChanged();
187
- for (const rec of clients.values()) for (const adm of rec.inflight.values()) if (adm.acked && !adm.settled && !adm.pendingSent) {
188
- adm.pendingSent = true;
189
- queueRes(rec.id, {
190
- seq: adm.seq,
191
- type: "pending"
192
- });
193
- }
194
- if (pendingOps.length === 0 && dirtyAcks.size === 0 && pendingRes.size === 0 && fin === void 0) return;
195
- const ops = pendingOps;
196
- const acks = dirtyAcks;
197
- const res = pendingRes;
198
- pendingOps = [];
199
- dirtyAcks = /* @__PURE__ */ new Set();
200
- pendingRes = /* @__PURE__ */ new Map();
201
- for (const conn of [...conns]) {
202
- const envelope = {};
203
- if (ops.length > 0) envelope.ops = ops;
204
- const clientRes = res.get(conn.clientId);
205
- if (clientRes !== void 0) envelope.res = clientRes;
206
- if (acks.has(conn.clientId)) {
207
- const rec = clients.get(conn.clientId);
208
- if (rec !== void 0) envelope.ack = rec.lastSeq;
209
- }
210
- if (fin !== void 0) envelope.fin = fin;
211
- if (Object.keys(envelope).length === 0) continue;
212
- sendTo(conn, JSON.stringify(envelope));
213
- if (fin !== void 0) closeConn(conn);
214
- }
215
- };
216
- const queueRes = (clientId, response) => {
217
- const list = pendingRes.get(clientId);
218
- if (list) list.push(response);
219
- else pendingRes.set(clientId, [response]);
220
- };
221
- const advance = (rec, seq) => {
222
- rec.lastSeq = seq;
223
- dirtyAcks.add(rec.id);
224
- };
225
- const retain = (rec, response) => {
226
- const now = Date.now();
227
- rec.results.set(response.seq, {
228
- ts: now,
229
- res: response
127
+ const scheduleFlush = (h) => {
128
+ if (h.flushScheduled || h.disposed) return;
129
+ h.flushScheduled = true;
130
+ h.post();
131
+ };
132
+ const recordOp = (h, op) => {
133
+ if (op.op === "replace" || op.op === "add") {
134
+ const { value, meta } = encodeValue(op.value);
135
+ h.pendingOps.push({
136
+ ...op,
137
+ value,
138
+ ...meta !== void 0 && { meta }
230
139
  });
231
- const newest = Math.max(...rec.results.keys());
232
- for (const [seq, entry] of rec.results) if (seq !== newest && now - entry.ts > RESULT_TTL_MS) rec.results.delete(seq);
233
- while (rec.results.size > RESULT_LIMIT) rec.results.delete(Math.min(...rec.results.keys()));
234
- };
235
- const duplicateAnswer = (rec, seq) => {
236
- const cached = rec.results.get(seq);
237
- if (cached) {
238
- if (seq === Math.max(...rec.results.keys()) || Date.now() - cached.ts <= RESULT_TTL_MS) return cached.res;
239
- rec.results.delete(seq);
240
- }
241
- if (rec.inflight.has(seq)) return {
242
- seq,
243
- type: "pending"
244
- };
245
- return {
140
+ } else h.pendingOps.push(op);
141
+ scheduleFlush(h);
142
+ };
143
+ const attach = (h, next) => {
144
+ if (next === h.current) return;
145
+ h.current = next;
146
+ const nextState = typeof next.state === "object" && next.state !== null ? getStateHost(next.state) : void 0;
147
+ const nextCommands = getCommandsHost(next.commands);
148
+ if (!nextState || !nextCommands?.submit) throw new Error("StatewireSocketHost requires a statewire/host transport (useStatewireState with object state, useStatewireCommands)");
149
+ if (nextState !== h.stateHost) {
150
+ h.stateHost?.setOnOp(null);
151
+ h.stateHost = nextState;
152
+ h.stateHost.setOnOp((op) => recordOp(h, op));
153
+ }
154
+ h.commandsHost = nextCommands;
155
+ };
156
+ const closeConn = (h, conn, code, reason) => {
157
+ if (!conn.open) return;
158
+ conn.open = false;
159
+ h.conns.delete(conn);
160
+ try {
161
+ conn.socket.close(code, reason);
162
+ } catch {}
163
+ if (h.conns.size === 0 && h.heartbeatTimer !== null) {
164
+ clearInterval(h.heartbeatTimer);
165
+ h.heartbeatTimer = null;
166
+ }
167
+ };
168
+ const sendTo = (h, conn, data) => {
169
+ if (!conn.open) return;
170
+ conn.lastSentAt = Date.now();
171
+ try {
172
+ conn.socket.send(data);
173
+ } catch {
174
+ closeConn(h, conn);
175
+ }
176
+ };
177
+ const ensureHeartbeat = (h) => {
178
+ if (h.heartbeatTimer !== null || h.heartbeatMs <= 0) return;
179
+ h.heartbeatTimer = setInterval(() => {
180
+ const now = Date.now();
181
+ for (const conn of [...h.conns]) if (now - conn.lastSentAt >= h.heartbeatMs) sendTo(h, conn, "{}");
182
+ }, h.heartbeatMs);
183
+ h.heartbeatTimer.unref?.();
184
+ };
185
+ const setHeartbeatMs = (h, ms) => {
186
+ if (ms === h.heartbeatMs) return;
187
+ h.heartbeatMs = ms;
188
+ if (h.heartbeatTimer !== null) {
189
+ clearInterval(h.heartbeatTimer);
190
+ h.heartbeatTimer = null;
191
+ ensureHeartbeat(h);
192
+ }
193
+ };
194
+ const idleLevel = (h) => {
195
+ for (const rec of h.clients.values()) if (rec.inflight.size > 0) return false;
196
+ return true;
197
+ };
198
+ const flush = (h, fin) => {
199
+ flushTapSync(() => {});
200
+ h.stateHost?.takeChanged();
201
+ for (const rec of h.clients.values()) for (const adm of rec.inflight.values()) if (adm.applied && !adm.settled && !adm.appliedSent) {
202
+ adm.appliedSent = true;
203
+ queueApplied(h, rec.id, adm.seq);
204
+ }
205
+ const level = idleLevel(h);
206
+ const edge = level !== h.idleWire;
207
+ if (h.pendingOps.length === 0 && h.pendingApplied.size === 0 && h.pendingCmds.size === 0 && fin === void 0 && !edge) return;
208
+ h.idleWire = level;
209
+ if (fin === void 0 && edge && level && h.finOnIdle) fin = { reason: "idle" };
210
+ const ops = h.pendingOps;
211
+ const applied = h.pendingApplied;
212
+ const cmds = h.pendingCmds;
213
+ h.pendingOps = [];
214
+ h.pendingApplied = /* @__PURE__ */ new Map();
215
+ h.pendingCmds = /* @__PURE__ */ new Map();
216
+ for (const conn of [...h.conns]) {
217
+ const appliedEntries = (applied.get(conn.clientId) ?? []).map((seq) => ({
246
218
  seq,
247
- type: "result-unavailable"
248
- };
249
- };
250
- const settleResponse = (rec, adm, response) => {
251
- if (adm.settled) return;
252
- adm.settled = true;
253
- rec.inflight.delete(adm.seq);
254
- retain(rec, response);
255
- if (!adm.acked) {
256
- adm.acked = true;
257
- advance(rec, adm.seq);
258
- }
259
- if (adm.pendingSent || response.type !== "accepted" || response.payload !== void 0) queueRes(rec.id, response);
260
- scheduleFlush();
261
- adm.release();
262
- };
263
- const settleWire = (rec, adm, outcome) => settleResponse(rec, adm, responseOf(adm, outcome));
264
- const callerOf = (rec) => ({
265
- clientId: rec.id,
266
- get context() {
267
- return rec.context;
268
- },
269
- get isConnected() {
270
- if (clients.get(rec.id) !== rec) return false;
271
- for (const conn of conns) if (conn.clientId === rec.id && conn.open) return true;
272
- return false;
219
+ type: "applied"
220
+ }));
221
+ if (ops.length > 0) {
222
+ const packet = {
223
+ ops,
224
+ ...appliedEntries.length > 0 && { cmd: appliedEntries },
225
+ ...edge && { idle: level }
226
+ };
227
+ sendTo(h, conn, JSON.stringify(packet));
228
+ } else if (appliedEntries.length > 0) sendTo(h, conn, JSON.stringify({ cmd: appliedEntries }));
229
+ const verdicts = cmds.get(conn.clientId) ?? [];
230
+ if (verdicts.length > 0) sendTo(h, conn, JSON.stringify({ cmd: verdicts }));
231
+ if (edge && ops.length === 0) sendTo(h, conn, JSON.stringify({ idle: level }));
232
+ if (fin !== void 0) {
233
+ sendTo(h, conn, JSON.stringify({ fin }));
234
+ closeConn(h, conn);
273
235
  }
236
+ }
237
+ };
238
+ const queueApplied = (h, clientId, seq) => {
239
+ const list = h.pendingApplied.get(clientId);
240
+ if (list) list.push(seq);
241
+ else h.pendingApplied.set(clientId, [seq]);
242
+ scheduleFlush(h);
243
+ };
244
+ const queueCmd = (h, clientId, cmd) => {
245
+ const list = h.pendingCmds.get(clientId);
246
+ if (list) list.push(cmd);
247
+ else h.pendingCmds.set(clientId, [cmd]);
248
+ scheduleFlush(h);
249
+ };
250
+ const retain = (rec, cmd) => {
251
+ const now = Date.now();
252
+ rec.results.set(cmd.seq, {
253
+ ts: now,
254
+ cmd
274
255
  });
275
- const admit = (conn, frame) => {
276
- if (!conn.open || disposed || finished !== null) return;
277
- const rec = clients.get(conn.clientId);
278
- if (frame.seq <= rec.lastSeq || rec.inflight.has(frame.seq)) {
279
- queueRes(rec.id, duplicateAnswer(rec, frame.seq));
280
- scheduleFlush();
256
+ const newest = Math.max(...rec.results.keys());
257
+ for (const [seq, entry] of rec.results) if (seq !== newest && now - entry.ts > RESULT_TTL_MS) rec.results.delete(seq);
258
+ while (rec.results.size > RESULT_LIMIT) rec.results.delete(Math.min(...rec.results.keys()));
259
+ };
260
+ /** Cmd-lane watermark: the last statement admitted, settled or not. */
261
+ const admittedThrough = (rec) => Math.max(rec.lastSeq, ...rec.inflight.keys());
262
+ const reemit = (h, rec, seq) => {
263
+ const cached = rec.results.get(seq);
264
+ if (cached) {
265
+ if (seq === Math.max(...rec.results.keys()) || Date.now() - cached.ts <= RESULT_TTL_MS) {
266
+ queueCmd(h, rec.id, cached.cmd);
281
267
  return;
282
268
  }
283
- if (frame.seq > rec.lastSeq + 1) {
284
- closeConn(conn, 1008, `seq gap: expected ${rec.lastSeq + 1}, got ${frame.seq}`);
269
+ rec.results.delete(seq);
270
+ }
271
+ const adm = rec.inflight.get(seq);
272
+ if (adm) {
273
+ if (adm.applied) queueApplied(h, rec.id, seq);
274
+ return;
275
+ }
276
+ queueCmd(h, rec.id, {
277
+ seq,
278
+ type: "expired"
279
+ });
280
+ };
281
+ const settleCmd = (h, rec, adm, cmd) => {
282
+ if (adm.settled) return;
283
+ adm.settled = true;
284
+ rec.inflight.delete(adm.seq);
285
+ retain(rec, cmd);
286
+ rec.lastSeq = Math.max(rec.lastSeq, adm.seq);
287
+ adm.applied = true;
288
+ queueCmd(h, rec.id, cmd);
289
+ };
290
+ const settleWire = (h, rec, adm, outcome) => settleCmd(h, rec, adm, cmdOf(adm, outcome));
291
+ const callerOf = (h, rec) => ({
292
+ clientId: rec.id,
293
+ get context() {
294
+ return rec.context;
295
+ },
296
+ get isConnected() {
297
+ if (h.clients.get(rec.id) !== rec) return false;
298
+ for (const conn of h.conns) if (conn.clientId === rec.id && conn.open) return true;
299
+ return false;
300
+ }
301
+ });
302
+ const rejectStatement = (h, rec, adm, message) => settleCmd(h, rec, adm, {
303
+ seq: adm.seq,
304
+ type: "rejected",
305
+ code: "invalid-params",
306
+ message
307
+ });
308
+ const admitCmd = (h, rec, statement) => {
309
+ const adm = {
310
+ seq: statement.seq,
311
+ method: statement.method,
312
+ applied: false,
313
+ appliedSent: false,
314
+ settled: false
315
+ };
316
+ rec.inflight.set(statement.seq, adm);
317
+ if (!Array.isArray(statement.params)) {
318
+ rejectStatement(h, rec, adm, "params must be an array");
319
+ return;
320
+ }
321
+ let params = statement.params;
322
+ if (statement.meta !== void 0) try {
323
+ params = reviveValue(params, statement.meta);
324
+ } catch (error) {
325
+ rejectStatement(h, rec, adm, `invalid params: ${error.message}`);
326
+ return;
327
+ }
328
+ if (statement.method === "statewire/commands") {
329
+ if (params.length > 0) {
330
+ rejectStatement(h, rec, adm, "statewire/commands takes no params");
285
331
  return;
286
332
  }
287
- const adm = {
288
- seq: frame.seq,
289
- method: frame.method ?? "",
290
- acked: false,
291
- pendingSent: false,
292
- settled: false,
293
- release: () => {}
294
- };
295
- if (frame.context !== void 0) {
296
- let value;
297
- try {
298
- value = contextValue(frame.context);
299
- } catch (error) {
300
- rec.inflight.set(frame.seq, adm);
301
- settleResponse(rec, adm, {
302
- seq: frame.seq,
303
- type: "rejected",
304
- code: "invalid-context",
305
- message: `invalid context: ${error.message}`
306
- });
307
- return;
308
- }
309
- rec.context = value;
310
- if (frame.method === void 0) {
311
- rec.inflight.set(frame.seq, adm);
312
- settleResponse(rec, adm, {
313
- seq: frame.seq,
314
- type: "accepted"
315
- });
316
- return;
317
- }
318
- }
319
- const released = new Promise((resolve) => {
320
- adm.release = resolve;
333
+ const names = [...h.commandsHost?.methods?.() ?? [], "statewire/commands"].sort();
334
+ settleCmd(h, rec, adm, {
335
+ seq: adm.seq,
336
+ type: "result",
337
+ payload: names
321
338
  });
322
- rec.inflight.set(frame.seq, adm);
323
- commandsHost.submit({
324
- method: frame.method,
325
- params: frame.params,
326
- clientId: rec.id,
327
- caller: callerOf(rec),
328
- onAck: () => {
329
- if (adm.settled || adm.acked) return;
330
- adm.acked = true;
331
- advance(rec, adm.seq);
332
- scheduleFlush();
333
- adm.release();
334
- },
335
- onSettle: (outcome) => settleWire(rec, adm, outcome)
336
- }).catch((error) => {
337
- if (!adm.settled) settleWire(rec, adm, {
338
- type: "rejected",
339
- error
340
- });
339
+ return;
340
+ }
341
+ h.commandsHost.submit({
342
+ method: statement.method,
343
+ params,
344
+ clientId: rec.id,
345
+ caller: callerOf(h, rec),
346
+ onApplied: () => {
347
+ if (adm.settled || adm.applied) return;
348
+ adm.applied = true;
349
+ rec.lastSeq = Math.max(rec.lastSeq, adm.seq);
350
+ scheduleFlush(h);
351
+ },
352
+ onSettle: (outcome) => settleWire(h, rec, adm, outcome)
353
+ }).catch((error) => {
354
+ if (!adm.settled) settleWire(h, rec, adm, {
355
+ type: "rejected",
356
+ error
341
357
  });
342
- return released;
358
+ });
359
+ };
360
+ const admitFrame = (h, conn, frame) => {
361
+ const rec = h.clients.get(conn.clientId);
362
+ rec.touched = Date.now();
363
+ const through = admittedThrough(rec);
364
+ for (const statement of frame.cmd) if (statement.seq <= through) reemit(h, rec, statement.seq);
365
+ const ctxNew = frame.ctx.filter((s) => s.seq > rec.ctxSeq);
366
+ const cmdNew = frame.cmd.filter((s) => s.seq > through);
367
+ if (ctxNew.length > 0 && ctxNew[0].seq !== rec.ctxSeq + 1 || cmdNew.length > 0 && cmdNew[0].seq !== through + 1) {
368
+ sendTo(h, conn, JSON.stringify({ syn: {
369
+ seq: through,
370
+ ctxSeq: rec.ctxSeq
371
+ } }));
372
+ return;
373
+ }
374
+ for (const statement of ctxNew) {
375
+ rec.context = statement.value;
376
+ rec.ctxSeq = statement.seq;
377
+ }
378
+ for (const statement of cmdNew) admitCmd(h, rec, statement);
379
+ if (cmdNew.length > 0) queueCmd(h, rec.id, {
380
+ seq: cmdNew.at(-1).seq,
381
+ dur: true
382
+ });
383
+ scheduleFlush(h);
384
+ };
385
+ const message = (h, conn, data) => {
386
+ if (!conn.open || h.disposed || h.finished !== null) return;
387
+ const first = !conn.sawFrame;
388
+ conn.sawFrame = true;
389
+ let frame;
390
+ try {
391
+ frame = parseFrame(data, first);
392
+ } catch (error) {
393
+ const reason = violationReason(error);
394
+ if (reason === null) throw error;
395
+ closeConn(h, conn, 1008, reason);
396
+ return;
397
+ }
398
+ if (frame.ctx.length === 0 && frame.cmd.length === 0) return;
399
+ admitFrame(h, conn, frame);
400
+ };
401
+ const gcClients = (h) => {
402
+ let excess = h.clients.size - h.clientRecordLimit;
403
+ if (excess <= 0) return;
404
+ const live = new Set([...h.conns].map((conn) => conn.clientId));
405
+ const oldest = [...h.clients.values()].sort((a, b) => a.touched - b.touched);
406
+ for (const rec of oldest) {
407
+ if (excess <= 0) return;
408
+ if (live.has(rec.id) || rec.inflight.size > 0) continue;
409
+ h.clients.delete(rec.id);
410
+ excess -= 1;
411
+ }
412
+ };
413
+ const connect = (h, clientId, socket) => {
414
+ const dead = {
415
+ clientId,
416
+ message: () => {},
417
+ close: () => {}
343
418
  };
344
- const message = (conn, data) => {
345
- if (!conn.open || disposed || finished !== null) return;
346
- const frame = parseFrame(data);
347
- if (frame === null) {
348
- closeConn(conn, 1008, "invalid frame");
349
- return;
350
- }
351
- inbox = inbox.then(() => admit(conn, frame));
419
+ const refuse = (code, reason) => {
420
+ try {
421
+ socket.close(code, reason);
422
+ } catch {}
423
+ return dead;
352
424
  };
353
- const connect = (clientId, socket) => {
354
- const dead = {
355
- clientId,
356
- message: () => {},
357
- close: () => {}
358
- };
359
- const refuse = (code, reason) => {
360
- try {
361
- socket.close(code, reason);
362
- } catch {}
363
- return dead;
364
- };
365
- if (disposed) return refuse(1001, "disposed");
366
- if (!CLIENT_ID_RE.test(clientId)) return refuse(1008, "invalid client id");
367
- flush();
368
- if (finished !== null) {
369
- try {
370
- socket.send(JSON.stringify({ fin: finished }));
371
- } catch {}
372
- return refuse(1e3, "finished");
373
- }
374
- const known = clients.has(clientId);
375
- const rec = known ? clients.get(clientId) : (() => {
376
- const created = {
377
- id: clientId,
378
- lastSeq: 0,
379
- context: null,
380
- results: /* @__PURE__ */ new Map(),
381
- inflight: /* @__PURE__ */ new Map()
382
- };
383
- clients.set(clientId, created);
384
- return created;
385
- })();
386
- for (const other of [...conns]) {
387
- if (other.clientId !== clientId) continue;
388
- sendTo(other, JSON.stringify({ fin: { reason: "superseded" } }));
389
- closeConn(other);
390
- }
391
- const conn = {
392
- clientId,
393
- socket,
394
- open: true,
395
- lastSentAt: 0
396
- };
397
- conns.add(conn);
398
- ensureHeartbeat();
399
- const { value, meta } = encodeValue(stateHost.snapshot());
400
- const snapshot = {
401
- ops: [{
402
- op: "replace",
403
- path: [],
404
- value,
405
- ...meta !== void 0 && { meta }
406
- }],
407
- syn: {
408
- lastSeq: known ? rec.lastSeq : -1,
409
- lease: randomId()
410
- }
411
- };
412
- sendTo(conn, JSON.stringify(snapshot));
413
- return {
414
- clientId,
415
- message: (data) => message(conn, data),
416
- close: () => closeConn(conn)
425
+ if (h.disposed) return refuse(1001, "disposed");
426
+ if (!CLIENT_ID_RE.test(clientId)) return refuse(1008, "invalid client id");
427
+ flush(h);
428
+ if (h.finished !== null) {
429
+ try {
430
+ socket.send(JSON.stringify({ fin: h.finished }));
431
+ } catch {}
432
+ return refuse(1e3, "finished");
433
+ }
434
+ const known = h.clients.has(clientId);
435
+ const rec = known ? h.clients.get(clientId) : (() => {
436
+ const created = {
437
+ id: clientId,
438
+ lastSeq: 0,
439
+ ctxSeq: 0,
440
+ touched: Date.now(),
441
+ context: null,
442
+ results: /* @__PURE__ */ new Map(),
443
+ inflight: /* @__PURE__ */ new Map()
417
444
  };
445
+ h.clients.set(clientId, created);
446
+ return created;
447
+ })();
448
+ rec.touched = Date.now();
449
+ for (const other of [...h.conns]) {
450
+ if (other.clientId !== clientId) continue;
451
+ sendTo(h, other, JSON.stringify({ fin: { reason: "superseded" } }));
452
+ closeConn(h, other);
453
+ }
454
+ const conn = {
455
+ clientId,
456
+ socket,
457
+ open: true,
458
+ sawFrame: false,
459
+ lastSentAt: 0
418
460
  };
419
- const finish = (fin) => {
420
- if (finished !== null || disposed) return;
421
- finished = fin;
422
- flush(fin);
461
+ h.conns.add(conn);
462
+ gcClients(h);
463
+ ensureHeartbeat(h);
464
+ const { value, meta } = encodeValue(h.stateHost.snapshot());
465
+ const hello = {
466
+ ops: [{
467
+ op: "replace",
468
+ path: [],
469
+ value,
470
+ ...meta !== void 0 && { meta }
471
+ }],
472
+ syn: {
473
+ seq: admittedThrough(rec),
474
+ ctxSeq: rec.ctxSeq,
475
+ record: known ? "resumed" : "fresh",
476
+ ...h.idleWire && { idle: true }
477
+ }
423
478
  };
479
+ sendTo(h, conn, JSON.stringify(hello));
424
480
  return {
425
- connect,
426
- finish,
427
- attach,
428
- resume: () => {
429
- disposed = false;
430
- stateHost?.setOnOp(recordOp);
431
- emitHost?.setSink((event) => recordOp({
432
- op: "event",
433
- value: event
434
- }));
435
- },
436
- suspend: () => {
437
- disposed = true;
438
- if (heartbeatTimer !== null) {
439
- clearInterval(heartbeatTimer);
440
- heartbeatTimer = null;
441
- }
442
- stateHost?.setOnOp(null);
443
- emitHost?.setSink(null);
444
- }
481
+ clientId,
482
+ message: (data) => message(h, conn, data),
483
+ close: () => closeConn(h, conn)
445
484
  };
446
485
  };
486
+ const finish = (h, fin) => {
487
+ if (h.finished !== null || h.disposed) return;
488
+ h.finished = fin;
489
+ flush(h, fin);
490
+ };
491
+ const resume = (h) => {
492
+ h.disposed = false;
493
+ h.stateHost?.setOnOp((op) => recordOp(h, op));
494
+ };
495
+ const suspend = (h) => {
496
+ h.disposed = true;
497
+ if (h.heartbeatTimer !== null) {
498
+ clearInterval(h.heartbeatTimer);
499
+ h.heartbeatTimer = null;
500
+ }
501
+ h.stateHost?.setOnOp(null);
502
+ };
447
503
  const useSocketHost = (transport, options) => {
448
- const $ = c(8);
449
- let t0;
450
- if ($[0] !== options) {
451
- t0 = () => createSocketHost(options);
452
- $[0] = options;
453
- $[1] = t0;
454
- } else t0 = $[1];
455
- const [h] = useState(t0);
456
- const value = useResource(transport);
457
- h.attach(value);
458
- let t1;
459
- if ($[2] !== h || $[3] !== value) {
460
- t1 = () => {
461
- h.attach(value);
462
- };
463
- $[2] = h;
464
- $[3] = value;
465
- $[4] = t1;
466
- } else t1 = $[4];
467
- useEffect(t1);
468
- let t2;
469
- let t3;
470
- if ($[5] !== h) {
471
- t2 = () => {
472
- h.resume();
473
- return () => h.suspend();
504
+ const [h] = useState(() => {
505
+ const host = {
506
+ heartbeatMs: 15e3,
507
+ clientRecordLimit: CLIENT_RECORD_LIMIT,
508
+ finOnIdle: false,
509
+ clients: /* @__PURE__ */ new Map(),
510
+ conns: /* @__PURE__ */ new Set(),
511
+ pendingOps: [],
512
+ pendingApplied: /* @__PURE__ */ new Map(),
513
+ pendingCmds: /* @__PURE__ */ new Map(),
514
+ idleWire: true,
515
+ finished: null,
516
+ disposed: false,
517
+ flushScheduled: false,
518
+ heartbeatTimer: null,
519
+ stateHost: null,
520
+ commandsHost: null,
521
+ current: null,
522
+ post: createMacrotaskPoster(() => {
523
+ host.flushScheduled = false;
524
+ flush(host);
525
+ }),
526
+ connect: (clientId, socket) => connect(host, clientId, socket),
527
+ finish: (fin) => finish(host, fin)
474
528
  };
475
- t3 = [h];
476
- $[5] = h;
477
- $[6] = t2;
478
- $[7] = t3;
479
- } else {
480
- t2 = $[6];
481
- t3 = $[7];
482
- }
483
- useEffect(t2, t3);
529
+ return host;
530
+ });
531
+ setHeartbeatMs(h, options?.heartbeatIntervalMs ?? 15e3);
532
+ h.clientRecordLimit = options?.clientRecordLimit ?? CLIENT_RECORD_LIMIT;
533
+ h.finOnIdle = options?.finOnIdle ?? false;
534
+ const value = useResource(transport);
535
+ attach(h, value);
536
+ useEffect(() => {
537
+ attach(h, value);
538
+ });
539
+ useEffect(() => {
540
+ resume(h);
541
+ return () => suspend(h);
542
+ }, [h]);
484
543
  return h;
485
544
  };
486
545
  /**
487
546
  * Transport-agnostic WS binding over a statewire/host transport:
488
547
  * feed it sockets (anything with `send`/`close`) and raw text frames, and it
489
- * speaks the Statewire envelope protocol — snapshot-first attach with `syn`,
490
- * delta/ack/res envelopes, per-client-id supersede, and `fin` on finish.
548
+ * speaks the Statewire packet protocol — snapshot-first hello with `syn`,
549
+ * statement-lane client frames under one admission law, `{cmd}` answer
550
+ * arrays, per-client-id supersede, idle edges, and `fin` on finish.
491
551
  */
492
552
  const StatewireSocketHost = (transport, options) => {
493
553
  const SocketHostRoot = () => {