statewire 0.0.1 → 0.0.3

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 (148) hide show
  1. package/README.md +14 -13
  2. package/dist/client/changes.d.ts +2 -2
  3. package/dist/client/changes.d.ts.map +1 -1
  4. package/dist/client/changes.js +3 -10
  5. package/dist/client/changes.js.map +1 -1
  6. package/dist/client/connection.d.ts +18 -39
  7. package/dist/client/connection.d.ts.map +1 -1
  8. package/dist/client/connection.js +61 -54
  9. package/dist/client/connection.js.map +1 -1
  10. package/dist/client/core.d.ts +7 -11
  11. package/dist/client/core.d.ts.map +1 -1
  12. package/dist/client/core.js +58 -55
  13. package/dist/client/core.js.map +1 -1
  14. package/dist/client/session.d.ts +12 -26
  15. package/dist/client/session.d.ts.map +1 -1
  16. package/dist/client/session.js +70 -43
  17. package/dist/client/session.js.map +1 -1
  18. package/dist/client/sessionStore.d.ts +1 -1
  19. package/dist/client/sessionStore.js.map +1 -1
  20. package/dist/client/timing.d.ts +2 -1
  21. package/dist/client/timing.d.ts.map +1 -1
  22. package/dist/client/timing.js +5 -1
  23. package/dist/client/timing.js.map +1 -1
  24. package/dist/host/adapter.d.ts +8 -0
  25. package/dist/host/adapter.d.ts.map +1 -0
  26. package/dist/host/adapter.js +23 -0
  27. package/dist/host/adapter.js.map +1 -0
  28. package/dist/host/draft.d.ts +21 -7
  29. package/dist/host/draft.d.ts.map +1 -1
  30. package/dist/host/draft.js +190 -103
  31. package/dist/host/draft.js.map +1 -1
  32. package/dist/host/host.d.ts +19 -35
  33. package/dist/host/host.d.ts.map +1 -1
  34. package/dist/host/host.js +86 -54
  35. package/dist/host/host.js.map +1 -1
  36. package/dist/host/socket.d.ts +14 -0
  37. package/dist/host/socket.d.ts.map +1 -0
  38. package/dist/host/socket.js +511 -0
  39. package/dist/host/socket.js.map +1 -0
  40. package/dist/host-internal.d.ts +27 -0
  41. package/dist/host-internal.d.ts.map +1 -0
  42. package/dist/host-internal.js +3 -0
  43. package/dist/host.d.ts +54 -0
  44. package/dist/host.d.ts.map +1 -0
  45. package/dist/host.js +4 -0
  46. package/dist/index.d.ts +5 -28
  47. package/dist/index.js +2 -3
  48. package/dist/statewire.d.ts +97 -10
  49. package/dist/statewire.d.ts.map +1 -1
  50. package/dist/statewire.js +32 -36
  51. package/dist/statewire.js.map +1 -1
  52. package/dist/transports/fake-sse.d.ts +12 -0
  53. package/dist/transports/fake-sse.d.ts.map +1 -0
  54. package/dist/transports/fake-sse.js +39 -0
  55. package/dist/transports/fake-sse.js.map +1 -0
  56. package/dist/transports/fixtures.d.ts +33 -0
  57. package/dist/transports/fixtures.d.ts.map +1 -0
  58. package/dist/transports/fixtures.js +50 -0
  59. package/dist/transports/fixtures.js.map +1 -0
  60. package/dist/transports/remote.d.ts +6 -13
  61. package/dist/transports/remote.d.ts.map +1 -1
  62. package/dist/transports/remote.js +7 -25
  63. package/dist/transports/remote.js.map +1 -1
  64. package/dist/transports/sse.d.ts +32 -20
  65. package/dist/transports/sse.d.ts.map +1 -1
  66. package/dist/transports/sse.js +57 -46
  67. package/dist/transports/sse.js.map +1 -1
  68. package/dist/transports/testing.d.ts +8 -6
  69. package/dist/transports/testing.d.ts.map +1 -1
  70. package/dist/transports/testing.js +19 -46
  71. package/dist/transports/testing.js.map +1 -1
  72. package/dist/transports/ws.d.ts +4 -13
  73. package/dist/transports/ws.d.ts.map +1 -1
  74. package/dist/transports/ws.js +59 -63
  75. package/dist/transports/ws.js.map +1 -1
  76. package/dist/types.d.ts +55 -100
  77. package/dist/types.d.ts.map +1 -1
  78. package/dist/types.js.map +1 -1
  79. package/dist/util/draftTraps.d.ts +6 -0
  80. package/dist/util/draftTraps.d.ts.map +1 -0
  81. package/dist/util/draftTraps.js +25 -0
  82. package/dist/util/draftTraps.js.map +1 -0
  83. package/dist/util/macrotask.d.ts +5 -0
  84. package/dist/util/macrotask.d.ts.map +1 -0
  85. package/dist/util/macrotask.js +25 -0
  86. package/dist/util/macrotask.js.map +1 -0
  87. package/dist/util/methodProxy.d.ts +5 -0
  88. package/dist/util/methodProxy.d.ts.map +1 -0
  89. package/dist/util/methodProxy.js +17 -0
  90. package/dist/util/methodProxy.js.map +1 -0
  91. package/dist/util/randomId.d.ts +5 -0
  92. package/dist/util/randomId.d.ts.map +1 -0
  93. package/dist/util/randomId.js +6 -0
  94. package/dist/util/randomId.js.map +1 -0
  95. package/dist/{client/optimistic.d.ts → util/recordMutations.d.ts} +2 -2
  96. package/dist/util/recordMutations.d.ts.map +1 -0
  97. package/dist/{client/optimistic.js → util/recordMutations.js} +5 -24
  98. package/dist/util/recordMutations.js.map +1 -0
  99. package/dist/util/statePath.d.ts +14 -0
  100. package/dist/util/statePath.d.ts.map +1 -0
  101. package/dist/util/statePath.js +31 -0
  102. package/dist/util/statePath.js.map +1 -0
  103. package/dist/wire/meta.d.ts +6 -1
  104. package/dist/wire/meta.d.ts.map +1 -1
  105. package/dist/wire/meta.js +30 -28
  106. package/dist/wire/meta.js.map +1 -1
  107. package/dist/wire/ops.d.ts +5 -26
  108. package/dist/wire/ops.d.ts.map +1 -1
  109. package/dist/wire/ops.js +67 -80
  110. package/dist/wire/ops.js.map +1 -1
  111. package/package.json +15 -11
  112. package/src/client/changes.ts +5 -15
  113. package/src/client/connection.ts +87 -113
  114. package/src/client/core.ts +57 -91
  115. package/src/client/session.ts +129 -90
  116. package/src/client/sessionStore.ts +1 -1
  117. package/src/client/timing.ts +6 -0
  118. package/src/host/adapter.ts +33 -0
  119. package/src/host/draft.ts +293 -125
  120. package/src/host/host.ts +122 -110
  121. package/src/host/socket.ts +618 -0
  122. package/src/host-internal.ts +28 -0
  123. package/src/host.ts +72 -0
  124. package/src/index.ts +2 -62
  125. package/src/statewire.ts +168 -61
  126. package/src/transports/fake-sse.ts +51 -0
  127. package/src/transports/fixtures.ts +60 -0
  128. package/src/transports/remote.ts +14 -51
  129. package/src/transports/sse.ts +87 -89
  130. package/src/transports/testing.ts +33 -63
  131. package/src/transports/ws.ts +78 -100
  132. package/src/types.ts +52 -131
  133. package/src/util/draftTraps.ts +32 -0
  134. package/src/util/macrotask.ts +25 -0
  135. package/src/util/methodProxy.ts +16 -0
  136. package/src/util/randomId.ts +4 -0
  137. package/src/{client/optimistic.ts → util/recordMutations.ts} +0 -0
  138. package/src/util/statePath.ts +61 -0
  139. package/src/wire/meta.ts +42 -47
  140. package/src/wire/ops.ts +107 -106
  141. package/LICENSE +0 -21
  142. package/dist/client/optimistic.d.ts.map +0 -1
  143. package/dist/client/optimistic.js.map +0 -1
  144. package/dist/index.d.ts.map +0 -1
  145. package/dist/server.d.ts +0 -15
  146. package/dist/server.d.ts.map +0 -1
  147. package/dist/server.js +0 -3
  148. package/src/server.ts +0 -32
