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,8 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import { applyOps } from "../wire/ops";
3
3
  import type { StatewirePath } from "../wire/ops";
4
- import type { StatewireResponse } from "./connection";
4
+ import type { StatewireCmd, StatewireSyn } from "./connection";
5
+ import { encodeParams, encodeValue, reviveResponse } from "../wire/meta";
5
6
  import { bindCommandsHost } from "../host/host";
6
7
  import type { CommandsInvocation, CommandsListener } from "../host/host";
7
8
  import type { Statewire } from "../types";
@@ -12,128 +13,45 @@ import type {
12
13
  StatewireSessionStore,
13
14
  StatewireSessionStoreOptions,
14
15
  } from "./sessionStore";
15
- import { retryDelay } from "./timing";
16
+ import { backoff } from "./timing";
16
17
  import { createMethodProxy } from "../util/methodProxy";
17
18
  import { randomId } from "../util/randomId";
18
19
  import { StatewireSendError } from "../types";
19
20
  import type {
20
21
  RemoteLink,
22
+ StatewireFrame,
21
23
  StatewireRemoteProps,
22
- StatewireSubmitResult,
23
- StatewireWireCommand,
24
24
  } from "../transports/sse";
25
25
 
26
- /**
27
- * queued/trying/posted: overlay live, result pending. awaiting-result: acked
28
- * (overlay gone) but the result rides a late res; `posted: false` re-sends.
29
- * settled-live: result settled but the overlay stays until the covering ack.
30
- * done: removed from the queue.
31
- */
32
- type PendingState =
33
- | { at: "queued"; pendingMarked: boolean }
34
- | { at: "trying"; pendingMarked: boolean; failedAttempts: number }
35
- | { at: "posted"; pendingMarked: boolean; failedAttempts: number }
36
- | { at: "awaiting-result"; posted: boolean; failedAttempts: number }
37
- | { at: "settled-live"; posted: boolean; failedAttempts: number }
38
- | { at: "done" };
26
+ type WireCommand = {
27
+ method: string;
28
+ params: unknown[];
29
+ meta?: unknown;
30
+ };
39
31
 
40
32
  type Pending = {
41
33
  readonly seq: number;
42
- readonly command: StatewireWireCommand;
43
- /** null for context submissions — they carry no command lifecycle. */
44
- readonly inv: CommandsInvocation | null;
45
- state: PendingState;
34
+ readonly command: WireCommand;
35
+ readonly inv: CommandsInvocation;
36
+ /** A send carrying this statement may have reached the server. */
37
+ attempted: boolean;
38
+ /** In the resend queue when false. */
39
+ posted: boolean;
40
+ /** The server acknowledged admission; the terminal verdict is still due. */
41
+ applied: boolean;
46
42
  resolve: (payload: unknown) => void;
47
43
  reject: (error: StatewireSendError) => void;
48
44
  };
49
45
 
