statewire 0.11.0 → 0.12.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 (51) hide show
  1. package/README.md +2 -2
  2. package/dist/client/connection.d.ts +6 -4
  3. package/dist/client/connection.d.ts.map +1 -1
  4. package/dist/client/connection.js +10 -2
  5. package/dist/client/connection.js.map +1 -1
  6. package/dist/client/core.d.ts +1 -1
  7. package/dist/client/session.d.ts +4 -2
  8. package/dist/client/session.d.ts.map +1 -1
  9. package/dist/client/session.js +102 -102
  10. package/dist/client/session.js.map +1 -1
  11. package/dist/host/host.d.ts +1 -1
  12. package/dist/host/http.js +4 -3
  13. package/dist/host/http.js.map +1 -1
  14. package/dist/host/socket.d.ts +6 -5
  15. package/dist/host/socket.d.ts.map +1 -1
  16. package/dist/host/socket.js +12 -20
  17. package/dist/host/socket.js.map +1 -1
  18. package/dist/host-internal.d.ts +2 -1
  19. package/dist/host-internal.d.ts.map +1 -1
  20. package/dist/host.d.ts +2 -2
  21. package/dist/host.d.ts.map +1 -1
  22. package/dist/index.d.ts +1 -1
  23. package/dist/statewire.d.ts +15 -4
  24. package/dist/statewire.d.ts.map +1 -1
  25. package/dist/statewire.js.map +1 -1
  26. package/dist/transports/fake-sse.js +4 -4
  27. package/dist/transports/fake-sse.js.map +1 -1
  28. package/dist/transports/fixtures.d.ts +1 -1
  29. package/dist/transports/remote.d.ts +1 -1
  30. package/dist/transports/remote.js +1 -0
  31. package/dist/transports/remote.js.map +1 -1
  32. package/dist/transports/sse.d.ts +2 -2
  33. package/dist/transports/testing.d.ts +1 -1
  34. package/dist/transports/testing.js +2 -4
  35. package/dist/transports/testing.js.map +1 -1
  36. package/dist/wire/meta.d.ts +5 -3
  37. package/dist/wire/meta.d.ts.map +1 -1
  38. package/dist/wire/meta.js +7 -4
  39. package/dist/wire/meta.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/client/connection.ts +12 -6
  42. package/src/client/session.ts +145 -139
  43. package/src/host/http.ts +10 -9
  44. package/src/host/socket.ts +18 -28
  45. package/src/host-internal.ts +2 -1
  46. package/src/host.ts +0 -1
  47. package/src/statewire.ts +18 -4
  48. package/src/transports/fake-sse.ts +6 -6
  49. package/src/transports/remote.ts +1 -0
  50. package/src/transports/testing.ts +2 -7
  51. package/src/wire/meta.ts +12 -4
