statewire 0.13.1 → 0.15.0
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 +29 -78
- package/dist/client/connection.d.ts +20 -6
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +120 -52
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts +3 -2
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +10 -4
- package/dist/client/core.js.map +1 -1
- package/dist/client/session.d.ts +2 -0
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +97 -17
- package/dist/client/session.js.map +1 -1
- package/dist/host/adapter.d.ts +1 -1
- package/dist/host/adapter.d.ts.map +1 -1
- package/dist/host/adapter.js +7 -1
- package/dist/host/adapter.js.map +1 -1
- package/dist/host/host.d.ts +1 -0
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js +45 -5
- package/dist/host/host.js.map +1 -1
- package/dist/host/http.d.ts +2 -1
- package/dist/host/http.d.ts.map +1 -1
- package/dist/host/http.js +108 -47
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +33 -8
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +388 -58
- package/dist/host/socket.js.map +1 -1
- package/dist/host/work.d.ts +7 -0
- package/dist/host/work.d.ts.map +1 -0
- package/dist/host/work.js +53 -0
- package/dist/host/work.js.map +1 -0
- package/dist/host-internal.d.ts +42 -4
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/host-internal.js +2 -1
- package/dist/host.d.ts +16 -3
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +2 -1
- package/dist/statewire.d.ts +21 -11
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +2 -2
- package/dist/statewire.js.map +1 -1
- package/dist/transports/sse.d.ts +1 -4
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +47 -56
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/testing.js +2 -2
- package/dist/transports/testing.js.map +1 -1
- package/dist/transports/ws.d.ts +1 -1
- package/dist/transports/ws.d.ts.map +1 -1
- package/dist/transports/ws.js +54 -41
- package/dist/transports/ws.js.map +1 -1
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/wire/documents.d.ts +20 -0
- package/dist/wire/documents.d.ts.map +1 -0
- package/dist/wire/documents.js +74 -0
- package/dist/wire/documents.js.map +1 -0
- package/dist/wire/limits.d.ts +10 -0
- package/dist/wire/limits.d.ts.map +1 -0
- package/dist/wire/limits.js +11 -0
- package/dist/wire/limits.js.map +1 -0
- package/dist/wire/meta.d.ts.map +1 -1
- package/dist/wire/meta.js +1 -1
- package/dist/wire/meta.js.map +1 -1
- package/dist/wire/ops.d.ts.map +1 -1
- package/dist/wire/ops.js +1 -2
- package/dist/wire/ops.js.map +1 -1
- package/dist/wire/version.d.ts +7 -0
- package/dist/wire/version.d.ts.map +1 -0
- package/dist/wire/version.js +8 -0
- package/dist/wire/version.js.map +1 -0
- package/package.json +1 -1
- package/src/client/connection.ts +213 -67
- package/src/client/core.ts +10 -3
- package/src/client/session.ts +106 -30
- package/src/host/adapter.ts +10 -2
- package/src/host/host.ts +54 -4
- package/src/host/http.ts +168 -64
- package/src/host/socket.ts +603 -60
- package/src/host/work.ts +50 -0
- package/src/host-internal.ts +44 -3
- package/src/host.ts +18 -2
- package/src/statewire.ts +33 -10
- package/src/transports/sse.ts +60 -68
- package/src/transports/testing.ts +2 -2
- package/src/transports/ws.ts +29 -48
- package/src/types.ts +1 -0
- package/src/wire/documents.ts +109 -0
- package/src/wire/limits.ts +6 -0
- package/src/wire/meta.ts +6 -1
- package/src/wire/ops.ts +1 -2
- package/src/wire/version.ts +3 -0
package/src/transports/ws.ts
CHANGED
|
@@ -2,7 +2,12 @@ import { useState } from "react";
|
|
|
2
2
|
import { flushTapSync, resource } from "@assistant-ui/tap";
|
|
3
3
|
import type { ResourceElement } from "@assistant-ui/tap";
|
|
4
4
|
import { timing } from "../client/timing";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
attachFrame,
|
|
7
|
+
handleEnvelope,
|
|
8
|
+
unsupportedVersion,
|
|
9
|
+
useLink,
|
|
10
|
+
} from "../client/connection";
|
|
6
11
|
import type {
|
|
7
12
|
AttemptOutcome,
|
|
8
13
|
Conn,
|
|
@@ -12,6 +17,7 @@ import type { Statewire } from "../types";
|
|
|
12
17
|
import type { StatewireClient } from "../statewire";
|
|
13
18
|
import { useStatewireRemote } from "./remote";
|
|
14
19
|
import { CLIENT_QUERY_PARAM } from "../host/adapter";
|
|
20
|
+
import { WS_SUBPROTOCOL } from "../wire/version";
|
|
15
21
|
import type {
|
|
16
22
|
RemoteLink,
|
|
17
23
|
StatewireFrame,
|
|
@@ -21,12 +27,11 @@ import type {
|
|
|
21
27
|
|
|
22
28
|
type WsChannel = {
|
|
23
29
|
socket: WebSocket | null;
|
|
24
|
-
authStale: boolean;
|
|
25
30
|
};
|
|
26
31
|
|
|
27
32
|
const OPEN = 1;
|
|
28
33
|
|
|
29
|
-
export
|
|
34
|
+
export { WS_SUBPROTOCOL };
|
|
30
35
|
|
|
31
36
|
const socketUrl = async (
|
|
32
37
|
options: StatewireClient.WSOptions,
|
|
@@ -37,30 +42,15 @@ const socketUrl = async (
|
|
|
37
42
|
typeof query === "function" ? await query() : query,
|
|
38
43
|
);
|
|
39
44
|
params.set(CLIENT_QUERY_PARAM, clientId);
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
options: StatewireClient.WSOptions,
|
|
46
|
-
stale: boolean,
|
|
47
|
-
lanes: StatewireFrame | null,
|
|
48
|
-
): Promise<string> => {
|
|
49
|
-
const { headers } = options;
|
|
50
|
-
const resolved = new Headers(
|
|
51
|
-
typeof headers === "function"
|
|
52
|
-
? await headers({ staleAuth: stale })
|
|
53
|
-
: headers,
|
|
54
|
-
);
|
|
55
|
-
return JSON.stringify({
|
|
56
|
-
headers: Object.fromEntries(resolved),
|
|
57
|
-
...(lanes ?? {}),
|
|
58
|
-
});
|
|
45
|
+
const resolved = new URL(url, globalThis.location?.href);
|
|
46
|
+
resolved.protocol = resolved.protocol.replace(/^http/, "ws");
|
|
47
|
+
resolved.pathname = `${resolved.pathname.replace(/\/$/, "")}/ws`;
|
|
48
|
+
resolved.search = params.toString();
|
|
49
|
+
return resolved.href;
|
|
59
50
|
};
|
|
60
51
|
|
|
61
52
|
const runSocket = (
|
|
62
53
|
c: Conn,
|
|
63
|
-
w: WsChannel,
|
|
64
54
|
ws: WebSocket,
|
|
65
55
|
frame: string,
|
|
66
56
|
abort: AbortController,
|
|
@@ -80,7 +70,15 @@ const runSocket = (
|
|
|
80
70
|
arm();
|
|
81
71
|
abort.signal.addEventListener("abort", () => ws.close());
|
|
82
72
|
ws.onerror = () => {};
|
|
83
|
-
ws.onopen = () =>
|
|
73
|
+
ws.onopen = () => {
|
|
74
|
+
const negotiated = ws.protocol.trim();
|
|
75
|
+
if (negotiated !== "" && negotiated !== WS_SUBPROTOCOL) {
|
|
76
|
+
outcome = unsupportedVersion(c, negotiated);
|
|
77
|
+
ws.close();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
ws.send(frame);
|
|
81
|
+
};
|
|
84
82
|
const stale = () => Boolean(outcome) || closed || abort.signal.aborted;
|
|
85
83
|
const fail = (error: unknown) => {
|
|
86
84
|
clearTimeout(timer);
|
|
@@ -94,7 +92,7 @@ const runSocket = (
|
|
|
94
92
|
try {
|
|
95
93
|
env = JSON.parse(String(event.data)) as StatewireEnvelope;
|
|
96
94
|
} catch {
|
|
97
|
-
fail(new
|
|
95
|
+
fail(new SyntaxError("invalid JSON packet"));
|
|
98
96
|
return;
|
|
99
97
|
}
|
|
100
98
|
// {} heartbeats live below the packet layer: invisible to handlers.
|
|
@@ -108,7 +106,6 @@ const runSocket = (
|
|
|
108
106
|
try {
|
|
109
107
|
const done = flushTapSync(() => handleEnvelope(c, env));
|
|
110
108
|
if (done) {
|
|
111
|
-
if (env.fin?.reason === "unauthorized") w.authStale = true;
|
|
112
109
|
outcome = done;
|
|
113
110
|
ws.close();
|
|
114
111
|
}
|
|
@@ -133,10 +130,7 @@ const attemptSocket = async (
|
|
|
133
130
|
let ws: WebSocket | null = null;
|
|
134
131
|
try {
|
|
135
132
|
const url = await socketUrl(options, c.ctx.clientId);
|
|
136
|
-
const
|
|
137
|
-
w.authStale = false;
|
|
138
|
-
const lanes = flushTapSync(() => c.ctx.takeAttachLanes());
|
|
139
|
-
const frame = await firstFrame(options, stale, lanes);
|
|
133
|
+
const { body } = await attachFrame(c, options.headers);
|
|
140
134
|
abort.signal.throwIfAborted();
|
|
141
135
|
const { protocols, webSocket } = options;
|
|
142
136
|
const WS = webSocket ?? globalThis.WebSocket;
|
|
@@ -149,7 +143,7 @@ const attemptSocket = async (
|
|
|
149
143
|
: protocols;
|
|
150
144
|
ws = new WS(url, [WS_SUBPROTOCOL, ...extra]);
|
|
151
145
|
w.socket = ws;
|
|
152
|
-
return await runSocket(c,
|
|
146
|
+
return await runSocket(c, ws, body, abort);
|
|
153
147
|
} finally {
|
|
154
148
|
if (w.socket === ws) w.socket = null;
|
|
155
149
|
}
|
|
@@ -175,26 +169,13 @@ const useWsLink = (
|
|
|
175
169
|
options: StatewireClient.WSOptions,
|
|
176
170
|
ctx: StatewireRemoteProps,
|
|
177
171
|
): RemoteLink => {
|
|
178
|
-
const [w] = useState((): WsChannel => ({ socket: null
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
isNew: options.isNew,
|
|
182
|
-
onChange: options.onChange,
|
|
172
|
+
const [w] = useState((): WsChannel => ({ socket: null }));
|
|
173
|
+
return useLink({
|
|
174
|
+
...options,
|
|
183
175
|
ctx,
|
|
184
176
|
attemptOnce: (c, abort) => attemptSocket(c, w, options, abort),
|
|
177
|
+
send: (_, frame) => sendFrame(w, frame),
|
|
185
178
|
});
|
|
186
|
-
const [link] = useState(
|
|
187
|
-
(): RemoteLink => ({
|
|
188
|
-
get connection() {
|
|
189
|
-
return c.connection;
|
|
190
|
-
},
|
|
191
|
-
get parked() {
|
|
192
|
-
return c.parked;
|
|
193
|
-
},
|
|
194
|
-
send: (frame) => sendFrame(w, frame),
|
|
195
|
-
}),
|
|
196
|
-
);
|
|
197
|
-
return link;
|
|
198
179
|
};
|
|
199
180
|
|
|
200
181
|
const useStatewireWS = (
|
package/src/types.ts
CHANGED
|
@@ -57,6 +57,7 @@ export interface Statewire<
|
|
|
57
57
|
durableSeq?: number;
|
|
58
58
|
/** Negation of the server's broadcast idle level; absent means always busy. */
|
|
59
59
|
isBusy?: boolean;
|
|
60
|
+
work?: { readonly isIdle: boolean };
|
|
60
61
|
/** Replace this client's ambient context on the server; it rides the ctx lane (no verdict). */
|
|
61
62
|
setContext?: (value: Record<string, unknown>) => void;
|
|
62
63
|
/** The retained failed-send lane; present on transports with a durable session. */
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { applyOps } from "./ops";
|
|
2
|
+
import { reviveValue } from "./meta";
|
|
3
|
+
import type { StatewirePath } from "./ops";
|
|
4
|
+
|
|
5
|
+
export namespace StatewireDocuments {
|
|
6
|
+
export type Document = { key: readonly string[] | null; value: unknown };
|
|
7
|
+
export type State = {
|
|
8
|
+
mode: "mounted" | "keyless" | null;
|
|
9
|
+
documents: ReadonlyMap<number, Document>;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const invalid: (message: string) => never = (message) => {
|
|
14
|
+
throw new Error(`statewire: protocol error: ${message}`);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const applyDocumentOps = (
|
|
18
|
+
previous: StatewireDocuments.State,
|
|
19
|
+
ops: readonly unknown[],
|
|
20
|
+
) => {
|
|
21
|
+
let mode = previous.mode;
|
|
22
|
+
const documents = new Map(previous.documents);
|
|
23
|
+
const changedPaths: StatewirePath[] = [];
|
|
24
|
+
for (const raw of ops) {
|
|
25
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
26
|
+
invalid("operation must be an object");
|
|
27
|
+
}
|
|
28
|
+
const op = raw as Record<string, unknown>;
|
|
29
|
+
const doc = op.doc === undefined ? 0 : op.doc;
|
|
30
|
+
if (typeof doc !== "number" || !Number.isSafeInteger(doc) || doc < 0) {
|
|
31
|
+
invalid("doc must be a nonnegative safe integer");
|
|
32
|
+
}
|
|
33
|
+
if (
|
|
34
|
+
!["mount", "unmount", "add", "replace", "remove"].includes(
|
|
35
|
+
op.op as string,
|
|
36
|
+
)
|
|
37
|
+
) {
|
|
38
|
+
const { doc: _doc, ...unknown } = op;
|
|
39
|
+
applyOps(undefined, [unknown]);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (mode === null) {
|
|
43
|
+
if (op.op === "mount") mode = "mounted";
|
|
44
|
+
else if (
|
|
45
|
+
op.op === "replace" &&
|
|
46
|
+
doc === 0 &&
|
|
47
|
+
Array.isArray(op.path) &&
|
|
48
|
+
op.path.length === 0 &&
|
|
49
|
+
"value" in op
|
|
50
|
+
) {
|
|
51
|
+
mode = "keyless";
|
|
52
|
+
documents.set(0, { key: null, value: undefined });
|
|
53
|
+
} else
|
|
54
|
+
invalid(
|
|
55
|
+
"first operation must mount a document or replace document 0 at the root",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (
|
|
59
|
+
mode === "keyless" &&
|
|
60
|
+
(doc !== 0 || op.op === "mount" || op.op === "unmount")
|
|
61
|
+
) {
|
|
62
|
+
invalid("keyless mode only supports mutations of document 0");
|
|
63
|
+
}
|
|
64
|
+
if (op.op === "mount") {
|
|
65
|
+
if (documents.has(doc)) invalid("document is already mounted");
|
|
66
|
+
if (
|
|
67
|
+
!Array.isArray(op.key) ||
|
|
68
|
+
!op.key.every((key) => typeof key === "string") ||
|
|
69
|
+
!("value" in op)
|
|
70
|
+
) {
|
|
71
|
+
invalid("mount requires a key array and value");
|
|
72
|
+
}
|
|
73
|
+
documents.set(doc, {
|
|
74
|
+
key: op.key,
|
|
75
|
+
value:
|
|
76
|
+
op.meta === undefined ? op.value : reviveValue(op.value, op.meta),
|
|
77
|
+
});
|
|
78
|
+
if (doc === 0) changedPaths.push([]);
|
|
79
|
+
} else if (op.op === "unmount") {
|
|
80
|
+
if (!documents.delete(doc)) invalid("document is not mounted");
|
|
81
|
+
if (doc === 0) changedPaths.push([]);
|
|
82
|
+
} else if (op.op === "add" || op.op === "replace" || op.op === "remove") {
|
|
83
|
+
const document = documents.get(doc);
|
|
84
|
+
if (!document) invalid("document is not mounted");
|
|
85
|
+
if (
|
|
86
|
+
!Array.isArray(op.path) ||
|
|
87
|
+
!op.path.every(
|
|
88
|
+
(part) =>
|
|
89
|
+
typeof part === "string" ||
|
|
90
|
+
(typeof part === "number" &&
|
|
91
|
+
Number.isSafeInteger(part) &&
|
|
92
|
+
part >= 0),
|
|
93
|
+
)
|
|
94
|
+
) {
|
|
95
|
+
invalid("operation requires a valid path");
|
|
96
|
+
}
|
|
97
|
+
if (op.op !== "remove" && !("value" in op))
|
|
98
|
+
invalid("operation requires a value");
|
|
99
|
+
const { doc: _doc, meta, ...mutation } = op;
|
|
100
|
+
if (meta !== undefined)
|
|
101
|
+
mutation.value = reviveValue(mutation.value, meta);
|
|
102
|
+
const applied = applyOps(document.value, [mutation]);
|
|
103
|
+
documents.set(doc, { ...document, value: applied.state });
|
|
104
|
+
if (doc === 0) changedPaths.push(...applied.changedPaths);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (mode === null) invalid("hello missing snapshot");
|
|
108
|
+
return { mode, documents, changedPaths };
|
|
109
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const MAX_COMMANDS_PER_FRAME = 256;
|
|
2
|
+
export const MAX_INFLIGHT_COMMANDS_PER_CLIENT = 256;
|
|
3
|
+
export const MAX_FRAME_BODY_BYTES = 8 * 1024 * 1024;
|
|
4
|
+
export const MAX_CONTEXT_VALUE_BYTES = 64 * 1024;
|
|
5
|
+
export const MAX_INFLIGHT_COMMANDS_PER_HOST = 1024;
|
|
6
|
+
export const COMMAND_CAPACITY_RETRY_MS = 1_000;
|
package/src/wire/meta.ts
CHANGED
|
@@ -42,7 +42,12 @@ export const reviveValue = (
|
|
|
42
42
|
return fault(`meta path ${path.join(".")} must name a string`);
|
|
43
43
|
}
|
|
44
44
|
const date = new Date(walked.leaf);
|
|
45
|
-
if (
|
|
45
|
+
if (
|
|
46
|
+
!/(?:T| )\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})$/i.test(
|
|
47
|
+
walked.leaf,
|
|
48
|
+
) ||
|
|
49
|
+
Number.isNaN(date.getTime())
|
|
50
|
+
) {
|
|
46
51
|
return fault(`invalid date at meta path ${path.join(".")}`);
|
|
47
52
|
}
|
|
48
53
|
if (walked.parent === undefined) return date;
|
package/src/wire/ops.ts
CHANGED
|
@@ -161,8 +161,7 @@ const applyOpToDraft = (box: { state: unknown }, op: Statewire.Op): void => {
|
|
|
161
161
|
return;
|
|
162
162
|
}
|
|
163
163
|
if (op.path.length === 0) {
|
|
164
|
-
|
|
165
|
-
box.state = sanitizeValue(op.value);
|
|
164
|
+
box.state = op.op === "remove" ? undefined : sanitizeValue(op.value);
|
|
166
165
|
return;
|
|
167
166
|
}
|
|
168
167
|
const walked = walkStatePath(box.state, op.path, "parent");
|