statewire 0.16.0 → 0.17.1

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 (96) hide show
  1. package/README.md +12 -4
  2. package/dist/client/connection.d.ts +5 -1
  3. package/dist/client/connection.d.ts.map +1 -1
  4. package/dist/client/connection.js +22 -6
  5. package/dist/client/connection.js.map +1 -1
  6. package/dist/client/core.d.ts.map +1 -1
  7. package/dist/client/core.js +2 -0
  8. package/dist/client/core.js.map +1 -1
  9. package/dist/client/session.d.ts +15 -4
  10. package/dist/client/session.d.ts.map +1 -1
  11. package/dist/client/session.js +235 -52
  12. package/dist/client/session.js.map +1 -1
  13. package/dist/client/sessionStore.d.ts +5 -1
  14. package/dist/client/sessionStore.d.ts.map +1 -1
  15. package/dist/client/sessionStore.js +3 -1
  16. package/dist/client/sessionStore.js.map +1 -1
  17. package/dist/document.d.ts +25 -0
  18. package/dist/document.d.ts.map +1 -0
  19. package/dist/document.js +88 -0
  20. package/dist/document.js.map +1 -0
  21. package/dist/host/draft.js +1 -1
  22. package/dist/host/host.d.ts +1 -0
  23. package/dist/host/host.d.ts.map +1 -1
  24. package/dist/host/host.js.map +1 -1
  25. package/dist/host/http.d.ts.map +1 -1
  26. package/dist/host/http.js +27 -9
  27. package/dist/host/http.js.map +1 -1
  28. package/dist/host/socket.d.ts +12 -5
  29. package/dist/host/socket.d.ts.map +1 -1
  30. package/dist/host/socket.js +107 -43
  31. package/dist/host/socket.js.map +1 -1
  32. package/dist/host-internal.d.ts +9 -1
  33. package/dist/host-internal.d.ts.map +1 -1
  34. package/dist/host-internal.js +1 -1
  35. package/dist/index.d.ts +4 -1
  36. package/dist/index.js +2 -1
  37. package/dist/statewire.d.ts +33 -2
  38. package/dist/statewire.d.ts.map +1 -1
  39. package/dist/statewire.js +34 -3
  40. package/dist/statewire.js.map +1 -1
  41. package/dist/transports/fixtures.js +1 -0
  42. package/dist/transports/fixtures.js.map +1 -1
  43. package/dist/transports/remote.d.ts.map +1 -1
  44. package/dist/transports/remote.js +15 -8
  45. package/dist/transports/remote.js.map +1 -1
  46. package/dist/transports/sse.d.ts.map +1 -1
  47. package/dist/transports/sse.js +8 -8
  48. package/dist/transports/sse.js.map +1 -1
  49. package/dist/transports/testing.d.ts +1 -1
  50. package/dist/transports/testing.d.ts.map +1 -1
  51. package/dist/transports/testing.js +12 -4
  52. package/dist/transports/testing.js.map +1 -1
  53. package/dist/transports/ws.js +2 -2
  54. package/dist/transports/ws.js.map +1 -1
  55. package/dist/types.d.ts +7 -0
  56. package/dist/types.d.ts.map +1 -1
  57. package/dist/types.js.map +1 -1
  58. package/dist/wire/documents.d.ts +25 -2
  59. package/dist/wire/documents.d.ts.map +1 -1
  60. package/dist/wire/documents.js +28 -8
  61. package/dist/wire/documents.js.map +1 -1
  62. package/dist/wire/meta.d.ts +3 -18
  63. package/dist/wire/meta.d.ts.map +1 -1
  64. package/dist/wire/meta.js +8 -57
  65. package/dist/wire/meta.js.map +1 -1
  66. package/dist/wire/ops.js +1 -1
  67. package/dist/wire/protocol.d.ts +55 -0
  68. package/dist/wire/protocol.d.ts.map +1 -0
  69. package/dist/wire/protocol.js +221 -0
  70. package/dist/wire/protocol.js.map +1 -0
  71. package/dist/wire/version.d.ts +1 -3
  72. package/dist/wire/version.d.ts.map +1 -1
  73. package/dist/wire/version.js +1 -3
  74. package/dist/wire/version.js.map +1 -1
  75. package/package.json +1 -1
  76. package/src/client/connection.ts +43 -13
  77. package/src/client/core.ts +6 -0
  78. package/src/client/session.ts +340 -45
  79. package/src/client/sessionStore.ts +8 -0
  80. package/src/document.ts +185 -0
  81. package/src/host/host.ts +1 -0
  82. package/src/host/http.ts +36 -7
  83. package/src/host/socket.ts +149 -57
  84. package/src/host-internal.ts +12 -1
  85. package/src/index.ts +5 -0
  86. package/src/statewire.ts +109 -6
  87. package/src/transports/fixtures.ts +1 -1
  88. package/src/transports/remote.ts +16 -7
  89. package/src/transports/sse.ts +18 -8
  90. package/src/transports/testing.ts +10 -2
  91. package/src/transports/ws.ts +1 -1
  92. package/src/types.ts +11 -0
  93. package/src/wire/documents.ts +126 -8
  94. package/src/wire/meta.ts +11 -94
  95. package/src/wire/protocol.ts +327 -0
  96. package/src/wire/version.ts +0 -2
