statewire 0.0.1 → 0.0.2

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 +8 -25
  15. package/dist/client/session.d.ts.map +1 -1
  16. package/dist/client/session.js +56 -36
  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 +18 -35
  33. package/dist/host/host.d.ts.map +1 -1
  34. package/dist/host/host.js +81 -53
  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 +459 -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 +44 -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 +94 -10
  49. package/dist/statewire.d.ts.map +1 -1
  50. package/dist/statewire.js +23 -35
  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 +5 -24
  63. package/dist/transports/remote.js.map +1 -1
  64. package/dist/transports/sse.d.ts +10 -20
  65. package/dist/transports/sse.d.ts.map +1 -1
  66. package/dist/transports/sse.js +42 -41
  67. package/dist/transports/sse.js.map +1 -1
  68. package/dist/transports/testing.d.ts +4 -3
  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 +57 -60
  75. package/dist/transports/ws.js.map +1 -1
  76. package/dist/types.d.ts +53 -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 +87 -70
  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 +107 -106
  121. package/src/host/socket.ts +542 -0
  122. package/src/host-internal.ts +28 -0
  123. package/src/host.ts +61 -0
  124. package/src/index.ts +2 -62
  125. package/src/statewire.ts +154 -61
  126. package/src/transports/fake-sse.ts +51 -0
  127. package/src/transports/fixtures.ts +60 -0
  128. package/src/transports/remote.ts +12 -54
  129. package/src/transports/sse.ts +68 -81
  130. package/src/transports/testing.ts +27 -59
  131. package/src/transports/ws.ts +76 -92
  132. package/src/types.ts +50 -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,542 @@
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
+ results: Map<number, { ts: number; res: StatewireResponse }>;
44
+ inflight: Map<number, Admission>;
45
+ };
46
+
47
+ type Conn = {
48
+ clientId: string;
49
+ socket: StatewireHostInternal.Socket;
50
+ open: boolean;
51
+ lastSentAt: number;
52
+ };
53
+
54
+ type Frame = { method: string; seq: number; params: unknown[] };
55
+
56
+ const parseFrame = (raw: string): Frame | null => {
57
+ let frame: unknown;
58
+ try {
59
+ frame = JSON.parse(raw);
60
+ } catch {
61
+ return null;
62
+ }
63
+ if (typeof frame !== "object" || frame === null || Array.isArray(frame)) {
64
+ return null;
65
+ }
66
+ const { method, seq, params, meta } = frame as Record<string, unknown>;
67
+ if (typeof method !== "string") return null;
68
+ if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 1) return null;
69
+ if (!Array.isArray(params)) return null;
70
+ if (meta !== undefined) {
71
+ try {
72
+ reviveValue(params, meta);
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+ return { method, seq, params };
78
+ };
79
+
80
+ const encodePayload = (
81
+ payload: unknown,
82
+ ): { payload?: unknown; meta?: StatewireMetaEntry[] } => {
83
+ if (payload === undefined) return {};
84
+ const { value, meta } = encodeValue(payload);
85
+ return { payload: value, ...(meta !== undefined && { meta }) };
86
+ };
87
+
88
+ const responseOf = (
89
+ adm: Admission,
90
+ outcome: CommandsOutcome,
91
+ ): StatewireResponse => {
92
+ if (outcome.type === "accepted") {
93
+ return {
94
+ seq: adm.seq,
95
+ type: "accepted",
96
+ ...encodePayload(outcome.payload),
97
+ };
98
+ }
99
+ const failure =
100
+ outcome.error instanceof StatewireSendError ? outcome.error.failure : null;
101
+ switch (failure?.fate) {
102
+ case "unknown-command":
103
+ return {
104
+ seq: adm.seq,
105
+ type: "rejected",
106
+ code: "unknown-command",
107
+ message: `unknown method: '${adm.method}'`,
108
+ };
109
+ case "invalid-params":
110
+ return {
111
+ seq: adm.seq,
112
+ type: "rejected",
113
+ code: "invalid-params",
114
+ message: failure.message,
115
+ };
116
+ case "crashed":
117
+ return { seq: adm.seq, type: "crashed" };
118
+ case "rejected":
119
+ return {
120
+ seq: adm.seq,
121
+ type: "rejected",
122
+ message: failure.message,
123
+ ...encodePayload(failure.payload),
124
+ };
125
+ default:
126
+ return {
127
+ seq: adm.seq,
128
+ type: "rejected",
129
+ message:
130
+ outcome.error instanceof Error
131
+ ? outcome.error.message
132
+ : String(outcome.error),
133
+ };
134
+ }
135
+ };
136
+
137
+ const createSocketHost = (
138
+ options?: StatewireHostInternal.SocketHostOptions,
139
+ ) => {
140
+ const heartbeatMs = options?.heartbeatIntervalMs ?? 15_000;
141
+
142
+ const clients = new Map<string, ClientRec>();
143
+ const conns = new Set<Conn>();
144
+ let pendingOps: WireOp[] = [];
145
+ let dirtyAcks = new Set<string>();
146
+ let pendingRes = new Map<string, StatewireResponse[]>();
147
+ let inbox: Promise<void> = Promise.resolve();
148
+ let finished: StatewireHost.Fin | null = null;
149
+ let disposed = false;
150
+ let flushScheduled = false;
151
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
152
+
153
+ let stateHost: StatewireHost.StateHost | null = null;
154
+ let commandsHost: CommandsHost | null = null;
155
+ let emitHost: ReturnType<typeof getEmitHost> | null = null;
156
+ let current: Statewire<unknown> | null = null;
157
+
158
+ const post = createMacrotaskPoster(() => {
159
+ flushScheduled = false;
160
+ flush();
161
+ });
162
+ const scheduleFlush = () => {
163
+ if (flushScheduled || disposed) return;
164
+ flushScheduled = true;
165
+ post();
166
+ };
167
+
168
+ const recordOp = (op: {
169
+ op: string;
170
+ path?: StatewirePath;
171
+ value?: unknown;
172
+ }) => {
173
+ if (op.op === "replace" || op.op === "add" || op.op === "event") {
174
+ const { value, meta } = encodeValue(op.value);
175
+ pendingOps.push({ ...op, value, ...(meta !== undefined && { meta }) });
176
+ } else {
177
+ pendingOps.push(op);
178
+ }
179
+ scheduleFlush();
180
+ };
181
+
182
+ const attach = (next: Statewire<unknown>) => {
183
+ if (next === current) return;
184
+ current = next;
185
+ const nextState =
186
+ typeof next.state === "object" && next.state !== null
187
+ ? getStateHost(next.state)
188
+ : undefined;
189
+ const nextCommands = getCommandsHost(next.commands);
190
+ const nextEmit = getEmitHost(next.emit);
191
+ if (!nextState || !nextCommands?.submit || !nextEmit) {
192
+ throw new Error(
193
+ "StatewireSocketHost requires a statewire/host transport (useStatewireState with object state, useStatewireCommands, useStatewireEmit)",
194
+ );
195
+ }
196
+ if (nextState !== stateHost) {
197
+ stateHost?.setOnOp(null);
198
+ stateHost = nextState;
199
+ stateHost.setOnOp(recordOp);
200
+ }
201
+ commandsHost = nextCommands;
202
+ if (nextEmit !== emitHost) {
203
+ emitHost?.setSink(null);
204
+ emitHost = nextEmit;
205
+ emitHost.setSink((event) => recordOp({ op: "event", value: event }));
206
+ }
207
+ };
208
+
209
+ const closeConn = (conn: Conn, code?: number, reason?: string) => {
210
+ if (!conn.open) return;
211
+ conn.open = false;
212
+ conns.delete(conn);
213
+ try {
214
+ conn.socket.close(code, reason);
215
+ } catch {
216
+ /* peer already gone */
217
+ }
218
+ if (conns.size === 0 && heartbeatTimer !== null) {
219
+ clearInterval(heartbeatTimer);
220
+ heartbeatTimer = null;
221
+ }
222
+ };
223
+
224
+ const sendTo = (conn: Conn, data: string) => {
225
+ if (!conn.open) return;
226
+ conn.lastSentAt = Date.now();
227
+ try {
228
+ conn.socket.send(data);
229
+ } catch {
230
+ closeConn(conn);
231
+ }
232
+ };
233
+
234
+ const ensureHeartbeat = () => {
235
+ if (heartbeatTimer !== null || heartbeatMs <= 0) return;
236
+ heartbeatTimer = setInterval(() => {
237
+ const now = Date.now();
238
+ for (const conn of [...conns]) {
239
+ if (now - conn.lastSentAt >= heartbeatMs) sendTo(conn, "{}");
240
+ }
241
+ }, heartbeatMs);
242
+ (heartbeatTimer as unknown as { unref?: () => void }).unref?.();
243
+ };
244
+
245
+ const flush = (fin?: StatewireHost.Fin) => {
246
+ flushTapSync(() => {});
247
+ stateHost?.takeChanged();
248
+ for (const rec of clients.values()) {
249
+ for (const adm of rec.inflight.values()) {
250
+ if (adm.acked && !adm.settled && !adm.pendingSent) {
251
+ adm.pendingSent = true;
252
+ queueRes(rec.id, { seq: adm.seq, type: "pending" });
253
+ }
254
+ }
255
+ }
256
+ if (
257
+ pendingOps.length === 0 &&
258
+ dirtyAcks.size === 0 &&
259
+ pendingRes.size === 0 &&
260
+ fin === undefined
261
+ ) {
262
+ return;
263
+ }
264
+ const ops = pendingOps;
265
+ const acks = dirtyAcks;
266
+ const res = pendingRes;
267
+ pendingOps = [];
268
+ dirtyAcks = new Set();
269
+ pendingRes = new Map();
270
+ for (const conn of [...conns]) {
271
+ const envelope: StatewireEnvelope = {};
272
+ if (ops.length > 0) envelope.ops = ops;
273
+ const clientRes = res.get(conn.clientId);
274
+ if (clientRes !== undefined) envelope.res = clientRes;
275
+ if (acks.has(conn.clientId)) {
276
+ const rec = clients.get(conn.clientId);
277
+ if (rec !== undefined) envelope.ack = rec.lastSeq;
278
+ }
279
+ if (fin !== undefined) envelope.fin = fin;
280
+ if (Object.keys(envelope).length === 0) continue;
281
+ sendTo(conn, JSON.stringify(envelope));
282
+ if (fin !== undefined) closeConn(conn);
283
+ }
284
+ };
285
+
286
+ const queueRes = (clientId: string, response: StatewireResponse) => {
287
+ const list = pendingRes.get(clientId);
288
+ if (list) list.push(response);
289
+ else pendingRes.set(clientId, [response]);
290
+ };
291
+
292
+ const advance = (rec: ClientRec, seq: number) => {
293
+ rec.lastSeq = seq;
294
+ dirtyAcks.add(rec.id);
295
+ };
296
+
297
+ const retain = (rec: ClientRec, response: StatewireResponse) => {
298
+ const now = Date.now();
299
+ rec.results.set(response.seq, { ts: now, res: response });
300
+ const newest = Math.max(...rec.results.keys());
301
+ for (const [seq, entry] of rec.results) {
302
+ if (seq !== newest && now - entry.ts > RESULT_TTL_MS) {
303
+ rec.results.delete(seq);
304
+ }
305
+ }
306
+ while (rec.results.size > RESULT_LIMIT) {
307
+ rec.results.delete(Math.min(...rec.results.keys()));
308
+ }
309
+ };
310
+
311
+ const duplicateAnswer = (rec: ClientRec, seq: number): StatewireResponse => {
312
+ const cached = rec.results.get(seq);
313
+ if (cached) {
314
+ const newest = Math.max(...rec.results.keys());
315
+ if (seq === newest || Date.now() - cached.ts <= RESULT_TTL_MS) {
316
+ return cached.res;
317
+ }
318
+ rec.results.delete(seq);
319
+ }
320
+ if (rec.inflight.has(seq)) return { seq, type: "pending" };
321
+ return { seq, type: "result-unavailable" };
322
+ };
323
+
324
+ const settleWire = (
325
+ rec: ClientRec,
326
+ adm: Admission,
327
+ outcome: CommandsOutcome,
328
+ ) => {
329
+ if (adm.settled) return;
330
+ adm.settled = true;
331
+ rec.inflight.delete(adm.seq);
332
+ const response = responseOf(adm, outcome);
333
+ retain(rec, response);
334
+ if (!adm.acked) {
335
+ adm.acked = true;
336
+ advance(rec, adm.seq);
337
+ }
338
+ if (
339
+ adm.pendingSent ||
340
+ response.type !== "accepted" ||
341
+ response.payload !== undefined
342
+ ) {
343
+ queueRes(rec.id, response);
344
+ }
345
+ scheduleFlush();
346
+ adm.release();
347
+ };
348
+
349
+ const admit = (conn: Conn, frame: Frame): Promise<void> | void => {
350
+ if (!conn.open || disposed || finished !== null) return;
351
+ const rec = clients.get(conn.clientId)!;
352
+ if (frame.seq <= rec.lastSeq || rec.inflight.has(frame.seq)) {
353
+ queueRes(rec.id, duplicateAnswer(rec, frame.seq));
354
+ scheduleFlush();
355
+ return;
356
+ }
357
+ if (frame.seq > rec.lastSeq + 1) {
358
+ closeConn(
359
+ conn,
360
+ 1008,
361
+ `seq gap: expected ${rec.lastSeq + 1}, got ${frame.seq}`,
362
+ );
363
+ return;
364
+ }
365
+ const adm: Admission = {
366
+ seq: frame.seq,
367
+ method: frame.method,
368
+ acked: false,
369
+ pendingSent: false,
370
+ settled: false,
371
+ release: () => {},
372
+ };
373
+ const released = new Promise<void>((resolve) => {
374
+ adm.release = resolve;
375
+ });
376
+ rec.inflight.set(frame.seq, adm);
377
+ commandsHost!.submit!({
378
+ method: frame.method,
379
+ params: frame.params,
380
+ clientId: rec.id,
381
+ onAck: () => {
382
+ if (adm.settled || adm.acked) return;
383
+ adm.acked = true;
384
+ advance(rec, adm.seq);
385
+ scheduleFlush();
386
+ adm.release();
387
+ },
388
+ onSettle: (outcome) => settleWire(rec, adm, outcome),
389
+ }).catch((error: unknown) => {
390
+ if (!adm.settled) settleWire(rec, adm, { type: "rejected", error });
391
+ });
392
+ return released;
393
+ };
394
+
395
+ const message = (conn: Conn, data: string) => {
396
+ if (!conn.open || disposed || finished !== null) return;
397
+ const frame = parseFrame(data);
398
+ if (frame === null) {
399
+ closeConn(conn, 1008, "invalid frame");
400
+ return;
401
+ }
402
+ inbox = inbox.then(() => admit(conn, frame));
403
+ };
404
+
405
+ const connect = (
406
+ clientId: string,
407
+ socket: StatewireHostInternal.Socket,
408
+ ): StatewireHostInternal.SocketConnection => {
409
+ const dead: StatewireHostInternal.SocketConnection = {
410
+ clientId,
411
+ message: () => {},
412
+ close: () => {},
413
+ };
414
+ const refuse = (code: number, reason: string) => {
415
+ try {
416
+ socket.close(code, reason);
417
+ } catch {
418
+ /* peer already gone */
419
+ }
420
+ return dead;
421
+ };
422
+ if (disposed) return refuse(1001, "disposed");
423
+ if (!CLIENT_ID_RE.test(clientId)) return refuse(1008, "invalid client id");
424
+ flush();
425
+ if (finished !== null) {
426
+ try {
427
+ socket.send(JSON.stringify({ fin: finished }));
428
+ } catch {
429
+ /* peer already gone */
430
+ }
431
+ return refuse(1000, "finished");
432
+ }
433
+ const known = clients.has(clientId);
434
+ const rec = known
435
+ ? clients.get(clientId)!
436
+ : (() => {
437
+ const created: ClientRec = {
438
+ id: clientId,
439
+ lastSeq: 0,
440
+ results: new Map(),
441
+ inflight: new Map(),
442
+ };
443
+ clients.set(clientId, created);
444
+ return created;
445
+ })();
446
+ for (const other of [...conns]) {
447
+ if (other.clientId !== clientId) continue;
448
+ sendTo(other, JSON.stringify({ fin: { reason: "superseded" } }));
449
+ closeConn(other);
450
+ }
451
+ const conn: Conn = { clientId, socket, open: true, lastSentAt: 0 };
452
+ conns.add(conn);
453
+ ensureHeartbeat();
454
+ const { value, meta } = encodeValue(stateHost!.snapshot());
455
+ const snapshot: StatewireEnvelope = {
456
+ ops: [
457
+ {
458
+ op: "replace",
459
+ path: [],
460
+ value,
461
+ ...(meta !== undefined && { meta }),
462
+ },
463
+ ],
464
+ syn: { lastSeq: known ? rec.lastSeq : -1, lease: randomId() },
465
+ };
466
+ sendTo(conn, JSON.stringify(snapshot));
467
+ return {
468
+ clientId,
469
+ message: (data) => message(conn, data),
470
+ close: () => closeConn(conn),
471
+ };
472
+ };
473
+
474
+ const finish = (fin: StatewireHost.Fin) => {
475
+ if (finished !== null || disposed) return;
476
+ finished = fin;
477
+ flush(fin);
478
+ };
479
+
480
+ return {
481
+ connect,
482
+ finish,
483
+ attach,
484
+ resume: () => {
485
+ disposed = false;
486
+ stateHost?.setOnOp(recordOp);
487
+ emitHost?.setSink((event) => recordOp({ op: "event", value: event }));
488
+ },
489
+ suspend: () => {
490
+ disposed = true;
491
+ if (heartbeatTimer !== null) {
492
+ clearInterval(heartbeatTimer);
493
+ heartbeatTimer = null;
494
+ }
495
+ stateHost?.setOnOp(null);
496
+ emitHost?.setSink(null);
497
+ },
498
+ };
499
+ };
500
+
501
+ const useSocketHost = (
502
+ transport: ResourceElement<Statewire>,
503
+ options?: StatewireHostInternal.SocketHostOptions,
504
+ ): Pick<StatewireHostInternal.SocketHost, "connect" | "finish"> => {
505
+ const [h] = useState(() => createSocketHost(options));
506
+ const value = useResource(transport) as Statewire<unknown>;
507
+ h.attach(value);
508
+ useEffect(() => {
509
+ h.attach(value);
510
+ });
511
+ useEffect(() => {
512
+ h.resume();
513
+ return () => h.suspend();
514
+ }, [h]);
515
+ return h;
516
+ };
517
+
518
+ /**
519
+ * Transport-agnostic WS binding over a statewire/host transport:
520
+ * feed it sockets (anything with `send`/`close`) and raw text frames, and it
521
+ * speaks the Statewire envelope protocol — snapshot-first attach with `syn`,
522
+ * delta/ack/res envelopes, per-client-id supersede, and `fin` on finish.
523
+ */
524
+ export const StatewireSocketHost = (
525
+ transport: ResourceElement<Statewire>,
526
+ options?: StatewireHostInternal.SocketHostOptions,
527
+ ): StatewireHostInternal.SocketHost => {
528
+ const SocketHostRoot = () => useSocketHost(transport, options);
529
+ const root = createTapRoot(SocketHostRoot);
530
+ let disposed = false;
531
+ return {
532
+ connect: (clientId, socket) =>
533
+ flushTapSync(() => root.getValue().connect(clientId, socket)),
534
+ finish: (fin) => flushTapSync(() => root.getValue().finish(fin)),
535
+ dispose: () => {
536
+ if (disposed) return;
537
+ disposed = true;
538
+ flushTapSync(() => root.getValue().finish({ reason: "evicted" }));
539
+ root.unmount();
540
+ },
541
+ };
542
+ };
@@ -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,61 @@
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
+ export type HandledCommands<THandlers extends Statewire.Commands> = {
50
+ [K in keyof THandlers]: (
51
+ ...args: Parameters<THandlers[K]>
52
+ ) => UnwrapExecution<Awaited<ReturnType<THandlers[K]>>>;
53
+ };
54
+
55
+ export type Fin = {
56
+ reason: "evicted" | "error" | "gone" | "superseded" | (string & {});
57
+ message?: string;
58
+ payload?: unknown;
59
+ meta?: unknown;
60
+ };
61
+ }
package/src/index.ts CHANGED
@@ -1,65 +1,5 @@
1
- import type { StatewireStateSource, StatewireTransport } from "./types";
2
- import type { StatewireOp } from "./wire/ops";
3
- import type {
4
- SendFailure as StatewireSendFailure,
5
- StatewireChanges,
6
- StatewireCommand,
7
- StatewireCommands,
8
- StatewireCommandsProxy,
9
- StatewireCommandUpdate,
10
- StatewireHelpers,
11
- StatewireOptimistic,
12
- StatewireOptimisticContext,
13
- StatewireOptions,
14
- } from "./types";
15
- import type {
16
- StatewireSSEOptions,
17
- StatewireConnection,
18
- } from "./transports/sse";
19
- import type { StatewireWSOptions } from "./transports/ws";
20
- import { Statewire } from "./statewire";
21
-
22
- export { Statewire };
23
- export { useStatewire, StatewireResource } from "./statewire";
24
- export { applyOps } from "./wire/ops";
1
+ export type { Statewire } from "./types";
2
+ export { StatewireClient, useStatewire, StatewireResource } from "./statewire";
25
3
  export { StatewireSSE } from "./transports/sse";
