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
@@ -1,127 +1,85 @@
1
+ import { useState } from "react";
1
2
  import { flushTapSync, resource } from "@assistant-ui/tap";
2
3
  import type { ResourceElement } from "@assistant-ui/tap";
3
4
  import { encodeParams } from "../wire/meta";
4
5
  import { timing } from "../client/timing";
5
6
  import {
6
- createGate,
7
7
  handleEnvelope,
8
8
  overloadedError,
9
- reconnectConn,
9
+ useStatewireConn,
10
10
  } from "../client/connection";
11
- import type { ConnBase, StatewireEnvelope } from "../client/connection";
12
- import type { StatewireTransport } from "../types";
11
+ import type { Conn, StatewireEnvelope } from "../client/connection";
12
+ import type { Statewire } from "../types";
13
+ import type { StatewireClient } from "../statewire";
13
14
  import { useStatewireRemote } from "./remote";
14
- import type { StatewireSessionStore } from "../client/sessionStore";
15
- import type { StatewireCommands } from "../types";
15
+ import { CLIENT_QUERY_PARAM } from "../host/adapter";
16
16
  import type {
17
17
  RemoteLink,
18
18
  StatewireSubmitResult,
19
- StatewireConnection,
20
- StatewireTransportProps,
19
+ StatewireRemoteProps,
21
20
  StatewireWireCommand,
22
21
  } from "./sse";
23
22
 
24
- export type StatewireWSOptions = {
25
- url: string;
26
- protocols?: string | string[];
27
- query?:
28
- | Record<string, string>
29
- | (() => Record<string, string> | Promise<Record<string, string>>);
30
- webSocket?: new (url: string, protocols?: string | string[]) => WebSocket;
31
- onChange?: (connection: StatewireConnection) => void;
32
- sessionStore?: StatewireSessionStore;
33
- };
34
-
35
- type Latest = {
36
- options: StatewireWSOptions;
37
- ctx: StatewireTransportProps;
38
- };
39
-
40
23
  type PendingCommand = {
41
24
  seq: number;
42
25
  resolve: (result: StatewireSubmitResult) => void;
43
26
  reject: (error: Error) => void;
44
27
  };
45
28
 
