statewire 0.20.0 → 0.21.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/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +18 -4
- package/dist/client/connection.js.map +1 -1
- package/dist/client/session.d.ts +3 -1
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +41 -16
- package/dist/client/session.js.map +1 -1
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js +14 -11
- package/dist/host/host.js.map +1 -1
- package/dist/host/http.d.ts.map +1 -1
- package/dist/host/http.js +13 -8
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +8 -2
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +83 -11
- package/dist/host/socket.js.map +1 -1
- package/dist/host-internal.d.ts +4 -1
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +6 -5
- package/dist/statewire.js.map +1 -1
- package/dist/transports/sse.d.ts +1 -0
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +7 -0
- package/dist/transports/sse.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +16 -4
- package/src/client/session.ts +54 -15
- package/src/host/host.ts +9 -3
- package/src/host/http.ts +37 -5
- package/src/host/socket.ts +111 -6
- package/src/host-internal.ts +4 -0
- package/src/statewire.ts +11 -6
- package/src/transports/sse.ts +12 -0
package/dist/statewire.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"statewire.js","names":["StatewireDocuments","StatewireProtocol","StatewireHost","useCallback","useEffect","useMemo","useRef","useState","useSyncExternalStore","createTapRoot","flushTapSync","resource","useContextProvider","useResource","useResources","withKey","ResourceElement","CONNECTED_CONNECTION","useStatewireCore","Statewire","TERMINAL","DocumentStorageContext","LinkContext","ProtocolContext","SessionStorageContext","useSessionTransport","transport","session","documents","protocol","link","StatewireClient","SessionStorage","Storage","Options","isNew","context","Readonly","Record","Context","SessionTransport","useStatewire","Commands","options","TState","TCommands","Helpers","latest","current","storage","undefined","wireVersion","applicationProtocols","keyPrefixes","value","localContext","connection","setContext","Object","entries","core","rematerialize","materializeKey","subscribe","listener","state","getState","isBusy","getBusy","failed","getFailed","source","selection","invoke","transportInvoke","transportSetContext","failures","transportFailures","method","params","Error","NonNullable","commands","CommandsProxy","retryFailed","entry","FailedCommand","retry","discardFailed","discard","StatewireResource","root","disposed","constructor","ClientRoot","getValue","Connection","Source","Selection","Promise","dispose","unmount","Range","Offer","initialState","optimistic","Optimistic","materialize","Materialize","onStateChange","ctx","changes","Changes","onCommandChange","update","CommandUpdate","onConnectionChange","onError","error","ConnectionError","failureCovered","ConnectionState","status","reason","degraded","attempt","cause","code","nextRetryAt","lastError","message","payload","reconnect","ConnectionStatus","draft","command","Command","OptimisticContext","ts","seq","clientId","SendFailure","fate","predecessorLost","OnSessionLost","pending","ReadonlyArray","produced","SessionCommand","meta","failure","wire","protocols","SessionLane","nextSeq","verdicts","SessionVerdict","SessionRecord","lanes","type","ready","flush","load","save","record","migrate","lock","fn","T","fin","Fin","HeadersOption","HeadersInit","staleAuth","HttpOptions","sessionStore","url","headers","fetch","globalThis","onSessionLost","onChange","onDone","WebsocketOptions","query","webSocket","WebSocket"],"sources":["../src/statewire.ts"],"sourcesContent":["import type { StatewireDocuments } from \"./wire/documents\";\nimport type { StatewireProtocol } from \"./wire/protocol\";\nimport type { StatewireHost } from \"./host\";\nimport {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n useSyncExternalStore,\n} 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 {\n DocumentStorageContext,\n LinkContext,\n ProtocolContext,\n SessionStorageContext,\n} from \"./client/sessionStore\";\n\nconst useSessionTransport = ({\n transport,\n session,\n documents,\n protocol,\n link,\n}: {\n transport: ResourceElement<Statewire<any, any>>;\n session: StatewireClient.SessionStorage | undefined;\n documents: StatewireDocuments.Storage | undefined;\n protocol: StatewireProtocol.Options;\n link: {\n isNew?: boolean | undefined;\n context?: Readonly<Record<string, Statewire.Context>> | undefined;\n };\n}): Statewire<unknown> =>\n useContextProvider(SessionStorageContext, session, () =>\n // oxlint-disable-next-line react-hooks/rules-of-hooks\n useContextProvider(DocumentStorageContext, documents, () =>\n // oxlint-disable-next-line react-hooks/rules-of-hooks\n useContextProvider(ProtocolContext, protocol, () =>\n // oxlint-disable-next-line react-hooks/rules-of-hooks\n useContextProvider(LinkContext, link, () =>\n // oxlint-disable-next-line react-hooks/rules-of-hooks\n useResource(transport),\n ),\n ),\n ),\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 documents = useResources(\n options.storage?.documents === undefined\n ? []\n : [withKey(\"documents\", options.storage.documents)],\n )[0];\n const protocol: StatewireProtocol.Options = {\n ...(options.protocol !== undefined && { protocol: options.protocol }),\n ...(options.wireVersion !== undefined && {\n wireVersion: options.wireVersion,\n }),\n ...(options.applicationProtocols !== undefined && {\n applicationProtocols: options.applicationProtocols,\n }),\n ...(options.keyPrefixes !== undefined && {\n keyPrefixes: options.keyPrefixes,\n }),\n };\n const link = useMemo(\n () => ({\n ...(options.isNew === undefined ? {} : { isNew: options.isNew }),\n ...(options.context === undefined ? {} : { context: options.context }),\n }),\n [options.isNew, options.context],\n );\n const value = useResource(\n SessionTransport({\n transport: options.transport,\n session,\n documents,\n protocol,\n link,\n }),\n );\n const localContext =\n value.connection === undefined ? value.setContext : undefined;\n useEffect(() => {\n const current = latest.current;\n if (localContext === undefined || current.context === undefined) return;\n for (const [protocol, context] of Object.entries(current.context))\n localContext(protocol, context);\n }, [options.context, localContext]);\n const core = useStatewireCore(() => latest.current, value);\n useEffect(() => core.rematerialize(), [core, options.materializeKey]);\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 const {\n documents: source,\n selection,\n invoke: transportInvoke,\n setContext: transportSetContext,\n failures: transportFailures,\n } = value;\n\n const invoke = useCallback(\n (protocol: string, method: string, params: readonly unknown[]) => {\n if (!transportInvoke)\n throw new Error(\n \"statewire: transport does not support protocol commands\",\n );\n return transportInvoke(protocol, method, params);\n },\n [transportInvoke],\n );\n\n return useMemo(() => {\n const failures = (): NonNullable<typeof transportFailures> => {\n if (!transportFailures) {\n throw new Error(\"statewire: this transport has no failed-send lane\");\n }\n return transportFailures;\n };\n return {\n state,\n documents: source,\n selection,\n invoke,\n setContext: (protocol: string, value: Statewire.Context) => {\n if (!transportSetContext)\n throw new Error(\"statewire: transport does not support context\");\n transportSetContext(protocol, value);\n },\n getState: core.getState as () => TState,\n connection,\n commands: core.commands as Statewire.CommandsProxy<TCommands>,\n isBusy,\n failed,\n retryFailed: (entry: StatewireClient.FailedCommand) =>\n failures().retry(entry),\n discardFailed: (entry: StatewireClient.FailedCommand) =>\n failures().discard(entry),\n };\n }, [\n state,\n isBusy,\n failed,\n connection,\n core,\n source,\n selection,\n invoke,\n transportFailures,\n transportSetContext,\n ]);\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 documents(): StatewireDocuments.Source | undefined {\n return this.root.getValue().documents;\n }\n get selection(): StatewireProtocol.Selection | undefined {\n return this.root.getValue().selection;\n }\n invoke(\n protocol: string,\n method: string,\n params: readonly unknown[],\n ): Promise<unknown> {\n return this.root.getValue().invoke(protocol, method, params);\n }\n setContext(protocol: string, value: Statewire.Context): void {\n this.root.getValue().setContext(protocol, value);\n }\n get isBusy(): boolean {\n return this.root.getValue().isBusy;\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 protocol?: string;\n context?: Readonly<Record<string, Statewire.Context>>;\n keyPrefixes?: Readonly<Record<string, readonly string[]>>;\n wireVersion?: StatewireProtocol.Range;\n applicationProtocols?: readonly StatewireProtocol.Offer[] | \"*\";\n transport: ResourceElement<Statewire<TState, TCommands>>;\n /** The session does not exist on the server yet: the transport's first attach waits for the first command send; the transport's own `isNew` wins. */\n isNew?: boolean;\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 | {\n session?: ResourceElement<SessionStorage> | undefined;\n documents?: ResourceElement<StatewireDocuments.Storage> | undefined;\n }\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 /** Republishes through `materialize` when it changes (`Object.is`); `materialize` itself is read fresh on every publish. */\n materializeKey?: unknown;\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 onError?: (error: ConnectionError) => 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 documents: StatewireDocuments.Source | undefined;\n selection: StatewireProtocol.Selection | undefined;\n setContext: (protocol: string, value: Statewire.Context) => void;\n invoke: (\n protocol: string,\n method: string,\n params: readonly unknown[],\n ) => Promise<unknown>;\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 /** 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 /** Connection lifecycle and whether its current state warrants a problem indicator. */\n export type ConnectionState =\n | { status: \"standby\"; reason: \"deferred\" | \"idle\"; degraded: false }\n | { status: \"connecting\"; degraded: false }\n | { status: \"connected\"; degraded: false }\n | {\n status: \"retrying\";\n degraded: true;\n attempt: number;\n /** Backoff details are absent while the retry dial is in flight. */\n cause?: \"dropped\" | \"reconnect\" | \"error\" | \"unauthorized\";\n code?: string;\n nextRetryAt?: number;\n lastError?: string;\n message?: string;\n }\n | {\n status: \"stopped\";\n degraded: true;\n reason:\n | \"gone\"\n | \"forbidden\"\n | \"protocol-error\"\n | \"unauthorized\"\n | \"storage-error\";\n message?: string;\n payload?: unknown;\n };\n export type Connection = ConnectionState & {\n error?: ConnectionError;\n reconnect: () => void;\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 fold client-known facts into the state: server-accepted,\n * still-uncommitted intents (e.g. dispatch records into messages) or the live\n * state of a sibling statewire. Runs after the optimistic folds on every\n * published state; `materializeKey` republishes when the facts change.\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 protocol: string;\n readonly produced?: SessionCommand[\"produced\"];\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 produced?: {\n wire: StatewireProtocol.Range;\n protocols: readonly StatewireProtocol.Offer[] | \"*\";\n };\n method: string;\n params: unknown[];\n meta?: unknown;\n };\n\n /** A durable lane: its cmd clock and the commands the server has not marked durable. */\n export type SessionLane = {\n nextSeq: 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 /** The persisted session: the client identity plus one lane per durable protocol. */\n export type SessionRecord = {\n clientId: string;\n lanes: Readonly<Record<string, SessionLane>>;\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 with optional asynchronous hydration and persistence. */\n export type SessionStorage = {\n /** Resolves when a synchronous load can return the hydrated record. */\n ready?: Promise<void>;\n /** Resolves after all queued saves are persisted; awaited before sending statements. */\n flush?: () => void | Promise<void>;\n load: () => SessionRecord | null;\n save: (record: SessionRecord) => void;\n /** Upgrade incompatible stored commands; null discards the old record and starts a new client identity. */\n migrate?: (\n record: SessionRecord,\n current: NonNullable<SessionCommand[\"produced\"]>,\n ) => SessionRecord | null;\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 ConnectionError = Error & { fin?: StatewireHost.Fin };\n\n export type HeadersOption =\n | HeadersInit\n | ((ctx: { staleAuth: boolean }) => HeadersInit | Promise<HeadersInit>);\n\n export type HttpOptions = StatewireProtocol.Options & {\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 onError?: (error: ConnectionError) => void;\n /** The broadcast idle flag's rising edge: the instance went quiet. */\n onDone?: () => void;\n };\n\n export type WebsocketOptions = StatewireProtocol.Options & {\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 onError?: (error: ConnectionError) => 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":["StatewireDocuments","StatewireProtocol","StatewireHost","useCallback","useEffect","useMemo","useRef","useState","useSyncExternalStore","createTapRoot","flushTapSync","resource","useContextProvider","useResource","useResources","withKey","ResourceElement","CONNECTED_CONNECTION","useStatewireCore","Statewire","TERMINAL","DocumentStorageContext","LinkContext","ProtocolContext","SessionStorageContext","useSessionTransport","transport","session","documents","protocol","link","StatewireClient","SessionStorage","Storage","Options","isNew","context","Readonly","Record","Context","SessionTransport","useStatewire","Commands","options","TState","TCommands","Helpers","latest","current","storage","undefined","wireVersion","applicationProtocols","keyPrefixes","value","localContext","connection","setContext","Object","entries","core","rematerialize","materializeKey","subscribe","listener","state","getState","isBusy","getBusy","failed","getFailed","source","selection","invoke","transportInvoke","transportSetContext","failures","transportFailures","method","params","Error","NonNullable","commands","CommandsProxy","retryFailed","entry","FailedCommand","retry","discardFailed","discard","StatewireResource","root","disposed","constructor","ClientRoot","getValue","Connection","Source","Selection","Promise","dispose","unmount","Range","Offer","initialState","optimistic","Optimistic","materialize","Materialize","onStateChange","ctx","changes","Changes","onCommandChange","update","CommandUpdate","onConnectionChange","onError","error","ConnectionError","failureCovered","ConnectionState","status","reason","degraded","attempt","cause","code","nextRetryAt","lastError","message","payload","reconnect","ConnectionStatus","draft","command","Command","OptimisticContext","ts","seq","clientId","SendFailure","fate","predecessorLost","OnSessionLost","pending","ReadonlyArray","produced","SessionCommand","meta","failure","wire","protocols","SessionLane","nextSeq","verdicts","SessionVerdict","SessionRecord","lanes","type","ready","flush","load","save","record","migrate","lock","fn","T","fin","Fin","HeadersOption","HeadersInit","staleAuth","HttpOptions","sessionStore","url","headers","fetch","globalThis","onSessionLost","onChange","onDone","WebsocketOptions","query","webSocket","WebSocket"],"sources":["../src/statewire.ts"],"sourcesContent":["import type { StatewireDocuments } from \"./wire/documents\";\nimport type { StatewireProtocol } from \"./wire/protocol\";\nimport type { StatewireHost } from \"./host\";\nimport {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n useSyncExternalStore,\n} 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 {\n DocumentStorageContext,\n LinkContext,\n ProtocolContext,\n SessionStorageContext,\n} from \"./client/sessionStore\";\n\nconst useSessionTransport = ({\n transport,\n session,\n documents,\n protocol,\n link,\n}: {\n transport: ResourceElement<Statewire<any, any>>;\n session: StatewireClient.SessionStorage | undefined;\n documents: StatewireDocuments.Storage | undefined;\n protocol: StatewireProtocol.Options;\n link: {\n isNew?: boolean | undefined;\n context?: Readonly<Record<string, Statewire.Context>> | undefined;\n };\n}): Statewire<unknown> =>\n useContextProvider(SessionStorageContext, session, () =>\n // oxlint-disable-next-line react-hooks/rules-of-hooks\n useContextProvider(DocumentStorageContext, documents, () =>\n // oxlint-disable-next-line react-hooks/rules-of-hooks\n useContextProvider(ProtocolContext, protocol, () =>\n // oxlint-disable-next-line react-hooks/rules-of-hooks\n useContextProvider(LinkContext, link, () =>\n // oxlint-disable-next-line react-hooks/rules-of-hooks\n useResource(transport),\n ),\n ),\n ),\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 documents = useResources(\n options.storage?.documents === undefined\n ? []\n : [withKey(\"documents\", options.storage.documents)],\n )[0];\n const protocol: StatewireProtocol.Options = {\n ...(options.protocol !== undefined && { protocol: options.protocol }),\n ...(options.wireVersion !== undefined && {\n wireVersion: options.wireVersion,\n }),\n ...(options.applicationProtocols !== undefined && {\n applicationProtocols: options.applicationProtocols,\n }),\n ...(options.keyPrefixes !== undefined && {\n keyPrefixes: options.keyPrefixes,\n }),\n };\n const link = useMemo(\n () => ({\n ...(options.isNew === undefined ? {} : { isNew: options.isNew }),\n ...(options.context === undefined ? {} : { context: options.context }),\n }),\n [options.isNew, options.context],\n );\n const value = useResource(\n SessionTransport({\n transport: options.transport,\n session,\n documents,\n protocol,\n link,\n }),\n );\n const localContext =\n value.connection === undefined ? value.setContext : undefined;\n useEffect(() => {\n const current = latest.current;\n if (localContext === undefined || current.context === undefined) return;\n for (const [protocol, context] of Object.entries(current.context))\n localContext(protocol, context);\n }, [options.context, localContext]);\n const core = useStatewireCore(() => latest.current, value);\n useEffect(() => core.rematerialize(), [core, options.materializeKey]);\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 const {\n documents: source,\n selection,\n invoke: transportInvoke,\n setContext: transportSetContext,\n failures: transportFailures,\n } = value;\n\n const invoke = useCallback(\n (protocol: string, method: string, params: readonly unknown[]) => {\n if (!transportInvoke)\n throw new Error(\n \"statewire: transport does not support protocol commands\",\n );\n return transportInvoke(protocol, method, params);\n },\n [transportInvoke],\n );\n\n const setContext = useCallback(\n (protocol: string, value: Statewire.Context) => {\n if (!transportSetContext)\n throw new Error(\"statewire: transport does not support context\");\n transportSetContext(protocol, value);\n },\n [transportSetContext],\n );\n\n return useMemo(() => {\n const failures = (): NonNullable<typeof transportFailures> => {\n if (!transportFailures) {\n throw new Error(\"statewire: this transport has no failed-send lane\");\n }\n return transportFailures;\n };\n return {\n state,\n documents: source,\n selection,\n invoke,\n setContext,\n getState: core.getState as () => TState,\n connection,\n commands: core.commands as Statewire.CommandsProxy<TCommands>,\n isBusy,\n failed,\n retryFailed: (entry: StatewireClient.FailedCommand) =>\n failures().retry(entry),\n discardFailed: (entry: StatewireClient.FailedCommand) =>\n failures().discard(entry),\n };\n }, [\n state,\n isBusy,\n failed,\n connection,\n core,\n source,\n selection,\n invoke,\n transportFailures,\n setContext,\n ]);\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 documents(): StatewireDocuments.Source | undefined {\n return this.root.getValue().documents;\n }\n get selection(): StatewireProtocol.Selection | undefined {\n return this.root.getValue().selection;\n }\n invoke(\n protocol: string,\n method: string,\n params: readonly unknown[],\n ): Promise<unknown> {\n return this.root.getValue().invoke(protocol, method, params);\n }\n setContext(protocol: string, value: Statewire.Context): void {\n this.root.getValue().setContext(protocol, value);\n }\n get isBusy(): boolean {\n return this.root.getValue().isBusy;\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 protocol?: string;\n context?: Readonly<Record<string, Statewire.Context>>;\n keyPrefixes?: Readonly<Record<string, readonly string[]>>;\n wireVersion?: StatewireProtocol.Range;\n applicationProtocols?: readonly StatewireProtocol.Offer[] | \"*\";\n transport: ResourceElement<Statewire<TState, TCommands>>;\n /** The session does not exist on the server yet: the transport's first attach waits for the first command send; the transport's own `isNew` wins. */\n isNew?: boolean;\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 | {\n session?: ResourceElement<SessionStorage> | undefined;\n documents?: ResourceElement<StatewireDocuments.Storage> | undefined;\n }\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 /** Republishes through `materialize` when it changes (`Object.is`); `materialize` itself is read fresh on every publish. */\n materializeKey?: unknown;\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 onError?: (error: ConnectionError) => 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 documents: StatewireDocuments.Source | undefined;\n selection: StatewireProtocol.Selection | undefined;\n setContext: (protocol: string, value: Statewire.Context) => void;\n invoke: (\n protocol: string,\n method: string,\n params: readonly unknown[],\n ) => Promise<unknown>;\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 /** 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 /** Connection lifecycle and whether its current state warrants a problem indicator. */\n export type ConnectionState =\n | { status: \"standby\"; reason: \"deferred\" | \"idle\"; degraded: false }\n | { status: \"connecting\"; degraded: false }\n | { status: \"connected\"; degraded: false }\n | {\n status: \"retrying\";\n degraded: true;\n attempt: number;\n /** Backoff details are absent while the retry dial is in flight. */\n cause?: \"dropped\" | \"reconnect\" | \"error\" | \"unauthorized\";\n code?: string;\n nextRetryAt?: number;\n lastError?: string;\n message?: string;\n }\n | {\n status: \"stopped\";\n degraded: true;\n reason:\n | \"gone\"\n | \"forbidden\"\n | \"protocol-error\"\n | \"unauthorized\"\n | \"storage-error\";\n message?: string;\n payload?: unknown;\n };\n export type Connection = ConnectionState & {\n error?: ConnectionError;\n reconnect: () => void;\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 fold client-known facts into the state: server-accepted,\n * still-uncommitted intents (e.g. dispatch records into messages) or the live\n * state of a sibling statewire. Runs after the optimistic folds on every\n * published state; `materializeKey` republishes when the facts change.\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 protocol: string;\n readonly produced?: SessionCommand[\"produced\"];\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 produced?: {\n wire: StatewireProtocol.Range;\n protocols: readonly StatewireProtocol.Offer[] | \"*\";\n };\n method: string;\n params: unknown[];\n meta?: unknown;\n };\n\n /** A durable lane: its cmd clock and the commands the server has not marked durable. */\n export type SessionLane = {\n nextSeq: 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 /** The persisted session: the client identity plus one lane per durable protocol. */\n export type SessionRecord = {\n clientId: string;\n lanes: Readonly<Record<string, SessionLane>>;\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 with optional asynchronous hydration and persistence. */\n export type SessionStorage = {\n /** Resolves when a synchronous load can return the hydrated record. */\n ready?: Promise<void>;\n /** Resolves after all queued saves are persisted; awaited before sending statements. */\n flush?: () => void | Promise<void>;\n load: () => SessionRecord | null;\n save: (record: SessionRecord) => void;\n /** Upgrade incompatible stored commands; null discards the old record and starts a new client identity. */\n migrate?: (\n record: SessionRecord,\n current: NonNullable<SessionCommand[\"produced\"]>,\n ) => SessionRecord | null;\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 ConnectionError = Error & { fin?: StatewireHost.Fin };\n\n export type HeadersOption =\n | HeadersInit\n | ((ctx: { staleAuth: boolean }) => HeadersInit | Promise<HeadersInit>);\n\n export type HttpOptions = StatewireProtocol.Options & {\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 onError?: (error: ConnectionError) => void;\n /** The broadcast idle flag's rising edge: the instance went quiet. */\n onDone?: () => void;\n };\n\n export type WebsocketOptions = StatewireProtocol.Options & {\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 onError?: (error: ConnectionError) => 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
|
@@ -51,6 +51,7 @@ type ConnectionState = StatewireClient.ConnectionState;
|
|
|
51
51
|
/** The live wire a remote session drives: connection status plus frame submission. */
|
|
52
52
|
type RemoteLink = {
|
|
53
53
|
storageFailed: (error: Error) => void;
|
|
54
|
+
protocolFailed: (error: Error) => void;
|
|
54
55
|
connection: StatewireClient.Connection;
|
|
55
56
|
/** A deferred first attach still waiting; `reconnect()` releases it. */
|
|
56
57
|
parked: boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sse.d.ts","names":[],"sources":["../../src/transports/sse.ts"],"mappings":";;;;;;;;KA2BY;EACV;IAAS;MAAqB,kBAAkB;EAChD;IACE;IACA;IACA;IACA;IACA;;;KAIQ;EACV,4BAA4B;EAC5B;;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,gBAAgB,OAAO;EACvB,YAAY,gBAAgB;;EAE5B;EACA,OAAO,OAAO,mBAAmB,QAAQ;;
|
|
1
|
+
{"version":3,"file":"sse.d.ts","names":[],"sources":["../../src/transports/sse.ts"],"mappings":";;;;;;;;KA2BY;EACV;IAAS;MAAqB,kBAAkB;EAChD;IACE;IACA;IACA;IACA;IACA;;;KAIQ;EACV,4BAA4B;EAC5B;;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,gBAAgB,OAAO;EACvB,iBAAiB,OAAO;EACxB,YAAY,gBAAgB;;EAE5B;EACA,OAAO,OAAO,mBAAmB,QAAQ;;cAoQ9B,gBACX,cACA,kBAAkB,UAAU,WAAW,UAAU,UAEjD,SAAS,gBAAgB,gBACtB,gBAAgB,UAAU,oBAAoB"}
|
package/dist/transports/sse.js
CHANGED
|
@@ -6,6 +6,12 @@ import { useStatewireRemote } from "./remote.js";
|
|
|
6
6
|
import { flushTapSync, resource } from "@assistant-ui/tap";
|
|
7
7
|
//#region src/transports/sse.ts
|
|
8
8
|
const LEASE_HEADER = "Statewire-Lease";
|
|
9
|
+
let warnedNoLease = false;
|
|
10
|
+
const warnNoLease = () => {
|
|
11
|
+
if (warnedNoLease) return;
|
|
12
|
+
warnedNoLease = true;
|
|
13
|
+
console.warn("statewire: SSE attach returned no readable Statewire-Lease header; commands will re-attach the stream. Cross-origin hosts must expose it (Access-Control-Expose-Headers: Statewire-Lease).");
|
|
14
|
+
};
|
|
9
15
|
const baseUrl = (options) => options.url.replace(/\/$/, "");
|
|
10
16
|
const withExtra = (headers, extra) => {
|
|
11
17
|
const merged = new Headers(headers);
|
|
@@ -76,6 +82,7 @@ const attemptStream = async (c, options, abort) => {
|
|
|
76
82
|
}
|
|
77
83
|
if (!res.body) throw new Error("statewire: protocol error: stream response has no body");
|
|
78
84
|
c.lease = res.headers.get(LEASE_HEADER);
|
|
85
|
+
if (c.lease === null) warnNoLease();
|
|
79
86
|
const reader = res.body.getReader();
|
|
80
87
|
const cancel = () => {
|
|
81
88
|
reader.cancel().catch(() => {});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sse.js","names":["StatewireDocument","flushTapSync","resource","ResourceElement","createSseParser","timing","settleFin","classify","handleEnvelope","resolveHeaders","useLink","validateRefusalSyn","AttemptOutcome","Conn","StatewireCmd","StatewireEnvelope","StatewireSyn","Statewire","StatewireClient","StatewireHost","useStatewireRemote","negotiationHeaders","parseNegotiation","StatewireFrame","ctx","protocol","Encoded","cmd","method","params","meta","seq","StatewireRemoteProps","beforeAttach","Promise","clientId","takeAttachLanes","onHello","args","ops","syn","onOps","cmds","onCmds","onRefusal","onIdle","level","onDetach","onFinish","Fin","StatewireSubmitResult","type","ConnectionState","RemoteLink","storageFailed","error","Error","connection","Connection","parked","send","frame","LEASE_HEADER","baseUrl","options","HttpOptions","url","replace","withExtra","headers","Headers","extra","Record","merged","name","value","Object","entries","set","timedFetch","input","init","RequestInit","signal","AbortSignal","timeout","Response","fetch","globalThis","consume","c","reader","ReadableStreamDefaultReader","Uint8Array","heartbeat","AbortController","dead","decoder","TextDecoder","parse","timer","setTimeout","abort","heartbeatTimeoutMs","result","ReadableStreamReadResult","read","clearTimeout","done","data","decode","stream","env","JSON","attemptStream","persistence","aborted","lease","authStale","has","negotiation","lanes","body","undefined","stringify","headerTimer","res","accept","cache","any","ok","clone","json","catch","fin","httpFin","get","getReader","cancel","addEventListener","once","removeEventListener","postFrame","lifetime","retryAfterMs","header","trim","test","retry","Number","isSafeInteger","reason","status","message","code","refusalSyn","sendFrame","headerRetry","useSseLink","attemptOnce","SyntaxError","startsWith","useStatewireHttp","StatewireHttp","Commands","TState","TCommands"],"sources":["../../src/transports/sse.ts"],"sourcesContent":["import type { StatewireDocument } from \"../document\";\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 settleFin,\n classify,\n handleEnvelope,\n resolveHeaders,\n useLink,\n validateRefusalSyn,\n} from \"../client/connection\";\nimport type {\n AttemptOutcome,\n Conn,\n StatewireCmd,\n StatewireEnvelope,\n StatewireSyn,\n} from \"../client/connection\";\nimport type { Statewire } from \"../types\";\nimport type { StatewireClient } from \"../statewire\";\nimport type { StatewireHost } from \"../host\";\nimport { useStatewireRemote } from \"./remote\";\nimport { negotiationHeaders, parseNegotiation } from \"../wire/protocol\";\n\n/** A client frame's lanes: stamped statements, strictly consecutive per lane. */\nexport type StatewireFrame = {\n ctx?: ({ protocol: string } & StatewireDocument.Encoded)[];\n cmd?: {\n protocol: string;\n method: string;\n params: unknown[];\n meta?: unknown;\n seq: number;\n }[];\n};\n\nexport type StatewireRemoteProps = {\n beforeAttach?: () => void | Promise<void>;\n clientId: string;\n /** Lanes to ride the attach frame: ephemeral-lane statements, plus durable cmds on no-lease supersession; records the included entries. */\n takeAttachLanes: () => StatewireFrame | null;\n onHello: (args: { ops: unknown[]; syn: StatewireSyn }) => void;\n onOps: (args: { ops: unknown[]; cmds: StatewireCmd[] }) => void;\n onCmds: (cmds: StatewireCmd[]) => void;\n onRefusal: (syn: StatewireSyn) => void;\n onIdle: (level: boolean) => void;\n onDetach: () => void;\n onFinish: (args: StatewireHost.Fin) => void;\n};\n\nexport type StatewireSubmitResult =\n | { type: \"ok\" }\n | { type: \"refusal\"; syn: StatewireSyn }\n | { type: \"stale-lease\" }\n | { type: \"no-lease\" };\n\nexport type ConnectionState = StatewireClient.ConnectionState;\n\n/** The live wire a remote session drives: connection status plus frame submission. */\nexport type RemoteLink = {\n storageFailed: (error: Error) => void;\n connection: StatewireClient.Connection;\n /** A deferred first attach still waiting; `reconnect()` releases it. */\n parked: boolean;\n send: (frame: StatewireFrame) => Promise<StatewireSubmitResult>;\n};\n\nconst LEASE_HEADER = \"Statewire-Lease\";\n\nconst baseUrl = (options: StatewireClient.HttpOptions) =>\n options.url.replace(/\\/$/, \"\");\n\nconst withExtra = (headers: Headers, extra: Record<string, string>) => {\n const merged = new Headers(headers);\n for (const [name, value] of Object.entries(extra)) merged.set(name, value);\n return merged;\n};\n\nconst timedFetch = async (\n options: StatewireClient.HttpOptions,\n input: string,\n init: RequestInit & { signal: AbortSignal },\n timeout: AbortSignal,\n): Promise<Response> => {\n try {\n return await (options.fetch ?? globalThis.fetch)(input, init);\n } catch {\n throw classify(timeout);\n }\n};\n\nconst consume = async (\n c: Conn,\n reader: ReadableStreamDefaultReader<Uint8Array>,\n heartbeat: AbortController,\n dead: () => boolean,\n): Promise<AttemptOutcome> => {\n const decoder = new TextDecoder();\n const parse = createSseParser();\n for (;;) {\n const timer = setTimeout(\n () => heartbeat.abort(),\n timing.heartbeatTimeoutMs,\n );\n let result: ReadableStreamReadResult<Uint8Array>;\n try {\n result = await reader.read();\n } catch {\n throw classify(heartbeat.signal);\n } finally {\n clearTimeout(timer);\n }\n if (result.done) return \"ended\";\n for (const data of parse(decoder.decode(result.value, { stream: true }))) {\n if (dead()) return \"ended\";\n const env = JSON.parse(data) as StatewireEnvelope;\n const done = flushTapSync(() => handleEnvelope(c, env));\n if (done) return done;\n }\n }\n};\n\nconst attemptStream = async (\n c: Conn,\n options: StatewireClient.HttpOptions,\n abort: AbortController,\n): Promise<AttemptOutcome> => {\n const persistence = c.ctx.beforeAttach?.();\n if (persistence) await persistence;\n if (abort.signal.aborted) return \"ended\";\n c.lease = null;\n const headers = await resolveHeaders(options.headers, c.authStale);\n for (const [name, value] of Object.entries(negotiationHeaders(options))) {\n if (!headers.has(name)) headers.set(name, value);\n }\n c.negotiation = parseNegotiation(headers);\n c.authStale = false;\n const lanes = flushTapSync(() => c.ctx.takeAttachLanes());\n const body = lanes === null ? undefined : JSON.stringify(lanes);\n const heartbeat = new AbortController();\n const headerTimer = setTimeout(\n () => heartbeat.abort(),\n timing.heartbeatTimeoutMs,\n );\n let res: Response;\n try {\n res = await timedFetch(\n options,\n `${baseUrl(options)}/stream`,\n {\n method: body === undefined ? \"GET\" : \"POST\",\n headers: withExtra(headers, {\n \"content-type\": \"application/json\",\n \"Statewire-Client-Id\": c.ctx.clientId,\n accept: \"text/event-stream\",\n }),\n ...(body === undefined ? {} : { body }),\n cache: \"no-store\",\n signal: AbortSignal.any([abort.signal, heartbeat.signal]),\n },\n heartbeat.signal,\n );\n } finally {\n clearTimeout(headerTimer);\n }\n if (!res.ok) {\n const body = await res\n .clone()\n .json()\n .catch(() => null);\n return (\n flushTapSync(() =>\n body?.fin === undefined\n ? settleFin(c, httpFin(res))\n : handleEnvelope(c, body),\n ) ?? \"stop\"\n );\n }\n if (!res.body)\n throw new Error(\"statewire: protocol error: stream response has no body\");\n c.lease = res.headers.get(LEASE_HEADER);\n const reader = res.body.getReader();\n const cancel = () => {\n void reader.cancel().catch(() => {});\n };\n abort.signal.addEventListener(\"abort\", cancel, { once: true });\n try {\n return await consume(c, reader, heartbeat, () => abort.signal.aborted);\n } finally {\n abort.signal.removeEventListener(\"abort\", cancel);\n reader.cancel().catch(() => {});\n }\n};\n\nconst postFrame = async (\n c: Conn,\n options: StatewireClient.HttpOptions,\n lease: string,\n frame: StatewireFrame,\n timeout: AbortSignal,\n): Promise<Response> =>\n timedFetch(\n options,\n `${baseUrl(options)}/frames`,\n {\n method: \"POST\",\n headers: withExtra(new Headers(), {\n \"content-type\": \"application/json\",\n [LEASE_HEADER]: lease,\n }),\n body: JSON.stringify(frame),\n signal: AbortSignal.any([\n c.lifetime?.signal ?? AbortSignal.abort(),\n timeout,\n ]),\n },\n timeout,\n );\n\n// Delta-seconds only; anything else reads as absent.\nconst retryAfterMs = (res: Response): number | null => {\n const header = res.headers.get(\"retry-after\")?.trim();\n if (header === undefined || !/^\\d+$/.test(header)) return null;\n const retry = Number(header) * 1000;\n return Number.isSafeInteger(retry) ? retry : null;\n};\n\nconst httpFin = (res: Response): StatewireHost.Fin => ({\n reason:\n res.status === 401\n ? \"unauthorized\"\n : res.status === 403\n ? \"forbidden\"\n : res.status === 429 || res.status === 503\n ? \"reconnect\"\n : res.status >= 400 && res.status < 500\n ? \"protocol-error\"\n : \"error\",\n status: res.status,\n message: `HTTP ${res.status}`,\n ...(res.status === 429 || res.status === 503 ? { code: \"overloaded\" } : {}),\n ...(retryAfterMs(res) !== null ? { retry: retryAfterMs(res)! } : {}),\n});\n\nconst refusalSyn = async (res: Response): Promise<StatewireSyn> => {\n const body = (await res.json().catch(() => null)) as {\n syn?: StatewireSyn;\n } | null;\n const syn = body?.syn;\n if (typeof syn !== \"object\" || syn === null) {\n throw new Error(`HTTP ${res.status}`);\n }\n return validateRefusalSyn(syn);\n};\n\nconst sendFrame = async (\n c: Conn,\n options: StatewireClient.HttpOptions,\n frame: StatewireFrame,\n): Promise<StatewireSubmitResult> => {\n const lease = c.lease;\n if (lease === null) return { type: \"no-lease\" };\n const timeout = AbortSignal.timeout(timing.heartbeatTimeoutMs);\n const res = await postFrame(c, options, lease, frame, timeout);\n if (res.ok) return { type: \"ok\" };\n if (res.status === 429 || res.status === 409) {\n // The syn body retry wins; Retry-After covers 429s minted below the host.\n const headerRetry = res.status === 429 ? retryAfterMs(res) : null;\n const syn: StatewireSyn = await refusalSyn(res).catch((error: unknown) => {\n if (headerRetry === null) throw error;\n return { retry: headerRetry };\n });\n if (syn.retry === undefined && headerRetry !== null)\n syn.retry = headerRetry;\n return { type: \"refusal\", syn };\n }\n if (res.status === 423) return { type: \"stale-lease\" };\n flushTapSync(() => settleFin(c, httpFin(res)));\n c.abort?.abort();\n throw new Error(`HTTP ${res.status}`);\n};\n\nconst useSseLink = (\n options: StatewireClient.HttpOptions,\n ctx: StatewireRemoteProps,\n): RemoteLink =>\n useLink({\n ...options,\n ctx,\n attemptOnce: (c, abort) => attemptStream(c, options, abort),\n send: async (c, frame) => {\n try {\n return await sendFrame(c, options, frame);\n } catch (error) {\n if (\n error instanceof SyntaxError ||\n (error instanceof Error &&\n error.message.startsWith(\"statewire: protocol error:\"))\n ) {\n flushTapSync(() =>\n settleFin(c, { reason: \"protocol-error\", message: error.message }),\n );\n c.abort?.abort();\n }\n throw error;\n }\n },\n });\n\nconst useStatewireHttp = (\n options: StatewireClient.HttpOptions,\n): Statewire<any, any> => useStatewireRemote(options, useSseLink);\n\nexport const StatewireHttp = resource(useStatewireHttp) as <\n TState = any,\n TCommands extends Statewire.Commands = Statewire.Commands,\n>(\n options: StatewireClient.HttpOptions,\n) => ResourceElement<Statewire<TState | undefined, TCommands>>;\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"sse.js","names":["StatewireDocument","flushTapSync","resource","ResourceElement","createSseParser","timing","settleFin","classify","handleEnvelope","resolveHeaders","useLink","validateRefusalSyn","AttemptOutcome","Conn","StatewireCmd","StatewireEnvelope","StatewireSyn","Statewire","StatewireClient","StatewireHost","useStatewireRemote","negotiationHeaders","parseNegotiation","StatewireFrame","ctx","protocol","Encoded","cmd","method","params","meta","seq","StatewireRemoteProps","beforeAttach","Promise","clientId","takeAttachLanes","onHello","args","ops","syn","onOps","cmds","onCmds","onRefusal","onIdle","level","onDetach","onFinish","Fin","StatewireSubmitResult","type","ConnectionState","RemoteLink","storageFailed","error","Error","protocolFailed","connection","Connection","parked","send","frame","LEASE_HEADER","warnedNoLease","warnNoLease","console","warn","baseUrl","options","HttpOptions","url","replace","withExtra","headers","Headers","extra","Record","merged","name","value","Object","entries","set","timedFetch","input","init","RequestInit","signal","AbortSignal","timeout","Response","fetch","globalThis","consume","c","reader","ReadableStreamDefaultReader","Uint8Array","heartbeat","AbortController","dead","decoder","TextDecoder","parse","timer","setTimeout","abort","heartbeatTimeoutMs","result","ReadableStreamReadResult","read","clearTimeout","done","data","decode","stream","env","JSON","attemptStream","persistence","aborted","lease","authStale","has","negotiation","lanes","body","undefined","stringify","headerTimer","res","accept","cache","any","ok","clone","json","catch","fin","httpFin","get","getReader","cancel","addEventListener","once","removeEventListener","postFrame","lifetime","retryAfterMs","header","trim","test","retry","Number","isSafeInteger","reason","status","message","code","refusalSyn","sendFrame","headerRetry","useSseLink","attemptOnce","SyntaxError","startsWith","useStatewireHttp","StatewireHttp","Commands","TState","TCommands"],"sources":["../../src/transports/sse.ts"],"sourcesContent":["import type { StatewireDocument } from \"../document\";\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 settleFin,\n classify,\n handleEnvelope,\n resolveHeaders,\n useLink,\n validateRefusalSyn,\n} from \"../client/connection\";\nimport type {\n AttemptOutcome,\n Conn,\n StatewireCmd,\n StatewireEnvelope,\n StatewireSyn,\n} from \"../client/connection\";\nimport type { Statewire } from \"../types\";\nimport type { StatewireClient } from \"../statewire\";\nimport type { StatewireHost } from \"../host\";\nimport { useStatewireRemote } from \"./remote\";\nimport { negotiationHeaders, parseNegotiation } from \"../wire/protocol\";\n\n/** A client frame's lanes: stamped statements, strictly consecutive per lane. */\nexport type StatewireFrame = {\n ctx?: ({ protocol: string } & StatewireDocument.Encoded)[];\n cmd?: {\n protocol: string;\n method: string;\n params: unknown[];\n meta?: unknown;\n seq: number;\n }[];\n};\n\nexport type StatewireRemoteProps = {\n beforeAttach?: () => void | Promise<void>;\n clientId: string;\n /** Lanes to ride the attach frame: ephemeral-lane statements, plus durable cmds on no-lease supersession; records the included entries. */\n takeAttachLanes: () => StatewireFrame | null;\n onHello: (args: { ops: unknown[]; syn: StatewireSyn }) => void;\n onOps: (args: { ops: unknown[]; cmds: StatewireCmd[] }) => void;\n onCmds: (cmds: StatewireCmd[]) => void;\n onRefusal: (syn: StatewireSyn) => void;\n onIdle: (level: boolean) => void;\n onDetach: () => void;\n onFinish: (args: StatewireHost.Fin) => void;\n};\n\nexport type StatewireSubmitResult =\n | { type: \"ok\" }\n | { type: \"refusal\"; syn: StatewireSyn }\n | { type: \"stale-lease\" }\n | { type: \"no-lease\" };\n\nexport type ConnectionState = StatewireClient.ConnectionState;\n\n/** The live wire a remote session drives: connection status plus frame submission. */\nexport type RemoteLink = {\n storageFailed: (error: Error) => void;\n protocolFailed: (error: Error) => void;\n connection: StatewireClient.Connection;\n /** A deferred first attach still waiting; `reconnect()` releases it. */\n parked: boolean;\n send: (frame: StatewireFrame) => Promise<StatewireSubmitResult>;\n};\n\nconst LEASE_HEADER = \"Statewire-Lease\";\n\nlet warnedNoLease = false;\n\nconst warnNoLease = () => {\n if (warnedNoLease) return;\n warnedNoLease = true;\n console.warn(\n \"statewire: SSE attach returned no readable Statewire-Lease header; commands will re-attach the stream. Cross-origin hosts must expose it (Access-Control-Expose-Headers: Statewire-Lease).\",\n );\n};\n\nconst baseUrl = (options: StatewireClient.HttpOptions) =>\n options.url.replace(/\\/$/, \"\");\n\nconst withExtra = (headers: Headers, extra: Record<string, string>) => {\n const merged = new Headers(headers);\n for (const [name, value] of Object.entries(extra)) merged.set(name, value);\n return merged;\n};\n\nconst timedFetch = async (\n options: StatewireClient.HttpOptions,\n input: string,\n init: RequestInit & { signal: AbortSignal },\n timeout: AbortSignal,\n): Promise<Response> => {\n try {\n return await (options.fetch ?? globalThis.fetch)(input, init);\n } catch {\n throw classify(timeout);\n }\n};\n\nconst consume = async (\n c: Conn,\n reader: ReadableStreamDefaultReader<Uint8Array>,\n heartbeat: AbortController,\n dead: () => boolean,\n): Promise<AttemptOutcome> => {\n const decoder = new TextDecoder();\n const parse = createSseParser();\n for (;;) {\n const timer = setTimeout(\n () => heartbeat.abort(),\n timing.heartbeatTimeoutMs,\n );\n let result: ReadableStreamReadResult<Uint8Array>;\n try {\n result = await reader.read();\n } catch {\n throw classify(heartbeat.signal);\n } finally {\n clearTimeout(timer);\n }\n if (result.done) return \"ended\";\n for (const data of parse(decoder.decode(result.value, { stream: true }))) {\n if (dead()) return \"ended\";\n const env = JSON.parse(data) as StatewireEnvelope;\n const done = flushTapSync(() => handleEnvelope(c, env));\n if (done) return done;\n }\n }\n};\n\nconst attemptStream = async (\n c: Conn,\n options: StatewireClient.HttpOptions,\n abort: AbortController,\n): Promise<AttemptOutcome> => {\n const persistence = c.ctx.beforeAttach?.();\n if (persistence) await persistence;\n if (abort.signal.aborted) return \"ended\";\n c.lease = null;\n const headers = await resolveHeaders(options.headers, c.authStale);\n for (const [name, value] of Object.entries(negotiationHeaders(options))) {\n if (!headers.has(name)) headers.set(name, value);\n }\n c.negotiation = parseNegotiation(headers);\n c.authStale = false;\n const lanes = flushTapSync(() => c.ctx.takeAttachLanes());\n const body = lanes === null ? undefined : JSON.stringify(lanes);\n const heartbeat = new AbortController();\n const headerTimer = setTimeout(\n () => heartbeat.abort(),\n timing.heartbeatTimeoutMs,\n );\n let res: Response;\n try {\n res = await timedFetch(\n options,\n `${baseUrl(options)}/stream`,\n {\n method: body === undefined ? \"GET\" : \"POST\",\n headers: withExtra(headers, {\n \"content-type\": \"application/json\",\n \"Statewire-Client-Id\": c.ctx.clientId,\n accept: \"text/event-stream\",\n }),\n ...(body === undefined ? {} : { body }),\n cache: \"no-store\",\n signal: AbortSignal.any([abort.signal, heartbeat.signal]),\n },\n heartbeat.signal,\n );\n } finally {\n clearTimeout(headerTimer);\n }\n if (!res.ok) {\n const body = await res\n .clone()\n .json()\n .catch(() => null);\n return (\n flushTapSync(() =>\n body?.fin === undefined\n ? settleFin(c, httpFin(res))\n : handleEnvelope(c, body),\n ) ?? \"stop\"\n );\n }\n if (!res.body)\n throw new Error(\"statewire: protocol error: stream response has no body\");\n c.lease = res.headers.get(LEASE_HEADER);\n if (c.lease === null) warnNoLease();\n const reader = res.body.getReader();\n const cancel = () => {\n void reader.cancel().catch(() => {});\n };\n abort.signal.addEventListener(\"abort\", cancel, { once: true });\n try {\n return await consume(c, reader, heartbeat, () => abort.signal.aborted);\n } finally {\n abort.signal.removeEventListener(\"abort\", cancel);\n reader.cancel().catch(() => {});\n }\n};\n\nconst postFrame = async (\n c: Conn,\n options: StatewireClient.HttpOptions,\n lease: string,\n frame: StatewireFrame,\n timeout: AbortSignal,\n): Promise<Response> =>\n timedFetch(\n options,\n `${baseUrl(options)}/frames`,\n {\n method: \"POST\",\n headers: withExtra(new Headers(), {\n \"content-type\": \"application/json\",\n [LEASE_HEADER]: lease,\n }),\n body: JSON.stringify(frame),\n signal: AbortSignal.any([\n c.lifetime?.signal ?? AbortSignal.abort(),\n timeout,\n ]),\n },\n timeout,\n );\n\n// Delta-seconds only; anything else reads as absent.\nconst retryAfterMs = (res: Response): number | null => {\n const header = res.headers.get(\"retry-after\")?.trim();\n if (header === undefined || !/^\\d+$/.test(header)) return null;\n const retry = Number(header) * 1000;\n return Number.isSafeInteger(retry) ? retry : null;\n};\n\nconst httpFin = (res: Response): StatewireHost.Fin => ({\n reason:\n res.status === 401\n ? \"unauthorized\"\n : res.status === 403\n ? \"forbidden\"\n : res.status === 429 || res.status === 503\n ? \"reconnect\"\n : res.status >= 400 && res.status < 500\n ? \"protocol-error\"\n : \"error\",\n status: res.status,\n message: `HTTP ${res.status}`,\n ...(res.status === 429 || res.status === 503 ? { code: \"overloaded\" } : {}),\n ...(retryAfterMs(res) !== null ? { retry: retryAfterMs(res)! } : {}),\n});\n\nconst refusalSyn = async (res: Response): Promise<StatewireSyn> => {\n const body = (await res.json().catch(() => null)) as {\n syn?: StatewireSyn;\n } | null;\n const syn = body?.syn;\n if (typeof syn !== \"object\" || syn === null) {\n throw new Error(`HTTP ${res.status}`);\n }\n return validateRefusalSyn(syn);\n};\n\nconst sendFrame = async (\n c: Conn,\n options: StatewireClient.HttpOptions,\n frame: StatewireFrame,\n): Promise<StatewireSubmitResult> => {\n const lease = c.lease;\n if (lease === null) return { type: \"no-lease\" };\n const timeout = AbortSignal.timeout(timing.heartbeatTimeoutMs);\n const res = await postFrame(c, options, lease, frame, timeout);\n if (res.ok) return { type: \"ok\" };\n if (res.status === 429 || res.status === 409) {\n // The syn body retry wins; Retry-After covers 429s minted below the host.\n const headerRetry = res.status === 429 ? retryAfterMs(res) : null;\n const syn: StatewireSyn = await refusalSyn(res).catch((error: unknown) => {\n if (headerRetry === null) throw error;\n return { retry: headerRetry };\n });\n if (syn.retry === undefined && headerRetry !== null)\n syn.retry = headerRetry;\n return { type: \"refusal\", syn };\n }\n if (res.status === 423) return { type: \"stale-lease\" };\n flushTapSync(() => settleFin(c, httpFin(res)));\n c.abort?.abort();\n throw new Error(`HTTP ${res.status}`);\n};\n\nconst useSseLink = (\n options: StatewireClient.HttpOptions,\n ctx: StatewireRemoteProps,\n): RemoteLink =>\n useLink({\n ...options,\n ctx,\n attemptOnce: (c, abort) => attemptStream(c, options, abort),\n send: async (c, frame) => {\n try {\n return await sendFrame(c, options, frame);\n } catch (error) {\n if (\n error instanceof SyntaxError ||\n (error instanceof Error &&\n error.message.startsWith(\"statewire: protocol error:\"))\n ) {\n flushTapSync(() =>\n settleFin(c, { reason: \"protocol-error\", message: error.message }),\n );\n c.abort?.abort();\n }\n throw error;\n }\n },\n });\n\nconst useStatewireHttp = (\n options: StatewireClient.HttpOptions,\n): Statewire<any, any> => useStatewireRemote(options, useSseLink);\n\nexport const StatewireHttp = resource(useStatewireHttp) as <\n TState = any,\n TCommands extends Statewire.Commands = Statewire.Commands,\n>(\n options: StatewireClient.HttpOptions,\n) => ResourceElement<Statewire<TState | undefined, TCommands>>;\n"],"mappings":"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"}
|
package/package.json
CHANGED
package/src/client/connection.ts
CHANGED
|
@@ -167,13 +167,21 @@ const setStatus = (c: Conn, next: ConnectionState) => {
|
|
|
167
167
|
c.onChange(c.connection);
|
|
168
168
|
};
|
|
169
169
|
|
|
170
|
-
const
|
|
170
|
+
const stopForError = ({
|
|
171
|
+
connection: c,
|
|
172
|
+
error,
|
|
173
|
+
reason,
|
|
174
|
+
}: {
|
|
175
|
+
connection: Conn;
|
|
176
|
+
error: Error;
|
|
177
|
+
reason: "storage-error" | "protocol-error";
|
|
178
|
+
}) => {
|
|
171
179
|
c.error = error;
|
|
172
180
|
c.onError(error);
|
|
173
181
|
flushTapSync(() =>
|
|
174
182
|
setStatus(c, {
|
|
175
183
|
status: "stopped",
|
|
176
|
-
reason
|
|
184
|
+
reason,
|
|
177
185
|
degraded: true,
|
|
178
186
|
message: error.message,
|
|
179
187
|
}),
|
|
@@ -481,6 +489,7 @@ export const handleEnvelope = (
|
|
|
481
489
|
return null;
|
|
482
490
|
}
|
|
483
491
|
deliverEnvelope(c, env);
|
|
492
|
+
if (c.connection.status === "stopped") return "stop";
|
|
484
493
|
if (env.fin !== undefined) return settleFin(c, env.fin);
|
|
485
494
|
if (c.connection.status !== "connected") {
|
|
486
495
|
c.attempt = 0;
|
|
@@ -568,7 +577,7 @@ const run = async (c: Conn, lifetime: AbortController, gate: Gate) => {
|
|
|
568
577
|
} catch (error) {
|
|
569
578
|
if (!abort.signal.aborted) {
|
|
570
579
|
if (isStorageError(error)) {
|
|
571
|
-
|
|
580
|
+
stopForError({ connection: c, error, reason: "storage-error" });
|
|
572
581
|
outcome = "stop";
|
|
573
582
|
} else if (
|
|
574
583
|
error instanceof SyntaxError ||
|
|
@@ -718,7 +727,10 @@ export const useLink = (
|
|
|
718
727
|
return c.parked;
|
|
719
728
|
},
|
|
720
729
|
send: (frame) => latest.current.send(c, frame),
|
|
721
|
-
storageFailed: (error) =>
|
|
730
|
+
storageFailed: (error) =>
|
|
731
|
+
stopForError({ connection: c, error, reason: "storage-error" }),
|
|
732
|
+
protocolFailed: (error) =>
|
|
733
|
+
stopForError({ connection: c, error, reason: "protocol-error" }),
|
|
722
734
|
}),
|
|
723
735
|
);
|
|
724
736
|
|
package/src/client/session.ts
CHANGED
|
@@ -85,6 +85,7 @@ export type StatewireSessionOptions = StatewireProtocol.Options & {
|
|
|
85
85
|
|
|
86
86
|
type Shared = {
|
|
87
87
|
txq: Promise<void>;
|
|
88
|
+
syncQueued: boolean;
|
|
88
89
|
parked: { protocol: string; verdict: StatewireClient.SessionVerdict }[];
|
|
89
90
|
unsubscribe: (() => void) | null;
|
|
90
91
|
};
|
|
@@ -204,7 +205,6 @@ const storageFailed = (s: Session, cause: unknown) => {
|
|
|
204
205
|
};
|
|
205
206
|
|
|
206
207
|
const setRecord = (s: Session, record: StatewireClient.SessionRecord) => {
|
|
207
|
-
s.record = record;
|
|
208
208
|
if (!s.deactivated) {
|
|
209
209
|
try {
|
|
210
210
|
s.store.save(record);
|
|
@@ -213,6 +213,7 @@ const setRecord = (s: Session, record: StatewireClient.SessionRecord) => {
|
|
|
213
213
|
throw s.storageError;
|
|
214
214
|
}
|
|
215
215
|
}
|
|
216
|
+
s.record = record;
|
|
216
217
|
};
|
|
217
218
|
|
|
218
219
|
const toStored = (p: Pending): StatewireClient.SessionCommand => {
|
|
@@ -288,8 +289,16 @@ const syncBacklogInner = (s: Session) => {
|
|
|
288
289
|
};
|
|
289
290
|
|
|
290
291
|
const syncBacklog = (s: Session) => {
|
|
291
|
-
|
|
292
|
-
|
|
292
|
+
const shared = s.shared;
|
|
293
|
+
if (!shared) return syncBacklogInner(s);
|
|
294
|
+
if (shared.syncQueued) return;
|
|
295
|
+
shared.syncQueued = true;
|
|
296
|
+
void transact(s, () => {
|
|
297
|
+
shared.syncQueued = false;
|
|
298
|
+
syncBacklogInner(s);
|
|
299
|
+
}).catch(() => {
|
|
300
|
+
shared.syncQueued = false;
|
|
301
|
+
});
|
|
293
302
|
};
|
|
294
303
|
|
|
295
304
|
// Every record mutation over shared storage is a locked read-modify-write.
|
|
@@ -305,7 +314,7 @@ const transact = (s: Session, fn: () => void): Promise<void> => {
|
|
|
305
314
|
if (stored !== null && stored.clientId !== s.record.clientId) {
|
|
306
315
|
adoptReset(s, stored);
|
|
307
316
|
} else if (stored !== null) {
|
|
308
|
-
s.record = stored;
|
|
317
|
+
s.record = boundRecord(stored);
|
|
309
318
|
}
|
|
310
319
|
fn();
|
|
311
320
|
const persistence = s.store.flush?.();
|
|
@@ -357,6 +366,25 @@ export const failedCommands = (
|
|
|
357
366
|
return s.failedList;
|
|
358
367
|
};
|
|
359
368
|
|
|
369
|
+
export const MAX_FAILED_RETAINED = 256;
|
|
370
|
+
|
|
371
|
+
const boundFailed = (failed: readonly StatewireClient.FailedCommand[]) =>
|
|
372
|
+
failed.length > MAX_FAILED_RETAINED
|
|
373
|
+
? [...failed].sort((a, b) => a.ts - b.ts).slice(-MAX_FAILED_RETAINED)
|
|
374
|
+
: failed;
|
|
375
|
+
|
|
376
|
+
const boundRecord = (record: StatewireClient.SessionRecord) => {
|
|
377
|
+
let lanes = record.lanes;
|
|
378
|
+
for (const [protocol, lane] of Object.entries(lanes)) {
|
|
379
|
+
if (lane.failed !== undefined && lane.failed.length > MAX_FAILED_RETAINED)
|
|
380
|
+
lanes = {
|
|
381
|
+
...lanes,
|
|
382
|
+
[protocol]: { ...lane, failed: boundFailed(lane.failed) },
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
return lanes === record.lanes ? record : { ...record, lanes };
|
|
386
|
+
};
|
|
387
|
+
|
|
360
388
|
const retainFailedInner = (
|
|
361
389
|
s: Session,
|
|
362
390
|
entry: Pending,
|
|
@@ -367,7 +395,7 @@ const retainFailedInner = (
|
|
|
367
395
|
s,
|
|
368
396
|
withLane(s.record, entry.command.protocol, {
|
|
369
397
|
...lane,
|
|
370
|
-
failed: [
|
|
398
|
+
failed: boundFailed([
|
|
371
399
|
...(lane.failed ?? []),
|
|
372
400
|
{
|
|
373
401
|
protocol: entry.command.protocol,
|
|
@@ -381,7 +409,7 @@ const retainFailedInner = (
|
|
|
381
409
|
ts: entry.inv.ts,
|
|
382
410
|
seq: entry.seq,
|
|
383
411
|
},
|
|
384
|
-
],
|
|
412
|
+
]),
|
|
385
413
|
}),
|
|
386
414
|
);
|
|
387
415
|
s.notify();
|
|
@@ -497,7 +525,7 @@ const rebaseRecord = (s: Session, adopt: () => void) => {
|
|
|
497
525
|
...lanes,
|
|
498
526
|
[protocol]: {
|
|
499
527
|
...current,
|
|
500
|
-
failed: [...(current.failed ?? []), ...failed],
|
|
528
|
+
failed: boundFailed([...(current.failed ?? []), ...failed]),
|
|
501
529
|
},
|
|
502
530
|
};
|
|
503
531
|
}
|
|
@@ -515,7 +543,7 @@ const resetRecord = (s: Session, clientId: string) =>
|
|
|
515
543
|
// attempted history and our survivors re-stamp on the stored clocks.
|
|
516
544
|
const adoptReset = (s: Session, stored: StatewireClient.SessionRecord) => {
|
|
517
545
|
rebaseRecord(s, () => {
|
|
518
|
-
s.record = stored;
|
|
546
|
+
s.record = boundRecord(stored);
|
|
519
547
|
});
|
|
520
548
|
// The host attributes frames by lease, and the lease is bound to the id
|
|
521
549
|
// this attach was opened with.
|
|
@@ -825,7 +853,7 @@ const handleOps = (
|
|
|
825
853
|
) => {
|
|
826
854
|
ingestOps(s, ops);
|
|
827
855
|
for (const cmd of cmds) handleCmd(s, cmd);
|
|
828
|
-
syncBacklog(s);
|
|
856
|
+
if (cmds.length > 0) syncBacklog(s);
|
|
829
857
|
publishIngested(s);
|
|
830
858
|
};
|
|
831
859
|
|
|
@@ -1018,7 +1046,7 @@ const pump = async (s: Session) => {
|
|
|
1018
1046
|
// Shared storage: the lock across the send keeps per-lane arrival
|
|
1019
1047
|
// ordered across sharers during attach handover.
|
|
1020
1048
|
const send = () => transport.send(lanes.frame);
|
|
1021
|
-
result = await (s.shared && !s.deactivated
|
|
1049
|
+
result = await (s.shared && !s.deactivated && lanes.entries.length > 0
|
|
1022
1050
|
? s.store.lock!(send)
|
|
1023
1051
|
: send());
|
|
1024
1052
|
} catch {
|
|
@@ -1224,7 +1252,10 @@ const adoptStored = (s: Session) => {
|
|
|
1224
1252
|
const params =
|
|
1225
1253
|
command.meta === undefined
|
|
1226
1254
|
? command.params
|
|
1227
|
-
: (reviveValue(
|
|
1255
|
+
: (reviveValue(
|
|
1256
|
+
structuredClone(command.params),
|
|
1257
|
+
command.meta,
|
|
1258
|
+
) as unknown[]);
|
|
1228
1259
|
const entry: Pending = {
|
|
1229
1260
|
seq,
|
|
1230
1261
|
command: { protocol, ...command },
|
|
@@ -1364,7 +1395,7 @@ export const retryFailed = (
|
|
|
1364
1395
|
const params =
|
|
1365
1396
|
found.meta === undefined
|
|
1366
1397
|
? found.params
|
|
1367
|
-
: (reviveValue(
|
|
1398
|
+
: (reviveValue(structuredClone(found.params), found.meta) as unknown[]);
|
|
1368
1399
|
const result = new Promise<unknown>((resolve, reject) => {
|
|
1369
1400
|
const retry = () => {
|
|
1370
1401
|
if (!s.running) {
|
|
@@ -1510,8 +1541,11 @@ const loadSessionRecord = (
|
|
|
1510
1541
|
store.save(stored);
|
|
1511
1542
|
}
|
|
1512
1543
|
}
|
|
1513
|
-
const record =
|
|
1514
|
-
|
|
1544
|
+
const record =
|
|
1545
|
+
stored === null
|
|
1546
|
+
? { clientId: newClientId(), lanes: {} }
|
|
1547
|
+
: boundRecord(stored);
|
|
1548
|
+
if (record !== stored) store.save(record);
|
|
1515
1549
|
return record;
|
|
1516
1550
|
};
|
|
1517
1551
|
|
|
@@ -1590,7 +1624,12 @@ export const useStatewireSession = (
|
|
|
1590
1624
|
documentStore,
|
|
1591
1625
|
shared:
|
|
1592
1626
|
store.lock !== undefined
|
|
1593
|
-
? {
|
|
1627
|
+
? {
|
|
1628
|
+
txq: Promise.resolve(),
|
|
1629
|
+
syncQueued: false,
|
|
1630
|
+
parked: [],
|
|
1631
|
+
unsubscribe: null,
|
|
1632
|
+
}
|
|
1594
1633
|
: false,
|
|
1595
1634
|
ephemeral: false,
|
|
1596
1635
|
deactivated: false,
|
package/src/host/host.ts
CHANGED
|
@@ -231,8 +231,13 @@ const LOCAL_CALLER: StatewireHost.Caller = Object.freeze({
|
|
|
231
231
|
isConnected: true,
|
|
232
232
|
});
|
|
233
233
|
|
|
234
|
-
const toSendError = (error: unknown): StatewireSendError =>
|
|
235
|
-
|
|
234
|
+
const toSendError = (error: unknown): StatewireSendError => {
|
|
235
|
+
if (
|
|
236
|
+
!(error instanceof StatewireSendError) &&
|
|
237
|
+
!(error instanceof StatewireReject)
|
|
238
|
+
)
|
|
239
|
+
console.error("statewire command failed", error);
|
|
240
|
+
return error instanceof StatewireSendError
|
|
236
241
|
? error
|
|
237
242
|
: error instanceof StatewireReject
|
|
238
243
|
? new StatewireSendError({
|
|
@@ -245,8 +250,9 @@ const toSendError = (error: unknown): StatewireSendError =>
|
|
|
245
250
|
: new StatewireSendError({
|
|
246
251
|
fate: "failed",
|
|
247
252
|
cause: "server",
|
|
248
|
-
message:
|
|
253
|
+
message: "the command failed",
|
|
249
254
|
});
|
|
255
|
+
};
|
|
250
256
|
|
|
251
257
|
const applyInvocation = (cell: CommandsCell, inv: ActiveInvocation) => {
|
|
252
258
|
if (inv.applied) return;
|
package/src/host/http.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
authEquals,
|
|
9
9
|
closeConn,
|
|
10
10
|
flush,
|
|
11
|
+
expireLease,
|
|
11
12
|
parseFrame,
|
|
12
13
|
parseFrameValue,
|
|
13
14
|
register,
|
|
@@ -94,9 +95,10 @@ const headersOf = (request: Request): Record<string, string> =>
|
|
|
94
95
|
const authorize = async (
|
|
95
96
|
h: SocketHostState,
|
|
96
97
|
headers: Record<string, string>,
|
|
97
|
-
): Promise<{ auth: unknown } | { refused: Response }> => {
|
|
98
|
+
): Promise<{ auth: unknown; authorizedAt: number } | { refused: Response }> => {
|
|
98
99
|
try {
|
|
99
|
-
|
|
100
|
+
const auth = await h.authorize?.({ headers });
|
|
101
|
+
return { auth, authorizedAt: Date.now() };
|
|
100
102
|
} catch (error) {
|
|
101
103
|
if (error instanceof Response) return { refused: error };
|
|
102
104
|
throw error;
|
|
@@ -140,6 +142,7 @@ const attachSse = (
|
|
|
140
142
|
h: SocketHostState,
|
|
141
143
|
clientId: string,
|
|
142
144
|
auth: unknown,
|
|
145
|
+
authorizedAt: number,
|
|
143
146
|
selection: StatewireProtocol.Selection,
|
|
144
147
|
frame?: ParsedFrame,
|
|
145
148
|
): SseAttachment => {
|
|
@@ -203,6 +206,7 @@ const attachSse = (
|
|
|
203
206
|
auth,
|
|
204
207
|
selection,
|
|
205
208
|
frame,
|
|
209
|
+
authorizedAt,
|
|
206
210
|
);
|
|
207
211
|
connection = registration?.connection ?? null;
|
|
208
212
|
if (!open) connection?.close();
|
|
@@ -249,7 +253,13 @@ const getStream = async (
|
|
|
249
253
|
]);
|
|
250
254
|
if ("fin" in selection)
|
|
251
255
|
return Response.json(selection, { status: selection.fin.status });
|
|
252
|
-
const attachment = attachSse(
|
|
256
|
+
const attachment = attachSse(
|
|
257
|
+
h,
|
|
258
|
+
client.clientId,
|
|
259
|
+
authorized.auth,
|
|
260
|
+
authorized.authorizedAt,
|
|
261
|
+
selection,
|
|
262
|
+
);
|
|
253
263
|
const lease = attachment.registration === null ? null : mintLease();
|
|
254
264
|
if (attachment.registration !== null)
|
|
255
265
|
attachment.registration.conn.lease = lease;
|
|
@@ -301,6 +311,7 @@ const postStream = async (
|
|
|
301
311
|
h,
|
|
302
312
|
client.clientId,
|
|
303
313
|
authorized.auth,
|
|
314
|
+
authorized.authorizedAt,
|
|
304
315
|
selection,
|
|
305
316
|
frame,
|
|
306
317
|
);
|
|
@@ -365,11 +376,24 @@ export const frames = async (
|
|
|
365
376
|
lease === null
|
|
366
377
|
? undefined
|
|
367
378
|
: [...h.conns].find((conn) => conn.open && conn.lease === lease);
|
|
368
|
-
if (
|
|
379
|
+
if (
|
|
380
|
+
h.disposed ||
|
|
381
|
+
h.finished !== null ||
|
|
382
|
+
leased === undefined ||
|
|
383
|
+
expireLease(h, leased)
|
|
384
|
+
) {
|
|
369
385
|
return Response.json({ error: "stale-lease" }, { status: 423 });
|
|
370
386
|
}
|
|
371
387
|
const clientId = leased.clientId;
|
|
372
388
|
const raw = await readFrameBody(request);
|
|
389
|
+
if (
|
|
390
|
+
!leased.open ||
|
|
391
|
+
h.disposed ||
|
|
392
|
+
h.finished !== null ||
|
|
393
|
+
expireLease(h, leased)
|
|
394
|
+
) {
|
|
395
|
+
return Response.json({ error: "stale-lease" }, { status: 423 });
|
|
396
|
+
}
|
|
373
397
|
if (raw === null) {
|
|
374
398
|
return Response.json({ error: "frame body too large" }, { status: 413 });
|
|
375
399
|
}
|
|
@@ -382,7 +406,12 @@ export const frames = async (
|
|
|
382
406
|
}
|
|
383
407
|
return violationResponse(h, clientId, error.message);
|
|
384
408
|
}
|
|
385
|
-
if (
|
|
409
|
+
if (
|
|
410
|
+
!leased.open ||
|
|
411
|
+
h.disposed ||
|
|
412
|
+
h.finished !== null ||
|
|
413
|
+
expireLease(h, leased)
|
|
414
|
+
) {
|
|
386
415
|
return Response.json({ error: "stale-lease" }, { status: 423 });
|
|
387
416
|
}
|
|
388
417
|
let admission: ReturnType<typeof admitFrame>;
|
|
@@ -393,6 +422,9 @@ export const frames = async (
|
|
|
393
422
|
throw error;
|
|
394
423
|
return violationResponse(h, clientId, error.message);
|
|
395
424
|
}
|
|
425
|
+
if (admission.type === "expired") {
|
|
426
|
+
return Response.json({ error: "stale-lease" }, { status: 423 });
|
|
427
|
+
}
|
|
396
428
|
if (admission.type === "refused") {
|
|
397
429
|
const retry = admission.syn.syn.retry;
|
|
398
430
|
return Response.json(admission.syn, {
|