package/src/wire/meta.ts CHANGED
@@ -1,60 +1,10 @@
1
- import { walkStatePath } from "../util/statePath";
1
+ import {
2
+ encodeDocument,
3
+ reviveDocumentValue as reviveValue,
4
+ } from "../document";
5
+ import type { StatewireDocument } from "../document";
2
6
 
3
- export type StatewireMetaEntry = {
4
- path: (string | number)[];
5
- type: string;
6
- };
7
-
8
- const fault = (message: string): never => {
9
- throw new Error(`statewire: protocol error: ${message}`);
10
- };
11
-
12
- const isPath = (path: unknown): path is (string | number)[] =>
13
- Array.isArray(path) &&
14
- path.every(
15
- (seg) =>
16
- typeof seg === "string" ||
17
- (typeof seg === "number" && Number.isInteger(seg)),
18
- );
19
-
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 => {
27
- if (!Array.isArray(meta)) return fault("meta must be an array");
28
- for (const entry of meta as { path?: unknown; type?: unknown }[]) {
29
- const path =
30
- typeof entry === "object" && entry !== null ? entry.path : null;
31
- if (!isPath(path)) return fault("meta entry must carry a path array");
32
- if (entry.type !== "date") {
33
- if (rejectUnknown)
34
- return fault(`unknown meta type: ${String(entry.type)}`);
35
- continue;
36
- }
37
- const walked = walkStatePath(value, path, "resolve");
38
- if (walked === undefined) {
39
- return fault(`meta path ${path.join(".")} does not resolve`);
40
- }
41
- if (typeof walked.leaf !== "string") {
42
- return fault(`meta path ${path.join(".")} must name a string`);
43
- }
44
- const date = new Date(walked.leaf);
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
- ) {
51
- return fault(`invalid date at meta path ${path.join(".")}`);
52
- }
53
- if (walked.parent === undefined) return date;
54
- (walked.parent as Record<string | number, unknown>)[walked.key] = date;
55
- }
56
- return value;
57
- };
7
+ export { reviveDocumentValue as reviveValue } from "../document";
58
8
 
59
9
  export const reviveOps = (ops: unknown[]): unknown[] =>