46
- type Conn = ConnBase & {
29
+ type WsChannel = {
47
30
  socket: WebSocket | null;
48
31
  pending: PendingCommand[];
49
- latest: { readonly current: Latest };
50
- send: RemoteLink["send"];
51
32
  };
52
33
 
53
34
  const OPEN = 1;
54
35
 
55
36
  export const WS_SUBPROTOCOL = "statewire.v1";
56
37
 
57
- const createConn = (
58
- latest: { readonly current: Latest },
59
- notify: (connection: StatewireConnection) => void,
60
- ): Conn => {
61
- const c: Conn = {
62
- hasSnapshot: false,
63
- lease: null,
64
- attempt: 0,
65
- episode: null,
66
- abort: null,
67
- socket: null,
68
- pending: [],
69
- gate: createGate(),
70
- lifetime: null,
71
- latest,
72
- notify,
73
- reconnect: () => reconnectConn(c),
74
- attemptOnce: (abort) => attemptSocket(c, abort),
75
- send: (args) => sendCommand(c, args),
76
- connection: { status: "connecting", reconnect: () => c.reconnect() },
77
- };
78
- return c;
79
- };
80
-
81
- const socketUrl = async (c: Conn) => {
82
- const { url, query } = c.latest.current.options;
38
+ const socketUrl = async (
39
+ options: StatewireClient.WSOptions,
40
+ clientId: string,
41
+ ) => {
42
+ const { url, query } = options;
83
43
  const params = new URLSearchParams(
84
44
  typeof query === "function" ? await query() : query,
85
45
  );
86
- params.set("client", c.latest.current.ctx.clientId);
46
+ params.set(CLIENT_QUERY_PARAM, clientId);
87
47
  const base = url.replace(/\/$/, "").replace(/^http/, "ws");
88
48
  return `${base}/ws?${params.toString()}`;
89
49
  };
90
50
 
91
51
  const settleOverloaded = (
92
- c: Conn,
52
+ w: WsChannel,
93
53
  { seq, retryAfterMs }: { seq?: number; retryAfterMs?: number },
94
54
  ) => {
95
55
  if (seq === undefined) throw overloadedError(retryAfterMs);
96
- const entry = c.pending.find((p) => p.seq === seq);
56
+ const entry = w.pending.find((p) => p.seq === seq);
97
57
  if (!entry) return;
98
- c.pending = c.pending.filter((p) => p !== entry);
58
+ w.pending = w.pending.filter((p) => p !== entry);
99
59
  entry.reject(overloadedError(retryAfterMs));
100
60
  };
101
61
 
102
- const resolveReceipts = (c: Conn, ack: number) => {
103
- while (c.pending[0] && c.pending[0].seq <= ack) {
104
- c.pending.shift()!.resolve({ type: "receipt" });
105
- }
106
- };
107
-
108
- const resolveResponded = (c: Conn, seqs: Set<number>) => {
62
+ const resolvePending = (
63
+ w: WsChannel,
64
+ matches: (entry: PendingCommand) => boolean,
65
+ ) => {
109
66
  const remaining: PendingCommand[] = [];
110
- for (const entry of c.pending) {
111
- if (seqs.has(entry.seq)) entry.resolve({ type: "receipt" });
67
+ for (const entry of w.pending) {
68
+ if (matches(entry)) entry.resolve({ type: "receipt" });
112
69
  else remaining.push(entry);
113
70
  }
114
- c.pending = remaining;
71
+ w.pending = remaining;
115
72
  };
116
73
 
117
- const failPending = (c: Conn, error: Error) => {
118
- const pending = c.pending;
119
- c.pending = [];
74
+ const failPending = (w: WsChannel, error: Error) => {
75
+ const pending = w.pending;
76
+ w.pending = [];
120
77
  for (const entry of pending) entry.reject(error);
121
78
  };
122
79
 
123
80
  const runSocket = (
124
81
  c: Conn,
82
+ w: WsChannel,
125
83
  ws: WebSocket,
126
84
  abort: AbortController,
127
85
  ): Promise<"stop" | "reattach"> =>
@@ -130,7 +88,6 @@ const runSocket = (
130
88
  let timedOut = false;
131
89
  let outcome: "stop" | "reattach" | null = null;
132
90
  let closed = false;
133
- let attached = false;
134
91
  const arm = () => {
135
92
  clearTimeout(timer);
136
93
  timer = setTimeout(() => {
@@ -148,11 +105,14 @@ const runSocket = (
148
105
  ws.close();
149
106
  };
150
107
  const processEnv = (env: StatewireEnvelope) => {
151
- const done = flushTapSync(() => handleEnvelope(c, env, attached));
152
- attached = true;
153
- if (env.ack !== undefined) resolveReceipts(c, env.ack);
108
+ const done = flushTapSync(() => handleEnvelope(c, env));
109
+ if (env.ack !== undefined) {
110
+ const ack = env.ack;
111
+ resolvePending(w, (entry) => entry.seq <= ack);
112
+ }
154
113
  if (env.res !== undefined) {
155
- resolveResponded(c, new Set(env.res.map((r) => r.seq)));
114
+ const seqs = new Set(env.res.map((r) => r.seq));
115
+ resolvePending(w, (entry) => seqs.has(entry.seq));
156
116
  }
157
117
  if (done) {
158
118
  outcome = done;
@@ -173,7 +133,7 @@ const runSocket = (
173
133
  }
174
134
  try {
175
135
  if (frame.overloaded !== undefined) {
176
- settleOverloaded(c, frame.overloaded);
136
+ settleOverloaded(w, frame.overloaded);
177
137
  } else {
178
138
  processEnv(frame);
179
139
  }
@@ -184,7 +144,7 @@ const runSocket = (
184
144
  ws.onclose = () => {
185
145
  closed = true;
186
146
  clearTimeout(timer);
187
- if (c.socket === ws) failPending(c, new Error("network"));
147
+ if (w.socket === ws) failPending(w, new Error("network"));
188
148
  if (outcome) resolve(outcome);
189
149
  else reject(new Error(timedOut ? "timeout" : "network"));
190
150
  };
@@ -192,13 +152,15 @@ const runSocket = (
192
152
 
193
153
  const attemptSocket = async (
194
154
  c: Conn,
155
+ w: WsChannel,
156
+ options: StatewireClient.WSOptions,
195
157
  abort: AbortController,
196
158
  ): Promise<"stop" | "reattach"> => {
197
159
  let ws: WebSocket | null = null;
198
160
  try {
199
- const url = await socketUrl(c);
161
+ const url = await socketUrl(options, c.ctx.clientId);
200
162
  abort.signal.throwIfAborted();
201
- const { protocols, webSocket } = c.latest.current.options;
163
+ const { protocols, webSocket } = options;
202
164
  const WS = webSocket ?? globalThis.WebSocket;
203
165
  if (!WS) throw new Error("no WebSocket implementation");
204
166
  const extra =
@@ -208,18 +170,18 @@ const attemptSocket = async (
208
170
  ? [protocols]
209
171
  : protocols;
210
172
  ws = new WS(url, [WS_SUBPROTOCOL, ...extra]);
211
- c.socket = ws;
212
- return await runSocket(c, ws, abort);
173
+ w.socket = ws;
174
+ return await runSocket(c, w, ws, abort);
213
175
  } finally {
214
- if (c.socket === ws) c.socket = null;
176
+ if (w.socket === ws) w.socket = null;
215
177
  }
216
178
  };
217
179
 
218
180
  const sendCommand = (
219
- c: Conn,
181
+ w: WsChannel,
220
182
  { seq, command }: { seq: number; command: StatewireWireCommand },
221
183
  ): Promise<StatewireSubmitResult> => {
222
- const ws = c.socket;
184
+ const ws = w.socket;
223
185
  if (!ws || ws.readyState !== OPEN) {
224
186
  return Promise.reject(new Error("network"));
225
187
  }
@@ -238,11 +200,11 @@ const sendCommand = (
238
200
  // A timed-out command fails the socket: the retry then rides reconnect +
239
201
  // attach-seq arbitration.
240
202
  const timer = setTimeout(() => {
241
- c.pending = c.pending.filter((p) => p !== entry);
203
+ w.pending = w.pending.filter((p) => p !== entry);
242
204
  reject(new Error("timeout"));
243
205
  ws.close();
244
206
  }, timing.heartbeatTimeoutMs);
245
- c.pending.push(entry);
207
+ w.pending.push(entry);
246
208
  try {
247
209
  ws.send(
248
210
  JSON.stringify({
@@ -253,20 +215,42 @@ const sendCommand = (
253
215
  );
254
216
  } catch {
255
217
  clearTimeout(timer);
256
- c.pending = c.pending.filter((p) => p !== entry);
218
+ w.pending = w.pending.filter((p) => p !== entry);
257
219
  reject(new Error("network"));
258
220
  }
259
221
  });
260
222
  };
261
223
 
224
+ const useWsLink = (
225
+ options: StatewireClient.WSOptions,
226
+ ctx: StatewireRemoteProps,
227
+ ): RemoteLink => {
228
+ const [w] = useState((): WsChannel => ({ socket: null, pending: [] }));
229
+ const c = useStatewireConn({
230
+ url: options.url,
231
+ onChange: options.onChange,
232
+ ctx,
233
+ attemptOnce: (c, abort) => attemptSocket(c, w, options, abort),
234
+ });
235
+ const [link] = useState(
236
+ (): RemoteLink => ({
237
+ get connection() {
238
+ return c.connection;
239
+ },
240
+ send: (args) => sendCommand(w, args),
241
+ }),
242
+ );
243
+ return link;
244
+ };
245
+
262
246
  const useStatewireWS = (
263
- options: StatewireWSOptions,
264
- ): StatewireTransport<any, any, any> => useStatewireRemote(options, createConn);
247
+ options: StatewireClient.WSOptions,
248
+ ): Statewire<any, any, any> => useStatewireRemote(options, useWsLink);
265
249
 
266
250
  export const StatewireWS = resource(useStatewireWS) as <
267
251
  TState = any,
268
- TCommands extends StatewireCommands = StatewireCommands,
252
+ TCommands extends Statewire.Commands = Statewire.Commands,
269
253
  TEvent = any,
270
254
  >(
271
- options: StatewireWSOptions,
272
- ) => ResourceElement<StatewireTransport<TState | undefined, TCommands, TEvent>>;
255
+ options: StatewireClient.WSOptions,
256
+ ) => ResourceElement<Statewire<TState | undefined, TCommands, TEvent>>;
package/src/types.ts CHANGED
@@ -1,145 +1,64 @@
1
- import type { ResourceElement } from "@assistant-ui/tap";
2
- import type { StatewireConnection } from "./transports/sse";
1
+ import type { StatewireClient } from "./statewire";
3
2
  import type { StatewirePath } from "./wire/ops";
4
3
 
5
- export type StatewireCommands = Record<string, (...args: never[]) => unknown>;
4
+ export namespace Statewire {
5
+ export type Commands = Record<string, (...args: never[]) => unknown>;
6
6
 
7
- /** Push-based canonical state feed a transport hands to the client core. */
8
- export type StatewireStateSource = {
9
- root: () => unknown;
10
- /** Drain the paths changed since the last take (`[]` entry = whole state). */
11
- takeChanged: () => StatewirePath[];
12
- /** Synchronous, per change; the consumer owns coalescing. */
13
- setOnChange: (listener: (() => void) | null) => void;
14
- };
15
-
16
- export type StatewireTransport<
17
- TState,
18
- TCommands extends StatewireCommands = StatewireCommands,
19
- TEvent = any,
20
- > = {
21
- state: TState;
22
- commands: StatewireCommandsProxy<TCommands>;
23
- emit: (event: TEvent) => void;
24
- connection?: StatewireConnection;
25
- source?: StatewireStateSource;
26
- };
7
+ export type Command<TCommands extends Commands = Commands> = {
8
+ [K in keyof TCommands]: { method: K; params: Parameters<TCommands[K]> };
9
+ }[keyof TCommands];
27
10
 
28
- export type StatewireCommand<
29
- TCommands extends StatewireCommands = StatewireCommands,
30
- > = {
31
- [K in keyof TCommands]: { method: K; params: Parameters<TCommands[K]> };
32
- }[keyof TCommands];
11
+ export type CommandsProxy<TCommands extends Commands> = {
12
+ [K in keyof TCommands]: (
13
+ ...args: Parameters<TCommands[K]>
14
+ ) => Promise<Awaited<ReturnType<TCommands[K]>>>;
15
+ };
33
16
 
34
- export type StatewireCommandsProxy<TCommands extends StatewireCommands> = {
35
- [K in keyof TCommands]: (
36
- ...args: Parameters<TCommands[K]>
37
- ) => Promise<Awaited<ReturnType<TCommands[K]>>>;
38
- };
17
+ export type CommandUpdate<TCommands extends Commands = Commands> = {
18
+ [K in keyof TCommands]: {
19
+ method: K;
20
+ params: Parameters<TCommands[K]>;
21
+ seq: number;
22
+ status: "queued" | "posted" | "acked" | "settled";
23
+ result?: unknown;
24
+ failure?: StatewireClient.SendFailure;
25
+ };
26
+ }[keyof TCommands];
39
27
 
40
- export type StatewireCommandUpdate<
41
- TCommands extends StatewireCommands = StatewireCommands,
42
- > = {
43
- [K in keyof TCommands]: {
44
- method: K;
45
- params: Parameters<TCommands[K]>;
46
- seq: number;
47
- status: "queued" | "posted" | "acked" | "settled";
48
- result?: unknown;
49
- failure?: SendFailure;
28
+ /** Push-based canonical state feed a statewire hands to the client core. */
29
+ export type StateSource = {
30
+ snapshot: () => unknown;
31
+ /** Drain the paths changed since the last take (`[]` entry = whole state). */
32
+ takeChanged: () => StatewirePath[];
33
+ /** Synchronous, per change; the consumer owns coalescing. */
34
+ setOnChange: (listener: (() => void) | null) => void;
50
35
  };
51
- }[keyof TCommands];
52
-
53
- export type StatewireOptimisticContext = {
54
- ts: number;
55
- seq: number;
56
- clientId?: string;
57
- };
58
36
 
59
- /**
60
- * Mutate the draft in place; the client records the next state plus the exact
61
- * changed paths. Re-run against the new base on every rebase.
62
- */
63
- export type StatewireOptimistic<
64
- TState,
65
- TCommands extends StatewireCommands = StatewireCommands,
66
- > = (
67
- draft: TState,
68
- command: StatewireCommand<TCommands>,
69
- ctx: StatewireOptimisticContext,
70
- ) => void;
37
+ export type Op =
38
+ | { op: "replace"; path: StatewirePath; value: unknown }
39
+ | { op: "add"; path: StatewirePath; value: unknown }
40
+ | { op: "remove"; path: StatewirePath }
41
+ | { op: "event"; value: object };
71
42
 
72
- export type StatewireChanges = {
73
- isChangedAt(path: readonly (string | number)[]): boolean;
74
- getChangedKeys(path: readonly (string | number)[]): string[];
75
- };
43
+ export type Changes = {
44
+ isChangedAt(path: readonly (string | number)[]): boolean;
45
+ getChangedKeys(path: readonly (string | number)[]): string[];
46
+ };
47
+ }
76
48
 
77
- export type StatewireOptions<
78
- TState,
79
- TCommands extends StatewireCommands = StatewireCommands,
49
+ export interface Statewire<
50
+ TState = any,
51
+ TCommands extends Statewire.Commands = Statewire.Commands,
80
52
  TEvent = any,
81
- > = {
82
- transport: ResourceElement<StatewireTransport<TState, TCommands, TEvent>>;
83
- optimistic?: StatewireOptimistic<NonNullable<TState>, TCommands>;
84
- onEvent?: (event: TEvent, ctx: { state: NonNullable<TState> }) => void;
85
- onStateChange?: (
86
- state: NonNullable<TState>,
87
- ctx: { changes: StatewireChanges },
88
- ) => void;
89
- onCommandChange?: (
90
- update: StatewireCommandUpdate<TCommands>,
91
- ctx: { state: NonNullable<TState> },
92
- ) => void;
93
- };
94
-
95
- export type StatewireHelpers<
96
- TState,
97
- TCommands extends StatewireCommands = StatewireCommands,
98
- > = {
99
- /** The transport's state type — remote transports include `undefined` until the first attach snapshot applies. */
53
+ > {
100
54
  state: TState;
101
- connection: StatewireConnection;
102
- commands: StatewireCommandsProxy<TCommands>;
103
- };
104
-
105
- /**
106
- * fate: "aborted" = never sent, "rejected" = the handler refused,
107
- * "unknown-command" = no such command, "invalid-params" = the args did not
108
- * match the command's schema, "lost" = sent but never covered by an ack,
109
- * "crashed" = acked but the processing died, "result-unavailable" = acked
110
- * but the result is gone.
111
- * cause is which channel settled it: "server" = the server's answer;
112
- * the rest are channel deaths the client settled locally.
113
- */
114
- export type SendFailure =
115
- | { fate: "rejected"; cause: "server"; message: string; payload?: unknown }
116
- | { fate: "unknown-command"; cause: "server" }
117
- | { fate: "invalid-params"; cause: "server"; message: string }
118
- | { fate: "crashed"; cause: "server" }
119
- | {
120
- fate: "result-unavailable";
121
- cause:
122
- | "server"
123
- | "unmount"
124
- | "stream-gone"
125
- | "session-lost"
126
- | "superseded";
127
- }
128
- | {
129
- fate: "aborted";
130
- cause:
131
- | "cascade"
132
- | "unmount"
133
- | "stream-gone"
134
- | "session-lost"
135
- | "superseded";
136
- }
137
- | {
138
- fate: "lost";
139
- cause: "unmount" | "stream-gone" | "session-lost" | "superseded";
140
- };
55
+ commands: Statewire.CommandsProxy<TCommands>;
56
+ emit?: (event: TEvent) => void;
57
+ connection?: StatewireClient.Connection;
58
+ source?: Statewire.StateSource;
59
+ }
141
60
 
142
- const describe = (failure: SendFailure): string => {
61
+ const describe = (failure: StatewireClient.SendFailure): string => {
143
62
  switch (failure.fate) {
144
63
  case "rejected":
145
64
  return `statewire: command rejected: ${failure.message}`;
@@ -159,9 +78,9 @@ const describe = (failure: SendFailure): string => {
159
78
  };
160
79
 
161
80
  export class StatewireSendError extends Error {
162
- readonly failure: SendFailure;
81
+ readonly failure: StatewireClient.SendFailure;
163
82
 
164
- constructor(failure: SendFailure) {
83
+ constructor(failure: StatewireClient.SendFailure) {
165
84
  super(describe(failure));
166
85
  this.name = "StatewireSendError";
167
86
  this.failure = failure;
@@ -0,0 +1,32 @@
1
+ type SharedTraps = Pick<
2
+ ProxyHandler<object>,
3
+ | "defineProperty"
4
+ | "setPrototypeOf"
5
+ | "preventExtensions"
6
+ | "ownKeys"
7
+ | "getOwnPropertyDescriptor"
8
+ >;
9
+
10
+ export const sharedDraftTraps = (
11
+ current: () => object,
12
+ context: string,
13
+ ): SharedTraps => {
14
+ const unsupported = (op: string): never => {
15
+ throw new Error(`statewire: ${op} is not supported ${context}`);
16
+ };
17
+ return {
18
+ defineProperty: () => unsupported("defineProperty"),
19
+ setPrototypeOf: () => unsupported("setPrototypeOf"),
20
+ preventExtensions: () => unsupported("preventExtensions"),
21
+ ownKeys: () => Reflect.ownKeys(current()),
22
+ getOwnPropertyDescriptor: (_target, prop) => {
23
+ const node = current();
24
+ const desc = Reflect.getOwnPropertyDescriptor(node, prop);
25
+ if (!desc) return undefined;
26
+ return {
27
+ ...desc,
28
+ configurable: !Array.isArray(node) || prop !== "length",
29
+ };
30
+ },
31
+ };
32
+ };
@@ -0,0 +1,25 @@
1
+ // MessageChannel schedules the flush as a macrotask (tap's pattern): it runs
2
+ // after the whole microtask drain, so every producer in the current turn
3
+ // coalesces. The port is ref'd only while a flush is pending — an active
4
+ // MessagePort holds the Node event loop open. ref/unref are Node-only.
5
+ export const createMacrotaskPoster = (callback: () => void): (() => void) => {
6
+ if (typeof MessageChannel !== "undefined") {
7
+ let port1: (MessagePort & { ref?: () => void; unref?: () => void }) | null =
8
+ null;
9
+ let port2: MessagePort;
10
+ return () => {
11
+ if (!port1) {
12
+ const channel = new MessageChannel();
13
+ channel.port1.onmessage = () => {
14
+ port1?.unref?.();
15
+ callback();
16
+ };
17
+ port1 = channel.port1;
18
+ port2 = channel.port2;
19
+ }
20
+ port1.ref?.();
21
+ port2!.postMessage(null);
22
+ };
23
+ }
24
+ return () => setTimeout(callback, 0);
25
+ };
@@ -0,0 +1,16 @@
1
+ export const createMethodProxy = <T extends object>(
2
+ dispatch: (method: string, params: unknown[]) => Promise<unknown>,
3
+ ): T => {
4
+ const memo = new Map<string, (...params: unknown[]) => Promise<unknown>>();
5
+ return new Proxy({} as T, {
6
+ get: (_target, method) => {
7
+ if (typeof method !== "string") return undefined;
8
+ let fn = memo.get(method);
9
+ if (fn === undefined) {
10
+ fn = (...params: unknown[]) => dispatch(method, params);
11
+ memo.set(method, fn);
12
+ }
13
+ return fn;
14
+ },
15
+ });
16
+ };
@@ -0,0 +1,4 @@
1
+ export const randomId = (): string =>
2
+ typeof crypto !== "undefined" && "randomUUID" in crypto
3
+ ? crypto.randomUUID()
4
+ : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
@@ -0,0 +1,61 @@
1
+ export type StatePath = readonly (string | number)[];
2
+
3
+ export const isUnsafeKey = (
4
+ key: string | number,
5
+ parent: string | number | undefined,
6
+ ) => key === "__proto__" || (key === "prototype" && parent === "constructor");
7
+
8
+ export type WalkedPath = {
9
+ /** Container of the final key; undefined when the path is empty. */
10
+ parent: unknown;
11
+ key: string | number;
12
+ leaf: unknown;
13
+ };
14
+
15
+ // "read": lenient, never fails — missing steps yield an undefined leaf.
16
+ // "parent": every step before the last must resolve; the leaf may not exist.
17
+ // "resolve": every step including the last must resolve.
18
+ // Strict modes require array+integer for number keys, plain object for string
19
+ // keys, and reject unsafe (prototype-polluting) segments.
20
+ export const walkStatePath = (
21
+ root: unknown,
22
+ path: StatePath,
23
+ mode: "read" | "parent" | "resolve",
24
+ ): WalkedPath | undefined => {
25
+ let parent: unknown = undefined;
26
+ let key: string | number = "";
27
+ let node: unknown = root;
28
+ for (let depth = 0; depth < path.length; depth++) {
29
+ const k = path[depth]!;
30
+ const mustResolve =
31
+ mode === "resolve" || (mode === "parent" && depth < path.length - 1);
32
+ if (mode !== "read") {
33
+ if (typeof k === "number") {
34
+ if (!Array.isArray(node) || !Number.isInteger(k) || k < 0) {
35
+ return undefined;
36
+ }
37
+ if (mustResolve && k >= node.length) return undefined;
38
+ } else {
39
+ if (
40
+ isUnsafeKey(k, path[depth - 1]) ||
41
+ typeof node !== "object" ||
42
+ node === null ||
43
+ Array.isArray(node)
44
+ ) {
45
+ return undefined;
46
+ }
47
+ if (mustResolve && !Object.hasOwn(node, k)) return undefined;
48
+ }
49
+ }
50
+ parent = node;
51
+ key = k;
52
+ node =
53
+ typeof node === "object" && node !== null && Object.hasOwn(node, k)
54
+ ? (node as Record<string | number, unknown>)[k]
55
+ : undefined;
56
+ }
57
+ return { parent, key, leaf: node };
58
+ };
59
+
60
+ export const readAtPath = (root: unknown, path: StatePath): unknown =>
61
+ walkStatePath(root, path, "read")!.leaf;