statewire 0.8.0 → 0.9.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.
Files changed (95) hide show
  1. package/README.md +2 -2
  2. package/dist/client/connection.d.ts +23 -19
  3. package/dist/client/connection.d.ts.map +1 -1
  4. package/dist/client/connection.js +143 -103
  5. package/dist/client/connection.js.map +1 -1
  6. package/dist/client/core.d.ts +30 -3
  7. package/dist/client/core.d.ts.map +1 -1
  8. package/dist/client/core.js +222 -259
  9. package/dist/client/core.js.map +1 -1
  10. package/dist/client/session.d.ts +34 -39
  11. package/dist/client/session.d.ts.map +1 -1
  12. package/dist/client/session.js +380 -373
  13. package/dist/client/session.js.map +1 -1
  14. package/dist/client/sessionStore.d.ts +1 -0
  15. package/dist/client/sessionStore.d.ts.map +1 -1
  16. package/dist/client/sessionStore.js.map +1 -1
  17. package/dist/client/timing.d.ts +1 -2
  18. package/dist/client/timing.d.ts.map +1 -1
  19. package/dist/client/timing.js +1 -5
  20. package/dist/client/timing.js.map +1 -1
  21. package/dist/host/draft.d.ts +2 -5
  22. package/dist/host/draft.d.ts.map +1 -1
  23. package/dist/host/draft.js.map +1 -1
  24. package/dist/host/host.d.ts +5 -8
  25. package/dist/host/host.d.ts.map +1 -1
  26. package/dist/host/host.js +23 -32
  27. package/dist/host/host.js.map +1 -1
  28. package/dist/host/socket.d.ts +3 -2
  29. package/dist/host/socket.d.ts.map +1 -1
  30. package/dist/host/socket.js +484 -424
  31. package/dist/host/socket.js.map +1 -1
  32. package/dist/host-internal.d.ts +4 -0
  33. package/dist/host-internal.d.ts.map +1 -1
  34. package/dist/host.d.ts +10 -10
  35. package/dist/host.d.ts.map +1 -1
  36. package/dist/host.js +2 -2
  37. package/dist/index.d.ts +1 -2
  38. package/dist/index.js +1 -2
  39. package/dist/statewire.d.ts +29 -24
  40. package/dist/statewire.d.ts.map +1 -1
  41. package/dist/statewire.js +8 -2
  42. package/dist/statewire.js.map +1 -1
  43. package/dist/transports/fake-sse.d.ts +1 -1
  44. package/dist/transports/fake-sse.d.ts.map +1 -1
  45. package/dist/transports/fake-sse.js +5 -2
  46. package/dist/transports/fake-sse.js.map +1 -1
  47. package/dist/transports/fixtures.d.ts +1 -2
  48. package/dist/transports/fixtures.d.ts.map +1 -1
  49. package/dist/transports/fixtures.js +5 -7
  50. package/dist/transports/fixtures.js.map +1 -1
  51. package/dist/transports/remote.d.ts +2 -1
  52. package/dist/transports/remote.d.ts.map +1 -1
  53. package/dist/transports/remote.js +10 -4
  54. package/dist/transports/remote.js.map +1 -1
  55. package/dist/transports/sse.d.ts +32 -46
  56. package/dist/transports/sse.d.ts.map +1 -1
  57. package/dist/transports/sse.js +56 -59
  58. package/dist/transports/sse.js.map +1 -1
  59. package/dist/transports/testing.d.ts +7 -5
  60. package/dist/transports/testing.d.ts.map +1 -1
  61. package/dist/transports/testing.js +41 -14
  62. package/dist/transports/testing.js.map +1 -1
  63. package/dist/transports/ws.d.ts +2 -3
  64. package/dist/transports/ws.d.ts.map +1 -1
  65. package/dist/transports/ws.js +25 -80
  66. package/dist/transports/ws.js.map +1 -1
  67. package/dist/types.d.ts +8 -8
  68. package/dist/types.d.ts.map +1 -1
  69. package/dist/types.js +3 -4
  70. package/dist/types.js.map +1 -1
  71. package/dist/wire/ops.d.ts +1 -5
  72. package/dist/wire/ops.d.ts.map +1 -1
  73. package/dist/wire/ops.js +1 -4
  74. package/dist/wire/ops.js.map +1 -1
  75. package/package.json +1 -1
  76. package/src/client/connection.ts +175 -128
  77. package/src/client/core.ts +287 -276
  78. package/src/client/session.ts +474 -480
  79. package/src/client/sessionStore.ts +1 -0
  80. package/src/client/timing.ts +0 -6
  81. package/src/host/draft.ts +1 -3
  82. package/src/host/host.ts +41 -58
  83. package/src/host/socket.ts +653 -448
  84. package/src/host-internal.ts +4 -0
  85. package/src/host.ts +13 -10
  86. package/src/index.ts +0 -1
  87. package/src/statewire.ts +38 -32
  88. package/src/transports/fake-sse.ts +2 -1
  89. package/src/transports/fixtures.ts +4 -9
  90. package/src/transports/remote.ts +12 -5
  91. package/src/transports/sse.ts +99 -115
  92. package/src/transports/testing.ts +47 -19
  93. package/src/transports/ws.ts +44 -115
  94. package/src/types.ts +15 -14
  95. package/src/wire/ops.ts +9 -16