60
10
  ops.map((op) => {
@@ -71,44 +21,11 @@ export const reviveResponse = <T extends { payload?: unknown; meta?: unknown }>(
71
21
  return { ...rest, payload: reviveValue(rest.payload, meta) } as T;
72
22
  };
73
23
 
74
- const walkDates = (
75
- node: unknown,
76
- path: (string | number)[],
77
- meta: StatewireMetaEntry[],
78
- ): unknown => {
79
- if (node instanceof Date) {
80
- meta.push({ path, type: "date" });
81
- return node.toISOString();
82
- }
83
- if (Array.isArray(node)) {
84
- return node.map((item, i) => walkDates(item, [...path, i], meta));
85
- }
86
- if (typeof node === "object" && node !== null) {
87
- return Object.fromEntries(
88
- Object.keys(node).map((key) => [
89
- key,
90
- walkDates((node as Record<string, unknown>)[key], [...path, key], meta),
91
- ]),
92
- );
93
- }
94
- return node;
95
- };
96
-
97
- /** Encode Date instances inside command params as canonical ISO strings,
98
- * recording their paths (starting at the param index) in meta. */
99
24
  export const encodeParams = (
100
25
  params: readonly unknown[],
101
- ): { params: unknown[]; meta?: StatewireMetaEntry[] } => {
102
- const meta: StatewireMetaEntry[] = [];
103
- const encoded = params.map((param, i) => walkDates(param, [i], meta));
104
- return meta.length > 0 ? { params: encoded, meta } : { params: encoded };
105
- };
106
-
107
- /** Encode Date instances inside a wire value, recording their paths in meta. */
108
- export const encodeValue = (
109
- value: unknown,
110
- ): { value: unknown; meta?: StatewireMetaEntry[] } => {
111
- const meta: StatewireMetaEntry[] = [];
112
- const encoded = walkDates(value, [], meta);
113
- return meta.length > 0 ? { value: encoded, meta } : { value: encoded };
26
+ ): { params: unknown[]; meta?: StatewireDocument.MetaEntry[] } => {
27
+ const { value, meta } = encodeDocument(params);
28
+ return meta
29
+ ? { params: value as unknown[], meta }
30
+ : { params: value as unknown[] };
114
31
  };
@@ -0,0 +1,327 @@
1
+ export namespace StatewireProtocol {
2
+ export type Range = { version: string; minVersion?: string };
3
+ export type Offer = Range & { name: string; optional?: boolean };
4
+ export type Selection = {
5
+ version: string;
6
+ protocols: { name: string; version: string }[];
7
+ };
8
+ export type Options = {
9
+ wireVersion?: Range;
10
+ applicationProtocols?: readonly Offer[] | "*";
11
+ protocol?: string;
12
+ keyPrefixes?: Readonly<Record<string, readonly string[]>>;
13
+ };
14
+ export type Definition = Range & {
15
+ name: string;
16
+ requires?: Readonly<Record<string, Range>>;
17
+ };
18
+ }
19
+
20
+ export const WIRE_VERSION = "2026-09-07";
21
+ export const DEFAULT_PROTOCOL = "default";
22
+ export const PROTOCOL_HEADER = "Statewire-Protocol";
23
+ export const VERSION_HEADER = "Statewire-Version";
24
+ export const protocolToken = /^[A-Za-z*][A-Za-z0-9!#$%&'*+.^_`|~:/-]*$/;
25
+
26
+ const invalid = (message: string): never => {
27
+ throw new Error(`statewire: protocol error: ${message}`);
28
+ };
29
+
30
+ export const validateVersion = (value: unknown): string => {
31
+ if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value))
32
+ return invalid("version must be a YYYY-MM-DD date");
33
+ const parsed = new Date(`${value}T00:00:00.000Z`);
34
+ if (
35
+ !Number.isFinite(parsed.getTime()) ||
36
+ parsed.toISOString().slice(0, 10) !== value ||
37
+ value.startsWith("0000")
38
+ )
39
+ return invalid("invalid version date");
40
+ return value;
41
+ };
42
+
43
+ export const validateRange = (range: StatewireProtocol.Range) => {
44
+ const version = validateVersion(range.version);
45
+ const minVersion =
46
+ range.minVersion === undefined
47
+ ? version
48
+ : validateVersion(range.minVersion);
49
+ if (minVersion > version) invalid("min-version exceeds version");
50
+ return { version, minVersion };
51
+ };
52
+
53
+ export const inRange = (value: string, range: StatewireProtocol.Range) => {
54
+ const checked = validateRange(range);
55
+ return value >= checked.minVersion && value <= checked.version;
56
+ };
57
+
58
+ export const protocolOffers = (options: StatewireProtocol.Options) =>
59
+ options.applicationProtocols ?? [
60
+ { name: options.protocol ?? DEFAULT_PROTOCOL, version: WIRE_VERSION },
61
+ ];
62
+
63
+ export const negotiationHeaders = (
64
+ options: StatewireProtocol.Options,
65
+ ): Record<string, string> => {
66
+ const suffix = (range: StatewireProtocol.Range) => {
67
+ const { version, minVersion } = validateRange(range);
68
+ return `"${version}"${minVersion === version ? "" : `; min-version="${minVersion}"`}`;
69
+ };
70
+ const offers = protocolOffers(options);
71
+ const seen = new Set<string>();
72
+ if (offers !== "*" && !offers.length)
73
+ invalid("protocol list must not be empty");
74
+ return {
75
+ [VERSION_HEADER]: suffix(options.wireVersion ?? { version: WIRE_VERSION }),
76
+ [PROTOCOL_HEADER]:
77
+ offers === "*"
78
+ ? "*"
79
+ : offers
80
+ .map((offer) => {
81
+ if (
82
+ !protocolToken.test(offer.name) ||
83
+ offer.name === "*" ||
84
+ seen.has(offer.name)
85
+ )
86
+ invalid("invalid or duplicate protocol name");
87
+ if (
88
+ offer.optional !== undefined &&
89
+ typeof offer.optional !== "boolean"
90
+ )
91
+ invalid("optional must be boolean");
92
+ seen.add(offer.name);
93
+ return `${offer.name}; version=${suffix(offer)}${offer.optional ? "; optional" : ""}`;
94
+ })
95
+ .join(", "),
96
+ };
97
+ };
98
+
99
+ const parseItem = (input: string) => {
100
+ const atom = String.raw`"(?:[\x20-\x21\x23-\x5b\x5d-\x7e]|\\["\\])*"|[A-Za-z*][A-Za-z0-9!#$%&'*+.^_\x60|~:/-]*|\?[01]`;
101
+ const text = input.trim();
102
+ const match = new RegExp(`^(?:${atom})`).exec(text);
103
+ if (!match) return invalid("invalid structured item");
104
+ const quoted = match[0].startsWith('"');
105
+ const decode = (raw: string) => raw.slice(1, -1).replace(/\\(["\\])/g, "$1");
106
+ const value = quoted ? decode(match[0]) : match[0];
107
+ const params = new Map<string, string | boolean>();
108
+ let rest = text.slice(match[0].length);
109
+ while (rest) {
110
+ const parameter = new RegExp(
111
+ `^; *([a-z*][a-z0-9_.*-]*)(?:=(${atom}))?`,
112
+ ).exec(rest);
113
+ if (!parameter) return invalid("invalid structured parameters");
114
+ const key = parameter[1]!;
115
+ const raw = parameter[2];
116
+ if (params.has(key)) invalid("duplicate parameter");
117
+ if (raw === undefined || raw === "?1") params.set(key, true);
118
+ else if (raw === "?0") params.set(key, false);
119
+ else if (raw.startsWith('"')) params.set(key, decode(raw));
120
+ else invalid("parameter must be string or boolean");
121
+ rest = rest.slice(parameter[0].length);
122
+ }
123
+ return { value, quoted, params };
124
+ };
125
+
126
+ export const parseNegotiation = (
127
+ headers: Headers,
128
+ ): Required<
129
+ Pick<StatewireProtocol.Options, "wireVersion" | "applicationProtocols">
130
+ > => {
131
+ const wire = headers.get(VERSION_HEADER);
132
+ const application = headers.get(PROTOCOL_HEADER);
133
+ if (wire === null || application === null)
134
+ return invalid("negotiation fields are required");
135
+ const item = parseItem(wire);
136
+ if (!item.quoted || [...item.params.keys()].some((k) => k !== "min-version"))
137
+ invalid("invalid Statewire-Version");
138
+ const minVersion = item.params.get("min-version");
139
+ if (minVersion !== undefined && typeof minVersion !== "string")
140
+ return invalid("min-version must be a string");
141
+ const wireVersion = validateRange({
142
+ version: item.value,
143
+ ...(minVersion === undefined ? {} : { minVersion }),
144
+ });
145
+ if (application.trim() === "*")
146
+ return { wireVersion, applicationProtocols: "*" };
147
+ const names = new Set<string>();
148
+ const applicationProtocols = application
149
+ .split(",")
150
+ .map((text): StatewireProtocol.Offer => {
151
+ const { value: name, quoted, params } = parseItem(text);
152
+ if (
153
+ quoted ||
154
+ !protocolToken.test(name) ||
155
+ name === "*" ||
156
+ names.has(name)
157
+ )
158
+ invalid("invalid or duplicate protocol name");
159
+ names.add(name);
160
+ if (
161
+ [...params.keys()].some(
162
+ (k) => !["version", "min-version", "optional"].includes(k),
163
+ )
164
+ )
165
+ invalid("unsupported parameter");
166
+ const version = params.get("version");
167
+ const minVersion = params.get("min-version");
168
+ const optional = params.get("optional") ?? false;
169
+ if (
170
+ typeof version !== "string" ||
171
+ (minVersion !== undefined && typeof minVersion !== "string") ||
172
+ typeof optional !== "boolean"
173
+ )
174
+ return invalid("invalid protocol parameters");
175
+ return {
176
+ name,
177
+ ...validateRange({
178
+ version,
179
+ ...(minVersion === undefined ? {} : { minVersion }),
180
+ }),
181
+ optional,
182
+ };
183
+ });
184
+ return { wireVersion, applicationProtocols };
185
+ };
186
+
187
+ export const validateSelection = (
188
+ value: unknown,
189
+ options: StatewireProtocol.Options,
190
+ ): StatewireProtocol.Selection => {
191
+ if (typeof value !== "object" || value === null)
192
+ return invalid("missing negotiation selection");
193
+ const syn = value as Record<string, unknown>;
194
+ const version = validateVersion(syn.version);
195
+ if (!inRange(version, options.wireVersion ?? { version: WIRE_VERSION }))
196
+ invalid("selected wire version is outside offer");
197
+ if (!Array.isArray(syn.protocols))
198
+ return invalid("syn.protocols must be an array");
199
+ const offers = protocolOffers(options);
200
+ const seen = new Set<string>();
201
+ const protocols = syn.protocols.map((entry: unknown) => {
202
+ if (typeof entry !== "object" || entry === null)
203
+ return invalid("invalid selected protocol");
204
+ const item = entry as Record<string, unknown>;
205
+ const name = item.name;
206
+ if (
207
+ typeof name !== "string" ||
208
+ !protocolToken.test(name) ||
209
+ name === "*" ||
210
+ seen.has(name)
211
+ )
212
+ return invalid("invalid selected protocol name");
213
+ const version = validateVersion(item.version);
214
+ seen.add(name);
215
+ if (offers !== "*") {
216
+ const offer = offers.find((o) => o.name === name);
217
+ if (!offer || !inRange(version, offer))
218
+ invalid("selected protocol is outside offer");
219
+ }
220
+ return { name, version };
221
+ });
222
+ if (offers !== "*" && offers.some((o) => !o.optional && !seen.has(o.name)))
223
+ invalid("required protocol missing from selection");
224
+ return { version, protocols };
225
+ };
226
+
227
+ export const negotiateProtocols = (
228
+ headers: Headers,
229
+ wire: StatewireProtocol.Range,
230
+ definitions: readonly StatewireProtocol.Definition[],
231
+ ):
232
+ | StatewireProtocol.Selection
233
+ | {
234
+ fin: {
235
+ reason: "protocol-error";
236
+ code: string;
237
+ status: number;
238
+ message: string;
239
+ payload?: unknown;
240
+ };
241
+ } => {
242
+ const supported = {
243
+ ...validateRange(wire),
244
+ protocols: definitions.map((d) => ({ name: d.name, ...validateRange(d) })),
245
+ };
246
+ const failure = (code: string, message: string) => ({
247
+ fin: {
248
+ reason: "protocol-error" as const,
249
+ code,
250
+ message,
251
+ status: code === "invalid-negotiation" ? 400 : 406,
252
+ ...(code === "invalid-negotiation" ? {} : { payload: supported }),
253
+ },
254
+ });
255
+ let request: ReturnType<typeof parseNegotiation>;
256
+ try {
257
+ request = parseNegotiation(headers);
258
+ } catch (error) {
259
+ return failure(
260
+ "invalid-negotiation",
261
+ error instanceof Error ? error.message : "invalid negotiation",
262
+ );
263
+ }
264
+ const version = [wire.version, request.wireVersion.version].sort()[0]!;
265
+ if (!inRange(version, wire) || !inRange(version, request.wireVersion))
266
+ return failure("unsupported-version", "Statewire versions do not overlap");
267
+ const offers =
268
+ request.applicationProtocols === "*"
269
+ ? definitions.map((d) => ({
270
+ name: d.name,
271
+ version: d.version,
272
+ optional: false,
273
+ }))
274
+ : request.applicationProtocols;
275
+ const registry = new Map(definitions.map((d) => [d.name, d]));
276
+ if (registry.size !== definitions.length)
277
+ throw new Error("statewire: duplicate registered protocol");
278
+ const offered = new Map(offers.map((o) => [o.name, o]));
279
+ const solve = (names: Set<string>) => {
280
+ const active = new Set(names);
281
+ for (const name of active) {
282
+ const definition = registry.get(name);
283
+ if (!definition || !offered.has(name)) return undefined;
284
+ for (const dependency of Object.keys(definition.requires ?? {}))
285
+ active.add(dependency);
286
+ }
287
+ const result = new Map<string, string>();
288
+ for (const name of active) {
289
+ const ranges = [
290
+ registry.get(name)!,
291
+ offered.get(name)!,
292
+ ...[...active].flatMap((parent) => {
293
+ const dependency = registry.get(parent)!.requires?.[name];
294
+ return dependency ? [dependency] : [];
295
+ }),
296
+ ].map(validateRange);
297
+ const lower = ranges
298
+ .map((r) => r.minVersion)
299
+ .sort()
300
+ .at(-1)!;
301
+ const upper = ranges.map((r) => r.version).sort()[0]!;
302
+ if (lower > upper) return undefined;
303
+ result.set(name, upper);
304
+ }
305
+ return result;
306
+ };
307
+ let selected = solve(
308
+ new Set(offers.filter((o) => !o.optional).map((o) => o.name)),
309
+ );
310
+ if (!selected)
311
+ return failure(
312
+ "unsupported-protocol",
313
+ "required protocol combination is unsupported",
314
+ );
315
+ for (const offer of offers) {
316
+ if (offer.optional)
317
+ selected = solve(new Set([...selected.keys(), offer.name])) ?? selected;
318
+ }
319
+ return {
320
+ version,
321
+ protocols: offers.flatMap((o) =>
322
+ selected!.has(o.name)
323
+ ? [{ name: o.name, version: selected!.get(o.name)! }]
324
+ : [],
325
+ ),
326
+ };
327
+ };
@@ -1,3 +1 @@
1
- export const PROTOCOL_VERSION = "1";
2
- export const VERSION_HEADER = "Statewire-Version";
3
1
  export const WS_SUBPROTOCOL = "statewire.v1";