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.
Files changed (48) hide show
  1. package/dist/client/connection.d.ts +1 -1
  2. package/dist/client/session.d.ts +7 -4
  3. package/dist/client/session.d.ts.map +1 -1
  4. package/dist/client/session.js +15 -8
  5. package/dist/client/session.js.map +1 -1
  6. package/dist/host/host.d.ts +3 -2
  7. package/dist/host/host.d.ts.map +1 -1
  8. package/dist/host/host.js +5 -1
  9. package/dist/host/host.js.map +1 -1
  10. package/dist/host/socket.d.ts.map +1 -1
  11. package/dist/host/socket.js +58 -6
  12. package/dist/host/socket.js.map +1 -1
  13. package/dist/host.d.ts +11 -1
  14. package/dist/host.d.ts.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/statewire.d.ts +19 -1
  17. package/dist/statewire.d.ts.map +1 -1
  18. package/dist/statewire.js +10 -2
  19. package/dist/statewire.js.map +1 -1
  20. package/dist/transports/fixtures.d.ts +1 -1
  21. package/dist/transports/remote.d.ts +1 -1
  22. package/dist/transports/remote.d.ts.map +1 -1
  23. package/dist/transports/remote.js +3 -2
  24. package/dist/transports/remote.js.map +1 -1
  25. package/dist/transports/sse.d.ts +26 -19
  26. package/dist/transports/sse.d.ts.map +1 -1
  27. package/dist/transports/sse.js +15 -5
  28. package/dist/transports/sse.js.map +1 -1
  29. package/dist/transports/testing.d.ts +5 -4
  30. package/dist/transports/testing.d.ts.map +1 -1
  31. package/dist/transports/testing.js.map +1 -1
  32. package/dist/transports/ws.d.ts.map +1 -1
  33. package/dist/transports/ws.js +2 -3
  34. package/dist/transports/ws.js.map +1 -1
  35. package/dist/types.d.ts +2 -0
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/types.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/client/session.ts +43 -21
  40. package/src/host/host.ts +15 -4
  41. package/src/host/socket.ts +85 -9
  42. package/src/host.ts +11 -0
  43. package/src/statewire.ts +27 -2
  44. package/src/transports/remote.ts +6 -1
  45. package/src/transports/sse.ts +20 -20
  46. package/src/transports/testing.ts +6 -4
  47. package/src/transports/ws.ts +2 -8
  48. package/src/types.ts +2 -0
@@ -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 = { method: string; seq: number; params: unknown[] };
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<string, unknown>;
67
- if (typeof method !== "string") return null;
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 settleWire = (
359
+ const settleResponse = (
325
360
  rec: ClientRec,
326
361
  adm: Admission,
327
- outcome: CommandsOutcome,
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
 
@@ -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 { setTransport, useStatewireSession } from "../client/session";
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
  };
@@ -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: readonly unknown[];
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 `{ method, params }` command bodies, in POST order. */
24
- commands: { method: string; params: unknown[] }[];
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: { method: string; params: unknown[] }[] = [];
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(String(init?.body)) as { method: string; params: unknown[] },
89
+ JSON.parse(
90
+ String(init?.body),
91
+ ) as MockTransport<unknown>["commands"][number],
90
92
  );
91
93
  return Response.json({});
92
94
  }
@@ -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 => {