statewire 0.17.1 → 0.18.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.
@@ -42,6 +42,9 @@ const consume = async (c, reader, heartbeat, dead) => {
42
42
  }
43
43
  };
44
44
  const attemptStream = async (c, options, abort) => {
45
+ const persistence = c.ctx.beforeAttach?.();
46
+ if (persistence) await persistence;
47
+ if (abort.signal.aborted) return "ended";
45
48
  c.lease = null;
46
49
  const headers = await resolveHeaders(options.headers, c.authStale);
47
50
  for (const [name, value] of Object.entries(negotiationHeaders(options))) if (!headers.has(name)) headers.set(name, value);
@@ -1 +1 @@
1
- {"version":3,"file":"sse.js","names":["flushTapSync","resource","ResourceElement","createSseParser","timing","settleFin","classify","handleEnvelope","resolveHeaders","useLink","validateRefusalSyn","AttemptOutcome","Conn","StatewireCmd","StatewireEnvelope","StatewireSyn","Statewire","StatewireClient","StatewireHost","useStatewireRemote","negotiationHeaders","parseNegotiation","StatewireFrame","ctx","ops","seq","cmd","method","params","meta","StatewireRemoteProps","clientId","takeAttachLanes","onHello","args","syn","onOps","cmds","onCmds","onRefusal","onIdle","level","onDetach","onFinish","Fin","StatewireSubmitResult","type","ConnectionState","RemoteLink","connection","Connection","parked","send","frame","Promise","LEASE_HEADER","baseUrl","options","HttpOptions","url","replace","withExtra","headers","Headers","extra","Record","merged","name","value","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","lease","authStale","has","negotiation","lanes","body","undefined","stringify","headerTimer","res","accept","cache","any","ok","clone","json","catch","fin","httpFin","Error","get","getReader","aborted","cancel","postFrame","lifetime","retryAfterMs","header","trim","test","retry","Number","isSafeInteger","reason","status","message","code","refusalSyn","sendFrame","headerRetry","error","useSseLink","attemptOnce","SyntaxError","startsWith","useStatewireHttp","StatewireHttp","Commands","TState","TCommands"],"sources":["../../src/transports/sse.ts"],"sourcesContent":["import { flushTapSync, resource } from \"@assistant-ui/tap\";\nimport type { ResourceElement } from \"@assistant-ui/tap\";\nimport { createSseParser } from \"./sse-parser\";\nimport { timing } from \"../client/timing\";\nimport {\n settleFin,\n classify,\n handleEnvelope,\n resolveHeaders,\n useLink,\n validateRefusalSyn,\n} from \"../client/connection\";\nimport type {\n AttemptOutcome,\n Conn,\n StatewireCmd,\n StatewireEnvelope,\n StatewireSyn,\n} from \"../client/connection\";\nimport type { Statewire } from \"../types\";\nimport type { StatewireClient } from \"../statewire\";\nimport type { StatewireHost } from \"../host\";\nimport { useStatewireRemote } from \"./remote\";\nimport { negotiationHeaders, parseNegotiation } from \"../wire/protocol\";\n\n/** A client frame's lanes: stamped statements, strictly consecutive per lane. */\nexport type StatewireFrame = {\n ctx?: { ops: unknown[]; seq: number }[];\n cmd?: { method: string; params: unknown[]; meta?: unknown; seq: number }[];\n};\n\nexport type StatewireRemoteProps = {\n clientId: string;\n /** Lanes to ride the attach frame (no-lease supersession only); records the included seqs. */\n takeAttachLanes: () => StatewireFrame | null;\n onHello: (args: { ops: unknown[]; syn: StatewireSyn }) => void;\n onOps: (args: { ops: unknown[]; cmds: StatewireCmd[] }) => void;\n onCmds: (cmds: StatewireCmd[]) => void;\n onRefusal: (syn: StatewireSyn) => void;\n onIdle: (level: boolean) => void;\n onDetach: () => void;\n onFinish: (args: StatewireHost.Fin) => void;\n};\n\nexport type StatewireSubmitResult =\n | { type: \"ok\" }\n | { type: \"refusal\"; syn: StatewireSyn }\n | { type: \"stale-lease\" }\n | { type: \"no-lease\" };\n\nexport type ConnectionState = StatewireClient.ConnectionState;\n\n/** The live wire a remote session drives: connection status plus frame submission. */\nexport type RemoteLink = {\n connection: StatewireClient.Connection;\n /** A deferred first attach still waiting; `reconnect()` releases it. */\n parked: boolean;\n send: (frame: StatewireFrame) => Promise<StatewireSubmitResult>;\n};\n\nconst LEASE_HEADER = \"Statewire-Lease\";\n\nconst baseUrl = (options: StatewireClient.HttpOptions) =>\n options.url.replace(/\\/$/, \"\");\n\nconst withExtra = (headers: Headers, extra: Record<string, string>) => {\n const merged = new Headers(headers);\n for (const [name, value] of Object.entries(extra)) merged.set(name, value);\n return merged;\n};\n\nconst timedFetch = async (\n options: StatewireClient.HttpOptions,\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<AttemptOutcome> => {\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.HttpOptions,\n abort: AbortController,\n): Promise<AttemptOutcome> => {\n c.lease = null;\n const headers = await resolveHeaders(options.headers, c.authStale);\n for (const [name, value] of Object.entries(negotiationHeaders(options))) {\n if (!headers.has(name)) headers.set(name, value);\n }\n c.negotiation = parseNegotiation(headers);\n c.authStale = false;\n const lanes = flushTapSync(() => c.ctx.takeAttachLanes());\n const body = lanes === null ? undefined : JSON.stringify(lanes);\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 method: body === undefined ? \"GET\" : \"POST\",\n headers: withExtra(headers, {\n \"content-type\": \"application/json\",\n \"Statewire-Client-Id\": c.ctx.clientId,\n accept: \"text/event-stream\",\n }),\n ...(body === undefined ? {} : { body }),\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.ok) {\n const body = await res\n .clone()\n .json()\n .catch(() => null);\n return (\n flushTapSync(() =>\n body?.fin === undefined\n ? settleFin(c, httpFin(res))\n : handleEnvelope(c, body),\n ) ?? \"stop\"\n );\n }\n if (!res.body)\n throw new Error(\"statewire: protocol error: stream response has no body\");\n c.lease = res.headers.get(LEASE_HEADER);\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 postFrame = async (\n c: Conn,\n options: StatewireClient.HttpOptions,\n lease: string,\n frame: StatewireFrame,\n timeout: AbortSignal,\n): Promise<Response> =>\n timedFetch(\n options,\n `${baseUrl(options)}/frames`,\n {\n method: \"POST\",\n headers: withExtra(new Headers(), {\n \"content-type\": \"application/json\",\n [LEASE_HEADER]: lease,\n }),\n body: JSON.stringify(frame),\n signal: AbortSignal.any([\n c.lifetime?.signal ?? AbortSignal.abort(),\n timeout,\n ]),\n },\n timeout,\n );\n\n// Delta-seconds only; anything else reads as absent.\nconst retryAfterMs = (res: Response): number | null => {\n const header = res.headers.get(\"retry-after\")?.trim();\n if (header === undefined || !/^\\d+$/.test(header)) return null;\n const retry = Number(header) * 1000;\n return Number.isSafeInteger(retry) ? retry : null;\n};\n\nconst httpFin = (res: Response): StatewireHost.Fin => ({\n reason:\n res.status === 401\n ? \"unauthorized\"\n : res.status === 403\n ? \"forbidden\"\n : res.status === 429 || res.status === 503\n ? \"reconnect\"\n : res.status >= 400 && res.status < 500\n ? \"protocol-error\"\n : \"error\",\n status: res.status,\n message: `HTTP ${res.status}`,\n ...(res.status === 429 || res.status === 503 ? { code: \"overloaded\" } : {}),\n ...(retryAfterMs(res) !== null ? { retry: retryAfterMs(res)! } : {}),\n});\n\nconst refusalSyn = async (res: Response): Promise<StatewireSyn> => {\n const body = (await res.json().catch(() => null)) as {\n syn?: StatewireSyn;\n } | null;\n const syn = body?.syn;\n if (typeof syn !== \"object\" || syn === null) {\n throw new Error(`HTTP ${res.status}`);\n }\n return validateRefusalSyn(syn);\n};\n\nconst sendFrame = async (\n c: Conn,\n options: StatewireClient.HttpOptions,\n frame: StatewireFrame,\n): Promise<StatewireSubmitResult> => {\n const lease = c.lease;\n if (lease === null) return { type: \"no-lease\" };\n const timeout = AbortSignal.timeout(timing.heartbeatTimeoutMs);\n const res = await postFrame(c, options, lease, frame, timeout);\n if (res.ok) return { type: \"ok\" };\n if (res.status === 429 || res.status === 409) {\n // The syn body retry wins; Retry-After covers 429s minted below the host.\n const headerRetry = res.status === 429 ? retryAfterMs(res) : null;\n const syn: StatewireSyn = await refusalSyn(res).catch((error: unknown) => {\n if (headerRetry === null) throw error;\n return { retry: headerRetry };\n });\n if (syn.retry === undefined && headerRetry !== null)\n syn.retry = headerRetry;\n return { type: \"refusal\", syn };\n }\n if (res.status === 423) return { type: \"stale-lease\" };\n flushTapSync(() => settleFin(c, httpFin(res)));\n c.abort?.abort();\n throw new Error(`HTTP ${res.status}`);\n};\n\nconst useSseLink = (\n options: StatewireClient.HttpOptions,\n ctx: StatewireRemoteProps,\n): RemoteLink =>\n useLink({\n ...options,\n ctx,\n attemptOnce: (c, abort) => attemptStream(c, options, abort),\n send: async (c, frame) => {\n try {\n return await sendFrame(c, options, frame);\n } catch (error) {\n if (\n error instanceof SyntaxError ||\n (error instanceof Error &&\n error.message.startsWith(\"statewire: protocol error:\"))\n ) {\n flushTapSync(() =>\n settleFin(c, { reason: \"protocol-error\", message: error.message }),\n );\n c.abort?.abort();\n }\n throw error;\n }\n },\n });\n\nconst useStatewireHttp = (\n options: StatewireClient.HttpOptions,\n): Statewire<any, any> => useStatewireRemote(options, useSseLink);\n\nexport const StatewireHttp = resource(useStatewireHttp) as <\n TState = any,\n TCommands extends Statewire.Commands = Statewire.Commands,\n>(\n options: StatewireClient.HttpOptions,\n) => ResourceElement<Statewire<TState | undefined, TCommands>>;\n"],"mappings":"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"}
1
+ {"version":3,"file":"sse.js","names":["flushTapSync","resource","ResourceElement","createSseParser","timing","settleFin","classify","handleEnvelope","resolveHeaders","useLink","validateRefusalSyn","AttemptOutcome","Conn","StatewireCmd","StatewireEnvelope","StatewireSyn","Statewire","StatewireClient","StatewireHost","useStatewireRemote","negotiationHeaders","parseNegotiation","StatewireFrame","ctx","ops","seq","cmd","method","params","meta","StatewireRemoteProps","beforeAttach","Promise","clientId","takeAttachLanes","onHello","args","syn","onOps","cmds","onCmds","onRefusal","onIdle","level","onDetach","onFinish","Fin","StatewireSubmitResult","type","ConnectionState","RemoteLink","storageFailed","error","Error","connection","Connection","parked","send","frame","LEASE_HEADER","baseUrl","options","HttpOptions","url","replace","withExtra","headers","Headers","extra","Record","merged","name","value","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","persistence","aborted","lease","authStale","has","negotiation","lanes","body","undefined","stringify","headerTimer","res","accept","cache","any","ok","clone","json","catch","fin","httpFin","get","getReader","cancel","postFrame","lifetime","retryAfterMs","header","trim","test","retry","Number","isSafeInteger","reason","status","message","code","refusalSyn","sendFrame","headerRetry","useSseLink","attemptOnce","SyntaxError","startsWith","useStatewireHttp","StatewireHttp","Commands","TState","TCommands"],"sources":["../../src/transports/sse.ts"],"sourcesContent":["import { flushTapSync, resource } from \"@assistant-ui/tap\";\nimport type { ResourceElement } from \"@assistant-ui/tap\";\nimport { createSseParser } from \"./sse-parser\";\nimport { timing } from \"../client/timing\";\nimport {\n settleFin,\n classify,\n handleEnvelope,\n resolveHeaders,\n useLink,\n validateRefusalSyn,\n} from \"../client/connection\";\nimport type {\n AttemptOutcome,\n Conn,\n StatewireCmd,\n StatewireEnvelope,\n StatewireSyn,\n} from \"../client/connection\";\nimport type { Statewire } from \"../types\";\nimport type { StatewireClient } from \"../statewire\";\nimport type { StatewireHost } from \"../host\";\nimport { useStatewireRemote } from \"./remote\";\nimport { negotiationHeaders, parseNegotiation } from \"../wire/protocol\";\n\n/** A client frame's lanes: stamped statements, strictly consecutive per lane. */\nexport type StatewireFrame = {\n ctx?: { ops: unknown[]; seq: number }[];\n cmd?: { method: string; params: unknown[]; meta?: unknown; seq: number }[];\n};\n\nexport type StatewireRemoteProps = {\n beforeAttach?: () => void | Promise<void>;\n clientId: string;\n /** Lanes to ride the attach frame (no-lease supersession only); records the included seqs. */\n takeAttachLanes: () => StatewireFrame | null;\n onHello: (args: { ops: unknown[]; syn: StatewireSyn }) => void;\n onOps: (args: { ops: unknown[]; cmds: StatewireCmd[] }) => void;\n onCmds: (cmds: StatewireCmd[]) => void;\n onRefusal: (syn: StatewireSyn) => void;\n onIdle: (level: boolean) => void;\n onDetach: () => void;\n onFinish: (args: StatewireHost.Fin) => void;\n};\n\nexport type StatewireSubmitResult =\n | { type: \"ok\" }\n | { type: \"refusal\"; syn: StatewireSyn }\n | { type: \"stale-lease\" }\n | { type: \"no-lease\" };\n\nexport type ConnectionState = StatewireClient.ConnectionState;\n\n/** The live wire a remote session drives: connection status plus frame submission. */\nexport type RemoteLink = {\n storageFailed: (error: Error) => void;\n connection: StatewireClient.Connection;\n /** A deferred first attach still waiting; `reconnect()` releases it. */\n parked: boolean;\n send: (frame: StatewireFrame) => Promise<StatewireSubmitResult>;\n};\n\nconst LEASE_HEADER = \"Statewire-Lease\";\n\nconst baseUrl = (options: StatewireClient.HttpOptions) =>\n options.url.replace(/\\/$/, \"\");\n\nconst withExtra = (headers: Headers, extra: Record<string, string>) => {\n const merged = new Headers(headers);\n for (const [name, value] of Object.entries(extra)) merged.set(name, value);\n return merged;\n};\n\nconst timedFetch = async (\n options: StatewireClient.HttpOptions,\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<AttemptOutcome> => {\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.HttpOptions,\n abort: AbortController,\n): Promise<AttemptOutcome> => {\n const persistence = c.ctx.beforeAttach?.();\n if (persistence) await persistence;\n if (abort.signal.aborted) return \"ended\";\n c.lease = null;\n const headers = await resolveHeaders(options.headers, c.authStale);\n for (const [name, value] of Object.entries(negotiationHeaders(options))) {\n if (!headers.has(name)) headers.set(name, value);\n }\n c.negotiation = parseNegotiation(headers);\n c.authStale = false;\n const lanes = flushTapSync(() => c.ctx.takeAttachLanes());\n const body = lanes === null ? undefined : JSON.stringify(lanes);\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 method: body === undefined ? \"GET\" : \"POST\",\n headers: withExtra(headers, {\n \"content-type\": \"application/json\",\n \"Statewire-Client-Id\": c.ctx.clientId,\n accept: \"text/event-stream\",\n }),\n ...(body === undefined ? {} : { body }),\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.ok) {\n const body = await res\n .clone()\n .json()\n .catch(() => null);\n return (\n flushTapSync(() =>\n body?.fin === undefined\n ? settleFin(c, httpFin(res))\n : handleEnvelope(c, body),\n ) ?? \"stop\"\n );\n }\n if (!res.body)\n throw new Error(\"statewire: protocol error: stream response has no body\");\n c.lease = res.headers.get(LEASE_HEADER);\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 postFrame = async (\n c: Conn,\n options: StatewireClient.HttpOptions,\n lease: string,\n frame: StatewireFrame,\n timeout: AbortSignal,\n): Promise<Response> =>\n timedFetch(\n options,\n `${baseUrl(options)}/frames`,\n {\n method: \"POST\",\n headers: withExtra(new Headers(), {\n \"content-type\": \"application/json\",\n [LEASE_HEADER]: lease,\n }),\n body: JSON.stringify(frame),\n signal: AbortSignal.any([\n c.lifetime?.signal ?? AbortSignal.abort(),\n timeout,\n ]),\n },\n timeout,\n );\n\n// Delta-seconds only; anything else reads as absent.\nconst retryAfterMs = (res: Response): number | null => {\n const header = res.headers.get(\"retry-after\")?.trim();\n if (header === undefined || !/^\\d+$/.test(header)) return null;\n const retry = Number(header) * 1000;\n return Number.isSafeInteger(retry) ? retry : null;\n};\n\nconst httpFin = (res: Response): StatewireHost.Fin => ({\n reason:\n res.status === 401\n ? \"unauthorized\"\n : res.status === 403\n ? \"forbidden\"\n : res.status === 429 || res.status === 503\n ? \"reconnect\"\n : res.status >= 400 && res.status < 500\n ? \"protocol-error\"\n : \"error\",\n status: res.status,\n message: `HTTP ${res.status}`,\n ...(res.status === 429 || res.status === 503 ? { code: \"overloaded\" } : {}),\n ...(retryAfterMs(res) !== null ? { retry: retryAfterMs(res)! } : {}),\n});\n\nconst refusalSyn = async (res: Response): Promise<StatewireSyn> => {\n const body = (await res.json().catch(() => null)) as {\n syn?: StatewireSyn;\n } | null;\n const syn = body?.syn;\n if (typeof syn !== \"object\" || syn === null) {\n throw new Error(`HTTP ${res.status}`);\n }\n return validateRefusalSyn(syn);\n};\n\nconst sendFrame = async (\n c: Conn,\n options: StatewireClient.HttpOptions,\n frame: StatewireFrame,\n): Promise<StatewireSubmitResult> => {\n const lease = c.lease;\n if (lease === null) return { type: \"no-lease\" };\n const timeout = AbortSignal.timeout(timing.heartbeatTimeoutMs);\n const res = await postFrame(c, options, lease, frame, timeout);\n if (res.ok) return { type: \"ok\" };\n if (res.status === 429 || res.status === 409) {\n // The syn body retry wins; Retry-After covers 429s minted below the host.\n const headerRetry = res.status === 429 ? retryAfterMs(res) : null;\n const syn: StatewireSyn = await refusalSyn(res).catch((error: unknown) => {\n if (headerRetry === null) throw error;\n return { retry: headerRetry };\n });\n if (syn.retry === undefined && headerRetry !== null)\n syn.retry = headerRetry;\n return { type: \"refusal\", syn };\n }\n if (res.status === 423) return { type: \"stale-lease\" };\n flushTapSync(() => settleFin(c, httpFin(res)));\n c.abort?.abort();\n throw new Error(`HTTP ${res.status}`);\n};\n\nconst useSseLink = (\n options: StatewireClient.HttpOptions,\n ctx: StatewireRemoteProps,\n): RemoteLink =>\n useLink({\n ...options,\n ctx,\n attemptOnce: (c, abort) => attemptStream(c, options, abort),\n send: async (c, frame) => {\n try {\n return await sendFrame(c, options, frame);\n } catch (error) {\n if (\n error instanceof SyntaxError ||\n (error instanceof Error &&\n error.message.startsWith(\"statewire: protocol error:\"))\n ) {\n flushTapSync(() =>\n settleFin(c, { reason: \"protocol-error\", message: error.message }),\n );\n c.abort?.abort();\n }\n throw error;\n }\n },\n });\n\nconst useStatewireHttp = (\n options: StatewireClient.HttpOptions,\n): Statewire<any, any> => useStatewireRemote(options, useSseLink);\n\nexport const StatewireHttp = resource(useStatewireHttp) as <\n TState = any,\n TCommands extends Statewire.Commands = Statewire.Commands,\n>(\n options: StatewireClient.HttpOptions,\n) => ResourceElement<Statewire<TState | undefined, TCommands>>;\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "statewire",
3
- "version": "0.17.1",
3
+ "version": "0.18.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": {
@@ -28,6 +28,10 @@
28
28
  "./testing": {
29
29
  "types": "./dist/transports/testing.d.ts",
30
30
  "default": "./dist/transports/testing.js"
31
+ },
32
+ "./storage": {
33
+ "types": "./dist/storage.d.ts",
34
+ "default": "./dist/storage.js"
31
35
  }
32
36
  },
33
37
  "main": "./dist/index.js",
@@ -8,6 +8,7 @@ import { useEffect, useRef, useState } from "react";
8
8
  import { flushTapSync } from "@assistant-ui/tap";
9
9
  import { reviveOps, reviveValue } from "../wire/meta";
10
10
  import { backoff, timing } from "./timing";
11
+ import { isStorageError } from "./storageError";
11
12
  import type {
12
13
  ConnectionState,
13
14
  RemoteLink,
@@ -127,26 +128,28 @@ export type Conn = {
127
128
  reconnect: () => void;
128
129
  };
129
130
 
130
- export const degradedOf = (state: ConnectionState): boolean =>
131
- (state.status === "connecting" && state.attempt > 0) ||
132
- (state.status === "disconnected" &&
133
- state.reason !== "idle" &&
134
- state.reason !== "deferred");
135
-
136
131
  const sameState = (a: ConnectionState, b: ConnectionState): boolean => {
137
132
  if (a.status !== b.status) return false;
138
- if (a.status === "connecting" && b.status === "connecting") {
133
+ if (a.status === "retrying" && b.status === "retrying") {
139
134
  return (
140
135
  a.attempt === b.attempt &&
141
136
  a.cause === b.cause &&
142
137
  a.code === b.code &&
143
138
  a.nextRetryAt === b.nextRetryAt &&
144
- a.lastError === b.lastError
139
+ a.lastError === b.lastError &&
140
+ a.message === b.message
145
141
  );
146
142
  }
147
- if (a.status === "disconnected" && b.status === "disconnected") {
143
+ if (a.status === "standby" && b.status === "standby") {
148
144
  return a.reason === b.reason;
149
145
  }
146
+ if (a.status === "stopped" && b.status === "stopped") {
147
+ return (
148
+ a.reason === b.reason &&
149
+ a.message === b.message &&
150
+ a.payload === b.payload
151
+ );
152
+ }
150
153
  return true;
151
154
  };
152
155
 
@@ -156,12 +159,25 @@ const setStatus = (c: Conn, next: ConnectionState) => {
156
159
  ...next,
157
160
  ...(c.error === undefined ? {} : { error: c.error }),
158
161
  reconnect: c.reconnect,
159
- degraded: degradedOf(next),
160
162
  };
161
163
  c.notify(c.connection);
162
164
  c.onChange(c.connection);
163
165
  };
164
166
 
167
+ const storageFailed = (c: Conn, error: Error) => {
168
+ c.error = error;
169
+ c.onError(error);
170
+ flushTapSync(() =>
171
+ setStatus(c, {
172
+ status: "stopped",
173
+ reason: "storage-error",
174
+ degraded: true,
175
+ message: error.message,
176
+ }),
177
+ );
178
+ c.abort?.abort();
179
+ };
180
+
165
181
  const protocolError = (message: string): never => {
166
182
  throw new Error(`statewire: protocol error: ${message}`);
167
183
  };
@@ -318,7 +334,7 @@ const handleHello = (c: Conn, env: StatewireEnvelope) => {
318
334
  c.liveAt = Date.now();
319
335
  c.authDenials = 0;
320
336
  c.episode = null;
321
- setStatus(c, { status: "connected" });
337
+ setStatus(c, { status: "connected", degraded: false });
322
338
  };
323
339
 
324
340
  let warnedUnknownSynFact = false;
@@ -384,7 +400,7 @@ export const settleFin = (c: Conn, fin: StatewireHost.Fin): AttemptOutcome => {
384
400
  c.attempt = 0;
385
401
  c.episode = null;
386
402
  c.error = undefined;
387
- setStatus(c, { status: "disconnected", reason: "idle" });
403
+ setStatus(c, { status: "standby", reason: "idle", degraded: false });
388
404
  return "idle";
389
405
  }
390
406
  const denial = finish.reason === "unauthorized";
@@ -421,7 +437,8 @@ export const settleFin = (c: Conn, fin: StatewireHost.Fin): AttemptOutcome => {
421
437
  }
422
438
  c.ctx.onFinish(finish);
423
439
  setStatus(c, {
424
- status: "disconnected",
440
+ status: "stopped",
441
+ degraded: true,
425
442
  reason: denial
426
443
  ? "unauthorized"
427
444
  : finish.reason === "forbidden"
@@ -450,13 +467,18 @@ export const handleEnvelope = (
450
467
  if (c.connection.status !== "connected") {
451
468
  c.attempt = 0;
452
469
  c.episode = null;
453
- setStatus(c, { status: "connected" });
470
+ setStatus(c, { status: "connected", degraded: false });
454
471
  }
455
472
  return null;
456
473
  };
457
474
 
458
475
  const dialing = (c: Conn) =>
459
- setStatus(c, { status: "connecting", attempt: c.attempt });
476
+ setStatus(
477
+ c,
478
+ c.attempt === 0
479
+ ? { status: "connecting", degraded: false }
480
+ : { status: "retrying", attempt: c.attempt, degraded: true },
481
+ );
460
482
 
461
483
  const scheduleRetry = (
462
484
  c: Conn,
@@ -474,7 +496,8 @@ const scheduleRetry = (
474
496
  c.retryAt - Date.now(),
475
497
  );
476
498
  setStatus(c, {
477
- status: "connecting",
499
+ status: "retrying",
500
+ degraded: true,
478
501
  attempt: c.attempt,
479
502
  ...c.episode,
480
503
  nextRetryAt: Date.now() + delay,
@@ -485,7 +508,7 @@ const scheduleRetry = (
485
508
 
486
509
  const reconnectConn = (c: Conn) => {
487
510
  if (!c.lifetime) return;
488
- if (c.connection.status === "disconnected") {
511
+ if (c.connection.status === "standby" || c.connection.status === "stopped") {
489
512
  // A reconnect out of a park starts a fresh episode: first dial, attempt 0.
490
513
  c.attempt = 0;
491
514
  c.authDenials = 0;
@@ -517,10 +540,16 @@ const run = async (c: Conn, lifetime: AbortController, gate: Gate) => {
517
540
  let outcome: AttemptOutcome | undefined;
518
541
  let failure: string | undefined;
519
542
  try {
543
+ const pending = c.ctx.beforeAttach?.();
544
+ if (pending) await pending;
545
+ if (!alive()) return;
520
546
  outcome = await c.attemptOnce(abort);
521
547
  } catch (error) {
522
548
  if (!abort.signal.aborted) {
523
- if (
549
+ if (isStorageError(error)) {
550
+ storageFailed(c, error);
551
+ outcome = "stop";
552
+ } else if (
524
553
  error instanceof SyntaxError ||
525
554
  (error instanceof Error &&
526
555
  /^(statewire: protocol error:|Statewire cannot apply)/.test(
@@ -552,11 +581,7 @@ const run = async (c: Conn, lifetime: AbortController, gate: Gate) => {
552
581
  c.ctx.onDetach();
553
582
  });
554
583
  if (!alive()) return;
555
- if (
556
- c.connection.status === "disconnected" &&
557
- !["deferred", "idle"].includes(c.connection.reason)
558
- )
559
- outcome = "stop";
584
+ if (c.connection.status === "stopped") outcome = "stop";
560
585
  if (outcome === "stop" || outcome === "idle") {
561
586
  await waitGate(gate);
562
587
  continue;
@@ -591,6 +616,8 @@ export const attachFrame = async (
591
616
  c: Conn,
592
617
  headers: StatewireClient.HeadersOption | undefined,
593
618
  ) => {
619
+ const pending = c.ctx.beforeAttach?.();
620
+ if (pending) await pending;
594
621
  const resolved = await resolveHeaders(headers, c.authStale);
595
622
  for (const [name, value] of Object.entries(
596
623
  negotiationHeaders(c.negotiation ?? {}),
@@ -599,6 +626,8 @@ export const attachFrame = async (
599
626
  }
600
627
  c.negotiation = parseNegotiation(resolved);
601
628
  c.authStale = false;
629
+ const persistence = c.ctx.beforeAttach?.();
630
+ if (persistence) await persistence;
602
631
  const lanes = flushTapSync(() => c.ctx.takeAttachLanes());
603
632
  return {
604
633
  headers: resolved,
@@ -652,9 +681,8 @@ export const useLink = (
652
681
  reconnect: () => reconnectConn(conn),
653
682
  connection: {
654
683
  ...((props.isNew ?? false)
655
- ? { status: "disconnected", reason: "deferred" }
656
- : { status: "connecting", attempt: 0 }),
657
- degraded: false,
684
+ ? { status: "standby", reason: "deferred", degraded: false }
685
+ : { status: "connecting", degraded: false }),
658
686
  reconnect: () => conn.reconnect(),
659
687
  },
660
688
  };
@@ -669,6 +697,7 @@ export const useLink = (
669
697
  return c.parked;
670
698
  },
671
699
  send: (frame) => latest.current.send(c, frame),
700
+ storageFailed: (error) => storageFailed(c, error),
672
701
  }),
673
702
  );
674
703
 
@@ -688,8 +717,8 @@ export const useLink = (
688
717
  setStatus(
689
718
  c,
690
719
  c.parked
691
- ? { status: "disconnected", reason: "deferred" }
692
- : { status: "connecting", attempt: 0 },
720
+ ? { status: "standby", reason: "deferred", degraded: false }
721
+ : { status: "connecting", degraded: false },
693
722
  );
694
723
  const lifetime = new AbortController();
695
724
  c.lifetime = lifetime;