@@ -1 +1 @@
1
- {"version":3,"file":"testing.js","names":["ResourceElement","StatewireEnvelope","StatewireSSE","StatewireFrame","Statewire","openSSEStream","sseFrame","FakeStreamHandle","tick","Promise","r","setTimeout","applyOptimistic","MockTransport","transport","TState","commands","method","params","context","setState","state","applied","seq","settle","payload","sendEnvelope","envelope","finish","reason","message","clientId","attached","mockCount","createMockTransport","initialState","finished","clients","Map","ctxSeq","streams","Set","stream","send","admit","id","frame","rec","get","statement","ctx","Math","max","root","ops","value","push","cmd","fakeFetch","input","RequestInfo","URL","init","RequestInit","Response","TypeError","url","String","headers","Headers","endsWith","known","has","set","JSON","parse","body","handle","op","path","syn","record","signal","undefined","json","status","fetch","next","type","fin","close","size"],"sources":["../../src/transports/testing.ts"],"sourcesContent":["import type { ResourceElement } from \"@assistant-ui/tap\";\nimport type { StatewireEnvelope } from \"../client/connection\";\nimport { StatewireSSE } from \"./sse\";\nimport type { StatewireFrame } from \"./sse\";\nimport type { Statewire } from \"../types\";\nimport { openSSEStream, sseFrame } from \"./fake-sse\";\nimport type { FakeStreamHandle } from \"./fake-sse\";\n\nexport * from \"./fixtures\";\n\n/** Let queued microtasks and op batches drain through the statewire client. */\nexport const tick = () => new Promise<void>((r) => setTimeout(r, 0));\n\nexport { applyOptimistic } from \"../util/recordMutations\";\n\nexport type MockTransport<TState> = {\n /** Pass as the `transport:` option of useStatewire / Harness. */\n transport: ResourceElement<Statewire<TState | undefined, any>>;\n /** Captured statements in arrival order: cmd as `{method, params}`, ctx as `{context}`. */\n commands: { method?: string; params?: unknown[]; context?: unknown }[];\n /** Replace the replicated state and broadcast it as a root snapshot op. */\n setState: (state: TState) => Promise<void>;\n /** Deliver a bare ack cmd packet on the stream. */\n applied: (seq: number) => Promise<void>;\n /** Deliver a terminal result cmd packet on the stream. */\n settle: (seq: number, payload?: unknown) => Promise<void>;\n /** Deliver an arbitrary envelope on the stream. */\n sendEnvelope: (envelope: StatewireEnvelope) => Promise<void>;\n /** End the stream with a fin; further attaches fail like a dead server. */\n finish: (reason: string, message?: string) => Promise<void>;\n readonly clientId: string;\n readonly attached: boolean;\n};\n\n/**\n * A mock statewire backend: serves `initialState` as the attach snapshot,\n * records submitted frames (receipt only — acks and results are delivered\n * from test code), and exposes the wire as a transport ResourceElement.\n */\nlet mockCount = 0;\n\nexport const createMockTransport = <TState>(\n initialState: TState,\n): MockTransport<TState> => {\n let state: unknown = initialState;\n let clientId = \"\";\n let finished = false;\n const clients = new Map<string, { seq: number; ctxSeq: number }>();\n const streams = new Set<FakeStreamHandle>();\n const commands: MockTransport<unknown>[\"commands\"] = [];\n\n const sendEnvelope = (envelope: StatewireEnvelope) => {\n for (const stream of streams) stream.send(sseFrame(envelope));\n return tick();\n };\n\n const admit = (id: string, frame: StatewireFrame) => {\n const rec = clients.get(id)!;\n for (const statement of frame.ctx ?? []) {\n rec.ctxSeq = Math.max(rec.ctxSeq, statement.seq);\n const root = statement.ops[0] as { value?: unknown } | undefined;\n commands.push({ context: root?.value });\n }\n for (const statement of frame.cmd ?? []) {\n rec.seq = Math.max(rec.seq, statement.seq);\n commands.push({ method: statement.method, params: statement.params });\n }\n };\n\n const fakeFetch = (async (\n input: RequestInfo | URL,\n init?: RequestInit,\n ): Promise<Response> => {\n if (finished) throw new TypeError(\"fetch failed (finished)\");\n const url = String(input);\n const headers = new Headers(init?.headers);\n const id = headers.get(\"Statewire-Client-Id\") ?? \"\";\n if (url.endsWith(\"/stream\")) {\n clientId = id;\n const known = clients.has(id);\n if (!known) clients.set(id, { seq: 0, ctxSeq: 0 });\n const rec = clients.get(id)!;\n const frame = JSON.parse(String(init?.body)) as StatewireFrame;\n admit(id, frame);\n return openSSEStream(\n streams,\n id,\n (handle) => {\n handle.send(\n sseFrame({\n ops: [{ op: \"replace\", path: [], value: state }],\n syn: {\n seq: rec.seq,\n ctxSeq: rec.ctxSeq,\n record: known ? \"resumed\" : \"fresh\",\n },\n }),\n );\n },\n init?.signal ?? undefined,\n { \"Statewire-Lease\": \"mock-lease\" },\n );\n }\n if (url.endsWith(\"/frames\")) {\n admit(id, JSON.parse(String(init?.body)) as StatewireFrame);\n return Response.json({});\n }\n return new Response(null, { status: 404 });\n }) as typeof fetch;\n\n return {\n transport: StatewireSSE({\n url: `http://mock-${++mockCount}/api`,\n fetch: fakeFetch,\n }),\n commands,\n setState: (next) => {\n state = next;\n return sendEnvelope({ ops: [{ op: \"replace\", path: [], value: next }] });\n },\n applied: (seq) => sendEnvelope({ cmd: [{ seq, type: \"applied\" }] }),\n settle: (seq, payload) =>\n sendEnvelope({\n cmd: [\n {\n seq,\n type: \"result\",\n ...(payload !== undefined && { payload }),\n },\n ],\n }),\n sendEnvelope,\n finish: async (reason, message) => {\n await sendEnvelope({\n fin: { reason, ...(message !== undefined && { message }) },\n });\n finished = true;\n for (const stream of [...streams]) stream.close();\n await tick();\n },\n get clientId() {\n return clientId;\n },\n get attached() {\n return streams.size > 0;\n },\n };\n};\n"],"mappings":"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"}
1
+ {"version":3,"file":"testing.js","names":["ResourceElement","StatewireEnvelope","StatewireSSE","StatewireFrame","Statewire","openSSEStream","sseFrame","FakeStreamHandle","tick","Promise","r","setTimeout","applyOptimistic","MockTransport","transport","TState","commands","method","params","context","setState","state","applied","seq","settle","payload","sendEnvelope","envelope","finish","reason","message","clientId","attached","mockCount","createMockTransport","initialState","finished","clients","Map","ctxSeq","streams","Set","stream","send","admit","id","frame","rec","get","statement","ctx","Math","max","root","ops","value","push","cmd","fakeFetch","input","RequestInfo","URL","init","RequestInit","Response","TypeError","url","String","headers","Headers","endsWith","has","set","JSON","parse","body","handle","op","path","syn","signal","undefined","json","status","fetch","next","type","fin","close","size"],"sources":["../../src/transports/testing.ts"],"sourcesContent":["import type { ResourceElement } from \"@assistant-ui/tap\";\nimport type { StatewireEnvelope } from \"../client/connection\";\nimport { StatewireSSE } from \"./sse\";\nimport type { StatewireFrame } from \"./sse\";\nimport type { Statewire } from \"../types\";\nimport { openSSEStream, sseFrame } from \"./fake-sse\";\nimport type { FakeStreamHandle } from \"./fake-sse\";\n\nexport * from \"./fixtures\";\n\n/** Let queued microtasks and op batches drain through the statewire client. */\nexport const tick = () => new Promise<void>((r) => setTimeout(r, 0));\n\nexport { applyOptimistic } from \"../util/recordMutations\";\n\nexport type MockTransport<TState> = {\n /** Pass as the `transport:` option of useStatewire / Harness. */\n transport: ResourceElement<Statewire<TState | undefined, any>>;\n /** Captured statements in arrival order: cmd as `{method, params}`, ctx as `{context}`. */\n commands: { method?: string; params?: unknown[]; context?: unknown }[];\n /** Replace the replicated state and broadcast it as a root snapshot op. */\n setState: (state: TState) => Promise<void>;\n /** Deliver a bare ack cmd packet on the stream. */\n applied: (seq: number) => Promise<void>;\n /** Deliver a terminal result cmd packet on the stream. */\n settle: (seq: number, payload?: unknown) => Promise<void>;\n /** Deliver an arbitrary envelope on the stream. */\n sendEnvelope: (envelope: StatewireEnvelope) => Promise<void>;\n /** End the stream with a fin; further attaches fail like a dead server. */\n finish: (reason: string, message?: string) => Promise<void>;\n readonly clientId: string;\n readonly attached: boolean;\n};\n\n/**\n * A mock statewire backend: serves `initialState` as the attach snapshot,\n * records submitted frames (receipt only — acks and results are delivered\n * from test code), and exposes the wire as a transport ResourceElement.\n */\nlet mockCount = 0;\n\nexport const createMockTransport = <TState>(\n initialState: TState,\n): MockTransport<TState> => {\n let state: unknown = initialState;\n let clientId = \"\";\n let finished = false;\n const clients = new Map<string, { seq: number; ctxSeq: number }>();\n const streams = new Set<FakeStreamHandle>();\n const commands: MockTransport<unknown>[\"commands\"] = [];\n\n const sendEnvelope = (envelope: StatewireEnvelope) => {\n for (const stream of streams) stream.send(sseFrame(envelope));\n return tick();\n };\n\n const admit = (id: string, frame: StatewireFrame) => {\n const rec = clients.get(id)!;\n for (const statement of frame.ctx ?? []) {\n rec.ctxSeq = Math.max(rec.ctxSeq, statement.seq);\n const root = statement.ops[0] as { value?: unknown } | undefined;\n commands.push({ context: root?.value });\n }\n for (const statement of frame.cmd ?? []) {\n rec.seq = Math.max(rec.seq, statement.seq);\n commands.push({ method: statement.method, params: statement.params });\n }\n };\n\n const fakeFetch = (async (\n input: RequestInfo | URL,\n init?: RequestInit,\n ): Promise<Response> => {\n if (finished) throw new TypeError(\"fetch failed (finished)\");\n const url = String(input);\n const headers = new Headers(init?.headers);\n const id = headers.get(\"Statewire-Client-Id\") ?? \"\";\n if (url.endsWith(\"/stream\")) {\n clientId = id;\n if (!clients.has(id)) clients.set(id, { seq: 0, ctxSeq: 0 });\n const rec = clients.get(id)!;\n const frame = JSON.parse(String(init?.body)) as StatewireFrame;\n admit(id, frame);\n return openSSEStream(\n streams,\n id,\n (handle) => {\n handle.send(\n sseFrame({\n ops: [{ op: \"replace\", path: [], value: state }],\n syn: { seq: rec.seq, ctxSeq: rec.ctxSeq },\n }),\n );\n },\n init?.signal ?? undefined,\n { \"Statewire-Lease\": \"mock-lease\" },\n );\n }\n if (url.endsWith(\"/frames\")) {\n admit(id, JSON.parse(String(init?.body)) as StatewireFrame);\n return Response.json({});\n }\n return new Response(null, { status: 404 });\n }) as typeof fetch;\n\n return {\n transport: StatewireSSE({\n url: `http://mock-${++mockCount}/api`,\n fetch: fakeFetch,\n }),\n commands,\n setState: (next) => {\n state = next;\n return sendEnvelope({ ops: [{ op: \"replace\", path: [], value: next }] });\n },\n applied: (seq) => sendEnvelope({ cmd: [{ seq, type: \"applied\" }] }),\n settle: (seq, payload) =>\n sendEnvelope({\n cmd: [\n {\n seq,\n type: \"result\",\n ...(payload !== undefined && { payload }),\n },\n ],\n }),\n sendEnvelope,\n finish: async (reason, message) => {\n await sendEnvelope({\n fin: { reason, ...(message !== undefined && { message }) },\n });\n finished = true;\n for (const stream of [...streams]) stream.close();\n await tick();\n },\n get clientId() {\n return clientId;\n },\n get attached() {\n return streams.size > 0;\n },\n };\n};\n"],"mappings":"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"}
@@ -3,9 +3,11 @@ type StatewireMetaEntry = {
3
3
  path: (string | number)[];
4
4
  type: string;
5
5
  };