50
- type Outcome =
51
- | { ok: true; payload: unknown }
52
- | { ok: false; failure: StatewireClient.SendFailure };
53
-
54
- const isResolved = (state: PendingState) =>
55
- state.at === "settled-live" || state.at === "done";
56
-
57
- const isStateSettled = (state: PendingState) =>
58
- state.at === "awaiting-result" || state.at === "done";
59
-
60
- // The pump admits an entry as trying before sending; only the pump itself
61
- // requeues, and a done entry is never still pending.
62
- const inFlight = (state: PendingState) => {
63
- if (state.at === "queued" || state.at === "done") {
64
- throw new Error(`statewire: ${state.at} entry observed after a send`);
65
- }
66
- return state;
67
- };
68
-
69
- const legalTransitions: Record<PendingState["at"], PendingState["at"][]> = {
70
- queued: ["queued", "trying", "awaiting-result", "settled-live", "done"],
71
- // trying -> queued: an answered unknown-client proves the seq was never admitted.
72
- trying: [
73
- "queued",
74
- "trying",
75
- "posted",
76
- "awaiting-result",
77
- "settled-live",
78
- "done",
79
- ],
80
- posted: ["posted", "trying", "awaiting-result", "settled-live", "done"],
81
- "awaiting-result": ["awaiting-result", "done"],
82
- "settled-live": ["settled-live", "done"],
83
- done: [],
84
- };
85
-
86
- const transition = (
87
- s: Session,
88
- entry: Pending,
89
- next: PendingState,
90
- outcome?: Outcome,
91
- ) => {
92
- const prev = entry.state;
93
- if (!legalTransitions[prev.at].includes(next.at)) {
94
- throw new Error(
95
- `statewire: illegal pending transition ${prev.at} -> ${next.at}`,
96
- );
97
- }
98
- const settles = !isResolved(prev) && isResolved(next);
99
- if (settles && outcome === undefined) {
100
- throw new Error(
101
- `statewire: settling transition ${prev.at} -> ${next.at} without an outcome`,
102
- );
103
- }
104
- const covers = !isStateSettled(prev) && isStateSettled(next);
105
- entry.state = next;
106
- if (covers && entry.inv) s.lifecycle?.onCovered?.(entry.inv);
107
- if (settles && outcome !== undefined) {
108
- if (outcome.ok) {
109
- entry.resolve(outcome.payload);
110
- if (entry.inv)
111
- s.lifecycle?.onSettle(entry.inv, {
112
- type: "accepted",
113
- payload: outcome.payload,
114
- });
115
- } else {
116
- entry.reject(new StatewireSendError(outcome.failure));
117
- if (entry.inv)
118
- s.lifecycle?.onSettle(entry.inv, {
119
- type: "rejected",
120
- error: new StatewireSendError(outcome.failure),
121
- });
122
- }
123
- }
124
- if (next.at === "done") {
125
- s.pending = s.pending.filter((p) => p !== entry);
126
- }
46
+ type CtxPending = {
47
+ readonly seq: number;
48
+ readonly ops: unknown[];
49
+ posted: boolean;
127
50
  };
128
51
 
129
- const carried = (
130
- state: Exclude<PendingState, { at: "done" }>,
131
- ): { posted: boolean; failedAttempts: number } =>
132
- state.at === "queued"
133
- ? { posted: false, failedAttempts: 0 }
134
- : state.at === "trying" || state.at === "posted"
135
- ? { posted: state.at === "posted", failedAttempts: state.failedAttempts }
136
- : { posted: state.posted, failedAttempts: state.failedAttempts };
52
+ const protocolError = (message: string): never => {
53
+ throw new Error(`statewire: protocol error: ${message}`);
54
+ };
137
55
 
138
56
  const newClientId = () => `c-${randomId()}`;
139
57
 
@@ -147,15 +65,26 @@ const rejectedSend = (
147
65
 
148
66
  type AttachState = "detached" | "attached" | "finished";
149
67
 
150
- export type StatewireSessionOptions = StatewireSessionStoreOptions;
68
+ export type StatewireSessionOptions = StatewireSessionStoreOptions & {
69
+ onDone?: (() => void) | undefined;
70
+ };
151
71
 
152
72
  type Session = {
153
73
  record: StatewireSessionRecord;
154
74
  ctx: StatewireRemoteProps;
155
75
  recordPersisted: boolean;
156
76
  store: StatewireSessionStore;
157
- lastAck: number;
158
- context: Record<string, unknown> | undefined;
77
+ ephemeral: boolean;
78
+ sessionKnown: boolean;
79
+ durableSeq: number;
80
+ resumeAt: number;
81
+ idle: boolean;
82
+ onDone: (() => void) | undefined;
83
+ ctxOps: unknown[] | undefined;
84
+ ctxPending: CtxPending[];
85
+ lanesOnAttach: boolean;
86
+ attachLaneSeqs: readonly [ReadonlySet<number>, ReadonlySet<number>];
87
+ frameFailures: number;
159
88
  pending: Pending[];
160
89
  canonical: unknown;
161
90
  lastPublished: unknown;
@@ -169,64 +98,9 @@ type Session = {
169
98
  waiters: (() => void)[];
170
99
  lifecycle: CommandsListener | null;
171
100
  notify: () => void;
172
- emitEvent: (event: unknown) => void;
173
- send: (command: StatewireWireCommand) => Promise<unknown>;
174
101
  commands: Record<string, (...params: unknown[]) => Promise<unknown>>;
175
102
  };
176
103
 
177
- const createSession = (options: StatewireSessionOptions): Session => {
178
- const store = options.sessionStore ?? createMemorySessionStore();
179
- const stored = store.load();
180
- const record = stored ?? { clientId: newClientId(), nextSeq: 1 };
181
- const s: Session = {
182
- record,
183
- ctx: {
184
- get clientId() {
185
- return s.record.clientId;
186
- },
187
- onSnapshot: (args) => handleSnapshot(s, args),
188
- onOps: (args) => handleOps(s, args),
189
- onFinish: (args) => handleFinish(s, args),
190
- },
191
- recordPersisted: stored !== null,
192
- store,
193
- lastAck: -1,
194
- context: undefined,
195
- pending: [],
196
- canonical: undefined,
197
- lastPublished: undefined,
198
- changed: [],
199
- source: {
200
- snapshot: () => s.canonical,
201
- takeChanged: () => {
202
- const changed = s.changed;
203
- s.changed = [];
204
- return changed;
205
- },
206
- setOnChange: (listener) => {
207
- s.notify = listener ?? (() => {});
208
- },
209
- },
210
- attach: "detached",
211
- attachEpoch: 0,
212
- running: false,
213
- pumping: false,
214
- transport: null,
215
- waiters: [],
216
- lifecycle: null,
217
- notify: () => {},
218
- emitEvent: () => {},
219
- send: (command) => sendCommand(s, command),
220
- commands: createMethodProxy((method, params) => s.send({ method, params })),
221
- };
222
- bindCommandsHost(s.commands, {
223
- setListener: (listener) => {
224
- s.lifecycle = listener;
225
- },
226
- });
227
- return s;
228
- };
229
-
230
104
  const publish = (s: Session) => {
231
105
  if (s.canonical === s.lastPublished) return;
232
106
  s.lastPublished = s.canonical;
@@ -254,17 +128,51 @@ const sleepUntilWake = (s: Session, delayMs: number) =>
254
128
  s.waiters.push(waiter);
255
129
  });
256
130
 
257
- const settleAll = (
131
+ const setRecord = (s: Session, record: StatewireSessionRecord) => {
132
+ s.record = record;
133
+ if (!s.ephemeral) s.store.save(record);
134
+ };
135
+
136
+ const setIdle = (s: Session, level: boolean) => {
137
+ if (level === s.idle) return;
138
+ s.idle = level;
139
+ s.notify();
140
+ if (level) s.onDone?.();
141
+ };
142
+
143
+ const cover = (s: Session, entry: Pending) => {
144
+ if (entry.applied) return;
145
+ entry.applied = true;
146
+ s.lifecycle?.onApplied(entry.inv);
147
+ s.lifecycle?.onCovered?.(entry.inv);
148
+ };
149
+
150
+ const settleEntry = (
258
151
  s: Session,
259
- detach: "evicted" | "unmount" | "gone" | "superseded",
260
- verdict?: {
261
- entry: Pending;
262
- failure: StatewireClient.SendFailure;
263
- },
152
+ entry: Pending,
153
+ outcome:
154
+ | { ok: true; payload: unknown }
155
+ | { ok: false; failure: StatewireClient.SendFailure },
264
156
  ) => {
157
+ s.pending = s.pending.filter((p) => p !== entry);
158
+ if (outcome.ok) {
159
+ entry.resolve(outcome.payload);
160
+ s.lifecycle?.onSettle(entry.inv, {
161
+ type: "accepted",
162
+ payload: outcome.payload,
163
+ });
164
+ } else {
165
+ entry.reject(new StatewireSendError(outcome.failure));
166
+ s.lifecycle?.onSettle(entry.inv, {
167
+ type: "rejected",
168
+ error: new StatewireSendError(outcome.failure),
169
+ });
170
+ }
171
+ };
172
+
173
+ const settleAll = (s: Session, detach: "unmount" | "gone" | "superseded") => {
265
174
  const cause = (
266
175
  {
267
- evicted: "session-lost",
268
176
  unmount: "unmount",
269
177
  gone: "stream-gone",
270
178
  superseded: "superseded",
@@ -272,12 +180,9 @@ const settleAll = (
272
180
  )[detach];
273
181
  const batch = s.pending;
274
182
  const detachFailure = (entry: Pending): StatewireClient.SendFailure => {
275
- const state = entry.state;
276
- if (state.at === "awaiting-result")
277
- return { fate: "result-unavailable", cause };
278
- if (state.at === "queued") {
183
+ if (!entry.attempted) {
279
184
  const predecessorLost = batch.some(
280
- (p) => p.seq < entry.seq && p.state.at !== "queued",
185
+ (p) => p.seq < entry.seq && p.attempted,
281
186
  );
282
187
  return predecessorLost
283
188
  ? { fate: "aborted", cause, predecessorLost }
@@ -286,221 +191,239 @@ const settleAll = (
286
191
  return { fate: "lost", cause };
287
192
  };
288
193
  for (const entry of [...batch]) {
289
- const failure =
290
- entry === verdict?.entry ? verdict.failure : detachFailure(entry);
291
- transition(s, entry, { at: "done" }, { ok: false, failure });
194
+ s.lifecycle?.onCovered?.(entry.inv);
195
+ settleEntry(s, entry, { ok: false, failure: detachFailure(entry) });
292
196
  }
293
197
  };
294
198
 
295
- // Claims every unsettled real command for replay under the rotated client id,
296
- // in seq order; context submissions settle and the context re-sends whole.
297
- const claimReplays = (s: Session): Pending[] => {
298
- const claimed = s.pending.filter(
299
- (entry) => entry.inv !== null && !isResolved(entry.state),
300
- );
301
- if (claimed.length === 0) return [];
302
- s.pending = s.pending.filter((p) => !claimed.includes(p));
303
- return claimed;
199
+ const restateContext = (s: Session) => {
200
+ s.ctxPending = [];
201
+ if (s.ctxOps === undefined) return;
202
+ const seq = s.record.ctxNextSeq;
203
+ setRecord(s, { ...s.record, ctxNextSeq: seq + 1 });
204
+ s.ctxPending.push({ seq, ops: s.ctxOps, posted: false });
304
205
  };
305
206
 
306
- const evict = (
307
- s: Session,
308
- verdict?: {
309
- entry: Pending;
310
- failure: StatewireClient.SendFailure;
311
- },
312
- ) => {
313
- const replays = claimReplays(s);
314
- settleAll(s, "evicted", verdict);
315
- s.record = { clientId: newClientId(), nextSeq: 1 };
316
- s.store.save(s.record);
317
- s.attach = "detached";
318
- // The server dropped this client's record and its stored context with it:
319
- // the new session opens with a fresh full root replace.
320
- if (s.context !== undefined) void s.send({ context: s.context });
321
- for (const old of replays) {
322
- if (old.inv) s.lifecycle?.onCovered?.(old.inv);
323
- enqueue(s, old.command, old.resolve, old.reject);
207
+ // The server-side record's dedup memory is gone: history that may have
208
+ // reached a server settles lost — the new hello's snapshot is the only
209
+ // truth about what survived — and never-attempted statements re-stamp
210
+ // from 1 on the reset lane clocks.
211
+ const resetRecord = (s: Session, clientId: string) => {
212
+ const survivors: Pending[] = [];
213
+ for (const entry of [...s.pending]) {
214
+ if (entry.attempted || entry.applied) {
215
+ s.lifecycle?.onCovered?.(entry.inv);
216
+ settleEntry(s, entry, {
217
+ ok: false,
218
+ failure: { fate: "lost", cause: "session-lost" },
219
+ });
220
+ } else {
221
+ survivors.push(entry);
222
+ }
223
+ }
224
+ s.pending = [];
225
+ s.durableSeq = 0;
226
+ setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1 });
227
+ for (const old of survivors) {
228
+ s.lifecycle?.onCovered?.(old.inv);
229
+ enqueue(s, old.command, old.inv.params, old.resolve, old.reject);
324
230
  }
231
+ restateContext(s);
325
232
  publish(s);
326
233
  wake(s);
327
234
  };
328
235
 
329
- const noteAcked = (s: Session, seq: number) => {
330
- for (const entry of s.pending) {
331
- if (entry.seq > seq || isStateSettled(entry.state)) continue;
332
- if (entry.inv) s.lifecycle?.onAck(entry.inv);
333
- }
334
- };
335
-
336
- const spliceAcked = (s: Session, seq: number, settleVoid: boolean) => {
337
- for (const entry of [...s.pending]) {
338
- const state = entry.state;
339
- if (entry.seq > seq || isStateSettled(state)) continue;
340
- if (state.at === "settled-live") {
341
- transition(s, entry, { at: "done" });
342
- } else if (settleVoid && !state.pendingMarked) {
343
- transition(s, entry, { at: "done" }, { ok: true, payload: undefined });
344
- } else {
345
- transition(s, entry, { at: "awaiting-result", ...carried(state) });
346
- }
347
- }
236
+ // The record died with the attach: mint a fresh id for the next one.
237
+ const ephemeralDetach = (s: Session) => {
238
+ s.ephemeral = false;
239
+ s.sessionKnown = false;
240
+ resetRecord(s, newClientId());
348
241
  };
349
242
 
350
- const protocolError = (message: string): never => {
351
- throw new Error(`statewire: protocol error: ${message}`);
243
+ const rejectedFailure = (cmd: StatewireCmd): StatewireClient.SendFailure => {
244
+ const code =
245
+ cmd.code === undefined ||
246
+ cmd.code === "unknown-command" ||
247
+ cmd.code === "invalid-params"
248
+ ? cmd.code
249
+ : protocolError(`unknown rejected code: ${cmd.code}`);
250
+ return {
251
+ fate: "rejected",
252
+ cause: "server",
253
+ ...(code !== undefined && { code }),
254
+ ...(cmd.message !== undefined && { message: cmd.message }),
255
+ ...(cmd.payload !== undefined && { payload: cmd.payload }),
256
+ };
352
257
  };
353
258
 
354
- const applyResponse = (s: Session, response: StatewireResponse) => {
355
- const entry = s.pending.find((p) => p.seq === response.seq);
259
+ const handleCmd = (s: Session, cmd: StatewireCmd) => {
260
+ if (typeof cmd.seq !== "number") protocolError("cmd entry missing seq");
261
+ if (cmd.dur === true) {
262
+ s.durableSeq = Math.max(s.durableSeq, cmd.seq);
263
+ for (const entry of s.pending) {
264
+ if (entry.seq <= s.durableSeq) entry.posted = true;
265
+ }
266
+ }
267
+ if (cmd.type === undefined) return;
268
+ const entry = s.pending.find((p) => p.seq === cmd.seq);
269
+ if (cmd.type === "applied") {
270
+ if (entry) cover(s, entry);
271
+ return;
272
+ }
356
273
  if (!entry) return;
357
- const state = entry.state;
358
- if (state.at === "done") return;
359
- switch (response.type) {
360
- case "pending":
361
- if (
362
- state.at === "queued" ||
363
- state.at === "trying" ||
364
- state.at === "posted"
365
- ) {
366
- transition(s, entry, { ...state, pendingMarked: true });
367
- }
368
- break;
369
- case "accepted":
370
- if (state.at === "settled-live") break;
371
- transition(
372
- s,
373
- entry,
374
- state.at === "awaiting-result"
375
- ? { at: "done" }
376
- : { at: "settled-live", ...carried(state) },
377
- { ok: true, payload: response.payload },
378
- );
379
- break;
380
- case "rejected":
381
- transition(
382
- s,
383
- entry,
384
- { at: "done" },
385
- {
386
- ok: false,
387
- failure:
388
- response.code === "unknown-command"
389
- ? { fate: "unknown-command", cause: "server" }
390
- : response.code === "invalid-params"
391
- ? {
392
- fate: "invalid-params",
393
- cause: "server",
394
- message:
395
- response.message ??
396
- protocolError("rejected response missing message"),
397
- }
398
- : {
399
- fate: "rejected",
400
- cause: "server",
401
- message:
402
- response.message ??
403
- protocolError("rejected response missing message"),
404
- ...(response.payload !== undefined && {
405
- payload: response.payload,
406
- }),
274
+ cover(s, entry);
275
+ const outcome =
276
+ cmd.type === "result"
277
+ ? { ok: true as const, payload: reviveResponse(cmd).payload }
278
+ : cmd.type === "rejected"
279
+ ? { ok: false as const, failure: rejectedFailure(reviveResponse(cmd)) }
280
+ : cmd.type === "failed"
281
+ ? {
282
+ ok: false as const,
283
+ failure: {
284
+ fate: "failed" as const,
285
+ cause: "server" as const,
286
+ ...(cmd.message !== undefined && { message: cmd.message }),
287
+ },
288
+ }
289
+ : cmd.type === "crashed"
290
+ ? {
291
+ ok: false as const,
292
+ failure: { fate: "crashed" as const, cause: "server" as const },
293
+ }
294
+ : cmd.type === "expired"
295
+ ? {
296
+ ok: false as const,
297
+ failure: {
298
+ fate: "expired" as const,
299
+ cause: "server" as const,
407
300
  },
408
- },
409
- );
410
- break;
411
- case "crashed":
412
- case "result-unavailable":
413
- if (state.at === "settled-live") break;
414
- transition(
415
- s,
416
- entry,
417
- state.at === "awaiting-result"
418
- ? { at: "done" }
419
- : { at: "settled-live", ...carried(state) },
420
- { ok: false, failure: { fate: response.type, cause: "server" } },
421
- );
422
- break;
423
- }
301
+ }
302
+ : protocolError(`unknown cmd type: ${String(cmd.type)}`);
303
+ settleEntry(s, entry, outcome);
304
+ };
305
+
306
+ const handleCmds = (s: Session, cmds: StatewireCmd[]) => {
307
+ for (const cmd of cmds) handleCmd(s, cmd);
308
+ wake(s);
424
309
  };
425
310
 
426
311
  const ingestOps = (s: Session, ops: unknown[]) => {
427
312
  const applied = applyOps(s.canonical, ops);
428
313
  s.canonical = applied.state;
429
314
  s.changed.push(...applied.changedPaths);
430
- return applied.events;
431
315
  };
432
316
 
433
- const publishIngested = (s: Session, events: unknown[]) => {
317
+ const publishIngested = (s: Session) => {
434
318
  publish(s);
435
- for (const event of events) s.emitEvent(event);
436
319
  wake(s);
437
320
  };
438
321
 
439
- const handleSnapshot = (
322
+ const RECORDS = ["fresh", "resumed", "ephemeral"];
323
+
324
+ // Snap a resumed session's lane clocks onto the record: seqs above the stated
325
+ // watermark were never admitted, so a stale restored clock re-stamps its
326
+ // never-admitted statements consecutively after the watermark.
327
+ const snapClocks = (s: Session, seqWm: number, ctxWm: number) => {
328
+ const tail = s.pending.filter((p) => p.seq > seqWm);
329
+ const cmdAligned =
330
+ tail.every((p, i) => p.seq === seqWm + 1 + i) &&
331
+ s.record.nextSeq === seqWm + tail.length + 1;
332
+ if (!cmdAligned) {
333
+ s.pending = s.pending.filter((p) => p.seq <= seqWm);
334
+ setRecord(s, { ...s.record, nextSeq: seqWm + 1 });
335
+ for (const old of tail) {
336
+ s.lifecycle?.onCovered?.(old.inv);
337
+ enqueue(s, old.command, old.inv.params, old.resolve, old.reject);
338
+ }
339
+ }
340
+ const ctxAligned =
341
+ s.ctxPending.every((c, i) => c.seq === ctxWm + 1 + i) &&
342
+ s.record.ctxNextSeq === ctxWm + s.ctxPending.length + 1;
343
+ if (!ctxAligned) {
344
+ setRecord(s, { ...s.record, ctxNextSeq: ctxWm + 1 });
345
+ restateContext(s);
346
+ }
347
+ };
348
+
349
+ const handleHello = (
440
350
  s: Session,
441
- { ops, lastSeq }: { ops: unknown[]; lastSeq: number },
351
+ { ops, syn }: { ops: unknown[]; syn: StatewireSyn },
442
352
  ) => {
443
- if (lastSeq === -1 && s.attach !== "detached") {
444
- evict(s);
445
- s.transport?.connection.reconnect();
446
- publishIngested(s, ingestOps(s, ops));
447
- return;
353
+ const record = syn.record ?? "fresh";
354
+ if (!RECORDS.includes(record)) {
355
+ protocolError(`unknown record: ${String(record)}`);
448
356
  }
449
- const events = ingestOps(s, ops);
357
+ ingestOps(s, ops);
450
358
  s.attach = "attached";
451
359
  s.attachEpoch += 1;
452
- // The attach snapshot is not the true covering envelope: acked entries
453
- // stay awaiting their result and re-send under seq dedup.
454
- noteAcked(s, lastSeq);
455
- spliceAcked(s, lastSeq, false);
456
- s.lastAck = lastSeq;
457
- for (const entry of s.pending) {
458
- const state = entry.state;
459
- if (entry.seq <= lastSeq) {
460
- if (state.at === "awaiting-result" && state.posted) {
461
- transition(s, entry, { ...state, posted: false });
462
- }
463
- if (state.at === "awaiting-result") void pump(s);
464
- } else if (state.at === "posted") {
465
- // Above the ack watermark a prior receipt no longer counts.
466
- transition(s, entry, {
467
- at: "trying",
468
- pendingMarked: state.pendingMarked,
469
- failedAttempts: state.failedAttempts,
470
- });
471
- void pump(s);
472
- } else if (state.at === "settled-live" && state.posted) {
473
- transition(s, entry, { ...state, posted: false });
474
- void pump(s);
360
+ s.frameFailures = 0;
361
+ s.resumeAt = 0;
362
+ s.ephemeral = record === "ephemeral";
363
+ const [attachCmd, attachCtx] = s.attachLaneSeqs;
364
+ s.attachLaneSeqs = [new Set(), new Set()];
365
+ if (record === "resumed") {
366
+ // Resending unsettled statements is exactly-once-safe: absorbed replays
367
+ // re-emit their stored verdicts (applied while still in flight).
368
+ for (const entry of s.pending) {
369
+ entry.posted = attachCmd.has(entry.seq);
475
370
  }
371
+ const ctxWatermark = syn.ctxSeq ?? 0;
372
+ s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
373
+ for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
374
+ snapClocks(s, syn.seq ?? 0, ctxWatermark);
375
+ } else {
376
+ resetRecord(s, s.record.clientId);
476
377
  }
477
- publishIngested(s, events);
378
+ s.sessionKnown = true;
379
+ publishIngested(s);
380
+ void pump(s);
478
381
  };
479
382
 
480
383
  const handleOps = (
481
384
  s: Session,
482
- {
483
- ops,
484
- res,
485
- ack,
486
- }: { ops: unknown[]; res?: StatewireResponse[]; ack?: number },
385
+ { ops, cmds }: { ops: unknown[]; cmds: StatewireCmd[] },
487
386
  ) => {
488
- const horizon = ack === undefined ? s.lastAck : Math.max(s.lastAck, ack);
489
- if (res !== undefined) {
490
- for (const response of res) {
491
- if (response.seq > horizon) {
492
- protocolError("response seq beyond the ack");
493
- }
494
- }
387
+ ingestOps(s, ops);
388
+ for (const cmd of cmds) handleCmd(s, cmd);
389
+ publishIngested(s);
390
+ };
391
+
392
+ // A {syn} refusal never settles: reconcile the resend queues against the
393
+ // stated watermarks; retry present means wait, absent means resend now.
394
+ const handleRefusal = (s: Session, syn: StatewireSyn) => {
395
+ const { seq, ctxSeq } = syn;
396
+ if (typeof seq === "number") {
397
+ const watermark = Math.max(seq, s.durableSeq);
398
+ for (const entry of s.pending) entry.posted = entry.seq <= watermark;
495
399
  }
496
- const events = ingestOps(s, ops);
497
- if (ack !== undefined) noteAcked(s, ack);
498
- if (res !== undefined) for (const response of res) applyResponse(s, response);
499
- if (ack !== undefined) {
500
- spliceAcked(s, ack, true);
501
- s.lastAck = Math.max(s.lastAck, ack);
400
+ if (typeof ctxSeq === "number") {
401
+ s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxSeq);
402
+ for (const c of s.ctxPending) c.posted = false;
502
403
  }
503
- publishIngested(s, events);
404
+ if (typeof syn.retry === "number") {
405
+ s.resumeAt = Date.now() + Math.max(0, syn.retry);
406
+ }
407
+ const firstCmd = s.pending.find((p) => !p.posted);
408
+ const firstCtx = s.ctxPending.find((c) => !c.posted);
409
+ const clocksAhead =
410
+ (typeof syn.seq === "number" &&
411
+ firstCmd !== undefined &&
412
+ firstCmd.seq > Math.max(syn.seq, s.durableSeq) + 1) ||
413
+ (typeof syn.ctxSeq === "number" &&
414
+ firstCtx !== undefined &&
415
+ firstCtx.seq > syn.ctxSeq + 1);
416
+ // Clocks ahead of a record that cannot admit them: reattach first and let
417
+ // the hello's record arbitrate.
418
+ if (clocksAhead) s.transport?.connection.reconnect();
419
+ wake(s);
420
+ void pump(s);
421
+ };
422
+
423
+ const handleDetach = (s: Session) => {
424
+ if (s.attach === "attached") s.attach = "detached";
425
+ if (s.ephemeral) ephemeralDetach(s);
426
+ wake(s);
504
427
  };
505
428
 
506
429
  const handleFinish = (s: Session, { reason }: { reason: string }) => {
@@ -513,130 +436,120 @@ const handleFinish = (s: Session, { reason }: { reason: string }) => {
513
436
 
514
437
  const isFinished = (s: Session) => s.attach === "finished";
515
438
 
516
- const sendable = (p: Pending) =>
517
- p.state.at === "queued" ||
518
- p.state.at === "trying" ||
519
- ((p.state.at === "awaiting-result" || p.state.at === "settled-live") &&
520
- !p.state.posted);
439
+ type Lanes = {
440
+ frame: StatewireFrame;
441
+ cmdEntries: Pending[];
442
+ ctxEntries: CtxPending[];
443
+ };
444
+
445
+ // A lane must be strictly consecutive within a frame: the first unposted run
446
+ // per lane rides now, later runs ride follow-up frames.
447
+ const buildLanes = (s: Session): Lanes | null => {
448
+ const ctxEntries: CtxPending[] = [];
449
+ for (const entry of s.ctxPending) {
450
+ if (entry.posted) continue;
451
+ if (ctxEntries.length > 0 && entry.seq !== ctxEntries.at(-1)!.seq + 1)
452
+ break;
453
+ ctxEntries.push(entry);
454
+ }
455
+ const cmdEntries: Pending[] = [];
456
+ for (const entry of s.pending) {
457
+ if (entry.posted) continue;
458
+ if (cmdEntries.length > 0 && entry.seq !== cmdEntries.at(-1)!.seq + 1)
459
+ break;
460
+ cmdEntries.push(entry);
461
+ }
462
+ if (ctxEntries.length === 0 && cmdEntries.length === 0) return null;
463
+ return {
464
+ frame: {
465
+ ...(ctxEntries.length > 0 && {
466
+ ctx: ctxEntries.map((c) => ({ ops: c.ops, seq: c.seq })),
467
+ }),
468
+ ...(cmdEntries.length > 0 && {
469
+ cmd: cmdEntries.map((p) => ({ ...p.command, seq: p.seq })),
470
+ }),
471
+ },
472
+ cmdEntries,
473
+ ctxEntries,
474
+ };
475
+ };
476
+
477
+ const takeAttachLanes = (s: Session): StatewireFrame | null => {
478
+ s.attachLaneSeqs = [new Set(), new Set()];
479
+ const wanted = s.lanesOnAttach;
480
+ s.lanesOnAttach = false;
481
+ if (!wanted || !s.sessionKnown) return null;
482
+ const lanes = buildLanes(s);
483
+ if (!lanes) return null;
484
+ s.attachLaneSeqs = [
485
+ new Set(lanes.cmdEntries.map((p) => p.seq)),
486
+ new Set(lanes.ctxEntries.map((c) => c.seq)),
487
+ ];
488
+ for (const entry of lanes.cmdEntries) entry.attempted = true;
489
+ return lanes.frame;
490
+ };
521
491
 
522
492
  const pump = async (s: Session) => {
523
493
  if (s.pumping) return;
524
494
  s.pumping = true;
525
495
  try {
526
496
  while (s.running && !isFinished(s)) {
527
- const entry = s.pending.find(sendable);
528
- if (!entry) return;
529
497
  const transport = s.transport;
498
+ if (buildLanes(s) === null) return;
530
499
  if (s.attach !== "attached" || !transport) {
500
+ if (transport?.connection.status === "idle") {
501
+ transport.connection.reconnect();
502
+ }
531
503
  await waitForWake(s);
532
504
  continue;
533
505
  }
534
- const clientId = s.record.clientId;
535
- const epoch = s.attachEpoch;
536
- if (entry.state.at === "queued") {
537
- transition(s, entry, {
538
- at: "trying",
539
- pendingMarked: entry.state.pendingMarked,
540
- failedAttempts: 0,
541
- });
506
+ const resumeDelay = s.resumeAt - Date.now();
507
+ if (resumeDelay > 0) {
508
+ await sleepUntilWake(s, resumeDelay);
509
+ continue;
542
510
  }
543
- let result: StatewireSubmitResult;
511
+ const lanes = buildLanes(s);
512
+ if (!lanes) continue;
513
+ const epoch = s.attachEpoch;
514
+ for (const entry of lanes.cmdEntries) entry.attempted = true;
515
+ let result;
544
516
  try {
545
- result = await transport.send({
546
- seq: entry.seq,
547
- command: entry.command,
548
- });
549
- } catch (error) {
517
+ result = await transport.send(lanes.frame);
518
+ } catch {
550
519
  if (!s.running || isFinished(s)) return;
551
- if (!s.pending.includes(entry)) continue;
552
- const state = inFlight(entry.state);
553
- transition(s, entry, {
554
- ...state,
555
- failedAttempts: state.failedAttempts + 1,
556
- });
557
- await sleepUntilWake(s, retryDelay(error, state.failedAttempts + 1));
520
+ s.frameFailures += 1;
521
+ await sleepUntilWake(s, backoff(s.frameFailures));
558
522
  continue;
559
523
  }
560
524
  if (!s.running) return;
561
- if (!s.pending.includes(entry)) continue;
562
- if (result.type === "receipt") {
563
- const response = result.response;
564
- const state = entry.state;
565
- if (state.at === "trying" || state.at === "posted") {
566
- transition(s, entry, {
567
- at: "posted",
568
- pendingMarked: state.pendingMarked,
569
- failedAttempts: state.failedAttempts,
570
- });
571
- } else if (
572
- state.at === "awaiting-result" ||
573
- state.at === "settled-live"
574
- ) {
575
- transition(s, entry, { ...state, posted: true });
576
- }
577
- if (entry.inv) s.lifecycle?.onPosted?.(entry.inv);
578
- if (response !== undefined) {
579
- applyResponse(s, response);
580
- publish(s);
525
+ s.frameFailures = 0;
526
+ if (result.type === "ok") {
527
+ if (epoch === s.attachEpoch) {
528
+ for (const entry of lanes.cmdEntries) {
529
+ if (s.pending.includes(entry)) {
530
+ entry.posted = true;
531
+ s.lifecycle?.onPosted?.(entry.inv);
532
+ }
533
+ }
534
+ for (const entry of lanes.ctxEntries) entry.posted = true;
581
535
  }
582
- } else if (result.type === "rejected") {
583
- transition(
584
- s,
585
- entry,
586
- { at: "done" },
587
- {
588
- ok: false,
589
- failure: {
590
- fate: "rejected",
591
- cause: "server",
592
- message: result.message,
593
- },
594
- },
595
- );
596
- publish(s);
536
+ } else if (result.type === "refusal") {
537
+ if (epoch === s.attachEpoch) handleRefusal(s, result.syn);
597
538
  } else if (result.type === "stale-lease") {
598
- if (epoch === s.attachEpoch) await waitForWake(s);
599
- } else if (result.type === "unknown-client") {
600
- // Eviction is never decided on the POST leg: re-attach first and let
601
- // the snapshot's lastSeq deliver the verdict. An answered attempt
602
- // proves the server never admitted this seq, so a clean entry requeues.
539
+ // A POST outlived its attach: reattach first, the hello arbitrates.
603
540
  if (epoch === s.attachEpoch) {
604
- const state = entry.state;
605
- if (state.at === "trying" && state.failedAttempts === 0) {
606
- transition(s, entry, {
607
- at: "queued",
608
- pendingMarked: state.pendingMarked,
609
- });
610
- }
611
- s.transport?.connection.reconnect();
612
- while (
613
- s.running &&
614
- !isFinished(s) &&
615
- epoch === s.attachEpoch &&
616
- clientId === s.record.clientId
617
- ) {
541
+ transport.connection.reconnect();
542
+ await waitForWake(s);
543
+ }
544
+ } else if (result.type === "no-lease") {
545
+ // No out-of-band write channel: the frame rides a fresh attach.
546
+ if (epoch === s.attachEpoch) {
547
+ s.lanesOnAttach = true;
548
+ transport.connection.reconnect();
549
+ while (s.running && !isFinished(s) && epoch === s.attachEpoch) {
618
550
  await waitForWake(s);
619
551
  }
620
552
  }
621
- } else if (result.type === "seq-gap") {
622
- if (clientId === s.record.clientId) {
623
- console.error(
624
- `statewire: server reports a seq gap for seq ${entry.seq}; ` +
625
- "treating the session as corrupt and rotating the client id",
626
- );
627
- // Every attempt answered means the server never saw it; a thrown
628
- // attempt may have left the machine unanswered, so the verdict is "lost".
629
- const state = inFlight(entry.state);
630
- const failure: StatewireClient.SendFailure =
631
- state.at === "awaiting-result"
632
- ? { fate: "result-unavailable", cause: "session-lost" }
633
- : {
634
- fate: state.failedAttempts > 0 ? "lost" : "aborted",
635
- cause: "session-lost",
636
- };
637
- evict(s, { entry, failure });
638
- s.transport?.connection.reconnect();
639
- }
640
553
  }
641
554
  }
642
555
  } finally {
@@ -667,38 +580,38 @@ const setTransport = (s: Session, transport: RemoteLink) => {
667
580
 
668
581
  const enqueue = (
669
582
  s: Session,
670
- command: StatewireWireCommand,
583
+ command: WireCommand,
584
+ params: readonly unknown[],
671
585
  resolve: (payload: unknown) => void,
672
586
  reject: (error: StatewireSendError) => void,
673
587
  ) => {
674
588
  const seq = s.record.nextSeq;
675
- s.record = { ...s.record, nextSeq: seq + 1 };
676
- s.store.save(s.record);
589
+ setRecord(s, { ...s.record, nextSeq: seq + 1 });
677
590
  const entry: Pending = {
678
591
  seq,
679
592
  command,
680
- inv:
681
- command.method === undefined
682
- ? null
683
- : {
684
- method: command.method,
685
- params: command.params,
686
- ts: Date.now(),
687
- seq,
688
- clientId: s.record.clientId,
689
- },
690
- state: { at: "queued", pendingMarked: false },
593
+ inv: {
594
+ method: command.method,
595
+ params,
596
+ ts: Date.now(),
597
+ seq,
598
+ clientId: s.record.clientId,
599
+ },
600
+ attempted: false,
601
+ posted: false,
602
+ applied: false,
691
603
  resolve,
692
604
  reject,
693
605
  };
694
606
  s.pending.push(entry);
695
- if (entry.inv) s.lifecycle?.onStart(entry.inv);
607
+ s.lifecycle?.onStart(entry.inv);
696
608
  void pump(s);
697
609
  };
698
610
 
699
611
  const sendCommand = (
700
612
  s: Session,
701
- command: StatewireWireCommand,
613
+ method: string,
614
+ params: readonly unknown[],
702
615
  ): Promise<unknown> => {
703
616
  if (!s.running) {
704
617
  return rejectedSend({ fate: "aborted", cause: "unmount" });
@@ -707,34 +620,115 @@ const sendCommand = (
707
620
  return rejectedSend({ fate: "aborted", cause: "stream-gone" });
708
621
  }
709
622
  const result = new Promise<unknown>((resolve, reject) => {
710
- enqueue(s, command, resolve, reject);
623
+ const { params: encoded, meta } = encodeParams(params);
624
+ enqueue(
625
+ s,
626
+ { method, params: encoded, ...(meta !== undefined && { meta }) },
627
+ params,
628
+ resolve,
629
+ reject,
630
+ );
711
631
  });
712
632
  result.catch(() => {});
713
633
  return result;
714
634
  };
715
635
 
716
- const setContext = (
717
- s: Session,
718
- value: Record<string, unknown>,
719
- ): Promise<unknown> => {
636
+ const setContext = (s: Session, value: Record<string, unknown>): void => {
720
637
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
721
638
  throw new Error("statewire: context must be a plain object");
722
639
  }
723
- s.context = value;
724
- return s.send({ context: value });
640
+ const { value: encoded, meta } = encodeValue(value);
641
+ s.ctxOps = [
642
+ {
643
+ op: "replace",
644
+ path: [],
645
+ value: encoded,
646
+ ...(meta !== undefined && { meta }),
647
+ },
648
+ ];
649
+ const seq = s.record.ctxNextSeq;
650
+ setRecord(s, { ...s.record, ctxNextSeq: seq + 1 });
651
+ s.ctxPending.push({ seq, ops: s.ctxOps, posted: false });
652
+ wake(s);
653
+ void pump(s);
725
654
  };
726
655
 
727
656
  export const useStatewireSession = (
728
657
  options: StatewireSessionOptions,
729
- emit: (event: unknown) => void,
730
658
  ): Session => {
731
- const [s] = useState(() => createSession(options));
732
- useEffect(() => {
733
- s.emitEvent = emit;
734
- return () => {
735
- s.emitEvent = () => {};
659
+ const [defaultStore] = useState(createMemorySessionStore);
660
+ const store = options.sessionStore ?? defaultStore;
661
+ const [s] = useState((): Session => {
662
+ const stored = store.load();
663
+ const record = stored ?? {
664
+ clientId: newClientId(),
665
+ nextSeq: 1,
666
+ ctxNextSeq: 1,
736
667
  };
737
- }, [s, emit]);
668
+ const session: Session = {
669
+ record,
670
+ ctx: {
671
+ get clientId() {
672
+ return session.record.clientId;
673
+ },
674
+ takeAttachLanes: () => takeAttachLanes(session),
675
+ onHello: (args) => handleHello(session, args),
676
+ onOps: (args) => handleOps(session, args),
677
+ onCmds: (cmds) => handleCmds(session, cmds),
678
+ onRefusal: (syn) => handleRefusal(session, syn),
679
+ onIdle: (level) => setIdle(session, level),
680
+ onDetach: () => handleDetach(session),
681
+ onFinish: (args) => handleFinish(session, args),
682
+ },
683
+ recordPersisted: stored !== null,
684
+ store,
685
+ ephemeral: false,
686
+ sessionKnown: false,
687
+ durableSeq: 0,
688
+ resumeAt: 0,
689
+ idle: false,
690
+ onDone: undefined,
691
+ ctxOps: undefined,
692
+ ctxPending: [],
693
+ lanesOnAttach: false,
694
+ attachLaneSeqs: [new Set(), new Set()],
695
+ frameFailures: 0,
696
+ pending: [],
697
+ canonical: undefined,
698
+ lastPublished: undefined,
699
+ changed: [],
700
+ source: {
701
+ snapshot: () => session.canonical,
702
+ takeChanged: () => {
703
+ const changed = session.changed;
704
+ session.changed = [];
705
+ return changed;
706
+ },
707
+ setOnChange: (listener) => {
708
+ session.notify = listener ?? (() => {});
709
+ },
710
+ },
711
+ attach: "detached",
712
+ attachEpoch: 0,
713
+ running: false,
714
+ pumping: false,
715
+ transport: null,
716
+ waiters: [],
717
+ lifecycle: null,
718
+ notify: () => {},
719
+ commands: createMethodProxy((method, params) =>
720
+ sendCommand(session, method, params),
721
+ ),
722
+ };
723
+ bindCommandsHost(session.commands, {
724
+ setListener: (listener) => {
725
+ session.lifecycle = listener;
726
+ },
727
+ });
728
+ return session;
729
+ });
730
+ s.store = store;
731
+ s.onDone = options.onDone;
738
732
  useEffect(() => {
739
733
  start(s);
740
734
  return () => stop(s);