statewire 0.17.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.
package/dist/wire/meta.js CHANGED
@@ -1,36 +1,11 @@
1
- import { walkStatePath } from "../util/statePath.js";
1
+ import { encodeDocument, reviveDocumentValue } from "../document.js";
2
2
  //#region src/wire/meta.ts
3
- const fault = (message) => {
4
- throw new Error(`statewire: protocol error: ${message}`);
5
- };
6
- const isPath = (path) => Array.isArray(path) && path.every((seg) => typeof seg === "string" || typeof seg === "number" && Number.isInteger(seg));
7
- /** Revive meta-typed locations inside a wire value (in place). Clients skip
8
- * unknown types; hosts pass `rejectUnknown` to fault on them instead. */
9
- const reviveValue = (value, meta, { rejectUnknown = false } = {}) => {
10
- if (!Array.isArray(meta)) return fault("meta must be an array");
11
- for (const entry of meta) {
12
- const path = typeof entry === "object" && entry !== null ? entry.path : null;
13
- if (!isPath(path)) return fault("meta entry must carry a path array");
14
- if (entry.type !== "date") {
15
- if (rejectUnknown) return fault(`unknown meta type: ${String(entry.type)}`);
16
- continue;
17
- }
18
- const walked = walkStatePath(value, path, "resolve");
19
- if (walked === void 0) return fault(`meta path ${path.join(".")} does not resolve`);
20
- if (typeof walked.leaf !== "string") return fault(`meta path ${path.join(".")} must name a string`);
21
- const date = new Date(walked.leaf);
22
- if (!/(?:T| )\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})$/i.test(walked.leaf) || Number.isNaN(date.getTime())) return fault(`invalid date at meta path ${path.join(".")}`);
23
- if (walked.parent === void 0) return date;
24
- walked.parent[walked.key] = date;
25
- }
26
- return value;
27
- };
28
3
  const reviveOps = (ops) => ops.map((op) => {
29
4
  if (typeof op !== "object" || op === null || !("meta" in op)) return op;
30
5
  const { meta, ...rest } = op;
31
6
  return {
32
7
  ...rest,
33
- value: reviveValue(rest.value, meta)
8
+ value: reviveDocumentValue(rest.value, meta)
34
9
  };
35
10
  });
