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
@@ -0,0 +1,185 @@
1
+ import { isUnsafeKey, walkStatePath } from "./util/statePath";
2
+
3
+ export namespace StatewireDocument {
4
+ export type Value =
5
+ | null
6
+ | boolean
7
+ | number
8
+ | string
9
+ | Value[]
10
+ | { [key: string]: Value };
11
+ export type MetaEntry = { path: (string | number)[]; type: "date" };
12
+ export type Encoded = { value: Value; meta?: MetaEntry[] };
13
+ }
14
+
15
+ const invalid = (message: string): never => {
16
+ throw new Error(`statewire: protocol error: invalid document: ${message}`);
17
+ };
18
+
19
+ const isObject = (value: unknown): value is Record<string, unknown> =>
20
+ typeof value === "object" &&
21
+ value !== null &&
22
+ (Object.getPrototypeOf(value) === Object.prototype ||
23
+ Object.getPrototypeOf(value) === null);
24
+
25
+ const copyValue = (
26
+ value: unknown,
27
+ dates: boolean,
28
+ meta: StatewireDocument.MetaEntry[],
29
+ path: (string | number)[] = [],
30
+ ancestors = new Set<object>(),
31
+ ): StatewireDocument.Value => {
32
+ if (value === null || typeof value === "boolean" || typeof value === "string")
33
+ return value;
34
+ if (typeof value === "number") {
35
+ if (
36
+ !Number.isFinite(value) ||
37
+ (Number.isInteger(value) && !Number.isSafeInteger(value))
38
+ )
39
+ invalid(
40
+ `number at ${JSON.stringify(path)} must be finite and within the safe integer range`,
41
+ );
42
+ return value;
43
+ }
44
+ if (dates && value instanceof Date) {
45
+ if (
46
+ !Number.isFinite(value.getTime()) ||
47
+ value.getUTCFullYear() < 1 ||
48
+ value.getUTCFullYear() > 9999
49
+ )
50
+ invalid(
51
+ `date at ${JSON.stringify(path)} must have a year from 0001 to 9999`,
52
+ );
53
+ meta.push({ path, type: "date" });
54
+ return value.toISOString();
55
+ }
56
+ if (!Array.isArray(value) && !isObject(value))
57
+ return invalid(`unsupported value at ${JSON.stringify(path)}`);
58
+ if (ancestors.has(value)) invalid(`cycle at ${JSON.stringify(path)}`);
59
+ ancestors.add(value);
60
+ const descriptors = Object.getOwnPropertyDescriptors(value);
61
+ for (const key of Reflect.ownKeys(descriptors)) {
62
+ if (Array.isArray(value) && key === "length") continue;
63
+ if (typeof key !== "string")
64
+ invalid(`symbol key at ${JSON.stringify(path)}`);
65
+ const descriptor = descriptors[key as string]!;
66
+ if (!("value" in descriptor) || !descriptor.enumerable)
67
+ invalid(
68
+ `property at ${JSON.stringify([...path, key])} must be enumerable data`,
69
+ );
70
+ if (isUnsafeKey(key as string, path.at(-1)))
71
+ invalid(`unsafe key at ${JSON.stringify([...path, key])}`);
72
+ if (
73
+ Array.isArray(value) &&
74
+ (!/^(0|[1-9]\d*)$/.test(key as string) || Number(key) >= value.length)
75
+ )
76
+ invalid(
77
+ `unsupported array property at ${JSON.stringify([...path, key])}`,
78
+ );
79
+ }
80
+ let result: StatewireDocument.Value;
81
+ if (Array.isArray(value)) {
82
+ result = Array.from({ length: value.length }, (_, index) => {
83
+ if (!Object.hasOwn(value, index))
84
+ invalid(`sparse array at ${JSON.stringify(path)}`);
85
+ return copyValue(
86
+ descriptors[index]!.value,
87
+ dates,
88
+ meta,
89
+ [...path, index],
90
+ ancestors,
91
+ );
92
+ });
93
+ } else {
94
+ result = Object.fromEntries(
95
+ Object.keys(descriptors).map((key) => [
96
+ key,
97
+ copyValue(
98
+ descriptors[key]!.value,
99
+ dates,
100
+ meta,
101
+ [...path, key],
102
+ ancestors,
103
+ ),
104
+ ]),
105
+ );
106
+ }
107
+ ancestors.delete(value);
108
+ return result;
109
+ };
110
+
111
+ /** Encode a document value as detached JSON data with date metadata. */
112
+ export const encodeDocument = (value: unknown): StatewireDocument.Encoded => {
113
+ const meta: StatewireDocument.MetaEntry[] = [];
114
+ const encoded = copyValue(value, true, meta);
115
+ return meta.length ? { value: encoded, meta } : { value: encoded };
116
+ };
117
+
118
+ /** Validate and decode a document into detached values with native dates. */
119
+ export const decodeDocument = (encoded: unknown): unknown => {
120
+ const document = copyValue(encoded, false, []);
121
+ if (
122
+ !isObject(document) ||
123
+ !Object.hasOwn(document, "value") ||
124
+ Object.keys(document).some((key) => key !== "value" && key !== "meta")
125
+ )
126
+ return invalid("expected an object with value and optional meta");
127
+ const value: unknown = document.value;
128
+ if (!Object.hasOwn(document, "meta")) return value;
129
+ if (!Array.isArray(document.meta)) return invalid("meta must be an array");
130
+ return reviveDocumentValue(value, document.meta, {
131
+ rejectUnknown: true,
132
+ canonical: true,
133
+ });
134
+ };
135
+
136
+ export const reviveDocumentValue = (
137
+ value: unknown,
138
+ meta: unknown,
139
+ { rejectUnknown = false, canonical = false } = {},
140
+ ): unknown => {
141
+ if (!Array.isArray(meta)) return invalid("meta must be an array");
142
+ const seen = new Set<string>();
143
+ for (const entry of meta) {
144
+ if (
145
+ !isObject(entry) ||
146
+ !Array.isArray(entry.path) ||
147
+ (canonical &&
148
+ Object.keys(entry).some((key) => key !== "path" && key !== "type")) ||
149
+ !entry.path.every(
150
+ (segment) =>
151
+ typeof segment === "string" ||
152
+ (typeof segment === "number" &&
153
+ Number.isSafeInteger(segment) &&
154
+ segment >= 0),
155
+ )
156
+ )
157
+ return invalid("meta entry requires a path array and type date");
158
+ if (entry.type !== "date") {
159
+ if (rejectUnknown) invalid(`unknown meta type: ${String(entry.type)}`);
160
+ continue;
161
+ }
162
+ const path = entry.path as (string | number)[];
163
+ const identity = JSON.stringify(path);
164
+ if (seen.has(identity)) invalid(`duplicate meta path ${identity}`);
165
+ seen.add(identity);
166
+ const walked = walkStatePath(value, path, "resolve");
167
+ if (!walked || typeof walked.leaf !== "string")
168
+ return invalid(`meta path ${identity} must resolve to a string`);
169
+ const date = new Date(walked.leaf);
170
+ if (
171
+ canonical
172
+ ? !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(walked.leaf) ||
173
+ !Number.isFinite(date.getTime()) ||
174
+ date.getUTCFullYear() < 1 ||
175
+ date.toISOString() !== walked.leaf
176
+ : !/(?:T| )\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})$/i.test(
177
+ walked.leaf,
178
+ ) || !Number.isFinite(date.getTime())
179
+ )
180
+ return invalid(`invalid date at meta path ${identity}`);
181
+ if (walked.parent === undefined) value = date;
182
+ else (walked.parent as Record<string | number, unknown>)[walked.key] = date;
183
+ }
184
+ return value;
185
+ };
package/src/host/host.ts CHANGED
@@ -102,6 +102,7 @@ type Settled =
102
102
  | { type: "rejected"; error: unknown };
