statewire 0.8.1 → 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 -260
  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 -278
  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
@@ -2,40 +2,32 @@ import { useState } from "react";
2
2
  import { flushTapSync, resource } from "@assistant-ui/tap";
3
3
  import type { ResourceElement } from "@assistant-ui/tap";
4
4
  import { timing } from "../client/timing";
5
- import {
6
- handleEnvelope,
7
- overloadedError,
8
- useStatewireConn,
5
+ import { handleEnvelope, useStatewireConn } from "../client/connection";
6
+ import type {
7
+ AttemptOutcome,
8
+ Conn,
9
+ StatewireEnvelope,
9
10
  } from "../client/connection";
10
- import type { Conn, StatewireEnvelope } from "../client/connection";
11
11
  import type { Statewire } from "../types";
12
12
  import type { StatewireClient } from "../statewire";
13
13
  import { useStatewireRemote } from "./remote";
14
14
  import { CLIENT_QUERY_PARAM } from "../host/adapter";
15
- import { encodeWireCommand } from "./sse";
16
15
  import type {
17
16
  RemoteLink,
17
+ StatewireFrame,
18
18
  StatewireSubmitResult,
19
19
  StatewireRemoteProps,
20
- StatewireWireCommand,
21
20
  } from "./sse";
22
21
 
23
- type PendingCommand = {
24
- seq: number;
25
- resolve: (result: StatewireSubmitResult) => void;
26
- reject: (error: Error) => void;
27
- };
28
-
29
22
  type WsChannel = {
30
23
  socket: WebSocket | null;
31
- pending: PendingCommand[];
32
24
  authStale: boolean;
33
25
  };
34
26
 
35
27
  const OPEN = 1;
36
28
 
37
29
  export const WS_SUBPROTOCOL = "statewire.v1";
38
- export const UNAUTHORIZED_CLOSE = 4401;
30
+ const UNAUTHORIZED_CLOSE = 4401;
39
31
 
40
32
  const socketUrl = async (
41
33
  options: StatewireClient.WSOptions,
@@ -50,64 +42,35 @@ const socketUrl = async (
50
42
  return `${base}/ws?${params.toString()}`;
51
43
  };
52
44
 
53
- const headersFrame = async (
45
+ const firstFrame = async (
54
46
  options: StatewireClient.WSOptions,
55
47
  stale: boolean,
56
- ): Promise<string | null> => {
48
+ lanes: StatewireFrame | null,
49
+ ): Promise<string> => {
57
50
  const { headers } = options;
58
- if (headers === undefined) return null;
59
51
  const resolved = new Headers(
60
52
  typeof headers === "function"
61
53
  ? await headers({ staleAuth: stale })
62
54
  : headers,
63
55
  );
64
56
  return JSON.stringify({
65
- type: "headers",
66
57
  headers: Object.fromEntries(resolved),
58
+ ...(lanes ?? {}),
67
59
  });
68
60
  };
69
61
 
70
- const settleOverloaded = (
71
- w: WsChannel,
72
- { seq, retryAfterMs }: { seq?: number; retryAfterMs?: number },
73
- ) => {
74
- if (seq === undefined) throw overloadedError(retryAfterMs);
75
- const entry = w.pending.find((p) => p.seq === seq);
76
- if (!entry) return;
77
- w.pending = w.pending.filter((p) => p !== entry);
78
- entry.reject(overloadedError(retryAfterMs));
79
- };
80
-
81
- const resolvePending = (
82
- w: WsChannel,
83
- matches: (entry: PendingCommand) => boolean,
84
- ) => {
85
- const remaining: PendingCommand[] = [];
86
- for (const entry of w.pending) {
87
- if (matches(entry)) entry.resolve({ type: "receipt" });
88
- else remaining.push(entry);
89
- }
90
- w.pending = remaining;
91
- };
92
-
93
- const failPending = (w: WsChannel, error: Error) => {
94
- const pending = w.pending;
95
- w.pending = [];
96
- for (const entry of pending) entry.reject(error);
97
- };
98
-
99
62
  const runSocket = (
100
63
  c: Conn,
101
64
  w: WsChannel,
102
65
  ws: WebSocket,
103
- frame: string | null,
66
+ frame: string,
104
67
  staleAuth: boolean,
105
68
  abort: AbortController,
106
- ): Promise<"stop" | "reattach"> =>
69
+ ): Promise<AttemptOutcome> =>
107
70
  new Promise((resolve, reject) => {
108
71
  let timer: ReturnType<typeof setTimeout> | undefined;
109
72
  let timedOut = false;
110
- let outcome: "stop" | "reattach" | null = null;
73
+ let outcome: AttemptOutcome | null = null;
111
74
  let closed = false;
112
75
  const arm = () => {
113
76
  clearTimeout(timer);
@@ -119,45 +82,36 @@ const runSocket = (
119
82
  arm();
120
83
  abort.signal.addEventListener("abort", () => ws.close());
121
84
  ws.onerror = () => {};
122
- if (frame !== null) ws.onopen = () => ws.send(frame);
85
+ ws.onopen = () => ws.send(frame);
123
86
  const stale = () => Boolean(outcome) || closed || abort.signal.aborted;
124
87
  const fail = (error: unknown) => {
125
88
  clearTimeout(timer);
126
89
  reject(error instanceof Error ? error : new Error("protocol"));
127
90
  ws.close();
128
91
  };
129
- const processEnv = (env: StatewireEnvelope) => {
130
- const done = flushTapSync(() => handleEnvelope(c, env));
131
- if (env.ack !== undefined) {
132
- const ack = env.ack;
133
- resolvePending(w, (entry) => entry.seq <= ack);
134
- }
135
- if (env.res !== undefined) {
136
- const seqs = new Set(env.res.map((r) => r.seq));
137
- resolvePending(w, (entry) => seqs.has(entry.seq));
138
- }
139
- if (done) {
140
- outcome = done;
141
- ws.close();
142
- }
143
- };
144
92
  ws.onmessage = (event) => {
145
93
  if (stale()) return;
146
94
  arm();
147
- let frame: StatewireEnvelope & {
148
- overloaded?: { seq?: number; retryAfterMs?: number };
149
- };
95
+ let env: StatewireEnvelope;
150
96
  try {
151
- frame = JSON.parse(String(event.data)) as typeof frame;
97
+ env = JSON.parse(String(event.data)) as StatewireEnvelope;
152
98
  } catch {
153
99
  fail(new Error("protocol"));
154
100
  return;
155
101
  }
102
+ // {} heartbeats live below the packet layer: invisible to handlers.
103
+ if (
104
+ typeof env === "object" &&
105
+ env !== null &&
106
+ Object.keys(env).length === 0
107
+ ) {
108
+ return;
109
+ }
156
110
  try {
157
- if (frame.overloaded !== undefined) {
158
- settleOverloaded(w, frame.overloaded);
159
- } else {
160
- processEnv(frame);
111
+ const done = flushTapSync(() => handleEnvelope(c, env));
112
+ if (done) {
113
+ outcome = done;
114
+ ws.close();
161
115
  }
162
116
  } catch (error) {
163
117
  fail(error);
@@ -166,7 +120,6 @@ const runSocket = (
166
120
  ws.onclose = (event?: { code?: number }) => {
167
121
  closed = true;
168
122
  clearTimeout(timer);
169
- if (w.socket === ws) failPending(w, new Error("network"));
170
123
  if (outcome) resolve(outcome);
171
124
  else if (event?.code === UNAUTHORIZED_CLOSE) {
172
125
  if (staleAuth) c.episode = { cause: "unauthorized" };
@@ -181,13 +134,14 @@ const attemptSocket = async (
181
134
  w: WsChannel,
182
135
  options: StatewireClient.WSOptions,
183
136
  abort: AbortController,
184
- ): Promise<"stop" | "reattach"> => {
137
+ ): Promise<AttemptOutcome> => {
185
138
  let ws: WebSocket | null = null;
186
139
  try {
187
140
  const url = await socketUrl(options, c.ctx.clientId);
188
141
  const stale = w.authStale;
189
142
  w.authStale = false;
190
- const frame = await headersFrame(options, stale);
143
+ const lanes = flushTapSync(() => c.ctx.takeAttachLanes());
144
+ const frame = await firstFrame(options, stale, lanes);
191
145
  abort.signal.throwIfAborted();
192
146
  const { protocols, webSocket } = options;
193
147
  const WS = webSocket ?? globalThis.WebSocket;
@@ -206,51 +160,27 @@ const attemptSocket = async (
206
160
  }
207
161
  };
208
162
 
209
- const sendCommand = (
163
+ const sendFrame = (
210
164
  w: WsChannel,
211
- { seq, command }: { seq: number; command: StatewireWireCommand },
165
+ frame: StatewireFrame,
212
166
  ): Promise<StatewireSubmitResult> => {
213
167
  const ws = w.socket;
214
168
  if (!ws || ws.readyState !== OPEN) {
215
169
  return Promise.reject(new Error("network"));
216
170
  }
217
- return new Promise((resolve, reject) => {
218
- const entry: PendingCommand = {
219
- seq,
220
- resolve: (result) => {
221
- clearTimeout(timer);
222
- resolve(result);
223
- },
224
- reject: (error) => {
225
- clearTimeout(timer);
226
- reject(error);
227
- },
228
- };
229
- // A timed-out command fails the socket: the retry then rides reconnect +
230
- // attach-seq arbitration.
231
- const timer = setTimeout(() => {
232
- w.pending = w.pending.filter((p) => p !== entry);
233
- reject(new Error("timeout"));
234
- ws.close();
235
- }, timing.heartbeatTimeoutMs);
236
- w.pending.push(entry);
237
- try {
238
- ws.send(JSON.stringify({ ...encodeWireCommand(command), seq }));
239
- } catch {
240
- clearTimeout(timer);
241
- w.pending = w.pending.filter((p) => p !== entry);
242
- reject(new Error("network"));
243
- }
244
- });
171
+ try {
172
+ ws.send(JSON.stringify(frame));
173
+ } catch {
174
+ return Promise.reject(new Error("network"));
175
+ }
176
+ return Promise.resolve({ type: "ok" });
245
177
  };
246
178
 
247
179
  const useWsLink = (
248
180
  options: StatewireClient.WSOptions,
249
181
  ctx: StatewireRemoteProps,
250
182
  ): RemoteLink => {
251
- const [w] = useState(
252
- (): WsChannel => ({ socket: null, pending: [], authStale: false }),
253
- );
183
+ const [w] = useState((): WsChannel => ({ socket: null, authStale: false }));
254
184
  const c = useStatewireConn({
255
185
  url: options.url,
256
186
  onChange: options.onChange,
@@ -262,7 +192,7 @@ const useWsLink = (
262
192
  get connection() {
263
193
  return c.connection;
264
194
  },
265
- send: (args) => sendCommand(w, args),
195
+ send: (frame) => sendFrame(w, frame),
266
196
  }),
267
197
  );
268
198
  return link;
@@ -270,12 +200,11 @@ const useWsLink = (
270
200
 
271
201
  const useStatewireWS = (
272
202
  options: StatewireClient.WSOptions,
273
- ): Statewire<any, any, any> => useStatewireRemote(options, useWsLink);
203
+ ): Statewire<any, any> => useStatewireRemote(options, useWsLink);
274
204
 
275
205
  export const StatewireWS = resource(useStatewireWS) as <
276
206
  TState = any,
277
207
  TCommands extends Statewire.Commands = Statewire.Commands,
278
- TEvent = any,
279
208
  >(
280
209
  options: StatewireClient.WSOptions,
281
- ) => ResourceElement<Statewire<TState | undefined, TCommands, TEvent>>;
210
+ ) => ResourceElement<Statewire<TState | undefined, TCommands>>;
package/src/types.ts CHANGED
@@ -19,7 +19,7 @@ export namespace Statewire {
19
19
  method: K;
20
20
  params: Parameters<TCommands[K]>;
21
21
  seq: number;
22
- status: "queued" | "posted" | "acked" | "settled";
22
+ status: "queued" | "posted" | "applied" | "settled";
23
23
  result?: unknown;
24
24
  failure?: StatewireClient.SendFailure;
25
25
  };
@@ -37,8 +37,7 @@ export namespace Statewire {
37
37
  export type Op =
38
38
  | { op: "replace"; path: StatewirePath; value: unknown }
39
39
  | { op: "add"; path: StatewirePath; value: unknown }
40
- | { op: "remove"; path: StatewirePath }
41
- | { op: "event"; value: object };
40
+ | { op: "remove"; path: StatewirePath };
42
41
 
43
42
  export type Changes = {
44
43
  isChangedAt(path: readonly (string | number)[]): boolean;
@@ -49,33 +48,35 @@ export namespace Statewire {
49
48
  export interface Statewire<
50
49
  TState = any,
51
50
  TCommands extends Statewire.Commands = Statewire.Commands,
52
- TEvent = any,
53
51
  > {
54
52
  state: TState;
55
53
  commands: Statewire.CommandsProxy<TCommands>;
56
- emit?: (event: TEvent) => void;
57
54
  connection?: StatewireClient.Connection;
58
55
  source?: Statewire.StateSource;
59
- /** Replace this client's ambient context on the server; absent on transports without a client context channel. */
60
- setContext?: (value: Record<string, unknown>) => Promise<unknown>;
56
+ /** Highest command seq released from the resend queue by the server's dur flag. */
57
+ durableSeq?: number;
58
+ /** Negation of the server's broadcast idle level; absent means always busy. */
59
+ isBusy?: boolean;
60
+ /** Replace this client's ambient context on the server; it rides the ctx lane (no verdict). */
61
+ setContext?: (value: Record<string, unknown>) => void;
61
62
  }
62
63
 
63
64
  const describe = (failure: StatewireClient.SendFailure): string => {
64
65
  switch (failure.fate) {
65
66
  case "rejected":
66
- return `statewire: command rejected: ${failure.message}`;
67
- case "unknown-command":
68
- return "statewire: unknown command";
69
- case "invalid-params":
70
- return `statewire: invalid params: ${failure.message}`;
67
+ return `statewire: command rejected${failure.code ? ` (${failure.code})` : ""}${failure.message ? `: ${failure.message}` : ""}`;
71
68
  case "aborted":
72
69
  return `statewire: command aborted (${failure.cause})`;
73
70
  case "lost":
74
71
  return `statewire: command lost (${failure.cause})`;
72
+ case "failed":
73
+ return failure.message
74
+ ? `statewire: command failed: ${failure.message}`
75
+ : "statewire: command failed";
75
76
  case "crashed":
76
77
  return "statewire: command crashed";
77
- case "result-unavailable":
78
- return `statewire: command result unavailable (${failure.cause})`;
78
+ case "expired":
79
+ return `statewire: command result expired (${failure.cause})`;
79
80
  }
80
81
  };
81
82
 
package/src/wire/ops.ts CHANGED
@@ -61,14 +61,12 @@ const sanitizeAt = (
61
61
  export const sanitizeValue = (value: unknown, parent?: string): unknown =>
62
62
  sanitizeAt(value, parent, parent === undefined ? [] : [parent]);
63
63
 
64
- type MutableOp = Extract<Statewire.Op, { op: "replace" | "add" | "remove" }>;
65
-
66
- const invalid = (op: MutableOp) =>
64
+ const invalid = (op: Statewire.Op) =>
67
65
  new Error(`Statewire cannot apply ${op.op} at path: ${op.path.join(".")}`);
68
66
 
69
67
  const insertedText = (
70
68
  current: string,
71
- op: Extract<MutableOp, { op: "add" }>,
69
+ op: Extract<Statewire.Op, { op: "add" }>,
72
70
  offset: number,
73
71
  ): string => {
74
72
  if (typeof op.value !== "string" || offset > current.length) {
@@ -79,7 +77,7 @@ const insertedText = (
79
77
 
80
78
  const applyAdd = (
81
79
  box: { state: unknown },
82
- op: Extract<MutableOp, { op: "add" }>,
80
+ op: Extract<Statewire.Op, { op: "add" }>,
83
81
  ) => {
84
82
  const offset = op.path[op.path.length - 1];
85
83
  const walked = walkStatePath(box.state, op.path.slice(0, -1), "resolve");
@@ -107,7 +105,7 @@ const applyAdd = (
107
105
  }
108
106
  };
109
107
 
110
- const applyInList = (list: unknown[], index: number, op: MutableOp) => {
108
+ const applyInList = (list: unknown[], index: number, op: Statewire.Op) => {
111
109
  if (index >= list.length) throw invalid(op);
112
110
  if (op.op === "replace") {
113
111
  list[index] = sanitizeValue(op.value);
@@ -119,7 +117,7 @@ const applyInList = (list: unknown[], index: number, op: MutableOp) => {
119
117
  const applyInObject = (
120
118
  obj: Record<string, unknown>,
121
119
  key: string,
122
- op: MutableOp,
120
+ op: Statewire.Op,
123
121
  ) => {
124
122
  if (op.op === "replace") {
125
123
  obj[key] = sanitizeValue(op.value, key);
@@ -129,7 +127,7 @@ const applyInObject = (
129
127
  }
130
128
  };
131
129
 
132
- const applyOpToDraft = (box: { state: unknown }, op: MutableOp): void => {
130
+ const applyOpToDraft = (box: { state: unknown }, op: Statewire.Op): void => {
133
131
  if (op.op === "add") {
134
132
  if (op.path.length === 0) throw invalid(op);
135
133
  applyAdd(box, op);
@@ -161,7 +159,7 @@ const unboxPaths = (paths: readonly StatewirePath[]): StatewirePath[] =>
161
159
 
162
160
  export const applyOp = (
163
161
  root: unknown,
164
- op: MutableOp,
162
+ op: Statewire.Op,
165
163
  ): { state: unknown; changedPaths: StatewirePath[] } => {
166
164
  const recorded = recordMutations({ state: root }, (draft) =>
167
165
  applyOpToDraft(draft, op),
@@ -177,15 +175,11 @@ export const applyOps = (
177
175
  ops: readonly unknown[],
178
176
  ): {
179
177
  state: unknown;
180
- events: unknown[];
181
178
  changedPaths: StatewirePath[];
182
179
  } => {
183
- const events: unknown[] = [];
184
- const mutable: MutableOp[] = [];
180
+ const mutable: Statewire.Op[] = [];
185
181
  for (const op of ops as Statewire.Op[]) {
186
- if (op.op === "event") {
187
- events.push(sanitizeValue(op.value));
188
- } else if (op.op === "replace" || op.op === "add" || op.op === "remove") {
182
+ if (op.op === "replace" || op.op === "add" || op.op === "remove") {
189
183
  mutable.push(op);
190
184
  } else {
191
185
  warnUnknownOp((op as { op: unknown }).op);
@@ -196,7 +190,6 @@ export const applyOps = (
196
190
  });
197
191
  return {
198
192
  state: recorded.state.state,
199
- events,
200
193
  changedPaths: unboxPaths(recorded.changedPaths),
201
194
  };
202
195
  };