@@ -0,0 +1,618 @@
1
+ import { useEffect, useState } from "react";
2
+ import { createTapRoot, flushTapSync, useResource } from "@assistant-ui/tap";
3
+ import type { ResourceElement } from "@assistant-ui/tap";
4
+ import { getCommandsHost, getEmitHost, getStateHost } from "./host";
5
+ import type { CommandsHost, CommandsOutcome } from "./host";
6
+ import { StatewireSendError } from "../types";
7
+ import type { Statewire } from "../types";
8
+ import type { StatewireHost } from "../host";
9
+ import type { StatewireHostInternal } from "../host-internal";
10
+ import { encodeValue, reviveValue } from "../wire/meta";
11
+ import { createMacrotaskPoster } from "../util/macrotask";
12
+ import { randomId } from "../util/randomId";
13
+ import type { StatewireMetaEntry } from "../wire/meta";
14
+ import type { StatewirePath } from "../wire/ops";
15
+ import type {
16
+ StatewireEnvelope,
17
+ StatewireResponse,
18
+ } from "../client/connection";
19
+
20
+ const CLIENT_ID_RE = /^[A-Za-z0-9._~-]{1,256}$/;
21
+ const RESULT_TTL_MS = 30_000;
22
+ const RESULT_LIMIT = 256;
23
+
24
+ type WireOp = {
25
+ op: string;
26
+ path?: StatewirePath;
27
+ value?: unknown;
28
+ meta?: StatewireMetaEntry[];
29
+ };
30
+
31
+ type Admission = {
32
+ seq: number;
33
+ method: string;
34
+ acked: boolean;
35
+ pendingSent: boolean;
36
+ settled: boolean;
37
+ release: () => void;
38
+ };
39
+
40
+ type ClientRec = {
41
+ id: string;
42
+ lastSeq: number;
43
+ context: Record<string, unknown> | null;
44
+ results: Map<number, { ts: number; res: StatewireResponse }>;
45
+ inflight: Map<number, Admission>;
46
+ };
47
+
48
+ type Conn = {
49
+ clientId: string;
50
+ socket: StatewireHostInternal.Socket;
51
+ open: boolean;
52
+ lastSentAt: number;
53
+ };
54
+
55
+ type Frame = {
56
+ method?: string;
57
+ seq: number;
58
+ params: unknown[];
59
+ context?: unknown;
60
+ };
61
+
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;
88
+ }
89
+ }
90
+ return { method, seq, params, ...(context !== undefined && { context }) };
91
+ };
92
+
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) {
96
+ throw new Error("context must be a single-op array");
97
+ }
98
+ const op = context[0] as Record<string, unknown> | null;
99
+ if (
100
+ typeof op !== "object" ||
101
+ op === null ||
102
+ op["op"] !== "replace" ||
103
+ !Array.isArray(op["path"]) ||
104
+ op["path"].length !== 0
105
+ ) {
106
+ throw new Error("context op must be a root replace");
107
+ }
108
+ const value = op["value"];
109
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
110
+ throw new Error("context value must be an object");
111
+ }
112
+ return value as Record<string, unknown>;
113
+ };
114
+
115
+ const encodePayload = (
116
+ payload: unknown,
117
+ ): { payload?: unknown; meta?: StatewireMetaEntry[] } => {
118
+ if (payload === undefined) return {};
119
+ const { value, meta } = encodeValue(payload);
120
+ return { payload: value, ...(meta !== undefined && { meta }) };
121
+ };
122
+
123
+ const responseOf = (
124
+ adm: Admission,
125
+ outcome: CommandsOutcome,
126
+ ): StatewireResponse => {
127
+ if (outcome.type === "accepted") {
128
+ return {
129
+ seq: adm.seq,
130
+ type: "accepted",
131
+ ...encodePayload(outcome.payload),
132
+ };
133
+ }
134
+ const failure =
135
+ outcome.error instanceof StatewireSendError ? outcome.error.failure : null;
136
+ switch (failure?.fate) {
137
+ case "unknown-command":
138
+ return {
139
+ seq: adm.seq,
140
+ type: "rejected",
141
+ code: "unknown-command",
142
+ message: `unknown method: '${adm.method}'`,
143
+ };
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":
154
+ return {
155
+ seq: adm.seq,
156
+ type: "rejected",
157
+ message: failure.message,
158
+ ...encodePayload(failure.payload),
159
+ };
160
+ default:
161
+ return {
162
+ seq: adm.seq,
163
+ type: "rejected",
164
+ message:
165
+ outcome.error instanceof Error
166
+ ? outcome.error.message
167
+ : String(outcome.error),
168
+ };
169
+ }
170
+ };
171
+
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;
192
+
193
+ const post = createMacrotaskPoster(() => {
194
+ flushScheduled = false;
195
+ flush();
196
+ });
197
+ const scheduleFlush = () => {
198
+ if (flushScheduled || disposed) return;
199
+ flushScheduled = true;
200
+ post();
201
+ };
202
+
203
+ const recordOp = (op: {
204
+ op: string;
205
+ path?: StatewirePath;
206
+ 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
+ };
216
+
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
+ };
243
+
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
+ };
258
+
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);
266
+ }
267
+ };
268
+
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
+ };
279
+
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
+ }
289
+ }
290
+ }
291
+ if (
292
+ pendingOps.length === 0 &&
293
+ dirtyAcks.size === 0 &&
294
+ pendingRes.size === 0 &&
295
+ fin === undefined
296
+ ) {
297
+ return;
298
+ }
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);
318
+ }
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
+ }
340
+ }
341
+ while (rec.results.size > RESULT_LIMIT) {
342
+ rec.results.delete(Math.min(...rec.results.keys()));
343
+ }
344
+ };
345
+
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
+ }
353
+ rec.results.delete(seq);
354
+ }
355
+ if (rec.inflight.has(seq)) return { seq, type: "pending" };
356
+ return { seq, type: "result-unavailable" };
357
+ };
358
+
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);
378
+ }
379
+ scheduleFlush();
380
+ adm.release();
381
+ };
382
+
383
+ const settleWire = (
384
+ rec: ClientRec,
385
+ adm: Admission,
386
+ outcome: CommandsOutcome,
387
+ ) => settleResponse(rec, adm, responseOf(adm, outcome));
388
+
389
+ const callerOf = (rec: ClientRec): StatewireHost.Caller => ({
390
+ get context() {
391
+ return rec.context;
392
+ },
393
+ get isConnected() {
394
+ if (clients.get(rec.id) !== rec) return false;
395
+ for (const conn of conns) {
396
+ if (conn.clientId === rec.id && conn.open) return true;
397
+ }
398
+ return false;
399
+ },
400
+ });
401
+
402
+ const admit = (conn: Conn, frame: Frame): Promise<void> | void => {
403
+ if (!conn.open || disposed || finished !== null) return;
404
+ const rec = clients.get(conn.clientId)!;
405
+ if (frame.seq <= rec.lastSeq || rec.inflight.has(frame.seq)) {
406
+ queueRes(rec.id, duplicateAnswer(rec, frame.seq));
407
+ scheduleFlush();
408
+ return;
409
+ }
410
+ if (frame.seq > rec.lastSeq + 1) {
411
+ closeConn(
412
+ conn,
413
+ 1008,
414
+ `seq gap: expected ${rec.lastSeq + 1}, got ${frame.seq}`,
415
+ );
416
+ return;
417
+ }
418
+ const adm: Admission = {
419
+ seq: frame.seq,
420
+ method: frame.method ?? "",
421
+ acked: false,
422
+ pendingSent: false,
423
+ settled: false,
424
+ release: () => {},
425
+ };
426
+ if (frame.context !== undefined) {
427
+ let value: Record<string, unknown>;
428
+ try {
429
+ value = contextValue(frame.context);
430
+ } catch (error) {
431
+ rec.inflight.set(frame.seq, adm);
432
+ settleResponse(rec, adm, {
433
+ seq: frame.seq,
434
+ type: "rejected",
435
+ code: "invalid-context",
436
+ message: `invalid context: ${(error as Error).message}`,
437
+ });
438
+ return;
439
+ }
440
+ rec.context = value;
441
+ if (frame.method === undefined) {
442
+ rec.inflight.set(frame.seq, adm);
443
+ settleResponse(rec, adm, { seq: frame.seq, type: "accepted" });
444
+ return;
445
+ }
446
+ }
447
+ const released = new Promise<void>((resolve) => {
448
+ adm.release = resolve;
449
+ });
450
+ rec.inflight.set(frame.seq, adm);
451
+ commandsHost!.submit!({
452
+ method: frame.method!,
453
+ params: frame.params,
454
+ clientId: rec.id,
455
+ caller: callerOf(rec),
456
+ onAck: () => {
457
+ if (adm.settled || adm.acked) return;
458
+ adm.acked = true;
459
+ advance(rec, adm.seq);
460
+ scheduleFlush();
461
+ adm.release();
462
+ },
463
+ onSettle: (outcome) => settleWire(rec, adm, outcome),
464
+ }).catch((error: unknown) => {
465
+ if (!adm.settled) settleWire(rec, adm, { type: "rejected", error });
466
+ });
467
+ return released;
468
+ };
469
+
470
+ const message = (conn: Conn, data: string) => {
471
+ if (!conn.open || disposed || finished !== null) return;
472
+ const frame = parseFrame(data);
473
+ if (frame === null) {
474
+ closeConn(conn, 1008, "invalid frame");
475
+ return;
476
+ }
477
+ inbox = inbox.then(() => admit(conn, frame));
478
+ };
479
+
480
+ const connect = (
481
+ clientId: string,
482
+ socket: StatewireHostInternal.Socket,
483
+ ): StatewireHostInternal.SocketConnection => {
484
+ const dead: StatewireHostInternal.SocketConnection = {
485
+ clientId,
486
+ message: () => {},
487
+ close: () => {},
488
+ };
489
+ const refuse = (code: number, reason: string) => {
490
+ try {
491
+ socket.close(code, reason);
492
+ } catch {
493
+ /* peer already gone */
494
+ }
495
+ return dead;
496
+ };
497
+ if (disposed) return refuse(1001, "disposed");
498
+ if (!CLIENT_ID_RE.test(clientId)) return refuse(1008, "invalid client id");
499
+ flush();
500
+ if (finished !== null) {
501
+ try {
502
+ socket.send(JSON.stringify({ fin: finished }));
503
+ } catch {
504
+ /* peer already gone */
505
+ }
506
+ return refuse(1000, "finished");
507
+ }
508
+ const known = clients.has(clientId);
509
+ const rec = known
510
+ ? clients.get(clientId)!
511
+ : (() => {
512
+ const created: ClientRec = {
513
+ id: clientId,
514
+ lastSeq: 0,
515
+ context: null,
516
+ results: new Map(),
517
+ inflight: new Map(),
518
+ };
519
+ clients.set(clientId, created);
520
+ return created;
521
+ })();
522
+ for (const other of [...conns]) {
523
+ if (other.clientId !== clientId) continue;
524
+ sendTo(other, JSON.stringify({ fin: { reason: "superseded" } }));
525
+ closeConn(other);
526
+ }
527
+ const conn: Conn = { clientId, socket, open: true, lastSentAt: 0 };
528
+ conns.add(conn);
529
+ ensureHeartbeat();
530
+ const { value, meta } = encodeValue(stateHost!.snapshot());
531
+ const snapshot: StatewireEnvelope = {
532
+ ops: [
533
+ {
534
+ op: "replace",
535
+ path: [],
536
+ value,
537
+ ...(meta !== undefined && { meta }),
538
+ },
539
+ ],
540
+ syn: { lastSeq: known ? rec.lastSeq : -1, lease: randomId() },
541
+ };
542
+ sendTo(conn, JSON.stringify(snapshot));
543
+ return {
544
+ clientId,
545
+ message: (data) => message(conn, data),
546
+ close: () => closeConn(conn),
547
+ };
548
+ };
549
+
550
+ const finish = (fin: StatewireHost.Fin) => {
551
+ if (finished !== null || disposed) return;
552
+ finished = fin;
553
+ flush(fin);
554
+ };
555
+
556
+ return {
557
+ connect,
558
+ finish,
559
+ attach,
560
+ resume: () => {
561
+ disposed = false;
562
+ stateHost?.setOnOp(recordOp);
563
+ emitHost?.setSink((event) => recordOp({ op: "event", value: event }));
564
+ },
565
+ suspend: () => {
566
+ disposed = true;
567
+ if (heartbeatTimer !== null) {
568
+ clearInterval(heartbeatTimer);
569
+ heartbeatTimer = null;
570
+ }
571
+ stateHost?.setOnOp(null);
572
+ emitHost?.setSink(null);
573
+ },
574
+ };
575
+ };
576
+
577
+ const useSocketHost = (
578
+ transport: ResourceElement<Statewire>,
579
+ options?: StatewireHostInternal.SocketHostOptions,
580
+ ): Pick<StatewireHostInternal.SocketHost, "connect" | "finish"> => {
581
+ const [h] = useState(() => createSocketHost(options));
582
+ const value = useResource(transport) as Statewire<unknown>;
583
+ h.attach(value);
584
+ useEffect(() => {
585
+ h.attach(value);
586
+ });
587
+ useEffect(() => {
588
+ h.resume();
589
+ return () => h.suspend();
590
+ }, [h]);
591
+ return h;
592
+ };
593
+
594
+ /**
595
+ * Transport-agnostic WS binding over a statewire/host transport:
596
+ * feed it sockets (anything with `send`/`close`) and raw text frames, and it
597
+ * speaks the Statewire envelope protocol — snapshot-first attach with `syn`,
598
+ * delta/ack/res envelopes, per-client-id supersede, and `fin` on finish.
599
+ */
600
+ export const StatewireSocketHost = (
601
+ transport: ResourceElement<Statewire>,
602
+ options?: StatewireHostInternal.SocketHostOptions,
603
+ ): StatewireHostInternal.SocketHost => {
604
+ const SocketHostRoot = () => useSocketHost(transport, options);
605
+ const root = createTapRoot(SocketHostRoot);
606
+ let disposed = false;
607
+ return {
608
+ connect: (clientId, socket) =>
609
+ flushTapSync(() => root.getValue().connect(clientId, socket)),
610
+ finish: (fin) => flushTapSync(() => root.getValue().finish(fin)),
611
+ dispose: () => {
612
+ if (disposed) return;
613
+ disposed = true;
614
+ flushTapSync(() => root.getValue().finish({ reason: "evicted" }));
615
+ root.unmount();
616
+ },
617
+ };
618
+ };
@@ -0,0 +1,28 @@
1
+ import type { StatewireHost } from "./host";
2
+
3
+ export { StatewireSocketHost } from "./host/socket";
4
+ export { clientIdFromUrl, textFrameOrClose } from "./host/adapter";
5
+ export namespace StatewireHostInternal {
6
+ export type Socket = {
7
+ send: (data: string) => void;
8
+ close: (code?: number, reason?: string) => void;
9
+ };
10
+
11
+ export type SocketConnection = {
12
+ readonly clientId: string;
13
+ message: (data: string) => void;
14
+ close: () => void;
15
+ };
16
+
17
+ export type SocketHost = {
18
+ connect: (clientId: string, socket: Socket) => SocketConnection;
19
+ finish: (fin: StatewireHost.Fin) => void;
20
+ dispose: () => void;
21
+ };
22
+
23
+ export type SocketHostOptions = {
24
+ heartbeatIntervalMs?: number;
25
+ };
26
+
27
+ export type Fin = StatewireHost.Fin;
28
+ }
package/src/host.ts ADDED
@@ -0,0 +1,72 @@
1
+ import type { Statewire } from "./types";
2
+ import type { ServerOp } from "./host/draft";
3
+
4
+ export { StatewireReject } from "./host/engine";
5
+ export { append } from "./host/draft";
6
+ export {
7
+ CommandExecution,
8
+ getStateHost,
9
+ useStatewireState,
10
+ useStatewireCommands,
11
+ useStatewireEmit,
12
+ } from "./host/host";
13
+
14
+ declare const executionResult: unique symbol;
15
+
16
+ type UnwrapExecution<V> = V extends StatewireHost.Execution<infer T> ? T : V;
17
+
18
+ export namespace StatewireHost {
19
+ export type StateCtl<TState = unknown> = {
20
+ /** Apply server-vocabulary ops (root replace = whole-state swap). */
21
+ apply: (ops: readonly Statewire.Op[]) => void;
22
+ /** The committed plain state — immutably replaced per op, so a captured value is a stable snapshot. */
23
+ snapshot: () => TState;
24
+ };
25
+
26
+ export type StateHost = Statewire.StateSource & {
27
+ /** Synchronous, per locally-recorded op; the host owns batching. */
28
+ setOnOp: (listener: ((op: ServerOp) => void) | null) => void;
29
+ };
30
+
31
+ export type StateSource = Statewire.StateSource;
32
+
33
+ /**
34
+ * Handle a command handler returns to keep the command open past its own
35
+ * return: `ack()` releases the client's optimistic overlay / ack watermark,
36
+ * `resolve`/`reject` settle the command (and imply ack). `ack` is idempotent
37
+ * and a no-op after settle.
38
+ */
39
+ export type Execution<T = void> = {
40
+ readonly [executionResult]: (value: T) => void;
41
+ };
42
+
43
+ export type ExecutionResolvers<T = void> = {
44
+ ack: () => void;
45
+ resolve: (value: T) => void;
46
+ reject: (error: unknown) => void;
47
+ };
48
+
49
+ /**
50
+ * The submitting client, bound as `this` of a command handler. Holdable
51
+ * across a long run: `context` reads the client's current ambient context
52
+ * live (null until the client sends one), `isConnected` reports whether the
53
+ * client has an active stream attachment.
54
+ */
55
+ export type Caller = {
56
+ readonly context: Record<string, unknown> | null;
57
+ readonly isConnected: boolean;
58
+ };
59
+
60
+ export type HandledCommands<THandlers extends Statewire.Commands> = {
61
+ [K in keyof THandlers]: (
62
+ ...args: Parameters<THandlers[K]>
63
+ ) => UnwrapExecution<Awaited<ReturnType<THandlers[K]>>>;
64
+ };
65
+
66
+ export type Fin = {
67
+ reason: "evicted" | "error" | "gone" | "superseded" | (string & {});
68
+ message?: string;
69
+ payload?: unknown;
70
+ meta?: unknown;
71
+ };
72
+ }