statewire 0.12.2 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/connection.d.ts +2 -0
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +6 -0
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts +2 -0
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +21 -3
- package/dist/client/core.js.map +1 -1
- package/dist/client/session.d.ts +4 -1
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +66 -4
- package/dist/client/session.js.map +1 -1
- package/dist/statewire.d.ts +26 -0
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +31 -11
- package/dist/statewire.js.map +1 -1
- package/dist/transports/remote.d.ts.map +1 -1
- package/dist/transports/remote.js +7 -2
- package/dist/transports/remote.js.map +1 -1
- package/dist/transports/sse.d.ts +2 -0
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +4 -0
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/ws.d.ts.map +1 -1
- package/dist/transports/ws.js +4 -0
- package/dist/transports/ws.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +8 -0
- package/src/client/core.ts +31 -3
- package/src/client/session.ts +109 -2
- package/src/statewire.ts +59 -5
- package/src/transports/remote.ts +8 -0
- package/src/transports/sse.ts +6 -0
- package/src/transports/ws.ts +4 -0
- package/src/types.ts +6 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sse.js","names":["useRef","useState","flushTapSync","resource","ResourceElement","createSseParser","timing","classify","handleEnvelope","useStatewireConn","validateRefusalSyn","AttemptOutcome","Conn","StatewireCmd","StatewireEnvelope","StatewireSyn","Statewire","StatewireClient","StatewireHost","useStatewireRemote","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","send","frame","Promise","LEASE_HEADER","baseUrl","options","SSEOptions","url","replace","AuthState","stale","resolveHeaders","staleAuth","Headers","headers","withExtra","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","auth","lease","userHeaders","lanes","fromEntries","headerTimer","res","accept","body","stringify","cache","any","status","episode","cause","Error","ok","get","getReader","aborted","cancel","catch","postFrame","lifetime","retryAfterMs","header","trim","undefined","test","retry","Number","isSafeInteger","refusalSyn","json","sendFrame","headerRetry","error","reconnect","useSseLink","latest","current","onChange","attemptOnce","link","useStatewireSSE","StatewireSSE","Commands","TState","TCommands"],"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 { createSseParser } from \"./sse-parser\";\nimport { timing } from \"../client/timing\";\nimport {\n classify,\n handleEnvelope,\n useStatewireConn,\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\";\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 send: (frame: StatewireFrame) => Promise<StatewireSubmitResult>;\n};\n\nconst LEASE_HEADER = \"Statewire-Lease\";\n\nconst baseUrl = (options: StatewireClient.SSEOptions) =>\n options.url.replace(/\\/$/, \"\");\n\nexport type AuthState = { stale: boolean };\n\nconst resolveHeaders = async (\n options: StatewireClient.SSEOptions,\n ctx: { staleAuth: boolean },\n): Promise<Headers> => {\n const { headers } = options;\n return new Headers(\n typeof headers === \"function\" ? await headers(ctx) : headers,\n );\n};\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.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<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.SSEOptions,\n auth: AuthState,\n abort: AbortController,\n): Promise<AttemptOutcome> => {\n const stale = auth.stale;\n auth.stale = false;\n c.lease = null;\n const userHeaders = await resolveHeaders(options, { staleAuth: stale });\n const lanes = flushTapSync(() => c.ctx.takeAttachLanes());\n const frame = {\n headers: Object.fromEntries(userHeaders),\n ...(lanes ?? {}),\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 method: \"POST\",\n headers: withExtra(userHeaders, {\n \"content-type\": \"application/json\",\n \"Statewire-Client-Id\": c.ctx.clientId,\n accept: \"text/event-stream\",\n }),\n body: JSON.stringify(frame),\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 === 401) {\n 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 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.SSEOptions,\n lease: string,\n frame: StatewireFrame,\n timeout: AbortSignal,\n stale: boolean,\n): Promise<Response> =>\n timedFetch(\n options,\n `${baseUrl(options)}/frames`,\n {\n method: \"POST\",\n headers: withExtra(await resolveHeaders(options, { staleAuth: stale }), {\n \"content-type\": \"application/json\",\n \"Statewire-Client-Id\": c.ctx.clientId,\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 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.SSEOptions,\n auth: AuthState,\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 let res = await postFrame(c, options, lease, frame, timeout, false);\n if (res.status === 401 && typeof options.headers === \"function\") {\n res = await postFrame(c, options, lease, frame, timeout, true);\n }\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 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 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: (frame) => sendFrame(c, latest.current, auth, frame),\n }),\n );\n return link;\n};\n\nconst useStatewireSSE = (\n options: StatewireClient.SSEOptions,\n): Statewire<any, any> => useStatewireRemote(options, useSseLink);\n\nexport const StatewireSSE = resource(useStatewireSSE) as <\n TState = any,\n TCommands extends Statewire.Commands = Statewire.Commands,\n>(\n options: StatewireClient.SSEOptions,\n) => ResourceElement<Statewire<TState | undefined, TCommands>>;\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"sse.js","names":["useRef","useState","flushTapSync","resource","ResourceElement","createSseParser","timing","classify","handleEnvelope","useStatewireConn","validateRefusalSyn","AttemptOutcome","Conn","StatewireCmd","StatewireEnvelope","StatewireSyn","Statewire","StatewireClient","StatewireHost","useStatewireRemote","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","SSEOptions","url","replace","AuthState","stale","resolveHeaders","staleAuth","Headers","headers","withExtra","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","auth","lease","userHeaders","lanes","fromEntries","headerTimer","res","accept","body","stringify","cache","any","status","episode","cause","Error","ok","get","getReader","aborted","cancel","catch","postFrame","lifetime","retryAfterMs","header","trim","undefined","test","retry","Number","isSafeInteger","refusalSyn","json","sendFrame","headerRetry","error","reconnect","useSseLink","latest","current","isNew","onChange","attemptOnce","link","useStatewireSSE","StatewireSSE","Commands","TState","TCommands"],"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 { createSseParser } from \"./sse-parser\";\nimport { timing } from \"../client/timing\";\nimport {\n classify,\n handleEnvelope,\n useStatewireConn,\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\";\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.SSEOptions) =>\n options.url.replace(/\\/$/, \"\");\n\nexport type AuthState = { stale: boolean };\n\nconst resolveHeaders = async (\n options: StatewireClient.SSEOptions,\n ctx: { staleAuth: boolean },\n): Promise<Headers> => {\n const { headers } = options;\n return new Headers(\n typeof headers === \"function\" ? await headers(ctx) : headers,\n );\n};\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.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<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.SSEOptions,\n auth: AuthState,\n abort: AbortController,\n): Promise<AttemptOutcome> => {\n const stale = auth.stale;\n auth.stale = false;\n c.lease = null;\n const userHeaders = await resolveHeaders(options, { staleAuth: stale });\n const lanes = flushTapSync(() => c.ctx.takeAttachLanes());\n const frame = {\n headers: Object.fromEntries(userHeaders),\n ...(lanes ?? {}),\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 method: \"POST\",\n headers: withExtra(userHeaders, {\n \"content-type\": \"application/json\",\n \"Statewire-Client-Id\": c.ctx.clientId,\n accept: \"text/event-stream\",\n }),\n body: JSON.stringify(frame),\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 === 401) {\n 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 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.SSEOptions,\n lease: string,\n frame: StatewireFrame,\n timeout: AbortSignal,\n stale: boolean,\n): Promise<Response> =>\n timedFetch(\n options,\n `${baseUrl(options)}/frames`,\n {\n method: \"POST\",\n headers: withExtra(await resolveHeaders(options, { staleAuth: stale }), {\n \"content-type\": \"application/json\",\n \"Statewire-Client-Id\": c.ctx.clientId,\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 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.SSEOptions,\n auth: AuthState,\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 let res = await postFrame(c, options, lease, frame, timeout, false);\n if (res.status === 401 && typeof options.headers === \"function\") {\n res = await postFrame(c, options, lease, frame, timeout, true);\n }\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 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 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 isNew: options.isNew,\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 get parked() {\n return c.parked;\n },\n send: (frame) => sendFrame(c, latest.current, auth, frame),\n }),\n );\n return link;\n};\n\nconst useStatewireSSE = (\n options: StatewireClient.SSEOptions,\n): Statewire<any, any> => useStatewireRemote(options, useSseLink);\n\nexport const StatewireSSE = resource(useStatewireSSE) as <\n TState = any,\n TCommands extends Statewire.Commands = Statewire.Commands,\n>(\n options: StatewireClient.SSEOptions,\n) => ResourceElement<Statewire<TState | undefined, TCommands>>;\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ws.d.ts","names":[],"sources":["../../src/transports/ws.ts"],"mappings":";;;;cA4Ba;
|
|
1
|
+
{"version":3,"file":"ws.d.ts","names":[],"sources":["../../src/transports/ws.ts"],"mappings":";;;;cA4Ba;cA+KA,cACX,cACA,kBAAkB,UAAU,WAAW,UAAU,UAEjD,SAAS,gBAAgB,cACtB,gBAAgB,UAAU,oBAAoB"}
|
package/dist/transports/ws.js
CHANGED
|
@@ -108,6 +108,7 @@ const useWsLink = (options, ctx) => {
|
|
|
108
108
|
}));
|
|
109
109
|
const c_0 = useStatewireConn({
|
|
110
110
|
url: options.url,
|
|
111
|
+
isNew: options.isNew,
|
|
111
112
|
onChange: options.onChange,
|
|
112
113
|
ctx,
|
|
113
114
|
attemptOnce: (c, abort) => attemptSocket(c, w, options, abort)
|
|
@@ -116,6 +117,9 @@ const useWsLink = (options, ctx) => {
|
|
|
116
117
|
get connection() {
|
|
117
118
|
return c_0.connection;
|
|
118
119
|
},
|
|
120
|
+
get parked() {
|
|
121
|
+
return c_0.parked;
|
|
122
|
+
},
|
|
119
123
|
send: (frame) => sendFrame(w, frame)
|
|
120
124
|
}));
|
|
121
125
|
return link;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ws.js","names":["useState","flushTapSync","resource","ResourceElement","timing","handleEnvelope","useStatewireConn","AttemptOutcome","Conn","StatewireEnvelope","Statewire","StatewireClient","useStatewireRemote","CLIENT_QUERY_PARAM","RemoteLink","StatewireFrame","StatewireSubmitResult","StatewireRemoteProps","WsChannel","socket","WebSocket","authStale","OPEN","WS_SUBPROTOCOL","socketUrl","options","WSOptions","clientId","url","query","params","URLSearchParams","set","base","replace","toString","firstFrame","stale","lanes","Promise","headers","resolved","Headers","staleAuth","JSON","stringify","Object","fromEntries","runSocket","c","w","ws","frame","abort","AbortController","resolve","reject","timer","ReturnType","setTimeout","timedOut","outcome","closed","arm","clearTimeout","close","heartbeatTimeoutMs","signal","addEventListener","onerror","onopen","send","Boolean","aborted","fail","error","Error","onmessage","event","env","parse","String","data","keys","length","done","fin","reason","onclose","attemptSocket","ctx","takeAttachLanes","throwIfAborted","protocols","webSocket","WS","globalThis","extra","undefined","sendFrame","readyState","type","useWsLink","onChange","attemptOnce","link","connection","useStatewireWS","StatewireWS","Commands","TState","TCommands"],"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 { handleEnvelope, useStatewireConn } from \"../client/connection\";\nimport type {\n AttemptOutcome,\n Conn,\n StatewireEnvelope,\n} 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 type {\n RemoteLink,\n StatewireFrame,\n StatewireSubmitResult,\n StatewireRemoteProps,\n} from \"./sse\";\n\ntype WsChannel = {\n socket: WebSocket | null;\n authStale: boolean;\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 firstFrame = async (\n options: StatewireClient.WSOptions,\n stale: boolean,\n lanes: StatewireFrame | null,\n): Promise<string> => {\n const { headers } = options;\n const resolved = new Headers(\n typeof headers === \"function\"\n ? await headers({ staleAuth: stale })\n : headers,\n );\n return JSON.stringify({\n headers: Object.fromEntries(resolved),\n ...(lanes ?? {}),\n });\n};\n\nconst runSocket = (\n c: Conn,\n w: WsChannel,\n ws: WebSocket,\n frame: string,\n abort: AbortController,\n): Promise<AttemptOutcome> =>\n new Promise((resolve, reject) => {\n let timer: ReturnType<typeof setTimeout> | undefined;\n let timedOut = false;\n let outcome: AttemptOutcome | 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 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 ws.onmessage = (event) => {\n if (stale()) return;\n arm();\n let env: StatewireEnvelope;\n try {\n env = JSON.parse(String(event.data)) as StatewireEnvelope;\n } catch {\n fail(new Error(\"protocol\"));\n return;\n }\n // {} heartbeats live below the packet layer: invisible to handlers.\n if (\n typeof env === \"object\" &&\n env !== null &&\n Object.keys(env).length === 0\n ) {\n return;\n }\n try {\n const done = flushTapSync(() => handleEnvelope(c, env));\n if (done) {\n if (env.fin?.reason === \"unauthorized\") w.authStale = true;\n outcome = done;\n ws.close();\n }\n } catch (error) {\n fail(error);\n }\n };\n ws.onclose = () => {\n closed = true;\n clearTimeout(timer);\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<AttemptOutcome> => {\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 lanes = flushTapSync(() => c.ctx.takeAttachLanes());\n const frame = await firstFrame(options, stale, lanes);\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, abort);\n } finally {\n if (w.socket === ws) w.socket = null;\n }\n};\n\nconst sendFrame = (\n w: WsChannel,\n frame: StatewireFrame,\n): Promise<StatewireSubmitResult> => {\n const ws = w.socket;\n if (!ws || ws.readyState !== OPEN) {\n return Promise.reject(new Error(\"network\"));\n }\n try {\n ws.send(JSON.stringify(frame));\n } catch {\n return Promise.reject(new Error(\"network\"));\n }\n return Promise.resolve({ type: \"ok\" });\n};\n\nconst useWsLink = (\n options: StatewireClient.WSOptions,\n ctx: StatewireRemoteProps,\n): RemoteLink => {\n const [w] = useState((): WsChannel => ({ socket: null, authStale: false }));\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: (frame) => sendFrame(w, frame),\n }),\n );\n return link;\n};\n\nconst useStatewireWS = (\n options: StatewireClient.WSOptions,\n): Statewire<any, any> => useStatewireRemote(options, useWsLink);\n\nexport const StatewireWS = resource(useStatewireWS) as <\n TState = any,\n TCommands extends Statewire.Commands = Statewire.Commands,\n>(\n options: StatewireClient.WSOptions,\n) => ResourceElement<Statewire<TState | undefined, TCommands>>;\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ws.js","names":["useState","flushTapSync","resource","ResourceElement","timing","handleEnvelope","useStatewireConn","AttemptOutcome","Conn","StatewireEnvelope","Statewire","StatewireClient","useStatewireRemote","CLIENT_QUERY_PARAM","RemoteLink","StatewireFrame","StatewireSubmitResult","StatewireRemoteProps","WsChannel","socket","WebSocket","authStale","OPEN","WS_SUBPROTOCOL","socketUrl","options","WSOptions","clientId","url","query","params","URLSearchParams","set","base","replace","toString","firstFrame","stale","lanes","Promise","headers","resolved","Headers","staleAuth","JSON","stringify","Object","fromEntries","runSocket","c","w","ws","frame","abort","AbortController","resolve","reject","timer","ReturnType","setTimeout","timedOut","outcome","closed","arm","clearTimeout","close","heartbeatTimeoutMs","signal","addEventListener","onerror","onopen","send","Boolean","aborted","fail","error","Error","onmessage","event","env","parse","String","data","keys","length","done","fin","reason","onclose","attemptSocket","ctx","takeAttachLanes","throwIfAborted","protocols","webSocket","WS","globalThis","extra","undefined","sendFrame","readyState","type","useWsLink","isNew","onChange","attemptOnce","link","connection","parked","useStatewireWS","StatewireWS","Commands","TState","TCommands"],"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 { handleEnvelope, useStatewireConn } from \"../client/connection\";\nimport type {\n AttemptOutcome,\n Conn,\n StatewireEnvelope,\n} 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 type {\n RemoteLink,\n StatewireFrame,\n StatewireSubmitResult,\n StatewireRemoteProps,\n} from \"./sse\";\n\ntype WsChannel = {\n socket: WebSocket | null;\n authStale: boolean;\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 firstFrame = async (\n options: StatewireClient.WSOptions,\n stale: boolean,\n lanes: StatewireFrame | null,\n): Promise<string> => {\n const { headers } = options;\n const resolved = new Headers(\n typeof headers === \"function\"\n ? await headers({ staleAuth: stale })\n : headers,\n );\n return JSON.stringify({\n headers: Object.fromEntries(resolved),\n ...(lanes ?? {}),\n });\n};\n\nconst runSocket = (\n c: Conn,\n w: WsChannel,\n ws: WebSocket,\n frame: string,\n abort: AbortController,\n): Promise<AttemptOutcome> =>\n new Promise((resolve, reject) => {\n let timer: ReturnType<typeof setTimeout> | undefined;\n let timedOut = false;\n let outcome: AttemptOutcome | 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 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 ws.onmessage = (event) => {\n if (stale()) return;\n arm();\n let env: StatewireEnvelope;\n try {\n env = JSON.parse(String(event.data)) as StatewireEnvelope;\n } catch {\n fail(new Error(\"protocol\"));\n return;\n }\n // {} heartbeats live below the packet layer: invisible to handlers.\n if (\n typeof env === \"object\" &&\n env !== null &&\n Object.keys(env).length === 0\n ) {\n return;\n }\n try {\n const done = flushTapSync(() => handleEnvelope(c, env));\n if (done) {\n if (env.fin?.reason === \"unauthorized\") w.authStale = true;\n outcome = done;\n ws.close();\n }\n } catch (error) {\n fail(error);\n }\n };\n ws.onclose = () => {\n closed = true;\n clearTimeout(timer);\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<AttemptOutcome> => {\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 lanes = flushTapSync(() => c.ctx.takeAttachLanes());\n const frame = await firstFrame(options, stale, lanes);\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, abort);\n } finally {\n if (w.socket === ws) w.socket = null;\n }\n};\n\nconst sendFrame = (\n w: WsChannel,\n frame: StatewireFrame,\n): Promise<StatewireSubmitResult> => {\n const ws = w.socket;\n if (!ws || ws.readyState !== OPEN) {\n return Promise.reject(new Error(\"network\"));\n }\n try {\n ws.send(JSON.stringify(frame));\n } catch {\n return Promise.reject(new Error(\"network\"));\n }\n return Promise.resolve({ type: \"ok\" });\n};\n\nconst useWsLink = (\n options: StatewireClient.WSOptions,\n ctx: StatewireRemoteProps,\n): RemoteLink => {\n const [w] = useState((): WsChannel => ({ socket: null, authStale: false }));\n const c = useStatewireConn({\n url: options.url,\n isNew: options.isNew,\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 get parked() {\n return c.parked;\n },\n send: (frame) => sendFrame(w, frame),\n }),\n );\n return link;\n};\n\nconst useStatewireWS = (\n options: StatewireClient.WSOptions,\n): Statewire<any, any> => useStatewireRemote(options, useWsLink);\n\nexport const StatewireWS = resource(useStatewireWS) as <\n TState = any,\n TCommands extends Statewire.Commands = Statewire.Commands,\n>(\n options: StatewireClient.WSOptions,\n) => ResourceElement<Statewire<TState | undefined, TCommands>>;\n"],"mappings":"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"}
|
package/dist/types.d.ts
CHANGED
|
@@ -52,6 +52,12 @@ interface Statewire<TState = any, TCommands extends Statewire.Commands = Statewi
|
|
|
52
52
|
isBusy?: boolean;
|
|
53
53
|
/** Replace this client's ambient context on the server; it rides the ctx lane (no verdict). */
|
|
54
54
|
setContext?: (value: Record<string, unknown>) => void;
|
|
55
|
+
/** The retained failed-send lane; present on transports with a durable session. */
|
|
56
|
+
failures?: {
|
|
57
|
+
list: () => readonly StatewireClient.FailedCommand[];
|
|
58
|
+
retry: (entry: StatewireClient.FailedCommand) => Promise<unknown>;
|
|
59
|
+
discard: (entry: StatewireClient.FailedCommand) => void;
|
|
60
|
+
};
|
|
55
61
|
}
|
|
56
62
|
declare class StatewireSendError extends Error {
|
|
57
63
|
readonly failure: StatewireClient.SendFailure;
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","names":[],"sources":["../src/types.ts"],"mappings":";;;kBAGiB;OACH,WAAW,mBAAmB;OAE9B,QAAQ,kBAAkB,WAAW,eAC9C,WAAW;IAAc,QAAQ;IAAG,QAAQ,WAAW,UAAU;aAC5D;OAEI,cAAc,kBAAkB,eACzC,WAAW,gBACP,MAAM,WAAW,UAAU,QAC3B,QAAQ,QAAQ,WAAW,UAAU;OAGhC,cAAc,kBAAkB,WAAW,eACpD,WAAW;IACV,QAAQ;IACR,QAAQ,WAAW,UAAU;IAC7B;IACA;IACA;IACA,UAAU,gBAAgB;aAEtB;;OAGI;IACV;;IAEA,mBAAmB;;IAEnB,cAAc;;OAGJ;IACN;IAAe,MAAM;IAAe;;IACpC;IAAW,MAAM;IAAe;;IAChC;IAAc,MAAM;;OAEd;IACV,YAAY;IACZ,eAAe;;;UAIF,UACf,cACA,kBAAkB,UAAU,WAAW,UAAU;EAEjD,OAAO;EACP,UAAU,UAAU,cAAc;EAClC,aAAa,gBAAgB;EAC7B,SAAS,UAAU;;EAEnB;;EAEA;;EAEA,cAAc,OAAO;;
|
|
1
|
+
{"version":3,"file":"types.d.ts","names":[],"sources":["../src/types.ts"],"mappings":";;;kBAGiB;OACH,WAAW,mBAAmB;OAE9B,QAAQ,kBAAkB,WAAW,eAC9C,WAAW;IAAc,QAAQ;IAAG,QAAQ,WAAW,UAAU;aAC5D;OAEI,cAAc,kBAAkB,eACzC,WAAW,gBACP,MAAM,WAAW,UAAU,QAC3B,QAAQ,QAAQ,WAAW,UAAU;OAGhC,cAAc,kBAAkB,WAAW,eACpD,WAAW;IACV,QAAQ;IACR,QAAQ,WAAW,UAAU;IAC7B;IACA;IACA;IACA,UAAU,gBAAgB;aAEtB;;OAGI;IACV;;IAEA,mBAAmB;;IAEnB,cAAc;;OAGJ;IACN;IAAe,MAAM;IAAe;;IACpC;IAAW,MAAM;IAAe;;IAChC;IAAc,MAAM;;OAEd;IACV,YAAY;IACZ,eAAe;;;UAIF,UACf,cACA,kBAAkB,UAAU,WAAW,UAAU;EAEjD,OAAO;EACP,UAAU,UAAU,cAAc;EAClC,aAAa,gBAAgB;EAC7B,SAAS,UAAU;;EAEnB;;EAEA;;EAEA,cAAc,OAAO;;EAErB;IACE,qBAAqB,gBAAgB;IACrC,QAAQ,OAAO,gBAAgB,kBAAkB;IACjD,UAAU,OAAO,gBAAgB;;;cAuBxB,2BAA2B;WAC7B,SAAS,gBAAgB;EAEtB,YAAA,SAAS,gBAAgB"}
|
package/dist/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","names":["StatewireClient","StatewirePath","Statewire","Commands","Record","args","Command","TCommands","method","K","params","Parameters","CommandsProxy","Promise","Awaited","ReturnType","CommandUpdate","seq","status","result","failure","SendFailure","StateSource","snapshot","takeChanged","setOnChange","listener","Op","op","path","value","Changes","isChangedAt","getChangedKeys","state","TState","commands","connection","Connection","source","durableSeq","isBusy","setContext","describe","fate","code","message","cause","StatewireSendError","Error","constructor","name"],"sources":["../src/types.ts"],"sourcesContent":["import type { StatewireClient } from \"./statewire\";\nimport type { StatewirePath } from \"./wire/ops\";\n\nexport namespace Statewire {\n export type Commands = Record<string, (...args: never[]) => unknown>;\n\n export type Command<TCommands extends Commands = Commands> = {\n [K in keyof TCommands]: { method: K; params: Parameters<TCommands[K]> };\n }[keyof TCommands];\n\n export type CommandsProxy<TCommands extends Commands> = {\n [K in keyof TCommands]: (\n ...args: Parameters<TCommands[K]>\n ) => Promise<Awaited<ReturnType<TCommands[K]>>>;\n };\n\n export type CommandUpdate<TCommands extends Commands = Commands> = {\n [K in keyof TCommands]: {\n method: K;\n params: Parameters<TCommands[K]>;\n seq: number;\n status: \"queued\" | \"posted\" | \"applied\" | \"settled\";\n result?: unknown;\n failure?: StatewireClient.SendFailure;\n };\n }[keyof TCommands];\n\n /** Push-based canonical state feed a statewire hands to the client core. */\n export type StateSource = {\n snapshot: () => unknown;\n /** Drain the paths changed since the last take (`[]` entry = whole state). */\n takeChanged: () => StatewirePath[];\n /** Synchronous, per change; the consumer owns coalescing. */\n setOnChange: (listener: (() => void) | null) => void;\n };\n\n export type Op =\n | { op: \"replace\"; path: StatewirePath; value: unknown }\n | { op: \"add\"; path: StatewirePath; value: unknown }\n | { op: \"remove\"; path: StatewirePath };\n\n export type Changes = {\n isChangedAt(path: readonly (string | number)[]): boolean;\n getChangedKeys(path: readonly (string | number)[]): string[];\n };\n}\n\nexport interface Statewire<\n TState = any,\n TCommands extends Statewire.Commands = Statewire.Commands,\n> {\n state: TState;\n commands: Statewire.CommandsProxy<TCommands>;\n connection?: StatewireClient.Connection;\n source?: Statewire.StateSource;\n /** Highest command seq the server's dur flag states durably admitted — never lost, never run again — and released from the resend queue. */\n durableSeq?: number;\n /** Negation of the server's broadcast idle level; absent means always busy. */\n isBusy?: boolean;\n /** Replace this client's ambient context on the server; it rides the ctx lane (no verdict). */\n setContext?: (value: Record<string, unknown>) => void;\n}\n\nconst describe = (failure: StatewireClient.SendFailure): string => {\n switch (failure.fate) {\n case \"rejected\":\n return `statewire: command rejected${failure.code ? ` (${failure.code})` : \"\"}${failure.message ? `: ${failure.message}` : \"\"}`;\n case \"aborted\":\n return `statewire: command aborted (${failure.cause})`;\n case \"lost\":\n return `statewire: command lost (${failure.cause})`;\n case \"failed\":\n return failure.message\n ? `statewire: command failed: ${failure.message}`\n : \"statewire: command failed\";\n case \"crashed\":\n return \"statewire: command crashed\";\n case \"expired\":\n return `statewire: command result expired (${failure.cause})`;\n }\n};\n\nexport class StatewireSendError extends Error {\n readonly failure: StatewireClient.SendFailure;\n\n constructor(failure: StatewireClient.SendFailure) {\n super(describe(failure));\n this.name = \"StatewireSendError\";\n this.failure = failure;\n }\n}\n"],"mappings":";
|
|
1
|
+
{"version":3,"file":"types.js","names":["StatewireClient","StatewirePath","Statewire","Commands","Record","args","Command","TCommands","method","K","params","Parameters","CommandsProxy","Promise","Awaited","ReturnType","CommandUpdate","seq","status","result","failure","SendFailure","StateSource","snapshot","takeChanged","setOnChange","listener","Op","op","path","value","Changes","isChangedAt","getChangedKeys","state","TState","commands","connection","Connection","source","durableSeq","isBusy","setContext","failures","list","FailedCommand","retry","entry","discard","describe","fate","code","message","cause","StatewireSendError","Error","constructor","name"],"sources":["../src/types.ts"],"sourcesContent":["import type { StatewireClient } from \"./statewire\";\nimport type { StatewirePath } from \"./wire/ops\";\n\nexport namespace Statewire {\n export type Commands = Record<string, (...args: never[]) => unknown>;\n\n export type Command<TCommands extends Commands = Commands> = {\n [K in keyof TCommands]: { method: K; params: Parameters<TCommands[K]> };\n }[keyof TCommands];\n\n export type CommandsProxy<TCommands extends Commands> = {\n [K in keyof TCommands]: (\n ...args: Parameters<TCommands[K]>\n ) => Promise<Awaited<ReturnType<TCommands[K]>>>;\n };\n\n export type CommandUpdate<TCommands extends Commands = Commands> = {\n [K in keyof TCommands]: {\n method: K;\n params: Parameters<TCommands[K]>;\n seq: number;\n status: \"queued\" | \"posted\" | \"applied\" | \"settled\";\n result?: unknown;\n failure?: StatewireClient.SendFailure;\n };\n }[keyof TCommands];\n\n /** Push-based canonical state feed a statewire hands to the client core. */\n export type StateSource = {\n snapshot: () => unknown;\n /** Drain the paths changed since the last take (`[]` entry = whole state). */\n takeChanged: () => StatewirePath[];\n /** Synchronous, per change; the consumer owns coalescing. */\n setOnChange: (listener: (() => void) | null) => void;\n };\n\n export type Op =\n | { op: \"replace\"; path: StatewirePath; value: unknown }\n | { op: \"add\"; path: StatewirePath; value: unknown }\n | { op: \"remove\"; path: StatewirePath };\n\n export type Changes = {\n isChangedAt(path: readonly (string | number)[]): boolean;\n getChangedKeys(path: readonly (string | number)[]): string[];\n };\n}\n\nexport interface Statewire<\n TState = any,\n TCommands extends Statewire.Commands = Statewire.Commands,\n> {\n state: TState;\n commands: Statewire.CommandsProxy<TCommands>;\n connection?: StatewireClient.Connection;\n source?: Statewire.StateSource;\n /** Highest command seq the server's dur flag states durably admitted — never lost, never run again — and released from the resend queue. */\n durableSeq?: number;\n /** Negation of the server's broadcast idle level; absent means always busy. */\n isBusy?: boolean;\n /** Replace this client's ambient context on the server; it rides the ctx lane (no verdict). */\n setContext?: (value: Record<string, unknown>) => void;\n /** The retained failed-send lane; present on transports with a durable session. */\n failures?: {\n list: () => readonly StatewireClient.FailedCommand[];\n retry: (entry: StatewireClient.FailedCommand) => Promise<unknown>;\n discard: (entry: StatewireClient.FailedCommand) => void;\n };\n}\n\nconst describe = (failure: StatewireClient.SendFailure): string => {\n switch (failure.fate) {\n case \"rejected\":\n return `statewire: command rejected${failure.code ? ` (${failure.code})` : \"\"}${failure.message ? `: ${failure.message}` : \"\"}`;\n case \"aborted\":\n return `statewire: command aborted (${failure.cause})`;\n case \"lost\":\n return `statewire: command lost (${failure.cause})`;\n case \"failed\":\n return failure.message\n ? `statewire: command failed: ${failure.message}`\n : \"statewire: command failed\";\n case \"crashed\":\n return \"statewire: command crashed\";\n case \"expired\":\n return `statewire: command result expired (${failure.cause})`;\n }\n};\n\nexport class StatewireSendError extends Error {\n readonly failure: StatewireClient.SendFailure;\n\n constructor(failure: StatewireClient.SendFailure) {\n super(describe(failure));\n this.name = \"StatewireSendError\";\n this.failure = failure;\n }\n}\n"],"mappings":";AAqEA,MAAMiD,YAAY7B,YAAiD;CACjE,QAAQA,QAAQ8B,MAAhB;EACE,KAAK,YACH,OAAO,8BAA8B9B,QAAQ+B,OAAO,KAAK/B,QAAQ+B,KAAI,KAAM,KAAK/B,QAAQgC,UAAU,KAAKhC,QAAQgC,YAAY;EAC7H,KAAK,WACH,OAAO,+BAA+BhC,QAAQiC,MAAK;EACrD,KAAK,QACH,OAAO,4BAA4BjC,QAAQiC,MAAK;EAClD,KAAK,UACH,OAAOjC,QAAQgC,UACX,8BAA8BhC,QAAQgC,YACtC;EACN,KAAK,WACH,OAAO;EACT,KAAK,WACH,OAAO,sCAAsChC,QAAQiC,MAAK;CAC9D;AACF;AAEA,IAAaC,qBAAb,cAAwCC,MAAM;CAC5C;CAEAC,YAAYpC,SAAsC;EAChD,MAAM6B,SAAS7B,OAAO,CAAC;EACvB,KAAKqC,OAAO;EACZ,KAAKrC,UAAUA;CACjB;AACF"}
|
package/package.json
CHANGED
package/src/client/connection.ts
CHANGED
|
@@ -89,6 +89,7 @@ export type AttemptOutcome = "stop" | "reattach" | "ended" | "idle";
|
|
|
89
89
|
|
|
90
90
|
export type Conn = {
|
|
91
91
|
connection: StatewireClient.Connection;
|
|
92
|
+
parked: boolean;
|
|
92
93
|
hasSnapshot: boolean;
|
|
93
94
|
lease: string | null;
|
|
94
95
|
attempt: number;
|
|
@@ -430,6 +431,11 @@ const reconnectConn = (c: Conn) => {
|
|
|
430
431
|
|
|
431
432
|
const run = async (c: Conn, lifetime: AbortController, gate: Gate) => {
|
|
432
433
|
const alive = () => c.lifetime === lifetime;
|
|
434
|
+
if (c.parked) {
|
|
435
|
+
await waitGate(gate);
|
|
436
|
+
if (!alive()) return;
|
|
437
|
+
c.parked = false;
|
|
438
|
+
}
|
|
433
439
|
while (alive()) {
|
|
434
440
|
const abort = new AbortController();
|
|
435
441
|
c.abort = abort;
|
|
@@ -465,6 +471,7 @@ const run = async (c: Conn, lifetime: AbortController, gate: Gate) => {
|
|
|
465
471
|
|
|
466
472
|
export const useStatewireConn = (props: {
|
|
467
473
|
url: string;
|
|
474
|
+
isNew?: boolean | undefined;
|
|
468
475
|
onChange?: ((connection: StatewireClient.Connection) => void) | undefined;
|
|
469
476
|
ctx: StatewireRemoteProps;
|
|
470
477
|
attemptOnce: (c: Conn, abort: AbortController) => Promise<AttemptOutcome>;
|
|
@@ -474,6 +481,7 @@ export const useStatewireConn = (props: {
|
|
|
474
481
|
const [, setConnection] = useState<StatewireClient.Connection | null>(null);
|
|
475
482
|
const [c] = useState((): Conn => {
|
|
476
483
|
const conn: Conn = {
|
|
484
|
+
parked: props.isNew ?? false,
|
|
477
485
|
hasSnapshot: false,
|
|
478
486
|
lease: null,
|
|
479
487
|
attempt: 0,
|
package/src/client/core.ts
CHANGED
|
@@ -15,6 +15,8 @@ export const LIVE_CONNECTION: StatewireClient.Connection = {
|
|
|
15
15
|
reconnect: () => {},
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
const NO_FAILED: readonly StatewireClient.FailedCommand[] = [];
|
|
19
|
+
|
|
18
20
|
const failureOf = (error: unknown): StatewireClient.SendFailure =>
|
|
19
21
|
error instanceof StatewireSendError
|
|
20
22
|
? error.failure
|
|
@@ -40,6 +42,7 @@ type Core = {
|
|
|
40
42
|
notifiedOverlayPaths: readonly StatewirePath[];
|
|
41
43
|
notifiedConnection: StatewireClient.Connection | null;
|
|
42
44
|
notifiedBusy: boolean;
|
|
45
|
+
notifiedFailed: readonly StatewireClient.FailedCommand[];
|
|
43
46
|
stale: boolean;
|
|
44
47
|
everAttached: boolean;
|
|
45
48
|
overlaysDirty: boolean;
|
|
@@ -50,6 +53,7 @@ type Core = {
|
|
|
50
53
|
commandsListener: CommandsListener;
|
|
51
54
|
getState: () => unknown;
|
|
52
55
|
getBusy: () => boolean;
|
|
56
|
+
getFailed: () => readonly StatewireClient.FailedCommand[];
|
|
53
57
|
commands: Statewire.CommandsProxy<any>;
|
|
54
58
|
subscribe: (listener: () => void) => () => void;
|
|
55
59
|
};
|
|
@@ -89,11 +93,21 @@ const drainCanonical = (c: Core) => {
|
|
|
89
93
|
}
|
|
90
94
|
};
|
|
91
95
|
|
|
96
|
+
const dropCovered = (c: Core) => {
|
|
97
|
+
const { failureCovered } = c.getOptions();
|
|
98
|
+
const failures = c.value?.failures;
|
|
99
|
+
if (!failureCovered || !failures || c.canonical === undefined) return;
|
|
100
|
+
for (const entry of failures.list()) {
|
|
101
|
+
if (failureCovered(c.canonical as never, entry)) failures.discard(entry);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
92
105
|
const recompute = (c: Core) => {
|
|
93
106
|
if (!c.stale || !c.value) return;
|
|
94
107
|
c.stale = false;
|
|
95
108
|
const prevCanonical = c.canonical;
|
|
96
109
|
drainCanonical(c);
|
|
110
|
+
if (c.canonical !== prevCanonical) dropCovered(c);
|
|
97
111
|
if (c.canonical === prevCanonical && !c.overlaysDirty) return;
|
|
98
112
|
c.overlaysDirty = false;
|
|
99
113
|
const { optimistic, materialize } = c.getOptions();
|
|
@@ -148,7 +162,7 @@ const recompute = (c: Core) => {
|
|
|
148
162
|
const getConnection = (c: Core): StatewireClient.Connection =>
|
|
149
163
|
c.value?.connection ?? LIVE_CONNECTION;
|
|
150
164
|
|
|
151
|
-
const flush = (c: Core) => {
|
|
165
|
+
const flush = (c: Core, scheduled = false) => {
|
|
152
166
|
if (!c.value) return;
|
|
153
167
|
if (c.flushing) {
|
|
154
168
|
schedule(c);
|
|
@@ -187,7 +201,19 @@ const flush = (c: Core) => {
|
|
|
187
201
|
const busy = c.getBusy();
|
|
188
202
|
const busyChanged = busy !== c.notifiedBusy;
|
|
189
203
|
c.notifiedBusy = busy;
|
|
190
|
-
|
|
204
|
+
// The failed snapshot advances only on the scheduled pass: settles land
|
|
205
|
+
// mid-unmount, where a changed snapshot re-renders the unmounting tree.
|
|
206
|
+
const failed = c.value.failures?.list() ?? NO_FAILED;
|
|
207
|
+
let failedChanged = false;
|
|
208
|
+
if (failed !== c.notifiedFailed) {
|
|
209
|
+
if (scheduled) {
|
|
210
|
+
c.notifiedFailed = failed;
|
|
211
|
+
failedChanged = true;
|
|
212
|
+
} else {
|
|
213
|
+
schedule(c);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (stateChanged || connectionChanged || busyChanged || failedChanged) {
|
|
191
217
|
for (const listener of [...c.listeners]) listener();
|
|
192
218
|
}
|
|
193
219
|
} finally {
|
|
@@ -271,6 +297,7 @@ export const useStatewireCore = (
|
|
|
271
297
|
notifiedOverlayPaths: [],
|
|
272
298
|
notifiedConnection: null,
|
|
273
299
|
notifiedBusy: true,
|
|
300
|
+
notifiedFailed: NO_FAILED,
|
|
274
301
|
stale: false,
|
|
275
302
|
everAttached: false,
|
|
276
303
|
overlaysDirty: false,
|
|
@@ -279,7 +306,7 @@ export const useStatewireCore = (
|
|
|
279
306
|
listeners: new Set(),
|
|
280
307
|
post: createMacrotaskPoster(() => {
|
|
281
308
|
c.scheduled = false;
|
|
282
|
-
flush(c);
|
|
309
|
+
flush(c, true);
|
|
283
310
|
}),
|
|
284
311
|
commandsListener: {
|
|
285
312
|
onStart: (inv) => {
|
|
@@ -313,6 +340,7 @@ export const useStatewireCore = (
|
|
|
313
340
|
return c.published;
|
|
314
341
|
},
|
|
315
342
|
getBusy: () => c.value?.isBusy ?? true,
|
|
343
|
+
getFailed: () => c.notifiedFailed,
|
|
316
344
|
commands: createMethodProxy<Statewire.CommandsProxy<any>>(
|
|
317
345
|
(method, params) => {
|
|
318
346
|
if (!c.value) {
|
package/src/client/session.ts
CHANGED
|
@@ -232,6 +232,34 @@ const cover = (s: Session, entry: Pending) => {
|
|
|
232
232
|
s.lifecycle?.onCovered?.(entry.inv);
|
|
233
233
|
};
|
|
234
234
|
|
|
235
|
+
const NO_FAILED: readonly StatewireClient.FailedCommand[] = [];
|
|
236
|
+
|
|
237
|
+
export const failedCommands = (
|
|
238
|
+
s: Session,
|
|
239
|
+
): readonly StatewireClient.FailedCommand[] => s.record.failed ?? NO_FAILED;
|
|
240
|
+
|
|
241
|
+
const retainFailed = (
|
|
242
|
+
s: Session,
|
|
243
|
+
entry: Pending,
|
|
244
|
+
failure: StatewireClient.SendFailure,
|
|
245
|
+
) => {
|
|
246
|
+
setRecord(s, {
|
|
247
|
+
...s.record,
|
|
248
|
+
failed: [
|
|
249
|
+
...(s.record.failed ?? []),
|
|
250
|
+
{
|
|
251
|
+
command: entry.command.method,
|
|
252
|
+
params: entry.command.params,
|
|
253
|
+
...(entry.command.meta !== undefined && { meta: entry.command.meta }),
|
|
254
|
+
failure,
|
|
255
|
+
ts: entry.inv.ts,
|
|
256
|
+
seq: entry.seq,
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
});
|
|
260
|
+
s.notify();
|
|
261
|
+
};
|
|
262
|
+
|
|
235
263
|
const settleEntry = (
|
|
236
264
|
s: Session,
|
|
237
265
|
entry: Pending,
|
|
@@ -247,6 +275,13 @@ const settleEntry = (
|
|
|
247
275
|
payload: outcome.payload,
|
|
248
276
|
});
|
|
249
277
|
} else {
|
|
278
|
+
// A statement the record still carries resends on the next attach; only
|
|
279
|
+
// invocations the record no longer owns become failed history.
|
|
280
|
+
const rides =
|
|
281
|
+
(outcome.failure.cause === "unmount" ||
|
|
282
|
+
outcome.failure.cause === "stream-gone") &&
|
|
283
|
+
entry.seq > s.durableSeq;
|
|
284
|
+
if (!rides) retainFailed(s, entry, outcome.failure);
|
|
250
285
|
entry.reject(new StatewireSendError(outcome.failure));
|
|
251
286
|
s.lifecycle?.onSettle(entry.inv, {
|
|
252
287
|
type: "rejected",
|
|
@@ -307,7 +342,15 @@ const rebaseRecord = (s: Session, adopt: () => void) => {
|
|
|
307
342
|
);
|
|
308
343
|
s.pending = [];
|
|
309
344
|
s.durableSeq = 0;
|
|
345
|
+
// Failed entries are user-owned history: they ride through every record replacement.
|
|
346
|
+
const failed = s.record.failed;
|
|
310
347
|
adopt();
|
|
348
|
+
if (failed !== undefined && s.record.failed !== failed) {
|
|
349
|
+
setRecord(s, {
|
|
350
|
+
...s.record,
|
|
351
|
+
failed: [...(s.record.failed ?? []), ...failed],
|
|
352
|
+
});
|
|
353
|
+
}
|
|
311
354
|
for (const old of survivors) {
|
|
312
355
|
s.lifecycle?.onCovered?.(old.inv);
|
|
313
356
|
enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
|
|
@@ -475,6 +518,7 @@ const sessionLostInner = (s: Session, seqWm: number, ctxWm: number) => {
|
|
|
475
518
|
nextSeq: seqWm + 1,
|
|
476
519
|
ctxNextSeq: ctxWm + 1,
|
|
477
520
|
commands: [],
|
|
521
|
+
...(s.record.failed !== undefined && { failed: s.record.failed }),
|
|
478
522
|
});
|
|
479
523
|
restateContext(s);
|
|
480
524
|
if (
|
|
@@ -668,7 +712,10 @@ const pump = async (s: Session) => {
|
|
|
668
712
|
const transport = s.transport;
|
|
669
713
|
if (buildLanes(s) === null) return;
|
|
670
714
|
if (s.attach !== "attached" || !transport) {
|
|
671
|
-
if (
|
|
715
|
+
if (
|
|
716
|
+
transport?.connection.status === "idle" ||
|
|
717
|
+
(transport?.parked && s.pending.length > 0)
|
|
718
|
+
) {
|
|
672
719
|
transport.connection.reconnect();
|
|
673
720
|
}
|
|
674
721
|
await waitForWake(s);
|
|
@@ -810,12 +857,16 @@ const enqueue = (
|
|
|
810
857
|
) => {
|
|
811
858
|
if (!s.shared) {
|
|
812
859
|
enqueueInner(s, command, params, resolve, reject);
|
|
860
|
+
wake(s);
|
|
813
861
|
void pump(s);
|
|
814
862
|
return;
|
|
815
863
|
}
|
|
816
864
|
void transact(s, () =>
|
|
817
865
|
enqueueInner(s, command, params, resolve, reject),
|
|
818
|
-
).then(() =>
|
|
866
|
+
).then(() => {
|
|
867
|
+
wake(s);
|
|
868
|
+
void pump(s);
|
|
869
|
+
});
|
|
819
870
|
};
|
|
820
871
|
|
|
821
872
|
// A stored command may have reached a server: attempted settles it lost,
|
|
@@ -876,6 +927,62 @@ const sendCommand = (
|
|
|
876
927
|
return result;
|
|
877
928
|
};
|
|
878
929
|
|
|
930
|
+
const takeFailed = (
|
|
931
|
+
s: Session,
|
|
932
|
+
entry: StatewireClient.FailedCommand,
|
|
933
|
+
): StatewireClient.FailedCommand => {
|
|
934
|
+
const failed = s.record.failed ?? [];
|
|
935
|
+
const found = failed.find(
|
|
936
|
+
(f) =>
|
|
937
|
+
f.seq === entry.seq && f.ts === entry.ts && f.command === entry.command,
|
|
938
|
+
);
|
|
939
|
+
if (found === undefined) {
|
|
940
|
+
throw new Error(`statewire: unknown failed command "${entry.command}"`);
|
|
941
|
+
}
|
|
942
|
+
setRecord(s, { ...s.record, failed: failed.filter((f) => f !== found) });
|
|
943
|
+
s.notify();
|
|
944
|
+
return found;
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
export const discardFailed = (
|
|
948
|
+
s: Session,
|
|
949
|
+
entry: StatewireClient.FailedCommand,
|
|
950
|
+
): void => {
|
|
951
|
+
takeFailed(s, entry);
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
export const retryFailed = (
|
|
955
|
+
s: Session,
|
|
956
|
+
entry: StatewireClient.FailedCommand,
|
|
957
|
+
): Promise<unknown> => {
|
|
958
|
+
if (!s.running) {
|
|
959
|
+
return rejectedSend({ fate: "aborted", cause: "unmount" });
|
|
960
|
+
}
|
|
961
|
+
if (isFinished(s)) {
|
|
962
|
+
return rejectedSend({ fate: "aborted", cause: "stream-gone" });
|
|
963
|
+
}
|
|
964
|
+
const found = takeFailed(s, entry);
|
|
965
|
+
const params =
|
|
966
|
+
found.meta === undefined
|
|
967
|
+
? found.params
|
|
968
|
+
: (reviveValue([...found.params], found.meta) as unknown[]);
|
|
969
|
+
const result = new Promise<unknown>((resolve, reject) => {
|
|
970
|
+
enqueue(
|
|
971
|
+
s,
|
|
972
|
+
{
|
|
973
|
+
method: found.command,
|
|
974
|
+
params: [...found.params],
|
|
975
|
+
...(found.meta !== undefined && { meta: found.meta }),
|
|
976
|
+
},
|
|
977
|
+
params,
|
|
978
|
+
resolve,
|
|
979
|
+
reject,
|
|
980
|
+
);
|
|
981
|
+
});
|
|
982
|
+
result.catch(() => {});
|
|
983
|
+
return result;
|
|
984
|
+
};
|
|
985
|
+
|
|
879
986
|
const setContext = (s: Session, value: Record<string, unknown>): void => {
|
|
880
987
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
881
988
|
throw new Error("statewire: context must be a plain object");
|
package/src/statewire.ts
CHANGED
|
@@ -59,10 +59,21 @@ export const useStatewire = <
|
|
|
59
59
|
core.getState,
|
|
60
60
|
) as TState;
|
|
61
61
|
const isBusy = useSyncExternalStore(subscribe, core.getBusy, core.getBusy);
|
|
62
|
+
const failed = useSyncExternalStore(
|
|
63
|
+
subscribe,
|
|
64
|
+
core.getFailed,
|
|
65
|
+
core.getFailed,
|
|
66
|
+
);
|
|
62
67
|
const connection = value.connection ?? LIVE_CONNECTION;
|
|
63
68
|
|
|
64
|
-
return useMemo(
|
|
65
|
-
() =>
|
|
69
|
+
return useMemo(() => {
|
|
70
|
+
const failures = (): NonNullable<typeof value.failures> => {
|
|
71
|
+
if (!value.failures) {
|
|
72
|
+
throw new Error("statewire: this transport has no failed-send lane");
|
|
73
|
+
}
|
|
74
|
+
return value.failures;
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
66
77
|
state,
|
|
67
78
|
getState: core.getState as () => TState,
|
|
68
79
|
connection,
|
|
@@ -76,9 +87,13 @@ export const useStatewire = <
|
|
|
76
87
|
}
|
|
77
88
|
value.setContext(contextValue);
|
|
78
89
|
},
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
90
|
+
failed,
|
|
91
|
+
retryFailed: (entry: StatewireClient.FailedCommand) =>
|
|
92
|
+
failures().retry(entry),
|
|
93
|
+
discardFailed: (entry: StatewireClient.FailedCommand) =>
|
|
94
|
+
failures().discard(entry),
|
|
95
|
+
};
|
|
96
|
+
}, [state, isBusy, failed, connection, core, value]);
|
|
82
97
|
};
|
|
83
98
|
|
|
84
99
|
export const StatewireResource = <
|
|
@@ -121,6 +136,18 @@ export class StatewireClient<
|
|
|
121
136
|
this.root.getValue().setContext(value);
|
|
122
137
|
}
|
|
123
138
|
|
|
139
|
+
get failed(): readonly StatewireClient.FailedCommand[] {
|
|
140
|
+
return this.root.getValue().failed;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
retryFailed(entry: StatewireClient.FailedCommand): Promise<unknown> {
|
|
144
|
+
return this.root.getValue().retryFailed(entry);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
discardFailed(entry: StatewireClient.FailedCommand): void {
|
|
148
|
+
this.root.getValue().discardFailed(entry);
|
|
149
|
+
}
|
|
150
|
+
|
|
124
151
|
subscribe(listener: () => void): () => void {
|
|
125
152
|
return this.root.subscribe(listener);
|
|
126
153
|
}
|
|
@@ -155,6 +182,11 @@ export namespace StatewireClient {
|
|
|
155
182
|
ctx: { state: NonNullable<TState> },
|
|
156
183
|
) => void;
|
|
157
184
|
onConnectionChange?: (connection: Connection) => void;
|
|
185
|
+
/** Drops a retained failed entry once a canonical snapshot shows its effect landed; checked against each new snapshot. */
|
|
186
|
+
failureCovered?: (
|
|
187
|
+
state: NonNullable<TState>,
|
|
188
|
+
entry: FailedCommand,
|
|
189
|
+
) => boolean;
|
|
158
190
|
};
|
|
159
191
|
|
|
160
192
|
export type Helpers<
|
|
@@ -171,6 +203,12 @@ export namespace StatewireClient {
|
|
|
171
203
|
isBusy: boolean;
|
|
172
204
|
/** Replace this client's ambient context on the server; throws on transports without a client context channel. */
|
|
173
205
|
setContext: (value: Record<string, unknown>) => void;
|
|
206
|
+
/** Non-accepted sends retained in the session record; reload with it. */
|
|
207
|
+
failed: readonly FailedCommand[];
|
|
208
|
+
/** Re-enqueues the entry's command on fresh clocks and removes the entry. */
|
|
209
|
+
retryFailed: (entry: FailedCommand) => Promise<unknown>;
|
|
210
|
+
/** Removes the entry. */
|
|
211
|
+
discardFailed: (entry: FailedCommand) => void;
|
|
174
212
|
};
|
|
175
213
|
|
|
176
214
|
export type ConnectionState =
|
|
@@ -259,6 +297,16 @@ export namespace StatewireClient {
|
|
|
259
297
|
}>,
|
|
260
298
|
) => "requeue" | "drop";
|
|
261
299
|
|
|
300
|
+
/** A non-accepted send retained for the user: the wire statement, its failure, and its original clocks. */
|
|
301
|
+
export type FailedCommand = {
|
|
302
|
+
readonly command: string;
|
|
303
|
+
readonly params: readonly unknown[];
|
|
304
|
+
readonly meta?: unknown;
|
|
305
|
+
readonly failure: SendFailure;
|
|
306
|
+
readonly ts: number;
|
|
307
|
+
readonly seq: number;
|
|
308
|
+
};
|
|
309
|
+
|
|
262
310
|
/** A command as persisted while un-durable: its lane seq plus the wire statement. */
|
|
263
311
|
export type SessionCommand = {
|
|
264
312
|
seq: number;
|
|
@@ -275,6 +323,8 @@ export namespace StatewireClient {
|
|
|
275
323
|
commands: readonly SessionCommand[];
|
|
276
324
|
/** Terminal verdicts parked for the sharer that minted the seq (shared storage only). */
|
|
277
325
|
verdicts?: readonly SessionVerdict[];
|
|
326
|
+
/** Non-accepted sends retained for the user; survive record rebases and resets. */
|
|
327
|
+
failed?: readonly FailedCommand[];
|
|
278
328
|
};
|
|
279
329
|
|
|
280
330
|
/** A terminal verdict one sharer's connection carried, parked for the sharer awaiting the seq. */
|
|
@@ -305,6 +355,8 @@ export namespace StatewireClient {
|
|
|
305
355
|
export type SSEOptions = {
|
|
306
356
|
sessionStore?: SessionStorage | undefined;
|
|
307
357
|
url: string;
|
|
358
|
+
/** The session does not exist on the server yet: the first attach waits for the first command send (or an explicit `reconnect()`). */
|
|
359
|
+
isNew?: boolean;
|
|
308
360
|
headers?: HeadersOption;
|
|
309
361
|
fetch?: typeof globalThis.fetch;
|
|
310
362
|
onSessionLost?: OnSessionLost;
|
|
@@ -316,6 +368,8 @@ export namespace StatewireClient {
|
|
|
316
368
|
export type WSOptions = {
|
|
317
369
|
sessionStore?: SessionStorage | undefined;
|
|
318
370
|
url: string;
|
|
371
|
+
/** The session does not exist on the server yet: the first attach waits for the first command send (or an explicit `reconnect()`). */
|
|
372
|
+
isNew?: boolean;
|
|
319
373
|
headers?: HeadersOption;
|
|
320
374
|
protocols?: string | string[];
|
|
321
375
|
query?:
|