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
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
2
  import { flushTapSync } from "@assistant-ui/tap";
3
- import { reviveOps, reviveResponse, reviveValue } from "../wire/meta";
4
- import { retryDelay } from "./timing";
3
+ import { reviveOps, reviveValue } from "../wire/meta";
4
+ import { backoff } from "./timing";
5
5
  import type { ConnectionState, StatewireRemoteProps } from "../transports/sse";
6
6
  import type { StatewireClient } from "../statewire";
7
7
  import type { StatewireHost } from "../host";
@@ -11,65 +11,72 @@ export type Episode = {
11
11
  message?: string;
12
12
  };
13
13
 
14
- export type StatewireResponse = {
14
+ export type StatewireCmd = {
15
15
  seq: number;
16
- type: "accepted" | "rejected" | "pending" | "crashed" | "result-unavailable";
16
+ type?: "applied" | "result" | "rejected" | "failed" | "crashed" | "expired";
17
17
  code?: string;
18
18
  message?: string;
19
19
  payload?: unknown;
20
20
  meta?: unknown;
21
+ /** Cumulative: resending through this seq will never be needed. */
22
+ dur?: boolean;
21
23
  };
22
24
 
23
25
  export type StatewireSyn = {
24
- lastSeq: number;
25
- lease?: string;
26
+ seq?: number;
27
+ ctxSeq?: number;
28
+ record?: "fresh" | "resumed" | "ephemeral";
29
+ id?: string;
30
+ idle?: boolean;
31
+ retry?: number;
26
32
  };
27
33
 
28
34
  export type StatewireEnvelope = {
29
35
  ops?: unknown[];
30
- res?: StatewireResponse[];
31
- ack?: number;
36
+ cmd?: StatewireCmd[];
37
+ next?: true;
38
+ idle?: boolean;
32
39
  syn?: StatewireSyn;
33
40
  fin?: StatewireHost.Fin;
34
41
  };
35
42
 
36
- const createGate = () => {
37
- let latched = false;
38
- let wake: (() => void) | undefined;
39
- return {
40
- take: () => {
41
- const was = latched;
42
- latched = false;
43
- return was;
44
- },
45
- wait: (timeoutMs?: number) => {
46
- if (latched) {
47
- latched = false;
48
- return Promise.resolve();
49
- }
50
- return new Promise<void>((resolve) => {
51
- const timer =
52
- timeoutMs === undefined
53
- ? undefined
54
- : setTimeout(() => {
55
- wake = undefined;
56
- resolve();
57
- }, timeoutMs);
58
- wake = () => {
59
- clearTimeout(timer);
60
- wake = undefined;
61
- resolve();
62
- };
63
- });
64
- },
65
- open: () => {
66
- if (wake) wake();
67
- else latched = true;
68
- },
69
- };
43
+ export type Gate = { latched: boolean; wake: (() => void) | undefined };
44
+
45
+ const newGate = (): Gate => ({ latched: false, wake: undefined });
46
+
47
+ const takeGate = (g: Gate) => {
48
+ const was = g.latched;
49
+ g.latched = false;
50
+ return was;
70
51
  };
71
52
 
72
- export type Gate = ReturnType<typeof createGate>;
53
+ const waitGate = (g: Gate, timeoutMs?: number) => {
54
+ if (g.latched) {
55
+ g.latched = false;
56
+ return Promise.resolve();
57
+ }
58
+ return new Promise<void>((resolve) => {
59
+ const timer =
60
+ timeoutMs === undefined
61
+ ? undefined
62
+ : setTimeout(() => {
63
+ g.wake = undefined;
64
+ resolve();
65
+ }, timeoutMs);
66
+ g.wake = () => {
67
+ clearTimeout(timer);
68
+ g.wake = undefined;
69
+ resolve();
70
+ };
71
+ });
72
+ };
73
+
74
+ const openGate = (g: Gate) => {
75
+ if (g.wake) g.wake();
76
+ else g.latched = true;
77
+ };
78
+
79
+ export type AttemptOutcome = "stop" | "reattach" | "ended" | "idle";
73
80
 
74
81
  export type Conn = {
75
82
  connection: StatewireClient.Connection;
@@ -77,14 +84,14 @@ export type Conn = {
77
84
  lease: string | null;
78
85
  attempt: number;
79
86
  episode: Episode | null;
87
+ awaitingHello: boolean;
88
+ held: StatewireCmd[];
80
89
  gate: Gate;
81
90
  abort: AbortController | null;
82
91
  lifetime: AbortController | null;
83
92
  ctx: StatewireRemoteProps;
84
93
  onChange: (connection: StatewireClient.Connection) => void;
85
- attemptOnce: (
86
- abort: AbortController,
87
- ) => Promise<"stop" | "reattach" | "ended">;
94
+ attemptOnce: (abort: AbortController) => Promise<AttemptOutcome>;
88
95
  notify: (connection: StatewireClient.Connection) => void;
89
96
  reconnect: () => void;
90
97
  };
@@ -96,84 +103,131 @@ const setStatus = (c: Conn, next: ConnectionState) => {
96
103
  if (changed) c.onChange(c.connection);
97
104
  };
98
105
 
99
- const deliverEnvelope = (
100
- c: Conn,
101
- env: Pick<StatewireEnvelope, "ops" | "res" | "ack" | "syn">,
102
- ) => {
103
- if (env.syn?.lease !== undefined) c.lease = env.syn.lease;
104
- const ctx = c.ctx;
105
- const ops = env.ops === undefined ? [] : reviveOps(env.ops);
106
- const res = env.res?.map(reviveResponse);
106
+ const protocolError = (message: string): never => {
107
+ throw new Error(`statewire: protocol error: ${message}`);
108
+ };
109
+
110
+ // A hold's release delivers, never settles: fin and bare close both drain it.
111
+ const releaseHeld = (c: Conn) => {
112
+ if (c.held.length === 0) return;
113
+ const held = c.held;
114
+ c.held = [];
115
+ c.ctx.onCmds(held);
116
+ };
117
+
118
+ const cmdsOf = (env: StatewireEnvelope): StatewireCmd[] => {
119
+ if (env.cmd === undefined) return [];
120
+ if (!Array.isArray(env.cmd)) return protocolError("cmd must be an array");
121
+ return env.cmd;
122
+ };
123
+
124
+ const handleHello = (c: Conn, env: StatewireEnvelope) => {
125
+ const syn = env.syn;
126
+ if (syn === undefined) return protocolError("expected hello");
127
+ c.awaitingHello = false;
128
+ c.hasSnapshot = true;
129
+ c.ctx.onHello({
130
+ ops: env.ops === undefined ? [] : reviveOps(env.ops),
131
+ syn,
132
+ });
133
+ c.ctx.onIdle(syn.idle ?? false);
134
+ c.attempt = 0;
135
+ c.episode = null;
136
+ setStatus(c, { status: "live" });
137
+ };
138
+
139
+ const deliverEnvelope = (c: Conn, env: StatewireEnvelope) => {
107
140
  if (env.syn !== undefined) {
108
- ctx.onSnapshot({ ops, lastSeq: env.syn.lastSeq });
109
- c.hasSnapshot = true;
110
- } else if (
111
- env.ops !== undefined ||
112
- env.res !== undefined ||
113
- env.ack !== undefined
114
- ) {
115
- ctx.onOps({
116
- ops,
117
- ...(res !== undefined && { res }),
118
- ...(env.ack !== undefined && { ack: env.ack }),
141
+ const syn = env.syn;
142
+ if (
143
+ syn.seq !== undefined ||
144
+ syn.ctxSeq !== undefined ||
145
+ syn.retry !== undefined
146
+ ) {
147
+ c.ctx.onRefusal(syn);
148
+ } else if (syn.id === undefined) {
149
+ protocolError("invalid mid-stream syn");
150
+ }
151
+ }
152
+ if (env.ops !== undefined) {
153
+ const held = c.held;
154
+ c.held = [];
155
+ c.ctx.onOps({
156
+ ops: reviveOps(env.ops),
157
+ cmds: [...held, ...cmdsOf(env)],
119
158
  });
159
+ } else if (env.cmd !== undefined) {
160
+ const cmds = cmdsOf(env);
161
+ if (env.next === true) c.held.push(...cmds);
162
+ else c.ctx.onCmds(cmds);
120
163
  }
164
+ if (typeof env.idle === "boolean") c.ctx.onIdle(env.idle);
121
165
  };
122
166
 
123
167
  const isTransientFinish = (
124
168
  reason: StatewireHost.Fin["reason"],
125
169
  ): reason is "evicted" | "error" => reason === "evicted" || reason === "error";
126
170
 
127
- const settleEnvelope = (
128
- c: Conn,
129
- env: StatewireEnvelope,
130
- ): "stop" | "reattach" | null => {
131
- let finish = env.fin;
132
- if (finish) {
133
- if (finish.meta !== undefined) {
134
- const { meta, ...rest } = finish;
135
- finish = { ...rest, payload: reviveValue(rest.payload, meta) };
136
- }
137
- c.ctx.onFinish(finish);
138
- if (isTransientFinish(finish.reason)) {
139
- c.episode = {
140
- cause: finish.reason,
141
- ...(finish.message !== undefined && { message: finish.message }),
142
- };
143
- return "reattach";
144
- }
145
- setStatus(c, {
146
- status: "gone",
147
- ...(finish.message !== undefined && { message: finish.message }),
148
- ...(finish.payload !== undefined && { payload: finish.payload }),
149
- });
150
- return "stop";
171
+ const settleFin = (c: Conn, fin: StatewireHost.Fin): AttemptOutcome => {
172
+ let finish = fin;
173
+ if (finish.meta !== undefined) {
174
+ const { meta, ...rest } = finish;
175
+ finish = { ...rest, payload: reviveValue(rest.payload, meta) };
151
176
  }
152
- if (c.connection.status !== "live") {
177
+ releaseHeld(c);
178
+ if (finish.reason === "idle") {
179
+ c.ctx.onIdle(true);
153
180
  c.attempt = 0;
154
181
  c.episode = null;
155
- setStatus(c, { status: "live" });
182
+ setStatus(c, { status: "idle" });
183
+ return "idle";
156
184
  }
157
- return null;
185
+ c.ctx.onFinish(finish);
186
+ if (isTransientFinish(finish.reason)) {
187
+ c.episode = {
188
+ cause: finish.reason,
189
+ ...(finish.message !== undefined && { message: finish.message }),
190
+ };
191
+ return "reattach";
192
+ }
193
+ setStatus(c, {
194
+ status: "gone",
195
+ ...(finish.message !== undefined && { message: finish.message }),
196
+ ...(finish.payload !== undefined && { payload: finish.payload }),
197
+ });
198
+ return "stop";
158
199
  };
159
200
 
160
201
  export const handleEnvelope = (
161
202
  c: Conn,
162
203
  env: StatewireEnvelope,
163
- ): "stop" | "reattach" | null => {
204
+ ): AttemptOutcome | null => {
205
+ if (typeof env !== "object" || env === null) {
206
+ return protocolError("packet must be an object");
207
+ }
208
+ if (c.awaitingHello) {
209
+ if (env.fin !== undefined) return settleFin(c, env.fin);
210
+ handleHello(c, env);
211
+ return null;
212
+ }
164
213
  deliverEnvelope(c, env);
165
- return settleEnvelope(c, env);
214
+ if (env.fin !== undefined) return settleFin(c, env.fin);
215
+ if (c.connection.status !== "live") {
216
+ c.attempt = 0;
217
+ c.episode = null;
218
+ setStatus(c, { status: "live" });
219
+ }
220
+ return null;
166
221
  };
167
222
 
168
223
  const scheduleRetry = (
169
224
  c: Conn,
170
225
  failure: string | undefined,
171
226
  immediate: boolean,
172
- cause?: unknown,
173
227
  ) => {
174
228
  c.attempt += 1;
175
229
  c.episode ??= { cause: "dropped" };
176
- const delay = immediate ? 0 : retryDelay(cause, c.attempt);
230
+ const delay = immediate ? 0 : backoff(c.attempt);
177
231
  if (c.hasSnapshot) {
178
232
  setStatus(c, {
179
233
  status: "reconnecting",
@@ -188,7 +242,7 @@ const scheduleRetry = (
188
242
 
189
243
  const reconnectConn = (c: Conn) => {
190
244
  if (!c.lifetime) return;
191
- if (c.connection.status === "gone") {
245
+ if (c.connection.status === "gone" || c.connection.status === "idle") {
192
246
  c.attempt = 0;
193
247
  c.episode = { cause: "dropped" };
194
248
  flushTapSync(() => {
@@ -196,7 +250,7 @@ const reconnectConn = (c: Conn) => {
196
250
  else setStatus(c, { status: "connecting" });
197
251
  });
198
252
  }
199
- c.gate.open();
253
+ openGate(c.gate);
200
254
  c.abort?.abort();
201
255
  };
202
256
 
@@ -205,30 +259,33 @@ const run = async (c: Conn, lifetime: AbortController, gate: Gate) => {
205
259
  while (alive()) {
206
260
  const abort = new AbortController();
207
261
  c.abort = abort;
262
+ c.awaitingHello = true;
263
+ c.held = [];
264
+ let outcome: AttemptOutcome | undefined;
208
265
  let failure: string | undefined;
209
- let cause: unknown;
210
- let gone = false;
211
266
  try {
212
- gone = (await c.attemptOnce(abort)) === "stop";
267
+ outcome = await c.attemptOnce(abort);
213
268
  } catch (error) {
214
269
  if (!abort.signal.aborted) {
215
270
  failure =
216
271
  error instanceof Error && error.message ? error.message : "network";
217
- cause = error;
218
272
  }
219
273
  }
220
274
  abort.abort();
221
275
  if (!alive()) return;
222
- if (gone) {
223
- await gate.wait();
276
+ flushTapSync(() => {
277
+ releaseHeld(c);
278
+ c.ctx.onDetach();
279
+ });
280
+ if (!alive()) return;
281
+ if (outcome === "stop" || outcome === "idle") {
282
+ await waitGate(gate);
224
283
  continue;
225
284
  }
226
- const immediate = gate.take();
227
- const delay = flushTapSync(() =>
228
- scheduleRetry(c, failure, immediate, cause),
229
- );
285
+ const immediate = takeGate(gate);
286
+ const delay = flushTapSync(() => scheduleRetry(c, failure, immediate));
230
287
  if (delay === 0) continue;
231
- await gate.wait(delay);
288
+ await waitGate(gate, delay);
232
289
  }
233
290
  };
234
291
 
@@ -236,10 +293,7 @@ export const useStatewireConn = (props: {
236
293
  url: string;
237
294
  onChange?: ((connection: StatewireClient.Connection) => void) | undefined;
238
295
  ctx: StatewireRemoteProps;
239
- attemptOnce: (
240
- c: Conn,
241
- abort: AbortController,
242
- ) => Promise<"stop" | "reattach" | "ended">;
296
+ attemptOnce: (c: Conn, abort: AbortController) => Promise<AttemptOutcome>;
243
297
  }): Conn => {
244
298
  const latest = useRef(props);
245
299
  latest.current = props;
@@ -250,10 +304,14 @@ export const useStatewireConn = (props: {
250
304
  lease: null,
251
305
  attempt: 0,
252
306
  episode: null,
307
+ awaitingHello: true,
308
+ held: [],
253
309
  abort: null,
254
- gate: createGate(),
310
+ gate: newGate(),
255
311
  lifetime: null,
256
- ctx: props.ctx,
312
+ get ctx() {
313
+ return latest.current.ctx;
314
+ },
257
315
  onChange: (connection) => latest.current.onChange?.(connection),
258
316
  attemptOnce: (abort) => latest.current.attemptOnce(conn, abort),
259
317
  notify: setConnection,
@@ -273,13 +331,13 @@ export const useStatewireConn = (props: {
273
331
  }
274
332
  const lifetime = new AbortController();
275
333
  c.lifetime = lifetime;
276
- c.gate = createGate();
334
+ c.gate = newGate();
277
335
  void run(c, lifetime, c.gate);
278
336
  return () => {
279
337
  c.abort?.abort();
280
338
  lifetime.abort();
281
339
  if (c.lifetime === lifetime) c.lifetime = null;
282
- c.gate.open();
340
+ openGate(c.gate);
283
341
  };
284
342
  }, [c, props.url]);
285
343
 
@@ -288,14 +346,3 @@ export const useStatewireConn = (props: {
288
346
 
289
347
  export const classify = (timeout: AbortSignal) =>
290
348
  new Error(timeout.aborted ? "timeout" : "network");
291
-
292
- export const parseRetryAfter = (header: string | null): number | undefined => {
293
- if (!header) return undefined;
294
- const value = header.trim();
295
- if (/^\d+$/.test(value)) return Number(value) * 1000;
296
- const date = Date.parse(value);
297
- return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
298
- };
299
-
300
- export const overloadedError = (retryAfterMs?: number) =>
301
- Object.assign(new Error("overloaded"), { retryAfterMs });