statewire 0.0.2 → 0.0.3
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/session.d.ts +5 -2
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +15 -8
- package/dist/client/session.js.map +1 -1
- package/dist/host/host.d.ts +2 -1
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js +5 -1
- package/dist/host/host.js.map +1 -1
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +58 -6
- package/dist/host/socket.js.map +1 -1
- package/dist/host.d.ts +10 -0
- package/dist/host.d.ts.map +1 -1
- package/dist/statewire.d.ts +3 -0
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +10 -2
- package/dist/statewire.js.map +1 -1
- package/dist/transports/remote.d.ts.map +1 -1
- package/dist/transports/remote.js +3 -2
- package/dist/transports/remote.js.map +1 -1
- package/dist/transports/sse.d.ts +23 -1
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +15 -5
- package/dist/transports/sse.js.map +1 -1
- package/dist/transports/testing.d.ts +4 -3
- package/dist/transports/testing.d.ts.map +1 -1
- package/dist/transports/testing.js.map +1 -1
- package/dist/transports/ws.d.ts.map +1 -1
- package/dist/transports/ws.js +2 -3
- package/dist/transports/ws.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/client/session.ts +43 -21
- package/src/host/host.ts +15 -4
- package/src/host/socket.ts +85 -9
- package/src/host.ts +11 -0
- package/src/statewire.ts +15 -1
- package/src/transports/remote.ts +6 -1
- package/src/transports/sse.ts +19 -8
- package/src/transports/testing.ts +6 -4
- package/src/transports/ws.ts +2 -8
- package/src/types.ts +2 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ws.js","names":["useState","flushTapSync","resource","ResourceElement","encodeParams","timing","handleEnvelope","overloadedError","useStatewireConn","Conn","StatewireEnvelope","Statewire","StatewireClient","useStatewireRemote","CLIENT_QUERY_PARAM","RemoteLink","StatewireSubmitResult","StatewireRemoteProps","StatewireWireCommand","PendingCommand","seq","resolve","result","reject","error","Error","WsChannel","socket","WebSocket","pending","OPEN","WS_SUBPROTOCOL","socketUrl","options","WSOptions","clientId","url","query","params","URLSearchParams","set","base","replace","toString","settleOverloaded","w","retryAfterMs","undefined","entry","find","p","filter","resolvePending","matches","remaining","type","push","failPending","runSocket","c","ws","abort","AbortController","Promise","timer","ReturnType","setTimeout","timedOut","outcome","closed","arm","clearTimeout","close","heartbeatTimeoutMs","signal","addEventListener","onerror","stale","Boolean","aborted","fail","processEnv","env","done","ack","res","seqs","Set","map","r","has","onmessage","event","frame","overloaded","JSON","parse","String","data","onclose","attemptSocket","ctx","throwIfAborted","protocols","webSocket","WS","globalThis","extra","sendCommand","command","readyState","send","stringify","method","useWsLink","onChange","attemptOnce","link","connection","args","useStatewireWS","StatewireWS","Commands","TState","TCommands","TEvent"],"sources":["../../src/transports/ws.ts"],"sourcesContent":["import { useState } from \"react\";\nimport { flushTapSync, resource } from \"@assistant-ui/tap\";\nimport type { ResourceElement } from \"@assistant-ui/tap\";\nimport { encodeParams } from \"../wire/meta\";\nimport { timing } from \"../client/timing\";\nimport {\n handleEnvelope,\n overloadedError,\n useStatewireConn,\n} from \"../client/connection\";\nimport type { Conn, StatewireEnvelope } from \"../client/connection\";\nimport type { Statewire } from \"../types\";\nimport type { StatewireClient } from \"../statewire\";\nimport { useStatewireRemote } from \"./remote\";\nimport { CLIENT_QUERY_PARAM } from \"../host/adapter\";\nimport type {\n RemoteLink,\n StatewireSubmitResult,\n StatewireRemoteProps,\n StatewireWireCommand,\n} from \"./sse\";\n\ntype PendingCommand = {\n seq: number;\n resolve: (result: StatewireSubmitResult) => void;\n reject: (error: Error) => void;\n};\n\ntype WsChannel = {\n socket: WebSocket | null;\n pending: PendingCommand[];\n};\n\nconst OPEN = 1;\n\nexport const WS_SUBPROTOCOL = \"statewire.v1\";\n\nconst socketUrl = async (\n options: StatewireClient.WSOptions,\n clientId: string,\n) => {\n const { url, query } = options;\n const params = new URLSearchParams(\n typeof query === \"function\" ? await query() : query,\n );\n params.set(CLIENT_QUERY_PARAM, clientId);\n const base = url.replace(/\\/$/, \"\").replace(/^http/, \"ws\");\n return `${base}/ws?${params.toString()}`;\n};\n\nconst settleOverloaded = (\n w: WsChannel,\n { seq, retryAfterMs }: { seq?: number; retryAfterMs?: number },\n) => {\n if (seq === undefined) throw overloadedError(retryAfterMs);\n const entry = w.pending.find((p) => p.seq === seq);\n if (!entry) return;\n w.pending = w.pending.filter((p) => p !== entry);\n entry.reject(overloadedError(retryAfterMs));\n};\n\nconst resolvePending = (\n w: WsChannel,\n matches: (entry: PendingCommand) => boolean,\n) => {\n const remaining: PendingCommand[] = [];\n for (const entry of w.pending) {\n if (matches(entry)) entry.resolve({ type: \"receipt\" });\n else remaining.push(entry);\n }\n w.pending = remaining;\n};\n\nconst failPending = (w: WsChannel, error: Error) => {\n const pending = w.pending;\n w.pending = [];\n for (const entry of pending) entry.reject(error);\n};\n\nconst runSocket = (\n c: Conn,\n w: WsChannel,\n ws: WebSocket,\n abort: AbortController,\n): Promise<\"stop\" | \"reattach\"> =>\n new Promise((resolve, reject) => {\n let timer: ReturnType<typeof setTimeout> | undefined;\n let timedOut = false;\n let outcome: \"stop\" | \"reattach\" | null = null;\n let closed = false;\n const arm = () => {\n clearTimeout(timer);\n timer = setTimeout(() => {\n timedOut = true;\n ws.close();\n }, timing.heartbeatTimeoutMs);\n };\n arm();\n abort.signal.addEventListener(\"abort\", () => ws.close());\n ws.onerror = () => {};\n const stale = () => Boolean(outcome) || closed || abort.signal.aborted;\n const fail = (error: unknown) => {\n clearTimeout(timer);\n reject(error instanceof Error ? error : new Error(\"protocol\"));\n ws.close();\n };\n const processEnv = (env: StatewireEnvelope) => {\n const done = flushTapSync(() => handleEnvelope(c, env));\n if (env.ack !== undefined) {\n const ack = env.ack;\n resolvePending(w, (entry) => entry.seq <= ack);\n }\n if (env.res !== undefined) {\n const seqs = new Set(env.res.map((r) => r.seq));\n resolvePending(w, (entry) => seqs.has(entry.seq));\n }\n if (done) {\n outcome = done;\n ws.close();\n }\n };\n ws.onmessage = (event) => {\n if (stale()) return;\n arm();\n let frame: StatewireEnvelope & {\n overloaded?: { seq?: number; retryAfterMs?: number };\n };\n try {\n frame = JSON.parse(String(event.data)) as typeof frame;\n } catch {\n fail(new Error(\"protocol\"));\n return;\n }\n try {\n if (frame.overloaded !== undefined) {\n settleOverloaded(w, frame.overloaded);\n } else {\n processEnv(frame);\n }\n } catch (error) {\n fail(error);\n }\n };\n ws.onclose = () => {\n closed = true;\n clearTimeout(timer);\n if (w.socket === ws) failPending(w, new Error(\"network\"));\n if (outcome) resolve(outcome);\n else reject(new Error(timedOut ? \"timeout\" : \"network\"));\n };\n });\n\nconst attemptSocket = async (\n c: Conn,\n w: WsChannel,\n options: StatewireClient.WSOptions,\n abort: AbortController,\n): Promise<\"stop\" | \"reattach\"> => {\n let ws: WebSocket | null = null;\n try {\n const url = await socketUrl(options, c.ctx.clientId);\n abort.signal.throwIfAborted();\n const { protocols, webSocket } = options;\n const WS = webSocket ?? globalThis.WebSocket;\n if (!WS) throw new Error(\"no WebSocket implementation\");\n const extra =\n protocols === undefined\n ? []\n : typeof protocols === \"string\"\n ? [protocols]\n : protocols;\n ws = new WS(url, [WS_SUBPROTOCOL, ...extra]);\n w.socket = ws;\n return await runSocket(c, w, ws, abort);\n } finally {\n if (w.socket === ws) w.socket = null;\n }\n};\n\nconst sendCommand = (\n w: WsChannel,\n { seq, command }: { seq: number; command: StatewireWireCommand },\n): Promise<StatewireSubmitResult> => {\n const ws = w.socket;\n if (!ws || ws.readyState !== OPEN) {\n return Promise.reject(new Error(\"network\"));\n }\n return new Promise((resolve, reject) => {\n const entry: PendingCommand = {\n seq,\n resolve: (result) => {\n clearTimeout(timer);\n resolve(result);\n },\n reject: (error) => {\n clearTimeout(timer);\n reject(error);\n },\n };\n // A timed-out command fails the socket: the retry then rides reconnect +\n // attach-seq arbitration.\n const timer = setTimeout(() => {\n w.pending = w.pending.filter((p) => p !== entry);\n reject(new Error(\"timeout\"));\n ws.close();\n }, timing.heartbeatTimeoutMs);\n w.pending.push(entry);\n try {\n ws.send(\n JSON.stringify({\n method: command.method,\n ...encodeParams(command.params),\n seq,\n }),\n );\n } catch {\n clearTimeout(timer);\n w.pending = w.pending.filter((p) => p !== entry);\n reject(new Error(\"network\"));\n }\n });\n};\n\nconst useWsLink = (\n options: StatewireClient.WSOptions,\n ctx: StatewireRemoteProps,\n): RemoteLink => {\n const [w] = useState((): WsChannel => ({ socket: null, pending: [] }));\n const c = useStatewireConn({\n url: options.url,\n onChange: options.onChange,\n ctx,\n attemptOnce: (c, abort) => attemptSocket(c, w, options, abort),\n });\n const [link] = useState(\n (): RemoteLink => ({\n get connection() {\n return c.connection;\n },\n send: (args) => sendCommand(w, args),\n }),\n );\n return link;\n};\n\nconst useStatewireWS = (\n options: StatewireClient.WSOptions,\n): Statewire<any, any, any> => useStatewireRemote(options, useWsLink);\n\nexport const StatewireWS = resource(useStatewireWS) as <\n TState = any,\n TCommands extends Statewire.Commands = Statewire.Commands,\n TEvent = any,\n>(\n options: StatewireClient.WSOptions,\n) => ResourceElement<Statewire<TState | undefined, TCommands, TEvent>>;\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ws.js","names":["useState","flushTapSync","resource","ResourceElement","timing","handleEnvelope","overloadedError","useStatewireConn","Conn","StatewireEnvelope","Statewire","StatewireClient","useStatewireRemote","CLIENT_QUERY_PARAM","encodeWireCommand","RemoteLink","StatewireSubmitResult","StatewireRemoteProps","StatewireWireCommand","PendingCommand","seq","resolve","result","reject","error","Error","WsChannel","socket","WebSocket","pending","OPEN","WS_SUBPROTOCOL","socketUrl","options","WSOptions","clientId","url","query","params","URLSearchParams","set","base","replace","toString","settleOverloaded","w","retryAfterMs","undefined","entry","find","p","filter","resolvePending","matches","remaining","type","push","failPending","runSocket","c","ws","abort","AbortController","Promise","timer","ReturnType","setTimeout","timedOut","outcome","closed","arm","clearTimeout","close","heartbeatTimeoutMs","signal","addEventListener","onerror","stale","Boolean","aborted","fail","processEnv","env","done","ack","res","seqs","Set","map","r","has","onmessage","event","frame","overloaded","JSON","parse","String","data","onclose","attemptSocket","ctx","throwIfAborted","protocols","webSocket","WS","globalThis","extra","sendCommand","command","readyState","send","stringify","useWsLink","onChange","attemptOnce","link","connection","args","useStatewireWS","StatewireWS","Commands","TState","TCommands","TEvent"],"sources":["../../src/transports/ws.ts"],"sourcesContent":["import { useState } from \"react\";\nimport { flushTapSync, resource } from \"@assistant-ui/tap\";\nimport type { ResourceElement } from \"@assistant-ui/tap\";\nimport { timing } from \"../client/timing\";\nimport {\n handleEnvelope,\n overloadedError,\n useStatewireConn,\n} from \"../client/connection\";\nimport type { Conn, StatewireEnvelope } from \"../client/connection\";\nimport type { Statewire } from \"../types\";\nimport type { StatewireClient } from \"../statewire\";\nimport { useStatewireRemote } from \"./remote\";\nimport { CLIENT_QUERY_PARAM } from \"../host/adapter\";\nimport { encodeWireCommand } from \"./sse\";\nimport type {\n RemoteLink,\n StatewireSubmitResult,\n StatewireRemoteProps,\n StatewireWireCommand,\n} from \"./sse\";\n\ntype PendingCommand = {\n seq: number;\n resolve: (result: StatewireSubmitResult) => void;\n reject: (error: Error) => void;\n};\n\ntype WsChannel = {\n socket: WebSocket | null;\n pending: PendingCommand[];\n};\n\nconst OPEN = 1;\n\nexport const WS_SUBPROTOCOL = \"statewire.v1\";\n\nconst socketUrl = async (\n options: StatewireClient.WSOptions,\n clientId: string,\n) => {\n const { url, query } = options;\n const params = new URLSearchParams(\n typeof query === \"function\" ? await query() : query,\n );\n params.set(CLIENT_QUERY_PARAM, clientId);\n const base = url.replace(/\\/$/, \"\").replace(/^http/, \"ws\");\n return `${base}/ws?${params.toString()}`;\n};\n\nconst settleOverloaded = (\n w: WsChannel,\n { seq, retryAfterMs }: { seq?: number; retryAfterMs?: number },\n) => {\n if (seq === undefined) throw overloadedError(retryAfterMs);\n const entry = w.pending.find((p) => p.seq === seq);\n if (!entry) return;\n w.pending = w.pending.filter((p) => p !== entry);\n entry.reject(overloadedError(retryAfterMs));\n};\n\nconst resolvePending = (\n w: WsChannel,\n matches: (entry: PendingCommand) => boolean,\n) => {\n const remaining: PendingCommand[] = [];\n for (const entry of w.pending) {\n if (matches(entry)) entry.resolve({ type: \"receipt\" });\n else remaining.push(entry);\n }\n w.pending = remaining;\n};\n\nconst failPending = (w: WsChannel, error: Error) => {\n const pending = w.pending;\n w.pending = [];\n for (const entry of pending) entry.reject(error);\n};\n\nconst runSocket = (\n c: Conn,\n w: WsChannel,\n ws: WebSocket,\n abort: AbortController,\n): Promise<\"stop\" | \"reattach\"> =>\n new Promise((resolve, reject) => {\n let timer: ReturnType<typeof setTimeout> | undefined;\n let timedOut = false;\n let outcome: \"stop\" | \"reattach\" | null = null;\n let closed = false;\n const arm = () => {\n clearTimeout(timer);\n timer = setTimeout(() => {\n timedOut = true;\n ws.close();\n }, timing.heartbeatTimeoutMs);\n };\n arm();\n abort.signal.addEventListener(\"abort\", () => ws.close());\n ws.onerror = () => {};\n const stale = () => Boolean(outcome) || closed || abort.signal.aborted;\n const fail = (error: unknown) => {\n clearTimeout(timer);\n reject(error instanceof Error ? error : new Error(\"protocol\"));\n ws.close();\n };\n const processEnv = (env: StatewireEnvelope) => {\n const done = flushTapSync(() => handleEnvelope(c, env));\n if (env.ack !== undefined) {\n const ack = env.ack;\n resolvePending(w, (entry) => entry.seq <= ack);\n }\n if (env.res !== undefined) {\n const seqs = new Set(env.res.map((r) => r.seq));\n resolvePending(w, (entry) => seqs.has(entry.seq));\n }\n if (done) {\n outcome = done;\n ws.close();\n }\n };\n ws.onmessage = (event) => {\n if (stale()) return;\n arm();\n let frame: StatewireEnvelope & {\n overloaded?: { seq?: number; retryAfterMs?: number };\n };\n try {\n frame = JSON.parse(String(event.data)) as typeof frame;\n } catch {\n fail(new Error(\"protocol\"));\n return;\n }\n try {\n if (frame.overloaded !== undefined) {\n settleOverloaded(w, frame.overloaded);\n } else {\n processEnv(frame);\n }\n } catch (error) {\n fail(error);\n }\n };\n ws.onclose = () => {\n closed = true;\n clearTimeout(timer);\n if (w.socket === ws) failPending(w, new Error(\"network\"));\n if (outcome) resolve(outcome);\n else reject(new Error(timedOut ? \"timeout\" : \"network\"));\n };\n });\n\nconst attemptSocket = async (\n c: Conn,\n w: WsChannel,\n options: StatewireClient.WSOptions,\n abort: AbortController,\n): Promise<\"stop\" | \"reattach\"> => {\n let ws: WebSocket | null = null;\n try {\n const url = await socketUrl(options, c.ctx.clientId);\n abort.signal.throwIfAborted();\n const { protocols, webSocket } = options;\n const WS = webSocket ?? globalThis.WebSocket;\n if (!WS) throw new Error(\"no WebSocket implementation\");\n const extra =\n protocols === undefined\n ? []\n : typeof protocols === \"string\"\n ? [protocols]\n : protocols;\n ws = new WS(url, [WS_SUBPROTOCOL, ...extra]);\n w.socket = ws;\n return await runSocket(c, w, ws, abort);\n } finally {\n if (w.socket === ws) w.socket = null;\n }\n};\n\nconst sendCommand = (\n w: WsChannel,\n { seq, command }: { seq: number; command: StatewireWireCommand },\n): Promise<StatewireSubmitResult> => {\n const ws = w.socket;\n if (!ws || ws.readyState !== OPEN) {\n return Promise.reject(new Error(\"network\"));\n }\n return new Promise((resolve, reject) => {\n const entry: PendingCommand = {\n seq,\n resolve: (result) => {\n clearTimeout(timer);\n resolve(result);\n },\n reject: (error) => {\n clearTimeout(timer);\n reject(error);\n },\n };\n // A timed-out command fails the socket: the retry then rides reconnect +\n // attach-seq arbitration.\n const timer = setTimeout(() => {\n w.pending = w.pending.filter((p) => p !== entry);\n reject(new Error(\"timeout\"));\n ws.close();\n }, timing.heartbeatTimeoutMs);\n w.pending.push(entry);\n try {\n ws.send(JSON.stringify({ ...encodeWireCommand(command), seq }));\n } catch {\n clearTimeout(timer);\n w.pending = w.pending.filter((p) => p !== entry);\n reject(new Error(\"network\"));\n }\n });\n};\n\nconst useWsLink = (\n options: StatewireClient.WSOptions,\n ctx: StatewireRemoteProps,\n): RemoteLink => {\n const [w] = useState((): WsChannel => ({ socket: null, pending: [] }));\n const c = useStatewireConn({\n url: options.url,\n onChange: options.onChange,\n ctx,\n attemptOnce: (c, abort) => attemptSocket(c, w, options, abort),\n });\n const [link] = useState(\n (): RemoteLink => ({\n get connection() {\n return c.connection;\n },\n send: (args) => sendCommand(w, args),\n }),\n );\n return link;\n};\n\nconst useStatewireWS = (\n options: StatewireClient.WSOptions,\n): Statewire<any, any, any> => useStatewireRemote(options, useWsLink);\n\nexport const StatewireWS = resource(useStatewireWS) as <\n TState = any,\n TCommands extends Statewire.Commands = Statewire.Commands,\n TEvent = any,\n>(\n options: StatewireClient.WSOptions,\n) => ResourceElement<Statewire<TState | undefined, TCommands, TEvent>>;\n"],"mappings":";;;;;;;;AAiCA,MAAM8B,OAAO;AAEb,MAAaC,iBAAiB;AAE9B,MAAMC,YAAY,OAChBC,SACAE,aACG;CACH,MAAM,EAAEC,KAAKC,UAAUJ;CACvB,MAAMK,SAAS,IAAIC,gBACjB,OAAOF,UAAU,aAAa,MAAMA,MAAM,IAAIA,KAChD;CACAC,OAAOE,IAAI3B,oBAAoBsB,QAAQ;CAEvC,OAAO,GADMC,IAAIM,QAAQ,OAAO,EAAE,CAAC,CAACA,QAAQ,SAAS,IAC3CD,EAAI,MAAOH,OAAOK,SAAS;AACvC;AAEA,MAAMC,oBACJC,GACA,EAAEzB,KAAK0B,mBACJ;CACH,IAAI1B,QAAQ2B,KAAAA,GAAW,MAAMzC,gBAAgBwC,YAAY;CACzD,MAAME,QAAQH,EAAEhB,QAAQoB,MAAMC,MAAMA,EAAE9B,QAAQA,GAAG;CACjD,IAAI,CAAC4B,OAAO;CACZH,EAAEhB,UAAUgB,EAAEhB,QAAQsB,QAAQD,MAAMA,MAAMF,KAAK;CAC/CA,MAAMzB,OAAOjB,gBAAgBwC,YAAY,CAAC;AAC5C;AAEA,MAAMM,kBACJP,GACAQ,YACG;CACH,MAAMC,YAA8B,CAAA;CACpC,KAAK,MAAMN,SAASH,EAAEhB,SACpB,IAAIwB,QAAQL,KAAK,GAAGA,MAAM3B,QAAQ,EAAEkC,MAAM,UAAU,CAAC;MAChDD,UAAUE,KAAKR,KAAK;CAE3BH,EAAEhB,UAAUyB;AACd;AAEA,MAAMG,eAAeZ,GAAcrB,UAAiB;CAClD,MAAMK,UAAUgB,EAAEhB;CAClBgB,EAAEhB,UAAU,CAAA;CACZ,KAAK,MAAMmB,SAASnB,SAASmB,MAAMzB,OAAOC,KAAK;AACjD;AAEA,MAAMkC,aACJC,GACAd,GACAe,IACAC,UAEA,IAAIE,SAAS1C,SAASE,WAAW;CAC/B,IAAIyC;CACJ,IAAIG,WAAW;CACf,IAAIC,UAAsC;CAC1C,IAAIC,SAAS;CACb,MAAMC,YAAY;EAChBC,aAAaP,KAAK;EAClBA,QAAQE,iBAAiB;GACvBC,WAAW;GACXP,GAAGY,MAAM;EACX,GAAGpE,OAAOqE,kBAAkB;CAC9B;CACAH,IAAI;CACJT,MAAMa,OAAOC,iBAAiB,eAAef,GAAGY,MAAM,CAAC;CACvDZ,GAAGgB,gBAAgB,CAAC;CACpB,MAAMC,cAAcC,QAAQV,OAAO,KAAKC,UAAUR,MAAMa,OAAOK;CAC/D,MAAMC,QAAQxD,UAAmB;EAC/B+C,aAAaP,KAAK;EAClBzC,OAAOC,iBAAiBC,QAAQD,wBAAQ,IAAIC,MAAM,UAAU,CAAC;EAC7DmC,GAAGY,MAAM;CACX;CACA,MAAMS,cAAcC,QAA2B;EAC7C,MAAMC,OAAOlF,mBAAmBI,eAAesD,GAAGuB,GAAG,CAAC;EACtD,IAAIA,IAAIE,QAAQrC,KAAAA,GAAW;GACzB,MAAMqC,MAAMF,IAAIE;GAChBhC,eAAeP,IAAIG,UAAUA,MAAM5B,OAAOgE,GAAG;EAC/C;EACA,IAAIF,IAAIG,QAAQtC,KAAAA,GAAW;GACzB,MAAMuC,OAAO,IAAIC,IAAIL,IAAIG,IAAIG,KAAKC,MAAMA,EAAErE,GAAG,CAAC;GAC9CgC,eAAeP,IAAIG,UAAUsC,KAAKI,IAAI1C,MAAM5B,GAAG,CAAC;EAClD;EACA,IAAI+D,MAAM;GACRf,UAAUe;GACVvB,GAAGY,MAAM;EACX;CACF;CACAZ,GAAG+B,aAAaC,UAAU;EACxB,IAAIf,MAAM,GAAG;EACbP,IAAI;EACJ,IAAIuB;EAGJ,IAAI;GACFA,QAAQE,KAAKC,MAAMC,OAAOL,MAAMM,IAAI,CAAC;EACvC,QAAQ;GACNlB,qBAAK,IAAIvD,MAAM,UAAU,CAAC;GAC1B;EACF;EACA,IAAI;GACF,IAAIoE,MAAMC,eAAe/C,KAAAA,GACvBH,iBAAiBC,GAAGgD,MAAMC,UAAU;QAEpCb,WAAWY,KAAK;EAEpB,SAASrE,OAAO;GACdwD,KAAKxD,KAAK;EACZ;CACF;CACAoC,GAAGuC,gBAAgB;EACjB9B,SAAS;EACTE,aAAaP,KAAK;EAClB,IAAInB,EAAElB,WAAWiC,IAAIH,YAAYZ,mBAAG,IAAIpB,MAAM,SAAS,CAAC;EACxD,IAAI2C,SAAS/C,QAAQ+C,OAAO;OACvB7C,uBAAO,IAAIE,MAAM0C,WAAW,YAAY,SAAS,CAAC;CACzD;AACF,CAAC;AAEH,MAAMiC,gBAAgB,OACpBzC,GACAd,GACAZ,SACA4B,UACiC;CACjC,IAAID,KAAuB;CAC3B,IAAI;EACF,MAAMxB,MAAM,MAAMJ,UAAUC,SAAS0B,EAAE0C,IAAIlE,QAAQ;EACnD0B,MAAMa,OAAO4B,eAAe;EAC5B,MAAM,EAAEC,WAAWC,cAAcvE;EACjC,MAAMwE,KAAKD,aAAaE,WAAW9E;EACnC,IAAI,CAAC6E,IAAI,MAAM,IAAIhF,MAAM,6BAA6B;EAOtDmC,KAAK,IAAI6C,GAAGrE,KAAK,CAACL,gBAAgB,GALhCwE,cAAcxD,KAAAA,IACV,CAAA,IACA,OAAOwD,cAAc,WACnB,CAACA,SAAS,IACVA,SACkC,CAAC;EAC3C1D,EAAElB,SAASiC;EACX,OAAO,MAAMF,UAAUC,GAAGd,GAAGe,IAAIC,KAAK;CACxC,UAAU;EACR,IAAIhB,EAAElB,WAAWiC,IAAIf,EAAElB,SAAS;CAClC;AACF;AAEA,MAAMiF,eACJ/D,GACA,EAAEzB,KAAKyF,cAC4B;CACnC,MAAMjD,KAAKf,EAAElB;CACb,IAAI,CAACiC,MAAMA,GAAGkD,eAAehF,MAC3B,OAAOiC,QAAQxC,uBAAO,IAAIE,MAAM,SAAS,CAAC;CAE5C,OAAO,IAAIsC,SAAS1C,SAASE,WAAW;EACtC,MAAMyB,QAAwB;GAC5B5B;GACAC,UAAUC,WAAW;IACnBiD,aAAaP,KAAK;IAClB3C,QAAQC,MAAM;GAChB;GACAC,SAASC,UAAU;IACjB+C,aAAaP,KAAK;IAClBzC,OAAOC,KAAK;GACd;EACF;EAGA,MAAMwC,QAAQE,iBAAiB;GAC7BrB,EAAEhB,UAAUgB,EAAEhB,QAAQsB,QAAQD,MAAMA,MAAMF,KAAK;GAC/CzB,uBAAO,IAAIE,MAAM,SAAS,CAAC;GAC3BmC,GAAGY,MAAM;EACX,GAAGpE,OAAOqE,kBAAkB;EAC5B5B,EAAEhB,QAAQ2B,KAAKR,KAAK;EACpB,IAAI;GACFY,GAAGmD,KAAKhB,KAAKiB,UAAU;IAAE,GAAGlG,kBAAkB+F,OAAO;IAAGzF;GAAI,CAAC,CAAC;EAChE,QAAQ;GACNmD,aAAaP,KAAK;GAClBnB,EAAEhB,UAAUgB,EAAEhB,QAAQsB,QAAQD,MAAMA,MAAMF,KAAK;GAC/CzB,uBAAO,IAAIE,MAAM,SAAS,CAAC;EAC7B;CACF,CAAC;AACH;AAEA,MAAMwF,aACJhF,SACAoE,QACe;CACf,MAAM,CAACxD,KAAK7C,gBAA2B;EAAE2B,QAAQ;EAAME,SAAS,CAAA;CAAG,EAAE;CACrE,MAAM8B,MAAIpD,iBAAiB;EACzB6B,KAAKH,QAAQG;EACb8E,UAAUjF,QAAQiF;EAClBb;EACAc,cAAcxD,GAAGE,UAAUuC,cAAczC,GAAGd,GAAGZ,SAAS4B,KAAK;CAC/D,CAAC;CACD,MAAM,CAACuD,QAAQpH,gBACM;EACjB,IAAIqH,aAAa;GACf,OAAO1D,IAAE0D;EACX;EACAN,OAAOO,SAASV,YAAY/D,GAAGyE,IAAI;CACrC,EACF;CACA,OAAOF;AACT;AAEA,MAAMG,kBACJtF,YAC6BrB,mBAAmBqB,SAASgF,SAAS;AAEpE,MAAaO,cAActH,SAASqH,cAAc"}
|
package/dist/types.d.ts
CHANGED
|
@@ -50,6 +50,8 @@ interface Statewire<TState = any, TCommands extends Statewire.Commands = Statewi
|
|
|
50
50
|
emit?: (event: TEvent) => void;
|
|
51
51
|
connection?: StatewireClient.Connection;
|
|
52
52
|
source?: Statewire.StateSource;
|
|
53
|
+
/** Replace this client's ambient context on the server; absent on transports without a client context channel. */
|
|
54
|
+
setContext?: (value: Record<string, unknown>) => Promise<unknown>;
|
|
53
55
|
}
|
|
54
56
|
declare class StatewireSendError extends Error {
|
|
55
57
|
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;;IACpB;IAAa;;OAEP;IACV,YAAY;IACZ,eAAe;;;UAIF,UACf,cACA,kBAAkB,UAAU,WAAW,UAAU,UACjD;EAEA,OAAO;EACP,UAAU,UAAU,cAAc;EAClC,QAAQ,OAAO;EACf,aAAa,gBAAgB;EAC7B,SAAS,UAAU;;
|
|
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;;IACpB;IAAa;;OAEP;IACV,YAAY;IACZ,eAAe;;;UAIF,UACf,cACA,kBAAkB,UAAU,WAAW,UAAU,UACjD;EAEA,OAAO;EACP,UAAU,UAAU,cAAc;EAClC,QAAQ,OAAO;EACf,aAAa,gBAAgB;EAC7B,SAAS,UAAU;;EAEnB,cAAc,OAAO,4BAA4B;;cAsBtC,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","emit","event","TEvent","connection","Connection","source","describe","fate","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\" | \"acked\" | \"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 | { op: \"event\"; value: object };\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 TEvent = any,\n> {\n state: TState;\n commands: Statewire.CommandsProxy<TCommands>;\n emit?: (event: TEvent) => void;\n connection?: StatewireClient.Connection;\n source?: Statewire.StateSource;\n}\n\nconst describe = (failure: StatewireClient.SendFailure): string => {\n switch (failure.fate) {\n case \"rejected\":\n return `statewire: command rejected: ${failure.message}`;\n case \"unknown-command\":\n return \"statewire: unknown command\";\n case \"invalid-params\":\n return `statewire: invalid params: ${failure.message}`;\n case \"aborted\":\n return `statewire: command aborted (${failure.cause})`;\n case \"lost\":\n return `statewire: command lost (${failure.cause})`;\n case \"crashed\":\n return \"statewire: command crashed\";\n case \"result-unavailable\":\n return `statewire: command result unavailable (${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","emit","event","TEvent","connection","Connection","source","setContext","describe","fate","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\" | \"acked\" | \"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 | { op: \"event\"; value: object };\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 TEvent = any,\n> {\n state: TState;\n commands: Statewire.CommandsProxy<TCommands>;\n emit?: (event: TEvent) => void;\n connection?: StatewireClient.Connection;\n source?: Statewire.StateSource;\n /** Replace this client's ambient context on the server; absent on transports without a client context channel. */\n setContext?: (value: Record<string, unknown>) => Promise<unknown>;\n}\n\nconst describe = (failure: StatewireClient.SendFailure): string => {\n switch (failure.fate) {\n case \"rejected\":\n return `statewire: command rejected: ${failure.message}`;\n case \"unknown-command\":\n return \"statewire: unknown command\";\n case \"invalid-params\":\n return `statewire: invalid params: ${failure.message}`;\n case \"aborted\":\n return `statewire: command aborted (${failure.cause})`;\n case \"lost\":\n return `statewire: command lost (${failure.cause})`;\n case \"crashed\":\n return \"statewire: command crashed\";\n case \"result-unavailable\":\n return `statewire: command result unavailable (${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":";AA8DA,MAAM4C,YAAYxB,YAAiD;CACjE,QAAQA,QAAQyB,MAAhB;EACE,KAAK,YACH,OAAO,gCAAgCzB,QAAQ0B;EACjD,KAAK,mBACH,OAAO;EACT,KAAK,kBACH,OAAO,8BAA8B1B,QAAQ0B;EAC/C,KAAK,WACH,OAAO,+BAA+B1B,QAAQ2B,MAAK;EACrD,KAAK,QACH,OAAO,4BAA4B3B,QAAQ2B,MAAK;EAClD,KAAK,WACH,OAAO;EACT,KAAK,sBACH,OAAO,0CAA0C3B,QAAQ2B,MAAK;CAClE;AACF;AAEA,IAAaC,qBAAb,cAAwCC,MAAM;CAC5C;CAEAC,YAAY9B,SAAsC;EAChD,MAAMwB,SAASxB,OAAO,CAAC;EACvB,KAAK+B,OAAO;EACZ,KAAK/B,UAAUA;CACjB;AACF"}
|
package/package.json
CHANGED
package/src/client/session.ts
CHANGED
|
@@ -40,7 +40,8 @@ type PendingState =
|
|
|
40
40
|
type Pending = {
|
|
41
41
|
readonly seq: number;
|
|
42
42
|
readonly command: StatewireWireCommand;
|
|
43
|
-
|
|
43
|
+
/** null for context submissions — they carry no command lifecycle. */
|
|
44
|
+
readonly inv: CommandsInvocation | null;
|
|
44
45
|
state: PendingState;
|
|
45
46
|
resolve: (payload: unknown) => void;
|
|
46
47
|
reject: (error: StatewireSendError) => void;
|
|
@@ -102,20 +103,22 @@ const transition = (
|
|
|
102
103
|
}
|
|
103
104
|
const covers = !isStateSettled(prev) && isStateSettled(next);
|
|
104
105
|
entry.state = next;
|
|
105
|
-
if (covers) s.lifecycle?.onCovered?.(entry.inv);
|
|
106
|
+
if (covers && entry.inv) s.lifecycle?.onCovered?.(entry.inv);
|
|
106
107
|
if (settles && outcome !== undefined) {
|
|
107
108
|
if (outcome.ok) {
|
|
108
109
|
entry.resolve(outcome.payload);
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
110
|
+
if (entry.inv)
|
|
111
|
+
s.lifecycle?.onSettle(entry.inv, {
|
|
112
|
+
type: "accepted",
|
|
113
|
+
payload: outcome.payload,
|
|
114
|
+
});
|
|
113
115
|
} else {
|
|
114
116
|
entry.reject(new StatewireSendError(outcome.failure));
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
if (entry.inv)
|
|
118
|
+
s.lifecycle?.onSettle(entry.inv, {
|
|
119
|
+
type: "rejected",
|
|
120
|
+
error: new StatewireSendError(outcome.failure),
|
|
121
|
+
});
|
|
119
122
|
}
|
|
120
123
|
}
|
|
121
124
|
if (next.at === "done") {
|
|
@@ -150,6 +153,7 @@ type Session = {
|
|
|
150
153
|
recordPersisted: boolean;
|
|
151
154
|
store: StatewireSessionStore;
|
|
152
155
|
lastAck: number;
|
|
156
|
+
context: Record<string, unknown> | undefined;
|
|
153
157
|
pending: Pending[];
|
|
154
158
|
canonical: unknown;
|
|
155
159
|
lastPublished: unknown;
|
|
@@ -185,6 +189,7 @@ const createSession = (options: StatewireSessionStoreOptions): Session => {
|
|
|
185
189
|
recordPersisted: stored !== null,
|
|
186
190
|
store,
|
|
187
191
|
lastAck: -1,
|
|
192
|
+
context: undefined,
|
|
188
193
|
pending: [],
|
|
189
194
|
canonical: undefined,
|
|
190
195
|
lastPublished: undefined,
|
|
@@ -294,6 +299,9 @@ const evict = (
|
|
|
294
299
|
s.record = { clientId: newClientId(), nextSeq: 1 };
|
|
295
300
|
s.store.save(s.record);
|
|
296
301
|
s.attach = "detached";
|
|
302
|
+
// The server dropped this client's record and its stored context with it:
|
|
303
|
+
// the new session opens with a fresh full root replace.
|
|
304
|
+
if (s.context !== undefined) void s.send({ context: s.context });
|
|
297
305
|
publish(s);
|
|
298
306
|
wake(s);
|
|
299
307
|
};
|
|
@@ -301,7 +309,7 @@ const evict = (
|
|
|
301
309
|
const noteAcked = (s: Session, seq: number) => {
|
|
302
310
|
for (const entry of s.pending) {
|
|
303
311
|
if (entry.seq > seq || isStateSettled(entry.state)) continue;
|
|
304
|
-
s.lifecycle?.onAck(entry.inv);
|
|
312
|
+
if (entry.inv) s.lifecycle?.onAck(entry.inv);
|
|
305
313
|
}
|
|
306
314
|
};
|
|
307
315
|
|
|
@@ -546,7 +554,7 @@ const pump = async (s: Session) => {
|
|
|
546
554
|
) {
|
|
547
555
|
transition(s, entry, { ...state, posted: true });
|
|
548
556
|
}
|
|
549
|
-
s.lifecycle?.onPosted?.(entry.inv);
|
|
557
|
+
if (entry.inv) s.lifecycle?.onPosted?.(entry.inv);
|
|
550
558
|
if (response !== undefined) {
|
|
551
559
|
applyResponse(s, response);
|
|
552
560
|
publish(s);
|
|
@@ -653,13 +661,16 @@ const sendCommand = (
|
|
|
653
661
|
const entry: Pending = {
|
|
654
662
|
seq,
|
|
655
663
|
command,
|
|
656
|
-
inv:
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
664
|
+
inv:
|
|
665
|
+
command.method === undefined
|
|
666
|
+
? null
|
|
667
|
+
: {
|
|
668
|
+
method: command.method,
|
|
669
|
+
params: command.params,
|
|
670
|
+
ts: Date.now(),
|
|
671
|
+
seq,
|
|
672
|
+
clientId: s.record.clientId,
|
|
673
|
+
},
|
|
663
674
|
state: { at: "queued", pendingMarked: false },
|
|
664
675
|
resolve: () => {},
|
|
665
676
|
reject: () => {},
|
|
@@ -670,11 +681,22 @@ const sendCommand = (
|
|
|
670
681
|
});
|
|
671
682
|
result.catch(() => {});
|
|
672
683
|
s.pending.push(entry);
|
|
673
|
-
s.lifecycle?.onStart(entry.inv);
|
|
684
|
+
if (entry.inv) s.lifecycle?.onStart(entry.inv);
|
|
674
685
|
void pump(s);
|
|
675
686
|
return result;
|
|
676
687
|
};
|
|
677
688
|
|
|
689
|
+
const setContext = (
|
|
690
|
+
s: Session,
|
|
691
|
+
value: Record<string, unknown>,
|
|
692
|
+
): Promise<unknown> => {
|
|
693
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
694
|
+
throw new Error("statewire: context must be a plain object");
|
|
695
|
+
}
|
|
696
|
+
s.context = value;
|
|
697
|
+
return s.send({ context: value });
|
|
698
|
+
};
|
|
699
|
+
|
|
678
700
|
export const useStatewireSession = (
|
|
679
701
|
options: StatewireSessionStoreOptions,
|
|
680
702
|
emit: (event: unknown) => void,
|
|
@@ -693,4 +715,4 @@ export const useStatewireSession = (
|
|
|
693
715
|
return s;
|
|
694
716
|
};
|
|
695
717
|
|
|
696
|
-
export { setTransport };
|
|
718
|
+
export { setContext, setTransport };
|
package/src/host/host.ts
CHANGED
|
@@ -203,6 +203,7 @@ export type CommandsSubmission = {
|
|
|
203
203
|
method: string;
|
|
204
204
|
params: readonly unknown[];
|
|
205
205
|
clientId?: string;
|
|
206
|
+
caller?: StatewireHost.Caller;
|
|
206
207
|
onAck?: () => void;
|
|
207
208
|
onSettle?: (outcome: CommandsOutcome) => void;
|
|
208
209
|
};
|
|
@@ -247,6 +248,12 @@ type CommandsCell = {
|
|
|
247
248
|
|
|
248
249
|
const STALL_WARN_MS = 5000;
|
|
249
250
|
|
|
251
|
+
// In-process dispatch has no wire client: never any ambient context, trivially attached.
|
|
252
|
+
const LOCAL_CALLER: StatewireHost.Caller = Object.freeze({
|
|
253
|
+
context: null,
|
|
254
|
+
isConnected: true,
|
|
255
|
+
});
|
|
256
|
+
|
|
250
257
|
const toSendError = (error: unknown): StatewireSendError =>
|
|
251
258
|
error instanceof StatewireSendError
|
|
252
259
|
? error
|
|
@@ -324,9 +331,9 @@ const pump = (cell: CommandsCell) => {
|
|
|
324
331
|
cell.listener?.onCovered?.(inv);
|
|
325
332
|
void (async () => {
|
|
326
333
|
try {
|
|
327
|
-
const value = await (
|
|
328
|
-
...
|
|
329
|
-
);
|
|
334
|
+
const value = await (
|
|
335
|
+
handler as (...args: readonly unknown[]) => unknown
|
|
336
|
+
).call(call.origin?.caller ?? LOCAL_CALLER, ...call.params);
|
|
330
337
|
const execution =
|
|
331
338
|
typeof value === "object" && value !== null
|
|
332
339
|
? executionStates.get(value)
|
|
@@ -367,7 +374,11 @@ const watchStall = (cell: CommandsCell) => {
|
|
|
367
374
|
};
|
|
368
375
|
|
|
369
376
|
export const useStatewireCommands = <THandlers extends Statewire.Commands>(
|
|
370
|
-
commands:
|
|
377
|
+
commands:
|
|
378
|
+
| (THandlers & ThisType<StatewireHost.Caller>)
|
|
379
|
+
| undefined
|
|
380
|
+
| null
|
|
381
|
+
| false,
|
|
371
382
|
): Statewire.CommandsProxy<StatewireHost.HandledCommands<THandlers>> => {
|
|
372
383
|
const [built] = useState(() => {
|
|
373
384
|
const cell: CommandsCell = {
|
package/src/host/socket.ts
CHANGED
|
@@ -40,6 +40,7 @@ type Admission = {
|
|
|
40
40
|
type ClientRec = {
|
|
41
41
|
id: string;
|
|
42
42
|
lastSeq: number;
|
|
43
|
+
context: Record<string, unknown> | null;
|
|
43
44
|
results: Map<number, { ts: number; res: StatewireResponse }>;
|
|
44
45
|
inflight: Map<number, Admission>;
|
|
45
46
|
};
|
|
@@ -51,7 +52,12 @@ type Conn = {
|
|
|
51
52
|
lastSentAt: number;
|
|
52
53
|
};
|
|
53
54
|
|
|
54
|
-
type Frame = {
|
|
55
|
+
type Frame = {
|
|
56
|
+
method?: string;
|
|
57
|
+
seq: number;
|
|
58
|
+
params: unknown[];
|
|
59
|
+
context?: unknown;
|
|
60
|
+
};
|
|
55
61
|
|
|
56
62
|
const parseFrame = (raw: string): Frame | null => {
|
|
57
63
|
let frame: unknown;
|
|
@@ -63,9 +69,16 @@ const parseFrame = (raw: string): Frame | null => {
|
|
|
63
69
|
if (typeof frame !== "object" || frame === null || Array.isArray(frame)) {
|
|
64
70
|
return null;
|
|
65
71
|
}
|
|
66
|
-
const { method, seq, params, meta } = frame as Record<
|
|
67
|
-
|
|
72
|
+
const { method, seq, params, meta, context } = frame as Record<
|
|
73
|
+
string,
|
|
74
|
+
unknown
|
|
75
|
+
>;
|
|
76
|
+
if (method === undefined && context === undefined) return null;
|
|
68
77
|
if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 1) return null;
|
|
78
|
+
if (method === undefined) {
|
|
79
|
+
return { seq, params: [], ...(context !== undefined && { context }) };
|
|
80
|
+
}
|
|
81
|
+
if (typeof method !== "string") return null;
|
|
69
82
|
if (!Array.isArray(params)) return null;
|
|
70
83
|
if (meta !== undefined) {
|
|
71
84
|
try {
|
|
@@ -74,7 +87,29 @@ const parseFrame = (raw: string): Frame | null => {
|
|
|
74
87
|
return null;
|
|
75
88
|
}
|
|
76
89
|
}
|
|
77
|
-
return { method, seq, params };
|
|
90
|
+
return { method, seq, params, ...(context !== undefined && { context }) };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** v1 context submissions are exactly one root replace with an object value. */
|
|
94
|
+
const contextValue = (context: unknown): Record<string, unknown> => {
|
|
95
|
+
if (!Array.isArray(context) || context.length !== 1) {
|
|
96
|
+
throw new Error("context must be a single-op array");
|
|
97
|
+
}
|
|
98
|
+
const op = context[0] as Record<string, unknown> | null;
|
|
99
|
+
if (
|
|
100
|
+
typeof op !== "object" ||
|
|
101
|
+
op === null ||
|
|
102
|
+
op["op"] !== "replace" ||
|
|
103
|
+
!Array.isArray(op["path"]) ||
|
|
104
|
+
op["path"].length !== 0
|
|
105
|
+
) {
|
|
106
|
+
throw new Error("context op must be a root replace");
|
|
107
|
+
}
|
|
108
|
+
const value = op["value"];
|
|
109
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
110
|
+
throw new Error("context value must be an object");
|
|
111
|
+
}
|
|
112
|
+
return value as Record<string, unknown>;
|
|
78
113
|
};
|
|
79
114
|
|
|
80
115
|
const encodePayload = (
|
|
@@ -321,15 +356,14 @@ const createSocketHost = (
|
|
|
321
356
|
return { seq, type: "result-unavailable" };
|
|
322
357
|
};
|
|
323
358
|
|
|
324
|
-
const
|
|
359
|
+
const settleResponse = (
|
|
325
360
|
rec: ClientRec,
|
|
326
361
|
adm: Admission,
|
|
327
|
-
|
|
362
|
+
response: StatewireResponse,
|
|
328
363
|
) => {
|
|
329
364
|
if (adm.settled) return;
|
|
330
365
|
adm.settled = true;
|
|
331
366
|
rec.inflight.delete(adm.seq);
|
|
332
|
-
const response = responseOf(adm, outcome);
|
|
333
367
|
retain(rec, response);
|
|
334
368
|
if (!adm.acked) {
|
|
335
369
|
adm.acked = true;
|
|
@@ -346,6 +380,25 @@ const createSocketHost = (
|
|
|
346
380
|
adm.release();
|
|
347
381
|
};
|
|
348
382
|
|
|
383
|
+
const settleWire = (
|
|
384
|
+
rec: ClientRec,
|
|
385
|
+
adm: Admission,
|
|
386
|
+
outcome: CommandsOutcome,
|
|
387
|
+
) => settleResponse(rec, adm, responseOf(adm, outcome));
|
|
388
|
+
|
|
389
|
+
const callerOf = (rec: ClientRec): StatewireHost.Caller => ({
|
|
390
|
+
get context() {
|
|
391
|
+
return rec.context;
|
|
392
|
+
},
|
|
393
|
+
get isConnected() {
|
|
394
|
+
if (clients.get(rec.id) !== rec) return false;
|
|
395
|
+
for (const conn of conns) {
|
|
396
|
+
if (conn.clientId === rec.id && conn.open) return true;
|
|
397
|
+
}
|
|
398
|
+
return false;
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
|
|
349
402
|
const admit = (conn: Conn, frame: Frame): Promise<void> | void => {
|
|
350
403
|
if (!conn.open || disposed || finished !== null) return;
|
|
351
404
|
const rec = clients.get(conn.clientId)!;
|
|
@@ -364,20 +417,42 @@ const createSocketHost = (
|
|
|
364
417
|
}
|
|
365
418
|
const adm: Admission = {
|
|
366
419
|
seq: frame.seq,
|
|
367
|
-
method: frame.method,
|
|
420
|
+
method: frame.method ?? "",
|
|
368
421
|
acked: false,
|
|
369
422
|
pendingSent: false,
|
|
370
423
|
settled: false,
|
|
371
424
|
release: () => {},
|
|
372
425
|
};
|
|
426
|
+
if (frame.context !== undefined) {
|
|
427
|
+
let value: Record<string, unknown>;
|
|
428
|
+
try {
|
|
429
|
+
value = contextValue(frame.context);
|
|
430
|
+
} catch (error) {
|
|
431
|
+
rec.inflight.set(frame.seq, adm);
|
|
432
|
+
settleResponse(rec, adm, {
|
|
433
|
+
seq: frame.seq,
|
|
434
|
+
type: "rejected",
|
|
435
|
+
code: "invalid-context",
|
|
436
|
+
message: `invalid context: ${(error as Error).message}`,
|
|
437
|
+
});
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
rec.context = value;
|
|
441
|
+
if (frame.method === undefined) {
|
|
442
|
+
rec.inflight.set(frame.seq, adm);
|
|
443
|
+
settleResponse(rec, adm, { seq: frame.seq, type: "accepted" });
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
373
447
|
const released = new Promise<void>((resolve) => {
|
|
374
448
|
adm.release = resolve;
|
|
375
449
|
});
|
|
376
450
|
rec.inflight.set(frame.seq, adm);
|
|
377
451
|
commandsHost!.submit!({
|
|
378
|
-
method: frame.method
|
|
452
|
+
method: frame.method!,
|
|
379
453
|
params: frame.params,
|
|
380
454
|
clientId: rec.id,
|
|
455
|
+
caller: callerOf(rec),
|
|
381
456
|
onAck: () => {
|
|
382
457
|
if (adm.settled || adm.acked) return;
|
|
383
458
|
adm.acked = true;
|
|
@@ -437,6 +512,7 @@ const createSocketHost = (
|
|
|
437
512
|
const created: ClientRec = {
|
|
438
513
|
id: clientId,
|
|
439
514
|
lastSeq: 0,
|
|
515
|
+
context: null,
|
|
440
516
|
results: new Map(),
|
|
441
517
|
inflight: new Map(),
|
|
442
518
|
};
|
package/src/host.ts
CHANGED
|
@@ -46,6 +46,17 @@ export namespace StatewireHost {
|
|
|
46
46
|
reject: (error: unknown) => void;
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The submitting client, bound as `this` of a command handler. Holdable
|
|
51
|
+
* across a long run: `context` reads the client's current ambient context
|
|
52
|
+
* live (null until the client sends one), `isConnected` reports whether the
|
|
53
|
+
* client has an active stream attachment.
|
|
54
|
+
*/
|
|
55
|
+
export type Caller = {
|
|
56
|
+
readonly context: Record<string, unknown> | null;
|
|
57
|
+
readonly isConnected: boolean;
|
|
58
|
+
};
|
|
59
|
+
|
|
49
60
|
export type HandledCommands<THandlers extends Statewire.Commands> = {
|
|
50
61
|
[K in keyof THandlers]: (
|
|
51
62
|
...args: Parameters<THandlers[K]>
|
package/src/statewire.ts
CHANGED
|
@@ -41,8 +41,16 @@ export const useStatewire = <
|
|
|
41
41
|
getState: core.getState as () => TState,
|
|
42
42
|
connection,
|
|
43
43
|
commands: core.commands as Statewire.CommandsProxy<TCommands>,
|
|
44
|
+
setContext: (contextValue: Record<string, unknown>) => {
|
|
45
|
+
if (!value.setContext) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
"statewire: this transport has no client context channel",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return value.setContext(contextValue);
|
|
51
|
+
},
|
|
44
52
|
}),
|
|
45
|
-
[state, connection, core],
|
|
53
|
+
[state, connection, core, value],
|
|
46
54
|
);
|
|
47
55
|
};
|
|
48
56
|
|
|
@@ -80,6 +88,10 @@ export class StatewireClient<
|
|
|
80
88
|
return this.root.getValue().commands;
|
|
81
89
|
}
|
|
82
90
|
|
|
91
|
+
setContext(value: Record<string, unknown>): Promise<unknown> {
|
|
92
|
+
return this.root.getValue().setContext(value);
|
|
93
|
+
}
|
|
94
|
+
|
|
83
95
|
subscribe(listener: () => void): () => void {
|
|
84
96
|
return this.root.subscribe(listener);
|
|
85
97
|
}
|
|
@@ -120,6 +132,8 @@ export namespace StatewireClient {
|
|
|
120
132
|
getState: () => TState;
|
|
121
133
|
connection: Connection;
|
|
122
134
|
commands: Statewire.CommandsProxy<TCommands>;
|
|
135
|
+
/** Replace this client's ambient context on the server; throws on transports without a client context channel. */
|
|
136
|
+
setContext: (value: Record<string, unknown>) => Promise<unknown>;
|
|
123
137
|
};
|
|
124
138
|
|
|
125
139
|
export type Connection = ConnectionState & { reconnect: () => void };
|
package/src/transports/remote.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { useEffect } from "react";
|
|
2
2
|
import { useStatewireEmit } from "../host/host";
|
|
3
3
|
import type { Statewire } from "../types";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
setContext,
|
|
6
|
+
setTransport,
|
|
7
|
+
useStatewireSession,
|
|
8
|
+
} from "../client/session";
|
|
5
9
|
import type { StatewireSessionStoreOptions } from "../client/sessionStore";
|
|
6
10
|
import type { StatewireClient } from "../statewire";
|
|
7
11
|
import type { RemoteLink, StatewireRemoteProps } from "./sse";
|
|
@@ -26,5 +30,6 @@ export const useStatewireRemote = <
|
|
|
26
30
|
emit,
|
|
27
31
|
connection: conn.connection,
|
|
28
32
|
source: s.source,
|
|
33
|
+
setContext: (value) => setContext(s, value),
|
|
29
34
|
};
|
|
30
35
|
};
|
package/src/transports/sse.ts
CHANGED
|
@@ -32,10 +32,24 @@ export type StatewireRemoteProps = {
|
|
|
32
32
|
onFinish: (args: StatewireHost.Fin) => void;
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
-
export type StatewireWireCommand =
|
|
36
|
-
method: string;
|
|
37
|
-
params:
|
|
38
|
-
|
|
35
|
+
export type StatewireWireCommand =
|
|
36
|
+
| { method: string; params: readonly unknown[]; context?: never }
|
|
37
|
+
| { method?: never; params?: never; context: Record<string, unknown> };
|
|
38
|
+
|
|
39
|
+
/** The wire shape of a client context submission: one root replace op. */
|
|
40
|
+
export const contextOps = (value: Record<string, unknown>) => [
|
|
41
|
+
{ op: "replace", path: [], value },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
export const encodeWireCommand = (command: StatewireWireCommand) => ({
|
|
45
|
+
...(command.method !== undefined && {
|
|
46
|
+
method: command.method,
|
|
47
|
+
...encodeParams(command.params),
|
|
48
|
+
}),
|
|
49
|
+
...(command.context !== undefined && {
|
|
50
|
+
context: contextOps(command.context),
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
39
53
|
|
|
40
54
|
export type StatewireSubmitResult =
|
|
41
55
|
| { type: "receipt"; response?: StatewireResponse }
|
|
@@ -184,10 +198,7 @@ const sendCommand = async (
|
|
|
184
198
|
"Statewire-Command-Seq": String(seq),
|
|
185
199
|
...(c.lease !== null && { "Statewire-Lease": c.lease }),
|
|
186
200
|
}),
|
|
187
|
-
body: JSON.stringify(
|
|
188
|
-
method: command.method,
|
|
189
|
-
...encodeParams(command.params),
|
|
190
|
-
}),
|
|
201
|
+
body: JSON.stringify(encodeWireCommand(command)),
|
|
191
202
|
signal: AbortSignal.any([
|
|
192
203
|
c.lifetime?.signal ?? AbortSignal.abort(),
|
|
193
204
|
timeout,
|
|
@@ -20,8 +20,8 @@ export const applyOptimistic = <TState>(
|
|
|
20
20
|
export type MockTransport<TState> = {
|
|
21
21
|
/** Pass as the `transport:` option of useStatewire / Harness. */
|
|
22
22
|
transport: ResourceElement<Statewire<TState | undefined, any, any>>;
|
|
23
|
-
/** Captured `
|
|
24
|
-
commands: { method
|
|
23
|
+
/** Captured `/commands` POST bodies, in POST order. */
|
|
24
|
+
commands: { method?: string; params?: unknown[]; context?: unknown }[];
|
|
25
25
|
/** Replace the replicated state and broadcast it as a root snapshot op. */
|
|
26
26
|
setState: (state: TState) => Promise<void>;
|
|
27
27
|
/** Deliver an ack watermark on the stream. */
|
|
@@ -49,7 +49,7 @@ export const createMockTransport = <TState>(
|
|
|
49
49
|
let finished = false;
|
|
50
50
|
const clients = new Map<string, number>();
|
|
51
51
|
const streams = new Set<FakeStreamHandle>();
|
|
52
|
-
const commands:
|
|
52
|
+
const commands: MockTransport<unknown>["commands"] = [];
|
|
53
53
|
|
|
54
54
|
const sendEnvelope = (envelope: StatewireEnvelope) => {
|
|
55
55
|
for (const stream of streams) stream.send(sseFrame(envelope));
|
|
@@ -86,7 +86,9 @@ export const createMockTransport = <TState>(
|
|
|
86
86
|
const seq = Number(headers.get("Statewire-Command-Seq"));
|
|
87
87
|
clients.set(id, Math.max(clients.get(id) ?? 0, seq));
|
|
88
88
|
commands.push(
|
|
89
|
-
JSON.parse(
|
|
89
|
+
JSON.parse(
|
|
90
|
+
String(init?.body),
|
|
91
|
+
) as MockTransport<unknown>["commands"][number],
|
|
90
92
|
);
|
|
91
93
|
return Response.json({});
|
|
92
94
|
}
|
package/src/transports/ws.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { flushTapSync, resource } from "@assistant-ui/tap";
|
|
3
3
|
import type { ResourceElement } from "@assistant-ui/tap";
|
|
4
|
-
import { encodeParams } from "../wire/meta";
|
|
5
4
|
import { timing } from "../client/timing";
|
|
6
5
|
import {
|
|
7
6
|
handleEnvelope,
|
|
@@ -13,6 +12,7 @@ import type { Statewire } from "../types";
|
|
|
13
12
|
import type { StatewireClient } from "../statewire";
|
|
14
13
|
import { useStatewireRemote } from "./remote";
|
|
15
14
|
import { CLIENT_QUERY_PARAM } from "../host/adapter";
|
|
15
|
+
import { encodeWireCommand } from "./sse";
|
|
16
16
|
import type {
|
|
17
17
|
RemoteLink,
|
|
18
18
|
StatewireSubmitResult,
|
|
@@ -206,13 +206,7 @@ const sendCommand = (
|
|
|
206
206
|
}, timing.heartbeatTimeoutMs);
|
|
207
207
|
w.pending.push(entry);
|
|
208
208
|
try {
|
|
209
|
-
ws.send(
|
|
210
|
-
JSON.stringify({
|
|
211
|
-
method: command.method,
|
|
212
|
-
...encodeParams(command.params),
|
|
213
|
-
seq,
|
|
214
|
-
}),
|
|
215
|
-
);
|
|
209
|
+
ws.send(JSON.stringify({ ...encodeWireCommand(command), seq }));
|
|
216
210
|
} catch {
|
|
217
211
|
clearTimeout(timer);
|
|
218
212
|
w.pending = w.pending.filter((p) => p !== entry);
|
package/src/types.ts
CHANGED
|
@@ -56,6 +56,8 @@ export interface Statewire<
|
|
|
56
56
|
emit?: (event: TEvent) => void;
|
|
57
57
|
connection?: StatewireClient.Connection;
|
|
58
58
|
source?: Statewire.StateSource;
|
|
59
|
+
/** Replace this client's ambient context on the server; absent on transports without a client context channel. */
|
|
60
|
+
setContext?: (value: Record<string, unknown>) => Promise<unknown>;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
const describe = (failure: StatewireClient.SendFailure): string => {
|