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.
Files changed (43) hide show
  1. package/README.md +2 -0
  2. package/dist/client/connection.d.ts +5 -2
  3. package/dist/client/connection.d.ts.map +1 -1
  4. package/dist/client/connection.js +54 -19
  5. package/dist/client/connection.js.map +1 -1
  6. package/dist/client/core.d.ts +2 -2
  7. package/dist/client/core.d.ts.map +1 -1
  8. package/dist/client/core.js +5 -4
  9. package/dist/client/core.js.map +1 -1
  10. package/dist/client/session.d.ts.map +1 -1
  11. package/dist/client/session.js +6 -2
  12. package/dist/client/session.js.map +1 -1
  13. package/dist/host/http.d.ts +2 -1
  14. package/dist/host/http.d.ts.map +1 -1
  15. package/dist/host/http.js +36 -3
  16. package/dist/host/http.js.map +1 -1
  17. package/dist/host/socket.d.ts +11 -2
  18. package/dist/host/socket.d.ts.map +1 -1
  19. package/dist/host/socket.js +135 -6
  20. package/dist/host/socket.js.map +1 -1
  21. package/dist/host-internal.d.ts +9 -1
  22. package/dist/host-internal.d.ts.map +1 -1
  23. package/dist/statewire.d.ts +16 -11
  24. package/dist/statewire.d.ts.map +1 -1
  25. package/dist/statewire.js +2 -2
  26. package/dist/statewire.js.map +1 -1
  27. package/dist/transports/sse.d.ts +2 -0
  28. package/dist/transports/sse.d.ts.map +1 -1
  29. package/dist/transports/sse.js +4 -0
  30. package/dist/transports/sse.js.map +1 -1
  31. package/dist/transports/ws.d.ts.map +1 -1
  32. package/dist/transports/ws.js +4 -0
  33. package/dist/transports/ws.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/client/connection.ts +67 -20
  36. package/src/client/core.ts +4 -3
  37. package/src/client/session.ts +10 -2
  38. package/src/host/http.ts +40 -2
  39. package/src/host/socket.ts +220 -6
  40. package/src/host-internal.ts +14 -1
  41. package/src/statewire.ts +23 -10
  42. package/src/transports/sse.ts +6 -0
  43. package/src/transports/ws.ts +4 -0
@@ -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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"}
@@ -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;EAC5B,OAAO,OAAO,mBAAmB,QAAQ;;KAQ/B;EAAc;;cAqOb,eACX,cACA,kBAAkB,UAAU,WAAW,UAAU,UAEjD,SAAS,gBAAgB,eACtB,gBAAgB,UAAU,oBAAoB"}
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"}
@@ -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;cA2KA,cACX,cACA,kBAAkB,UAAU,WAAW,UAAU,UAEjD,SAAS,gBAAgB,cACtB,gBAAgB,UAAU,oBAAoB"}
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"}
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "statewire",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "State over the wire: snapshot-first delta streaming and command submission for any state and any command",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
- const changed = next.status !== c.connection.status;
112
- c.connection = { ...next, reconnect: c.reconnect };
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
- if (changed) c.onChange(c.connection);
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: "live" });
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: "gone",
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 !== "live") {
413
+ if (c.connection.status !== "connected") {
388
414
  c.attempt = 0;
389
415
  c.episode = null;
390
- setStatus(c, { status: "live" });
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
- const delay = immediate ? 0 : backoff(c.attempt);
435
+ if (immediate) return 0;
436
+ const delay = backoff(c.attempt);
407
437
  setStatus(c, {
408
- status: "reconnecting",
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 === "gone" || c.connection.status === "idle") {
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 = { cause: "dropped" };
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: { status: "connecting", reconnect: () => conn.reconnect() },
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
- if (c.connection.status !== "connecting") {
508
- setStatus(c, { status: "connecting" });
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();
@@ -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 LIVE_CONNECTION: StatewireClient.Connection = {
14
- status: "live",
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 ?? LIVE_CONNECTION;
164
+ c.value?.connection ?? CONNECTED_CONNECTION;
164
165
 
165
166
  const flush = (c: Core, scheduled = false) => {
166
167
  if (!c.value) return;
@@ -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 (transport?.connection.status === "idle") {
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(() => void pump(s));
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(await request.text());
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(await request.text(), false);
307
+ frame = parseFrame(raw, false);
270
308
  } catch (error) {
271
309
  if (!(error instanceof Error) || error.name !== "StatewireViolation") {
272
310
  throw error;