statewire 0.19.0 → 0.19.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 (51) hide show
  1. package/dist/client/connection.d.ts.map +1 -1
  2. package/dist/client/connection.js +2 -1
  3. package/dist/client/connection.js.map +1 -1
  4. package/dist/client/core.d.ts +2 -0
  5. package/dist/client/core.d.ts.map +1 -1
  6. package/dist/client/core.js +8 -3
  7. package/dist/client/core.js.map +1 -1
  8. package/dist/client/session.d.ts.map +1 -1
  9. package/dist/client/session.js +11 -15
  10. package/dist/client/session.js.map +1 -1
  11. package/dist/client/sessionStore.d.ts +5 -1
  12. package/dist/client/sessionStore.d.ts.map +1 -1
  13. package/dist/client/sessionStore.js +3 -1
  14. package/dist/client/sessionStore.js.map +1 -1
  15. package/dist/client/timing.d.ts +1 -0
  16. package/dist/client/timing.d.ts.map +1 -1
  17. package/dist/client/timing.js +1 -0
  18. package/dist/client/timing.js.map +1 -1
  19. package/dist/host/socket.d.ts +1 -0
  20. package/dist/host/socket.d.ts.map +1 -1
  21. package/dist/host/socket.js +26 -16
  22. package/dist/host/socket.js.map +1 -1
  23. package/dist/host-internal.d.ts +2 -0
  24. package/dist/host-internal.d.ts.map +1 -1
  25. package/dist/statewire.d.ts +8 -3
  26. package/dist/statewire.d.ts.map +1 -1
  27. package/dist/statewire.js +21 -13
  28. package/dist/statewire.js.map +1 -1
  29. package/dist/transports/remote.js +13 -9
  30. package/dist/transports/remote.js.map +1 -1
  31. package/dist/transports/testing.js +2 -1
  32. package/dist/transports/testing.js.map +1 -1
  33. package/dist/transports/ws.d.ts.map +1 -1
  34. package/dist/transports/ws.js +4 -0
  35. package/dist/transports/ws.js.map +1 -1
  36. package/dist/types.d.ts +2 -0
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/types.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/client/connection.ts +6 -1
  41. package/src/client/core.ts +10 -3
  42. package/src/client/session.ts +20 -10
  43. package/src/client/sessionStore.ts +3 -0
  44. package/src/client/timing.ts +1 -0
  45. package/src/host/socket.ts +27 -27
  46. package/src/host-internal.ts +2 -0
  47. package/src/statewire.ts +63 -22
  48. package/src/transports/remote.ts +22 -12
  49. package/src/transports/testing.ts +4 -3
  50. package/src/transports/ws.ts +6 -0
  51. package/src/types.ts +2 -0
@@ -140,7 +140,8 @@ const EMPTY_LANE: StatewireClient.SessionLane = { nextSeq: 1, commands: [] };
140
140
  const laneOf = (
141
141
  record: StatewireClient.SessionRecord,
142
142
  protocol: string,
143
- ): StatewireClient.SessionLane => record.lanes[protocol] ?? EMPTY_LANE;
143
+ ): StatewireClient.SessionLane =>
144
+ Object.hasOwn(record.lanes, protocol) ? record.lanes[protocol]! : EMPTY_LANE;
144
145
 
145
146
  const withLane = (
146
147
  record: StatewireClient.SessionRecord,
@@ -157,7 +158,7 @@ const isEphemeral = (s: Session, protocol: string) =>
157
158
  ) ?? false;
158
159
 
159
160
  const durableSeqOf = (s: Session, protocol: string) =>
160
- s.durableSeq[protocol] ?? 0;
161
+ Object.hasOwn(s.durableSeq, protocol) ? s.durableSeq[protocol]! : 0;
161
162
 
162
163
  const laneEntries = (s: Session, protocol: string) =>
163
164
  s.pending.filter((p) => p.command.protocol === protocol);
@@ -506,10 +507,14 @@ const resetRecord = (s: Session, clientId: string) =>
506
507
 
