statewire 0.0.0-0 → 0.0.1
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/LICENSE +21 -0
- package/README.md +83 -1
- package/dist/client/changes.d.ts +7 -0
- package/dist/client/changes.d.ts.map +1 -0
- package/dist/client/changes.js +32 -0
- package/dist/client/changes.js.map +1 -0
- package/dist/client/connection.d.ts +84 -0
- package/dist/client/connection.d.ts.map +1 -0
- package/dist/client/connection.js +204 -0
- package/dist/client/connection.js.map +1 -0
- package/dist/client/core.d.ts +18 -0
- package/dist/client/core.d.ts.map +1 -0
- package/dist/client/core.js +269 -0
- package/dist/client/core.js.map +1 -0
- package/dist/client/optimistic.d.ts +10 -0
- package/dist/client/optimistic.d.ts.map +1 -0
- package/dist/client/optimistic.js +128 -0
- package/dist/client/optimistic.js.map +1 -0
- package/dist/client/session.d.ts +88 -0
- package/dist/client/session.d.ts.map +1 -0
- package/dist/client/session.js +509 -0
- package/dist/client/session.js.map +1 -0
- package/dist/client/sessionStore.d.ts +16 -0
- package/dist/client/sessionStore.d.ts.map +1 -0
- package/dist/client/sessionStore.js +14 -0
- package/dist/client/sessionStore.js.map +1 -0
- package/dist/client/timing.d.ts +10 -0
- package/dist/client/timing.d.ts.map +1 -0
- package/dist/client/timing.js +11 -0
- package/dist/client/timing.js.map +1 -0
- package/dist/host/draft.d.ts +14 -0
- package/dist/host/draft.d.ts.map +1 -0
- package/dist/host/draft.js +124 -0
- package/dist/host/draft.js.map +1 -0
- package/dist/host/engine.d.ts +14 -0
- package/dist/host/engine.d.ts.map +1 -0
- package/dist/host/engine.js +17 -0
- package/dist/host/engine.js.map +1 -0
- package/dist/host/host.d.ts +79 -0
- package/dist/host/host.d.ts.map +1 -0
- package/dist/host/host.js +303 -0
- package/dist/host/host.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/server.d.ts +15 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +3 -0
- package/dist/statewire.d.ts +20 -0
- package/dist/statewire.d.ts.map +1 -0
- package/dist/statewire.js +65 -0
- package/dist/statewire.js.map +1 -0
- package/dist/transports/remote.d.ts +19 -0
- package/dist/transports/remote.d.ts.map +1 -0
- package/dist/transports/remote.js +40 -0
- package/dist/transports/remote.js.map +1 -0
- package/dist/transports/sse-parser.d.ts +5 -0
- package/dist/transports/sse-parser.d.ts.map +1 -0
- package/dist/transports/sse-parser.js +26 -0
- package/dist/transports/sse-parser.js.map +1 -0
- package/dist/transports/sse.d.ts +74 -0
- package/dist/transports/sse.d.ts.map +1 -0
- package/dist/transports/sse.js +136 -0
- package/dist/transports/sse.js.map +1 -0
- package/dist/transports/testing.d.ts +31 -0
- package/dist/transports/testing.d.ts.map +1 -0
- package/dist/transports/testing.js +121 -0
- package/dist/transports/testing.js.map +1 -0
- package/dist/transports/ws.d.ts +18 -0
- package/dist/transports/ws.d.ts.map +1 -0
- package/dist/transports/ws.js +172 -0
- package/dist/transports/ws.js.map +1 -0
- package/dist/types.d.ts +107 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +24 -0
- package/dist/types.js.map +1 -0
- package/dist/wire/meta.d.ts +22 -0
- package/dist/wire/meta.d.ts.map +1 -0
- package/dist/wire/meta.js +73 -0
- package/dist/wire/meta.js.map +1 -0
- package/dist/wire/ops.d.ts +40 -0
- package/dist/wire/ops.d.ts.map +1 -0
- package/dist/wire/ops.js +125 -0
- package/dist/wire/ops.js.map +1 -0
- package/package.json +49 -6
- package/src/client/changes.ts +48 -0
- package/src/client/connection.ts +327 -0
- package/src/client/core.ts +346 -0
- package/src/client/optimistic.ts +0 -0
- package/src/client/session.ts +679 -0
- package/src/client/sessionStore.ts +23 -0
- package/src/client/timing.ts +8 -0
- package/src/host/draft.ts +155 -0
- package/src/host/engine.ts +13 -0
- package/src/host/host.ts +481 -0
- package/src/index.ts +65 -0
- package/src/server.ts +32 -0
- package/src/statewire.ts +107 -0
- package/src/transports/remote.ts +72 -0
- package/src/transports/sse-parser.ts +24 -0
- package/src/transports/sse.ts +280 -0
- package/src/transports/testing.ts +155 -0
- package/src/transports/ws.ts +272 -0
- package/src/types.ts +169 -0
- package/src/wire/meta.ts +106 -0
- package/src/wire/ops.ts +201 -0
- package/index.js +0 -1
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { flushTapSync, resource } from "@assistant-ui/tap";
|
|
2
|
+
import type { ResourceElement } from "@assistant-ui/tap";
|
|
3
|
+
import { encodeParams } from "../wire/meta";
|
|
4
|
+
import { timing } from "../client/timing";
|
|
5
|
+
import {
|
|
6
|
+
createGate,
|
|
7
|
+
handleEnvelope,
|
|
8
|
+
overloadedError,
|
|
9
|
+
reconnectConn,
|
|
10
|
+
} from "../client/connection";
|
|
11
|
+
import type { ConnBase, StatewireEnvelope } from "../client/connection";
|
|
12
|
+
import type { StatewireTransport } from "../types";
|
|
13
|
+
import { useStatewireRemote } from "./remote";
|
|
14
|
+
import type { StatewireSessionStore } from "../client/sessionStore";
|
|
15
|
+
import type { StatewireCommands } from "../types";
|
|
16
|
+
import type {
|
|
17
|
+
RemoteLink,
|
|
18
|
+
StatewireSubmitResult,
|
|
19
|
+
StatewireConnection,
|
|
20
|
+
StatewireTransportProps,
|
|
21
|
+
StatewireWireCommand,
|
|
22
|
+
} from "./sse";
|
|
23
|
+
|
|
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
|
+
type PendingCommand = {
|
|
41
|
+
seq: number;
|
|
42
|
+
resolve: (result: StatewireSubmitResult) => void;
|
|
43
|
+
reject: (error: Error) => void;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type Conn = ConnBase & {
|
|
47
|
+
socket: WebSocket | null;
|
|
48
|
+
pending: PendingCommand[];
|
|
49
|
+
latest: { readonly current: Latest };
|
|
50
|
+
send: RemoteLink["send"];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const OPEN = 1;
|
|
54
|
+
|
|
55
|
+
export const WS_SUBPROTOCOL = "statewire.v1";
|
|
56
|
+
|
|
57
|
+
const createConn = (
|
|
58
|
+
latest: { readonly current: Latest },
|
|
59
|
+
notify: (connection: StatewireConnection) => void,
|
|
60
|
+
): Conn => {
|
|
61
|
+
const c: Conn = {
|
|
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;
|
|
83
|
+
const params = new URLSearchParams(
|
|
84
|
+
typeof query === "function" ? await query() : query,
|
|
85
|
+
);
|
|
86
|
+
params.set("client", c.latest.current.ctx.clientId);
|
|
87
|
+
const base = url.replace(/\/$/, "").replace(/^http/, "ws");
|
|
88
|
+
return `${base}/ws?${params.toString()}`;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const settleOverloaded = (
|
|
92
|
+
c: Conn,
|
|
93
|
+
{ seq, retryAfterMs }: { seq?: number; retryAfterMs?: number },
|
|
94
|
+
) => {
|
|
95
|
+
if (seq === undefined) throw overloadedError(retryAfterMs);
|
|
96
|
+
const entry = c.pending.find((p) => p.seq === seq);
|
|
97
|
+
if (!entry) return;
|
|
98
|
+
c.pending = c.pending.filter((p) => p !== entry);
|
|
99
|
+
entry.reject(overloadedError(retryAfterMs));
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const resolveReceipts = (c: Conn, ack: number) => {
|
|
103
|
+
while (c.pending[0] && c.pending[0].seq <= ack) {
|
|
104
|
+
c.pending.shift()!.resolve({ type: "receipt" });
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const resolveResponded = (c: Conn, seqs: Set<number>) => {
|
|
109
|
+
const remaining: PendingCommand[] = [];
|
|
110
|
+
for (const entry of c.pending) {
|
|
111
|
+
if (seqs.has(entry.seq)) entry.resolve({ type: "receipt" });
|
|
112
|
+
else remaining.push(entry);
|
|
113
|
+
}
|
|
114
|
+
c.pending = remaining;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const failPending = (c: Conn, error: Error) => {
|
|
118
|
+
const pending = c.pending;
|
|
119
|
+
c.pending = [];
|
|
120
|
+
for (const entry of pending) entry.reject(error);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const runSocket = (
|
|
124
|
+
c: Conn,
|
|
125
|
+
ws: WebSocket,
|
|
126
|
+
abort: AbortController,
|
|
127
|
+
): Promise<"stop" | "reattach"> =>
|
|
128
|
+
new Promise((resolve, reject) => {
|
|
129
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
130
|
+
let timedOut = false;
|
|
131
|
+
let outcome: "stop" | "reattach" | null = null;
|
|
132
|
+
let closed = false;
|
|
133
|
+
let attached = false;
|
|
134
|
+
const arm = () => {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
timer = setTimeout(() => {
|
|
137
|
+
timedOut = true;
|
|
138
|
+
ws.close();
|
|
139
|
+
}, timing.heartbeatTimeoutMs);
|
|
140
|
+
};
|
|
141
|
+
arm();
|
|
142
|
+
abort.signal.addEventListener("abort", () => ws.close());
|
|
143
|
+
ws.onerror = () => {};
|
|
144
|
+
const stale = () => Boolean(outcome) || closed || abort.signal.aborted;
|
|
145
|
+
const fail = (error: unknown) => {
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
reject(error instanceof Error ? error : new Error("protocol"));
|
|
148
|
+
ws.close();
|
|
149
|
+
};
|
|
150
|
+
const processEnv = (env: StatewireEnvelope) => {
|
|
151
|
+
const done = flushTapSync(() => handleEnvelope(c, env, attached));
|
|
152
|
+
attached = true;
|
|
153
|
+
if (env.ack !== undefined) resolveReceipts(c, env.ack);
|
|
154
|
+
if (env.res !== undefined) {
|
|
155
|
+
resolveResponded(c, new Set(env.res.map((r) => r.seq)));
|
|
156
|
+
}
|
|
157
|
+
if (done) {
|
|
158
|
+
outcome = done;
|
|
159
|
+
ws.close();
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
ws.onmessage = (event) => {
|
|
163
|
+
if (stale()) return;
|
|
164
|
+
arm();
|
|
165
|
+
let frame: StatewireEnvelope & {
|
|
166
|
+
overloaded?: { seq?: number; retryAfterMs?: number };
|
|
167
|
+
};
|
|
168
|
+
try {
|
|
169
|
+
frame = JSON.parse(String(event.data)) as typeof frame;
|
|
170
|
+
} catch {
|
|
171
|
+
fail(new Error("protocol"));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
if (frame.overloaded !== undefined) {
|
|
176
|
+
settleOverloaded(c, frame.overloaded);
|
|
177
|
+
} else {
|
|
178
|
+
processEnv(frame);
|
|
179
|
+
}
|
|
180
|
+
} catch (error) {
|
|
181
|
+
fail(error);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
ws.onclose = () => {
|
|
185
|
+
closed = true;
|
|
186
|
+
clearTimeout(timer);
|
|
187
|
+
if (c.socket === ws) failPending(c, new Error("network"));
|
|
188
|
+
if (outcome) resolve(outcome);
|
|
189
|
+
else reject(new Error(timedOut ? "timeout" : "network"));
|
|
190
|
+
};
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const attemptSocket = async (
|
|
194
|
+
c: Conn,
|
|
195
|
+
abort: AbortController,
|
|
196
|
+
): Promise<"stop" | "reattach"> => {
|
|
197
|
+
let ws: WebSocket | null = null;
|
|
198
|
+
try {
|
|
199
|
+
const url = await socketUrl(c);
|
|
200
|
+
abort.signal.throwIfAborted();
|
|
201
|
+
const { protocols, webSocket } = c.latest.current.options;
|
|
202
|
+
const WS = webSocket ?? globalThis.WebSocket;
|
|
203
|
+
if (!WS) throw new Error("no WebSocket implementation");
|
|
204
|
+
const extra =
|
|
205
|
+
protocols === undefined
|
|
206
|
+
? []
|
|
207
|
+
: typeof protocols === "string"
|
|
208
|
+
? [protocols]
|
|
209
|
+
: protocols;
|
|
210
|
+
ws = new WS(url, [WS_SUBPROTOCOL, ...extra]);
|
|
211
|
+
c.socket = ws;
|
|
212
|
+
return await runSocket(c, ws, abort);
|
|
213
|
+
} finally {
|
|
214
|
+
if (c.socket === ws) c.socket = null;
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const sendCommand = (
|
|
219
|
+
c: Conn,
|
|
220
|
+
{ seq, command }: { seq: number; command: StatewireWireCommand },
|
|
221
|
+
): Promise<StatewireSubmitResult> => {
|
|
222
|
+
const ws = c.socket;
|
|
223
|
+
if (!ws || ws.readyState !== OPEN) {
|
|
224
|
+
return Promise.reject(new Error("network"));
|
|
225
|
+
}
|
|
226
|
+
return new Promise((resolve, reject) => {
|
|
227
|
+
const entry: PendingCommand = {
|
|
228
|
+
seq,
|
|
229
|
+
resolve: (result) => {
|
|
230
|
+
clearTimeout(timer);
|
|
231
|
+
resolve(result);
|
|
232
|
+
},
|
|
233
|
+
reject: (error) => {
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
reject(error);
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
// A timed-out command fails the socket: the retry then rides reconnect +
|
|
239
|
+
// attach-seq arbitration.
|
|
240
|
+
const timer = setTimeout(() => {
|
|
241
|
+
c.pending = c.pending.filter((p) => p !== entry);
|
|
242
|
+
reject(new Error("timeout"));
|
|
243
|
+
ws.close();
|
|
244
|
+
}, timing.heartbeatTimeoutMs);
|
|
245
|
+
c.pending.push(entry);
|
|
246
|
+
try {
|
|
247
|
+
ws.send(
|
|
248
|
+
JSON.stringify({
|
|
249
|
+
method: command.method,
|
|
250
|
+
...encodeParams(command.params),
|
|
251
|
+
seq,
|
|
252
|
+
}),
|
|
253
|
+
);
|
|
254
|
+
} catch {
|
|
255
|
+
clearTimeout(timer);
|
|
256
|
+
c.pending = c.pending.filter((p) => p !== entry);
|
|
257
|
+
reject(new Error("network"));
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const useStatewireWS = (
|
|
263
|
+
options: StatewireWSOptions,
|
|
264
|
+
): StatewireTransport<any, any, any> => useStatewireRemote(options, createConn);
|
|
265
|
+
|
|
266
|
+
export const StatewireWS = resource(useStatewireWS) as <
|
|
267
|
+
TState = any,
|
|
268
|
+
TCommands extends StatewireCommands = StatewireCommands,
|
|
269
|
+
TEvent = any,
|
|
270
|
+
>(
|
|
271
|
+
options: StatewireWSOptions,
|
|
272
|
+
) => ResourceElement<StatewireTransport<TState | undefined, TCommands, TEvent>>;
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { ResourceElement } from "@assistant-ui/tap";
|
|
2
|
+
import type { StatewireConnection } from "./transports/sse";
|
|
3
|
+
import type { StatewirePath } from "./wire/ops";
|
|
4
|
+
|
|
5
|
+
export type StatewireCommands = Record<string, (...args: never[]) => unknown>;
|
|
6
|
+
|
|
7
|
+
/** Push-based canonical state feed a transport hands to the client core. */
|
|
8
|
+
export type StatewireStateSource = {
|
|
9
|
+
root: () => unknown;
|
|
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
|
+
};
|
|
27
|
+
|
|
28
|
+
export type StatewireCommand<
|
|
29
|
+
TCommands extends StatewireCommands = StatewireCommands,
|
|
30
|
+
> = {
|
|
31
|
+
[K in keyof TCommands]: { method: K; params: Parameters<TCommands[K]> };
|
|
32
|
+
}[keyof TCommands];
|
|
33
|
+
|
|
34
|
+
export type StatewireCommandsProxy<TCommands extends StatewireCommands> = {
|
|
35
|
+
[K in keyof TCommands]: (
|
|
36
|
+
...args: Parameters<TCommands[K]>
|
|
37
|
+
) => Promise<Awaited<ReturnType<TCommands[K]>>>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type StatewireCommandUpdate<
|
|
41
|
+
TCommands extends StatewireCommands = StatewireCommands,
|
|
42
|
+
> = {
|
|
43
|
+
[K in keyof TCommands]: {
|
|
44
|
+
method: K;
|
|
45
|
+
params: Parameters<TCommands[K]>;
|
|
46
|
+
seq: number;
|
|
47
|
+
status: "queued" | "posted" | "acked" | "settled";
|
|
48
|
+
result?: unknown;
|
|
49
|
+
failure?: SendFailure;
|
|
50
|
+
};
|
|
51
|
+
}[keyof TCommands];
|
|
52
|
+
|
|
53
|
+
export type StatewireOptimisticContext = {
|
|
54
|
+
ts: number;
|
|
55
|
+
seq: number;
|
|
56
|
+
clientId?: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Mutate the draft in place; the client records the next state plus the exact
|
|
61
|
+
* changed paths. Re-run against the new base on every rebase.
|
|
62
|
+
*/
|
|
63
|
+
export type StatewireOptimistic<
|
|
64
|
+
TState,
|
|
65
|
+
TCommands extends StatewireCommands = StatewireCommands,
|
|
66
|
+
> = (
|
|
67
|
+
draft: TState,
|
|
68
|
+
command: StatewireCommand<TCommands>,
|
|
69
|
+
ctx: StatewireOptimisticContext,
|
|
70
|
+
) => void;
|
|
71
|
+
|
|
72
|
+
export type StatewireChanges = {
|
|
73
|
+
isChangedAt(path: readonly (string | number)[]): boolean;
|
|
74
|
+
getChangedKeys(path: readonly (string | number)[]): string[];
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type StatewireOptions<
|
|
78
|
+
TState,
|
|
79
|
+
TCommands extends StatewireCommands = StatewireCommands,
|
|
80
|
+
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. */
|
|
100
|
+
state: TState;
|
|
101
|
+
connection: StatewireConnection;
|
|
102
|
+
commands: StatewireCommandsProxy<TCommands>;
|
|
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
|
+
};
|
|
141
|
+
|
|
142
|
+
const describe = (failure: SendFailure): string => {
|
|
143
|
+
switch (failure.fate) {
|
|
144
|
+
case "rejected":
|
|
145
|
+
return `statewire: command rejected: ${failure.message}`;
|
|
146
|
+
case "unknown-command":
|
|
147
|
+
return "statewire: unknown command";
|
|
148
|
+
case "invalid-params":
|
|
149
|
+
return `statewire: invalid params: ${failure.message}`;
|
|
150
|
+
case "aborted":
|
|
151
|
+
return `statewire: command aborted (${failure.cause})`;
|
|
152
|
+
case "lost":
|
|
153
|
+
return `statewire: command lost (${failure.cause})`;
|
|
154
|
+
case "crashed":
|
|
155
|
+
return "statewire: command crashed";
|
|
156
|
+
case "result-unavailable":
|
|
157
|
+
return `statewire: command result unavailable (${failure.cause})`;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export class StatewireSendError extends Error {
|
|
162
|
+
readonly failure: SendFailure;
|
|
163
|
+
|
|
164
|
+
constructor(failure: SendFailure) {
|
|
165
|
+
super(describe(failure));
|
|
166
|
+
this.name = "StatewireSendError";
|
|
167
|
+
this.failure = failure;
|
|
168
|
+
}
|
|
169
|
+
}
|
package/src/wire/meta.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export type StatewireMetaEntry = {
|
|
2
|
+
path: (string | number)[];
|
|
3
|
+
type: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
const fault = (message: string): never => {
|
|
7
|
+
throw new Error(`statewire: protocol error: ${message}`);
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const isPath = (path: unknown): path is (string | number)[] =>
|
|
11
|
+
Array.isArray(path) &&
|
|
12
|
+
path.every(
|
|
13
|
+
(seg) =>
|
|
14
|
+
typeof seg === "string" ||
|
|
15
|
+
(typeof seg === "number" && Number.isInteger(seg)),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
/** Revive meta-typed locations inside a wire value (in place). Unknown types
|
|
19
|
+
* are skipped; a path that does not resolve to a valid target faults. */
|
|
20
|
+
export const reviveValue = (value: unknown, meta: unknown): unknown => {
|
|
21
|
+
if (!Array.isArray(meta)) return fault("meta must be an array");
|
|
22
|
+
for (const entry of meta as { path?: unknown; type?: unknown }[]) {
|
|
23
|
+
const path =
|
|
24
|
+
typeof entry === "object" && entry !== null ? entry.path : null;
|
|
25
|
+
if (!isPath(path)) return fault("meta entry must carry a path array");
|
|
26
|
+
if (entry.type !== "date") continue;
|
|
27
|
+
let parent: unknown = null;
|
|
28
|
+
let key: string | number = "";
|
|
29
|
+
let node = value;
|
|
30
|
+
for (const seg of path) {
|
|
31
|
+
parent = node;
|
|
32
|
+
key = seg;
|
|
33
|
+
if (
|
|
34
|
+
typeof seg === "string" &&
|
|
35
|
+
typeof node === "object" &&
|
|
36
|
+
node !== null &&
|
|
37
|
+
!Array.isArray(node) &&
|
|
38
|
+
Object.hasOwn(node, seg)
|
|
39
|
+
) {
|
|
40
|
+
node = (node as Record<string, unknown>)[seg];
|
|
41
|
+
} else if (
|
|
42
|
+
typeof seg === "number" &&
|
|
43
|
+
Array.isArray(node) &&
|
|
44
|
+
seg >= 0 &&
|
|
45
|
+
seg < node.length
|
|
46
|
+
) {
|
|
47
|
+
node = node[seg];
|
|
48
|
+
} else {
|
|
49
|
+
return fault(`meta path ${path.join(".")} does not resolve`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (typeof node !== "string") {
|
|
53
|
+
return fault(`meta path ${path.join(".")} must name a string`);
|
|
54
|
+
}
|
|
55
|
+
const date = new Date(node);
|
|
56
|
+
if (Number.isNaN(date.getTime())) {
|
|
57
|
+
return fault(`invalid date at meta path ${path.join(".")}`);
|
|
58
|
+
}
|
|
59
|
+
if (parent === null) return date;
|
|
60
|
+
(parent as Record<string | number, unknown>)[key] = date;
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const reviveOps = (ops: unknown[]): unknown[] =>
|
|
66
|
+
ops.map((op) => {
|
|
67
|
+
if (typeof op !== "object" || op === null || !("meta" in op)) return op;
|
|
68
|
+
const { meta, ...rest } = op as { meta: unknown; value?: unknown };
|
|
69
|
+
return { ...rest, value: reviveValue(rest.value, meta) };
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export const reviveResponse = <T extends { payload?: unknown; meta?: unknown }>(
|
|
73
|
+
response: T,
|
|
74
|
+
): T => {
|
|
75
|
+
if (response.meta === undefined) return response;
|
|
76
|
+
const { meta, ...rest } = response;
|
|
77
|
+
return { ...rest, payload: reviveValue(rest.payload, meta) } as T;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Encode Date instances inside command params as canonical ISO strings,
|
|
81
|
+
* recording their paths (starting at the param index) in meta. */
|
|
82
|
+
export const encodeParams = (
|
|
83
|
+
params: readonly unknown[],
|
|
84
|
+
): { params: unknown[]; meta?: StatewireMetaEntry[] } => {
|
|
85
|
+
const meta: StatewireMetaEntry[] = [];
|
|
86
|
+
const walk = (node: unknown, path: (string | number)[]): unknown => {
|
|
87
|
+
if (node instanceof Date) {
|
|
88
|
+
meta.push({ path, type: "date" });
|
|
89
|
+
return node.toISOString();
|
|
90
|
+
}
|
|
91
|
+
if (Array.isArray(node)) {
|
|
92
|
+
return node.map((item, i) => walk(item, [...path, i]));
|
|
93
|
+
}
|
|
94
|
+
if (typeof node === "object" && node !== null) {
|
|
95
|
+
return Object.fromEntries(
|
|
96
|
+
Object.keys(node).map((key) => [
|
|
97
|
+
key,
|
|
98
|
+
walk((node as Record<string, unknown>)[key], [...path, key]),
|
|
99
|
+
]),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return node;
|
|
103
|
+
};
|
|
104
|
+
const encoded = params.map((param, i) => walk(param, [i]));
|
|
105
|
+
return meta.length > 0 ? { params: encoded, meta } : { params: encoded };
|
|
106
|
+
};
|