statewire 0.0.1 → 0.0.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/README.md +14 -13
- package/dist/client/changes.d.ts +2 -2
- package/dist/client/changes.d.ts.map +1 -1
- package/dist/client/changes.js +3 -10
- package/dist/client/changes.js.map +1 -1
- package/dist/client/connection.d.ts +18 -39
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +61 -54
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts +7 -11
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +58 -55
- package/dist/client/core.js.map +1 -1
- package/dist/client/session.d.ts +8 -25
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +56 -36
- package/dist/client/session.js.map +1 -1
- package/dist/client/sessionStore.d.ts +1 -1
- package/dist/client/sessionStore.js.map +1 -1
- package/dist/client/timing.d.ts +2 -1
- package/dist/client/timing.d.ts.map +1 -1
- package/dist/client/timing.js +5 -1
- package/dist/client/timing.js.map +1 -1
- package/dist/host/adapter.d.ts +8 -0
- package/dist/host/adapter.d.ts.map +1 -0
- package/dist/host/adapter.js +23 -0
- package/dist/host/adapter.js.map +1 -0
- package/dist/host/draft.d.ts +21 -7
- package/dist/host/draft.d.ts.map +1 -1
- package/dist/host/draft.js +190 -103
- package/dist/host/draft.js.map +1 -1
- package/dist/host/host.d.ts +18 -35
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js +81 -53
- package/dist/host/host.js.map +1 -1
- package/dist/host/socket.d.ts +14 -0
- package/dist/host/socket.d.ts.map +1 -0
- package/dist/host/socket.js +459 -0
- package/dist/host/socket.js.map +1 -0
- package/dist/host-internal.d.ts +27 -0
- package/dist/host-internal.d.ts.map +1 -0
- package/dist/host-internal.js +3 -0
- package/dist/host.d.ts +44 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +4 -0
- package/dist/index.d.ts +5 -28
- package/dist/index.js +2 -3
- package/dist/statewire.d.ts +94 -10
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +23 -35
- package/dist/statewire.js.map +1 -1
- package/dist/transports/fake-sse.d.ts +12 -0
- package/dist/transports/fake-sse.d.ts.map +1 -0
- package/dist/transports/fake-sse.js +39 -0
- package/dist/transports/fake-sse.js.map +1 -0
- package/dist/transports/fixtures.d.ts +33 -0
- package/dist/transports/fixtures.d.ts.map +1 -0
- package/dist/transports/fixtures.js +50 -0
- package/dist/transports/fixtures.js.map +1 -0
- package/dist/transports/remote.d.ts +6 -13
- package/dist/transports/remote.d.ts.map +1 -1
- package/dist/transports/remote.js +5 -24
- package/dist/transports/remote.js.map +1 -1
- package/dist/transports/sse.d.ts +10 -20
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +42 -41
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/testing.d.ts +4 -3
- package/dist/transports/testing.d.ts.map +1 -1
- package/dist/transports/testing.js +19 -46
- package/dist/transports/testing.js.map +1 -1
- package/dist/transports/ws.d.ts +4 -13
- package/dist/transports/ws.d.ts.map +1 -1
- package/dist/transports/ws.js +57 -60
- package/dist/transports/ws.js.map +1 -1
- package/dist/types.d.ts +53 -100
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/util/draftTraps.d.ts +6 -0
- package/dist/util/draftTraps.d.ts.map +1 -0
- package/dist/util/draftTraps.js +25 -0
- package/dist/util/draftTraps.js.map +1 -0
- package/dist/util/macrotask.d.ts +5 -0
- package/dist/util/macrotask.d.ts.map +1 -0
- package/dist/util/macrotask.js +25 -0
- package/dist/util/macrotask.js.map +1 -0
- package/dist/util/methodProxy.d.ts +5 -0
- package/dist/util/methodProxy.d.ts.map +1 -0
- package/dist/util/methodProxy.js +17 -0
- package/dist/util/methodProxy.js.map +1 -0
- package/dist/util/randomId.d.ts +5 -0
- package/dist/util/randomId.d.ts.map +1 -0
- package/dist/util/randomId.js +6 -0
- package/dist/util/randomId.js.map +1 -0
- package/dist/{client/optimistic.d.ts → util/recordMutations.d.ts} +2 -2
- package/dist/util/recordMutations.d.ts.map +1 -0
- package/dist/{client/optimistic.js → util/recordMutations.js} +5 -24
- package/dist/util/recordMutations.js.map +1 -0
- package/dist/util/statePath.d.ts +14 -0
- package/dist/util/statePath.d.ts.map +1 -0
- package/dist/util/statePath.js +31 -0
- package/dist/util/statePath.js.map +1 -0
- package/dist/wire/meta.d.ts +6 -1
- package/dist/wire/meta.d.ts.map +1 -1
- package/dist/wire/meta.js +30 -28
- package/dist/wire/meta.js.map +1 -1
- package/dist/wire/ops.d.ts +5 -26
- package/dist/wire/ops.d.ts.map +1 -1
- package/dist/wire/ops.js +67 -80
- package/dist/wire/ops.js.map +1 -1
- package/package.json +15 -11
- package/src/client/changes.ts +5 -15
- package/src/client/connection.ts +87 -113
- package/src/client/core.ts +57 -91
- package/src/client/session.ts +87 -70
- package/src/client/sessionStore.ts +1 -1
- package/src/client/timing.ts +6 -0
- package/src/host/adapter.ts +33 -0
- package/src/host/draft.ts +293 -125
- package/src/host/host.ts +107 -106
- package/src/host/socket.ts +542 -0
- package/src/host-internal.ts +28 -0
- package/src/host.ts +61 -0
- package/src/index.ts +2 -62
- package/src/statewire.ts +154 -61
- package/src/transports/fake-sse.ts +51 -0
- package/src/transports/fixtures.ts +60 -0
- package/src/transports/remote.ts +12 -54
- package/src/transports/sse.ts +68 -81
- package/src/transports/testing.ts +27 -59
- package/src/transports/ws.ts +76 -92
- package/src/types.ts +50 -131
- package/src/util/draftTraps.ts +32 -0
- package/src/util/macrotask.ts +25 -0
- package/src/util/methodProxy.ts +16 -0
- package/src/util/randomId.ts +4 -0
- package/src/{client/optimistic.ts → util/recordMutations.ts} +0 -0
- package/src/util/statePath.ts +61 -0
- package/src/wire/meta.ts +42 -47
- package/src/wire/ops.ts +107 -106
- package/LICENSE +0 -21
- package/dist/client/optimistic.d.ts.map +0 -1
- package/dist/client/optimistic.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/server.d.ts +0 -15
- package/dist/server.d.ts.map +0 -1
- package/dist/server.js +0 -3
- package/src/server.ts +0 -32
package/src/transports/ws.ts
CHANGED
|
@@ -1,127 +1,85 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
1
2
|
import { flushTapSync, resource } from "@assistant-ui/tap";
|
|
2
3
|
import type { ResourceElement } from "@assistant-ui/tap";
|
|
3
4
|
import { encodeParams } from "../wire/meta";
|
|
4
5
|
import { timing } from "../client/timing";
|
|
5
6
|
import {
|
|
6
|
-
createGate,
|
|
7
7
|
handleEnvelope,
|
|
8
8
|
overloadedError,
|
|
9
|
-
|
|
9
|
+
useStatewireConn,
|
|
10
10
|
} from "../client/connection";
|
|
11
|
-
import type {
|
|
12
|
-
import type {
|
|
11
|
+
import type { Conn, StatewireEnvelope } from "../client/connection";
|
|
12
|
+
import type { Statewire } from "../types";
|
|
13
|
+
import type { StatewireClient } from "../statewire";
|
|
13
14
|
import { useStatewireRemote } from "./remote";
|
|
14
|
-
import
|
|
15
|
-
import type { StatewireCommands } from "../types";
|
|
15
|
+
import { CLIENT_QUERY_PARAM } from "../host/adapter";
|
|
16
16
|
import type {
|
|
17
17
|
RemoteLink,
|
|
18
18
|
StatewireSubmitResult,
|
|
19
|
-
|
|
20
|
-
StatewireTransportProps,
|
|
19
|
+
StatewireRemoteProps,
|
|
21
20
|
StatewireWireCommand,
|
|
22
21
|
} from "./sse";
|
|
23
22
|
|
|
24
|
-
export type StatewireWSOptions = {
|
|
25
|
-
url: string;
|
|
26
|
-
protocols?: string | string[];
|
|
27
|
-
query?:
|
|
28
|
-
| Record<string, string>
|
|
29
|
-
| (() => Record<string, string> | Promise<Record<string, string>>);
|
|
30
|
-
webSocket?: new (url: string, protocols?: string | string[]) => WebSocket;
|
|
31
|
-
onChange?: (connection: StatewireConnection) => void;
|
|
32
|
-
sessionStore?: StatewireSessionStore;
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
type Latest = {
|
|
36
|
-
options: StatewireWSOptions;
|
|
37
|
-
ctx: StatewireTransportProps;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
23
|
type PendingCommand = {
|
|
41
24
|
seq: number;
|
|
42
25
|
resolve: (result: StatewireSubmitResult) => void;
|
|
43
26
|
reject: (error: Error) => void;
|
|
44
27
|
};
|
|
45
28
|
|
|
46
|
-
type
|
|
29
|
+
type WsChannel = {
|
|
47
30
|
socket: WebSocket | null;
|
|
48
31
|
pending: PendingCommand[];
|
|
49
|
-
latest: { readonly current: Latest };
|
|
50
|
-
send: RemoteLink["send"];
|
|
51
32
|
};
|
|
52
33
|
|
|
53
34
|
const OPEN = 1;
|
|
54
35
|
|
|
55
36
|
export const WS_SUBPROTOCOL = "statewire.v1";
|
|
56
37
|
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
)
|
|
61
|
-
const
|
|
62
|
-
hasSnapshot: false,
|
|
63
|
-
lease: null,
|
|
64
|
-
attempt: 0,
|
|
65
|
-
episode: null,
|
|
66
|
-
abort: null,
|
|
67
|
-
socket: null,
|
|
68
|
-
pending: [],
|
|
69
|
-
gate: createGate(),
|
|
70
|
-
lifetime: null,
|
|
71
|
-
latest,
|
|
72
|
-
notify,
|
|
73
|
-
reconnect: () => reconnectConn(c),
|
|
74
|
-
attemptOnce: (abort) => attemptSocket(c, abort),
|
|
75
|
-
send: (args) => sendCommand(c, args),
|
|
76
|
-
connection: { status: "connecting", reconnect: () => c.reconnect() },
|
|
77
|
-
};
|
|
78
|
-
return c;
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
const socketUrl = async (c: Conn) => {
|
|
82
|
-
const { url, query } = c.latest.current.options;
|
|
38
|
+
const socketUrl = async (
|
|
39
|
+
options: StatewireClient.WSOptions,
|
|
40
|
+
clientId: string,
|
|
41
|
+
) => {
|
|
42
|
+
const { url, query } = options;
|
|
83
43
|
const params = new URLSearchParams(
|
|
84
44
|
typeof query === "function" ? await query() : query,
|
|
85
45
|
);
|
|
86
|
-
params.set(
|
|
46
|
+
params.set(CLIENT_QUERY_PARAM, clientId);
|
|
87
47
|
const base = url.replace(/\/$/, "").replace(/^http/, "ws");
|
|
88
48
|
return `${base}/ws?${params.toString()}`;
|
|
89
49
|
};
|
|
90
50
|
|
|
91
51
|
const settleOverloaded = (
|
|
92
|
-
|
|
52
|
+
w: WsChannel,
|
|
93
53
|
{ seq, retryAfterMs }: { seq?: number; retryAfterMs?: number },
|
|
94
54
|
) => {
|
|
95
55
|
if (seq === undefined) throw overloadedError(retryAfterMs);
|
|
96
|
-
const entry =
|
|
56
|
+
const entry = w.pending.find((p) => p.seq === seq);
|
|
97
57
|
if (!entry) return;
|
|
98
|
-
|
|
58
|
+
w.pending = w.pending.filter((p) => p !== entry);
|
|
99
59
|
entry.reject(overloadedError(retryAfterMs));
|
|
100
60
|
};
|
|
101
61
|
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
const resolveResponded = (c: Conn, seqs: Set<number>) => {
|
|
62
|
+
const resolvePending = (
|
|
63
|
+
w: WsChannel,
|
|
64
|
+
matches: (entry: PendingCommand) => boolean,
|
|
65
|
+
) => {
|
|
109
66
|
const remaining: PendingCommand[] = [];
|
|
110
|
-
for (const entry of
|
|
111
|
-
if (
|
|
67
|
+
for (const entry of w.pending) {
|
|
68
|
+
if (matches(entry)) entry.resolve({ type: "receipt" });
|
|
112
69
|
else remaining.push(entry);
|
|
113
70
|
}
|
|
114
|
-
|
|
71
|
+
w.pending = remaining;
|
|
115
72
|
};
|
|
116
73
|
|
|
117
|
-
const failPending = (
|
|
118
|
-
const pending =
|
|
119
|
-
|
|
74
|
+
const failPending = (w: WsChannel, error: Error) => {
|
|
75
|
+
const pending = w.pending;
|
|
76
|
+
w.pending = [];
|
|
120
77
|
for (const entry of pending) entry.reject(error);
|
|
121
78
|
};
|
|
122
79
|
|
|
123
80
|
const runSocket = (
|
|
124
81
|
c: Conn,
|
|
82
|
+
w: WsChannel,
|
|
125
83
|
ws: WebSocket,
|
|
126
84
|
abort: AbortController,
|
|
127
85
|
): Promise<"stop" | "reattach"> =>
|
|
@@ -130,7 +88,6 @@ const runSocket = (
|
|
|
130
88
|
let timedOut = false;
|
|
131
89
|
let outcome: "stop" | "reattach" | null = null;
|
|
132
90
|
let closed = false;
|
|
133
|
-
let attached = false;
|
|
134
91
|
const arm = () => {
|
|
135
92
|
clearTimeout(timer);
|
|
136
93
|
timer = setTimeout(() => {
|
|
@@ -148,11 +105,14 @@ const runSocket = (
|
|
|
148
105
|
ws.close();
|
|
149
106
|
};
|
|
150
107
|
const processEnv = (env: StatewireEnvelope) => {
|
|
151
|
-
const done = flushTapSync(() => handleEnvelope(c, env
|
|
152
|
-
|
|
153
|
-
|
|
108
|
+
const done = flushTapSync(() => handleEnvelope(c, env));
|
|
109
|
+
if (env.ack !== undefined) {
|
|
110
|
+
const ack = env.ack;
|
|
111
|
+
resolvePending(w, (entry) => entry.seq <= ack);
|
|
112
|
+
}
|
|
154
113
|
if (env.res !== undefined) {
|
|
155
|
-
|
|
114
|
+
const seqs = new Set(env.res.map((r) => r.seq));
|
|
115
|
+
resolvePending(w, (entry) => seqs.has(entry.seq));
|
|
156
116
|
}
|
|
157
117
|
if (done) {
|
|
158
118
|
outcome = done;
|
|
@@ -173,7 +133,7 @@ const runSocket = (
|
|
|
173
133
|
}
|
|
174
134
|
try {
|
|
175
135
|
if (frame.overloaded !== undefined) {
|
|
176
|
-
settleOverloaded(
|
|
136
|
+
settleOverloaded(w, frame.overloaded);
|
|
177
137
|
} else {
|
|
178
138
|
processEnv(frame);
|
|
179
139
|
}
|
|
@@ -184,7 +144,7 @@ const runSocket = (
|
|
|
184
144
|
ws.onclose = () => {
|
|
185
145
|
closed = true;
|
|
186
146
|
clearTimeout(timer);
|
|
187
|
-
if (
|
|
147
|
+
if (w.socket === ws) failPending(w, new Error("network"));
|
|
188
148
|
if (outcome) resolve(outcome);
|
|
189
149
|
else reject(new Error(timedOut ? "timeout" : "network"));
|
|
190
150
|
};
|
|
@@ -192,13 +152,15 @@ const runSocket = (
|
|
|
192
152
|
|
|
193
153
|
const attemptSocket = async (
|
|
194
154
|
c: Conn,
|
|
155
|
+
w: WsChannel,
|
|
156
|
+
options: StatewireClient.WSOptions,
|
|
195
157
|
abort: AbortController,
|
|
196
158
|
): Promise<"stop" | "reattach"> => {
|
|
197
159
|
let ws: WebSocket | null = null;
|
|
198
160
|
try {
|
|
199
|
-
const url = await socketUrl(c);
|
|
161
|
+
const url = await socketUrl(options, c.ctx.clientId);
|
|
200
162
|
abort.signal.throwIfAborted();
|
|
201
|
-
const { protocols, webSocket } =
|
|
163
|
+
const { protocols, webSocket } = options;
|
|
202
164
|
const WS = webSocket ?? globalThis.WebSocket;
|
|
203
165
|
if (!WS) throw new Error("no WebSocket implementation");
|
|
204
166
|
const extra =
|
|
@@ -208,18 +170,18 @@ const attemptSocket = async (
|
|
|
208
170
|
? [protocols]
|
|
209
171
|
: protocols;
|
|
210
172
|
ws = new WS(url, [WS_SUBPROTOCOL, ...extra]);
|
|
211
|
-
|
|
212
|
-
return await runSocket(c, ws, abort);
|
|
173
|
+
w.socket = ws;
|
|
174
|
+
return await runSocket(c, w, ws, abort);
|
|
213
175
|
} finally {
|
|
214
|
-
if (
|
|
176
|
+
if (w.socket === ws) w.socket = null;
|
|
215
177
|
}
|
|
216
178
|
};
|
|
217
179
|
|
|
218
180
|
const sendCommand = (
|
|
219
|
-
|
|
181
|
+
w: WsChannel,
|
|
220
182
|
{ seq, command }: { seq: number; command: StatewireWireCommand },
|
|
221
183
|
): Promise<StatewireSubmitResult> => {
|
|
222
|
-
const ws =
|
|
184
|
+
const ws = w.socket;
|
|
223
185
|
if (!ws || ws.readyState !== OPEN) {
|
|
224
186
|
return Promise.reject(new Error("network"));
|
|
225
187
|
}
|
|
@@ -238,11 +200,11 @@ const sendCommand = (
|
|
|
238
200
|
// A timed-out command fails the socket: the retry then rides reconnect +
|
|
239
201
|
// attach-seq arbitration.
|
|
240
202
|
const timer = setTimeout(() => {
|
|
241
|
-
|
|
203
|
+
w.pending = w.pending.filter((p) => p !== entry);
|
|
242
204
|
reject(new Error("timeout"));
|
|
243
205
|
ws.close();
|
|
244
206
|
}, timing.heartbeatTimeoutMs);
|
|
245
|
-
|
|
207
|
+
w.pending.push(entry);
|
|
246
208
|
try {
|
|
247
209
|
ws.send(
|
|
248
210
|
JSON.stringify({
|
|
@@ -253,20 +215,42 @@ const sendCommand = (
|
|
|
253
215
|
);
|
|
254
216
|
} catch {
|
|
255
217
|
clearTimeout(timer);
|
|
256
|
-
|
|
218
|
+
w.pending = w.pending.filter((p) => p !== entry);
|
|
257
219
|
reject(new Error("network"));
|
|
258
220
|
}
|
|
259
221
|
});
|
|
260
222
|
};
|
|
261
223
|
|
|
224
|
+
const useWsLink = (
|
|
225
|
+
options: StatewireClient.WSOptions,
|
|
226
|
+
ctx: StatewireRemoteProps,
|
|
227
|
+
): RemoteLink => {
|
|
228
|
+
const [w] = useState((): WsChannel => ({ socket: null, pending: [] }));
|
|
229
|
+
const c = useStatewireConn({
|
|
230
|
+
url: options.url,
|
|
231
|
+
onChange: options.onChange,
|
|
232
|
+
ctx,
|
|
233
|
+
attemptOnce: (c, abort) => attemptSocket(c, w, options, abort),
|
|
234
|
+
});
|
|
235
|
+
const [link] = useState(
|
|
236
|
+
(): RemoteLink => ({
|
|
237
|
+
get connection() {
|
|
238
|
+
return c.connection;
|
|
239
|
+
},
|
|
240
|
+
send: (args) => sendCommand(w, args),
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
return link;
|
|
244
|
+
};
|
|
245
|
+
|
|
262
246
|
const useStatewireWS = (
|
|
263
|
-
options:
|
|
264
|
-
):
|
|
247
|
+
options: StatewireClient.WSOptions,
|
|
248
|
+
): Statewire<any, any, any> => useStatewireRemote(options, useWsLink);
|
|
265
249
|
|
|
266
250
|
export const StatewireWS = resource(useStatewireWS) as <
|
|
267
251
|
TState = any,
|
|
268
|
-
TCommands extends
|
|
252
|
+
TCommands extends Statewire.Commands = Statewire.Commands,
|
|
269
253
|
TEvent = any,
|
|
270
254
|
>(
|
|
271
|
-
options:
|
|
272
|
-
) => ResourceElement<
|
|
255
|
+
options: StatewireClient.WSOptions,
|
|
256
|
+
) => ResourceElement<Statewire<TState | undefined, TCommands, TEvent>>;
|
package/src/types.ts
CHANGED
|
@@ -1,145 +1,64 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type { StatewireConnection } from "./transports/sse";
|
|
1
|
+
import type { StatewireClient } from "./statewire";
|
|
3
2
|
import type { StatewirePath } from "./wire/ops";
|
|
4
3
|
|
|
5
|
-
export
|
|
4
|
+
export namespace Statewire {
|
|
5
|
+
export type Commands = Record<string, (...args: never[]) => unknown>;
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
/** Drain the paths changed since the last take (`[]` entry = whole state). */
|
|
11
|
-
takeChanged: () => StatewirePath[];
|
|
12
|
-
/** Synchronous, per change; the consumer owns coalescing. */
|
|
13
|
-
setOnChange: (listener: (() => void) | null) => void;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
export type StatewireTransport<
|
|
17
|
-
TState,
|
|
18
|
-
TCommands extends StatewireCommands = StatewireCommands,
|
|
19
|
-
TEvent = any,
|
|
20
|
-
> = {
|
|
21
|
-
state: TState;
|
|
22
|
-
commands: StatewireCommandsProxy<TCommands>;
|
|
23
|
-
emit: (event: TEvent) => void;
|
|
24
|
-
connection?: StatewireConnection;
|
|
25
|
-
source?: StatewireStateSource;
|
|
26
|
-
};
|
|
7
|
+
export type Command<TCommands extends Commands = Commands> = {
|
|
8
|
+
[K in keyof TCommands]: { method: K; params: Parameters<TCommands[K]> };
|
|
9
|
+
}[keyof TCommands];
|
|
27
10
|
|
|
28
|
-
export type
|
|
29
|
-
|
|
30
|
-
>
|
|
31
|
-
|
|
32
|
-
}
|
|
11
|
+
export type CommandsProxy<TCommands extends Commands> = {
|
|
12
|
+
[K in keyof TCommands]: (
|
|
13
|
+
...args: Parameters<TCommands[K]>
|
|
14
|
+
) => Promise<Awaited<ReturnType<TCommands[K]>>>;
|
|
15
|
+
};
|
|
33
16
|
|
|
34
|
-
export type
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
17
|
+
export type CommandUpdate<TCommands extends Commands = Commands> = {
|
|
18
|
+
[K in keyof TCommands]: {
|
|
19
|
+
method: K;
|
|
20
|
+
params: Parameters<TCommands[K]>;
|
|
21
|
+
seq: number;
|
|
22
|
+
status: "queued" | "posted" | "acked" | "settled";
|
|
23
|
+
result?: unknown;
|
|
24
|
+
failure?: StatewireClient.SendFailure;
|
|
25
|
+
};
|
|
26
|
+
}[keyof TCommands];
|
|
39
27
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
status: "queued" | "posted" | "acked" | "settled";
|
|
48
|
-
result?: unknown;
|
|
49
|
-
failure?: SendFailure;
|
|
28
|
+
/** Push-based canonical state feed a statewire hands to the client core. */
|
|
29
|
+
export type StateSource = {
|
|
30
|
+
snapshot: () => unknown;
|
|
31
|
+
/** Drain the paths changed since the last take (`[]` entry = whole state). */
|
|
32
|
+
takeChanged: () => StatewirePath[];
|
|
33
|
+
/** Synchronous, per change; the consumer owns coalescing. */
|
|
34
|
+
setOnChange: (listener: (() => void) | null) => void;
|
|
50
35
|
};
|
|
51
|
-
}[keyof TCommands];
|
|
52
|
-
|
|
53
|
-
export type StatewireOptimisticContext = {
|
|
54
|
-
ts: number;
|
|
55
|
-
seq: number;
|
|
56
|
-
clientId?: string;
|
|
57
|
-
};
|
|
58
36
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
TState,
|
|
65
|
-
TCommands extends StatewireCommands = StatewireCommands,
|
|
66
|
-
> = (
|
|
67
|
-
draft: TState,
|
|
68
|
-
command: StatewireCommand<TCommands>,
|
|
69
|
-
ctx: StatewireOptimisticContext,
|
|
70
|
-
) => void;
|
|
37
|
+
export type Op =
|
|
38
|
+
| { op: "replace"; path: StatewirePath; value: unknown }
|
|
39
|
+
| { op: "add"; path: StatewirePath; value: unknown }
|
|
40
|
+
| { op: "remove"; path: StatewirePath }
|
|
41
|
+
| { op: "event"; value: object };
|
|
71
42
|
|
|
72
|
-
export type
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
};
|
|
43
|
+
export type Changes = {
|
|
44
|
+
isChangedAt(path: readonly (string | number)[]): boolean;
|
|
45
|
+
getChangedKeys(path: readonly (string | number)[]): string[];
|
|
46
|
+
};
|
|
47
|
+
}
|
|
76
48
|
|
|
77
|
-
export
|
|
78
|
-
TState,
|
|
79
|
-
TCommands extends
|
|
49
|
+
export interface Statewire<
|
|
50
|
+
TState = any,
|
|
51
|
+
TCommands extends Statewire.Commands = Statewire.Commands,
|
|
80
52
|
TEvent = any,
|
|
81
|
-
>
|
|
82
|
-
transport: ResourceElement<StatewireTransport<TState, TCommands, TEvent>>;
|
|
83
|
-
optimistic?: StatewireOptimistic<NonNullable<TState>, TCommands>;
|
|
84
|
-
onEvent?: (event: TEvent, ctx: { state: NonNullable<TState> }) => void;
|
|
85
|
-
onStateChange?: (
|
|
86
|
-
state: NonNullable<TState>,
|
|
87
|
-
ctx: { changes: StatewireChanges },
|
|
88
|
-
) => void;
|
|
89
|
-
onCommandChange?: (
|
|
90
|
-
update: StatewireCommandUpdate<TCommands>,
|
|
91
|
-
ctx: { state: NonNullable<TState> },
|
|
92
|
-
) => void;
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
export type StatewireHelpers<
|
|
96
|
-
TState,
|
|
97
|
-
TCommands extends StatewireCommands = StatewireCommands,
|
|
98
|
-
> = {
|
|
99
|
-
/** The transport's state type — remote transports include `undefined` until the first attach snapshot applies. */
|
|
53
|
+
> {
|
|
100
54
|
state: TState;
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
* fate: "aborted" = never sent, "rejected" = the handler refused,
|
|
107
|
-
* "unknown-command" = no such command, "invalid-params" = the args did not
|
|
108
|
-
* match the command's schema, "lost" = sent but never covered by an ack,
|
|
109
|
-
* "crashed" = acked but the processing died, "result-unavailable" = acked
|
|
110
|
-
* but the result is gone.
|
|
111
|
-
* cause is which channel settled it: "server" = the server's answer;
|
|
112
|
-
* the rest are channel deaths the client settled locally.
|
|
113
|
-
*/
|
|
114
|
-
export type SendFailure =
|
|
115
|
-
| { fate: "rejected"; cause: "server"; message: string; payload?: unknown }
|
|
116
|
-
| { fate: "unknown-command"; cause: "server" }
|
|
117
|
-
| { fate: "invalid-params"; cause: "server"; message: string }
|
|
118
|
-
| { fate: "crashed"; cause: "server" }
|
|
119
|
-
| {
|
|
120
|
-
fate: "result-unavailable";
|
|
121
|
-
cause:
|
|
122
|
-
| "server"
|
|
123
|
-
| "unmount"
|
|
124
|
-
| "stream-gone"
|
|
125
|
-
| "session-lost"
|
|
126
|
-
| "superseded";
|
|
127
|
-
}
|
|
128
|
-
| {
|
|
129
|
-
fate: "aborted";
|
|
130
|
-
cause:
|
|
131
|
-
| "cascade"
|
|
132
|
-
| "unmount"
|
|
133
|
-
| "stream-gone"
|
|
134
|
-
| "session-lost"
|
|
135
|
-
| "superseded";
|
|
136
|
-
}
|
|
137
|
-
| {
|
|
138
|
-
fate: "lost";
|
|
139
|
-
cause: "unmount" | "stream-gone" | "session-lost" | "superseded";
|
|
140
|
-
};
|
|
55
|
+
commands: Statewire.CommandsProxy<TCommands>;
|
|
56
|
+
emit?: (event: TEvent) => void;
|
|
57
|
+
connection?: StatewireClient.Connection;
|
|
58
|
+
source?: Statewire.StateSource;
|
|
59
|
+
}
|
|
141
60
|
|
|
142
|
-
const describe = (failure: SendFailure): string => {
|
|
61
|
+
const describe = (failure: StatewireClient.SendFailure): string => {
|
|
143
62
|
switch (failure.fate) {
|
|
144
63
|
case "rejected":
|
|
145
64
|
return `statewire: command rejected: ${failure.message}`;
|
|
@@ -159,9 +78,9 @@ const describe = (failure: SendFailure): string => {
|
|
|
159
78
|
};
|
|
160
79
|
|
|
161
80
|
export class StatewireSendError extends Error {
|
|
162
|
-
readonly failure: SendFailure;
|
|
81
|
+
readonly failure: StatewireClient.SendFailure;
|
|
163
82
|
|
|
164
|
-
constructor(failure: SendFailure) {
|
|
83
|
+
constructor(failure: StatewireClient.SendFailure) {
|
|
165
84
|
super(describe(failure));
|
|
166
85
|
this.name = "StatewireSendError";
|
|
167
86
|
this.failure = failure;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
type SharedTraps = Pick<
|
|
2
|
+
ProxyHandler<object>,
|
|
3
|
+
| "defineProperty"
|
|
4
|
+
| "setPrototypeOf"
|
|
5
|
+
| "preventExtensions"
|
|
6
|
+
| "ownKeys"
|
|
7
|
+
| "getOwnPropertyDescriptor"
|
|
8
|
+
>;
|
|
9
|
+
|
|
10
|
+
export const sharedDraftTraps = (
|
|
11
|
+
current: () => object,
|
|
12
|
+
context: string,
|
|
13
|
+
): SharedTraps => {
|
|
14
|
+
const unsupported = (op: string): never => {
|
|
15
|
+
throw new Error(`statewire: ${op} is not supported ${context}`);
|
|
16
|
+
};
|
|
17
|
+
return {
|
|
18
|
+
defineProperty: () => unsupported("defineProperty"),
|
|
19
|
+
setPrototypeOf: () => unsupported("setPrototypeOf"),
|
|
20
|
+
preventExtensions: () => unsupported("preventExtensions"),
|
|
21
|
+
ownKeys: () => Reflect.ownKeys(current()),
|
|
22
|
+
getOwnPropertyDescriptor: (_target, prop) => {
|
|
23
|
+
const node = current();
|
|
24
|
+
const desc = Reflect.getOwnPropertyDescriptor(node, prop);
|
|
25
|
+
if (!desc) return undefined;
|
|
26
|
+
return {
|
|
27
|
+
...desc,
|
|
28
|
+
configurable: !Array.isArray(node) || prop !== "length",
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// MessageChannel schedules the flush as a macrotask (tap's pattern): it runs
|
|
2
|
+
// after the whole microtask drain, so every producer in the current turn
|
|
3
|
+
// coalesces. The port is ref'd only while a flush is pending — an active
|
|
4
|
+
// MessagePort holds the Node event loop open. ref/unref are Node-only.
|
|
5
|
+
export const createMacrotaskPoster = (callback: () => void): (() => void) => {
|
|
6
|
+
if (typeof MessageChannel !== "undefined") {
|
|
7
|
+
let port1: (MessagePort & { ref?: () => void; unref?: () => void }) | null =
|
|
8
|
+
null;
|
|
9
|
+
let port2: MessagePort;
|
|
10
|
+
return () => {
|
|
11
|
+
if (!port1) {
|
|
12
|
+
const channel = new MessageChannel();
|
|
13
|
+
channel.port1.onmessage = () => {
|
|
14
|
+
port1?.unref?.();
|
|
15
|
+
callback();
|
|
16
|
+
};
|
|
17
|
+
port1 = channel.port1;
|
|
18
|
+
port2 = channel.port2;
|
|
19
|
+
}
|
|
20
|
+
port1.ref?.();
|
|
21
|
+
port2!.postMessage(null);
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return () => setTimeout(callback, 0);
|
|
25
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const createMethodProxy = <T extends object>(
|
|
2
|
+
dispatch: (method: string, params: unknown[]) => Promise<unknown>,
|
|
3
|
+
): T => {
|
|
4
|
+
const memo = new Map<string, (...params: unknown[]) => Promise<unknown>>();
|
|
5
|
+
return new Proxy({} as T, {
|
|
6
|
+
get: (_target, method) => {
|
|
7
|
+
if (typeof method !== "string") return undefined;
|
|
8
|
+
let fn = memo.get(method);
|
|
9
|
+
if (fn === undefined) {
|
|
10
|
+
fn = (...params: unknown[]) => dispatch(method, params);
|
|
11
|
+
memo.set(method, fn);
|
|
12
|
+
}
|
|
13
|
+
return fn;
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
};
|
|
Binary file
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type StatePath = readonly (string | number)[];
|
|
2
|
+
|
|
3
|
+
export const isUnsafeKey = (
|
|
4
|
+
key: string | number,
|
|
5
|
+
parent: string | number | undefined,
|
|
6
|
+
) => key === "__proto__" || (key === "prototype" && parent === "constructor");
|
|
7
|
+
|
|
8
|
+
export type WalkedPath = {
|
|
9
|
+
/** Container of the final key; undefined when the path is empty. */
|
|
10
|
+
parent: unknown;
|
|
11
|
+
key: string | number;
|
|
12
|
+
leaf: unknown;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// "read": lenient, never fails — missing steps yield an undefined leaf.
|
|
16
|
+
// "parent": every step before the last must resolve; the leaf may not exist.
|
|
17
|
+
// "resolve": every step including the last must resolve.
|
|
18
|
+
// Strict modes require array+integer for number keys, plain object for string
|
|
19
|
+
// keys, and reject unsafe (prototype-polluting) segments.
|
|
20
|
+
export const walkStatePath = (
|
|
21
|
+
root: unknown,
|
|
22
|
+
path: StatePath,
|
|
23
|
+
mode: "read" | "parent" | "resolve",
|
|
24
|
+
): WalkedPath | undefined => {
|
|
25
|
+
let parent: unknown = undefined;
|
|
26
|
+
let key: string | number = "";
|
|
27
|
+
let node: unknown = root;
|
|
28
|
+
for (let depth = 0; depth < path.length; depth++) {
|
|
29
|
+
const k = path[depth]!;
|
|
30
|
+
const mustResolve =
|
|
31
|
+
mode === "resolve" || (mode === "parent" && depth < path.length - 1);
|
|
32
|
+
if (mode !== "read") {
|
|
33
|
+
if (typeof k === "number") {
|
|
34
|
+
if (!Array.isArray(node) || !Number.isInteger(k) || k < 0) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
if (mustResolve && k >= node.length) return undefined;
|
|
38
|
+
} else {
|
|
39
|
+
if (
|
|
40
|
+
isUnsafeKey(k, path[depth - 1]) ||
|
|
41
|
+
typeof node !== "object" ||
|
|
42
|
+
node === null ||
|
|
43
|
+
Array.isArray(node)
|
|
44
|
+
) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
if (mustResolve && !Object.hasOwn(node, k)) return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
parent = node;
|
|
51
|
+
key = k;
|
|
52
|
+
node =
|
|
53
|
+
typeof node === "object" && node !== null && Object.hasOwn(node, k)
|
|
54
|
+
? (node as Record<string | number, unknown>)[k]
|
|
55
|
+
: undefined;
|
|
56
|
+
}
|
|
57
|
+
return { parent, key, leaf: node };
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const readAtPath = (root: unknown, path: StatePath): unknown =>
|
|
61
|
+
walkStatePath(root, path, "read")!.leaf;
|