103
103
 
104
104
  export type CommandsInvocation = {
105
+ readonly protocol?: string;
105
106
  readonly method: string;
106
107
  readonly params: readonly unknown[];
107
108
  readonly ts: number;
package/src/host/http.ts CHANGED
@@ -15,7 +15,8 @@ import {
15
15
  } from "./socket";
16
16
  import type { ParsedFrame, Registration, SocketHostState } from "./socket";
17
17
  import { MAX_FRAME_BODY_BYTES } from "../wire/limits";
18
- import { PROTOCOL_VERSION, VERSION_HEADER } from "../wire/version";
18
+ import { negotiateProtocols } from "../wire/protocol";
19
+ import type { StatewireProtocol } from "../wire/protocol";
19
20
 
20
21
  export { MAX_FRAME_BODY_BYTES } from "../wire/limits";
21
22
 
@@ -139,6 +140,7 @@ const attachSse = (
139
140
  h: SocketHostState,
140
141
  clientId: string,
141
142
  auth: unknown,
143
+ selection: StatewireProtocol.Selection,
142
144
  ): SseAttachment => {
143
145
  let controller: ReadableStreamDefaultController<Uint8Array>;
144
146
  let connection: Registration["connection"] | null = null;
@@ -192,7 +194,7 @@ const attachSse = (
192
194
  }
193
195
  },
194
196
  };
