statewire 0.9.1 → 0.10.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 (53) hide show
  1. package/dist/client/connection.d.ts +5 -2
  2. package/dist/client/connection.d.ts.map +1 -1
  3. package/dist/client/connection.js +123 -23
  4. package/dist/client/connection.js.map +1 -1
  5. package/dist/client/session.d.ts +7 -4
  6. package/dist/client/session.d.ts.map +1 -1
  7. package/dist/client/session.js +62 -15
  8. package/dist/client/session.js.map +1 -1
  9. package/dist/client/sessionStore.d.ts +5 -14
  10. package/dist/client/sessionStore.d.ts.map +1 -1
  11. package/dist/client/sessionStore.js +4 -1
  12. package/dist/client/sessionStore.js.map +1 -1
  13. package/dist/client/timing.d.ts +2 -0
  14. package/dist/client/timing.d.ts.map +1 -1
  15. package/dist/client/timing.js +8 -2
  16. package/dist/client/timing.js.map +1 -1
  17. package/dist/host/http.d.ts +7 -0
  18. package/dist/host/http.d.ts.map +1 -0
  19. package/dist/host/http.js +175 -0
  20. package/dist/host/http.js.map +1 -0
  21. package/dist/host/socket.d.ts +102 -1
  22. package/dist/host/socket.d.ts.map +1 -1
  23. package/dist/host/socket.js +48 -27
  24. package/dist/host/socket.js.map +1 -1
  25. package/dist/host-internal.d.ts +5 -0
  26. package/dist/host-internal.d.ts.map +1 -1
  27. package/dist/host.d.ts +1 -1
  28. package/dist/host.d.ts.map +1 -1
  29. package/dist/statewire.d.ts +34 -8
  30. package/dist/statewire.d.ts.map +1 -1
  31. package/dist/statewire.js +9 -2
  32. package/dist/statewire.js.map +1 -1
  33. package/dist/transports/sse.d.ts.map +1 -1
  34. package/dist/transports/sse.js +6 -4
  35. package/dist/transports/sse.js.map +1 -1
  36. package/dist/transports/ws.d.ts.map +1 -1
  37. package/dist/transports/ws.js +5 -9
  38. package/dist/transports/ws.js.map +1 -1
  39. package/dist/types.d.ts +1 -1
  40. package/dist/types.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/client/connection.ts +212 -40
  43. package/src/client/session.ts +90 -24
  44. package/src/client/sessionStore.ts +8 -15
  45. package/src/client/timing.ts +10 -2
  46. package/src/host/http.ts +280 -0
  47. package/src/host/socket.ts +120 -57
  48. package/src/host-internal.ts +5 -0
  49. package/src/host.ts +1 -0
  50. package/src/statewire.ts +65 -9
  51. package/src/transports/sse.ts +6 -5
  52. package/src/transports/ws.ts +4 -9
  53. package/src/types.ts +1 -1
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
2
  import { flushTapSync } from "@assistant-ui/tap";
3
3
  import { reviveOps, reviveValue } from "../wire/meta";