26
4
  export { StatewireWS } from "./transports/ws";
27
5
  export { StatewireSendError } from "./types";
28
-
29
- export namespace Statewire {
30
- export type Connection = StatewireConnection;
31
- export type ConnectionStatus = StatewireConnection["status"];
32
- export type Transport<
33
- TState = any,
34
- TCommands extends StatewireCommands = StatewireCommands,
35
- TEvent = any,
36
- > = StatewireTransport<TState, TCommands, TEvent>;
37
- export type StateSource = StatewireStateSource;
38
- export type SSEOptions = StatewireSSEOptions;
39
- export type WSOptions = StatewireWSOptions;
40
- export type Options<
41
- TState,
42
- TCommands extends StatewireCommands = StatewireCommands,
43
- TEvent = any,
44
- > = StatewireOptions<TState, TCommands, TEvent>;
45
- export type Helpers<
46
- TState,
47
- TCommands extends StatewireCommands = StatewireCommands,
48
- > = StatewireHelpers<TState, TCommands>;
49
- export type Commands = StatewireCommands;
50
- export type Command<TCommands extends StatewireCommands = StatewireCommands> =
51
- StatewireCommand<TCommands>;
52
- export type CommandsProxy<TCommands extends StatewireCommands> =
53
- StatewireCommandsProxy<TCommands>;
54
- export type CommandUpdate<
55
- TCommands extends StatewireCommands = StatewireCommands,
56
- > = StatewireCommandUpdate<TCommands>;
57
- export type Optimistic<
58
- TState,
59
- TCommands extends StatewireCommands = StatewireCommands,
60
- > = StatewireOptimistic<TState, TCommands>;
61
- export type OptimisticContext = StatewireOptimisticContext;
62
- export type SendFailure = StatewireSendFailure;
63
- export type Changes = StatewireChanges;
64
- export type Op = StatewireOp;
65
- }