@@ -22,6 +22,10 @@ export namespace StatewireHostInternal {
22
22
 
23
23
  export type SocketHostOptions = {
24
24
  heartbeatIntervalMs?: number;
25
+ /** LRU cap on stored client records; live or in-flight records are exempt. */
26
+ clientRecordLimit?: number;
27
+ /** Hang every attach up with `fin: idle` at the idle flag's rising edge. */
28
+ finOnIdle?: boolean;
25
29
  };
26
30
 
27
31
  export type Fin = StatewireHost.Fin;
package/src/host.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import type { Statewire } from "./types";
2
- import type { ServerOp } from "./host/draft";
3
2
 
4
3
  export { StatewireReject } from "./host/engine";
5
4
  export { append } from "./host/draft";
@@ -7,7 +6,6 @@ export {
7
6
  getStateHost,
8
7
  useStatewireState,
9
8
  useStatewireCommands,
10
- useStatewireEmit,
11
9
  } from "./host/host";
12
10
 
13
11
  export namespace StatewireHost {
@@ -20,20 +18,19 @@ export namespace StatewireHost {
20
18
 
21
19
  export type StateHost = Statewire.StateSource & {
22
20
  /** Synchronous, per locally-recorded op; the host owns batching. */
23
- setOnOp: (listener: ((op: ServerOp) => void) | null) => void;
21
+ setOnOp: (listener: ((op: Statewire.Op) => void) | null) => void;
24
22
  };
25
23
 
26
- export type StateSource = Statewire.StateSource;
27
-
28
24
  /**
29
25
  * Per-invocation handle bound as `this` of a command handler: `caller` is
30
- * the submitting client, `ack()` releases the client's optimistic overlay /
31
- * ack watermark before the handler settles (idempotent, a no-op after
32
- * settle). The command settles when the handler's returned promise does.
26
+ * the submitting client, `applied()` states the broadcast state now contains
27
+ * the command's effects — the client drops its optimistic overlay — before
28
+ * the handler settles (idempotent, a no-op after settle). The command
29
+ * settles when the handler's returned promise does.
33
30
  */
34
31
  export type Ctx = {
35
32
  readonly caller: Caller;
36
- readonly ack: () => void;
33
+ readonly applied: () => void;
37
34
  };
38
35
 
39
36
  /**
@@ -56,7 +53,13 @@ export namespace StatewireHost {
56
53
  };
57
54
 
58
55
  export type Fin = {
59
- reason: "evicted" | "error" | "gone" | "superseded" | (string & {});
56
+ reason:
57
+ | "evicted"
58
+ | "error"
59
+ | "gone"
60
+ | "superseded"
61
+ | "idle"
62
+ | (string & {});
60
63
  message?: string;
61
64
  payload?: unknown;
62
65
  meta?: unknown;
package/src/index.ts CHANGED
@@ -3,4 +3,3 @@ export { StatewireClient, useStatewire, StatewireResource } from "./statewire";
3
3
  export { StatewireSSE } from "./transports/sse";
4
4
  export { StatewireWS } from "./transports/ws";
5
5
  export { StatewireSendError } from "./types";
6
- export { applyOptimistic } from "./util/recordMutations";
package/src/statewire.ts CHANGED
@@ -13,9 +13,8 @@ import type { StatewireSessionStoreOptions } from "./client/sessionStore";
13
13
  export const useStatewire = <
14
14
  TState,
15
15
  TCommands extends Statewire.Commands = Statewire.Commands,
16
- TEvent = any,
17
16
  >(
18
- options: StatewireClient.Options<TState, TCommands, TEvent>,
17
+ options: StatewireClient.Options<TState, TCommands>,
19
18
  ): StatewireClient.Helpers<TState, TCommands> => {
20
19
  const latest = useRef(options);
21
20
  latest.current = options;
@@ -32,6 +31,7 @@ export const useStatewire = <
32
31
  core.getState,
33
32
  core.getState,
34
33
  ) as TState;
34
+ const isBusy = useSyncExternalStore(subscribe, core.getBusy, core.getBusy);
35
35
  const connection = value.connection ?? LIVE_CONNECTION;
36
36
 
37
37
  return useMemo(
@@ -40,38 +40,37 @@ export const useStatewire = <
40
40
  getState: core.getState as () => TState,
41
41
  connection,
42
42
  commands: core.commands as Statewire.CommandsProxy<TCommands>,
43
+ isBusy,
43
44
  setContext: (contextValue: Record<string, unknown>) => {
44
45
  if (!value.setContext) {
45
46
  throw new Error(
46
47
  "statewire: this transport has no client context channel",
47
48
  );
48
49
  }
49
- return value.setContext(contextValue);
50
+ value.setContext(contextValue);
50
51
  },
51
52
  }),
52
- [state, connection, core, value],
53
+ [state, isBusy, connection, core, value],
53
54
  );
54
55
  };
55
56
 
56
57
  export const StatewireResource = <
57
58
  TState,
58
59
  TCommands extends Statewire.Commands = Statewire.Commands,
59
- TEvent = any,
60
60
  >(
61
- options: StatewireClient.Options<TState, TCommands, TEvent>,
61
+ options: StatewireClient.Options<TState, TCommands>,
62
62
  ): ResourceElement<StatewireClient.Helpers<TState, TCommands>> =>
63
- resource(useStatewire<TState, TCommands, TEvent>)(options);
63
+ resource(useStatewire<TState, TCommands>)(options);
64
64
 
65
65
  export class StatewireClient<
66
66
  TState,
67
67
  TCommands extends Statewire.Commands = Statewire.Commands,
68
- TEvent = any,
69
68
  > {
70
69
  private readonly root;
71
70
  private disposed = false;
72
71
 
73
- constructor(options: StatewireClient.Options<TState, TCommands, TEvent>) {
74
- const ClientRoot = () => useStatewire<TState, TCommands, TEvent>(options);
72
+ constructor(options: StatewireClient.Options<TState, TCommands>) {
73
+ const ClientRoot = () => useStatewire<TState, TCommands>(options);
75
74
  this.root = createTapRoot(ClientRoot);
76
75
  }
77
76
 
@@ -87,8 +86,12 @@ export class StatewireClient<
87
86
  return this.root.getValue().commands;
88
87
  }
89
88
 
90
- setContext(value: Record<string, unknown>): Promise<unknown> {
91
- return this.root.getValue().setContext(value);
89
+ get isBusy(): boolean {
90
+ return this.root.getValue().isBusy;
91
+ }
92
+
93
+ setContext(value: Record<string, unknown>): void {
94
+ this.root.getValue().setContext(value);
92
95
  }
93
96
 
94
97
  subscribe(listener: () => void): () => void {
@@ -106,14 +109,12 @@ export namespace StatewireClient {
106
109
  export type Options<
107
110
  TState,
108
111
  TCommands extends Statewire.Commands = Statewire.Commands,
109
- TEvent = any,
110
112
  > = {
111
- transport: ResourceElement<Statewire<TState, TCommands, TEvent>>;
113
+ transport: ResourceElement<Statewire<TState, TCommands>>;
112
114
  /** Served as the canonical state until the attach snapshot; own commands fold over it. */
113
115
  initialState?: TState;
114
116
  optimistic?: Optimistic<NonNullable<TState>, TCommands>;
115
117
  materialize?: Materialize<NonNullable<TState>>;
116
- onEvent?: (event: TEvent, ctx: { state: NonNullable<TState> }) => void;
117
118
  onStateChange?: (
118
119
  state: NonNullable<TState>,
119
120
  ctx: { changes: Statewire.Changes },
@@ -135,13 +136,16 @@ export namespace StatewireClient {
135
136
  getState: () => TState;
136
137
  connection: Connection;
137
138
  commands: Statewire.CommandsProxy<TCommands>;
139
+ /** Negation of the server's broadcast idle level (a transport without idle is always busy). */
140
+ isBusy: boolean;
138
141
  /** Replace this client's ambient context on the server; throws on transports without a client context channel. */
139
- setContext: (value: Record<string, unknown>) => Promise<unknown>;
142
+ setContext: (value: Record<string, unknown>) => void;
140
143
  };
141
144
 
142
145
  export type ConnectionState =
143
146
  | { status: "connecting" }
144
147
  | { status: "live" }
148
+ | { status: "idle" }
145
149
  | {
146
150
  status: "reconnecting";
147
151
  cause: "dropped" | "evicted" | "error" | "unauthorized";
@@ -181,28 +185,26 @@ export namespace StatewireClient {
181
185
  export type Materialize<TState> = (draft: TState) => void;
182
186
 
183
187
  /**
184
- * fate: "aborted" = never sent, "rejected" = the handler refused,
185
- * "unknown-command" = no such command, "invalid-params" = the args did not
186
- * match the command's schema, "lost" = sent but never covered by an ack,
187
- * "crashed" = acked but the processing died, "result-unavailable" = acked
188
- * but the result is gone.
188
+ * fate: "aborted" = never sent, "rejected" = the server refused it
189
+ * (`code` "unknown-command" | "invalid-params" for the static rejects,
190
+ * code-less for handler rejects), "lost" = sent but never covered by an
191
+ * applied, "failed" = the handler ran and threw, "crashed" = the handler
192
+ * died involuntarily, "expired" = concluded but the outcome is
193
+ * unavailable.
189
194
  * cause is which channel settled it: "server" = the server's answer;
190
195
  * the rest are channel deaths the client settled locally.
191
196
  */
192
197
  export type SendFailure =
193
- | { fate: "rejected"; cause: "server"; message: string; payload?: unknown }
194
- | { fate: "unknown-command"; cause: "server" }
195
- | { fate: "invalid-params"; cause: "server"; message: string }
196
- | { fate: "crashed"; cause: "server" }
197
198
  | {
198
- fate: "result-unavailable";
199
- cause:
200
- | "server"
201
- | "unmount"
202
- | "stream-gone"
203
- | "session-lost"
204
- | "superseded";
199
+ fate: "rejected";
200
+ cause: "server";
201
+ code?: "unknown-command" | "invalid-params";
202
+ message?: string;
203
+ payload?: unknown;
205
204
  }
205
+ | { fate: "failed"; cause: "server"; message?: string }
206
+ | { fate: "crashed"; cause: "server" }
207
+ | { fate: "expired"; cause: "server" }
206
208
  | {
207
209
  fate: "aborted";
208
210
  cause: "unmount" | "stream-gone" | "session-lost" | "superseded";
@@ -223,6 +225,8 @@ export namespace StatewireClient {
223
225
  headers?: HeadersOption;
224
226
  fetch?: typeof globalThis.fetch;
225
227
  onChange?: (connection: Connection) => void;
228
+ /** The broadcast idle flag's rising edge: the instance went quiet. */
229
+ onDone?: () => void;
226
230
  };
227
231
 
228
232
  export type WSOptions = StatewireSessionStoreOptions & {
@@ -234,5 +238,7 @@ export namespace StatewireClient {
234
238
  | (() => Record<string, string> | Promise<Record<string, string>>);
235
239
  webSocket?: new (url: string, protocols?: string | string[]) => WebSocket;
236
240
  onChange?: (connection: Connection) => void;
241
+ /** The broadcast idle flag's rising edge: the instance went quiet. */
242
+ onDone?: () => void;
237
243
  };
238
244
  }
@@ -14,6 +14,7 @@ export const openSSEStream = (
14
14
  clientId: string,
15
15
  onOpen: (handle: FakeStreamHandle) => void,
16
16
  signal?: AbortSignal,
17
+ headers?: Record<string, string>,
17
18
  ): Response => {
18
19
  const encoder = new TextEncoder();
19
20
  let handle: FakeStreamHandle;
@@ -46,6 +47,6 @@ export const openSSEStream = (
46
47
  });
47
48
  return new Response(body, {
48
49
  status: 200,
49
- headers: { "content-type": "text/event-stream" },
50
+ headers: { "content-type": "text/event-stream", ...headers },
50
51
  });
51
52
  };
@@ -1,9 +1,5 @@
1
1
  import { resource } from "@assistant-ui/tap";
2
- import {
3
- useStatewireCommands,
4
- useStatewireEmit,
5
- useStatewireState,
6
- } from "../host";
2
+ import { useStatewireCommands, useStatewireState } from "../host";
7
3
  import { tick } from "./testing";
8
4
 
9
5
  /** Let several rounds of queued microtasks and op batches drain. */
@@ -15,21 +11,20 @@ export const settle = async () => {
15
11
  export const createCounterElement = () =>
16
12
  resource(() => {
17
13
  const [state] = useStatewireState(() => ({ count: 0 }));
18
- const emit = useStatewireEmit();
19
14
  const commands = useStatewireCommands({
20
15
  increment: () => {
21
16
  state.count += 1;
22
17
  },
23
18
  });
24
- return { state, commands, emit };
19
+ return { state, commands };
25
20
  })();
26
21
 
27
- /** Serialize a command frame as a client would send it over a socket. */
22
+ /** Serialize a one-statement cmd-lane frame as a client would send it. */
28
23
  export const commandFrame = (
29
24
  method: string,
30
25
  seq: number,
31
26
  params: unknown[] = [],
32
- ) => JSON.stringify({ method, params, seq });
27
+ ) => JSON.stringify({ cmd: [{ method, params, seq }] });
33
28
 
34
29
  export type SocketRecorder = {
35
30
  sent: string[];
@@ -1,5 +1,4 @@
1
1
  import { useEffect } from "react";
2
- import { useStatewireEmit } from "../host/host";
3
2
  import type { Statewire } from "../types";
4
3
  import {
5
4
  setContext,
@@ -14,22 +13,30 @@ export const useStatewireRemote = <
14
13
  TOptions extends {
15
14
  url: string;
16
15
  onChange?: (connection: StatewireClient.Connection) => void;
16
+ onDone?: () => void;
17
17
  } & StatewireSessionOptions,
18
18
  >(
19
19
  options: TOptions,
20
20
  useLink: (options: TOptions, ctx: StatewireRemoteProps) => RemoteLink,
21
- ): Statewire<any, any, any> => {
22
- const emit = useStatewireEmit<any>();
23
- const s = useStatewireSession({ sessionStore: options.sessionStore }, emit);
21
+ ): Statewire<any, any> => {
22
+ const s = useStatewireSession({
23
+ sessionStore: options.sessionStore,
24
+ onDone: options.onDone,
25
+ });
24
26
  const conn = useLink(options, s.ctx);
25
27
  useEffect(() => setTransport(s, conn), [s, conn]);
26
28
 
27
29
  return {
28
30
  state: s.canonical,
29
31
  commands: s.commands,
30
- emit,
31
32
  connection: conn.connection,
32
33
  source: s.source,
34
+ get durableSeq() {
35
+ return s.durableSeq;
36
+ },
37
+ get isBusy() {
38
+ return !s.idle;
39
+ },
33
40
  setContext: (value) => setContext(s, value),
34
41
  };
35
42
  };