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.
Files changed (95) hide show
  1. package/README.md +29 -78
  2. package/dist/client/connection.d.ts +20 -6
  3. package/dist/client/connection.d.ts.map +1 -1
  4. package/dist/client/connection.js +120 -52
  5. package/dist/client/connection.js.map +1 -1
  6. package/dist/client/core.d.ts +3 -2
  7. package/dist/client/core.d.ts.map +1 -1
  8. package/dist/client/core.js +10 -4
  9. package/dist/client/core.js.map +1 -1
  10. package/dist/client/session.d.ts +2 -0
  11. package/dist/client/session.d.ts.map +1 -1
  12. package/dist/client/session.js +97 -17
  13. package/dist/client/session.js.map +1 -1
  14. package/dist/host/adapter.d.ts +1 -1
  15. package/dist/host/adapter.d.ts.map +1 -1
  16. package/dist/host/adapter.js +7 -1
  17. package/dist/host/adapter.js.map +1 -1
  18. package/dist/host/host.d.ts +1 -0
  19. package/dist/host/host.d.ts.map +1 -1
  20. package/dist/host/host.js +45 -5
  21. package/dist/host/host.js.map +1 -1
  22. package/dist/host/http.d.ts +2 -1
  23. package/dist/host/http.d.ts.map +1 -1
  24. package/dist/host/http.js +108 -47
  25. package/dist/host/http.js.map +1 -1
  26. package/dist/host/socket.d.ts +33 -8
  27. package/dist/host/socket.d.ts.map +1 -1
  28. package/dist/host/socket.js +388 -58
  29. package/dist/host/socket.js.map +1 -1
  30. package/dist/host/work.d.ts +7 -0
  31. package/dist/host/work.d.ts.map +1 -0
  32. package/dist/host/work.js +53 -0
  33. package/dist/host/work.js.map +1 -0
  34. package/dist/host-internal.d.ts +42 -4
  35. package/dist/host-internal.d.ts.map +1 -1
  36. package/dist/host-internal.js +2 -1
  37. package/dist/host.d.ts +16 -3
  38. package/dist/host.d.ts.map +1 -1
  39. package/dist/host.js +2 -1
  40. package/dist/statewire.d.ts +21 -11
  41. package/dist/statewire.d.ts.map +1 -1
  42. package/dist/statewire.js +2 -2
  43. package/dist/statewire.js.map +1 -1
  44. package/dist/transports/sse.d.ts +1 -4
  45. package/dist/transports/sse.d.ts.map +1 -1
  46. package/dist/transports/sse.js +47 -56
  47. package/dist/transports/sse.js.map +1 -1
  48. package/dist/transports/testing.js +2 -2
  49. package/dist/transports/testing.js.map +1 -1
  50. package/dist/transports/ws.d.ts +1 -1
  51. package/dist/transports/ws.d.ts.map +1 -1
  52. package/dist/transports/ws.js +54 -41
  53. package/dist/transports/ws.js.map +1 -1
  54. package/dist/types.d.ts +3 -0
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/types.js.map +1 -1
  57. package/dist/wire/documents.d.ts +20 -0
  58. package/dist/wire/documents.d.ts.map +1 -0
  59. package/dist/wire/documents.js +74 -0
  60. package/dist/wire/documents.js.map +1 -0
  61. package/dist/wire/limits.d.ts +10 -0
  62. package/dist/wire/limits.d.ts.map +1 -0
  63. package/dist/wire/limits.js +11 -0
  64. package/dist/wire/limits.js.map +1 -0
  65. package/dist/wire/meta.d.ts.map +1 -1
  66. package/dist/wire/meta.js +1 -1
  67. package/dist/wire/meta.js.map +1 -1
  68. package/dist/wire/ops.d.ts.map +1 -1
  69. package/dist/wire/ops.js +1 -2
  70. package/dist/wire/ops.js.map +1 -1
  71. package/dist/wire/version.d.ts +7 -0
  72. package/dist/wire/version.d.ts.map +1 -0
  73. package/dist/wire/version.js +8 -0
  74. package/dist/wire/version.js.map +1 -0
  75. package/package.json +1 -1
  76. package/src/client/connection.ts +213 -67
  77. package/src/client/core.ts +10 -3
  78. package/src/client/session.ts +106 -30
  79. package/src/host/adapter.ts +10 -2
  80. package/src/host/host.ts +54 -4
  81. package/src/host/http.ts +168 -64
  82. package/src/host/socket.ts +603 -60
  83. package/src/host/work.ts +50 -0
  84. package/src/host-internal.ts +44 -3
  85. package/src/host.ts +18 -2
  86. package/src/statewire.ts +33 -10
  87. package/src/transports/sse.ts +60 -68
  88. package/src/transports/testing.ts +2 -2
  89. package/src/transports/ws.ts +29 -48
  90. package/src/types.ts +1 -0
  91. package/src/wire/documents.ts +109 -0
  92. package/src/wire/limits.ts +6 -0
  93. package/src/wire/meta.ts +6 -1
  94. package/src/wire/ops.ts +1 -2
  95. package/src/wire/version.ts +3 -0
@@ -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 { handleEnvelope, useStatewireConn } from "../client/connection";
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 const WS_SUBPROTOCOL = "statewire.v1";
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 base = url.replace(/\/$/, "").replace(/^http/, "ws");
41
- return `${base}/ws?${params.toString()}`;
42
- };
43
-
44
- const firstFrame = async (
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 = () => ws.send(frame);
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 Error("protocol"));
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 stale = w.authStale;
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, w, ws, frame, abort);
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, authStale: false }));
179
- const c = useStatewireConn({
180
- url: options.url,
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 (Number.isNaN(date.getTime())) {
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
- if (op.op !== "replace") throw invalid(op);
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");
@@ -0,0 +1,3 @@
1
+ export const PROTOCOL_VERSION = "1";
2
+ export const VERSION_HEADER = "Statewire-Version";
3
+ export const WS_SUBPROTOCOL = "statewire.v1";