statewire 0.0.5 → 0.5.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 (42) hide show
  1. package/README.md +1 -1
  2. package/dist/client/connection.d.ts +1 -1
  3. package/dist/client/connection.js.map +1 -1
  4. package/dist/client/core.d.ts.map +1 -1
  5. package/dist/client/core.js +1 -0
  6. package/dist/client/core.js.map +1 -1
  7. package/dist/client/session.d.ts +1 -5
  8. package/dist/client/session.d.ts.map +1 -1
  9. package/dist/client/session.js +18 -29
  10. package/dist/client/session.js.map +1 -1
  11. package/dist/host/host.d.ts +2 -14
  12. package/dist/host/host.d.ts.map +1 -1
  13. package/dist/host/host.js +41 -92
  14. package/dist/host/host.js.map +1 -1
  15. package/dist/host.d.ts +11 -17
  16. package/dist/host.d.ts.map +1 -1
  17. package/dist/host.js +2 -2
  18. package/dist/statewire.d.ts +8 -14
  19. package/dist/statewire.d.ts.map +1 -1
  20. package/dist/statewire.js.map +1 -1
  21. package/dist/transports/remote.js +1 -4
  22. package/dist/transports/remote.js.map +1 -1
  23. package/dist/transports/sse.d.ts +4 -1
  24. package/dist/transports/sse.d.ts.map +1 -1
  25. package/dist/transports/sse.js +41 -25
  26. package/dist/transports/sse.js.map +1 -1
  27. package/dist/transports/ws.d.ts +2 -1
  28. package/dist/transports/ws.d.ts.map +1 -1
  29. package/dist/transports/ws.js +25 -6
  30. package/dist/transports/ws.js.map +1 -1
  31. package/dist/util/statePath.js +8 -6
  32. package/dist/util/statePath.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/client/connection.ts +1 -1
  35. package/src/client/core.ts +1 -0
  36. package/src/client/session.ts +9 -26
  37. package/src/host/host.ts +55 -113
  38. package/src/host.ts +9 -19
  39. package/src/statewire.ts +8 -14
  40. package/src/transports/remote.ts +1 -7
  41. package/src/transports/sse.ts +71 -27
  42. package/src/transports/ws.ts +35 -4
@@ -20,9 +20,9 @@ const encodeWireCommand = (command) => ({
20
20
  ...command.context !== void 0 && { context: contextOps(command.context) }
21
21
  });
22
22
  const baseUrl = (options) => options.url.replace(/\/$/, "");
23
- const requestHeaders = async (options, extra) => {
23
+ const requestHeaders = async (options, extra, ctx) => {
24
24
  const { headers } = options;
25
- const merged = new Headers(typeof headers === "function" ? await headers() : headers);
25
+ const merged = new Headers(typeof headers === "function" ? await headers(ctx) : headers);
26
26
  for (const [name, value] of Object.entries(extra)) merged.set(name, value);
27
27
  return merged;
28
28
  };
@@ -55,11 +55,13 @@ const consume = async (c, reader, heartbeat, dead) => {
55
55
  }
56
56
  }
57
57
  };
58
- const attemptStream = async (c, options, abort) => {
58
+ const attemptStream = async (c, options, auth, abort) => {
59
+ const stale = auth.stale;
60
+ auth.stale = false;
59
61
  const headers = await requestHeaders(options, {
60
62
  "Statewire-Client-Id": c.ctx.clientId,
61
63
  accept: "text/event-stream"
62
- });
64
+ }, { staleAuth: stale });
63
65
  const heartbeat = new AbortController();
64
66
  const headerTimer = setTimeout(() => heartbeat.abort(), timing.heartbeatTimeoutMs);
65
67
  let res;
@@ -73,6 +75,11 @@ const attemptStream = async (c, options, abort) => {
73
75
  clearTimeout(headerTimer);
74
76
  }
75
77
  if (res.status === 429 || res.status === 503) throw overloaded(res);
78
+ if (res.status === 401) {
79
+ if (stale) c.episode = { cause: "unauthorized" };
80
+ auth.stale = true;
81
+ throw new Error("unauthorized");
82
+ }
76
83
  if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
77
84
  const reader = res.body.getReader();
78
85
  try {
@@ -82,19 +89,21 @@ const attemptStream = async (c, options, abort) => {
82
89
  }
83
90
  };
84
91
  const overloaded = (res) => overloadedError(parseRetryAfter(res.headers.get("Retry-After")));