507
508
  // Another sharer replaced the record's identity: their reset settles our
508
509
  // attempted history and our survivors re-stamp on the stored clocks.
509
- const adoptReset = (s: Session, stored: StatewireClient.SessionRecord) =>
510
+ const adoptReset = (s: Session, stored: StatewireClient.SessionRecord) => {
510
511
  rebaseRecord(s, () => {
511
512
  s.record = stored;
512
513
  });
514
+ // The host attributes frames by lease, and the lease is bound to the id
515
+ // this attach was opened with.
516
+ if (s.attach === "attached") s.transport?.connection.reconnect();
517
+ };
513
518
 
514
519
  // The record died with the attach: mint a fresh id for the next one.
515
520
  const ephemeralDetach = (s: Session) => {
@@ -775,7 +780,9 @@ const handleHello = (
775
780
  for (const lane of lanes) {
776
781
  if (lane.record === "ephemeral") {
777
782
  s.ephemeralSeqs[lane.name] = Math.max(
778
- s.ephemeralSeqs[lane.name] ?? 0,
783
+ Object.hasOwn(s.ephemeralSeqs, lane.name)
784
+ ? s.ephemeralSeqs[lane.name]!
785
+ : 0,
779
786
  ...laneEntries(s, lane.name).map((p) => p.seq),
780
787
  );
781
788
  } else s.durableSeq[lane.name] = lane.seq ?? 0;
@@ -897,7 +904,10 @@ const buildLanes = (
897
904
  (cmdGate === "persisted" &&
898
905
  (s.persistedRecord?.clientId !== s.record.clientId ||
899
906
  entry.seq >= laneOf(s.persistedRecord, protocol).nextSeq)));
900
- if (gated || (protocol in last && entry.seq !== last[protocol]! + 1)) {
907
+ if (
908
+ gated ||
909
+ (Object.hasOwn(last, protocol) && entry.seq !== last[protocol]! + 1)
910
+ ) {
901
911
  stopped.add(protocol);
902
912
  continue;
903
913
  }
@@ -1094,7 +1104,9 @@ const enqueueInner = (
1094
1104
  const protocol = command.protocol;
1095
1105
  const ephemeral = isEphemeral(s, protocol);
1096
1106
  const seq = ephemeral
1097
- ? (s.ephemeralSeqs[protocol] ?? 0) + 1
1107
+ ? (Object.hasOwn(s.ephemeralSeqs, protocol)
1108
+ ? s.ephemeralSeqs[protocol]!
1109
+ : 0) + 1
1098
1110
  : Math.max(
1099
1111
  laneOf(s.record, protocol).nextSeq,
1100
1112
  durableSeqOf(s, protocol) + 1,
@@ -1144,10 +1156,8 @@ const enqueue = (
1144
1156
  return;
1145
1157
  }
1146
1158
  void transact(s, () => {
1147
- if (!s.running) {
1148
- reject(new StatewireSendError({ fate: "aborted", cause: "unmount" }));
1149
- return;
1150
- }
1159
+ // A send queued behind the lock before this tab unmounted still lands in
1160
+ // the shared journal: another sharer adopts and replays it.
1151
1161
  if (isFinished(s)) {
1152
1162
  reject(new StatewireSendError({ fate: "aborted", cause: "stream-gone" }));
1153
1163
  return;
@@ -20,6 +20,9 @@ export const createMemorySessionStore = (): StatewireClient.SessionStorage => {
20
20
 
21
21
  export const ProtocolContext = createContext<StatewireProtocol.Options>({});
22
22
 
23
+ /** Link options `useStatewire` hands down to whichever transport it mounts. */
24
+ export const LinkContext = createContext<{ isNew?: boolean | undefined }>({});
25
+
23
26
  export const DocumentStorageContext = createContext<
24
27
  StatewireDocuments.Storage | undefined
25
28
  >(undefined);
@@ -3,6 +3,7 @@ export const timing = {
3
3
  backoffMaxMs: 30_000,
4
4
  backoffResetMs: 30_000,
5
5
  heartbeatTimeoutMs: 30_000,
6
+ pingIntervalMs: 10_000,
6
7
  random: Math.random,
7
8
  };
8
9
 
@@ -256,22 +256,15 @@ const cmdOf = (adm: Admission, outcome: CommandsOutcome): Cmd => {
256
256
  ...(failure.message !== undefined && { message: failure.message }),
257
257
  };
258
258
  case "rejected": {
259
- if (failure.code !== undefined) {
260
- const message =
261
- failure.code === "unknown-command"
262
- ? `unknown method: '${adm.method}'`
263
- : failure.message;
264
- return {
265
- ...at,
266
- type: "rejected",
267
- code: failure.code,
268
- ...(message !== undefined && { message }),
269
- };
270
- }
259
+ const message =
260
+ failure.code === "unknown-command"
261
+ ? `unknown method: '${adm.method}'`
262
+ : failure.message;
271
263
  return {
272
264
  ...at,
273
265
  type: "rejected",
274
- ...(failure.message !== undefined && { message: failure.message }),
266
+ ...(failure.code !== undefined && { code: failure.code }),
267
+ ...(message !== undefined && { message }),
275
268
  ...encodePayload(failure.payload),
276
269
  };
277
270
  }
@@ -313,6 +306,7 @@ const parseInitialFrame = (raw: string) => {
313
306
 
314
307
  export type SocketHostState = {
315
308
  protocol: StatewireProtocol.Definition;
309
+ key: readonly string[] | undefined;
316
310
  wireVersion: StatewireProtocol.Range;
317
311
  heartbeatMs: number;
318
312
  wsInitTimeoutMs: number;
@@ -653,9 +647,7 @@ export const flush = (h: SocketHostState, fin?: StatewireHost.Fin) => {
653
647
  h.pendingOps = [];
654
648
  h.pendingApplied = new Map();
655
649
  h.pendingCmds = new Map();
656
- // Applied entries anchor to their effects: piggybacked on the ops packet
657
- // when one is flushing, standalone otherwise. Terminal verdicts ride their
658
- // own packet.
650
+ // One packet per flush: ops first, then applied entries, then verdicts.
659
651
  for (const conn of [...h.conns]) {
660
652
  if (!conn.ready) continue;
661
653
  const forConn = (list: Cmd[] | undefined) =>
@@ -664,9 +656,11 @@ export const flush = (h: SocketHostState, fin?: StatewireHost.Fin) => {
664
656
  ...(selects(conn, h.protocol.name) ? mainOps : []),
665
657
  ...diffView(h, conn),
666
658
  ];
667
- const appliedEntries = [
659
+ const cmd = [
668
660
  ...forConn(applied.get(conn.clientId)),
669
661
  ...conn.pendingApplied.splice(0),
662
+ ...forConn(cmds.get(conn.clientId)),
663
+ ...conn.pendingCmds.splice(0),
670
664
  ];
671
665
  if (ops.length > 0) {
672
666
  sendTo(
@@ -674,19 +668,12 @@ export const flush = (h: SocketHostState, fin?: StatewireHost.Fin) => {
674
668
  conn,
675
669
  JSON.stringify({
676
670
  ops,
677
- ...(appliedEntries.length > 0 && { cmd: appliedEntries }),
671
+ ...(cmd.length > 0 && { cmd }),
678
672
  ...(edge && { idle: level }),
679
673
  }),
680
674
  );
681
- } else if (appliedEntries.length > 0) {
682
- sendTo(h, conn, JSON.stringify({ cmd: appliedEntries }));
683
- }
684
- const verdicts = [
685
- ...forConn(cmds.get(conn.clientId)),
686
- ...conn.pendingCmds.splice(0),
687
- ];
688
- if (verdicts.length > 0) {
689
- sendTo(h, conn, JSON.stringify({ cmd: verdicts }));
675
+ } else if (cmd.length > 0) {
676
+ sendTo(h, conn, JSON.stringify({ cmd }));
690
677
  }
691
678
  if (edge && ops.length === 0) {
692
679
  sendTo(h, conn, JSON.stringify({ idle: level }));
@@ -1052,6 +1039,10 @@ const message = (h: SocketHostState, conn: Conn, data: string) => {
1052
1039
  closeConn(h, conn, 1009, "frame too large");
1053
1040
  return;
1054
1041
  }
1042
+ if (data === "{}") {
1043
+ if (conn.sawFrame) sendTo(h, conn, "{}");
1044
+ return;
1045
+ }
1055
1046
  const first = !conn.sawFrame;
1056
1047
  conn.sawFrame = true;
1057
1048
  let admission: ReturnType<typeof admitFrame>;
@@ -1464,6 +1455,12 @@ export const register = (
1464
1455
  };
1465
1456
  conn.attach = attachOf(h, conn);
1466
1457
  h.conns.add(conn);
1458
+ try {
1459
+ h.current?.attaching?.(conn.attach);
1460
+ } catch (error) {
1461
+ closeConn(h, conn, 1011, "attach failed");
1462
+ throw error;
1463
+ }
1467
1464
  gcClients(h);
1468
1465
  ensureHeartbeat(h);
1469
1466
  const isEphemeral = (s: CmdStatement) =>
@@ -1491,6 +1488,7 @@ export const register = (
1491
1488
  ops.push({
1492
1489
  op: "mount",
1493
1490
  protocol: h.protocol.name,
1491
+ ...(h.key !== undefined && { key: h.key }),
1494
1492
  main: true,
1495
1493
  value,
1496
1494
  ...(meta !== undefined && { meta }),
@@ -1610,6 +1608,7 @@ const useSocketHost = (
1610
1608
  const [h] = useState((): SocketHostState => {
1611
1609
  const host: SocketHostState = {
1612
1610
  protocol: { name: DEFAULT_PROTOCOL, version: WIRE_VERSION },
1611
+ key: undefined,
1613
1612
  wireVersion: { version: WIRE_VERSION },
1614
1613
  heartbeatMs: 15_000,
1615
1614
  wsInitTimeoutMs: 5_000,
@@ -1652,6 +1651,7 @@ const useSocketHost = (
1652
1651
  name: DEFAULT_PROTOCOL,
1653
1652
  version: WIRE_VERSION,
1654
1653
  };
1654
+ h.key = options?.key;
1655
1655
  h.wireVersion = options?.wireVersion ?? { version: WIRE_VERSION };
1656
1656
  setHeartbeatMs(h, options?.heartbeatIntervalMs ?? 15_000);
1657
1657
  h.pushback = options?.pushback;
@@ -78,6 +78,8 @@ export namespace StatewireHostInternal {
78
78
 
79
79
  export type SocketHostOptions<TAuth = unknown> = {
80
80
  protocol?: StatewireProtocol.Definition;
81
+ /** Key of the host protocol's main document. */
82
+ key?: readonly string[];
81
83
  wireVersion?: StatewireProtocol.Range;
82
84
  /** Gates every attach; the awaited return value is the attach's auth, exposed to command handlers as `this.caller.auth`. The first attach binds the client id's record to this value; a later attach whose value is not structurally equal is refused (HTTP 403, socket close 1008). */
83
85
  authorize?: (ctx: {
package/src/statewire.ts CHANGED
@@ -1,7 +1,14 @@
1
1
  import type { StatewireDocuments } from "./wire/documents";
2
2
  import type { StatewireProtocol } from "./wire/protocol";
3
3
  import type { StatewireHost } from "./host";
4
- import { useMemo, useRef, useState, useSyncExternalStore } from "react";
4
+ import {
5
+ useCallback,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ useSyncExternalStore,
11
+ } from "react";
5
12
  import {
6
13
  createTapRoot,
7
14
  flushTapSync,
@@ -17,6 +24,7 @@ import type { Statewire } from "./types";
17
24
  import type { TERMINAL } from "./client/connection";
18
25
  import {
19
26
  DocumentStorageContext,
27
+ LinkContext,
20
28
  ProtocolContext,
21
29
  SessionStorageContext,
22
30
  } from "./client/sessionStore";
@@ -26,11 +34,13 @@ const useSessionTransport = ({
26
34
  session,
27
35
  documents,
28
36
  protocol,
37
+ link,
29
38
  }: {
30
39
  transport: ResourceElement<Statewire<any, any>>;
31
40
  session: StatewireClient.SessionStorage | undefined;
32
41
  documents: StatewireDocuments.Storage | undefined;
33
42
  protocol: StatewireProtocol.Options;
43
+ link: { isNew?: boolean | undefined };
34
44
  }): Statewire<unknown> =>
35
45
  useContextProvider(SessionStorageContext, session, () =>
36
46
  // oxlint-disable-next-line react-hooks/rules-of-hooks
@@ -38,7 +48,10 @@ const useSessionTransport = ({
38
48
  // oxlint-disable-next-line react-hooks/rules-of-hooks
39
49
  useContextProvider(ProtocolContext, protocol, () =>
40
50
  // oxlint-disable-next-line react-hooks/rules-of-hooks
41
- useResource(transport),
51
+ useContextProvider(LinkContext, link, () =>
52
+ // oxlint-disable-next-line react-hooks/rules-of-hooks
53
+ useResource(transport),
54
+ ),
42
55
  ),
43
56
  ),
44
57
  );
@@ -78,15 +91,21 @@ export const useStatewire = <
78
91
  keyPrefixes: options.keyPrefixes,
79
92
  }),
80
93
  };
94
+ const link = useMemo(
95
+ () => (options.isNew === undefined ? {} : { isNew: options.isNew }),
96
+ [options.isNew],
97
+ );
81
98
  const value = useResource(
82
99
  SessionTransport({
83
100
  transport: options.transport,
84
101
  session,
85
102
  documents,
86
103
  protocol,
104
+ link,
87
105
  }),
88
106
  );
89
107
  const core = useStatewireCore(() => latest.current, value);
108
+ useEffect(() => core.rematerialize(), [core, options.materializeKey]);
90
109
  const [subscribe] = useState(
91
110
  () => (listener: () => void) =>
92
111
  core.subscribe(() => flushTapSync(listener)),
@@ -104,29 +123,36 @@ export const useStatewire = <
104
123
  core.getFailed,
105
124
  );
106
125
  const connection = value.connection ?? CONNECTED_CONNECTION;
126
+ const {
127
+ documents: source,
128
+ selection,
129
+ invoke: transportInvoke,
130
+ failures: transportFailures,
131
+ } = value;
132
+
133
+ const invoke = useCallback(
134
+ (protocol: string, method: string, params: readonly unknown[]) => {
135
+ if (!transportInvoke)
136
+ throw new Error(
137
+ "statewire: transport does not support protocol commands",
138
+ );
139
+ return transportInvoke(protocol, method, params);
140
+ },
141
+ [transportInvoke],
142
+ );
107
143
 
108
144
  return useMemo(() => {
109
- const failures = (): NonNullable<typeof value.failures> => {
110
- if (!value.failures) {
145
+ const failures = (): NonNullable<typeof transportFailures> => {
146
+ if (!transportFailures) {
111
147
  throw new Error("statewire: this transport has no failed-send lane");
112
148
  }
113
- return value.failures;
149
+ return transportFailures;
114
150
  };
115
151
  return {
116
152
  state,
117
- documents: value.documents,
118
- selection: value.selection,
119
- invoke: (
120
- protocol: string,
121
- method: string,
122
- params: readonly unknown[],
123
- ) => {
124
- if (!value.invoke)
125
- throw new Error(
126
- "statewire: transport does not support protocol commands",
127
- );
128
- return value.invoke(protocol, method, params);
129
- },
153
+ documents: source,
154
+ selection,
155
+ invoke,
130
156
  getState: core.getState as () => TState,
131
157
  connection,
132
158
  commands: core.commands as Statewire.CommandsProxy<TCommands>,
@@ -137,7 +163,17 @@ export const useStatewire = <
137
163
  discardFailed: (entry: StatewireClient.FailedCommand) =>
138
164
  failures().discard(entry),
139
165
  };
140
- }, [state, isBusy, failed, connection, core, value]);
166
+ }, [
167
+ state,
168
+ isBusy,
169
+ failed,
170
+ connection,
171
+ core,
172
+ source,
173
+ selection,
174
+ invoke,
175
+ transportFailures,
176
+ ]);
141
177
  };
142
178
 
143
179
  export const StatewireResource = <
@@ -222,6 +258,8 @@ export namespace StatewireClient {
222
258
  wireVersion?: StatewireProtocol.Range;
223
259
  applicationProtocols?: readonly StatewireProtocol.Offer[] | "*";
224
260
  transport: ResourceElement<Statewire<TState, TCommands>>;
261
+ /** The session does not exist on the server yet: the transport's first attach waits for the first command send; the transport's own `isNew` wins. */
262
+ isNew?: boolean;
225
263
  /** Durable session: the mounted storage holds this client's lane identity and its un-durable commands, which resend on the next attach. */
226
264
  storage?:
227
265
  | {
@@ -233,6 +271,8 @@ export namespace StatewireClient {
233
271
  initialState?: TState;
234
272
  optimistic?: Optimistic<NonNullable<TState>, TCommands>;
235
273
  materialize?: Materialize<NonNullable<TState>>;
274
+ /** Republishes through `materialize` when it changes (`Object.is`); `materialize` itself is read fresh on every publish. */
275
+ materializeKey?: unknown;
236
276
  onStateChange?: (
237
277
  state: NonNullable<TState>,
238
278
  ctx: { changes: Statewire.Changes },
@@ -331,9 +371,10 @@ export namespace StatewireClient {
331
371
  };
332
372
 
333
373
  /**
334
- * Mutate the draft to make server-accepted, still-uncommitted intents in
335
- * the state concrete (e.g. dispatch records into messages). Runs after the
336
- * optimistic folds on every published state.
374
+ * Mutate the draft to fold client-known facts into the state: server-accepted,
375
+ * still-uncommitted intents (e.g. dispatch records into messages) or the live
376
+ * state of a sibling statewire. Runs after the optimistic folds on every
377
+ * published state; `materializeKey` republishes when the facts change.
337
378
  */
338
379
  export type Materialize<TState> = (draft: TState) => void;
339
380
 
@@ -1,5 +1,5 @@
1
- import { useContext, useEffect } from "react";
2
- import { ProtocolContext } from "../client/sessionStore";
1
+ import { useContext, useEffect, useMemo } from "react";
2
+ import { LinkContext, ProtocolContext } from "../client/sessionStore";
3
3
  import type { Statewire } from "../types";
4
4
  import {
5
5
  documentSource,
@@ -24,30 +24,40 @@ export const useStatewireRemote = <
24
24
  options: TOptions,
25
25
  useLink: (options: TOptions, ctx: StatewireRemoteProps) => RemoteLink,
26
26
  ): Statewire<any, any> => {
27
- const inherited = useContext(ProtocolContext);
28
- const configured = { ...inherited, ...options };
27
+ const configured = {
28
+ ...useContext(ProtocolContext),
29
+ ...useContext(LinkContext),
30
+ ...options,
31
+ };
29
32
  const s = useStatewireSession(configured);
30
33
  const conn = useLink(configured, s.ctx);
31
34
  useEffect(() => setTransport(s, conn), [s, conn]);
35
+ const lanes = useMemo(
36
+ () => ({
37
+ documents: documentSource(s),
38
+ invoke: (protocol: string, method: string, params: readonly unknown[]) =>
39
+ invokeProtocol(s, protocol, method, params),
40
+ failures: {
41
+ list: () => failedCommands(s),
42
+ retry: (entry: StatewireClient.FailedCommand) => retryFailed(s, entry),
43
+ discard: (entry: StatewireClient.FailedCommand) =>
44
+ discardFailed(s, entry),
45
+ },
46
+ }),
47
+ [s],
48
+ );
32
49
 
33
50
  return {
34
51
  state: s.canonical,
35
- documents: documentSource(s),
52
+ ...lanes,
36
53
  get selection() {
37
54
  return s.selection;
38
55
  },
39
- invoke: (protocol, method, params) =>
40
- invokeProtocol(s, protocol, method, params),
41
56
  commands: s.commands,
42
57
  connection: conn.connection,
43
58
  source: s.source,
44
59
  get isBusy() {
45
60
  return !s.idle;
46
61
  },
47
- failures: {
48
- list: () => failedCommands(s),
49
- retry: (entry) => retryFailed(s, entry),
50
- discard: (entry) => discardFailed(s, entry),
51
- },
52
62
  };
53
63
  };
@@ -45,9 +45,9 @@ export const createMockTransport = <TState>(
45
45
  protocol = "default",
46
46
  options: { ephemeral?: readonly string[] } = {},
47
47
  ): MockTransport<TState> => {
48
- const ephemeral = (options.ephemeral ?? []).map(
49
- (child) => `${protocol}/${child}`,
50
- );
48
+ // Read per attach: a test can grow the lane set between connects.
49
+ const ephemeralLanes = () =>
50
+ (options.ephemeral ?? []).map((child) => `${protocol}/${child}`);
51
51
  let state: unknown = initialState;
52
52
  let clientId = "";
53
53
  let finished = false;
@@ -85,6 +85,7 @@ export const createMockTransport = <TState>(
85
85
  clientId = id;
86
86
  if (!clients.has(id)) clients.set(id, {});
87
87
  const rec = clients.get(id)!;
88
+ const ephemeral = ephemeralLanes();
88
89
  for (const lane of ephemeral) delete rec[lane];
89
90
  const frame = JSON.parse(String(init?.body ?? "{}")) as StatewireFrame;
90
91
  admit(id, frame);
@@ -57,6 +57,7 @@ const runSocket = (
57
57
  ): Promise<AttemptOutcome> =>
58
58
  new Promise((resolve, reject) => {
59
59
  let timer: ReturnType<typeof setTimeout> | undefined;
60
+ let ping: ReturnType<typeof setInterval> | undefined;
60
61
  let timedOut = false;
61
62
  let outcome: AttemptOutcome | null = null;
62
63
  let closed = false;
@@ -78,10 +79,14 @@ const runSocket = (
78
79
  return;
79
80
  }
80
81
  ws.send(frame);
82
+ // A ping keeps a host that cannot run timers (a hibernating durable
83
+ // object answers it without waking) talking before the silence timeout.
84
+ ping = setInterval(() => ws.send("{}"), timing.pingIntervalMs);
81
85
  };
82
86
  const stale = () => Boolean(outcome) || closed || abort.signal.aborted;
83
87
  const fail = (error: unknown) => {
84
88
  clearTimeout(timer);
89
+ clearInterval(ping);
85
90
  reject(error instanceof Error ? error : new Error("protocol"));
86
91
  ws.close();
87
92
  };
@@ -116,6 +121,7 @@ const runSocket = (
116
121
  ws.onclose = () => {
117
122
  closed = true;
118
123
  clearTimeout(timer);
124
+ clearInterval(ping);
119
125
  if (outcome) resolve(outcome);
120
126
  else reject(new Error(timedOut ? "timeout" : "network"));
121
127
  };
package/src/types.ts CHANGED
@@ -67,6 +67,8 @@ export interface Statewire<
67
67
  /** Negation of the server's broadcast idle level; absent means always busy. */
68
68
  isBusy?: boolean;
69
69
  work?: { readonly isIdle: boolean };
70
+ /** Called by the serving host once an attach is registered, before its hello: documents mounted here ride the hello. */
71
+ attaching?: (attach: StatewireHost.Attach) => void;
70
72
  /** Called by the serving host when an attach ends; its ephemeral documents and includes are gone. */
71
73
  detached?: (attach: StatewireHost.Attach) => void;
72
74
  /** The retained failed-send lane; present on transports with a durable session. */