statewire 0.0.2 → 0.0.4
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 +1 -1
- package/dist/client/session.d.ts +7 -4
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +15 -8
- package/dist/client/session.js.map +1 -1
- package/dist/host/host.d.ts +3 -2
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js +5 -1
- package/dist/host/host.js.map +1 -1
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +58 -6
- package/dist/host/socket.js.map +1 -1
- package/dist/host.d.ts +11 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/statewire.d.ts +19 -1
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +10 -2
- package/dist/statewire.js.map +1 -1
- package/dist/transports/fixtures.d.ts +1 -1
- package/dist/transports/remote.d.ts +1 -1
- package/dist/transports/remote.d.ts.map +1 -1
- package/dist/transports/remote.js +3 -2
- package/dist/transports/remote.js.map +1 -1
- package/dist/transports/sse.d.ts +26 -19
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +15 -5
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/testing.d.ts +5 -4
- package/dist/transports/testing.d.ts.map +1 -1
- package/dist/transports/testing.js.map +1 -1
- package/dist/transports/ws.d.ts.map +1 -1
- package/dist/transports/ws.js +2 -3
- 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/session.ts +43 -21
- package/src/host/host.ts +15 -4
- package/src/host/socket.ts +85 -9
- package/src/host.ts +11 -0
- package/src/statewire.ts +27 -2
- package/src/transports/remote.ts +6 -1
- package/src/transports/sse.ts +20 -20
- package/src/transports/testing.ts +6 -4
- package/src/transports/ws.ts +2 -8
- package/src/types.ts +2 -0
package/src/host/socket.ts
CHANGED
|
@@ -40,6 +40,7 @@ type Admission = {
|
|
|
40
40
|
type ClientRec = {
|
|
41
41
|
id: string;
|
|
42
42
|
lastSeq: number;
|
|
43
|
+
context: Record<string, unknown> | null;
|
|
43
44
|
results: Map<number, { ts: number; res: StatewireResponse }>;
|
|
44
45
|
inflight: Map<number, Admission>;
|
|
45
46
|
};
|
|
@@ -51,7 +52,12 @@ type Conn = {
|
|
|
51
52
|
lastSentAt: number;
|
|
52
53
|
};
|
|
53
54
|
|
|
54
|
-
type Frame = {
|
|
55
|
+
type Frame = {
|
|
56
|
+
method?: string;
|
|
57
|
+
seq: number;
|
|
58
|
+
params: unknown[];
|
|
59
|
+
context?: unknown;
|
|
60
|
+
};
|
|
55
61
|
|
|
56
62
|
const parseFrame = (raw: string): Frame | null => {
|
|
57
63
|
let frame: unknown;
|
|
@@ -63,9 +69,16 @@ const parseFrame = (raw: string): Frame | null => {
|
|
|
63
69
|
if (typeof frame !== "object" || frame === null || Array.isArray(frame)) {
|
|
64
70
|
return null;
|
|
65
71
|
}
|
|
66
|
-
const { method, seq, params, meta } = frame as Record<
|
|
67
|
-
|
|
72
|
+
const { method, seq, params, meta, context } = frame as Record<
|
|
73
|
+
string,
|
|
74
|
+
unknown
|
|
75
|
+
>;
|
|
76
|
+
if (method === undefined && context === undefined) return null;
|
|
68
77
|
if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 1) return null;
|
|
78
|
+
if (method === undefined) {
|
|
79
|
+
return { seq, params: [], ...(context !== undefined && { context }) };
|
|
80
|
+
}
|
|
81
|
+
if (typeof method !== "string") return null;
|
|
69
82
|
if (!Array.isArray(params)) return null;
|
|
70
83
|
if (meta !== undefined) {
|
|
71
84
|
try {
|
|
@@ -74,7 +87,29 @@ const parseFrame = (raw: string): Frame | null => {
|
|
|
74
87
|
return null;
|
|
75
88
|
}
|
|
76
89
|
}
|
|
77
|
-
return { method, seq, params };
|
|
90
|
+
return { method, seq, params, ...(context !== undefined && { context }) };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** v1 context submissions are exactly one root replace with an object value. */
|
|
94
|
+
const contextValue = (context: unknown): Record<string, unknown> => {
|
|
95
|
+
if (!Array.isArray(context) || context.length !== 1) {
|
|
96
|
+
throw new Error("context must be a single-op array");
|
|
97
|
+
}
|
|
98
|
+
const op = context[0] as Record<string, unknown> | null;
|
|
99
|
+
if (
|
|
100
|
+
typeof op !== "object" ||
|
|
101
|
+
op === null ||
|
|
102
|
+
op["op"] !== "replace" ||
|
|
103
|
+
!Array.isArray(op["path"]) ||
|
|
104
|
+
op["path"].length !== 0
|
|
105
|
+
) {
|
|
106
|
+
throw new Error("context op must be a root replace");
|
|
107
|
+
}
|
|
108
|
+
const value = op["value"];
|
|
109
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
110
|
+
throw new Error("context value must be an object");
|
|
111
|
+
}
|
|
112
|
+
return value as Record<string, unknown>;
|
|
78
113
|
};
|
|
79
114
|
|
|
80
115
|
const encodePayload = (
|
|
@@ -321,15 +356,14 @@ const createSocketHost = (
|
|
|
321
356
|
return { seq, type: "result-unavailable" };
|
|
322
357
|
};
|
|
323
358
|
|
|
324
|
-
const
|
|
359
|
+
const settleResponse = (
|
|
325
360
|
rec: ClientRec,
|
|
326
361
|
adm: Admission,
|
|
327
|
-
|
|
362
|
+
response: StatewireResponse,
|
|
328
363
|
) => {
|
|
329
364
|
if (adm.settled) return;
|
|
330
365
|
adm.settled = true;
|
|
331
366
|
rec.inflight.delete(adm.seq);
|
|
332
|
-
const response = responseOf(adm, outcome);
|
|
333
367
|
retain(rec, response);
|
|
334
368
|
if (!adm.acked) {
|
|
335
369
|
adm.acked = true;
|
|
@@ -346,6 +380,25 @@ const createSocketHost = (
|
|
|
346
380
|
adm.release();
|
|
347
381
|
};
|
|
348
382
|
|
|
383
|
+
const settleWire = (
|
|
384
|
+
rec: ClientRec,
|
|
385
|
+
adm: Admission,
|
|
386
|
+
outcome: CommandsOutcome,
|
|
387
|
+
) => settleResponse(rec, adm, responseOf(adm, outcome));
|
|
388
|
+
|
|
389
|
+
const callerOf = (rec: ClientRec): StatewireHost.Caller => ({
|
|
390
|
+
get context() {
|
|
391
|
+
return rec.context;
|
|
392
|
+
},
|
|
393
|
+
get isConnected() {
|
|
394
|
+
if (clients.get(rec.id) !== rec) return false;
|
|
395
|
+
for (const conn of conns) {
|
|
396
|
+
if (conn.clientId === rec.id && conn.open) return true;
|
|
397
|
+
}
|
|
398
|
+
return false;
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
|
|
349
402
|
const admit = (conn: Conn, frame: Frame): Promise<void> | void => {
|
|
350
403
|
if (!conn.open || disposed || finished !== null) return;
|
|
351
404
|
const rec = clients.get(conn.clientId)!;
|
|
@@ -364,20 +417,42 @@ const createSocketHost = (
|
|
|
364
417
|
}
|
|
365
418
|
const adm: Admission = {
|
|
366
419
|
seq: frame.seq,
|
|
367
|
-
method: frame.method,
|
|
420
|
+
method: frame.method ?? "",
|
|
368
421
|
acked: false,
|
|
369
422
|
pendingSent: false,
|
|
370
423
|
settled: false,
|
|
371
424
|
release: () => {},
|
|
372
425
|
};
|
|
426
|
+
if (frame.context !== undefined) {
|
|
427
|
+
let value: Record<string, unknown>;
|
|
428
|
+
try {
|
|
429
|
+
value = contextValue(frame.context);
|
|
430
|
+
} catch (error) {
|
|
431
|
+
rec.inflight.set(frame.seq, adm);
|
|
432
|
+
settleResponse(rec, adm, {
|
|
433
|
+
seq: frame.seq,
|
|
434
|
+
type: "rejected",
|
|
435
|
+
code: "invalid-context",
|
|
436
|
+
message: `invalid context: ${(error as Error).message}`,
|
|
437
|
+
});
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
rec.context = value;
|
|
441
|
+
if (frame.method === undefined) {
|
|
442
|
+
rec.inflight.set(frame.seq, adm);
|
|
443
|
+
settleResponse(rec, adm, { seq: frame.seq, type: "accepted" });
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
373
447
|
const released = new Promise<void>((resolve) => {
|
|
374
448
|
adm.release = resolve;
|
|
375
449
|
});
|
|
376
450
|
rec.inflight.set(frame.seq, adm);
|
|
377
451
|
commandsHost!.submit!({
|
|
378
|
-
method: frame.method
|
|
452
|
+
method: frame.method!,
|
|
379
453
|
params: frame.params,
|
|
380
454
|
clientId: rec.id,
|
|
455
|
+
caller: callerOf(rec),
|
|
381
456
|
onAck: () => {
|
|
382
457
|
if (adm.settled || adm.acked) return;
|
|
383
458
|
adm.acked = true;
|
|
@@ -437,6 +512,7 @@ const createSocketHost = (
|
|
|
437
512
|
const created: ClientRec = {
|
|
438
513
|
id: clientId,
|
|
439
514
|
lastSeq: 0,
|
|
515
|
+
context: null,
|
|
440
516
|
results: new Map(),
|
|
441
517
|
inflight: new Map(),
|
|
442
518
|
};
|
package/src/host.ts
CHANGED
|
@@ -46,6 +46,17 @@ export namespace StatewireHost {
|
|
|
46
46
|
reject: (error: unknown) => void;
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The submitting client, bound as `this` of a command handler. Holdable
|
|
51
|
+
* across a long run: `context` reads the client's current ambient context
|
|
52
|
+
* live (null until the client sends one), `isConnected` reports whether the
|
|
53
|
+
* client has an active stream attachment.
|
|
54
|
+
*/
|
|
55
|
+
export type Caller = {
|
|
56
|
+
readonly context: Record<string, unknown> | null;
|
|
57
|
+
readonly isConnected: boolean;
|
|
58
|
+
};
|
|
59
|
+
|
|
49
60
|
export type HandledCommands<THandlers extends Statewire.Commands> = {
|
|
50
61
|
[K in keyof THandlers]: (
|
|
51
62
|
...args: Parameters<THandlers[K]>
|
package/src/statewire.ts
CHANGED
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
import type { ResourceElement } from "@assistant-ui/tap";
|
|
9
9
|
import { LIVE_CONNECTION, useStatewireCore } from "./client/core";
|
|
10
10
|
import type { Statewire } from "./types";
|
|
11
|
-
import type { ConnectionState } from "./transports/sse";
|
|
12
11
|
import type { StatewireSessionStoreOptions } from "./client/sessionStore";
|
|
13
12
|
|
|
14
13
|
export const useStatewire = <
|
|
@@ -41,8 +40,16 @@ export const useStatewire = <
|
|
|
41
40
|
getState: core.getState as () => TState,
|
|
42
41
|
connection,
|
|
43
42
|
commands: core.commands as Statewire.CommandsProxy<TCommands>,
|
|
43
|
+
setContext: (contextValue: Record<string, unknown>) => {
|
|
44
|
+
if (!value.setContext) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
"statewire: this transport has no client context channel",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return value.setContext(contextValue);
|
|
50
|
+
},
|
|
44
51
|
}),
|
|
45
|
-
[state, connection, core],
|
|
52
|
+
[state, connection, core, value],
|
|
46
53
|
);
|
|
47
54
|
};
|
|
48
55
|
|
|
@@ -80,6 +87,10 @@ export class StatewireClient<
|
|
|
80
87
|
return this.root.getValue().commands;
|
|
81
88
|
}
|
|
82
89
|
|
|
90
|
+
setContext(value: Record<string, unknown>): Promise<unknown> {
|
|
91
|
+
return this.root.getValue().setContext(value);
|
|
92
|
+
}
|
|
93
|
+
|
|
83
94
|
subscribe(listener: () => void): () => void {
|
|
84
95
|
return this.root.subscribe(listener);
|
|
85
96
|
}
|
|
@@ -120,8 +131,22 @@ export namespace StatewireClient {
|
|
|
120
131
|
getState: () => TState;
|
|
121
132
|
connection: Connection;
|
|
122
133
|
commands: Statewire.CommandsProxy<TCommands>;
|
|
134
|
+
/** Replace this client's ambient context on the server; throws on transports without a client context channel. */
|
|
135
|
+
setContext: (value: Record<string, unknown>) => Promise<unknown>;
|
|
123
136
|
};
|
|
124
137
|
|
|
138
|
+
export type ConnectionState =
|
|
139
|
+
| { status: "connecting" }
|
|
140
|
+
| { status: "live" }
|
|
141
|
+
| {
|
|
142
|
+
status: "reconnecting";
|
|
143
|
+
cause: "dropped" | "evicted" | "error";
|
|
144
|
+
attempt: number;
|
|
145
|
+
nextRetryAt: number;
|
|
146
|
+
lastError?: string;
|
|
147
|
+
message?: string;
|
|
148
|
+
}
|
|
149
|
+
| { status: "gone"; message?: string; payload?: unknown };
|
|
125
150
|
export type Connection = ConnectionState & { reconnect: () => void };
|
|
126
151
|
export type ConnectionStatus = Connection["status"];
|
|
127
152
|
|
package/src/transports/remote.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { useEffect } from "react";
|
|
2
2
|
import { useStatewireEmit } from "../host/host";
|
|
3
3
|
import type { Statewire } from "../types";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
setContext,
|
|
6
|
+
setTransport,
|
|
7
|
+
useStatewireSession,
|
|
8
|
+
} from "../client/session";
|
|
5
9
|
import type { StatewireSessionStoreOptions } from "../client/sessionStore";
|
|
6
10
|
import type { StatewireClient } from "../statewire";
|
|
7
11
|
import type { RemoteLink, StatewireRemoteProps } from "./sse";
|
|
@@ -26,5 +30,6 @@ export const useStatewireRemote = <
|
|
|
26
30
|
emit,
|
|
27
31
|
connection: conn.connection,
|
|
28
32
|
source: s.source,
|
|
33
|
+
setContext: (value) => setContext(s, value),
|
|
29
34
|
};
|
|
30
35
|
};
|
package/src/transports/sse.ts
CHANGED
|
@@ -32,10 +32,24 @@ export type StatewireRemoteProps = {
|
|
|
32
32
|
onFinish: (args: StatewireHost.Fin) => void;
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
-
export type StatewireWireCommand =
|
|
36
|
-
method: string;
|
|
37
|
-
params:
|
|
38
|
-
|
|
35
|
+
export type StatewireWireCommand =
|
|
36
|
+
| { method: string; params: readonly unknown[]; context?: never }
|
|
37
|
+
| { method?: never; params?: never; context: Record<string, unknown> };
|
|
38
|
+
|
|
39
|
+
/** The wire shape of a client context submission: one root replace op. */
|
|
40
|
+
export const contextOps = (value: Record<string, unknown>) => [
|
|
41
|
+
{ op: "replace", path: [], value },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
export const encodeWireCommand = (command: StatewireWireCommand) => ({
|
|
45
|
+
...(command.method !== undefined && {
|
|
46
|
+
method: command.method,
|
|
47
|
+
...encodeParams(command.params),
|
|
48
|
+
}),
|
|
49
|
+
...(command.context !== undefined && {
|
|
50
|
+
context: contextOps(command.context),
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
39
53
|
|
|
40
54
|
export type StatewireSubmitResult =
|
|
41
55
|
| { type: "receipt"; response?: StatewireResponse }
|
|
@@ -44,18 +58,7 @@ export type StatewireSubmitResult =
|
|
|
44
58
|
| { type: "unknown-client" }
|
|
45
59
|
| { type: "stale-lease" };
|
|
46
60
|
|
|
47
|
-
export type ConnectionState =
|
|
48
|
-
| { status: "connecting" }
|
|
49
|
-
| { status: "live" }
|
|
50
|
-
| {
|
|
51
|
-
status: "reconnecting";
|
|
52
|
-
cause: "dropped" | "evicted" | "error";
|
|
53
|
-
attempt: number;
|
|
54
|
-
nextRetryAt: number;
|
|
55
|
-
lastError?: string;
|
|
56
|
-
message?: string;
|
|
57
|
-
}
|
|
58
|
-
| { status: "gone"; message?: string; payload?: unknown };
|
|
61
|
+
export type ConnectionState = StatewireClient.ConnectionState;
|
|
59
62
|
|
|
60
63
|
/** The live wire a remote session drives: connection status plus command POST. */
|
|
61
64
|
export type RemoteLink = {
|
|
@@ -184,10 +187,7 @@ const sendCommand = async (
|
|
|
184
187
|
"Statewire-Command-Seq": String(seq),
|
|
185
188
|
...(c.lease !== null && { "Statewire-Lease": c.lease }),
|
|
186
189
|
}),
|
|
187
|
-
body: JSON.stringify(
|
|
188
|
-
method: command.method,
|
|
189
|
-
...encodeParams(command.params),
|
|
190
|
-
}),
|
|
190
|
+
body: JSON.stringify(encodeWireCommand(command)),
|
|
191
191
|
signal: AbortSignal.any([
|
|
192
192
|
c.lifetime?.signal ?? AbortSignal.abort(),
|
|
193
193
|
timeout,
|
|
@@ -20,8 +20,8 @@ export const applyOptimistic = <TState>(
|
|
|
20
20
|
export type MockTransport<TState> = {
|
|
21
21
|
/** Pass as the `transport:` option of useStatewire / Harness. */
|
|
22
22
|
transport: ResourceElement<Statewire<TState | undefined, any, any>>;
|
|
23
|
-
/** Captured `
|
|
24
|
-
commands: { method
|
|
23
|
+
/** Captured `/commands` POST bodies, in POST order. */
|
|
24
|
+
commands: { method?: string; params?: unknown[]; context?: unknown }[];
|
|
25
25
|
/** Replace the replicated state and broadcast it as a root snapshot op. */
|
|
26
26
|
setState: (state: TState) => Promise<void>;
|
|
27
27
|
/** Deliver an ack watermark on the stream. */
|
|
@@ -49,7 +49,7 @@ export const createMockTransport = <TState>(
|
|
|
49
49
|
let finished = false;
|
|
50
50
|
const clients = new Map<string, number>();
|
|
51
51
|
const streams = new Set<FakeStreamHandle>();
|
|
52
|
-
const commands:
|
|
52
|
+
const commands: MockTransport<unknown>["commands"] = [];
|
|
53
53
|
|
|
54
54
|
const sendEnvelope = (envelope: StatewireEnvelope) => {
|
|
55
55
|
for (const stream of streams) stream.send(sseFrame(envelope));
|
|
@@ -86,7 +86,9 @@ export const createMockTransport = <TState>(
|
|
|
86
86
|
const seq = Number(headers.get("Statewire-Command-Seq"));
|
|
87
87
|
clients.set(id, Math.max(clients.get(id) ?? 0, seq));
|
|
88
88
|
commands.push(
|
|
89
|
-
JSON.parse(
|
|
89
|
+
JSON.parse(
|
|
90
|
+
String(init?.body),
|
|
91
|
+
) as MockTransport<unknown>["commands"][number],
|
|
90
92
|
);
|
|
91
93
|
return Response.json({});
|
|
92
94
|
}
|
package/src/transports/ws.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { flushTapSync, resource } from "@assistant-ui/tap";
|
|
3
3
|
import type { ResourceElement } from "@assistant-ui/tap";
|
|
4
|
-
import { encodeParams } from "../wire/meta";
|
|
5
4
|
import { timing } from "../client/timing";
|
|
6
5
|
import {
|
|
7
6
|
handleEnvelope,
|
|
@@ -13,6 +12,7 @@ import type { Statewire } from "../types";
|
|
|
13
12
|
import type { StatewireClient } from "../statewire";
|
|
14
13
|
import { useStatewireRemote } from "./remote";
|
|
15
14
|
import { CLIENT_QUERY_PARAM } from "../host/adapter";
|
|
15
|
+
import { encodeWireCommand } from "./sse";
|
|
16
16
|
import type {
|
|
17
17
|
RemoteLink,
|
|
18
18
|
StatewireSubmitResult,
|
|
@@ -206,13 +206,7 @@ const sendCommand = (
|
|
|
206
206
|
}, timing.heartbeatTimeoutMs);
|
|
207
207
|
w.pending.push(entry);
|
|
208
208
|
try {
|
|
209
|
-
ws.send(
|
|
210
|
-
JSON.stringify({
|
|
211
|
-
method: command.method,
|
|
212
|
-
...encodeParams(command.params),
|
|
213
|
-
seq,
|
|
214
|
-
}),
|
|
215
|
-
);
|
|
209
|
+
ws.send(JSON.stringify({ ...encodeWireCommand(command), seq }));
|
|
216
210
|
} catch {
|
|
217
211
|
clearTimeout(timer);
|
|
218
212
|
w.pending = w.pending.filter((p) => p !== entry);
|
package/src/types.ts
CHANGED
|
@@ -56,6 +56,8 @@ export interface Statewire<
|
|
|
56
56
|
emit?: (event: TEvent) => void;
|
|
57
57
|
connection?: StatewireClient.Connection;
|
|
58
58
|
source?: Statewire.StateSource;
|
|
59
|
+
/** Replace this client's ambient context on the server; absent on transports without a client context channel. */
|
|
60
|
+
setContext?: (value: Record<string, unknown>) => Promise<unknown>;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
const describe = (failure: StatewireClient.SendFailure): string => {
|