85
- const sendCommand = async (c, options, { seq, command }) => {
92
+ const postCommand = async (c, options, { seq, command }, timeout, stale) => timedFetch(options, `${baseUrl(options)}/commands`, {
93
+ method: "POST",
94
+ headers: await requestHeaders(options, {
95
+ "content-type": "application/json",
96
+ "Statewire-Client-Id": c.ctx.clientId,
97
+ "Statewire-Command-Seq": String(seq),
98
+ ...c.lease !== null && { "Statewire-Lease": c.lease }
99
+ }, { staleAuth: stale }),
100
+ body: JSON.stringify(encodeWireCommand(command)),
101
+ signal: AbortSignal.any([c.lifetime?.signal ?? AbortSignal.abort(), timeout])
102
+ }, timeout);
103
+ const sendCommand = async (c, options, auth, args) => {
86
104
  const timeout = AbortSignal.timeout(timing.heartbeatTimeoutMs);
87
- const res = await timedFetch(options, `${baseUrl(options)}/commands`, {
88
- method: "POST",
89
- headers: await requestHeaders(options, {
90
- "content-type": "application/json",
91
- "Statewire-Client-Id": c.ctx.clientId,
92
- "Statewire-Command-Seq": String(seq),
93
- ...c.lease !== null && { "Statewire-Lease": c.lease }
94
- }),
95
- body: JSON.stringify(encodeWireCommand(command)),
96
- signal: AbortSignal.any([c.lifetime?.signal ?? AbortSignal.abort(), timeout])
97
- }, timeout);
105
+ let res = await postCommand(c, options, args, timeout, false);
106
+ if (res.status === 401 && typeof options.headers === "function") res = await postCommand(c, options, args, timeout, true);
98
107
  if (res.ok) {
99
108
  let body;
100
109
  try {
@@ -110,32 +119,39 @@ const sendCommand = async (c, options, { seq, command }) => {
110
119
  response: reviveResponse(body.res)
111
120
  } : { type: "receipt" };
112
121
  }
113
- if (res.status === 409 || res.status === 412 || res.status === 423) {
114
- const verdict = res.status === 409 ? "seq-gap" : res.status === 412 ? "unknown-client" : "stale-lease";
115
- if ((await res.json().catch(() => null))?.error === verdict) return { type: verdict };
116
- throw new Error(`HTTP ${res.status}`);
117
- }
118
122
  if (res.status === 429 || res.status === 503) throw overloaded(res);
119
- if (res.status >= 400 && res.status < 500) return {
123
+ if (res.status === 401) {
124
+ auth.stale = true;
125
+ c.episode = { cause: "unauthorized" };
126
+ c.connection.reconnect();
127
+ throw new Error("unauthorized");
128
+ }
129
+ const body = await res.json().catch(() => null);
130
+ if (body?.error === "rejected") return {
120
131
  type: "rejected",
121
- message: `HTTP ${res.status}`
132
+ message: body.rejections?.[0]?.message ?? `HTTP ${res.status}`
122
133
  };
134
+ if (res.status === 409 || res.status === 412 || res.status === 423) {
135
+ const verdict = res.status === 409 ? "seq-gap" : res.status === 412 ? "unknown-client" : "stale-lease";
136
+ if (body?.error === verdict) return { type: verdict };
137
+ }
123
138
  throw new Error(`HTTP ${res.status}`);
124
139
  };
125
140
  const useSseLink = (options, ctx) => {
126
141
  const latest = useRef(options);
127
142
  latest.current = options;
143
+ const [auth] = useState(() => ({ stale: false }));
128
144
  const c_0 = useStatewireConn({
129
145
  url: options.url,
130
146
  onChange: options.onChange,
131
147
  ctx,
132
- attemptOnce: (c, abort) => attemptStream(c, options, abort)
148
+ attemptOnce: (c, abort) => attemptStream(c, options, auth, abort)
133
149
  });
134
150
  const [link] = useState(() => ({
135
151
  get connection() {
136
152
  return c_0.connection;
137
153
  },
138
- send: (args) => sendCommand(c_0, latest.current, args)
154
+ send: (args) => sendCommand(c_0, latest.current, auth, args)
139
155
  }));
140
156
  return link;
141
157
  };
@@ -1 +1 @@
1
- {"version":3,"file":"sse.js","names":["useRef","useState","flushTapSync","resource","ResourceElement","encodeParams","reviveResponse","createSseParser","timing","classify","handleEnvelope","overloadedError","parseRetryAfter","useStatewireConn","Conn","StatewireEnvelope","StatewireResponse","Statewire","StatewireClient","StatewireHost","useStatewireRemote","StatewireRemoteProps","clientId","onSnapshot","args","ops","lastSeq","onOps","res","ack","onFinish","Fin","StatewireWireCommand","method","params","context","Record","contextOps","value","op","path","encodeWireCommand","command","undefined","StatewireSubmitResult","type","response","message","ConnectionState","RemoteLink","connection","Connection","send","seq","Promise","baseUrl","options","SSEOptions","url","replace","requestHeaders","extra","headers","merged","Headers","name","Object","entries","set","timedFetch","input","init","RequestInit","signal","AbortSignal","timeout","Response","fetch","globalThis","consume","c","reader","ReadableStreamDefaultReader","Uint8Array","heartbeat","AbortController","dead","decoder","TextDecoder","parse","timer","setTimeout","abort","heartbeatTimeoutMs","result","ReadableStreamReadResult","read","clearTimeout","done","data","decode","stream","env","JSON","attemptStream","ctx","accept","headerTimer","cache","any","status","overloaded","ok","body","Error","getReader","aborted","cancel","catch","get","sendCommand","String","lease","stringify","lifetime","json","verdict","const","error","useSseLink","latest","current","onChange","attemptOnce","link","useStatewireSSE","StatewireSSE","Commands","TState","TCommands","TEvent"],"sources":["../../src/transports/sse.ts"],"sourcesContent":["import { useRef, useState } from \"react\";\nimport { flushTapSync, resource } from \"@assistant-ui/tap\";\nimport type { ResourceElement } from \"@assistant-ui/tap\";\nimport { encodeParams, reviveResponse } from \"../wire/meta\";\nimport { createSseParser } from \"./sse-parser\";\nimport { timing } from \"../client/timing\";\nimport {\n classify,\n handleEnvelope,\n overloadedError,\n parseRetryAfter,\n useStatewireConn,\n} from \"../client/connection\";\nimport type {\n Conn,\n StatewireEnvelope,\n StatewireResponse,\n} from \"../client/connection\";\nimport type { Statewire } from \"../types\";\nimport type { StatewireClient } from \"../statewire\";\nimport type { StatewireHost } from \"../host\";\nimport { useStatewireRemote } from \"./remote\";\n\nexport type StatewireRemoteProps = {\n clientId: string;\n onSnapshot: (args: { ops: unknown[]; lastSeq: number }) => void;\n onOps: (args: {\n ops: unknown[];\n res?: StatewireResponse[];\n ack?: number;\n }) => void;\n onFinish: (args: StatewireHost.Fin) => void;\n};\n\nexport type StatewireWireCommand =\n | { method: string; params: readonly unknown[]; context?: never }\n | { method?: never; params?: never; context: Record<string, unknown> };\n\n/** The wire shape of a client context submission: one root replace op. */\nexport const contextOps = (value: Record<string, unknown>) => [\n { op: \"replace\", path: [], value },\n];\n\nexport const encodeWireCommand = (command: StatewireWireCommand) => ({\n ...(command.method !== undefined && {\n method: command.method,\n ...encodeParams(command.params),\n }),\n ...(command.context !== undefined && {\n context: contextOps(command.context),\n }),\n});\n\nexport type StatewireSubmitResult =\n | { type: \"receipt\"; response?: StatewireResponse }\n | { type: \"rejected\"; message: string }\n | { type: \"seq-gap\" }\n | { type: \"unknown-client\" }\n | { type: \"stale-lease\" };\n\nexport type ConnectionState = StatewireClient.ConnectionState;\n\n/** The live wire a remote session drives: connection status plus command POST. */\nexport type RemoteLink = {\n connection: StatewireClient.Connection;\n send: (args: {\n seq: number;\n command: StatewireWireCommand;\n }) => Promise<StatewireSubmitResult>;\n};\n\nconst baseUrl = (options: StatewireClient.SSEOptions) =>\n options.url.replace(/\\/$/, \"\");\n\nconst requestHeaders = async (\n options: StatewireClient.SSEOptions,\n extra: Record<string, string>,\n) => {\n const { headers } = options;\n const merged = new Headers(\n typeof headers === \"function\" ? await headers() : headers,\n );\n for (const [name, value] of Object.entries(extra)) merged.set(name, value);\n return merged;\n};\n\nconst timedFetch = async (\n options: StatewireClient.SSEOptions,\n input: string,\n init: RequestInit & { signal: AbortSignal },\n timeout: AbortSignal,\n): Promise<Response> => {\n try {\n return await (options.fetch ?? globalThis.fetch)(input, init);\n } catch {\n throw classify(timeout);\n }\n};\n\nconst consume = async (\n c: Conn,\n reader: ReadableStreamDefaultReader<Uint8Array>,\n heartbeat: AbortController,\n dead: () => boolean,\n): Promise<\"stop\" | \"reattach\" | \"ended\"> => {\n const decoder = new TextDecoder();\n const parse = createSseParser();\n for (;;) {\n const timer = setTimeout(\n () => heartbeat.abort(),\n timing.heartbeatTimeoutMs,\n );\n let result: ReadableStreamReadResult<Uint8Array>;\n try {\n result = await reader.read();\n } catch {\n throw classify(heartbeat.signal);\n } finally {\n clearTimeout(timer);\n }\n if (result.done) return \"ended\";\n for (const data of parse(decoder.decode(result.value, { stream: true }))) {\n if (dead()) return \"ended\";\n const env = JSON.parse(data) as StatewireEnvelope;\n const done = flushTapSync(() => handleEnvelope(c, env));\n if (done) return done;\n }\n }\n};\n\nconst attemptStream = async (\n c: Conn,\n options: StatewireClient.SSEOptions,\n abort: AbortController,\n): Promise<\"stop\" | \"reattach\" | \"ended\"> => {\n const headers = await requestHeaders(options, {\n \"Statewire-Client-Id\": c.ctx.clientId,\n accept: \"text/event-stream\",\n });\n const heartbeat = new AbortController();\n const headerTimer = setTimeout(\n () => heartbeat.abort(),\n timing.heartbeatTimeoutMs,\n );\n let res: Response;\n try {\n res = await timedFetch(\n options,\n `${baseUrl(options)}/stream`,\n {\n headers,\n cache: \"no-store\",\n signal: AbortSignal.any([abort.signal, heartbeat.signal]),\n },\n heartbeat.signal,\n );\n } finally {\n clearTimeout(headerTimer);\n }\n if (res.status === 429 || res.status === 503) throw overloaded(res);\n if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);\n const reader = res.body.getReader();\n try {\n return await consume(c, reader, heartbeat, () => abort.signal.aborted);\n } finally {\n reader.cancel().catch(() => {});\n }\n};\n\nconst overloaded = (res: Response) =>\n overloadedError(parseRetryAfter(res.headers.get(\"Retry-After\")));\n\nconst sendCommand = async (\n c: Conn,\n options: StatewireClient.SSEOptions,\n { seq, command }: { seq: number; command: StatewireWireCommand },\n): Promise<StatewireSubmitResult> => {\n const timeout = AbortSignal.timeout(timing.heartbeatTimeoutMs);\n const res = await timedFetch(\n options,\n `${baseUrl(options)}/commands`,\n {\n method: \"POST\",\n headers: await requestHeaders(options, {\n \"content-type\": \"application/json\",\n \"Statewire-Client-Id\": c.ctx.clientId,\n \"Statewire-Command-Seq\": String(seq),\n ...(c.lease !== null && { \"Statewire-Lease\": c.lease }),\n }),\n body: JSON.stringify(encodeWireCommand(command)),\n signal: AbortSignal.any([\n c.lifetime?.signal ?? AbortSignal.abort(),\n timeout,\n ]),\n },\n timeout,\n );\n if (res.ok) {\n let body: { res?: StatewireResponse } | null;\n try {\n body = (await res.json()) as { res?: StatewireResponse } | null;\n } catch {\n return {\n type: \"rejected\",\n message: \"statewire: protocol error: malformed /commands receipt body\",\n };\n }\n return body?.res !== undefined\n ? { type: \"receipt\", response: reviveResponse(body.res) }\n : { type: \"receipt\" };\n }\n if (res.status === 409 || res.status === 412 || res.status === 423) {\n // Only a body naming the same verdict as the status is statewire's own;\n // a bare status is middleware noise — a transport fault, retried.\n const verdict =\n res.status === 409\n ? (\"seq-gap\" as const)\n : res.status === 412\n ? (\"unknown-client\" as const)\n : (\"stale-lease\" as const);\n const body = (await res.json().catch(() => null)) as {\n error?: string;\n } | null;\n if (body?.error === verdict) return { type: verdict };\n throw new Error(`HTTP ${res.status}`);\n }\n if (res.status === 429 || res.status === 503) throw overloaded(res);\n if (res.status >= 400 && res.status < 500) {\n return { type: \"rejected\", message: `HTTP ${res.status}` };\n }\n throw new Error(`HTTP ${res.status}`);\n};\n\nconst useSseLink = (\n options: StatewireClient.SSEOptions,\n ctx: StatewireRemoteProps,\n): RemoteLink => {\n const latest = useRef(options);\n latest.current = options;\n const c = useStatewireConn({\n url: options.url,\n onChange: options.onChange,\n ctx,\n attemptOnce: (c, abort) => attemptStream(c, options, abort),\n });\n const [link] = useState(\n (): RemoteLink => ({\n get connection() {\n return c.connection;\n },\n send: (args) => sendCommand(c, latest.current, args),\n }),\n );\n return link;\n};\n\nconst useStatewireSSE = (\n options: StatewireClient.SSEOptions,\n): Statewire<any, any, any> => useStatewireRemote(options, useSseLink);\n\nexport const StatewireSSE = resource(useStatewireSSE) as <\n TState = any,\n TCommands extends Statewire.Commands = Statewire.Commands,\n TEvent = any,\n>(\n options: StatewireClient.SSEOptions,\n) => ResourceElement<Statewire<TState | undefined, TCommands, TEvent>>;\n"],"mappings":"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"}
1
+ {"version":3,"file":"sse.js","names":["useRef","useState","flushTapSync","resource","ResourceElement","encodeParams","reviveResponse","createSseParser","timing","classify","handleEnvelope","overloadedError","parseRetryAfter","useStatewireConn","Conn","StatewireEnvelope","StatewireResponse","Statewire","StatewireClient","StatewireHost","useStatewireRemote","StatewireRemoteProps","clientId","onSnapshot","args","ops","lastSeq","onOps","res","ack","onFinish","Fin","StatewireWireCommand","method","params","context","Record","contextOps","value","op","path","encodeWireCommand","command","undefined","StatewireSubmitResult","type","response","message","ConnectionState","RemoteLink","connection","Connection","send","seq","Promise","baseUrl","options","SSEOptions","url","replace","AuthState","stale","requestHeaders","extra","ctx","staleAuth","headers","merged","Headers","name","Object","entries","set","timedFetch","input","init","RequestInit","signal","AbortSignal","timeout","Response","fetch","globalThis","consume","c","reader","ReadableStreamDefaultReader","Uint8Array","heartbeat","AbortController","dead","decoder","TextDecoder","parse","timer","setTimeout","abort","heartbeatTimeoutMs","result","ReadableStreamReadResult","read","clearTimeout","done","data","decode","stream","env","JSON","attemptStream","auth","accept","headerTimer","cache","any","status","overloaded","episode","cause","Error","ok","body","getReader","aborted","cancel","catch","get","postCommand","String","lease","stringify","lifetime","sendCommand","json","reconnect","error","rejections","verdict","const","useSseLink","latest","current","onChange","attemptOnce","link","useStatewireSSE","StatewireSSE","Commands","TState","TCommands","TEvent"],"sources":["../../src/transports/sse.ts"],"sourcesContent":["import { useRef, useState } from \"react\";\nimport { flushTapSync, resource } from \"@assistant-ui/tap\";\nimport type { ResourceElement } from \"@assistant-ui/tap\";\nimport { encodeParams, reviveResponse } from \"../wire/meta\";\nimport { createSseParser } from \"./sse-parser\";\nimport { timing } from \"../client/timing\";\nimport {\n classify,\n handleEnvelope,\n overloadedError,\n parseRetryAfter,\n useStatewireConn,\n} from \"../client/connection\";\nimport type {\n Conn,\n StatewireEnvelope,\n StatewireResponse,\n} from \"../client/connection\";\nimport type { Statewire } from \"../types\";\nimport type { StatewireClient } from \"../statewire\";\nimport type { StatewireHost } from \"../host\";\nimport { useStatewireRemote } from \"./remote\";\n\nexport type StatewireRemoteProps = {\n clientId: string;\n onSnapshot: (args: { ops: unknown[]; lastSeq: number }) => void;\n onOps: (args: {\n ops: unknown[];\n res?: StatewireResponse[];\n ack?: number;\n }) => void;\n onFinish: (args: StatewireHost.Fin) => void;\n};\n\nexport type StatewireWireCommand =\n | { method: string; params: readonly unknown[]; context?: never }\n | { method?: never; params?: never; context: Record<string, unknown> };\n\n/** The wire shape of a client context submission: one root replace op. */\nexport const contextOps = (value: Record<string, unknown>) => [\n { op: \"replace\", path: [], value },\n];\n\nexport const encodeWireCommand = (command: StatewireWireCommand) => ({\n ...(command.method !== undefined && {\n method: command.method,\n ...encodeParams(command.params),\n }),\n ...(command.context !== undefined && {\n context: contextOps(command.context),\n }),\n});\n\nexport type StatewireSubmitResult =\n | { type: \"receipt\"; response?: StatewireResponse }\n | { type: \"rejected\"; message: string }\n | { type: \"seq-gap\" }\n | { type: \"unknown-client\" }\n | { type: \"stale-lease\" };\n\nexport type ConnectionState = StatewireClient.ConnectionState;\n\n/** The live wire a remote session drives: connection status plus command POST. */\nexport type RemoteLink = {\n connection: StatewireClient.Connection;\n send: (args: {\n seq: number;\n command: StatewireWireCommand;\n }) => Promise<StatewireSubmitResult>;\n};\n\nconst baseUrl = (options: StatewireClient.SSEOptions) =>\n options.url.replace(/\\/$/, \"\");\n\nexport type AuthState = { stale: boolean };\n\nconst requestHeaders = async (\n options: StatewireClient.SSEOptions,\n extra: Record<string, string>,\n ctx: { staleAuth: boolean },\n) => {\n const { headers } = options;\n const merged = new Headers(\n typeof headers === \"function\" ? await headers(ctx) : headers,\n );\n for (const [name, value] of Object.entries(extra)) merged.set(name, value);\n return merged;\n};\n\nconst timedFetch = async (\n options: StatewireClient.SSEOptions,\n input: string,\n init: RequestInit & { signal: AbortSignal },\n timeout: AbortSignal,\n): Promise<Response> => {\n try {\n return await (options.fetch ?? globalThis.fetch)(input, init);\n } catch {\n throw classify(timeout);\n }\n};\n\nconst consume = async (\n c: Conn,\n reader: ReadableStreamDefaultReader<Uint8Array>,\n heartbeat: AbortController,\n dead: () => boolean,\n): Promise<\"stop\" | \"reattach\" | \"ended\"> => {\n const decoder = new TextDecoder();\n const parse = createSseParser();\n for (;;) {\n const timer = setTimeout(\n () => heartbeat.abort(),\n timing.heartbeatTimeoutMs,\n );\n let result: ReadableStreamReadResult<Uint8Array>;\n try {\n result = await reader.read();\n } catch {\n throw classify(heartbeat.signal);\n } finally {\n clearTimeout(timer);\n }\n if (result.done) return \"ended\";\n for (const data of parse(decoder.decode(result.value, { stream: true }))) {\n if (dead()) return \"ended\";\n const env = JSON.parse(data) as StatewireEnvelope;\n const done = flushTapSync(() => handleEnvelope(c, env));\n if (done) return done;\n }\n }\n};\n\nconst attemptStream = async (\n c: Conn,\n options: StatewireClient.SSEOptions,\n auth: AuthState,\n abort: AbortController,\n): Promise<\"stop\" | \"reattach\" | \"ended\"> => {\n const stale = auth.stale;\n auth.stale = false;\n const headers = await requestHeaders(\n options,\n {\n \"Statewire-Client-Id\": c.ctx.clientId,\n accept: \"text/event-stream\",\n },\n { staleAuth: stale },\n );\n const heartbeat = new AbortController();\n const headerTimer = setTimeout(\n () => heartbeat.abort(),\n timing.heartbeatTimeoutMs,\n );\n let res: Response;\n try {\n res = await timedFetch(\n options,\n `${baseUrl(options)}/stream`,\n {\n headers,\n cache: \"no-store\",\n signal: AbortSignal.any([abort.signal, heartbeat.signal]),\n },\n heartbeat.signal,\n );\n } finally {\n clearTimeout(headerTimer);\n }\n if (res.status === 429 || res.status === 503) throw overloaded(res);\n if (res.status === 401) {\n if (stale) c.episode = { cause: \"unauthorized\" };\n auth.stale = true;\n throw new Error(\"unauthorized\");\n }\n if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);\n const reader = res.body.getReader();\n try {\n return await consume(c, reader, heartbeat, () => abort.signal.aborted);\n } finally {\n reader.cancel().catch(() => {});\n }\n};\n\nconst overloaded = (res: Response) =>\n overloadedError(parseRetryAfter(res.headers.get(\"Retry-After\")));\n\nconst postCommand = async (\n c: Conn,\n options: StatewireClient.SSEOptions,\n { seq, command }: { seq: number; command: StatewireWireCommand },\n timeout: AbortSignal,\n stale: boolean,\n): Promise<Response> =>\n timedFetch(\n options,\n `${baseUrl(options)}/commands`,\n {\n method: \"POST\",\n headers: await requestHeaders(\n options,\n {\n \"content-type\": \"application/json\",\n \"Statewire-Client-Id\": c.ctx.clientId,\n \"Statewire-Command-Seq\": String(seq),\n ...(c.lease !== null && { \"Statewire-Lease\": c.lease }),\n },\n { staleAuth: stale },\n ),\n body: JSON.stringify(encodeWireCommand(command)),\n signal: AbortSignal.any([\n c.lifetime?.signal ?? AbortSignal.abort(),\n timeout,\n ]),\n },\n timeout,\n );\n\nconst sendCommand = async (\n c: Conn,\n options: StatewireClient.SSEOptions,\n auth: AuthState,\n args: { seq: number; command: StatewireWireCommand },\n): Promise<StatewireSubmitResult> => {\n const timeout = AbortSignal.timeout(timing.heartbeatTimeoutMs);\n let res = await postCommand(c, options, args, timeout, false);\n if (res.status === 401 && typeof options.headers === \"function\") {\n res = await postCommand(c, options, args, timeout, true);\n }\n if (res.ok) {\n let body: { res?: StatewireResponse } | null;\n try {\n body = (await res.json()) as { res?: StatewireResponse } | null;\n } catch {\n return {\n type: \"rejected\",\n message: \"statewire: protocol error: malformed /commands receipt body\",\n };\n }\n return body?.res !== undefined\n ? { type: \"receipt\", response: reviveResponse(body.res) }\n : { type: \"receipt\" };\n }\n if (res.status === 429 || res.status === 503) throw overloaded(res);\n if (res.status === 401) {\n // Fresh credentials did not clear the 401: signal auth-failed on the\n // connection and let the send retry as a transport fault.\n auth.stale = true;\n c.episode = { cause: \"unauthorized\" };\n c.connection.reconnect();\n throw new Error(\"unauthorized\");\n }\n // Bodies settle, statuses pace: only a statewire-shaped body carries a\n // verdict; any bare status is middleware noise — a transport fault, retried.\n const body = (await res.json().catch(() => null)) as {\n error?: string;\n rejections?: { message?: string }[];\n } | null;\n if (body?.error === \"rejected\") {\n return {\n type: \"rejected\",\n message: body.rejections?.[0]?.message ?? `HTTP ${res.status}`,\n };\n }\n if (res.status === 409 || res.status === 412 || res.status === 423) {\n const verdict =\n res.status === 409\n ? (\"seq-gap\" as const)\n : res.status === 412\n ? (\"unknown-client\" as const)\n : (\"stale-lease\" as const);\n if (body?.error === verdict) return { type: verdict };\n }\n throw new Error(`HTTP ${res.status}`);\n};\n\nconst useSseLink = (\n options: StatewireClient.SSEOptions,\n ctx: StatewireRemoteProps,\n): RemoteLink => {\n const latest = useRef(options);\n latest.current = options;\n const [auth] = useState((): AuthState => ({ stale: false }));\n const c = useStatewireConn({\n url: options.url,\n onChange: options.onChange,\n ctx,\n attemptOnce: (c, abort) => attemptStream(c, options, auth, abort),\n });\n const [link] = useState(\n (): RemoteLink => ({\n get connection() {\n return c.connection;\n },\n send: (args) => sendCommand(c, latest.current, auth, args),\n }),\n );\n return link;\n};\n\nconst useStatewireSSE = (\n options: StatewireClient.SSEOptions,\n): Statewire<any, any, any> => useStatewireRemote(options, useSseLink);\n\nexport const StatewireSSE = resource(useStatewireSSE) as <\n TState = any,\n TCommands extends Statewire.Commands = Statewire.Commands,\n TEvent = any,\n>(\n options: StatewireClient.SSEOptions,\n) => ResourceElement<Statewire<TState | undefined, TCommands, TEvent>>;\n"],"mappings":"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"}
@@ -3,7 +3,8 @@ import { Statewire } from "../types.js";
3
3
  import { ResourceElement } from "@assistant-ui/tap";
4
4
  //#region src/transports/ws.d.ts
5
5
  declare const WS_SUBPROTOCOL = "statewire.v1";
6
+ declare const UNAUTHORIZED_CLOSE = 4401;
6
7
  declare const StatewireWS: <TState = any, TCommands extends Statewire.Commands = Statewire.Commands, TEvent = any>(options: StatewireClient.WSOptions) => ResourceElement<Statewire<TState | undefined, TCommands, TEvent>>;
7
8
  //#endregion
8
- export { StatewireWS, WS_SUBPROTOCOL };
9
+ export { StatewireWS, UNAUTHORIZED_CLOSE, WS_SUBPROTOCOL };
9
10
  //# sourceMappingURL=ws.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ws.d.ts","names":[],"sources":["../../src/transports/ws.ts"],"mappings":";;;;cAmCa;cAgNA,cACX,cACA,kBAAkB,UAAU,WAAW,UAAU,UACjD,cAEA,SAAS,gBAAgB,cACtB,gBAAgB,UAAU,oBAAoB,WAAW"}
1
+ {"version":3,"file":"ws.d.ts","names":[],"sources":["../../src/transports/ws.ts"],"mappings":";;;;cAoCa;cACA;cA6OA,cACX,cACA,kBAAkB,UAAU,WAAW,UAAU,UACjD,cAEA,SAAS,gBAAgB,cACtB,gBAAgB,UAAU,oBAAoB,WAAW"}
@@ -8,12 +8,22 @@ import { flushTapSync, resource } from "@assistant-ui/tap";
8
8
  //#region src/transports/ws.ts
9
9
  const OPEN = 1;
10
10
  const WS_SUBPROTOCOL = "statewire.v1";
11
+ const UNAUTHORIZED_CLOSE = 4401;
11
12
  const socketUrl = async (options, clientId) => {
12
13
  const { url, query } = options;
13
14
  const params = new URLSearchParams(typeof query === "function" ? await query() : query);
14
15
  params.set(CLIENT_QUERY_PARAM, clientId);
15
16
  return `${url.replace(/\/$/, "").replace(/^http/, "ws")}/ws?${params.toString()}`;
16
17
  };
18
+ const headersFrame = async (options, stale) => {
19
+ const { headers } = options;
20
+ if (headers === void 0) return null;
21
+ const resolved = new Headers(typeof headers === "function" ? await headers({ staleAuth: stale }) : headers);
22
+ return JSON.stringify({
23
+ type: "headers",
24
+ headers: Object.fromEntries(resolved)
25
+ });
26
+ };
17
27
  const settleOverloaded = (w, { seq, retryAfterMs }) => {
18
28
  if (seq === void 0) throw overloadedError(retryAfterMs);
19
29
  const entry = w.pending.find((p) => p.seq === seq);
@@ -32,7 +42,7 @@ const failPending = (w, error) => {
32
42
  w.pending = [];
33
43
  for (const entry of pending) entry.reject(error);
34
44
  };
35
- const runSocket = (c, w, ws, abort) => new Promise((resolve, reject) => {
45
+ const runSocket = (c, w, ws, frame, staleAuth, abort) => new Promise((resolve, reject) => {
36
46
  let timer;
37
47
  let timedOut = false;
38
48
  let outcome = null;
@@ -47,6 +57,7 @@ const runSocket = (c, w, ws, abort) => new Promise((resolve, reject) => {
47
57
  arm();
48
58
  abort.signal.addEventListener("abort", () => ws.close());
49
59
  ws.onerror = () => {};
60
+ if (frame !== null) ws.onopen = () => ws.send(frame);
50
61
  const stale = () => Boolean(outcome) || closed || abort.signal.aborted;
51
62
  const fail = (error) => {
52
63
  clearTimeout(timer);
@@ -85,25 +96,32 @@ const runSocket = (c, w, ws, abort) => new Promise((resolve, reject) => {
85
96
  fail(error);
86
97
  }
87
98
  };
88
- ws.onclose = () => {
99
+ ws.onclose = (event) => {
89
100
  closed = true;
90
101
  clearTimeout(timer);
91
102
  if (w.socket === ws) failPending(w, /* @__PURE__ */ new Error("network"));
92
103
  if (outcome) resolve(outcome);
93
- else reject(/* @__PURE__ */ new Error(timedOut ? "timeout" : "network"));
104
+ else if (event?.code === 4401) {
105
+ if (staleAuth) c.episode = { cause: "unauthorized" };
106
+ w.authStale = true;
107
+ reject(/* @__PURE__ */ new Error("unauthorized"));
108
+ } else reject(/* @__PURE__ */ new Error(timedOut ? "timeout" : "network"));
94
109
  };
95
110
  });
96
111
  const attemptSocket = async (c, w, options, abort) => {
97
112
  let ws = null;
98
113
  try {
99
114
  const url = await socketUrl(options, c.ctx.clientId);
115
+ const stale = w.authStale;
116
+ w.authStale = false;
117
+ const frame = await headersFrame(options, stale);
100
118
  abort.signal.throwIfAborted();
101
119
  const { protocols, webSocket } = options;
102
120
  const WS = webSocket ?? globalThis.WebSocket;
103
121
  if (!WS) throw new Error("no WebSocket implementation");
104
122
  ws = new WS(url, [WS_SUBPROTOCOL, ...protocols === void 0 ? [] : typeof protocols === "string" ? [protocols] : protocols]);
105
123
  w.socket = ws;
106
- return await runSocket(c, w, ws, abort);
124
+ return await runSocket(c, w, ws, frame, stale, abort);
107
125
  } finally {
108
126
  if (w.socket === ws) w.socket = null;
109
127
  }
@@ -144,7 +162,8 @@ const sendCommand = (w, { seq, command }) => {
144
162
  const useWsLink = (options, ctx) => {
145
163
  const [w] = useState(() => ({
146
164
  socket: null,
147
- pending: []
165
+ pending: [],
166
+ authStale: false
148
167
  }));
149
168
  const c_0 = useStatewireConn({
150
169
  url: options.url,
@@ -163,6 +182,6 @@ const useWsLink = (options, ctx) => {
163
182
  const useStatewireWS = (options) => useStatewireRemote(options, useWsLink);
164
183
  const StatewireWS = resource(useStatewireWS);
165
184
  //#endregion
166
- export { StatewireWS, WS_SUBPROTOCOL };
185
+ export { StatewireWS, UNAUTHORIZED_CLOSE, WS_SUBPROTOCOL };
167
186
 
168
187
  //# sourceMappingURL=ws.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"ws.js","names":["useState","flushTapSync","resource","ResourceElement","timing","handleEnvelope","overloadedError","useStatewireConn","Conn","StatewireEnvelope","Statewire","StatewireClient","useStatewireRemote","CLIENT_QUERY_PARAM","encodeWireCommand","RemoteLink","StatewireSubmitResult","StatewireRemoteProps","StatewireWireCommand","PendingCommand","seq","resolve","result","reject","error","Error","WsChannel","socket","WebSocket","pending","OPEN","WS_SUBPROTOCOL","socketUrl","options","WSOptions","clientId","url","query","params","URLSearchParams","set","base","replace","toString","settleOverloaded","w","retryAfterMs","undefined","entry","find","p","filter","resolvePending","matches","remaining","type","push","failPending","runSocket","c","ws","abort","AbortController","Promise","timer","ReturnType","setTimeout","timedOut","outcome","closed","arm","clearTimeout","close","heartbeatTimeoutMs","signal","addEventListener","onerror","stale","Boolean","aborted","fail","processEnv","env","done","ack","res","seqs","Set","map","r","has","onmessage","event","frame","overloaded","JSON","parse","String","data","onclose","attemptSocket","ctx","throwIfAborted","protocols","webSocket","WS","globalThis","extra","sendCommand","command","readyState","send","stringify","useWsLink","onChange","attemptOnce","link","connection","args","useStatewireWS","StatewireWS","Commands","TState","TCommands","TEvent"],"sources":["../../src/transports/ws.ts"],"sourcesContent":["import { useState } from \"react\";\nimport { flushTapSync, resource } from \"@assistant-ui/tap\";\nimport type { ResourceElement } from \"@assistant-ui/tap\";\nimport { timing } from \"../client/timing\";\nimport {\n handleEnvelope,\n overloadedError,\n useStatewireConn,\n} from \"../client/connection\";\nimport type { Conn, StatewireEnvelope } from \"../client/connection\";\nimport type { Statewire } from \"../types\";\nimport type { StatewireClient } from \"../statewire\";\nimport { useStatewireRemote } from \"./remote\";\nimport { CLIENT_QUERY_PARAM } from \"../host/adapter\";\nimport { encodeWireCommand } from \"./sse\";\nimport type {\n RemoteLink,\n StatewireSubmitResult,\n StatewireRemoteProps,\n StatewireWireCommand,\n} from \"./sse\";\n\ntype PendingCommand = {\n seq: number;\n resolve: (result: StatewireSubmitResult) => void;\n reject: (error: Error) => void;\n};\n\ntype WsChannel = {\n socket: WebSocket | null;\n pending: PendingCommand[];\n};\n\nconst OPEN = 1;\n\nexport const WS_SUBPROTOCOL = \"statewire.v1\";\n\nconst socketUrl = async (\n options: StatewireClient.WSOptions,\n clientId: string,\n) => {\n const { url, query } = options;\n const params = new URLSearchParams(\n typeof query === \"function\" ? await query() : query,\n );\n params.set(CLIENT_QUERY_PARAM, clientId);\n const base = url.replace(/\\/$/, \"\").replace(/^http/, \"ws\");\n return `${base}/ws?${params.toString()}`;\n};\n\nconst settleOverloaded = (\n w: WsChannel,\n { seq, retryAfterMs }: { seq?: number; retryAfterMs?: number },\n) => {\n if (seq === undefined) throw overloadedError(retryAfterMs);\n const entry = w.pending.find((p) => p.seq === seq);\n if (!entry) return;\n w.pending = w.pending.filter((p) => p !== entry);\n entry.reject(overloadedError(retryAfterMs));\n};\n\nconst resolvePending = (\n w: WsChannel,\n matches: (entry: PendingCommand) => boolean,\n) => {\n const remaining: PendingCommand[] = [];\n for (const entry of w.pending) {\n if (matches(entry)) entry.resolve({ type: \"receipt\" });\n else remaining.push(entry);\n }\n w.pending = remaining;\n};\n\nconst failPending = (w: WsChannel, error: Error) => {\n const pending = w.pending;\n w.pending = [];\n for (const entry of pending) entry.reject(error);\n};\n\nconst runSocket = (\n c: Conn,\n w: WsChannel,\n ws: WebSocket,\n abort: AbortController,\n): Promise<\"stop\" | \"reattach\"> =>\n new Promise((resolve, reject) => {\n let timer: ReturnType<typeof setTimeout> | undefined;\n let timedOut = false;\n let outcome: \"stop\" | \"reattach\" | null = null;\n let closed = false;\n const arm = () => {\n clearTimeout(timer);\n timer = setTimeout(() => {\n timedOut = true;\n ws.close();\n }, timing.heartbeatTimeoutMs);\n };\n arm();\n abort.signal.addEventListener(\"abort\", () => ws.close());\n ws.onerror = () => {};\n const stale = () => Boolean(outcome) || closed || abort.signal.aborted;\n const fail = (error: unknown) => {\n clearTimeout(timer);\n reject(error instanceof Error ? error : new Error(\"protocol\"));\n ws.close();\n };\n const processEnv = (env: StatewireEnvelope) => {\n const done = flushTapSync(() => handleEnvelope(c, env));\n if (env.ack !== undefined) {\n const ack = env.ack;\n resolvePending(w, (entry) => entry.seq <= ack);\n }\n if (env.res !== undefined) {\n const seqs = new Set(env.res.map((r) => r.seq));\n resolvePending(w, (entry) => seqs.has(entry.seq));\n }\n if (done) {\n outcome = done;\n ws.close();\n }\n };\n ws.onmessage = (event) => {\n if (stale()) return;\n arm();\n let frame: StatewireEnvelope & {\n overloaded?: { seq?: number; retryAfterMs?: number };\n };\n try {\n frame = JSON.parse(String(event.data)) as typeof frame;\n } catch {\n fail(new Error(\"protocol\"));\n return;\n }\n try {\n if (frame.overloaded !== undefined) {\n settleOverloaded(w, frame.overloaded);\n } else {\n processEnv(frame);\n }\n } catch (error) {\n fail(error);\n }\n };\n ws.onclose = () => {\n closed = true;\n clearTimeout(timer);\n if (w.socket === ws) failPending(w, new Error(\"network\"));\n if (outcome) resolve(outcome);\n else reject(new Error(timedOut ? \"timeout\" : \"network\"));\n };\n });\n\nconst attemptSocket = async (\n c: Conn,\n w: WsChannel,\n options: StatewireClient.WSOptions,\n abort: AbortController,\n): Promise<\"stop\" | \"reattach\"> => {\n let ws: WebSocket | null = null;\n try {\n const url = await socketUrl(options, c.ctx.clientId);\n abort.signal.throwIfAborted();\n const { protocols, webSocket } = options;\n const WS = webSocket ?? globalThis.WebSocket;\n if (!WS) throw new Error(\"no WebSocket implementation\");\n const extra =\n protocols === undefined\n ? []\n : typeof protocols === \"string\"\n ? [protocols]\n : protocols;\n ws = new WS(url, [WS_SUBPROTOCOL, ...extra]);\n w.socket = ws;\n return await runSocket(c, w, ws, abort);\n } finally {\n if (w.socket === ws) w.socket = null;\n }\n};\n\nconst sendCommand = (\n w: WsChannel,\n { seq, command }: { seq: number; command: StatewireWireCommand },\n): Promise<StatewireSubmitResult> => {\n const ws = w.socket;\n if (!ws || ws.readyState !== OPEN) {\n return Promise.reject(new Error(\"network\"));\n }\n return new Promise((resolve, reject) => {\n const entry: PendingCommand = {\n seq,\n resolve: (result) => {\n clearTimeout(timer);\n resolve(result);\n },\n reject: (error) => {\n clearTimeout(timer);\n reject(error);\n },\n };\n // A timed-out command fails the socket: the retry then rides reconnect +\n // attach-seq arbitration.\n const timer = setTimeout(() => {\n w.pending = w.pending.filter((p) => p !== entry);\n reject(new Error(\"timeout\"));\n ws.close();\n }, timing.heartbeatTimeoutMs);\n w.pending.push(entry);\n try {\n ws.send(JSON.stringify({ ...encodeWireCommand(command), seq }));\n } catch {\n clearTimeout(timer);\n w.pending = w.pending.filter((p) => p !== entry);\n reject(new Error(\"network\"));\n }\n });\n};\n\nconst useWsLink = (\n options: StatewireClient.WSOptions,\n ctx: StatewireRemoteProps,\n): RemoteLink => {\n const [w] = useState((): WsChannel => ({ socket: null, pending: [] }));\n const c = useStatewireConn({\n url: options.url,\n onChange: options.onChange,\n ctx,\n attemptOnce: (c, abort) => attemptSocket(c, w, options, abort),\n });\n const [link] = useState(\n (): RemoteLink => ({\n get connection() {\n return c.connection;\n },\n send: (args) => sendCommand(w, args),\n }),\n );\n return link;\n};\n\nconst useStatewireWS = (\n options: StatewireClient.WSOptions,\n): Statewire<any, any, any> => useStatewireRemote(options, useWsLink);\n\nexport const StatewireWS = resource(useStatewireWS) as <\n TState = any,\n TCommands extends Statewire.Commands = Statewire.Commands,\n TEvent = any,\n>(\n options: StatewireClient.WSOptions,\n) => ResourceElement<Statewire<TState | undefined, TCommands, TEvent>>;\n"],"mappings":";;;;;;;;AAiCA,MAAM8B,OAAO;AAEb,MAAaC,iBAAiB;AAE9B,MAAMC,YAAY,OAChBC,SACAE,aACG;CACH,MAAM,EAAEC,KAAKC,UAAUJ;CACvB,MAAMK,SAAS,IAAIC,gBACjB,OAAOF,UAAU,aAAa,MAAMA,MAAM,IAAIA,KAChD;CACAC,OAAOE,IAAI3B,oBAAoBsB,QAAQ;CAEvC,OAAO,GADMC,IAAIM,QAAQ,OAAO,EAAE,CAAC,CAACA,QAAQ,SAAS,IAC3CD,EAAI,MAAOH,OAAOK,SAAS;AACvC;AAEA,MAAMC,oBACJC,GACA,EAAEzB,KAAK0B,mBACJ;CACH,IAAI1B,QAAQ2B,KAAAA,GAAW,MAAMzC,gBAAgBwC,YAAY;CACzD,MAAME,QAAQH,EAAEhB,QAAQoB,MAAMC,MAAMA,EAAE9B,QAAQA,GAAG;CACjD,IAAI,CAAC4B,OAAO;CACZH,EAAEhB,UAAUgB,EAAEhB,QAAQsB,QAAQD,MAAMA,MAAMF,KAAK;CAC/CA,MAAMzB,OAAOjB,gBAAgBwC,YAAY,CAAC;AAC5C;AAEA,MAAMM,kBACJP,GACAQ,YACG;CACH,MAAMC,YAA8B,CAAA;CACpC,KAAK,MAAMN,SAASH,EAAEhB,SACpB,IAAIwB,QAAQL,KAAK,GAAGA,MAAM3B,QAAQ,EAAEkC,MAAM,UAAU,CAAC;MAChDD,UAAUE,KAAKR,KAAK;CAE3BH,EAAEhB,UAAUyB;AACd;AAEA,MAAMG,eAAeZ,GAAcrB,UAAiB;CAClD,MAAMK,UAAUgB,EAAEhB;CAClBgB,EAAEhB,UAAU,CAAA;CACZ,KAAK,MAAMmB,SAASnB,SAASmB,MAAMzB,OAAOC,KAAK;AACjD;AAEA,MAAMkC,aACJC,GACAd,GACAe,IACAC,UAEA,IAAIE,SAAS1C,SAASE,WAAW;CAC/B,IAAIyC;CACJ,IAAIG,WAAW;CACf,IAAIC,UAAsC;CAC1C,IAAIC,SAAS;CACb,MAAMC,YAAY;EAChBC,aAAaP,KAAK;EAClBA,QAAQE,iBAAiB;GACvBC,WAAW;GACXP,GAAGY,MAAM;EACX,GAAGpE,OAAOqE,kBAAkB;CAC9B;CACAH,IAAI;CACJT,MAAMa,OAAOC,iBAAiB,eAAef,GAAGY,MAAM,CAAC;CACvDZ,GAAGgB,gBAAgB,CAAC;CACpB,MAAMC,cAAcC,QAAQV,OAAO,KAAKC,UAAUR,MAAMa,OAAOK;CAC/D,MAAMC,QAAQxD,UAAmB;EAC/B+C,aAAaP,KAAK;EAClBzC,OAAOC,iBAAiBC,QAAQD,wBAAQ,IAAIC,MAAM,UAAU,CAAC;EAC7DmC,GAAGY,MAAM;CACX;CACA,MAAMS,cAAcC,QAA2B;EAC7C,MAAMC,OAAOlF,mBAAmBI,eAAesD,GAAGuB,GAAG,CAAC;EACtD,IAAIA,IAAIE,QAAQrC,KAAAA,GAAW;GACzB,MAAMqC,MAAMF,IAAIE;GAChBhC,eAAeP,IAAIG,UAAUA,MAAM5B,OAAOgE,GAAG;EAC/C;EACA,IAAIF,IAAIG,QAAQtC,KAAAA,GAAW;GACzB,MAAMuC,OAAO,IAAIC,IAAIL,IAAIG,IAAIG,KAAKC,MAAMA,EAAErE,GAAG,CAAC;GAC9CgC,eAAeP,IAAIG,UAAUsC,KAAKI,IAAI1C,MAAM5B,GAAG,CAAC;EAClD;EACA,IAAI+D,MAAM;GACRf,UAAUe;GACVvB,GAAGY,MAAM;EACX;CACF;CACAZ,GAAG+B,aAAaC,UAAU;EACxB,IAAIf,MAAM,GAAG;EACbP,IAAI;EACJ,IAAIuB;EAGJ,IAAI;GACFA,QAAQE,KAAKC,MAAMC,OAAOL,MAAMM,IAAI,CAAC;EACvC,QAAQ;GACNlB,qBAAK,IAAIvD,MAAM,UAAU,CAAC;GAC1B;EACF;EACA,IAAI;GACF,IAAIoE,MAAMC,eAAe/C,KAAAA,GACvBH,iBAAiBC,GAAGgD,MAAMC,UAAU;QAEpCb,WAAWY,KAAK;EAEpB,SAASrE,OAAO;GACdwD,KAAKxD,KAAK;EACZ;CACF;CACAoC,GAAGuC,gBAAgB;EACjB9B,SAAS;EACTE,aAAaP,KAAK;EAClB,IAAInB,EAAElB,WAAWiC,IAAIH,YAAYZ,mBAAG,IAAIpB,MAAM,SAAS,CAAC;EACxD,IAAI2C,SAAS/C,QAAQ+C,OAAO;OACvB7C,uBAAO,IAAIE,MAAM0C,WAAW,YAAY,SAAS,CAAC;CACzD;AACF,CAAC;AAEH,MAAMiC,gBAAgB,OACpBzC,GACAd,GACAZ,SACA4B,UACiC;CACjC,IAAID,KAAuB;CAC3B,IAAI;EACF,MAAMxB,MAAM,MAAMJ,UAAUC,SAAS0B,EAAE0C,IAAIlE,QAAQ;EACnD0B,MAAMa,OAAO4B,eAAe;EAC5B,MAAM,EAAEC,WAAWC,cAAcvE;EACjC,MAAMwE,KAAKD,aAAaE,WAAW9E;EACnC,IAAI,CAAC6E,IAAI,MAAM,IAAIhF,MAAM,6BAA6B;EAOtDmC,KAAK,IAAI6C,GAAGrE,KAAK,CAACL,gBAAgB,GALhCwE,cAAcxD,KAAAA,IACV,CAAA,IACA,OAAOwD,cAAc,WACnB,CAACA,SAAS,IACVA,SACkC,CAAC;EAC3C1D,EAAElB,SAASiC;EACX,OAAO,MAAMF,UAAUC,GAAGd,GAAGe,IAAIC,KAAK;CACxC,UAAU;EACR,IAAIhB,EAAElB,WAAWiC,IAAIf,EAAElB,SAAS;CAClC;AACF;AAEA,MAAMiF,eACJ/D,GACA,EAAEzB,KAAKyF,cAC4B;CACnC,MAAMjD,KAAKf,EAAElB;CACb,IAAI,CAACiC,MAAMA,GAAGkD,eAAehF,MAC3B,OAAOiC,QAAQxC,uBAAO,IAAIE,MAAM,SAAS,CAAC;CAE5C,OAAO,IAAIsC,SAAS1C,SAASE,WAAW;EACtC,MAAMyB,QAAwB;GAC5B5B;GACAC,UAAUC,WAAW;IACnBiD,aAAaP,KAAK;IAClB3C,QAAQC,MAAM;GAChB;GACAC,SAASC,UAAU;IACjB+C,aAAaP,KAAK;IAClBzC,OAAOC,KAAK;GACd;EACF;EAGA,MAAMwC,QAAQE,iBAAiB;GAC7BrB,EAAEhB,UAAUgB,EAAEhB,QAAQsB,QAAQD,MAAMA,MAAMF,KAAK;GAC/CzB,uBAAO,IAAIE,MAAM,SAAS,CAAC;GAC3BmC,GAAGY,MAAM;EACX,GAAGpE,OAAOqE,kBAAkB;EAC5B5B,EAAEhB,QAAQ2B,KAAKR,KAAK;EACpB,IAAI;GACFY,GAAGmD,KAAKhB,KAAKiB,UAAU;IAAE,GAAGlG,kBAAkB+F,OAAO;IAAGzF;GAAI,CAAC,CAAC;EAChE,QAAQ;GACNmD,aAAaP,KAAK;GAClBnB,EAAEhB,UAAUgB,EAAEhB,QAAQsB,QAAQD,MAAMA,MAAMF,KAAK;GAC/CzB,uBAAO,IAAIE,MAAM,SAAS,CAAC;EAC7B;CACF,CAAC;AACH;AAEA,MAAMwF,aACJhF,SACAoE,QACe;CACf,MAAM,CAACxD,KAAK7C,gBAA2B;EAAE2B,QAAQ;EAAME,SAAS,CAAA;CAAG,EAAE;CACrE,MAAM8B,MAAIpD,iBAAiB;EACzB6B,KAAKH,QAAQG;EACb8E,UAAUjF,QAAQiF;EAClBb;EACAc,cAAcxD,GAAGE,UAAUuC,cAAczC,GAAGd,GAAGZ,SAAS4B,KAAK;CAC/D,CAAC;CACD,MAAM,CAACuD,QAAQpH,gBACM;EACjB,IAAIqH,aAAa;GACf,OAAO1D,IAAE0D;EACX;EACAN,OAAOO,SAASV,YAAY/D,GAAGyE,IAAI;CACrC,EACF;CACA,OAAOF;AACT;AAEA,MAAMG,kBACJtF,YAC6BrB,mBAAmBqB,SAASgF,SAAS;AAEpE,MAAaO,cAActH,SAASqH,cAAc"}
1
+ {"version":3,"file":"ws.js","names":["useState","flushTapSync","resource","ResourceElement","timing","handleEnvelope","overloadedError","useStatewireConn","Conn","StatewireEnvelope","Statewire","StatewireClient","useStatewireRemote","CLIENT_QUERY_PARAM","encodeWireCommand","RemoteLink","StatewireSubmitResult","StatewireRemoteProps","StatewireWireCommand","PendingCommand","seq","resolve","result","reject","error","Error","WsChannel","socket","WebSocket","pending","authStale","OPEN","WS_SUBPROTOCOL","UNAUTHORIZED_CLOSE","socketUrl","options","WSOptions","clientId","url","query","params","URLSearchParams","set","base","replace","toString","headersFrame","stale","Promise","headers","undefined","resolved","Headers","staleAuth","JSON","stringify","type","Object","fromEntries","settleOverloaded","w","retryAfterMs","entry","find","p","filter","resolvePending","matches","remaining","push","failPending","runSocket","c","ws","frame","abort","AbortController","timer","ReturnType","setTimeout","timedOut","outcome","closed","arm","clearTimeout","close","heartbeatTimeoutMs","signal","addEventListener","onerror","onopen","send","Boolean","aborted","fail","processEnv","env","done","ack","res","seqs","Set","map","r","has","onmessage","event","overloaded","parse","String","data","onclose","code","episode","cause","attemptSocket","ctx","throwIfAborted","protocols","webSocket","WS","globalThis","extra","sendCommand","command","readyState","useWsLink","onChange","attemptOnce","link","connection","args","useStatewireWS","StatewireWS","Commands","TState","TCommands","TEvent"],"sources":["../../src/transports/ws.ts"],"sourcesContent":["import { useState } from \"react\";\nimport { flushTapSync, resource } from \"@assistant-ui/tap\";\nimport type { ResourceElement } from \"@assistant-ui/tap\";\nimport { timing } from \"../client/timing\";\nimport {\n handleEnvelope,\n overloadedError,\n useStatewireConn,\n} from \"../client/connection\";\nimport type { Conn, StatewireEnvelope } from \"../client/connection\";\nimport type { Statewire } from \"../types\";\nimport type { StatewireClient } from \"../statewire\";\nimport { useStatewireRemote } from \"./remote\";\nimport { CLIENT_QUERY_PARAM } from \"../host/adapter\";\nimport { encodeWireCommand } from \"./sse\";\nimport type {\n RemoteLink,\n StatewireSubmitResult,\n StatewireRemoteProps,\n StatewireWireCommand,\n} from \"./sse\";\n\ntype PendingCommand = {\n seq: number;\n resolve: (result: StatewireSubmitResult) => void;\n reject: (error: Error) => void;\n};\n\ntype WsChannel = {\n socket: WebSocket | null;\n pending: PendingCommand[];\n authStale: boolean;\n};\n\nconst OPEN = 1;\n\nexport const WS_SUBPROTOCOL = \"statewire.v1\";\nexport const UNAUTHORIZED_CLOSE = 4401;\n\nconst socketUrl = async (\n options: StatewireClient.WSOptions,\n clientId: string,\n) => {\n const { url, query } = options;\n const params = new URLSearchParams(\n typeof query === \"function\" ? await query() : query,\n );\n params.set(CLIENT_QUERY_PARAM, clientId);\n const base = url.replace(/\\/$/, \"\").replace(/^http/, \"ws\");\n return `${base}/ws?${params.toString()}`;\n};\n\nconst headersFrame = async (\n options: StatewireClient.WSOptions,\n stale: boolean,\n): Promise<string | null> => {\n const { headers } = options;\n if (headers === undefined) return null;\n const resolved = new Headers(\n typeof headers === \"function\"\n ? await headers({ staleAuth: stale })\n : headers,\n );\n return JSON.stringify({\n type: \"headers\",\n headers: Object.fromEntries(resolved),\n });\n};\n\nconst settleOverloaded = (\n w: WsChannel,\n { seq, retryAfterMs }: { seq?: number; retryAfterMs?: number },\n) => {\n if (seq === undefined) throw overloadedError(retryAfterMs);\n const entry = w.pending.find((p) => p.seq === seq);\n if (!entry) return;\n w.pending = w.pending.filter((p) => p !== entry);\n entry.reject(overloadedError(retryAfterMs));\n};\n\nconst resolvePending = (\n w: WsChannel,\n matches: (entry: PendingCommand) => boolean,\n) => {\n const remaining: PendingCommand[] = [];\n for (const entry of w.pending) {\n if (matches(entry)) entry.resolve({ type: \"receipt\" });\n else remaining.push(entry);\n }\n w.pending = remaining;\n};\n\nconst failPending = (w: WsChannel, error: Error) => {\n const pending = w.pending;\n w.pending = [];\n for (const entry of pending) entry.reject(error);\n};\n\nconst runSocket = (\n c: Conn,\n w: WsChannel,\n ws: WebSocket,\n frame: string | null,\n staleAuth: boolean,\n abort: AbortController,\n): Promise<\"stop\" | \"reattach\"> =>\n new Promise((resolve, reject) => {\n let timer: ReturnType<typeof setTimeout> | undefined;\n let timedOut = false;\n let outcome: \"stop\" | \"reattach\" | null = null;\n let closed = false;\n const arm = () => {\n clearTimeout(timer);\n timer = setTimeout(() => {\n timedOut = true;\n ws.close();\n }, timing.heartbeatTimeoutMs);\n };\n arm();\n abort.signal.addEventListener(\"abort\", () => ws.close());\n ws.onerror = () => {};\n if (frame !== null) ws.onopen = () => ws.send(frame);\n const stale = () => Boolean(outcome) || closed || abort.signal.aborted;\n const fail = (error: unknown) => {\n clearTimeout(timer);\n reject(error instanceof Error ? error : new Error(\"protocol\"));\n ws.close();\n };\n const processEnv = (env: StatewireEnvelope) => {\n const done = flushTapSync(() => handleEnvelope(c, env));\n if (env.ack !== undefined) {\n const ack = env.ack;\n resolvePending(w, (entry) => entry.seq <= ack);\n }\n if (env.res !== undefined) {\n const seqs = new Set(env.res.map((r) => r.seq));\n resolvePending(w, (entry) => seqs.has(entry.seq));\n }\n if (done) {\n outcome = done;\n ws.close();\n }\n };\n ws.onmessage = (event) => {\n if (stale()) return;\n arm();\n let frame: StatewireEnvelope & {\n overloaded?: { seq?: number; retryAfterMs?: number };\n };\n try {\n frame = JSON.parse(String(event.data)) as typeof frame;\n } catch {\n fail(new Error(\"protocol\"));\n return;\n }\n try {\n if (frame.overloaded !== undefined) {\n settleOverloaded(w, frame.overloaded);\n } else {\n processEnv(frame);\n }\n } catch (error) {\n fail(error);\n }\n };\n ws.onclose = (event?: { code?: number }) => {\n closed = true;\n clearTimeout(timer);\n if (w.socket === ws) failPending(w, new Error(\"network\"));\n if (outcome) resolve(outcome);\n else if (event?.code === UNAUTHORIZED_CLOSE) {\n if (staleAuth) c.episode = { cause: \"unauthorized\" };\n w.authStale = true;\n reject(new Error(\"unauthorized\"));\n } else reject(new Error(timedOut ? \"timeout\" : \"network\"));\n };\n });\n\nconst attemptSocket = async (\n c: Conn,\n w: WsChannel,\n options: StatewireClient.WSOptions,\n abort: AbortController,\n): Promise<\"stop\" | \"reattach\"> => {\n let ws: WebSocket | null = null;\n try {\n const url = await socketUrl(options, c.ctx.clientId);\n const stale = w.authStale;\n w.authStale = false;\n const frame = await headersFrame(options, stale);\n abort.signal.throwIfAborted();\n const { protocols, webSocket } = options;\n const WS = webSocket ?? globalThis.WebSocket;\n if (!WS) throw new Error(\"no WebSocket implementation\");\n const extra =\n protocols === undefined\n ? []\n : typeof protocols === \"string\"\n ? [protocols]\n : protocols;\n ws = new WS(url, [WS_SUBPROTOCOL, ...extra]);\n w.socket = ws;\n return await runSocket(c, w, ws, frame, stale, abort);\n } finally {\n if (w.socket === ws) w.socket = null;\n }\n};\n\nconst sendCommand = (\n w: WsChannel,\n { seq, command }: { seq: number; command: StatewireWireCommand },\n): Promise<StatewireSubmitResult> => {\n const ws = w.socket;\n if (!ws || ws.readyState !== OPEN) {\n return Promise.reject(new Error(\"network\"));\n }\n return new Promise((resolve, reject) => {\n const entry: PendingCommand = {\n seq,\n resolve: (result) => {\n clearTimeout(timer);\n resolve(result);\n },\n reject: (error) => {\n clearTimeout(timer);\n reject(error);\n },\n };\n // A timed-out command fails the socket: the retry then rides reconnect +\n // attach-seq arbitration.\n const timer = setTimeout(() => {\n w.pending = w.pending.filter((p) => p !== entry);\n reject(new Error(\"timeout\"));\n ws.close();\n }, timing.heartbeatTimeoutMs);\n w.pending.push(entry);\n try {\n ws.send(JSON.stringify({ ...encodeWireCommand(command), seq }));\n } catch {\n clearTimeout(timer);\n w.pending = w.pending.filter((p) => p !== entry);\n reject(new Error(\"network\"));\n }\n });\n};\n\nconst useWsLink = (\n options: StatewireClient.WSOptions,\n ctx: StatewireRemoteProps,\n): RemoteLink => {\n const [w] = useState(\n (): WsChannel => ({ socket: null, pending: [], authStale: false }),\n );\n const c = useStatewireConn({\n url: options.url,\n onChange: options.onChange,\n ctx,\n attemptOnce: (c, abort) => attemptSocket(c, w, options, abort),\n });\n const [link] = useState(\n (): RemoteLink => ({\n get connection() {\n return c.connection;\n },\n send: (args) => sendCommand(w, args),\n }),\n );\n return link;\n};\n\nconst useStatewireWS = (\n options: StatewireClient.WSOptions,\n): Statewire<any, any, any> => useStatewireRemote(options, useWsLink);\n\nexport const StatewireWS = resource(useStatewireWS) as <\n TState = any,\n TCommands extends Statewire.Commands = Statewire.Commands,\n TEvent = any,\n>(\n options: StatewireClient.WSOptions,\n) => ResourceElement<Statewire<TState | undefined, TCommands, TEvent>>;\n"],"mappings":"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"}
@@ -7,12 +7,14 @@ const walkStatePath = (root, path, mode) => {
7
7
  for (let depth = 0; depth < path.length; depth++) {
8
8
  const k = path[depth];
9
9
  const mustResolve = mode === "resolve" || mode === "parent" && depth < path.length - 1;
10
- if (mode !== "read") if (typeof k === "number") {
11
- if (!Array.isArray(node) || !Number.isInteger(k) || k < 0) return;
12
- if (mustResolve && k >= node.length) return void 0;
13
- } else {
14
- if (isUnsafeKey(k, path[depth - 1]) || typeof node !== "object" || node === null || Array.isArray(node)) return;
15
- if (mustResolve && !Object.hasOwn(node, k)) return void 0;
10
+ if (mode !== "read") {
11
+ if (typeof k === "number") {
12
+ if (!Array.isArray(node) || !Number.isInteger(k) || k < 0) return;
13
+ if (mustResolve && k >= node.length) return void 0;
14
+ } else {
15
+ if (isUnsafeKey(k, path[depth - 1]) || typeof node !== "object" || node === null || Array.isArray(node)) return;
16
+ if (mustResolve && !Object.hasOwn(node, k)) return void 0;
17
+ }
16
18
  }
17
19
  parent = node;
18
20
  key = k;
@@ -1 +1 @@
1
- {"version":3,"file":"statePath.js","names":["StatePath","isUnsafeKey","key","parent","WalkedPath","leaf","walkStatePath","root","path","mode","undefined","node","depth","length","k","mustResolve","Array","isArray","Number","isInteger","Object","hasOwn","Record","readAtPath"],"sources":["../../src/util/statePath.ts"],"sourcesContent":["export type StatePath = readonly (string | number)[];\n\nexport const isUnsafeKey = (\n key: string | number,\n parent: string | number | undefined,\n) => key === \"__proto__\" || (key === \"prototype\" && parent === \"constructor\");\n\nexport type WalkedPath = {\n /** Container of the final key; undefined when the path is empty. */\n parent: unknown;\n key: string | number;\n leaf: unknown;\n};\n\n// \"read\": lenient, never fails — missing steps yield an undefined leaf.\n// \"parent\": every step before the last must resolve; the leaf may not exist.\n// \"resolve\": every step including the last must resolve.\n// Strict modes require array+integer for number keys, plain object for string\n// keys, and reject unsafe (prototype-polluting) segments.\nexport const walkStatePath = (\n root: unknown,\n path: StatePath,\n mode: \"read\" | \"parent\" | \"resolve\",\n): WalkedPath | undefined => {\n let parent: unknown = undefined;\n let key: string | number = \"\";\n let node: unknown = root;\n for (let depth = 0; depth < path.length; depth++) {\n const k = path[depth]!;\n const mustResolve =\n mode === \"resolve\" || (mode === \"parent\" && depth < path.length - 1);\n if (mode !== \"read\") {\n if (typeof k === \"number\") {\n if (!Array.isArray(node) || !Number.isInteger(k) || k < 0) {\n return undefined;\n }\n if (mustResolve && k >= node.length) return undefined;\n } else {\n if (\n isUnsafeKey(k, path[depth - 1]) ||\n typeof node !== \"object\" ||\n node === null ||\n Array.isArray(node)\n ) {\n return undefined;\n }\n if (mustResolve && !Object.hasOwn(node, k)) return undefined;\n }\n }\n parent = node;\n key = k;\n node =\n typeof node === \"object\" && node !== null && Object.hasOwn(node, k)\n ? (node as Record<string | number, unknown>)[k]\n : undefined;\n }\n return { parent, key, leaf: node };\n};\n\nexport const readAtPath = (root: unknown, path: StatePath): unknown =>\n walkStatePath(root, path, \"read\")!.leaf;\n"],"mappings":";AAEA,MAAaC,eACXC,KACAC,WACGD,QAAQ,eAAgBA,QAAQ,eAAeC,WAAW;AAc/D,MAAaG,iBACXC,MACAC,MACAC,SAC2B;CAC3B,IAAIN,SAAkBO,KAAAA;CACtB,IAAIR,MAAuB;CAC3B,IAAIS,OAAgBJ;CACpB,KAAK,IAAIK,QAAQ,GAAGA,QAAQJ,KAAKK,QAAQD,SAAS;EAChD,MAAME,IAAIN,KAAKI;EACf,MAAMG,cACJN,SAAS,aAAcA,SAAS,YAAYG,QAAQJ,KAAKK,SAAS;EACpE,IAAIJ,SAAS,QACX,IAAI,OAAOK,MAAM,UAAU;GACzB,IAAI,CAACE,MAAMC,QAAQN,IAAI,KAAK,CAACO,OAAOC,UAAUL,CAAC,KAAKA,IAAI,GACtD;GAEF,IAAIC,eAAeD,KAAKH,KAAKE,QAAQ,OAAOH,KAAAA;EAC9C,OAAO;GACL,IACET,YAAYa,GAAGN,KAAKI,QAAQ,EAAE,KAC9B,OAAOD,SAAS,YAChBA,SAAS,QACTK,MAAMC,QAAQN,IAAI,GAElB;GAEF,IAAII,eAAe,CAACK,OAAOC,OAAOV,MAAMG,CAAC,GAAG,OAAOJ,KAAAA;EACrD;EAEFP,SAASQ;EACTT,MAAMY;EACNH,OACE,OAAOA,SAAS,YAAYA,SAAS,QAAQS,OAAOC,OAAOV,MAAMG,CAAC,IAC7DH,KAA0CG,KAC3CJ,KAAAA;CACR;CACA,OAAO;EAAEP;EAAQD;EAAKG,MAAMM;CAAK;AACnC;AAEA,MAAaY,cAAchB,MAAeC,SACxCF,cAAcC,MAAMC,MAAM,MAAM,CAAC,CAAEH"}
1
+ {"version":3,"file":"statePath.js","names":["StatePath","isUnsafeKey","key","parent","WalkedPath","leaf","walkStatePath","root","path","mode","undefined","node","depth","length","k","mustResolve","Array","isArray","Number","isInteger","Object","hasOwn","Record","readAtPath"],"sources":["../../src/util/statePath.ts"],"sourcesContent":["export type StatePath = readonly (string | number)[];\n\nexport const isUnsafeKey = (\n key: string | number,\n parent: string | number | undefined,\n) => key === \"__proto__\" || (key === \"prototype\" && parent === \"constructor\");\n\nexport type WalkedPath = {\n /** Container of the final key; undefined when the path is empty. */\n parent: unknown;\n key: string | number;\n leaf: unknown;\n};\n\n// \"read\": lenient, never fails — missing steps yield an undefined leaf.\n// \"parent\": every step before the last must resolve; the leaf may not exist.\n// \"resolve\": every step including the last must resolve.\n// Strict modes require array+integer for number keys, plain object for string\n// keys, and reject unsafe (prototype-polluting) segments.\nexport const walkStatePath = (\n root: unknown,\n path: StatePath,\n mode: \"read\" | \"parent\" | \"resolve\",\n): WalkedPath | undefined => {\n let parent: unknown = undefined;\n let key: string | number = \"\";\n let node: unknown = root;\n for (let depth = 0; depth < path.length; depth++) {\n const k = path[depth]!;\n const mustResolve =\n mode === \"resolve\" || (mode === \"parent\" && depth < path.length - 1);\n if (mode !== \"read\") {\n if (typeof k === \"number\") {\n if (!Array.isArray(node) || !Number.isInteger(k) || k < 0) {\n return undefined;\n }\n if (mustResolve && k >= node.length) return undefined;\n } else {\n if (\n isUnsafeKey(k, path[depth - 1]) ||\n typeof node !== \"object\" ||\n node === null ||\n Array.isArray(node)\n ) {\n return undefined;\n }\n if (mustResolve && !Object.hasOwn(node, k)) return undefined;\n }\n }\n parent = node;\n key = k;\n node =\n typeof node === \"object\" && node !== null && Object.hasOwn(node, k)\n ? (node as Record<string | number, unknown>)[k]\n : undefined;\n }\n return { parent, key, leaf: node };\n};\n\nexport const readAtPath = (root: unknown, path: StatePath): unknown =>\n walkStatePath(root, path, \"read\")!.leaf;\n"],"mappings":";AAEA,MAAaC,eACXC,KACAC,WACGD,QAAQ,eAAgBA,QAAQ,eAAeC,WAAW;AAc/D,MAAaG,iBACXC,MACAC,MACAC,SAC2B;CAC3B,IAAIN,SAAkBO,KAAAA;CACtB,IAAIR,MAAuB;CAC3B,IAAIS,OAAgBJ;CACpB,KAAK,IAAIK,QAAQ,GAAGA,QAAQJ,KAAKK,QAAQD,SAAS;EAChD,MAAME,IAAIN,KAAKI;EACf,MAAMG,cACJN,SAAS,aAAcA,SAAS,YAAYG,QAAQJ,KAAKK,SAAS;EACpE,IAAIJ,SAAS,QAAQ;GACnB,IAAI,OAAOK,MAAM,UAAU;IACzB,IAAI,CAACE,MAAMC,QAAQN,IAAI,KAAK,CAACO,OAAOC,UAAUL,CAAC,KAAKA,IAAI,GACtD;IAEF,IAAIC,eAAeD,KAAKH,KAAKE,QAAQ,OAAOH,KAAAA;GAC9C,OAAO;IACL,IACET,YAAYa,GAAGN,KAAKI,QAAQ,EAAE,KAC9B,OAAOD,SAAS,YAChBA,SAAS,QACTK,MAAMC,QAAQN,IAAI,GAElB;IAEF,IAAII,eAAe,CAACK,OAAOC,OAAOV,MAAMG,CAAC,GAAG,OAAOJ,KAAAA;GACrD;EACF;EACAP,SAASQ;EACTT,MAAMY;EACNH,OACE,OAAOA,SAAS,YAAYA,SAAS,QAAQS,OAAOC,OAAOV,MAAMG,CAAC,IAC7DH,KAA0CG,KAC3CJ,KAAAA;CACR;CACA,OAAO;EAAEP;EAAQD;EAAKG,MAAMM;CAAK;AACnC;AAEA,MAAaY,cAAchB,MAAeC,SACxCF,cAAcC,MAAMC,MAAM,MAAM,CAAC,CAAEH"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "statewire",
3
- "version": "0.0.5",
3
+ "version": "0.5.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": {
@@ -7,7 +7,7 @@ import type { StatewireClient } from "../statewire";
7
7
  import type { StatewireHost } from "../host";
8
8
 
9
9
  export type Episode = {
10
- cause: "dropped" | "evicted" | "error";
10
+ cause: "dropped" | "evicted" | "error" | "unauthorized";
11
11
  message?: string;
12
12
  };
13
13
 
@@ -159,6 +159,7 @@ const createCore = (
159
159
  const connection = getConnection();
160
160
  const connectionChanged = connection !== notifiedConnection;
161
161
  notifiedConnection = connection;
162
+ if (connectionChanged) options.onConnectionChange?.(connection);
162
163
  if (stateChanged || connectionChanged) {
163
164
  for (const listener of [...listeners]) listener();
164
165
  }
@@ -147,11 +147,7 @@ const rejectedSend = (
147
147
 
148
148
  type AttachState = "detached" | "attached" | "finished";
149
149
 
150
- export type StatewireSessionOptions = StatewireSessionStoreOptions & {
151
- onCommandSessionLost?:
152
- | ((command: StatewireClient.SessionLostCommand) => "replay" | void)
153
- | undefined;
154
- };
150
+ export type StatewireSessionOptions = StatewireSessionStoreOptions;
155
151
 
156
152
  type Session = {
157
153
  record: StatewireSessionRecord;
@@ -172,7 +168,6 @@ type Session = {
172
168
  transport: RemoteLink | null;
173
169
  waiters: (() => void)[];
174
170
  lifecycle: CommandsListener | null;
175
- onCommandSessionLost: StatewireSessionOptions["onCommandSessionLost"];
176
171
  notify: () => void;
177
172
  emitEvent: (event: unknown) => void;
178
173
  send: (command: StatewireWireCommand) => Promise<unknown>;
@@ -219,7 +214,6 @@ const createSession = (options: StatewireSessionOptions): Session => {
219
214
  transport: null,
220
215
  waiters: [],
221
216
  lifecycle: null,
222
- onCommandSessionLost: options.onCommandSessionLost,
223
217
  notify: () => {},
224
218
  emitEvent: () => {},
225
219
  send: (command) => sendCommand(s, command),
@@ -298,25 +292,15 @@ const settleAll = (
298
292
  }
299
293
  };
300
294
 
301
- // Offers every unsettled real command for replay, in seq order. Not caught:
302
- // a throwing callback aborts the eviction before any entry settles; the
303
- // attach retry evicts again and re-offers.
295
+ // Claims every unsettled real command for replay under the rotated client id,
296
+ // in seq order; context submissions settle and the context re-sends whole.
304
297
  const claimReplays = (s: Session): Pending[] => {
305
- const handler = s.onCommandSessionLost;
306
- if (!handler) return [];
307
- const claimed = new Set<Pending>();
308
- for (const entry of s.pending) {
309
- if (!entry.inv || isResolved(entry.state)) continue;
310
- const decision = handler({
311
- method: entry.inv.method,
312
- params: [...entry.inv.params],
313
- seq: entry.seq,
314
- });
315
- if (decision === "replay") claimed.add(entry);
316
- }
317
- if (claimed.size === 0) return [];
318
- s.pending = s.pending.filter((p) => !claimed.has(p));
319
- return [...claimed];
298
+ const claimed = s.pending.filter(
299
+ (entry) => entry.inv !== null && !isResolved(entry.state),
300
+ );
301
+ if (claimed.length === 0) return [];
302
+ s.pending = s.pending.filter((p) => !claimed.includes(p));
303
+ return claimed;
320
304
  };
321
305
 
322
306
  const evict = (
@@ -745,7 +729,6 @@ export const useStatewireSession = (
745
729
  emit: (event: unknown) => void,
746
730
  ): Session => {
747
731
  const [s] = useState(() => createSession(options));
748
- s.onCommandSessionLost = options.onCommandSessionLost;
749
732
  useEffect(() => {
750
733
  s.emitEvent = emit;
751
734
  return () => {