statewire 0.16.0 → 0.17.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 (83) 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 +19 -4
  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 +232 -50
  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/host/host.d.ts +1 -0
  18. package/dist/host/host.d.ts.map +1 -1
  19. package/dist/host/host.js.map +1 -1
  20. package/dist/host/http.d.ts.map +1 -1
  21. package/dist/host/http.js +27 -9
  22. package/dist/host/http.js.map +1 -1
  23. package/dist/host/socket.d.ts +10 -3
  24. package/dist/host/socket.d.ts.map +1 -1
  25. package/dist/host/socket.js +103 -39
  26. package/dist/host/socket.js.map +1 -1
  27. package/dist/host-internal.d.ts +9 -1
  28. package/dist/host-internal.d.ts.map +1 -1
  29. package/dist/host-internal.js +1 -1
  30. package/dist/index.d.ts +3 -1
  31. package/dist/statewire.d.ts +33 -2
  32. package/dist/statewire.d.ts.map +1 -1
  33. package/dist/statewire.js +34 -3
  34. package/dist/statewire.js.map +1 -1
  35. package/dist/transports/fixtures.js +1 -0
  36. package/dist/transports/fixtures.js.map +1 -1
  37. package/dist/transports/remote.d.ts.map +1 -1
  38. package/dist/transports/remote.js +15 -8
  39. package/dist/transports/remote.js.map +1 -1
  40. package/dist/transports/sse.d.ts.map +1 -1
  41. package/dist/transports/sse.js +8 -8
  42. package/dist/transports/sse.js.map +1 -1
  43. package/dist/transports/testing.d.ts +1 -1
  44. package/dist/transports/testing.d.ts.map +1 -1
  45. package/dist/transports/testing.js +12 -4
  46. package/dist/transports/testing.js.map +1 -1
  47. package/dist/transports/ws.js +2 -2
  48. package/dist/transports/ws.js.map +1 -1
  49. package/dist/types.d.ts +7 -0
  50. package/dist/types.d.ts.map +1 -1
  51. package/dist/types.js.map +1 -1
  52. package/dist/wire/documents.d.ts +25 -2
  53. package/dist/wire/documents.d.ts.map +1 -1
  54. package/dist/wire/documents.js +27 -7
  55. package/dist/wire/documents.js.map +1 -1
  56. package/dist/wire/protocol.d.ts +55 -0
  57. package/dist/wire/protocol.d.ts.map +1 -0
  58. package/dist/wire/protocol.js +221 -0
  59. package/dist/wire/protocol.js.map +1 -0
  60. package/dist/wire/version.d.ts +1 -3
  61. package/dist/wire/version.d.ts.map +1 -1
  62. package/dist/wire/version.js +1 -3
  63. package/dist/wire/version.js.map +1 -1
  64. package/package.json +1 -1
  65. package/src/client/connection.ts +43 -13
  66. package/src/client/core.ts +6 -0
  67. package/src/client/session.ts +338 -39
  68. package/src/client/sessionStore.ts +8 -0
  69. package/src/host/host.ts +1 -0
  70. package/src/host/http.ts +36 -7
  71. package/src/host/socket.ts +141 -50
  72. package/src/host-internal.ts +12 -1
  73. package/src/index.ts +3 -0
  74. package/src/statewire.ts +109 -6
  75. package/src/transports/fixtures.ts +1 -1
  76. package/src/transports/remote.ts +16 -7
  77. package/src/transports/sse.ts +18 -8
  78. package/src/transports/testing.ts +10 -2
  79. package/src/transports/ws.ts +1 -1
  80. package/src/types.ts +11 -0
  81. package/src/wire/documents.ts +126 -8
  82. package/src/wire/protocol.ts +327 -0
  83. package/src/wire/version.ts +0 -2
@@ -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";