statewire 0.10.0 → 0.12.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 +2 -2
- package/dist/client/connection.d.ts +6 -4
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +10 -2
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts +1 -1
- package/dist/client/session.d.ts +10 -2
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +246 -97
- package/dist/client/session.js.map +1 -1
- package/dist/host/host.d.ts +1 -1
- package/dist/host/http.js +4 -3
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +6 -5
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +12 -20
- package/dist/host/socket.js.map +1 -1
- package/dist/host-internal.d.ts +2 -1
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/host.d.ts +2 -2
- package/dist/host.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/statewire.d.ts +29 -3
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js.map +1 -1
- package/dist/transports/fake-sse.js +4 -4
- package/dist/transports/fake-sse.js.map +1 -1
- package/dist/transports/fixtures.d.ts +1 -1
- package/dist/transports/remote.d.ts +1 -1
- package/dist/transports/remote.js +1 -0
- package/dist/transports/remote.js.map +1 -1
- package/dist/transports/sse.d.ts +2 -2
- package/dist/transports/testing.d.ts +1 -1
- package/dist/transports/testing.js +2 -4
- package/dist/transports/testing.js.map +1 -1
- package/dist/wire/meta.d.ts +5 -3
- package/dist/wire/meta.d.ts.map +1 -1
- package/dist/wire/meta.js +7 -4
- package/dist/wire/meta.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +12 -6
- package/src/client/session.ts +332 -114
- package/src/host/http.ts +10 -9
- package/src/host/socket.ts +18 -28
- package/src/host-internal.ts +2 -1
- package/src/host.ts +0 -1
- package/src/statewire.ts +33 -3
- package/src/transports/fake-sse.ts +6 -6
- package/src/transports/remote.ts +1 -0
- package/src/transports/testing.ts +2 -7
- package/src/wire/meta.ts +12 -4
package/src/host/http.ts
CHANGED
|
@@ -166,7 +166,7 @@ const getStream = async (
|
|
|
166
166
|
const attachment = attachSse(h, client.clientId);
|
|
167
167
|
const lease = attachment.registration === null ? null : mintLease();
|
|
168
168
|
if (attachment.registration !== null)
|
|
169
|
-
attachment.registration.
|
|
169
|
+
attachment.registration.conn.lease = lease;
|
|
170
170
|
return streamResponse(attachment, lease);
|
|
171
171
|
};
|
|
172
172
|
|
|
@@ -202,7 +202,7 @@ const postStream = async (
|
|
|
202
202
|
const attachment = attachSse(h, client.clientId);
|
|
203
203
|
if (attachment.registration === null) return streamResponse(attachment, null);
|
|
204
204
|
const lease = mintLease();
|
|
205
|
-
attachment.registration.
|
|
205
|
+
attachment.registration.conn.lease = lease;
|
|
206
206
|
admitAttachFrame(h, attachment.registration, frame);
|
|
207
207
|
return streamResponse(attachment, lease);
|
|
208
208
|
};
|
|
@@ -254,13 +254,14 @@ export const frames = async (
|
|
|
254
254
|
if ("response" in client) return client.response;
|
|
255
255
|
const rec = h.clients.get(client.clientId);
|
|
256
256
|
const lease = request.headers.get(LEASE_HEADER);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
257
|
+
// A lease lives and dies with its attach: any live attach of the id accepts.
|
|
258
|
+
const live =
|
|
259
|
+
lease !== null &&
|
|
260
|
+
[...h.conns].some(
|
|
261
|
+
(conn) =>
|
|
262
|
+
conn.open && conn.clientId === client.clientId && conn.lease === lease,
|
|
263
|
+
);
|
|
264
|
+
if (h.disposed || h.finished !== null || rec === undefined || !live) {
|
|
264
265
|
return Response.json({ error: "stale-lease" }, { status: 423 });
|
|
265
266
|
}
|
|
266
267
|
let frame: ParsedFrame;
|
package/src/host/socket.ts
CHANGED
|
@@ -38,7 +38,6 @@ export type ClientRec = {
|
|
|
38
38
|
id: string;
|
|
39
39
|
lastSeq: number;
|
|
40
40
|
ctxSeq: number;
|
|
41
|
-
lease: string | null;
|
|
42
41
|
touched: number;
|
|
43
42
|
context: Record<string, unknown> | null;
|
|
44
43
|
results: Map<number, { ts: number; cmd: StatewireCmd }>;
|
|
@@ -51,6 +50,7 @@ export type Conn = {
|
|
|
51
50
|
open: boolean;
|
|
52
51
|
sawFrame: boolean;
|
|
53
52
|
lastSentAt: number;
|
|
53
|
+
lease: string | null;
|
|
54
54
|
};
|
|
55
55
|
|
|
56
56
|
// ─── frame parsing ──────────────────────────────────────
|
|
@@ -115,7 +115,7 @@ const contextValue = (ops: unknown): Record<string, unknown> => {
|
|
|
115
115
|
}
|
|
116
116
|
const value =
|
|
117
117
|
op["meta"] !== undefined
|
|
118
|
-
? reviveValue(op["value"], op["meta"])
|
|
118
|
+
? reviveValue(op["value"], op["meta"], { rejectUnknown: true })
|
|
119
119
|
: op["value"];
|
|
120
120
|
if (!isPlainObject(value)) {
|
|
121
121
|
throw new Error("context value must be an object");
|
|
@@ -128,13 +128,13 @@ export const parseFrameValue = (
|
|
|
128
128
|
first: boolean,
|
|
129
129
|
): ParsedFrame => {
|
|
130
130
|
if (!isPlainObject(frame)) return violation("malformed frame");
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
131
|
+
const keys = Object.keys(frame);
|
|
132
|
+
if (keys.length === 0) return violation("malformed frame");
|
|
133
|
+
const unknown = keys
|
|
134
|
+
.filter((key) => key !== "headers" && key !== "ctx" && key !== "cmd")
|
|
135
|
+
.sort();
|
|
136
|
+
if (unknown.length > 0)
|
|
137
|
+
return violation(`unknown frame key: '${unknown[0]}'`);
|
|
138
138
|
let headers: Record<string, string> | null = null;
|
|
139
139
|
if (frame["headers"] !== undefined) {
|
|
140
140
|
if (!first) return violation("mid-session headers");
|
|
@@ -267,7 +267,7 @@ export type SocketHostState = {
|
|
|
267
267
|
connect: (
|
|
268
268
|
clientId: string,
|
|
269
269
|
socket: StatewireHostInternal.Socket,
|
|
270
|
-
) => StatewireHostInternal.SocketConnection;
|
|
270
|
+
) => StatewireHostInternal.SocketConnection | null;
|
|
271
271
|
finish: (fin: StatewireHost.Fin) => void;
|
|
272
272
|
stream: (request: Request) => Promise<Response>;
|
|
273
273
|
frames: (request: Request) => Promise<Response>;
|
|
@@ -563,7 +563,9 @@ const admitCmd = (
|
|
|
563
563
|
let params = statement.params;
|
|
564
564
|
if (statement.meta !== undefined) {
|
|
565
565
|
try {
|
|
566
|
-
params = reviveValue(params, statement.meta
|
|
566
|
+
params = reviveValue(params, statement.meta, {
|
|
567
|
+
rejectUnknown: true,
|
|
568
|
+
}) as unknown[];
|
|
567
569
|
} catch (error) {
|
|
568
570
|
rejectStatement(
|
|
569
571
|
h,
|
|
@@ -717,7 +719,6 @@ export const register = (
|
|
|
717
719
|
id: clientId,
|
|
718
720
|
lastSeq: 0,
|
|
719
721
|
ctxSeq: 0,
|
|
720
|
-
lease: null,
|
|
721
722
|
touched: Date.now(),
|
|
722
723
|
context: null,
|
|
723
724
|
results: new Map(),
|
|
@@ -727,18 +728,13 @@ export const register = (
|
|
|
727
728
|
return created;
|
|
728
729
|
})();
|
|
729
730
|
rec.touched = Date.now();
|
|
730
|
-
rec.lease = null;
|
|
731
|
-
for (const other of [...h.conns]) {
|
|
732
|
-
if (other.clientId !== clientId) continue;
|
|
733
|
-
sendTo(h, other, JSON.stringify({ fin: { reason: "superseded" } }));
|
|
734
|
-
closeConn(h, other);
|
|
735
|
-
}
|
|
736
731
|
const conn: Conn = {
|
|
737
732
|
clientId,
|
|
738
733
|
socket,
|
|
739
734
|
open: true,
|
|
740
735
|
sawFrame: false,
|
|
741
736
|
lastSentAt: 0,
|
|
737
|
+
lease: null,
|
|
742
738
|
};
|
|
743
739
|
h.conns.add(conn);
|
|
744
740
|
gcClients(h);
|
|
@@ -756,7 +752,6 @@ export const register = (
|
|
|
756
752
|
syn: {
|
|
757
753
|
seq: admittedThrough(rec),
|
|
758
754
|
ctxSeq: rec.ctxSeq,
|
|
759
|
-
record: known ? "resumed" : "fresh",
|
|
760
755
|
...(h.idleWire && { idle: true }),
|
|
761
756
|
},
|
|
762
757
|
};
|
|
@@ -773,15 +768,9 @@ const connect = (
|
|
|
773
768
|
h: SocketHostState,
|
|
774
769
|
clientId: string,
|
|
775
770
|
socket: StatewireHostInternal.Socket,
|
|
776
|
-
): StatewireHostInternal.SocketConnection => {
|
|
771
|
+
): StatewireHostInternal.SocketConnection | null => {
|
|
777
772
|
const registration = register(h, clientId, socket);
|
|
778
|
-
return
|
|
779
|
-
registration?.connection ?? {
|
|
780
|
-
clientId,
|
|
781
|
-
message: () => {},
|
|
782
|
-
close: () => {},
|
|
783
|
-
}
|
|
784
|
-
);
|
|
773
|
+
return registration?.connection ?? null;
|
|
785
774
|
};
|
|
786
775
|
|
|
787
776
|
const finish = (h: SocketHostState, fin: StatewireHost.Fin) => {
|
|
@@ -862,7 +851,8 @@ const useSocketHost = (
|
|
|
862
851
|
* feed it sockets (anything with `send`/`close`) and raw text frames, and it
|
|
863
852
|
* speaks the Statewire packet protocol — snapshot-first hello with `syn`,
|
|
864
853
|
* statement-lane client frames under one admission law, `{cmd}` answer
|
|
865
|
-
* arrays
|
|
854
|
+
* arrays fanned out to every attach of a client id, idle edges, and `fin` on
|
|
855
|
+
* finish.
|
|
866
856
|
*/
|
|
867
857
|
export const StatewireSocketHost = (
|
|
868
858
|
transport: ResourceElement<Statewire>,
|
package/src/host-internal.ts
CHANGED
|
@@ -15,7 +15,8 @@ export namespace StatewireHostInternal {
|
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
export type SocketHost = {
|
|
18
|
-
|
|
18
|
+
/** Null means refused: the socket was told (fin and/or close) and is dead. */
|
|
19
|
+
connect: (clientId: string, socket: Socket) => SocketConnection | null;
|
|
19
20
|
stream: (request: Request) => Promise<Response>;
|
|
20
21
|
frames: (request: Request) => Promise<Response>;
|
|
21
22
|
finish: (fin: StatewireHost.Fin) => void;
|
package/src/host.ts
CHANGED
package/src/statewire.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import type { ResourceElement } from "@assistant-ui/tap";
|
|
12
12
|
import { LIVE_CONNECTION, useStatewireCore } from "./client/core";
|
|
13
13
|
import type { Statewire } from "./types";
|
|
14
|
+
import type { TERMINAL } from "./client/connection";
|
|
14
15
|
import { SessionStorageContext } from "./client/sessionStore";
|
|
15
16
|
|
|
16
17
|
const useSessionTransport = ({
|
|
@@ -218,7 +219,7 @@ export namespace StatewireClient {
|
|
|
218
219
|
* fate: "aborted" = never sent, "rejected" = the server refused it
|
|
219
220
|
* (`code` "unknown-command" | "invalid-params" for the static rejects,
|
|
220
221
|
* code-less for handler rejects), "lost" = sent but no verdict will ever
|
|
221
|
-
* arrive — the record is gone (
|
|
222
|
+
* arrive — the record is gone (a proven seq gap) or the channel died: it
|
|
222
223
|
* may or may not have run, "failed" = the handler ran and threw,
|
|
223
224
|
* "crashed" = the handler died involuntarily, "expired" = settled —
|
|
224
225
|
* result, rejected, failed, or crashed — but the verdict outlived the
|
|
@@ -239,14 +240,25 @@ export namespace StatewireClient {
|
|
|
239
240
|
| { fate: "expired"; cause: "server" }
|
|
240
241
|
| {
|
|
241
242
|
fate: "aborted";
|
|
242
|
-
cause: "unmount" | "stream-gone" | "session-lost"
|
|
243
|
+
cause: "unmount" | "stream-gone" | "session-lost";
|
|
243
244
|
predecessorLost?: true;
|
|
244
245
|
}
|
|
245
246
|
| {
|
|
246
247
|
fate: "lost";
|
|
247
|
-
cause: "unmount" | "stream-gone" | "session-lost"
|
|
248
|
+
cause: "unmount" | "stream-gone" | "session-lost";
|
|
248
249
|
};
|
|
249
250
|
|
|
251
|
+
/** Held commands a proven record gap settled `{fate: "lost", cause: "session-lost"}`; return "requeue" to re-enqueue them on the reset clocks. */
|
|
252
|
+
export type OnSessionLost = (
|
|
253
|
+
pending: ReadonlyArray<{
|
|
254
|
+
method: string;
|
|
255
|
+
params: readonly unknown[];
|
|
256
|
+
ts: number;
|
|
257
|
+
seq: number;
|
|
258
|
+
clientId?: string;
|
|
259
|
+
}>,
|
|
260
|
+
) => "requeue" | "drop";
|
|
261
|
+
|
|
250
262
|
/** A command as persisted while un-durable: its lane seq plus the wire statement. */
|
|
251
263
|
export type SessionCommand = {
|
|
252
264
|
seq: number;
|
|
@@ -261,12 +273,28 @@ export namespace StatewireClient {
|
|
|
261
273
|
nextSeq: number;
|
|
262
274
|
ctxNextSeq: number;
|
|
263
275
|
commands: readonly SessionCommand[];
|
|
276
|
+
/** Terminal verdicts parked for the sharer that minted the seq (shared storage only). */
|
|
277
|
+
verdicts?: readonly SessionVerdict[];
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/** A terminal verdict one sharer's connection carried, parked for the sharer awaiting the seq. */
|
|
281
|
+
export type SessionVerdict = {
|
|
282
|
+
seq: number;
|
|
283
|
+
type: (typeof TERMINAL)[number];
|
|
284
|
+
code?: string;
|
|
285
|
+
message?: string;
|
|
286
|
+
payload?: unknown;
|
|
287
|
+
meta?: unknown;
|
|
264
288
|
};
|
|
265
289
|
|
|
266
290
|
/** Session storage seam; `save` must persist synchronously in the caller's flow. */
|
|
267
291
|
export type SessionStorage = {
|
|
268
292
|
load: () => SessionRecord | null;
|
|
269
293
|
save: (record: SessionRecord) => void;
|
|
294
|
+
/** Serializes record mutations across sharers; presence marks the storage shared. */
|
|
295
|
+
lock?: <T>(fn: () => T | Promise<T>) => Promise<T>;
|
|
296
|
+
/** Notifies of record saves by other sharers; returns unsubscribe. Required alongside `lock`. */
|
|
297
|
+
subscribe?: (listener: () => void) => () => void;
|
|
270
298
|
};
|
|
271
299
|
|
|
272
300
|
/** Static headers, or a resolver invoked per request/attach; `staleAuth` is true right after an auth rejection. */
|
|
@@ -279,6 +307,7 @@ export namespace StatewireClient {
|
|
|
279
307
|
url: string;
|
|
280
308
|
headers?: HeadersOption;
|
|
281
309
|
fetch?: typeof globalThis.fetch;
|
|
310
|
+
onSessionLost?: OnSessionLost;
|
|
282
311
|
onChange?: (connection: Connection) => void;
|
|
283
312
|
/** The broadcast idle flag's rising edge: the instance went quiet. */
|
|
284
313
|
onDone?: () => void;
|
|
@@ -293,6 +322,7 @@ export namespace StatewireClient {
|
|
|
293
322
|
| Record<string, string>
|
|
294
323
|
| (() => Record<string, string> | Promise<Record<string, string>>);
|
|
295
324
|
webSocket?: new (url: string, protocols?: string | string[]) => WebSocket;
|
|
325
|
+
onSessionLost?: OnSessionLost;
|
|
296
326
|
onChange?: (connection: Connection) => void;
|
|
297
327
|
/** The broadcast idle flag's rising edge: the instance went quiet. */
|
|
298
328
|
onDone?: () => void;
|
|
@@ -30,7 +30,11 @@ export const openSSEStream = (
|
|
|
30
30
|
if (!open) return;
|
|
31
31
|
open = false;
|
|
32
32
|
streams.delete(handle);
|
|
33
|
-
|
|
33
|
+
try {
|
|
34
|
+
controller.close();
|
|
35
|
+
} catch {
|
|
36
|
+
// the reader already canceled
|
|
37
|
+
}
|
|
34
38
|
},
|
|
35
39
|
};
|
|
36
40
|
streams.add(handle);
|
|
@@ -40,11 +44,7 @@ export const openSSEStream = (
|
|
|
40
44
|
streams.delete(handle);
|
|
41
45
|
},
|
|
42
46
|
});
|
|
43
|
-
signal?.addEventListener("abort", () =>
|
|
44
|
-
for (const stream of [...streams]) {
|
|
45
|
-
if (stream.clientId === clientId) stream.close();
|
|
46
|
-
}
|
|
47
|
-
});
|
|
47
|
+
signal?.addEventListener("abort", () => handle.close());
|
|
48
48
|
return new Response(body, {
|
|
49
49
|
status: 200,
|
|
50
50
|
headers: { "content-type": "text/event-stream", ...headers },
|
package/src/transports/remote.ts
CHANGED
|
@@ -77,8 +77,7 @@ export const createMockTransport = <TState>(
|
|
|
77
77
|
const id = headers.get("Statewire-Client-Id") ?? "";
|
|
78
78
|
if (url.endsWith("/stream")) {
|
|
79
79
|
clientId = id;
|
|
80
|
-
|
|
81
|
-
if (!known) clients.set(id, { seq: 0, ctxSeq: 0 });
|
|
80
|
+
if (!clients.has(id)) clients.set(id, { seq: 0, ctxSeq: 0 });
|
|
82
81
|
const rec = clients.get(id)!;
|
|
83
82
|
const frame = JSON.parse(String(init?.body)) as StatewireFrame;
|
|
84
83
|
admit(id, frame);
|
|
@@ -89,11 +88,7 @@ export const createMockTransport = <TState>(
|
|
|
89
88
|
handle.send(
|
|
90
89
|
sseFrame({
|
|
91
90
|
ops: [{ op: "replace", path: [], value: state }],
|
|
92
|
-
syn: {
|
|
93
|
-
seq: rec.seq,
|
|
94
|
-
ctxSeq: rec.ctxSeq,
|
|
95
|
-
record: known ? "resumed" : "fresh",
|
|
96
|
-
},
|
|
91
|
+
syn: { seq: rec.seq, ctxSeq: rec.ctxSeq },
|
|
97
92
|
}),
|
|
98
93
|
);
|
|
99
94
|
},
|
package/src/wire/meta.ts
CHANGED
|
@@ -17,15 +17,23 @@ const isPath = (path: unknown): path is (string | number)[] =>
|
|
|
17
17
|
(typeof seg === "number" && Number.isInteger(seg)),
|
|
18
18
|
);
|
|
19
19
|
|
|
20
|
-
/** Revive meta-typed locations inside a wire value (in place).
|
|
21
|
-
*
|
|
22
|
-
export const reviveValue = (
|
|
20
|
+
/** Revive meta-typed locations inside a wire value (in place). Clients skip
|
|
21
|
+
* unknown types; hosts pass `rejectUnknown` to fault on them instead. */
|
|
22
|
+
export const reviveValue = (
|
|
23
|
+
value: unknown,
|
|
24
|
+
meta: unknown,
|
|
25
|
+
{ rejectUnknown = false } = {},
|
|
26
|
+
): unknown => {
|
|
23
27
|
if (!Array.isArray(meta)) return fault("meta must be an array");
|
|
24
28
|
for (const entry of meta as { path?: unknown; type?: unknown }[]) {
|
|
25
29
|
const path =
|
|
26
30
|
typeof entry === "object" && entry !== null ? entry.path : null;
|
|
27
31
|
if (!isPath(path)) return fault("meta entry must carry a path array");
|
|
28
|
-
if (entry.type !== "date")
|
|
32
|
+
if (entry.type !== "date") {
|
|
33
|
+
if (rejectUnknown)
|
|
34
|
+
return fault(`unknown meta type: ${String(entry.type)}`);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
29
37
|
const walked = walkStatePath(value, path, "resolve");
|
|
30
38
|
if (walked === undefined) {
|
|
31
39
|
return fault(`meta path ${path.join(".")} does not resolve`);
|