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.
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +2 -1
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts +2 -0
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +8 -3
- package/dist/client/core.js.map +1 -1
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +11 -15
- package/dist/client/session.js.map +1 -1
- package/dist/client/sessionStore.d.ts +5 -1
- package/dist/client/sessionStore.d.ts.map +1 -1
- package/dist/client/sessionStore.js +3 -1
- package/dist/client/sessionStore.js.map +1 -1
- package/dist/client/timing.d.ts +1 -0
- package/dist/client/timing.d.ts.map +1 -1
- package/dist/client/timing.js +1 -0
- package/dist/client/timing.js.map +1 -1
- package/dist/host/socket.d.ts +1 -0
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +26 -16
- package/dist/host/socket.js.map +1 -1
- package/dist/host-internal.d.ts +2 -0
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/statewire.d.ts +8 -3
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +21 -13
- package/dist/statewire.js.map +1 -1
- package/dist/transports/remote.js +13 -9
- package/dist/transports/remote.js.map +1 -1
- package/dist/transports/testing.js +2 -1
- package/dist/transports/testing.js.map +1 -1
- package/dist/transports/ws.d.ts.map +1 -1
- package/dist/transports/ws.js +4 -0
- package/dist/transports/ws.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +6 -1
- package/src/client/core.ts +10 -3
- package/src/client/session.ts +20 -10
- package/src/client/sessionStore.ts +3 -0
- package/src/client/timing.ts +1 -0
- package/src/host/socket.ts +27 -27
- package/src/host-internal.ts +2 -0
- package/src/statewire.ts +63 -22
- package/src/transports/remote.ts +22 -12
- package/src/transports/testing.ts +4 -3
- package/src/transports/ws.ts +6 -0
- package/src/types.ts +2 -0
package/src/client/session.ts
CHANGED
|
@@ -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 =>
|
|
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]
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
1148
|
-
|
|
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);
|
package/src/client/timing.ts
CHANGED
package/src/host/socket.ts
CHANGED
|
@@ -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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
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
|
-
...(
|
|
671
|
+
...(cmd.length > 0 && { cmd }),
|
|
678
672
|
...(edge && { idle: level }),
|
|
679
673
|
}),
|
|
680
674
|
);
|
|
681
|
-
} else if (
|
|
682
|
-
sendTo(h, conn, JSON.stringify({ cmd
|
|
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;
|
package/src/host-internal.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
110
|
-
if (!
|
|
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
|
|
149
|
+
return transportFailures;
|
|
114
150
|
};
|
|
115
151
|
return {
|
|
116
152
|
state,
|
|
117
|
-
documents:
|
|
118
|
-
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
|
-
}, [
|
|
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
|
|
335
|
-
*
|
|
336
|
-
* optimistic folds on every
|
|
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
|
|
package/src/transports/remote.ts
CHANGED
|
@@ -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
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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);
|
package/src/transports/ws.ts
CHANGED
|
@@ -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. */
|