statewire 0.11.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +2 -2
  2. package/dist/client/connection.d.ts +6 -4
  3. package/dist/client/connection.d.ts.map +1 -1
  4. package/dist/client/connection.js +10 -2
  5. package/dist/client/connection.js.map +1 -1
  6. package/dist/client/core.d.ts +1 -1
  7. package/dist/client/session.d.ts +4 -2
  8. package/dist/client/session.d.ts.map +1 -1
  9. package/dist/client/session.js +102 -102
  10. package/dist/client/session.js.map +1 -1
  11. package/dist/host/draft.d.ts.map +1 -1
  12. package/dist/host/draft.js +9 -1
  13. package/dist/host/draft.js.map +1 -1
  14. package/dist/host/host.d.ts +1 -1
  15. package/dist/host/http.js +4 -3
  16. package/dist/host/http.js.map +1 -1
  17. package/dist/host/socket.d.ts +6 -5
  18. package/dist/host/socket.d.ts.map +1 -1
  19. package/dist/host/socket.js +12 -20
  20. package/dist/host/socket.js.map +1 -1
  21. package/dist/host-internal.d.ts +2 -1
  22. package/dist/host-internal.d.ts.map +1 -1
  23. package/dist/host.d.ts +2 -2
  24. package/dist/host.d.ts.map +1 -1
  25. package/dist/index.d.ts +1 -1
  26. package/dist/statewire.d.ts +15 -4
  27. package/dist/statewire.d.ts.map +1 -1
  28. package/dist/statewire.js.map +1 -1
  29. package/dist/transports/fake-sse.js +4 -4
  30. package/dist/transports/fake-sse.js.map +1 -1
  31. package/dist/transports/fixtures.d.ts +1 -1
  32. package/dist/transports/remote.d.ts +1 -1
  33. package/dist/transports/remote.js +1 -0
  34. package/dist/transports/remote.js.map +1 -1
  35. package/dist/transports/sse.d.ts +2 -2
  36. package/dist/transports/testing.d.ts +1 -1
  37. package/dist/transports/testing.js +2 -4
  38. package/dist/transports/testing.js.map +1 -1
  39. package/dist/wire/meta.d.ts +5 -3
  40. package/dist/wire/meta.d.ts.map +1 -1
  41. package/dist/wire/meta.js +7 -4
  42. package/dist/wire/meta.js.map +1 -1
  43. package/dist/wire/ops.d.ts.map +1 -1
  44. package/dist/wire/ops.js +26 -2
  45. package/dist/wire/ops.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/client/connection.ts +12 -6
  48. package/src/client/session.ts +145 -139
  49. package/src/host/draft.ts +15 -1
  50. package/src/host/http.ts +10 -9
  51. package/src/host/socket.ts +18 -28
  52. package/src/host-internal.ts +2 -1
  53. package/src/host.ts +0 -1
  54. package/src/statewire.ts +18 -4
  55. package/src/transports/fake-sse.ts +6 -6
  56. package/src/transports/remote.ts +1 -0
  57. package/src/transports/testing.ts +2 -7
  58. package/src/wire/meta.ts +12 -4
  59. package/src/wire/ops.ts +30 -3
@@ -1,6 +1,7 @@
1
1
  import { useContext, useEffect, useState } from "react";
2
2
  import { applyOps } from "../wire/ops";
3
3
  import type { StatewirePath } from "../wire/ops";
4
+ import { TERMINAL } from "./connection";
4
5
  import type { StatewireCmd, StatewireSyn } from "./connection";