6
- /** Revive meta-typed locations inside a wire value (in place). Unknown types
7
- * are skipped; a path that does not resolve to a valid target faults. */
8
- declare const reviveValue: (value: unknown, meta: unknown) => unknown;
6
+ /** Revive meta-typed locations inside a wire value (in place). Clients skip
7
+ * unknown types; hosts pass `rejectUnknown` to fault on them instead. */
8
+ declare const reviveValue: (value: unknown, meta: unknown, { rejectUnknown }?: {
9
+ rejectUnknown?: boolean | undefined;
10
+ }) => unknown;
9
11
  declare const reviveOps: (ops: unknown[]) => unknown[];
10
12
  declare const reviveResponse: <T extends {
11
13
  payload?: unknown;
@@ -1 +1 @@
1
- {"version":3,"file":"meta.d.ts","names":[],"sources":["../../src/wire/meta.ts"],"mappings":";KAEY;EACV;EACA;;;;cAiBW,cAAe,gBAAgB;cAwB/B,YAAa;cAOb,iBAAkB;EAAY;EAAmB;GAC5D,UAAU,MACT;;;cA+BU,eACX;EACG;EAAmB,OAAO;;;cAOlB,cACX;EACG;EAAgB,OAAO"}
1
+ {"version":3,"file":"meta.d.ts","names":[],"sources":["../../src/wire/meta.ts"],"mappings":";KAEY;EACV;EACA;;;;cAiBW,cACX,gBACA,iBACA;;;cA6BW,YAAa;cAOb,iBAAkB;EAAY;EAAmB;GAC5D,UAAU,MACT;;;cA+BU,eACX;EACG;EAAmB,OAAO;;;cAOlB,cACX;EACG;EAAgB,OAAO"}
package/dist/wire/meta.js CHANGED
@@ -4,14 +4,17 @@ const fault = (message) => {
4
4
  throw new Error(`statewire: protocol error: ${message}`);
5
5
  };
6
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). Unknown types
8
- * are skipped; a path that does not resolve to a valid target faults. */
9
- const reviveValue = (value, meta) => {
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
10
  if (!Array.isArray(meta)) return fault("meta must be an array");
11
11
  for (const entry of meta) {
12
12
  const path = typeof entry === "object" && entry !== null ? entry.path : null;
13
13
  if (!isPath(path)) return fault("meta entry must carry a path array");
14
- if (entry.type !== "date") continue;
14
+ if (entry.type !== "date") {
15
+ if (rejectUnknown) return fault(`unknown meta type: ${String(entry.type)}`);
16
+ continue;
17
+ }
15
18
  const walked = walkStatePath(value, path, "resolve");
16
19
  if (walked === void 0) return fault(`meta path ${path.join(".")} does not resolve`);
17
20
  if (typeof walked.leaf !== "string") return fault(`meta path ${path.join(".")} must name a string`);
@@ -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","entry","walked","undefined","join","leaf","date","Date","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). Unknown types\n * are skipped; a path that does not resolve to a valid target faults. */\nexport const reviveValue = (value: unknown, meta: unknown): 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\") continue;\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 (Number.isNaN(date.getTime())) {\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":["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","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 (Number.isNaN(date.getTime())) {\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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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "statewire",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
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": {
@@ -11,9 +11,18 @@ export type Episode = {
11
11
  message?: string;
12
12
  };
13
13
 
14
+ /** The terminal cmd verdict types; "applied" is the sole non-terminal type. */
15
+ export const TERMINAL = [
16
+ "result",
17
+ "rejected",
18
+ "failed",
19
+ "crashed",
20
+ "expired",
21
+ ] as const;
22
+
14
23
  export type StatewireCmd = {
15
24
  seq: number;
16
- type?: "applied" | "result" | "rejected" | "failed" | "crashed" | "expired";
25
+ type?: "applied" | (typeof TERMINAL)[number];
17
26
  code?: string;
18
27
  message?: string;
19
28
  payload?: unknown;
@@ -25,7 +34,7 @@ export type StatewireCmd = {
25
34
  export type StatewireSyn = {
26
35
  seq?: number;
27
36
  ctxSeq?: number;
28
- record?: "fresh" | "resumed" | "ephemeral";
37
+ record?: "ephemeral";
29
38
  id?: string;
30
39
  idle?: boolean;
31
40
  retry?: number;
@@ -344,10 +353,7 @@ const settleFin = (c: Conn, fin: StatewireHost.Fin): AttemptOutcome => {
344
353
  }
345
354
  // An auth denial gets one immediate redial; a consecutive repeat parks as gone.
346
355
  const denial = finish.reason === "unauthorized";
347
- const terminal =
348
- finish.reason === "gone" ||
349
- finish.reason === "superseded" ||
350
- (denial && c.authDenials > 0);
356
+ const terminal = finish.reason === "gone" || (denial && c.authDenials > 0);
351
357
  if (denial) c.authDenials += 1;
352
358
  c.ctx.onFinish(terminal ? finish : { ...finish, reason: "error" });
353
359
  if (!terminal) {
@@ -1,6 +1,7 @@
1
1
  import { useContext, useEffect, useState } from "react";
2
2
  import { applyOps } from "../wire/ops";
3
3
  import type { StatewirePath } from "../wire/ops";
4
+ import { TERMINAL } from "./connection";
4
5
  import type { StatewireCmd, StatewireSyn } from "./connection";
5
6
  import {
6
7
  encodeParams,
@@ -70,6 +71,7 @@ type AttachState = "detached" | "attached" | "finished";
70
71
 
71
72
  export type StatewireSessionOptions = {
72
73
  sessionStore?: StatewireClient.SessionStorage | undefined;
74
+ onSessionLost?: StatewireClient.OnSessionLost | undefined;
73
75
  onDone?: (() => void) | undefined;
74
76
  };
75
77
 
@@ -92,6 +94,7 @@ type Session = {
92
94
  durableSeq: number;
93
95
  resumeAt: number;
94
96
  idle: boolean;
97
+ onSessionLost: StatewireClient.OnSessionLost | undefined;
95
98
  onDone: (() => void) | undefined;
96
99
  ctxOps: unknown[] | undefined;
97
100
  ctxPending: CtxPending[];
@@ -252,14 +255,9 @@ const settleEntry = (
252
255
  }
253
256
  };
254
257
 
255
- const settleAll = (s: Session, detach: "unmount" | "gone" | "superseded") => {
256
- const cause = (
257
- {
258
- unmount: "unmount",
259
- gone: "stream-gone",
260
- superseded: "superseded",
261
- } as const
262
- )[detach];
258
+ const settleAll = (s: Session, detach: "unmount" | "gone") => {
259
+ const cause =
260
+ detach === "unmount" ? ("unmount" as const) : ("stream-gone" as const);
263
261
  const batch = s.pending;
264
262
  const detachFailure = (entry: Pending): StatewireClient.SendFailure => {
265
263
  if (!entry.attempted) {
@@ -286,26 +284,30 @@ const restateContext = (s: Session) => {
286
284
  s.ctxPending.push({ seq, ops: s.ctxOps, posted: false });
287
285
  };
288
286
 
287
+ const settleLost = (s: Session, entries: Pending[]) => {
288
+ for (const entry of entries) {
289
+ s.lifecycle?.onCovered?.(entry.inv);
290
+ settleEntry(s, entry, {
291
+ ok: false,
292
+ failure: { fate: "lost", cause: "session-lost" },
293
+ });
294
+ }
295
+ };
296
+
289
297
  // The server-side record's dedup memory is gone: history that may have
290
298
  // reached a server settles lost — the new hello's snapshot is the only
291
299
  // truth about what survived — and never-attempted statements re-stamp
292
- // from 1 on the reset lane clocks.
293
- const resetRecord = (s: Session, clientId: string) => {
294
- const survivors: Pending[] = [];
295
- for (const entry of [...s.pending]) {
296
- if (entry.attempted || entry.applied) {
297
- s.lifecycle?.onCovered?.(entry.inv);
298
- settleEntry(s, entry, {
299
- ok: false,
300
- failure: { fate: "lost", cause: "session-lost" },
301
- });
302
- } else {
303
- survivors.push(entry);
304
- }
305
- }
300
+ // on the adopted lane clocks.
301
+ const rebaseRecord = (s: Session, adopt: () => void) => {
302
+ const held = [...s.pending];
303
+ const survivors = held.filter((p) => !p.attempted && !p.applied);
304
+ settleLost(
305
+ s,
306
+ held.filter((p) => p.attempted || p.applied),
307
+ );
306
308
  s.pending = [];
307
309
  s.durableSeq = 0;
308
- setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1, commands: [] });
310
+ adopt();
309
311
  for (const old of survivors) {
310
312
  s.lifecycle?.onCovered?.(old.inv);
311
313
  enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
@@ -316,33 +318,17 @@ const resetRecord = (s: Session, clientId: string) => {
316
318
  void pump(s);
317
319
  };
318
320
 
321
+ const resetRecord = (s: Session, clientId: string) =>
322
+ rebaseRecord(s, () =>
323
+ setRecord(s, { clientId, nextSeq: 1, ctxNextSeq: 1, commands: [] }),
324
+ );
325
+
319
326
  // Another sharer replaced the record's identity: their reset settles our
320
327
  // attempted history and our survivors re-stamp on the stored clocks.
321
- const adoptReset = (s: Session, stored: StatewireClient.SessionRecord) => {
322
- const survivors: Pending[] = [];
323
- for (const entry of [...s.pending]) {
324
- if (entry.attempted || entry.applied) {
325
- s.lifecycle?.onCovered?.(entry.inv);
326
- settleEntry(s, entry, {
327
- ok: false,
328
- failure: { fate: "lost", cause: "session-lost" },
329
- });
330
- } else {
331
- survivors.push(entry);
332
- }
333
- }
334
- s.pending = [];
335
- s.durableSeq = 0;
336
- s.record = stored;
337
- for (const old of survivors) {
338
- s.lifecycle?.onCovered?.(old.inv);
339
- enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
340
- }
341
- restateContext(s);
342
- publish(s);
343
- wake(s);
344
- void pump(s);
345
- };
328
+ const adoptReset = (s: Session, stored: StatewireClient.SessionRecord) =>
329
+ rebaseRecord(s, () => {
330
+ s.record = stored;
331
+ });
346
332
 
347
333
  // The record died with the attach: mint a fresh id for the next one.
348
334
  const ephemeralDetach = (s: Session) => {
@@ -365,42 +351,31 @@ const outcomeOf = (
365
351
  cmd: StatewireCmd,
366
352
  ):
367
353
  | { ok: true; payload: unknown }
368
- | { ok: false; failure: StatewireClient.SendFailure } =>
369
- cmd.type === "result"
370
- ? { ok: true as const, payload: reviveResponse(cmd).payload }
371
- : cmd.type === "rejected"
372
- ? { ok: false as const, failure: rejectedFailure(reviveResponse(cmd)) }
373
- : cmd.type === "failed"
374
- ? {
375
- ok: false as const,
376
- failure: {
377
- fate: "failed" as const,
378
- cause: "server" as const,
379
- ...(cmd.message !== undefined && { message: cmd.message }),
380
- },
381
- }
382
- : cmd.type === "crashed"
383
- ? {
384
- ok: false as const,
385
- failure: { fate: "crashed" as const, cause: "server" as const },
386
- }
387
- : cmd.type === "expired"
388
- ? {
389
- ok: false as const,
390
- failure: {
391
- fate: "expired" as const,
392
- cause: "server" as const,
393
- },
394
- }
395
- : protocolError(`unknown cmd type: ${String(cmd.type)}`);
396
-
397
- const TERMINAL_CMD_TYPES = new Set([
398
- "result",
399
- "rejected",
400
- "failed",
401
- "crashed",
402
- "expired",
403
- ]);
354
+ | { ok: false; failure: StatewireClient.SendFailure } => {
355
+ switch (cmd.type) {
356
+ case "result":
357
+ return { ok: true, payload: reviveResponse(cmd).payload };
358
+ case "rejected":
359
+ return { ok: false, failure: rejectedFailure(reviveResponse(cmd)) };
360
+ case "failed":
361
+ return {
362
+ ok: false,
363
+ failure: {
364
+ fate: "failed",
365
+ cause: "server",
366
+ ...(cmd.message !== undefined && { message: cmd.message }),
367
+ },
368
+ };
369
+ case "crashed":
370
+ return { ok: false, failure: { fate: "crashed", cause: "server" } };
371
+ case "expired":
372
+ return { ok: false, failure: { fate: "expired", cause: "server" } };
373
+ default:
374
+ return protocolError(`unknown cmd type: ${String(cmd.type)}`);
375
+ }
376
+ };
377
+
378
+ const TERMINAL_CMD_TYPES: ReadonlySet<string> = new Set(TERMINAL);
404
379
 
405
380
  const parkVerdict = (s: Session, cmd: StatewireCmd) => {
406
381
  if (!s.shared || !TERMINAL_CMD_TYPES.has(cmd.type!)) return;
@@ -473,24 +448,10 @@ const publishIngested = (s: Session) => {
473
448
  wake(s);
474
449
  };
475
450
 
476
- const RECORDS = ["fresh", "resumed", "ephemeral"];
477
-
478
- // Snap a resumed session's lane clocks onto the record: seqs above the stated
479
- // watermark were never admitted, so a stale restored clock re-stamps its
480
- // never-admitted statements consecutively after the watermark.
481
- const snapClocks = (s: Session, seqWm: number, ctxWm: number) => {
482
- const tail = s.pending.filter((p) => p.seq > seqWm);
483
- const cmdAligned =
484
- tail.every((p, i) => p.seq === seqWm + 1 + i) &&
485
- s.record.nextSeq === seqWm + tail.length + 1;
486
- if (!cmdAligned) {
487
- s.pending = s.pending.filter((p) => p.seq <= seqWm);
488
- setRecord(s, { ...s.record, nextSeq: seqWm + 1 });
489
- for (const old of tail) {
490
- s.lifecycle?.onCovered?.(old.inv);
491
- enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
492
- }
493
- }
451
+ // A hello's cmd watermark may understate a durable record (cold host, tray
452
+ // unloaded): held seqs resend as-is and the frame's answer arbitrates —
453
+ // absorption where the record survived, a gap refusal where it did not.
454
+ const snapCtxClock = (s: Session, ctxWm: number) => {
494
455
  const ctxAligned =
495
456
  s.ctxPending.every((c, i) => c.seq === ctxWm + 1 + i) &&
496
457
  s.record.ctxNextSeq === ctxWm + s.ctxPending.length + 1;
@@ -500,39 +461,69 @@ const snapClocks = (s: Session, seqWm: number, ctxWm: number) => {
500
461
  }
501
462
  };
502
463
 
464
+ // The refusal's watermarks are exact: every held statement above them was
465
+ // never admitted by the live record, and the first unposted seq being beyond
466
+ // watermark+1 proves the record that admitted the missing prefix is gone —
467
+ // its history settles lost and the lane clocks restart at the watermarks.
468
+ const sessionLostInner = (s: Session, seqWm: number, ctxWm: number) => {
469
+ const held = [...s.pending];
470
+ settleLost(s, held);
471
+ s.pending = [];
472
+ s.durableSeq = seqWm;
473
+ setRecord(s, {
474
+ clientId: s.record.clientId,
475
+ nextSeq: seqWm + 1,
476
+ ctxNextSeq: ctxWm + 1,
477
+ commands: [],
478
+ });
479
+ restateContext(s);
480
+ if (
481
+ held.length > 0 &&
482
+ s.onSessionLost?.(held.map((p) => p.inv)) === "requeue"
483
+ ) {
484
+ for (const old of held) {
485
+ enqueueInner(
486
+ s,
487
+ old.command,
488
+ old.inv.params,
489
+ () => {},
490
+ () => {},
491
+ );
492
+ }
493
+ }
494
+ publish(s);
495
+ wake(s);
496
+ };
497
+
503
498
  const handleHello = (
504
499
  s: Session,
505
500
  { ops, syn }: { ops: unknown[]; syn: StatewireSyn },
506
501
  ) => {
507
- const record = syn.record ?? "fresh";
508
- if (!RECORDS.includes(record)) {
509
- protocolError(`unknown record: ${String(record)}`);
502
+ if (syn.record !== undefined && syn.record !== "ephemeral") {
503
+ protocolError(`unknown record: ${String(syn.record)}`);
510
504
  }
511
505
  ingestOps(s, ops);
512
506
  s.attach = "attached";
513
507
  s.attachEpoch += 1;
514
508
  s.frameFailures = 0;
515
509
  s.resumeAt = 0;
516
- s.ephemeral = record === "ephemeral";
510
+ s.ephemeral = syn.record === "ephemeral";
517
511
  if (s.ephemeral) s.deactivated = true;
518
512
  const [attachCmd, attachCtx] = s.attachLaneSeqs;
519
513
  s.attachLaneSeqs = [new Set(), new Set()];
520
514
  const reconcile = () => {
521
- if (record === "resumed") {
522
- if (s.shared) adoptStored(s);
523
- // Resending unsettled statements is exactly-once-safe: absorbed replays
524
- // re-emit their stored verdicts (applied while still in flight).
525
- for (const entry of s.pending) {
526
- entry.posted = attachCmd.has(entry.seq);
527
- }
528
- const ctxWatermark = syn.ctxSeq ?? 0;
529
- s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
530
- for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
531
- snapClocks(s, syn.seq ?? 0, ctxWatermark);
532
- syncBacklogInner(s);
533
- } else {
534
- resetRecord(s, s.record.clientId);
515
+ if (s.shared) adoptStored(s);
516
+ // Resending unsettled statements is exactly-once-safe: absorbed replays
517
+ // re-emit their stored verdicts (applied while still in flight).
518
+ s.durableSeq = syn.seq ?? 0;
519
+ for (const entry of s.pending) {
520
+ entry.posted = attachCmd.has(entry.seq);
535
521
  }
522
+ const ctxWatermark = syn.ctxSeq ?? 0;
523
+ s.ctxPending = s.ctxPending.filter((c) => c.seq > ctxWatermark);
524
+ for (const c of s.ctxPending) c.posted = attachCtx.has(c.seq);
525
+ snapCtxClock(s, ctxWatermark);
526
+ syncBacklogInner(s);
536
527
  s.sessionKnown = true;
537
528
  };
538
529
  if (!s.shared || s.deactivated) {
@@ -557,8 +548,9 @@ const handleOps = (
557
548
  publishIngested(s);
558
549
  };
559
550
 
560
- // A {syn} refusal never settles: reconcile the resend queues against the
561
- // stated watermarks; retry present means wait, absent means resend now.
551
+ // A retry-less {syn} refusal is the one session-loss verdict: a cmd gap
552
+ // against its exact watermark settles the held history lost; a pushback
553
+ // (retry present) or a gapless refusal only reconciles the resend queues.
562
554
  const handleRefusal = (s: Session, syn: StatewireSyn) => {
563
555
  const { seq, ctxSeq } = syn;
564
556
  if (typeof seq === "number") {
@@ -571,19 +563,30 @@ const handleRefusal = (s: Session, syn: StatewireSyn) => {
571
563
  }
572
564
  if (typeof syn.retry === "number") {
573
565
  s.resumeAt = Date.now() + syn.retry;
566
+ wake(s);
567
+ void pump(s);
568
+ return;
574
569
  }
575
570
  const firstCmd = s.pending.find((p) => !p.posted);
576
571
  const firstCtx = s.ctxPending.find((c) => !c.posted);
577
- const clocksAhead =
578
- (typeof syn.seq === "number" &&
579
- firstCmd !== undefined &&
580
- firstCmd.seq > Math.max(syn.seq, s.durableSeq) + 1) ||
581
- (typeof syn.ctxSeq === "number" &&
582
- firstCtx !== undefined &&
583
- firstCtx.seq > syn.ctxSeq + 1);
584
- // Clocks ahead of a record that cannot admit them: reattach first and let
585
- // the hello's record arbitrate.
586
- if (clocksAhead) s.transport?.connection.reconnect();
572
+ const cmdGap =
573
+ typeof seq === "number" &&
574
+ firstCmd !== undefined &&
575
+ firstCmd.seq > Math.max(seq, s.durableSeq) + 1;
576
+ const ctxGap =
577
+ typeof ctxSeq === "number" &&
578
+ firstCtx !== undefined &&
579
+ firstCtx.seq > ctxSeq + 1;
580
+ if (cmdGap || ctxGap) {
581
+ void transact(s, () => {
582
+ if (cmdGap) sessionLostInner(s, seq!, ctxSeq ?? 0);
583
+ else snapCtxClock(s, ctxSeq!);
584
+ }).then(() => {
585
+ wake(s);
586
+ void pump(s);
587
+ });
588
+ return;
589
+ }
587
590
  wake(s);
588
591
  void pump(s);
589
592
  };
@@ -597,11 +600,7 @@ const handleDetach = (s: Session) => {
597
600
  const handleFinish = (s: Session, { reason }: { reason: string }) => {
598
601
  if (reason === "evicted" || reason === "error") return;
599
602
  s.attach = "finished";
600
- // Superseded over shared storage: the superseder resends our backlog and
601
- // parks the verdicts; our promises settle from the observed record.
602
- if (!(s.shared && reason === "superseded")) {
603
- settleAll(s, reason === "superseded" ? "superseded" : "gone");
604
- }
603
+ settleAll(s, "gone");
605
604
  publish(s);
606
605
  wake(s);
607
606
  };
@@ -819,8 +818,8 @@ const enqueue = (
819
818
  ).then(() => void pump(s));
820
819
  };
821
820
 
822
- // A stored command cannot be told from one that reached the server: attempted
823
- // keeps a fresh record settling it lost instead of replaying it blind.
821
+ // A stored command may have reached a server: attempted settles it lost,
822
+ // never aborted, when a channel death ends the session.
824
823
  const adoptStored = (s: Session) => {
825
824
  const known = new Set(s.pending.map((p) => p.seq));
826
825
  const stored = s.record.commands.filter((c) => !known.has(c.seq));
@@ -965,6 +964,7 @@ export const useStatewireSession = (
965
964
  durableSeq: 0,
966
965
  resumeAt: 0,
967
966
  idle: false,
967
+ onSessionLost: undefined,
968
968
  onDone: undefined,
969
969
  ctxOps: undefined,
970
970
  ctxPending: [],
@@ -1005,7 +1005,13 @@ export const useStatewireSession = (
1005
1005
  });
1006
1006
  return session;
1007
1007
  });
1008
+ if ((store.lock !== undefined) !== (s.shared !== false)) {
1009
+ throw new Error(
1010
+ "statewire: session storage changed sharedness across renders — lock/subscribe must be present from the first render or never",
1011
+ );
1012
+ }
1008
1013
  s.store = store;
1014
+ s.onSessionLost = options.onSessionLost;
1009
1015
  s.onDone = options.onDone;
1010
1016
  useEffect(() => {
1011
1017
  start(s);
package/src/host/http.ts CHANGED
@@ -166,7 +166,7 @@ const getStream = async (
166
166
  const attachment = attachSse(h, client.clientId);
167
167
  const lease = attachment.registration === null ? null : mintLease();
168
168
  if (attachment.registration !== null)
169
- attachment.registration.rec.lease = lease;
169
+ attachment.registration.conn.lease = lease;
170
170
  return streamResponse(attachment, lease);
171
171
  };
172
172
 
@@ -202,7 +202,7 @@ const postStream = async (
202
202
  const attachment = attachSse(h, client.clientId);
203
203
  if (attachment.registration === null) return streamResponse(attachment, null);
204
204
  const lease = mintLease();
205
- attachment.registration.rec.lease = lease;
205
+ attachment.registration.conn.lease = lease;
206
206
  admitAttachFrame(h, attachment.registration, frame);
207
207
  return streamResponse(attachment, lease);
208
208
  };
@@ -254,13 +254,14 @@ export const frames = async (
254
254
  if ("response" in client) return client.response;
255
255
  const rec = h.clients.get(client.clientId);
256
256
  const lease = request.headers.get(LEASE_HEADER);
257
- if (
258
- h.disposed ||
259
- h.finished !== null ||
260
- rec === undefined ||
261
- lease === null ||
262
- lease !== rec.lease
263
- ) {
257
+ // A lease lives and dies with its attach: any live attach of the id accepts.
258
+ const live =
259
+ lease !== null &&
260
+ [...h.conns].some(
261
+ (conn) =>
262
+ conn.open && conn.clientId === client.clientId && conn.lease === lease,
263
+ );
264
+ if (h.disposed || h.finished !== null || rec === undefined || !live) {
264
265
  return Response.json({ error: "stale-lease" }, { status: 423 });
265
266
  }
266
267
  let frame: ParsedFrame;