statewire 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/client/connection.d.ts +5 -2
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +54 -19
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts +2 -2
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +5 -4
- package/dist/client/core.js.map +1 -1
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +6 -2
- package/dist/client/session.js.map +1 -1
- package/dist/host/http.d.ts +2 -1
- package/dist/host/http.d.ts.map +1 -1
- package/dist/host/http.js +36 -3
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +11 -2
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +135 -6
- package/dist/host/socket.js.map +1 -1
- package/dist/host-internal.d.ts +9 -1
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/statewire.d.ts +16 -11
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +2 -2
- package/dist/statewire.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/package.json +1 -1
- package/src/client/connection.ts +67 -20
- package/src/client/core.ts +4 -3
- package/src/client/session.ts +10 -2
- package/src/host/http.ts +40 -2
- package/src/host/socket.ts +220 -6
- package/src/host-internal.ts +14 -1
- package/src/statewire.ts +23 -10
- package/src/transports/sse.ts +6 -0
- package/src/transports/ws.ts +4 -0
package/dist/statewire.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"statewire.js","names":["useMemo","useRef","useState","useSyncExternalStore","createTapRoot","flushTapSync","resource","useContextProvider","useResource","useResources","withKey","ResourceElement","LIVE_CONNECTION","useStatewireCore","Statewire","TERMINAL","SessionStorageContext","useSessionTransport","transport","session","StatewireClient","SessionStorage","SessionTransport","useStatewire","Commands","options","Options","TState","TCommands","Helpers","latest","current","storage","undefined","value","core","subscribe","listener","state","getState","isBusy","getBusy","failed","getFailed","connection","failures","NonNullable","Error","commands","CommandsProxy","setContext","contextValue","Record","retryFailed","entry","FailedCommand","retry","discardFailed","discard","StatewireResource","root","disposed","constructor","ClientRoot","getValue","Connection","Promise","dispose","unmount","initialState","optimistic","Optimistic","materialize","Materialize","onStateChange","ctx","changes","Changes","onCommandChange","update","CommandUpdate","onConnectionChange","failureCovered","ConnectionState","status","cause","attempt","nextRetryAt","lastError","message","payload","reconnect","ConnectionStatus","draft","command","Command","OptimisticContext","ts","seq","clientId","SendFailure","fate","code","predecessorLost","OnSessionLost","pending","ReadonlyArray","method","params","meta","failure","SessionCommand","SessionRecord","nextSeq","ctxNextSeq","verdicts","SessionVerdict","type","load","save","record","lock","fn","T","HeadersOption","HeadersInit","staleAuth","SSEOptions","sessionStore","url","headers","fetch","globalThis","onSessionLost","onChange","onDone","WSOptions","protocols","query","webSocket","WebSocket"],"sources":["../src/statewire.ts"],"sourcesContent":["import { useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport {\n createTapRoot,\n flushTapSync,\n resource,\n useContextProvider,\n useResource,\n useResources,\n withKey,\n} from \"@assistant-ui/tap\";\nimport type { ResourceElement } from \"@assistant-ui/tap\";\nimport { LIVE_CONNECTION, useStatewireCore } from \"./client/core\";\nimport type { Statewire } from \"./types\";\nimport type { TERMINAL } from \"./client/connection\";\nimport { SessionStorageContext } from \"./client/sessionStore\";\n\nconst useSessionTransport = ({\n transport,\n session,\n}: {\n transport: ResourceElement<Statewire<any, any>>;\n session: StatewireClient.SessionStorage | undefined;\n}): Statewire<unknown> =>\n useContextProvider(SessionStorageContext, session, () =>\n // oxlint-disable-next-line react-hooks/rules-of-hooks\n useResource(transport),\n );\n\n// The transport owns the session; it reads the storage off the context this\n// wrapper's own fiber provides, so plain-React callers get a provider too.\nconst SessionTransport = resource(useSessionTransport);\n\nexport const useStatewire = <\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n>(\n options: StatewireClient.Options<TState, TCommands>,\n): StatewireClient.Helpers<TState, TCommands> => {\n const latest = useRef(options);\n latest.current = options;\n\n const session = useResources(\n options.storage?.session !== undefined\n ? [withKey(\"session\", options.storage.session)]\n : [],\n )[0];\n const value = useResource(\n SessionTransport({ transport: options.transport, session }),\n );\n const core = useStatewireCore(() => latest.current, value);\n const [subscribe] = useState(\n () => (listener: () => void) =>\n core.subscribe(() => flushTapSync(listener)),\n );\n\n const state = useSyncExternalStore(\n subscribe,\n core.getState,\n core.getState,\n ) as TState;\n const isBusy = useSyncExternalStore(subscribe, core.getBusy, core.getBusy);\n const failed = useSyncExternalStore(\n subscribe,\n core.getFailed,\n core.getFailed,\n );\n const connection = value.connection ?? LIVE_CONNECTION;\n\n return useMemo(() => {\n const failures = (): NonNullable<typeof value.failures> => {\n if (!value.failures) {\n throw new Error(\"statewire: this transport has no failed-send lane\");\n }\n return value.failures;\n };\n return {\n state,\n getState: core.getState as () => TState,\n connection,\n commands: core.commands as Statewire.CommandsProxy<TCommands>,\n isBusy,\n setContext: (contextValue: Record<string, unknown>) => {\n if (!value.setContext) {\n throw new Error(\n \"statewire: this transport has no client context channel\",\n );\n }\n value.setContext(contextValue);\n },\n failed,\n retryFailed: (entry: StatewireClient.FailedCommand) =>\n failures().retry(entry),\n discardFailed: (entry: StatewireClient.FailedCommand) =>\n failures().discard(entry),\n };\n }, [state, isBusy, failed, connection, core, value]);\n};\n\nexport const StatewireResource = <\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n>(\n options: StatewireClient.Options<TState, TCommands>,\n): ResourceElement<StatewireClient.Helpers<TState, TCommands>> =>\n resource(useStatewire<TState, TCommands>)(options);\n\nexport class StatewireClient<\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n> {\n private readonly root;\n private disposed = false;\n\n constructor(options: StatewireClient.Options<TState, TCommands>) {\n const ClientRoot = () => useStatewire<TState, TCommands>(options);\n this.root = createTapRoot(ClientRoot);\n }\n\n get state(): TState {\n return this.root.getValue().getState();\n }\n\n get connection(): StatewireClient.Connection {\n return this.root.getValue().connection;\n }\n\n get commands(): Statewire.CommandsProxy<TCommands> {\n return this.root.getValue().commands;\n }\n\n get isBusy(): boolean {\n return this.root.getValue().isBusy;\n }\n\n setContext(value: Record<string, unknown>): void {\n this.root.getValue().setContext(value);\n }\n\n get failed(): readonly StatewireClient.FailedCommand[] {\n return this.root.getValue().failed;\n }\n\n retryFailed(entry: StatewireClient.FailedCommand): Promise<unknown> {\n return this.root.getValue().retryFailed(entry);\n }\n\n discardFailed(entry: StatewireClient.FailedCommand): void {\n this.root.getValue().discardFailed(entry);\n }\n\n subscribe(listener: () => void): () => void {\n return this.root.subscribe(listener);\n }\n\n dispose(): void {\n if (this.disposed) return;\n this.disposed = true;\n this.root.unmount();\n }\n}\n\nexport namespace StatewireClient {\n export type Options<\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n > = {\n transport: ResourceElement<Statewire<TState, TCommands>>;\n /** Durable session: the mounted storage holds this client's lane identity and its un-durable commands, which resend on the next attach. */\n storage?:\n | { session?: ResourceElement<SessionStorage> | undefined }\n | undefined;\n /** Served as the canonical state until the attach snapshot; own commands fold over it. */\n initialState?: TState;\n optimistic?: Optimistic<NonNullable<TState>, TCommands>;\n materialize?: Materialize<NonNullable<TState>>;\n onStateChange?: (\n state: NonNullable<TState>,\n ctx: { changes: Statewire.Changes },\n ) => void;\n onCommandChange?: (\n update: Statewire.CommandUpdate<TCommands>,\n ctx: { state: NonNullable<TState> },\n ) => void;\n onConnectionChange?: (connection: Connection) => void;\n /** Drops a retained failed entry once a canonical snapshot shows its effect landed; checked against each new snapshot. */\n failureCovered?: (\n state: NonNullable<TState>,\n entry: FailedCommand,\n ) => boolean;\n };\n\n export type Helpers<\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n > = {\n /** The statewire's state type — remote statewires include `undefined` until the first attach snapshot applies. */\n state: TState;\n /** Identity-stable across renders; reads the current canonical state without subscribing the caller. */\n getState: () => TState;\n connection: Connection;\n commands: Statewire.CommandsProxy<TCommands>;\n /** Negation of the server's broadcast idle level (a transport without idle is always busy). */\n isBusy: boolean;\n /** Replace this client's ambient context on the server; throws on transports without a client context channel. */\n setContext: (value: Record<string, unknown>) => void;\n /** Non-accepted sends retained in the session record; reload with it. */\n failed: readonly FailedCommand[];\n /** Re-enqueues the entry's command on fresh clocks and removes the entry. */\n retryFailed: (entry: FailedCommand) => Promise<unknown>;\n /** Removes the entry. */\n discardFailed: (entry: FailedCommand) => void;\n };\n\n export type ConnectionState =\n | { status: \"connecting\" }\n | { status: \"live\" }\n | { status: \"idle\" }\n | {\n status: \"reconnecting\";\n cause: \"dropped\" | \"evicted\" | \"error\" | \"unauthorized\";\n attempt: number;\n nextRetryAt: number;\n lastError?: string;\n message?: string;\n }\n | { status: \"gone\"; message?: string; payload?: unknown };\n export type Connection = ConnectionState & { reconnect: () => void };\n export type ConnectionStatus = Connection[\"status\"];\n\n /**\n * Mutate the draft in place; the client records the next state plus the exact\n * changed paths. Re-run against the new base on every rebase.\n */\n export type Optimistic<\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n > = (\n draft: TState,\n command: Statewire.Command<TCommands>,\n ctx: OptimisticContext,\n ) => void;\n\n export type OptimisticContext = {\n ts: number;\n seq: number;\n clientId?: string;\n };\n\n /**\n * Mutate the draft to make server-accepted, still-uncommitted intents in\n * the state concrete (e.g. dispatch records into messages). Runs after the\n * optimistic folds on every published state.\n */\n export type Materialize<TState> = (draft: TState) => void;\n\n /**\n * fate: \"aborted\" = never sent, \"rejected\" = the server refused it\n * (`code` \"unknown-command\" | \"invalid-params\" for the static rejects,\n * code-less for handler rejects), \"lost\" = sent but no verdict will ever\n * arrive — the record is gone (a proven seq gap) or the channel died: it\n * may or may not have run, \"failed\" = the handler ran and threw,\n * \"crashed\" = the handler died involuntarily, \"expired\" = settled —\n * result, rejected, failed, or crashed — but the verdict outlived the\n * server's retention.\n * cause is which channel settled it: \"server\" = the server's answer;\n * the rest are channel deaths the client settled locally.\n */\n export type SendFailure =\n | {\n fate: \"rejected\";\n cause: \"server\";\n code?: string;\n message?: string;\n payload?: unknown;\n }\n | { fate: \"failed\"; cause: \"server\"; message?: string }\n | { fate: \"crashed\"; cause: \"server\" }\n | { fate: \"expired\"; cause: \"server\" }\n | {\n fate: \"aborted\";\n cause: \"unmount\" | \"stream-gone\" | \"session-lost\";\n predecessorLost?: true;\n }\n | {\n fate: \"lost\";\n cause: \"unmount\" | \"stream-gone\" | \"session-lost\";\n };\n\n /** Held commands a proven record gap settled `{fate: \"lost\", cause: \"session-lost\"}`; return \"requeue\" to re-enqueue them on the reset clocks. */\n export type OnSessionLost = (\n pending: ReadonlyArray<{\n method: string;\n params: readonly unknown[];\n ts: number;\n seq: number;\n clientId?: string;\n }>,\n ) => \"requeue\" | \"drop\";\n\n /** A non-accepted send retained for the user: the wire statement, its failure, and its original clocks. */\n export type FailedCommand = {\n readonly command: string;\n readonly params: readonly unknown[];\n readonly meta?: unknown;\n readonly failure: SendFailure;\n readonly ts: number;\n readonly seq: number;\n };\n\n /** A command as persisted while un-durable: its lane seq plus the wire statement. */\n export type SessionCommand = {\n seq: number;\n method: string;\n params: unknown[];\n meta?: unknown;\n };\n\n /** The persisted session: the lane identity, the lane clocks, and the commands the server has not marked durable. */\n export type SessionRecord = {\n clientId: string;\n nextSeq: number;\n ctxNextSeq: number;\n commands: readonly SessionCommand[];\n /** Terminal verdicts parked for the sharer that minted the seq (shared storage only). */\n verdicts?: readonly SessionVerdict[];\n /** Non-accepted sends retained for the user; survive record rebases and resets. */\n failed?: readonly FailedCommand[];\n };\n\n /** A terminal verdict one sharer's connection carried, parked for the sharer awaiting the seq. */\n export type SessionVerdict = {\n seq: number;\n type: (typeof TERMINAL)[number];\n code?: string;\n message?: string;\n payload?: unknown;\n meta?: unknown;\n };\n\n /** Session storage seam; `save` must persist synchronously in the caller's flow. */\n export type SessionStorage = {\n load: () => SessionRecord | null;\n save: (record: SessionRecord) => void;\n /** Serializes record mutations across sharers; presence marks the storage shared. */\n lock?: <T>(fn: () => T | Promise<T>) => Promise<T>;\n /** Notifies of record saves by other sharers; returns unsubscribe. Required alongside `lock`. */\n subscribe?: (listener: () => void) => () => void;\n };\n\n /** Static headers, or a resolver invoked per request/attach; `staleAuth` is true right after an auth rejection. */\n export type HeadersOption =\n | HeadersInit\n | ((ctx: { staleAuth: boolean }) => HeadersInit | Promise<HeadersInit>);\n\n export type SSEOptions = {\n sessionStore?: SessionStorage | undefined;\n url: string;\n headers?: HeadersOption;\n fetch?: typeof globalThis.fetch;\n onSessionLost?: OnSessionLost;\n onChange?: (connection: Connection) => void;\n /** The broadcast idle flag's rising edge: the instance went quiet. */\n onDone?: () => void;\n };\n\n export type WSOptions = {\n sessionStore?: SessionStorage | undefined;\n url: string;\n headers?: HeadersOption;\n protocols?: string | string[];\n query?:\n | Record<string, string>\n | (() => Record<string, string> | Promise<Record<string, string>>);\n webSocket?: new (url: string, protocols?: string | string[]) => WebSocket;\n onSessionLost?: OnSessionLost;\n onChange?: (connection: Connection) => void;\n /** The broadcast idle flag's rising edge: the instance went quiet. */\n onDone?: () => void;\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"statewire.js","names":["useMemo","useRef","useState","useSyncExternalStore","createTapRoot","flushTapSync","resource","useContextProvider","useResource","useResources","withKey","ResourceElement","CONNECTED_CONNECTION","useStatewireCore","Statewire","TERMINAL","SessionStorageContext","useSessionTransport","transport","session","StatewireClient","SessionStorage","SessionTransport","useStatewire","Commands","options","Options","TState","TCommands","Helpers","latest","current","storage","undefined","value","core","subscribe","listener","state","getState","isBusy","getBusy","failed","getFailed","connection","failures","NonNullable","Error","commands","CommandsProxy","setContext","contextValue","Record","retryFailed","entry","FailedCommand","retry","discardFailed","discard","StatewireResource","root","disposed","constructor","ClientRoot","getValue","Connection","Promise","dispose","unmount","initialState","optimistic","Optimistic","materialize","Materialize","onStateChange","ctx","changes","Changes","onCommandChange","update","CommandUpdate","onConnectionChange","failureCovered","ConnectionState","status","reason","message","payload","attempt","cause","nextRetryAt","lastError","reconnect","degraded","ConnectionStatus","draft","command","Command","OptimisticContext","ts","seq","clientId","SendFailure","fate","code","predecessorLost","OnSessionLost","pending","ReadonlyArray","method","params","meta","failure","SessionCommand","SessionRecord","nextSeq","ctxNextSeq","verdicts","SessionVerdict","type","load","save","record","lock","fn","T","HeadersOption","HeadersInit","staleAuth","SSEOptions","sessionStore","url","isNew","headers","fetch","globalThis","onSessionLost","onChange","onDone","WSOptions","protocols","query","webSocket","WebSocket"],"sources":["../src/statewire.ts"],"sourcesContent":["import { useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport {\n createTapRoot,\n flushTapSync,\n resource,\n useContextProvider,\n useResource,\n useResources,\n withKey,\n} from \"@assistant-ui/tap\";\nimport type { ResourceElement } from \"@assistant-ui/tap\";\nimport { CONNECTED_CONNECTION, useStatewireCore } from \"./client/core\";\nimport type { Statewire } from \"./types\";\nimport type { TERMINAL } from \"./client/connection\";\nimport { SessionStorageContext } from \"./client/sessionStore\";\n\nconst useSessionTransport = ({\n transport,\n session,\n}: {\n transport: ResourceElement<Statewire<any, any>>;\n session: StatewireClient.SessionStorage | undefined;\n}): Statewire<unknown> =>\n useContextProvider(SessionStorageContext, session, () =>\n // oxlint-disable-next-line react-hooks/rules-of-hooks\n useResource(transport),\n );\n\n// The transport owns the session; it reads the storage off the context this\n// wrapper's own fiber provides, so plain-React callers get a provider too.\nconst SessionTransport = resource(useSessionTransport);\n\nexport const useStatewire = <\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n>(\n options: StatewireClient.Options<TState, TCommands>,\n): StatewireClient.Helpers<TState, TCommands> => {\n const latest = useRef(options);\n latest.current = options;\n\n const session = useResources(\n options.storage?.session !== undefined\n ? [withKey(\"session\", options.storage.session)]\n : [],\n )[0];\n const value = useResource(\n SessionTransport({ transport: options.transport, session }),\n );\n const core = useStatewireCore(() => latest.current, value);\n const [subscribe] = useState(\n () => (listener: () => void) =>\n core.subscribe(() => flushTapSync(listener)),\n );\n\n const state = useSyncExternalStore(\n subscribe,\n core.getState,\n core.getState,\n ) as TState;\n const isBusy = useSyncExternalStore(subscribe, core.getBusy, core.getBusy);\n const failed = useSyncExternalStore(\n subscribe,\n core.getFailed,\n core.getFailed,\n );\n const connection = value.connection ?? CONNECTED_CONNECTION;\n\n return useMemo(() => {\n const failures = (): NonNullable<typeof value.failures> => {\n if (!value.failures) {\n throw new Error(\"statewire: this transport has no failed-send lane\");\n }\n return value.failures;\n };\n return {\n state,\n getState: core.getState as () => TState,\n connection,\n commands: core.commands as Statewire.CommandsProxy<TCommands>,\n isBusy,\n setContext: (contextValue: Record<string, unknown>) => {\n if (!value.setContext) {\n throw new Error(\n \"statewire: this transport has no client context channel\",\n );\n }\n value.setContext(contextValue);\n },\n failed,\n retryFailed: (entry: StatewireClient.FailedCommand) =>\n failures().retry(entry),\n discardFailed: (entry: StatewireClient.FailedCommand) =>\n failures().discard(entry),\n };\n }, [state, isBusy, failed, connection, core, value]);\n};\n\nexport const StatewireResource = <\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n>(\n options: StatewireClient.Options<TState, TCommands>,\n): ResourceElement<StatewireClient.Helpers<TState, TCommands>> =>\n resource(useStatewire<TState, TCommands>)(options);\n\nexport class StatewireClient<\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n> {\n private readonly root;\n private disposed = false;\n\n constructor(options: StatewireClient.Options<TState, TCommands>) {\n const ClientRoot = () => useStatewire<TState, TCommands>(options);\n this.root = createTapRoot(ClientRoot);\n }\n\n get state(): TState {\n return this.root.getValue().getState();\n }\n\n get connection(): StatewireClient.Connection {\n return this.root.getValue().connection;\n }\n\n get commands(): Statewire.CommandsProxy<TCommands> {\n return this.root.getValue().commands;\n }\n\n get isBusy(): boolean {\n return this.root.getValue().isBusy;\n }\n\n setContext(value: Record<string, unknown>): void {\n this.root.getValue().setContext(value);\n }\n\n get failed(): readonly StatewireClient.FailedCommand[] {\n return this.root.getValue().failed;\n }\n\n retryFailed(entry: StatewireClient.FailedCommand): Promise<unknown> {\n return this.root.getValue().retryFailed(entry);\n }\n\n discardFailed(entry: StatewireClient.FailedCommand): void {\n this.root.getValue().discardFailed(entry);\n }\n\n subscribe(listener: () => void): () => void {\n return this.root.subscribe(listener);\n }\n\n dispose(): void {\n if (this.disposed) return;\n this.disposed = true;\n this.root.unmount();\n }\n}\n\nexport namespace StatewireClient {\n export type Options<\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n > = {\n transport: ResourceElement<Statewire<TState, TCommands>>;\n /** Durable session: the mounted storage holds this client's lane identity and its un-durable commands, which resend on the next attach. */\n storage?:\n | { session?: ResourceElement<SessionStorage> | undefined }\n | undefined;\n /** Served as the canonical state until the attach snapshot; own commands fold over it. */\n initialState?: TState;\n optimistic?: Optimistic<NonNullable<TState>, TCommands>;\n materialize?: Materialize<NonNullable<TState>>;\n onStateChange?: (\n state: NonNullable<TState>,\n ctx: { changes: Statewire.Changes },\n ) => void;\n onCommandChange?: (\n update: Statewire.CommandUpdate<TCommands>,\n ctx: { state: NonNullable<TState> },\n ) => void;\n onConnectionChange?: (connection: Connection) => void;\n /** Drops a retained failed entry once a canonical snapshot shows its effect landed; checked against each new snapshot. */\n failureCovered?: (\n state: NonNullable<TState>,\n entry: FailedCommand,\n ) => boolean;\n };\n\n export type Helpers<\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n > = {\n /** The statewire's state type — remote statewires include `undefined` until the first attach snapshot applies. */\n state: TState;\n /** Identity-stable across renders; reads the current canonical state without subscribing the caller. */\n getState: () => TState;\n connection: Connection;\n commands: Statewire.CommandsProxy<TCommands>;\n /** Negation of the server's broadcast idle level (a transport without idle is always busy). */\n isBusy: boolean;\n /** Replace this client's ambient context on the server; throws on transports without a client context channel. */\n setContext: (value: Record<string, unknown>) => void;\n /** Non-accepted sends retained in the session record; reload with it. */\n failed: readonly FailedCommand[];\n /** Re-enqueues the entry's command on fresh clocks and removes the entry. */\n retryFailed: (entry: FailedCommand) => Promise<unknown>;\n /** Removes the entry. */\n discardFailed: (entry: FailedCommand) => void;\n };\n\n /** `cause`/`nextRetryAt`/`lastError` are present only while waiting out the backoff timer; a dial in flight carries `attempt` alone. */\n export type ConnectionState =\n | {\n status: \"disconnected\";\n reason: \"deferred\" | \"idle\" | \"gone\";\n message?: string;\n payload?: unknown;\n }\n | {\n status: \"connecting\";\n /** 0 = the episode's first dial, >0 = retry. */\n attempt: number;\n cause?: \"dropped\" | \"evicted\" | \"error\" | \"unauthorized\";\n nextRetryAt?: number;\n lastError?: string;\n message?: string;\n }\n | { status: \"connected\" };\n export type Connection = ConnectionState & {\n reconnect: () => void;\n /** True while retrying (`connecting` with `attempt > 0`) or parked `gone`. */\n degraded: boolean;\n };\n export type ConnectionStatus = Connection[\"status\"];\n\n /**\n * Mutate the draft in place; the client records the next state plus the exact\n * changed paths. Re-run against the new base on every rebase.\n */\n export type Optimistic<\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n > = (\n draft: TState,\n command: Statewire.Command<TCommands>,\n ctx: OptimisticContext,\n ) => void;\n\n export type OptimisticContext = {\n ts: number;\n seq: number;\n clientId?: string;\n };\n\n /**\n * Mutate the draft to make server-accepted, still-uncommitted intents in\n * the state concrete (e.g. dispatch records into messages). Runs after the\n * optimistic folds on every published state.\n */\n export type Materialize<TState> = (draft: TState) => void;\n\n /**\n * fate: \"aborted\" = never sent, \"rejected\" = the server refused it\n * (`code` \"unknown-command\" | \"invalid-params\" for the static rejects,\n * code-less for handler rejects), \"lost\" = sent but no verdict will ever\n * arrive — the record is gone (a proven seq gap) or the channel died: it\n * may or may not have run, \"failed\" = the handler ran and threw,\n * \"crashed\" = the handler died involuntarily, \"expired\" = settled —\n * result, rejected, failed, or crashed — but the verdict outlived the\n * server's retention.\n * cause is which channel settled it: \"server\" = the server's answer;\n * the rest are channel deaths the client settled locally.\n */\n export type SendFailure =\n | {\n fate: \"rejected\";\n cause: \"server\";\n code?: string;\n message?: string;\n payload?: unknown;\n }\n | { fate: \"failed\"; cause: \"server\"; message?: string }\n | { fate: \"crashed\"; cause: \"server\" }\n | { fate: \"expired\"; cause: \"server\" }\n | {\n fate: \"aborted\";\n cause: \"unmount\" | \"stream-gone\" | \"session-lost\";\n predecessorLost?: true;\n }\n | {\n fate: \"lost\";\n cause: \"unmount\" | \"stream-gone\" | \"session-lost\";\n };\n\n /** Held commands a proven record gap settled `{fate: \"lost\", cause: \"session-lost\"}`; return \"requeue\" to re-enqueue them on the reset clocks. */\n export type OnSessionLost = (\n pending: ReadonlyArray<{\n method: string;\n params: readonly unknown[];\n ts: number;\n seq: number;\n clientId?: string;\n }>,\n ) => \"requeue\" | \"drop\";\n\n /** A non-accepted send retained for the user: the wire statement, its failure, and its original clocks. */\n export type FailedCommand = {\n readonly command: string;\n readonly params: readonly unknown[];\n readonly meta?: unknown;\n readonly failure: SendFailure;\n readonly ts: number;\n readonly seq: number;\n };\n\n /** A command as persisted while un-durable: its lane seq plus the wire statement. */\n export type SessionCommand = {\n seq: number;\n method: string;\n params: unknown[];\n meta?: unknown;\n };\n\n /** The persisted session: the lane identity, the lane clocks, and the commands the server has not marked durable. */\n export type SessionRecord = {\n clientId: string;\n nextSeq: number;\n ctxNextSeq: number;\n commands: readonly SessionCommand[];\n /** Terminal verdicts parked for the sharer that minted the seq (shared storage only). */\n verdicts?: readonly SessionVerdict[];\n /** Non-accepted sends retained for the user; survive record rebases and resets. */\n failed?: readonly FailedCommand[];\n };\n\n /** A terminal verdict one sharer's connection carried, parked for the sharer awaiting the seq. */\n export type SessionVerdict = {\n seq: number;\n type: (typeof TERMINAL)[number];\n code?: string;\n message?: string;\n payload?: unknown;\n meta?: unknown;\n };\n\n /** Session storage seam; `save` must persist synchronously in the caller's flow. */\n export type SessionStorage = {\n load: () => SessionRecord | null;\n save: (record: SessionRecord) => void;\n /** Serializes record mutations across sharers; presence marks the storage shared. */\n lock?: <T>(fn: () => T | Promise<T>) => Promise<T>;\n /** Notifies of record saves by other sharers; returns unsubscribe. Required alongside `lock`. */\n subscribe?: (listener: () => void) => () => void;\n };\n\n /** Static headers, or a resolver invoked per request/attach; `staleAuth` is true right after an auth rejection. */\n export type HeadersOption =\n | HeadersInit\n | ((ctx: { staleAuth: boolean }) => HeadersInit | Promise<HeadersInit>);\n\n export type SSEOptions = {\n sessionStore?: SessionStorage | undefined;\n url: string;\n /** The session does not exist on the server yet: the first attach waits for the first command send (or an explicit `reconnect()`). */\n isNew?: boolean;\n headers?: HeadersOption;\n fetch?: typeof globalThis.fetch;\n onSessionLost?: OnSessionLost;\n onChange?: (connection: Connection) => void;\n /** The broadcast idle flag's rising edge: the instance went quiet. */\n onDone?: () => void;\n };\n\n export type WSOptions = {\n sessionStore?: SessionStorage | undefined;\n url: string;\n /** The session does not exist on the server yet: the first attach waits for the first command send (or an explicit `reconnect()`). */\n isNew?: boolean;\n headers?: HeadersOption;\n protocols?: string | string[];\n query?:\n | Record<string, string>\n | (() => Record<string, string> | Promise<Record<string, string>>);\n webSocket?: new (url: string, protocols?: string | string[]) => WebSocket;\n onSessionLost?: OnSessionLost;\n onChange?: (connection: Connection) => void;\n /** The broadcast idle flag's rising edge: the instance went quiet. */\n onDone?: () => void;\n };\n}\n"],"mappings":"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"}
|
package/dist/transports/sse.d.ts
CHANGED
|
@@ -49,6 +49,8 @@ type ConnectionState = StatewireClient.ConnectionState;
|
|
|
49
49
|
/** The live wire a remote session drives: connection status plus frame submission. */
|
|
50
50
|
type RemoteLink = {
|
|
51
51
|
connection: StatewireClient.Connection;
|
|
52
|
+
/** A deferred first attach still waiting; `reconnect()` releases it. */
|
|
53
|
+
parked: boolean;
|
|
52
54
|
send: (frame: StatewireFrame) => Promise<StatewireSubmitResult>;
|
|
53
55
|
};
|
|
54
56
|
type AuthState = {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sse.d.ts","names":[],"sources":["../../src/transports/sse.ts"],"mappings":";;;;;;;KAwBY;EACV;IAAQ;IAAgB;;EACxB;IAAQ;IAAgB;IAAmB;IAAgB;;;KAGjD;EACV;;EAEA,uBAAuB;EACvB,UAAU;IAAQ;IAAgB,KAAK;;EACvC,QAAQ;IAAQ;IAAgB,MAAM;;EACtC,SAAS,MAAM;EACf,YAAY,KAAK;EACjB,SAAS;EACT;EACA,WAAW,MAAM,cAAc;;KAGrB;EACN;;EACA;EAAiB,KAAK;;EACtB;;EACA;;KAEM,kBAAkB,gBAAgB;;KAGlC;EACV,YAAY,gBAAgB;
|
|
1
|
+
{"version":3,"file":"sse.d.ts","names":[],"sources":["../../src/transports/sse.ts"],"mappings":";;;;;;;KAwBY;EACV;IAAQ;IAAgB;;EACxB;IAAQ;IAAgB;IAAmB;IAAgB;;;KAGjD;EACV;;EAEA,uBAAuB;EACvB,UAAU;IAAQ;IAAgB,KAAK;;EACvC,QAAQ;IAAQ;IAAgB,MAAM;;EACtC,SAAS,MAAM;EACf,YAAY,KAAK;EACjB,SAAS;EACT;EACA,WAAW,MAAM,cAAc;;KAGrB;EACN;;EACA;EAAiB,KAAK;;EACtB;;EACA;;KAEM,kBAAkB,gBAAgB;;KAGlC;EACV,YAAY,gBAAgB;;EAE5B;EACA,OAAO,OAAO,mBAAmB,QAAQ;;KAQ/B;EAAc;;cAyOb,eACX,cACA,kBAAkB,UAAU,WAAW,UAAU,UAEjD,SAAS,gBAAgB,eACtB,gBAAgB,UAAU,oBAAoB"}
|
package/dist/transports/sse.js
CHANGED
|
@@ -142,6 +142,7 @@ const useSseLink = (options, ctx) => {
|
|
|
142
142
|
const [auth] = useState(() => ({ stale: false }));
|
|
143
143
|
const c_0 = useStatewireConn({
|
|
144
144
|
url: options.url,
|
|
145
|
+
isNew: options.isNew,
|
|
145
146
|
onChange: options.onChange,
|
|
146
147
|
ctx,
|
|
147
148
|
attemptOnce: (c, abort) => attemptStream(c, options, auth, abort)
|
|
@@ -150,6 +151,9 @@ const useSseLink = (options, ctx) => {
|
|
|
150
151
|
get connection() {
|
|
151
152
|
return c_0.connection;
|
|
152
153
|
},
|
|
154
|
+
get parked() {
|
|
155
|
+
return c_0.parked;
|
|
156
|
+
},
|
|
153
157
|
send: (frame) => sendFrame(c_0, latest.current, auth, frame)
|
|
154
158
|
}));
|
|
155
159
|
return link;
|
|
@@ -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/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;
|
|
@@ -107,11 +108,35 @@ export type Conn = {
|
|
|
107
108
|
reconnect: () => void;
|
|
108
109
|
};
|
|
109
110
|
|
|
111
|
+
export const degradedOf = (state: ConnectionState): boolean =>
|
|
112
|
+
(state.status === "connecting" && state.attempt > 0) ||
|
|
113
|
+
(state.status === "disconnected" && state.reason === "gone");
|
|
114
|
+
|
|
115
|
+
const sameState = (a: ConnectionState, b: ConnectionState): boolean => {
|
|
116
|
+
if (a.status !== b.status) return false;
|
|
117
|
+
if (a.status === "connecting" && b.status === "connecting") {
|
|
118
|
+
return (
|
|
119
|
+
a.attempt === b.attempt &&
|
|
120
|
+
a.cause === b.cause &&
|
|
121
|
+
a.nextRetryAt === b.nextRetryAt &&
|
|
122
|
+
a.lastError === b.lastError
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (a.status === "disconnected" && b.status === "disconnected") {
|
|
126
|
+
return a.reason === b.reason;
|
|
127
|
+
}
|
|
128
|
+
return true;
|
|
129
|
+
};
|
|
130
|
+
|
|
110
131
|
const setStatus = (c: Conn, next: ConnectionState) => {
|
|
111
|
-
|
|
112
|
-
c.connection = {
|
|
132
|
+
if (sameState(next, c.connection)) return;
|
|
133
|
+
c.connection = {
|
|
134
|
+
...next,
|
|
135
|
+
reconnect: c.reconnect,
|
|
136
|
+
degraded: degradedOf(next),
|
|
137
|
+
};
|
|
113
138
|
c.notify(c.connection);
|
|
114
|
-
|
|
139
|
+
c.onChange(c.connection);
|
|
115
140
|
};
|
|
116
141
|
|
|
117
142
|
const protocolError = (message: string): never => {
|
|
@@ -273,7 +298,7 @@ const handleHello = (c: Conn, env: StatewireEnvelope) => {
|
|
|
273
298
|
c.liveAt = Date.now();
|
|
274
299
|
c.authDenials = 0;
|
|
275
300
|
c.episode = null;
|
|
276
|
-
setStatus(c, { status: "
|
|
301
|
+
setStatus(c, { status: "connected" });
|
|
277
302
|
};
|
|
278
303
|
|
|
279
304
|
let warnedUnknownSynFact = false;
|
|
@@ -348,7 +373,7 @@ const settleFin = (c: Conn, fin: StatewireHost.Fin): AttemptOutcome => {
|
|
|
348
373
|
c.ctx.onIdle(true);
|
|
349
374
|
c.attempt = 0;
|
|
350
375
|
c.episode = null;
|
|
351
|
-
setStatus(c, { status: "idle" });
|
|
376
|
+
setStatus(c, { status: "disconnected", reason: "idle" });
|
|
352
377
|
return "idle";
|
|
353
378
|
}
|
|
354
379
|
// An auth denial gets one immediate redial; a consecutive repeat parks as gone.
|
|
@@ -365,7 +390,8 @@ const settleFin = (c: Conn, fin: StatewireHost.Fin): AttemptOutcome => {
|
|
|
365
390
|
return "reattach";
|
|
366
391
|
}
|
|
367
392
|
setStatus(c, {
|
|
368
|
-
status: "
|
|
393
|
+
status: "disconnected",
|
|
394
|
+
reason: "gone",
|
|
369
395
|
...(finish.message !== undefined && { message: finish.message }),
|
|
370
396
|
...(finish.payload !== undefined && { payload: finish.payload }),
|
|
371
397
|
});
|
|
@@ -384,14 +410,17 @@ export const handleEnvelope = (
|
|
|
384
410
|
}
|
|
385
411
|
deliverEnvelope(c, env);
|
|
386
412
|
if (env.fin !== undefined) return settleFin(c, env.fin);
|
|
387
|
-
if (c.connection.status !== "
|
|
413
|
+
if (c.connection.status !== "connected") {
|
|
388
414
|
c.attempt = 0;
|
|
389
415
|
c.episode = null;
|
|
390
|
-
setStatus(c, { status: "
|
|
416
|
+
setStatus(c, { status: "connected" });
|
|
391
417
|
}
|
|
392
418
|
return null;
|
|
393
419
|
};
|
|
394
420
|
|
|
421
|
+
const dialing = (c: Conn) =>
|
|
422
|
+
setStatus(c, { status: "connecting", attempt: c.attempt });
|
|
423
|
+
|
|
395
424
|
const scheduleRetry = (
|
|
396
425
|
c: Conn,
|
|
397
426
|
failure: string | undefined,
|
|
@@ -403,11 +432,12 @@ const scheduleRetry = (
|
|
|
403
432
|
c.liveAt = null;
|
|
404
433
|
c.attempt += 1;
|
|
405
434
|
c.episode ??= { cause: "dropped" };
|
|
406
|
-
|
|
435
|
+
if (immediate) return 0;
|
|
436
|
+
const delay = backoff(c.attempt);
|
|
407
437
|
setStatus(c, {
|
|
408
|
-
status: "
|
|
409
|
-
...c.episode,
|
|
438
|
+
status: "connecting",
|
|
410
439
|
attempt: c.attempt,
|
|
440
|
+
...c.episode,
|
|
411
441
|
nextRetryAt: Date.now() + delay,
|
|
412
442
|
...(failure !== undefined && { lastError: failure }),
|
|
413
443
|
});
|
|
@@ -416,13 +446,12 @@ const scheduleRetry = (
|
|
|
416
446
|
|
|
417
447
|
const reconnectConn = (c: Conn) => {
|
|
418
448
|
if (!c.lifetime) return;
|
|
419
|
-
if (c.connection.status === "
|
|
449
|
+
if (c.connection.status === "disconnected") {
|
|
450
|
+
// A reconnect out of a park starts a fresh episode: first dial, attempt 0.
|
|
420
451
|
c.attempt = 0;
|
|
421
452
|
c.authDenials = 0;
|
|
422
|
-
c.episode =
|
|
423
|
-
flushTapSync(() =>
|
|
424
|
-
scheduleRetry(c, undefined, true);
|
|
425
|
-
});
|
|
453
|
+
c.episode = null;
|
|
454
|
+
flushTapSync(() => dialing(c));
|
|
426
455
|
}
|
|
427
456
|
openGate(c.gate);
|
|
428
457
|
c.abort?.abort();
|
|
@@ -430,7 +459,14 @@ const reconnectConn = (c: Conn) => {
|
|
|
430
459
|
|
|
431
460
|
const run = async (c: Conn, lifetime: AbortController, gate: Gate) => {
|
|
432
461
|
const alive = () => c.lifetime === lifetime;
|
|
462
|
+
if (c.parked) {
|
|
463
|
+
await waitGate(gate);
|
|
464
|
+
if (!alive()) return;
|
|
465
|
+
c.parked = false;
|
|
466
|
+
}
|
|
433
467
|
while (alive()) {
|
|
468
|
+
// The dial is in flight: attempt alone, no backoff fields.
|
|
469
|
+
flushTapSync(() => dialing(c));
|
|
434
470
|
const abort = new AbortController();
|
|
435
471
|
c.abort = abort;
|
|
436
472
|
c.awaitingHello = true;
|
|
@@ -465,6 +501,7 @@ const run = async (c: Conn, lifetime: AbortController, gate: Gate) => {
|
|
|
465
501
|
|
|
466
502
|
export const useStatewireConn = (props: {
|
|
467
503
|
url: string;
|
|
504
|
+
isNew?: boolean | undefined;
|
|
468
505
|
onChange?: ((connection: StatewireClient.Connection) => void) | undefined;
|
|
469
506
|
ctx: StatewireRemoteProps;
|
|
470
507
|
attemptOnce: (c: Conn, abort: AbortController) => Promise<AttemptOutcome>;
|
|
@@ -474,6 +511,7 @@ export const useStatewireConn = (props: {
|
|
|
474
511
|
const [, setConnection] = useState<StatewireClient.Connection | null>(null);
|
|
475
512
|
const [c] = useState((): Conn => {
|
|
476
513
|
const conn: Conn = {
|
|
514
|
+
parked: props.isNew ?? false,
|
|
477
515
|
hasSnapshot: false,
|
|
478
516
|
lease: null,
|
|
479
517
|
attempt: 0,
|
|
@@ -492,7 +530,13 @@ export const useStatewireConn = (props: {
|
|
|
492
530
|
attemptOnce: (abort) => latest.current.attemptOnce(conn, abort),
|
|
493
531
|
notify: setConnection,
|
|
494
532
|
reconnect: () => reconnectConn(conn),
|
|
495
|
-
connection: {
|
|
533
|
+
connection: {
|
|
534
|
+
...((props.isNew ?? false)
|
|
535
|
+
? { status: "disconnected", reason: "deferred" }
|
|
536
|
+
: { status: "connecting", attempt: 0 }),
|
|
537
|
+
degraded: false,
|
|
538
|
+
reconnect: () => conn.reconnect(),
|
|
539
|
+
},
|
|
496
540
|
};
|
|
497
541
|
return conn;
|
|
498
542
|
});
|
|
@@ -504,9 +548,12 @@ export const useStatewireConn = (props: {
|
|
|
504
548
|
c.authDenials = 0;
|
|
505
549
|
c.liveAt = null;
|
|
506
550
|
c.episode = null;
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
551
|
+
setStatus(
|
|
552
|
+
c,
|
|
553
|
+
c.parked
|
|
554
|
+
? { status: "disconnected", reason: "deferred" }
|
|
555
|
+
: { status: "connecting", attempt: 0 },
|
|
556
|
+
);
|
|
510
557
|
const lifetime = new AbortController();
|
|
511
558
|
c.lifetime = lifetime;
|
|
512
559
|
c.gate = newGate();
|
package/src/client/core.ts
CHANGED
|
@@ -10,8 +10,9 @@ import { StatewireSendError } from "../types";
|
|
|
10
10
|
import type { Statewire } from "../types";
|
|
11
11
|
import type { StatewireClient } from "../statewire";
|
|
12
12
|
|
|
13
|
-
export const
|
|
14
|
-
status: "
|
|
13
|
+
export const CONNECTED_CONNECTION: StatewireClient.Connection = {
|
|
14
|
+
status: "connected",
|
|
15
|
+
degraded: false,
|
|
15
16
|
reconnect: () => {},
|
|
16
17
|
};
|
|
17
18
|
|
|
@@ -160,7 +161,7 @@ const recompute = (c: Core) => {
|
|
|
160
161
|
};
|
|
161
162
|
|
|
162
163
|
const getConnection = (c: Core): StatewireClient.Connection =>
|
|
163
|
-
c.value?.connection ??
|
|
164
|
+
c.value?.connection ?? CONNECTED_CONNECTION;
|
|
164
165
|
|
|
165
166
|
const flush = (c: Core, scheduled = false) => {
|
|
166
167
|
if (!c.value) return;
|
package/src/client/session.ts
CHANGED
|
@@ -712,7 +712,11 @@ const pump = async (s: Session) => {
|
|
|
712
712
|
const transport = s.transport;
|
|
713
713
|
if (buildLanes(s) === null) return;
|
|
714
714
|
if (s.attach !== "attached" || !transport) {
|
|
715
|
-
if (
|
|
715
|
+
if (
|
|
716
|
+
(transport?.connection.status === "disconnected" &&
|
|
717
|
+
transport.connection.reason === "idle") ||
|
|
718
|
+
(transport?.parked && s.pending.length > 0)
|
|
719
|
+
) {
|
|
716
720
|
transport.connection.reconnect();
|
|
717
721
|
}
|
|
718
722
|
await waitForWake(s);
|
|
@@ -854,12 +858,16 @@ const enqueue = (
|
|
|
854
858
|
) => {
|
|
855
859
|
if (!s.shared) {
|
|
856
860
|
enqueueInner(s, command, params, resolve, reject);
|
|
861
|
+
wake(s);
|
|
857
862
|
void pump(s);
|
|
858
863
|
return;
|
|
859
864
|
}
|
|
860
865
|
void transact(s, () =>
|
|
861
866
|
enqueueInner(s, command, params, resolve, reject),
|
|
862
|
-
).then(() =>
|
|
867
|
+
).then(() => {
|
|
868
|
+
wake(s);
|
|
869
|
+
void pump(s);
|
|
870
|
+
});
|
|
863
871
|
};
|
|
864
872
|
|
|
865
873
|
// A stored command may have reached a server: attempted settles it lost,
|
package/src/host/http.ts
CHANGED
|
@@ -16,6 +16,36 @@ const LEASE_HEADER = "Statewire-Lease";
|
|
|
16
16
|
const VERSION_HEADER = "Statewire-Version";
|
|
17
17
|
const VERSION = "1";
|
|
18
18
|
const encoder = new TextEncoder();
|
|
19
|
+
export const MAX_FRAME_BODY_BYTES = 8 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
const readFrameBody = async (request: Request): Promise<string | null> => {
|
|
22
|
+
const declared = request.headers.get("content-length");
|
|
23
|
+
if (declared !== null && Number(declared) > MAX_FRAME_BODY_BYTES) {
|
|
24
|
+
await request.body?.cancel("frame body too large").catch(() => {});
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (request.body === null) return "";
|
|
28
|
+
const reader = request.body.getReader();
|
|
29
|
+
const chunks: Uint8Array[] = [];
|
|
30
|
+
let size = 0;
|
|
31
|
+
for (;;) {
|
|
32
|
+
const { done, value } = await reader.read();
|
|
33
|
+
if (done) break;
|
|
34
|
+
size += value.length;
|
|
35
|
+
if (size > MAX_FRAME_BODY_BYTES) {
|
|
36
|
+
await reader.cancel("frame body too large").catch(() => {});
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
chunks.push(value);
|
|
40
|
+
}
|
|
41
|
+
const body = new Uint8Array(size);
|
|
42
|
+
let offset = 0;
|
|
43
|
+
for (const chunk of chunks) {
|
|
44
|
+
body.set(chunk, offset);
|
|
45
|
+
offset += chunk.length;
|
|
46
|
+
}
|
|
47
|
+
return new TextDecoder().decode(body);
|
|
48
|
+
};
|
|
19
49
|
|
|
20
50
|
const invalidClientIdReason = (clientId: string): string | null => {
|
|
21
51
|
if (clientId === "") return "id must be non-empty";
|
|
@@ -176,9 +206,13 @@ const postStream = async (
|
|
|
176
206
|
): Promise<Response> => {
|
|
177
207
|
const client = clientIdResponse(request);
|
|
178
208
|
if ("response" in client) return client.response;
|
|
209
|
+
const raw = await readFrameBody(request);
|
|
210
|
+
if (raw === null) {
|
|
211
|
+
return Response.json({ detail: "frame body too large" }, { status: 413 });
|
|
212
|
+
}
|
|
179
213
|
let body: unknown;
|
|
180
214
|
try {
|
|
181
|
-
body = JSON.parse(
|
|
215
|
+
body = JSON.parse(raw);
|
|
182
216
|
} catch {
|
|
183
217
|
return Response.json({ detail: "body must be JSON" }, { status: 400 });
|
|
184
218
|
}
|
|
@@ -264,9 +298,13 @@ export const frames = async (
|
|
|
264
298
|
if (h.disposed || h.finished !== null || rec === undefined || !live) {
|
|
265
299
|
return Response.json({ error: "stale-lease" }, { status: 423 });
|
|
266
300
|
}
|
|
301
|
+
const raw = await readFrameBody(request);
|
|
302
|
+
if (raw === null) {
|
|
303
|
+
return Response.json({ error: "frame body too large" }, { status: 413 });
|
|
304
|
+
}
|
|
267
305
|
let frame: ParsedFrame;
|
|
268
306
|
try {
|
|
269
|
-
frame = parseFrame(
|
|
307
|
+
frame = parseFrame(raw, false);
|
|
270
308
|
} catch (error) {
|
|
271
309
|
if (!(error instanceof Error) || error.name !== "StatewireViolation") {
|
|
272
310
|
throw error;
|