5
6
  import {
6
7
  encodeParams,
@@ -70,6 +71,7 @@ type AttachState = "detached" | "attached" | "finished";
70
71
 
71
72
  export type StatewireSessionOptions = {
72
73
  sessionStore?: StatewireClient.SessionStorage | undefined;
74
+ onSessionLost?: StatewireClient.OnSessionLost | undefined;
73
75
  onDone?: (() => void) | undefined;
74
76
  };
75
77
 
@@ -92,6 +94,7 @@ type Session = {
92
94
  durableSeq: number;
93
95
  resumeAt: number;
94
96
  idle: boolean;
97
+ onSessionLost: StatewireClient.OnSessionLost | undefined;
95
98
  onDone: (() => void) | undefined;
96
99
  ctxOps: unknown[] | undefined;
97
100
  ctxPending: CtxPending[];
@@ -252,14 +255,9 @@ const settleEntry = (
252
255
  }
253
256
  };
254
257
 
255
- const settleAll = (s: Session, detach: "unmount" | "gone" | "superseded") => {
256
- const cause = (
257
- {
258
- unmount: "unmount",
259
- gone: "stream-gone",
260
- superseded: "superseded",
261
- } as const
262
- )[detach];
258
+ const settleAll = (s: Session, detach: "unmount" | "gone") => {
259
+ const cause =
260
+ detach === "unmount" ? ("unmount" as const) : ("stream-gone" as const);
263
261
  const batch = s.pending;
264
262
  const detachFailure = (entry: Pending): StatewireClient.SendFailure => {
265
263
  if (!entry.attempted) {
@@ -286,26 +284,30 @@ const restateContext = (s: Session) => {
286
284
  s.ctxPending.push({ seq, ops: s.ctxOps, posted: false });
287
285
  };
288
286
 
287
+ const settleLost = (s: Session, entries: Pending[]) => {
288
+ for (const entry of entries) {
289
+ s.lifecycle?.onCovered?.(entry.inv);
290
+ settleEntry(s, entry, {
291
+ ok: false,
292
+ failure: { fate: "lost", cause: "session-lost" },
293
+ });
294
+ }
295
+ };
296
+
289
297
  // The server-side record's dedup memory is gone: history that may have
290
298
  // reached a server settles lost — the new hello's snapshot is the only
291
299
  // truth about what survived — and never-attempted statements re-stamp
292
- // from 1 on the reset lane clocks.
293
- const resetRecord = (s: Session, clientId: string) => {
294
- const survivors: Pending[] = [];
295
- for (const entry of [...s.pending]) {
296
- if (entry.attempted || entry.applied) {
297
- s.lifecycle?.onCovered?.(entry.inv);
298
- settleEntry(s, entry, {
299
- ok: false,
300
- failure: { fate: "lost", cause: "session-lost" },
301
- });
302
- } else {
303
- survivors.push(entry);
304
- }
305
- }
300
+ // on the adopted lane clocks.
301
+ const rebaseRecord = (s: Session, adopt: () => void) => {
302
+ const held = [...s.pending];
303
+ const survivors = held.filter((p) => !p.attempted && !p.applied);
304
+ settleLost(
305
+ s,
306
+ held.filter((p) => p.attempted || p.applied),
307
+ );
306
308
  s.pending = [];
307
309
  s.durableSeq = 0;
308
- setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1, commands: [] });
310
+ adopt();
309
311
  for (const old of survivors) {
310
312
  s.lifecycle?.onCovered?.(old.inv);
311
313
  enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
@@ -316,33 +318,17 @@ const resetRecord = (s: Session, clientId: string) => {
316
318
  void pump(s);
317
319
  };
318
320
 
321
+ const resetRecord = (s: Session, clientId: string) =>
322
+ rebaseRecord(s, () =>
323
+ setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1, commands: [] }),
324
+ );
325
+
319
326
  // Another sharer replaced the record's identity: their reset settles our
320
327
  // attempted history and our survivors re-stamp on the stored clocks.
321
- const adoptReset = (s: Session, stored: StatewireClient.SessionRecord) => {
322
- const survivors: Pending[] = [];
323
- for (const entry of [...s.pending]) {
324
- if (entry.attempted || entry.applied) {
325
- s.lifecycle?.onCovered?.(entry.inv);
326
- settleEntry(s, entry, {
327
- ok: false,
328
- failure: { fate: "lost", cause: "session-lost" },
329
- });
330
- } else {
331
- survivors.push(entry);
332
- }
333
- }
334
- s.pending = [];
335
- s.durableSeq = 0;
336
- s.record = stored;
337
- for (const old of survivors) {
338
- s.lifecycle?.onCovered?.(old.inv);
339
- enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
340
- }
341
- restateContext(s);
342
- publish(s);
343
- wake(s);
344
- void pump(s);
345
- };
328
+ const adoptReset = (s: Session, stored: StatewireClient.SessionRecord) =>
329
+ rebaseRecord(s, () => {
330
+ s.record = stored;
331
+ });
346
332
 
347
333
  // The record died with the attach: mint a fresh id for the next one.
348
334
  const ephemeralDetach = (s: Session) => {
@@ -365,42 +351,31 @@ const outcomeOf = (
365
351
  cmd: StatewireCmd,
366
352
  ):
367
353
  | { ok: true; payload: unknown }
368
- | { ok: false; failure: StatewireClient.SendFailure } =>
369
- cmd.type === "result"
370
- ? { ok: true as const, payload: reviveResponse(cmd).payload }
371
- : cmd.type === "rejected"
372
- ? { ok: false as const, failure: rejectedFailure(reviveResponse(cmd)) }
373
- : cmd.type === "failed"
374
- ? {
375
- ok: false as const,
376
- failure: {
377
- fate: "failed" as const,
378
- cause: "server" as const,
379
- ...(cmd.message !== undefined && { message: cmd.message }),
380
- },
381
- }
382
- : cmd.type === "crashed"
383
- ? {
384
- ok: false as const,
385
- failure: { fate: "crashed" as const, cause: "server" as const },
386
- }
387
- : cmd.type === "expired"
388
- ? {
389
- ok: false as const,
390
- failure: {
391
- fate: "expired" as const,
392
- cause: "server" as const,
393
- },
394
- }
395
- : protocolError(`unknown cmd type: ${String(cmd.type)}`);
396
-
397
- const TERMINAL_CMD_TYPES = new Set([
398
- "result",
399
- "rejected",
400
- "failed",
401
- "crashed",
402
- "expired",
403
- ]);
354
+ | { ok: false; failure: StatewireClient.SendFailure } => {
355
+ switch (cmd.type) {
356
+ case "result":
357
+ return { ok: true, payload: reviveResponse(cmd).payload };
358
+ case "rejected":
359
+ return { ok: false, failure: rejectedFailure(reviveResponse(cmd)) };
360
+ case "failed":
361
+ return {
362
+ ok: false,
363
+ failure: {
364
+ fate: "failed",
365
+ cause: "server",
366
+ ...(cmd.message !== undefined && { message: cmd.message }),
367
+ },
368
+ };
369
+ case "crashed":
370
+ return { ok: false, failure: { fate: "crashed", cause: "server" } };
371
+ case "expired":
372
+ return { ok: false, failure: { fate: "expired", cause: "server" } };
373
+ default:
374
+ return protocolError(`unknown cmd type: ${String(cmd.type)}`);
375
+ }
376
+ };
377
+
378
+ const TERMINAL_CMD_TYPES: ReadonlySet<string> = new Set(TERMINAL);
404
379
 
405
380
  const parkVerdict = (s: Session, cmd: StatewireCmd) => {
406
381
  if (!s.shared || !TERMINAL_CMD_TYPES.has(cmd.type!)) return;
@@ -473,24 +448,10 @@ const publishIngested = (s: Session) => {
473
448
  wake(s);
474
449
  };
475
450
 
476
- const RECORDS = ["fresh", "resumed", "ephemeral"];
477
-
478
- // Snap a resumed session's lane clocks onto the record: seqs above the stated
479
- // watermark were never admitted, so a stale restored clock re-stamps its
480
- // never-admitted statements consecutively after the watermark.
481
- const snapClocks = (s: Session, seqWm: number, ctxWm: number) => {
482
- const tail = s.pending.filter((p) => p.seq > seqWm);
483
- const cmdAligned =
484
- tail.every((p, i) => p.seq === seqWm + 1 + i) &&
485
- s.record.nextSeq === seqWm + tail.length + 1;
486
- if (!cmdAligned) {
487
- s.pending = s.pending.filter((p) => p.seq <= seqWm);
488
- setRecord(s, { ...s.record, nextSeq: seqWm + 1 });
489
- for (const old of tail) {
490
- s.lifecycle?.onCovered?.(old.inv);
491
- enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
492
- }
493
- }
451
+ // A hello's cmd watermark may understate a durable record (cold host, tray
452
+ // unloaded): held seqs resend as-is and the frame's answer arbitrates —
453
+ // absorption where the record survived, a gap refusal where it did not.
454
+ const snapCtxClock = (s: Session, ctxWm: number) => {
494
455
  const ctxAligned =
495
456
  s.ctxPending.every((c, i) => c.seq === ctxWm + 1 + i) &&
496
457
  s.record.ctxNextSeq === ctxWm + s.ctxPending.length + 1;
@@ -500,39 +461,69 @@ const snapClocks = (s: Session, seqWm: number, ctxWm: number) => {
500
461
  }
501
462
  };
502
463
 
464
+ // The refusal's watermarks are exact: every held statement above them was
465
+ // never admitted by the live record, and the first unposted seq being beyond
466
+ // watermark+1 proves the record that admitted the missing prefix is gone —
467
+ // its history settles lost and the lane clocks restart at the watermarks.
468
+ const sessionLostInner = (s: Session, seqWm: number, ctxWm: number) => {
469
+ const held = [...s.pending];
470
+ settleLost(s, held);
471
+ s.pending = [];
472
+ s.durableSeq = seqWm;
473
+ setRecord(s, {
474
+ clientId: s.record.clientId,
475
+ nextSeq: seqWm + 1,
476
+ ctxNextSeq: ctxWm + 1,
477
+ commands: [],
478
+ });
479
+ restateContext(s);
480
+ if (
481
+ held.length > 0 &&
482
+ s.onSessionLost?.(held.map((p) => p.inv)) === "requeue"
483
+ ) {
484
+ for (const old of held) {
485
+ enqueueInner(
486
+ s,
487
+ old.command,
488
+ old.inv.params,
489
+ () => {},
490
+ () => {},
491
+ );
492
+ }
493
+ }
494
+ publish(s);
495
+ wake(s);
496
+ };
497
+
503
498
  const handleHello = (
504
499
  s: Session,
505
500
  { ops, syn }: { ops: unknown[]; syn: StatewireSyn },
506
501
  ) => {
507
- const record = syn.record ?? "fresh";
508
- if (!RECORDS.includes(record)) {
509
- protocolError(`unknown record: ${String(record)}`);
502
+ if (syn.record !== undefined && syn.record !== "ephemeral") {
503
+ protocolError(`unknown record: ${String(syn.record)}`);
510
504
  }
511
505
  ingestOps(s, ops);
512
506
  s.attach = "attached";
513
507
  s.attachEpoch += 1;
514
508
  s.frameFailures = 0;
515
509
  s.resumeAt = 0;
516
- s.ephemeral = record === "ephemeral";
510
+ s.ephemeral = syn.record === "ephemeral";
517
511
  if (s.ephemeral) s.deactivated = true;
518
512
  const [attachCmd, attachCtx] = s.attachLaneSeqs;
519
513
  s.attachLaneSeqs = [new Set(), new Set()];
520
514
  const reconcile = () => {
521
- if (record === "resumed") {
522
- if (s.shared) adoptStored(s);
523
- // Resending unsettled statements is exactly-once-safe: absorbed replays
524
- // re-emit their stored verdicts (applied while still in flight).
525
- for (const entry of s.pending) {
526
- entry.posted = attachCmd.has(entry.seq);
527
- }
528
- const ctxWatermark = syn.ctxSeq ?? 0;
529
- s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
530
- for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
531
- snapClocks(s, syn.seq ?? 0, ctxWatermark);
532
- syncBacklogInner(s);
533
- } else {
534
- resetRecord(s, s.record.clientId);
515
+ if (s.shared) adoptStored(s);
516
+ // Resending unsettled statements is exactly-once-safe: absorbed replays
517
+ // re-emit their stored verdicts (applied while still in flight).
518
+ s.durableSeq = syn.seq ?? 0;
519
+ for (const entry of s.pending) {
520
+ entry.posted = attachCmd.has(entry.seq);
535
521
  }
522
+ const ctxWatermark = syn.ctxSeq ?? 0;
523
+ s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
524
+ for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
525
+ snapCtxClock(s, ctxWatermark);
526
+ syncBacklogInner(s);
536
527
  s.sessionKnown = true;
537
528
  };
538
529
  if (!s.shared || s.deactivated) {
@@ -557,8 +548,9 @@ const handleOps = (
557
548
  publishIngested(s);
558
549
  };
559
550
 
560
- // A {syn} refusal never settles: reconcile the resend queues against the
561
- // stated watermarks; retry present means wait, absent means resend now.
551
+ // A retry-less {syn} refusal is the one session-loss verdict: a cmd gap
552
+ // against its exact watermark settles the held history lost; a pushback
553
+ // (retry present) or a gapless refusal only reconciles the resend queues.
562
554
  const handleRefusal = (s: Session, syn: StatewireSyn) => {
563
555
  const { seq, ctxSeq } = syn;
564
556
  if (typeof seq === "number") {
@@ -571,19 +563,30 @@ const handleRefusal = (s: Session, syn: StatewireSyn) => {
571
563
  }
572
564
  if (typeof syn.retry === "number") {
573
565
  s.resumeAt = Date.now() + syn.retry;
566
+ wake(s);
567
+ void pump(s);
568
+ return;
574
569
  }
575
570
  const firstCmd = s.pending.find((p) => !p.posted);
576
571
  const firstCtx = s.ctxPending.find((c) => !c.posted);
577
- const clocksAhead =
578
- (typeof syn.seq === "number" &&
579
- firstCmd !== undefined &&
580
- firstCmd.seq > Math.max(syn.seq, s.durableSeq) + 1) ||
581
- (typeof syn.ctxSeq === "number" &&
582
- firstCtx !== undefined &&
583
- firstCtx.seq > syn.ctxSeq + 1);
584
- // Clocks ahead of a record that cannot admit them: reattach first and let
585
- // the hello's record arbitrate.
586
- if (clocksAhead) s.transport?.connection.reconnect();
572
+ const cmdGap =
573
+ typeof seq === "number" &&
574
+ firstCmd !== undefined &&
575
+ firstCmd.seq > Math.max(seq, s.durableSeq) + 1;
576
+ const ctxGap =
577
+ typeof ctxSeq === "number" &&
578
+ firstCtx !== undefined &&
579
+ firstCtx.seq > ctxSeq + 1;
580
+ if (cmdGap || ctxGap) {
581
+ void transact(s, () => {
582
+ if (cmdGap) sessionLostInner(s, seq!, ctxSeq ?? 0);
583
+ else snapCtxClock(s, ctxSeq!);
584
+ }).then(() => {
585
+ wake(s);
586
+ void pump(s);
587
+ });
588
+ return;
589
+ }
587
590
  wake(s);
588
591
  void pump(s);
589
592
  };
@@ -597,11 +600,7 @@ const handleDetach = (s: Session) => {
597
600
  const handleFinish = (s: Session, { reason }: { reason: string }) => {
598
601
  if (reason === "evicted" || reason === "error") return;
599
602
  s.attach = "finished";
600
- // Superseded over shared storage: the superseder resends our backlog and
601
- // parks the verdicts; our promises settle from the observed record.
602
- if (!(s.shared && reason === "superseded")) {
603
- settleAll(s, reason === "superseded" ? "superseded" : "gone");
604
- }
603
+ settleAll(s, "gone");
605
604
  publish(s);
606
605
  wake(s);
607
606
  };
@@ -819,8 +818,8 @@ const enqueue = (
819
818
  ).then(() => void pump(s));
820
819
  };
821
820
 
822
- // A stored command cannot be told from one that reached the server: attempted
823
- // keeps a fresh record settling it lost instead of replaying it blind.
821
+ // A stored command may have reached a server: attempted settles it lost,
822
+ // never aborted, when a channel death ends the session.
824
823
  const adoptStored = (s: Session) => {
825
824
  const known = new Set(s.pending.map((p) => p.seq));
826
825
  const stored = s.record.commands.filter((c) => !known.has(c.seq));
@@ -965,6 +964,7 @@ export const useStatewireSession = (
965
964
  durableSeq: 0,
966
965
  resumeAt: 0,
967
966
  idle: false,
967
+ onSessionLost: undefined,
968
968
  onDone: undefined,
969
969
  ctxOps: undefined,
970
970
  ctxPending: [],
@@ -1005,7 +1005,13 @@ export const useStatewireSession = (
1005
1005
  });
1006
1006
  return session;
1007
1007
  });
1008
+ if ((store.lock !== undefined) !== (s.shared !== false)) {
1009
+ throw new Error(
1010
+ "statewire: session storage changed sharedness across renders — lock/subscribe must be present from the first render or never",
1011
+ );
1012
+ }
1008
1013
  s.store = store;
1014
+ s.onSessionLost = options.onSessionLost;
1009
1015
  s.onDone = options.onDone;
1010
1016
  useEffect(() => {
1011
1017
  start(s);
package/src/host/draft.ts CHANGED
@@ -30,6 +30,16 @@ const invalid = (path: StatewirePath, message: string) =>
30
30
 
31
31
  type Container = Record<string | number, unknown>;
32
32
 
33
+ // Text-insert offsets are Unicode code points on the wire, not UTF-16 units.
34
+ const codePointLength = (text: string): number => {
35
+ let count = 0;
36
+ for (let index = 0; index < text.length; index += 1) {
37
+ if (text.codePointAt(index)! > 0xffff) index += 1;
38
+ count += 1;
39
+ }
40
+ return count;
41
+ };
42
+
33
43
  type Node = {
34
44
  parent: Node | null;
35
45
  key: string | number;
@@ -96,7 +106,11 @@ export const createStateDraft = <T>(host: DraftHost): StateDraft<T> => {
96
106
  if (suffix === "") return;
97
107
  prepareForWrite(node)[key] = existing + suffix;
98
108
  host.record(
99
- { op: "add", path: [...node.path, key, existing.length], value: suffix },
109
+ {
110
+ op: "add",
111
+ path: [...node.path, key, codePointLength(existing)],
112
+ value: suffix,
113
+ },
100
114
  changedPathFor(node, key),
101
115
  );
102
116
  };
package/src/host/http.ts CHANGED
@@ -166,7 +166,7 @@ const getStream = async (
166
166
  const attachment = attachSse(h, client.clientId);
167
167
  const lease = attachment.registration === null ? null : mintLease();
168
168
  if (attachment.registration !== null)
169
- attachment.registration.rec.lease = lease;
169
+ attachment.registration.conn.lease = lease;
170
170
  return streamResponse(attachment, lease);
171
171
  };
172
172
 
@@ -202,7 +202,7 @@ const postStream = async (
202
202
  const attachment = attachSse(h, client.clientId);
203
203
  if (attachment.registration === null) return streamResponse(attachment, null);
204
204
  const lease = mintLease();
205
- attachment.registration.rec.lease = lease;
205
+ attachment.registration.conn.lease = lease;
206
206
  admitAttachFrame(h, attachment.registration, frame);
207
207
  return streamResponse(attachment, lease);
208
208
  };
@@ -254,13 +254,14 @@ export const frames = async (
254
254
  if ("response" in client) return client.response;
255
255
  const rec = h.clients.get(client.clientId);
256
256
  const lease = request.headers.get(LEASE_HEADER);
257
- if (
258
- h.disposed ||
259
- h.finished !== null ||
260
- rec === undefined ||
261
- lease === null ||
262
- lease !== rec.lease
263
- ) {
257
+ // A lease lives and dies with its attach: any live attach of the id accepts.
258
+ const live =
259
+ lease !== null &&
260
+ [...h.conns].some(
261
+ (conn) =>
262
+ conn.open && conn.clientId === client.clientId && conn.lease === lease,
263
+ );
264
+ if (h.disposed || h.finished !== null || rec === undefined || !live) {
264
265
  return Response.json({ error: "stale-lease" }, { status: 423 });
265
266
  }
266
267
  let frame: ParsedFrame;
@@ -38,7 +38,6 @@ export type ClientRec = {
38
38
  id: string;
39
39
  lastSeq: number;
40
40
  ctxSeq: number;
41
- lease: string | null;
42
41
  touched: number;
43
42
  context: Record<string, unknown> | null;
44
43
  results: Map<number, { ts: number; cmd: StatewireCmd }>;
@@ -51,6 +50,7 @@ export type Conn = {
51
50
  open: boolean;
52
51
  sawFrame: boolean;
53
52
  lastSentAt: number;
53
+ lease: string | null;
54
54
  };
55
55
 
56
56
  // ─── frame parsing ──────────────────────────────────────
@@ -115,7 +115,7 @@ const contextValue = (ops: unknown): Record<string, unknown> => {
115
115
  }
116
116
  const value =
117
117
  op["meta"] !== undefined
118
- ? reviveValue(op["value"], op["meta"])
118
+ ? reviveValue(op["value"], op["meta"], { rejectUnknown: true })
119
119
  : op["value"];
120
120
  if (!isPlainObject(value)) {
121
121
  throw new Error("context value must be an object");
@@ -128,13 +128,13 @@ export const parseFrameValue = (
128
128
  first: boolean,
129
129
  ): ParsedFrame => {
130
130
  if (!isPlainObject(frame)) return violation("malformed frame");
131
- if (
132
- frame["headers"] === undefined &&
133
- frame["ctx"] === undefined &&
134
- frame["cmd"] === undefined
135
- ) {
136
- return violation("malformed frame");
137
- }
131
+ const keys = Object.keys(frame);
132
+ if (keys.length === 0) return violation("malformed frame");
133
+ const unknown = keys
134
+ .filter((key) => key !== "headers" && key !== "ctx" && key !== "cmd")
135
+ .sort();
136
+ if (unknown.length > 0)
137
+ return violation(`unknown frame key: '${unknown[0]}'`);
138
138
  let headers: Record<string, string> | null = null;
139
139
  if (frame["headers"] !== undefined) {
140
140
  if (!first) return violation("mid-session headers");
@@ -267,7 +267,7 @@ export type SocketHostState = {
267
267
  connect: (
268
268
  clientId: string,
269
269
  socket: StatewireHostInternal.Socket,
270
- ) => StatewireHostInternal.SocketConnection;
270
+ ) => StatewireHostInternal.SocketConnection | null;
271
271
  finish: (fin: StatewireHost.Fin) => void;
272
272
  stream: (request: Request) => Promise<Response>;
273
273
  frames: (request: Request) => Promise<Response>;
@@ -563,7 +563,9 @@ const admitCmd = (
563
563
  let params = statement.params;
564
564
  if (statement.meta !== undefined) {
565
565
  try {
566
- params = reviveValue(params, statement.meta) as unknown[];
566
+ params = reviveValue(params, statement.meta, {
567
+ rejectUnknown: true,
568
+ }) as unknown[];
567
569
  } catch (error) {
568
570
  rejectStatement(
569
571
  h,
@@ -717,7 +719,6 @@ export const register = (
717
719
  id: clientId,
718
720
  lastSeq: 0,
719
721
  ctxSeq: 0,
720
- lease: null,
721
722
  touched: Date.now(),
722
723
  context: null,
723
724
  results: new Map(),
@@ -727,18 +728,13 @@ export const register = (
727
728
  return created;
728
729
  })();
729
730
  rec.touched = Date.now();
730
- rec.lease = null;
731
- for (const other of [...h.conns]) {
732
- if (other.clientId !== clientId) continue;
733
- sendTo(h, other, JSON.stringify({ fin: { reason: "superseded" } }));
734
- closeConn(h, other);
735
- }
736
731
  const conn: Conn = {
737
732
  clientId,
738
733
  socket,
739
734
  open: true,
740
735
  sawFrame: false,
741
736
  lastSentAt: 0,
737
+ lease: null,
742
738
  };
743
739
  h.conns.add(conn);
744
740
  gcClients(h);
@@ -756,7 +752,6 @@ export const register = (
756
752
  syn: {
757
753
  seq: admittedThrough(rec),
758
754
  ctxSeq: rec.ctxSeq,
759
- record: known ? "resumed" : "fresh",
760
755
  ...(h.idleWire && { idle: true }),
761
756
  },
762
757
  };
@@ -773,15 +768,9 @@ const connect = (
773
768
  h: SocketHostState,
774
769
  clientId: string,
775
770
  socket: StatewireHostInternal.Socket,
776
- ): StatewireHostInternal.SocketConnection => {
771
+ ): StatewireHostInternal.SocketConnection | null => {
777
772
  const registration = register(h, clientId, socket);
778
- return (
779
- registration?.connection ?? {
780
- clientId,
781
- message: () => {},
782
- close: () => {},
783
- }
784
- );
773
+ return registration?.connection ?? null;
785
774
  };
786
775
 
787
776
  const finish = (h: SocketHostState, fin: StatewireHost.Fin) => {
@@ -862,7 +851,8 @@ const useSocketHost = (
862
851
  * feed it sockets (anything with `send`/`close`) and raw text frames, and it
863
852
  * speaks the Statewire packet protocol — snapshot-first hello with `syn`,
864
853
  * statement-lane client frames under one admission law, `{cmd}` answer
865
- * arrays, per-client-id supersede, idle edges, and `fin` on finish.
854
+ * arrays fanned out to every attach of a client id, idle edges, and `fin` on
855
+ * finish.
866
856
  */
867
857
  export const StatewireSocketHost = (
868
858
  transport: ResourceElement<Statewire>,
@@ -15,7 +15,8 @@ export namespace StatewireHostInternal {
15
15
  };
16
16
 
17
17
  export type SocketHost = {
18
- connect: (clientId: string, socket: Socket) => SocketConnection;
18
+ /** Null means refused: the socket was told (fin and/or close) and is dead. */
19
+ connect: (clientId: string, socket: Socket) => SocketConnection | null;
19
20
  stream: (request: Request) => Promise<Response>;
20
21
  frames: (request: Request) => Promise<Response>;
21
22
  finish: (fin: StatewireHost.Fin) => void;
package/src/host.ts CHANGED
@@ -58,7 +58,6 @@ export namespace StatewireHost {
58
58
  | "error"
59
59
  | "unauthorized"
60
60
  | "gone"
61
- | "superseded"
62
61
  | "idle"
63
62
  | (string & {});
64
63
  message?: string;