36
11
  const reviveResponse = (response) => {
@@ -38,41 +13,17 @@ const reviveResponse = (response) => {
38
13
  const { meta, ...rest } = response;
39
14
  return {
40
15
  ...rest,
41
- payload: reviveValue(rest.payload, meta)
16
+ payload: reviveDocumentValue(rest.payload, meta)
42
17
  };
43
18
  };
44
- const walkDates = (node, path, meta) => {
45
- if (node instanceof Date) {
46
- meta.push({
47
- path,
48
- type: "date"
49
- });
50
- return node.toISOString();
51
- }
52
- if (Array.isArray(node)) return node.map((item, i) => walkDates(item, [...path, i], meta));
53
- if (typeof node === "object" && node !== null) return Object.fromEntries(Object.keys(node).map((key) => [key, walkDates(node[key], [...path, key], meta)]));
54
- return node;
55
- };
56
- /** Encode Date instances inside command params as canonical ISO strings,
57
- * recording their paths (starting at the param index) in meta. */
58
19
  const encodeParams = (params) => {
59
- const meta = [];
60
- const encoded = params.map((param, i) => walkDates(param, [i], meta));
61
- return meta.length > 0 ? {
62
- params: encoded,
63
- meta
64
- } : { params: encoded };
65
- };
66
- /** Encode Date instances inside a wire value, recording their paths in meta. */
67
- const encodeValue = (value) => {
68
- const meta = [];
69
- const encoded = walkDates(value, [], meta);
70
- return meta.length > 0 ? {
71
- value: encoded,
20
+ const { value, meta } = encodeDocument(params);
21
+ return meta ? {
22
+ params: value,
72
23
  meta
73
- } : { value: encoded };
24
+ } : { params: value };
74
25
  };
75
26
  //#endregion
76
- export { encodeParams, encodeValue, reviveOps, reviveResponse, reviveValue };
27
+ export { encodeParams, reviveOps, reviveResponse, reviveDocumentValue as reviveValue };
77
28
 
78
29
  //# sourceMappingURL=meta.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"meta.js","names":["walkStatePath","StatewireMetaEntry","path","type","fault","message","Error","isPath","Array","isArray","every","seg","Number","isInteger","reviveValue","value","meta","rejectUnknown","entry","String","walked","undefined","join","leaf","date","Date","test","isNaN","getTime","parent","Record","key","reviveOps","ops","map","op","rest","reviveResponse","payload","response","T","walkDates","node","push","toISOString","item","i","Object","fromEntries","keys","encodeParams","params","encoded","param","length","encodeValue"],"sources":["../../src/wire/meta.ts"],"sourcesContent":["import { walkStatePath } from \"../util/statePath\";\n\nexport type StatewireMetaEntry = {\n path: (string | number)[];\n type: string;\n};\n\nconst fault = (message: string): never => {\n throw new Error(`statewire: protocol error: ${message}`);\n};\n\nconst isPath = (path: unknown): path is (string | number)[] =>\n Array.isArray(path) &&\n path.every(\n (seg) =>\n typeof seg === \"string\" ||\n (typeof seg === \"number\" && Number.isInteger(seg)),\n );\n\n/** Revive meta-typed locations inside a wire value (in place). Clients skip\n * unknown types; hosts pass `rejectUnknown` to fault on them instead. */\nexport const reviveValue = (\n value: unknown,\n meta: unknown,\n { rejectUnknown = false } = {},\n): unknown => {\n if (!Array.isArray(meta)) return fault(\"meta must be an array\");\n for (const entry of meta as { path?: unknown; type?: unknown }[]) {\n const path =\n typeof entry === \"object\" && entry !== null ? entry.path : null;\n if (!isPath(path)) return fault(\"meta entry must carry a path array\");\n if (entry.type !== \"date\") {\n if (rejectUnknown)\n return fault(`unknown meta type: ${String(entry.type)}`);\n continue;\n }\n const walked = walkStatePath(value, path, \"resolve\");\n if (walked === undefined) {\n return fault(`meta path ${path.join(\".\")} does not resolve`);\n }\n if (typeof walked.leaf !== \"string\") {\n return fault(`meta path ${path.join(\".\")} must name a string`);\n }\n const date = new Date(walked.leaf);\n if (\n !/(?:T| )\\d{2}:\\d{2}(?::\\d{2}(?:\\.\\d+)?)?(?:Z|[+-]\\d{2}:?\\d{2})$/i.test(\n walked.leaf,\n ) ||\n Number.isNaN(date.getTime())\n ) {\n return fault(`invalid date at meta path ${path.join(\".\")}`);\n }\n if (walked.parent === undefined) return date;\n (walked.parent as Record<string | number, unknown>)[walked.key] = date;\n }\n return value;\n};\n\nexport const reviveOps = (ops: unknown[]): unknown[] =>\n ops.map((op) => {\n if (typeof op !== \"object\" || op === null || !(\"meta\" in op)) return op;\n const { meta, ...rest } = op as { meta: unknown; value?: unknown };\n return { ...rest, value: reviveValue(rest.value, meta) };\n });\n\nexport const reviveResponse = <T extends { payload?: unknown; meta?: unknown }>(\n response: T,\n): T => {\n if (response.meta === undefined) return response;\n const { meta, ...rest } = response;\n return { ...rest, payload: reviveValue(rest.payload, meta) } as T;\n};\n\nconst walkDates = (\n node: unknown,\n path: (string | number)[],\n meta: StatewireMetaEntry[],\n): unknown => {\n if (node instanceof Date) {\n meta.push({ path, type: \"date\" });\n return node.toISOString();\n }\n if (Array.isArray(node)) {\n return node.map((item, i) => walkDates(item, [...path, i], meta));\n }\n if (typeof node === \"object\" && node !== null) {\n return Object.fromEntries(\n Object.keys(node).map((key) => [\n key,\n walkDates((node as Record<string, unknown>)[key], [...path, key], meta),\n ]),\n );\n }\n return node;\n};\n\n/** Encode Date instances inside command params as canonical ISO strings,\n * recording their paths (starting at the param index) in meta. */\nexport const encodeParams = (\n params: readonly unknown[],\n): { params: unknown[]; meta?: StatewireMetaEntry[] } => {\n const meta: StatewireMetaEntry[] = [];\n const encoded = params.map((param, i) => walkDates(param, [i], meta));\n return meta.length > 0 ? { params: encoded, meta } : { params: encoded };\n};\n\n/** Encode Date instances inside a wire value, recording their paths in meta. */\nexport const encodeValue = (\n value: unknown,\n): { value: unknown; meta?: StatewireMetaEntry[] } => {\n const meta: StatewireMetaEntry[] = [];\n const encoded = walkDates(value, [], meta);\n return meta.length > 0 ? { value: encoded, meta } : { value: encoded };\n};\n"],"mappings":"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"}
1
+ {"version":3,"file":"meta.js","names":["encodeDocument","reviveDocumentValue","reviveValue","StatewireDocument","reviveOps","ops","map","op","meta","rest","value","reviveResponse","payload","response","T","undefined","encodeParams","params","MetaEntry"],"sources":["../../src/wire/meta.ts"],"sourcesContent":["import {\n encodeDocument,\n reviveDocumentValue as reviveValue,\n} from \"../document\";\nimport type { StatewireDocument } from \"../document\";\n\nexport { reviveDocumentValue as reviveValue } from \"../document\";\n\nexport const reviveOps = (ops: unknown[]): unknown[] =>\n ops.map((op) => {\n if (typeof op !== \"object\" || op === null || !(\"meta\" in op)) return op;\n const { meta, ...rest } = op as { meta: unknown; value?: unknown };\n return { ...rest, value: reviveValue(rest.value, meta) };\n });\n\nexport const reviveResponse = <T extends { payload?: unknown; meta?: unknown }>(\n response: T,\n): T => {\n if (response.meta === undefined) return response;\n const { meta, ...rest } = response;\n return { ...rest, payload: reviveValue(rest.payload, meta) } as T;\n};\n\nexport const encodeParams = (\n params: readonly unknown[],\n): { params: unknown[]; meta?: StatewireDocument.MetaEntry[] } => {\n const { value, meta } = encodeDocument(params);\n return meta\n ? { params: value as unknown[], meta }\n : { params: value as unknown[] };\n};\n"],"mappings":";;AAQA,MAAaI,aAAaC,QACxBA,IAAIC,KAAKC,OAAO;CACd,IAAI,OAAOA,OAAO,YAAYA,OAAO,QAAQ,EAAE,UAAUA,KAAK,OAAOA;CACrE,MAAM,EAAEC,MAAM,GAAGC,SAASF;CAC1B,OAAO;EAAE,GAAGE;EAAMC,OAAOR,oBAAYO,KAAKC,OAAOF,IAAI;CAAE;AACzD,CAAC;AAEH,MAAaG,kBACXE,aACM;CACN,IAAIA,SAASL,SAASO,KAAAA,GAAW,OAAOF;CACxC,MAAM,EAAEL,MAAM,GAAGC,SAASI;CAC1B,OAAO;EAAE,GAAGJ;EAAMG,SAASV,oBAAYO,KAAKG,SAASJ,IAAI;CAAE;AAC7D;AAEA,MAAaQ,gBACXC,WACgE;CAChE,MAAM,EAAEP,OAAOF,SAASR,eAAeiB,MAAM;CAC7C,OAAOT,OACH;EAAES,QAAQP;EAAoBF;CAAK,IACnC,EAAES,QAAQP,MAAmB;AACnC"}
package/dist/wire/ops.js CHANGED
@@ -1,5 +1,5 @@
1
- import { recordMutations } from "../util/recordMutations.js";
2
1
  import { isUnsafeKey, walkStatePath } from "../util/statePath.js";
2
+ import { recordMutations } from "../util/recordMutations.js";
3
3
  //#region src/wire/ops.ts
4
4
  const warnStripped = (key, parent) => {
5
5
  if (typeof process === "undefined" || process.env["NODE_ENV"] !== "production") console.warn(`statewire: stripped unsafe key "${key}"` + (parent === void 0 ? "" : ` under "${parent}"`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "statewire",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "State over the wire: snapshot-first delta streaming and command submission for any state and any command",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,3 +1,4 @@
1
+ import { encodeDocument } from "../document";
1
2
  import {
2
3
  DEFAULT_PROTOCOL,
3
4
  WIRE_VERSION,
@@ -18,12 +19,7 @@ import type { StatewirePath } from "../wire/ops";
18
19
  import { MAX_COMMANDS_PER_FRAME } from "../wire/limits";
19
20
  import { TERMINAL } from "./connection";
20
21
  import type { StatewireCmd, StatewireSyn } from "./connection";
21
- import {
22
- encodeParams,
23
- encodeValue,
24
- reviveResponse,
25
- reviveValue,
26
- } from "../wire/meta";
22
+ import { encodeParams, reviveResponse, reviveValue } from "../wire/meta";
27
23
  import { bindCommandsHost } from "../host/host";
28
24
  import type { CommandsInvocation, CommandsListener } from "../host/host";
29
25
  import type { Statewire } from "../types";
@@ -1165,7 +1161,7 @@ export const retryFailed = (
1165
1161
 
1166
1162
  const contextSnapshot = (documents: StatewireDocuments.State) =>
1167
1163
  [...documents.documents].map(([doc, d]) => {
1168
- const encoded = encodeValue(d.value);
1164
+ const encoded = encodeDocument(d.value);
1169
1165
  return {
1170
1166
  op: "mount",
1171
1167
  ...(doc === 0 ? {} : { doc }),
@@ -1253,7 +1249,7 @@ const setContext = (
1253
1249
  ): void => {
1254
1250
  if (typeof value !== "object" || value === null || Array.isArray(value))
1255
1251
  throw new Error("statewire: context must be a plain object");
1256
- const encoded = encodeValue(value);
1252
+ const encoded = encodeDocument(value);
1257
1253
  changeContext(s, (documents) => {
1258
1254
  const found = [...documents.documents].find(
1259
1255
  ([, d]) => d.protocol === protocol && d.main,
@@ -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
+ };
@@ -1,3 +1,4 @@
1
+ import { encodeDocument } from "../document";
1
2
  import {
2
3
  DEFAULT_PROTOCOL,
3
4
  WIRE_VERSION,
@@ -15,7 +16,7 @@ import { StatewireSendError } from "../types";
15
16
  import type { Statewire } from "../types";
16
17
  import type { StatewireHost } from "../host";
17
18
  import type { StatewireHostInternal } from "../host-internal";
18
- import { encodeValue, reviveValue } from "../wire/meta";
19
+ import { reviveValue } from "../wire/meta";
19
20
  import {
20
21
  COMMAND_CAPACITY_RETRY_MS,
21
22
  MAX_COMMANDS_PER_FRAME,
@@ -25,7 +26,7 @@ import {
25
26
  MAX_INFLIGHT_COMMANDS_PER_HOST,
26
27
  } from "../wire/limits";
27
28
  import { createMacrotaskPoster } from "../util/macrotask";
28
- import type { StatewireMetaEntry } from "../wire/meta";
29
+ import type { StatewireDocument } from "../document";
29
30
  import type { StatewirePath } from "../wire/ops";
30
31
  import type { StatewireCmd, StatewireEnvelope } from "../client/connection";
31
32
  import { frames as serveFrames, stream as serveStream } from "./http";
@@ -79,7 +80,7 @@ type WireOp = {
79
80
  op: string;
80
81
  path?: StatewirePath;
81
82
  value?: unknown;
82
- meta?: StatewireMetaEntry[];
83
+ meta?: StatewireDocument.MetaEntry[];
83
84
  };
84
85
 
85
86
  type Admission = {
@@ -242,9 +243,9 @@ const frameIsOversized = (raw: string): boolean =>
242
243
 
243
244
  const encodePayload = (
244
245
  payload: unknown,
245
- ): { payload?: unknown; meta?: StatewireMetaEntry[] } => {
246
+ ): { payload?: unknown; meta?: StatewireDocument.MetaEntry[] } => {
246
247
  if (payload === undefined) return {};
247
- const { value, meta } = encodeValue(payload);
248
+ const { value, meta } = encodeDocument(payload);
248
249
  return { payload: value, ...(meta !== undefined && { meta }) };
249
250
  };
250
251
 
@@ -374,7 +375,7 @@ const recordOp = (
374
375
  },
375
376
  ) => {
376
377
  if (op.op === "replace" || op.op === "add") {
377
- const { value, meta } = encodeValue(op.value);
378
+ const { value, meta } = encodeDocument(op.value);
378
379
  h.pendingOps.push({ ...op, value, ...(meta !== undefined && { meta }) });
379
380
  } else {
380
381
  h.pendingOps.push(op);
@@ -1290,7 +1291,7 @@ export const register = (
1290
1291
  gcClients(h);
1291
1292
  notifyClientRecords(h);
1292
1293
  ensureHeartbeat(h);
1293
- const { value, meta } = encodeValue(h.stateHost!.snapshot());
1294
+ const { value, meta } = encodeDocument(h.stateHost!.snapshot());
1294
1295
  const hello: StatewireEnvelope = {
1295
1296
  ops: [
1296
1297
  {
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export type { Statewire } from "./types";
2
+ export { encodeDocument, decodeDocument } from "./document";
3
+ export type { StatewireDocument } from "./document";
2
4
  export { StatewireClient, useStatewire, StatewireResource } from "./statewire";
3
5
  export { StatewireHttp } from "./transports/sse";
4
6
  export { StatewireWebsocket } from "./transports/ws";
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
  };