195
- const registration = register(h, clientId, socket, false, auth);
197
+ const registration = register(h, clientId, socket, false, auth, selection);
196
198
  connection = registration?.connection ?? null;
197
199
  if (!open) connection?.close();
198
200
  return {
@@ -213,7 +215,6 @@ const streamResponse = (
213
215
  status: 200,
214
216
  headers: {
215
217
  "Content-Type": "text/event-stream",
216
- [VERSION_HEADER]: PROTOCOL_VERSION,
217
218
  ...(lease !== null && { [LEASE_HEADER]: lease }),
218
219
  },
219
220
  });
@@ -230,6 +231,7 @@ const admitAttachFrame = (
230
231
  registration.rec,
231
232
  frame,
232
233
  registration.conn.auth,
234
+ registration.conn.selection,
233
235
  );
234
236
  if (admission.type === "refused") {
235
237
  sendTo(h, registration.conn, JSON.stringify(admission.syn));
@@ -251,7 +253,12 @@ const getStream = async (
251
253
  );
252
254
  if (mismatch !== null) return mismatch;
253
255
  if (atSubscriberLimit(h)) return subscriberLimitResponse();
254
- const attachment = attachSse(h, client.clientId, authorized.auth);
256
+ const selection = negotiateProtocols(request.headers, h.wireVersion, [
257
+ h.protocol,
258
+ ]);
259
+ if ("fin" in selection)
260
+ return Response.json(selection, { status: selection.fin.status });
261
+ const attachment = attachSse(h, client.clientId, authorized.auth, selection);
255
262
  const lease = attachment.registration === null ? null : mintLease();
256
263
  if (attachment.registration !== null)
257
264
  attachment.registration.conn.lease = lease;
@@ -295,11 +302,26 @@ const postStream = async (
295
302
  );
296
303
  if (mismatch !== null) return mismatch;
297
304
  if (atSubscriberLimit(h)) return subscriberLimitResponse();
298
- const attachment = attachSse(h, client.clientId, authorized.auth);
305
+ const selection = negotiateProtocols(request.headers, h.wireVersion, [
306
+ h.protocol,
307
+ ]);
308
+ if ("fin" in selection)
309
+ return Response.json(selection, { status: selection.fin.status });
310
+ const attachment = attachSse(h, client.clientId, authorized.auth, selection);
299
311
  if (attachment.registration === null) return streamResponse(attachment, null);
300
312
  const lease = mintLease();
301
313
  attachment.registration.conn.lease = lease;
302
- admitAttachFrame(h, attachment.registration, frame);
314
+ try {
315
+ admitAttachFrame(h, attachment.registration, frame);
316
+ } catch (error) {
317
+ closeConn(h, attachment.registration.conn, 1008, "invalid-context");
318
+ if (!(error instanceof Error) || error.name !== "StatewireViolation")
319
+ throw error;
320
+ return Response.json(
321
+ { fin: { reason: "protocol-error", message: error.message } },
322
+ { status: 400 },
323
+ );
324
+ }
303
325
  return streamResponse(attachment, lease);
304
326
  };
305
327
 
@@ -371,7 +393,14 @@ export const frames = async (
371
393
  if (!leased.open || h.disposed || h.finished !== null) {
372
394
  return Response.json({ error: "stale-lease" }, { status: 423 });
373
395
  }
374
- const admission = admitFrame(h, rec, frame, leased.auth);
396
+ let admission: ReturnType<typeof admitFrame>;
397
+ try {
398
+ admission = admitFrame(h, rec, frame, leased.auth, leased.selection);
399
+ } catch (error) {
400
+ if (!(error instanceof Error) || error.name !== "StatewireViolation")
401
+ throw error;
402
+ return violationResponse(h, clientId, error.message);
403
+ }
375
404
  if (admission.type === "refused") {
376
405
  const retry = admission.syn.syn.retry;
377
406
  return Response.json(admission.syn, {