4
- import { backoff } from "./timing";
4
+ import { backoff, timing } 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";
@@ -83,6 +83,8 @@ export type Conn = {
83
83
  hasSnapshot: boolean;
84
84
  lease: string | null;
85
85
  attempt: number;
86
+ authDenials: number;
87
+ liveAt: number | null;
86
88
  episode: Episode | null;
87
89
  awaitingHello: boolean;
88
90
  held: StatewireCmd[];
@@ -107,6 +109,108 @@ const protocolError = (message: string): never => {
107
109
  throw new Error(`statewire: protocol error: ${message}`);
108
110
  };
109
111
 
112
+ const isObject = (value: unknown): value is Record<string, unknown> =>
113
+ typeof value === "object" && value !== null && !Array.isArray(value);
114
+
115
+ const isNonnegativeSafeInteger = (value: unknown): value is number =>
116
+ Number.isSafeInteger(value) && (value as number) >= 0;
117
+
118
+ const isPositiveSafeInteger = (value: unknown): value is number =>
119
+ Number.isSafeInteger(value) && (value as number) > 0;
120
+
121
+ const validateOptionalString = (value: unknown, field: string): void => {
122
+ if (value !== undefined && typeof value !== "string") {
123
+ protocolError(`${field} must be a string`);
124
+ }
125
+ };
126
+
127
+ const validateSynScalars = (syn: Record<string, unknown>) => {
128
+ if (syn["seq"] !== undefined && !isNonnegativeSafeInteger(syn["seq"])) {
129
+ protocolError("syn.seq must be a nonnegative safe integer");
130
+ }
131
+ if (syn["ctxSeq"] !== undefined && !isNonnegativeSafeInteger(syn["ctxSeq"])) {
132
+ protocolError("syn.ctxSeq must be a nonnegative safe integer");
133
+ }
134
+ if (syn["retry"] !== undefined && !isNonnegativeSafeInteger(syn["retry"])) {
135
+ protocolError("syn.retry must be a nonnegative safe integer");
136
+ }
137
+ validateOptionalString(syn["id"], "syn.id");
138
+ if (syn["idle"] !== undefined && typeof syn["idle"] !== "boolean") {
139
+ protocolError("syn.idle must be a boolean");
140
+ }
141
+ validateOptionalString(syn["record"], "syn.record");
142
+ };
143
+
144
+ export const validateRefusalSyn = (value: unknown): StatewireSyn => {
145
+ if (!isObject(value)) return protocolError("syn must be an object");
146
+ validateSynScalars(value);
147
+ if (
148
+ value["id"] !== undefined ||
149
+ value["idle"] !== undefined ||
150
+ value["record"] !== undefined
151
+ ) {
152
+ protocolError("invalid refusal syn");
153
+ }
154
+ if (
155
+ value["seq"] === undefined &&
156
+ value["ctxSeq"] === undefined &&
157
+ value["retry"] === undefined
158
+ ) {
159
+ protocolError("refusal syn has no watermarks or retry");
160
+ }
161
+ return value as StatewireSyn;
162
+ };
163
+
164
+ const validateCmd = (value: unknown): value is StatewireCmd => {
165
+ if (!isObject(value)) return protocolError("cmd entry must be an object");
166
+ if (!isPositiveSafeInteger(value["seq"])) {
167
+ protocolError("cmd.seq must be a positive safe integer");
168
+ }
169
+ validateOptionalString(value["type"], "cmd.type");
170
+ validateOptionalString(value["code"], "cmd.code");
171
+ validateOptionalString(value["message"], "cmd.message");
172
+ if (value["dur"] !== undefined && value["dur"] !== true) {
173
+ protocolError("cmd.dur must be true");
174
+ }
175
+ return true;
176
+ };
177
+
178
+ const validateFin = (value: unknown): value is StatewireHost.Fin => {
179
+ if (!isObject(value)) return protocolError("fin must be an object");
180
+ if (typeof value["reason"] !== "string") {
181
+ protocolError("fin.reason must be a string");
182
+ }
183
+ validateOptionalString(value["message"], "fin.message");
184
+ return true;
185
+ };
186
+
187
+ const validateEnvelope = (value: unknown): StatewireEnvelope => {
188
+ if (!isObject(value)) return protocolError("packet must be an object");
189
+ if (value["ops"] !== undefined && !Array.isArray(value["ops"])) {
190
+ protocolError("ops must be an array");
191
+ }
192
+ if (value["cmd"] !== undefined) {
193
+ const cmd = value["cmd"];
194
+ if (!Array.isArray(cmd)) return protocolError("cmd must be an array");
195
+ for (const entry of cmd) validateCmd(entry);
196
+ }
197
+ if (value["idle"] !== undefined && typeof value["idle"] !== "boolean") {
198
+ protocolError("idle must be a boolean");
199
+ }
200
+ if (value["next"] !== undefined) {
201
+ if (value["next"] !== true) protocolError("next must be true");
202
+ if (
203
+ value["cmd"] === undefined ||
204
+ value["ops"] !== undefined ||
205
+ value["fin"] !== undefined
206
+ ) {
207
+ protocolError("next must ride a cmd-only packet");
208
+ }
209
+ }
210
+ if (value["fin"] !== undefined) validateFin(value["fin"]);
211
+ return value as StatewireEnvelope;
212
+ };
213
+
110
214
  // A hold's release delivers, never settles: fin and bare close both drain it.
111
215
  const releaseHeld = (c: Conn) => {
112
216
  if (c.held.length === 0) return;
@@ -117,38 +221,83 @@ const releaseHeld = (c: Conn) => {
117
221
 
118
222
  const cmdsOf = (env: StatewireEnvelope): StatewireCmd[] => {
119
223
  if (env.cmd === undefined) return [];
120
- if (!Array.isArray(env.cmd)) return protocolError("cmd must be an array");
121
224
  return env.cmd;
122
225
  };
123
226
 
227
+ const snapshotOf = (env: StatewireEnvelope): unknown[] => {
228
+ const ops = env.ops;
229
+ if (ops === undefined) return protocolError("hello missing snapshot");
230
+ if (ops.length !== 1 || !isObject(ops[0])) {
231
+ return protocolError("hello snapshot must be one root replace op");
232
+ }
233
+ const op = ops[0];
234
+ if (
235
+ op["op"] !== "replace" ||
236
+ !Array.isArray(op["path"]) ||
237
+ op["path"].length !== 0 ||
238
+ !Object.hasOwn(op, "value")
239
+ ) {
240
+ return protocolError("hello snapshot must be one root replace op");
241
+ }
242
+ return reviveOps(ops);
243
+ };
244
+
245
+ const helloSynOf = (env: StatewireEnvelope): StatewireSyn => {
246
+ if (!isObject(env.syn)) return protocolError("expected hello");
247
+ validateSynScalars(env.syn);
248
+ if (env.syn["retry"] !== undefined) {
249
+ protocolError("hello syn cannot carry retry");
250
+ }
251
+ return env.syn as StatewireSyn;
252
+ };
253
+
124
254
  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;
255
+ const syn = helloSynOf(env);
256
+ const ops = snapshotOf(env);
129
257
  c.ctx.onHello({
130
- ops: env.ops === undefined ? [] : reviveOps(env.ops),
258
+ ops,
131
259
  syn,
132
260
  });
133
261
  c.ctx.onIdle(syn.idle ?? false);
134
- c.attempt = 0;
262
+ c.awaitingHello = false;
263
+ c.hasSnapshot = true;
264
+ c.liveAt = Date.now();
265
+ c.authDenials = 0;
135
266
  c.episode = null;
136
267
  setStatus(c, { status: "live" });
137
268
  };
138
269
 
139
- const deliverEnvelope = (c: Conn, env: StatewireEnvelope) => {
140
- if (env.syn !== undefined) {
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
- }
270
+ let warnedUnknownSynFact = false;
271
+
272
+ const warnUnknownSynFact = () => {
273
+ if (warnedUnknownSynFact) return;
274
+ warnedUnknownSynFact = true;
275
+ console.warn("statewire: ignoring unknown syn fact");
276
+ };
277
+
278
+ const deliverSyn = (c: Conn, value: unknown) => {
279
+ if (!isObject(value)) return protocolError("syn must be an object");
280
+ validateSynScalars(value);
281
+ if (value["record"] !== undefined || value["idle"] !== undefined) {
282
+ protocolError("invalid mid-stream syn");
283
+ }
284
+ if (
285
+ value["seq"] !== undefined ||
286
+ value["ctxSeq"] !== undefined ||
287
+ value["retry"] !== undefined
288
+ ) {
289
+ c.ctx.onRefusal(validateRefusalSyn(value));
290
+ return;
291
+ }
292
+ if (value["id"] !== undefined) return;
293
+ if (Object.keys(value).length === 0) {
294
+ protocolError("invalid mid-stream syn");
151
295
  }
296
+ warnUnknownSynFact();
297
+ };
298
+
299
+ const deliverEnvelope = (c: Conn, env: StatewireEnvelope) => {
300
+ if (env.syn !== undefined) deliverSyn(c, env.syn);
152
301
  if (env.ops !== undefined) {
153
302
  const held = c.held;
154
303
  c.held = [];
@@ -164,9 +313,20 @@ const deliverEnvelope = (c: Conn, env: StatewireEnvelope) => {
164
313
  if (typeof env.idle === "boolean") c.ctx.onIdle(env.idle);
165
314
  };
166
315
 
167
- const isTransientFinish = (
316
+ const transientCauseOf = (
168
317
  reason: StatewireHost.Fin["reason"],
169
- ): reason is "evicted" | "error" => reason === "evicted" || reason === "error";
318
+ ): Episode["cause"] => {
319
+ switch (reason) {
320
+ case "evicted":
321
+ return "evicted";
322
+ case "error":
323
+ return "error";
324
+ case "unauthorized":
325
+ return "unauthorized";
326
+ default:
327
+ return "error";
328
+ }
329
+ };
170
330
 
171
331
  const settleFin = (c: Conn, fin: StatewireHost.Fin): AttemptOutcome => {
172
332
  let finish = fin;
@@ -182,12 +342,20 @@ const settleFin = (c: Conn, fin: StatewireHost.Fin): AttemptOutcome => {
182
342
  setStatus(c, { status: "idle" });
183
343
  return "idle";
184
344
  }
185
- c.ctx.onFinish(finish);
186
- if (isTransientFinish(finish.reason)) {
345
+ // An auth denial gets one immediate redial; a consecutive repeat parks as gone.
346
+ const denial = finish.reason === "unauthorized";
347
+ const terminal =
348
+ finish.reason === "gone" ||
349
+ finish.reason === "superseded" ||
350
+ (denial && c.authDenials > 0);
351
+ if (denial) c.authDenials += 1;
352
+ c.ctx.onFinish(terminal ? finish : { ...finish, reason: "error" });
353
+ if (!terminal) {
187
354
  c.episode = {
188
- cause: finish.reason,
355
+ cause: transientCauseOf(finish.reason),
189
356
  ...(finish.message !== undefined && { message: finish.message }),
190
357
  };
358
+ if (denial) openGate(c.gate);
191
359
  return "reattach";
192
360
  }
193
361
  setStatus(c, {
@@ -200,11 +368,9 @@ const settleFin = (c: Conn, fin: StatewireHost.Fin): AttemptOutcome => {
200
368
 
201
369
  export const handleEnvelope = (
202
370
  c: Conn,
203
- env: StatewireEnvelope,
371
+ value: unknown,
204
372
  ): AttemptOutcome | null => {
205
- if (typeof env !== "object" || env === null) {
206
- return protocolError("packet must be an object");
207
- }
373
+ const env = validateEnvelope(value);
208
374
  if (c.awaitingHello) {
209
375
  if (env.fin !== undefined) return settleFin(c, env.fin);
210
376
  handleHello(c, env);
@@ -225,18 +391,20 @@ const scheduleRetry = (
225
391
  failure: string | undefined,
226
392
  immediate: boolean,
227
393
  ) => {
394
+ if (c.liveAt !== null && Date.now() - c.liveAt >= timing.backoffResetMs) {
395
+ c.attempt = 0;
396
+ }
397
+ c.liveAt = null;
228
398
  c.attempt += 1;
229
399
  c.episode ??= { cause: "dropped" };
230
400
  const delay = immediate ? 0 : backoff(c.attempt);
231
- if (c.hasSnapshot) {
232
- setStatus(c, {
233
- status: "reconnecting",
234
- ...c.episode,
235
- attempt: c.attempt,
236
- nextRetryAt: Date.now() + delay,
237
- ...(failure !== undefined && { lastError: failure }),
238
- });
239
- }
401
+ setStatus(c, {
402
+ status: "reconnecting",
403
+ ...c.episode,
404
+ attempt: c.attempt,
405
+ nextRetryAt: Date.now() + delay,
406
+ ...(failure !== undefined && { lastError: failure }),
407
+ });
240
408
  return delay;
241
409
  };
242
410
 
@@ -244,10 +412,10 @@ const reconnectConn = (c: Conn) => {
244
412
  if (!c.lifetime) return;
245
413
  if (c.connection.status === "gone" || c.connection.status === "idle") {
246
414
  c.attempt = 0;
415
+ c.authDenials = 0;
247
416
  c.episode = { cause: "dropped" };
248
417
  flushTapSync(() => {
249
- if (c.hasSnapshot) scheduleRetry(c, undefined, true);
250
- else setStatus(c, { status: "connecting" });
418
+ scheduleRetry(c, undefined, true);
251
419
  });
252
420
  }
253
421
  openGate(c.gate);
@@ -303,6 +471,8 @@ export const useStatewireConn = (props: {
303
471
  hasSnapshot: false,
304
472
  lease: null,
305
473
  attempt: 0,
474
+ authDenials: 0,
475
+ liveAt: null,
306
476
  episode: null,
307
477
  awaitingHello: true,
308
478
  held: [],
@@ -325,6 +495,8 @@ export const useStatewireConn = (props: {
325
495
  c.hasSnapshot = false;
326
496
  c.lease = null;
327
497
  c.attempt = 0;
498
+ c.authDenials = 0;
499
+ c.liveAt = null;
328
500
  c.episode = null;
329
501
  if (c.connection.status !== "connecting") {
330
502
  setStatus(c, { status: "connecting" });
@@ -1,17 +1,20 @@
1
- import { useEffect, useState } from "react";
1
+ import { useContext, useEffect, useState } from "react";
2
2
  import { applyOps } from "../wire/ops";
3
3
  import type { StatewirePath } from "../wire/ops";
4
4
  import type { StatewireCmd, StatewireSyn } from "./connection";
5
- import { encodeParams, encodeValue, reviveResponse } from "../wire/meta";
5
+ import {
6
+ encodeParams,
7
+ encodeValue,
8
+ reviveResponse,
9
+ reviveValue,
10
+ } from "../wire/meta";
6
11
  import { bindCommandsHost } from "../host/host";
7
12
  import type { CommandsInvocation, CommandsListener } from "../host/host";
8
13
  import type { Statewire } from "../types";
9
14
  import type { StatewireClient } from "../statewire";
10
- import { createMemorySessionStore } from "./sessionStore";
11
- import type {
12
- StatewireSessionRecord,
13
- StatewireSessionStore,
14
- StatewireSessionStoreOptions,
15
+ import {
16
+ SessionStorageContext,
17
+ createMemorySessionStore,
15
18
  } from "./sessionStore";
16
19
  import { backoff } from "./timing";
17
20
  import { createMethodProxy } from "../util/methodProxy";
@@ -65,16 +68,19 @@ const rejectedSend = (
65
68
 
66
69
  type AttachState = "detached" | "attached" | "finished";
67
70
 
68
- export type StatewireSessionOptions = StatewireSessionStoreOptions & {
71
+ export type StatewireSessionOptions = {
72
+ sessionStore?: StatewireClient.SessionStorage | undefined;
69
73
  onDone?: (() => void) | undefined;
70
74
  };
71
75
 
72
76
  type Session = {
73
- record: StatewireSessionRecord;
77
+ record: StatewireClient.SessionRecord;
74
78
  ctx: StatewireRemoteProps;
75
79
  recordPersisted: boolean;
76
- store: StatewireSessionStore;
80
+ store: StatewireClient.SessionStorage;
77
81
  ephemeral: boolean;
82
+ /** An ephemeral record died with its attach: the storage is off for good. */
83
+ deactivated: boolean;
78
84
  sessionKnown: boolean;
79
85
  durableSeq: number;
80
86
  resumeAt: number;
@@ -128,9 +134,25 @@ const sleepUntilWake = (s: Session, delayMs: number) =>
128
134
  s.waiters.push(waiter);
129
135
  });
130
136
 
131
- const setRecord = (s: Session, record: StatewireSessionRecord) => {
137
+ const setRecord = (s: Session, record: StatewireClient.SessionRecord) => {
132
138
  s.record = record;
133
- if (!s.ephemeral) s.store.save(record);
139
+ if (!s.deactivated) s.store.save(record);
140
+ };
141
+
142
+ const backlog = (s: Session): StatewireClient.SessionCommand[] =>
143
+ s.pending
144
+ .filter((p) => p.seq > s.durableSeq)
145
+ .map((p) => ({ seq: p.seq, ...p.command }));
146
+
147
+ // Storage holds what a reload must resend: a command leaves at its dur, at a
148
+ // server verdict, or at a record reset — never at a local channel death.
149
+ const syncBacklog = (s: Session) => {
150
+ const commands = backlog(s);
151
+ const unchanged =
152
+ commands.length === s.record.commands.length &&
153
+ commands.every((c, i) => c.seq === s.record.commands[i]!.seq);
154
+ if (unchanged) return;
155
+ setRecord(s, { ...s.record, commands });
134
156
  };
135
157
 
136
158
  const setIdle = (s: Session, level: boolean) => {
@@ -223,7 +245,7 @@ const resetRecord = (s: Session, clientId: string) => {
223
245
  }
224
246
  s.pending = [];
225
247
  s.durableSeq = 0;
226
- setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1 });
248
+ setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1, commands: [] });
227
249
  for (const old of survivors) {
228
250
  s.lifecycle?.onCovered?.(old.inv);
229
251
  enqueue(s, old.command, old.inv.params, old.resolve, old.reject);
@@ -241,23 +263,19 @@ const ephemeralDetach = (s: Session) => {
241
263
  };
242
264
 
243
265
  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
266
  return {
251
267
  fate: "rejected",
252
268
  cause: "server",
253
- ...(code !== undefined && { code }),
269
+ ...(cmd.code !== undefined && { code: cmd.code }),
254
270
  ...(cmd.message !== undefined && { message: cmd.message }),
255
271
  ...(cmd.payload !== undefined && { payload: cmd.payload }),
256
272
  };
257
273
  };
258
274
 
259
275
  const handleCmd = (s: Session, cmd: StatewireCmd) => {
260
- if (typeof cmd.seq !== "number") protocolError("cmd entry missing seq");
276
+ if (!Number.isSafeInteger(cmd.seq) || cmd.seq <= 0) {
277
+ protocolError("cmd.seq must be a positive safe integer");
278
+ }
261
279
  if (cmd.dur === true) {
262
280
  s.durableSeq = Math.max(s.durableSeq, cmd.seq);
263
281
  for (const entry of s.pending) {
@@ -305,6 +323,7 @@ const handleCmd = (s: Session, cmd: StatewireCmd) => {
305
323
 
306
324
  const handleCmds = (s: Session, cmds: StatewireCmd[]) => {
307
325
  for (const cmd of cmds) handleCmd(s, cmd);
326
+ syncBacklog(s);
308
327
  wake(s);
309
328
  };
310
329
 
@@ -360,6 +379,7 @@ const handleHello = (
360
379
  s.frameFailures = 0;
361
380
  s.resumeAt = 0;
362
381
  s.ephemeral = record === "ephemeral";
382
+ if (s.ephemeral) s.deactivated = true;
363
383
  const [attachCmd, attachCtx] = s.attachLaneSeqs;
364
384
  s.attachLaneSeqs = [new Set(), new Set()];
365
385
  if (record === "resumed") {
@@ -372,6 +392,7 @@ const handleHello = (
372
392
  s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
373
393
  for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
374
394
  snapClocks(s, syn.seq ?? 0, ctxWatermark);
395
+ syncBacklog(s);
375
396
  } else {
376
397
  resetRecord(s, s.record.clientId);
377
398
  }
@@ -386,6 +407,7 @@ const handleOps = (
386
407
  ) => {
387
408
  ingestOps(s, ops);
388
409
  for (const cmd of cmds) handleCmd(s, cmd);
410
+ syncBacklog(s);
389
411
  publishIngested(s);
390
412
  };
391
413
 
@@ -402,7 +424,7 @@ const handleRefusal = (s: Session, syn: StatewireSyn) => {
402
424
  for (const c of s.ctxPending) c.posted = false;
403
425
  }
404
426
  if (typeof syn.retry === "number") {
405
- s.resumeAt = Date.now() + Math.max(0, syn.retry);
427
+ s.resumeAt = Date.now() + syn.retry;
406
428
  }
407
429
  const firstCmd = s.pending.find((p) => !p.posted);
408
430
  const firstCtx = s.ctxPending.find((c) => !c.posted);
@@ -562,6 +584,7 @@ const start = (s: Session) => {
562
584
  s.recordPersisted = true;
563
585
  s.store.save(s.record);
564
586
  }
587
+ resume(s);
565
588
  s.running = true;
566
589
  void pump(s);
567
590
  };
@@ -586,7 +609,6 @@ const enqueue = (
586
609
  reject: (error: StatewireSendError) => void,
587
610
  ) => {
588
611
  const seq = s.record.nextSeq;
589
- setRecord(s, { ...s.record, nextSeq: seq + 1 });
590
612
  const entry: Pending = {
591
613
  seq,
592
614
  command,
@@ -604,10 +626,41 @@ const enqueue = (
604
626
  reject,
605
627
  };
606
628
  s.pending.push(entry);
629
+ setRecord(s, { ...s.record, nextSeq: seq + 1, commands: backlog(s) });
607
630
  s.lifecycle?.onStart(entry.inv);
608
631
  void pump(s);
609
632
  };
610
633
 
634
+ // A stored command cannot be told from one that reached the server: attempted
635
+ // keeps a fresh record settling it lost instead of replaying it blind.
636
+ const resume = (s: Session) => {
637
+ const stored = [...s.record.commands].sort((a, b) => a.seq - b.seq);
638
+ for (const { seq, ...command } of stored) {
639
+ const params =
640
+ command.meta === undefined
641
+ ? command.params
642
+ : (reviveValue([...command.params], command.meta) as unknown[]);
643
+ const entry: Pending = {
644
+ seq,
645
+ command,
646
+ inv: {
647
+ method: command.method,
648
+ params,
649
+ ts: Date.now(),
650
+ seq,
651
+ clientId: s.record.clientId,
652
+ },
653
+ attempted: true,
654
+ posted: false,
655
+ applied: false,
656
+ resolve: () => {},
657
+ reject: () => {},
658
+ };
659
+ s.pending.push(entry);
660
+ s.lifecycle?.onStart(entry.inv);
661
+ }
662
+ };
663
+
611
664
  const sendCommand = (
612
665
  s: Session,
613
666
  method: string,
@@ -657,13 +710,25 @@ export const useStatewireSession = (
657
710
  options: StatewireSessionOptions,
658
711
  ): Session => {
659
712
  const [defaultStore] = useState(createMemorySessionStore);
660
- const store = options.sessionStore ?? defaultStore;
713
+ const mounted = useContext(SessionStorageContext);
714
+ if (options.sessionStore !== undefined && mounted !== undefined) {
715
+ throw new Error(
716
+ "statewire: session storage is set twice — drop the transport's sessionStore or the client's storage.session",
717
+ );
718
+ }
719
+ const store = options.sessionStore ?? mounted ?? defaultStore;
661
720
  const [s] = useState((): Session => {
662
721
  const stored = store.load();
722
+ if (stored !== null && !Array.isArray(stored.commands)) {
723
+ throw new Error(
724
+ "statewire: session storage loaded a record without a commands array",
725
+ );
726
+ }
663
727
  const record = stored ?? {
664
728
  clientId: newClientId(),
665
729
  nextSeq: 1,
666
730
  ctxNextSeq: 1,
731
+ commands: [],
667
732
  };
668
733
  const session: Session = {
669
734
  record,
@@ -683,6 +748,7 @@ export const useStatewireSession = (
683
748
  recordPersisted: stored !== null,
684
749
  store,
685
750
  ephemeral: false,
751
+ deactivated: false,
686
752
  sessionKnown: false,
687
753
  durableSeq: 0,
688
754
  resumeAt: 0,
@@ -1,20 +1,13 @@
1
- export type StatewireSessionRecord = {
2
- clientId: string;
3
- nextSeq: number;
4
- ctxNextSeq: number;
5
- };
6
-
7
- export type StatewireSessionStore = {
8
- load: () => StatewireSessionRecord | null;
9
- save: (record: StatewireSessionRecord) => void;
10
- };
1
+ import { createContext } from "react";
2
+ import type { StatewireClient } from "../statewire";
11
3
 
12
- export type StatewireSessionStoreOptions = {
13
- sessionStore?: StatewireSessionStore | undefined;
14
- };
4
+ /** The session storage `useStatewire` mounts, read by the transport's session. */
5
+ export const SessionStorageContext = createContext<
6
+ StatewireClient.SessionStorage | undefined
7
+ >(undefined);
15
8
 
16
- export const createMemorySessionStore = (): StatewireSessionStore => {
17
- let record: StatewireSessionRecord | null = null;
9
+ export const createMemorySessionStore = (): StatewireClient.SessionStorage => {
10
+ let record: StatewireClient.SessionRecord | null = null;
18
11
  return {
19
12
  load: () => record,
20
13
  save: (next) => {
@@ -1,8 +1,16 @@
1
1
  export const timing = {
2
2
  backoffBaseMs: 250,
3
3
  backoffMaxMs: 30_000,
4
+ backoffResetMs: 30_000,
4
5
  heartbeatTimeoutMs: 30_000,
6
+ random: Math.random,
5
7
  };
6
8
 
7
- export const backoff = (attempt: number) =>
8
- Math.min(timing.backoffBaseMs * 2 ** (attempt - 1), timing.backoffMaxMs);
9
+ export const backoff = (attempt: number) => {
10
+ const limit = Math.min(
11
+ timing.backoffBaseMs * 2 ** (attempt - 1),
12
+ timing.backoffMaxMs,
13
+ );
14
+ const floor = Math.min(timing.backoffBaseMs, limit);
15
+ return Math.round(floor + (limit - floor) * timing.random());
16
+ };