statewire 0.19.1 → 0.19.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +2 -1
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts +2 -0
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +9 -4
- package/dist/client/core.js.map +1 -1
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +5 -5
- package/dist/client/session.js.map +1 -1
- package/dist/client/sessionStore.d.ts +5 -1
- package/dist/client/sessionStore.d.ts.map +1 -1
- package/dist/client/sessionStore.js +3 -1
- package/dist/client/sessionStore.js.map +1 -1
- package/dist/client/timing.d.ts +1 -0
- package/dist/client/timing.d.ts.map +1 -1
- package/dist/client/timing.js +1 -0
- package/dist/client/timing.js.map +1 -1
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +9 -11
- package/dist/host/socket.js.map +1 -1
- package/dist/statewire.d.ts +8 -3
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +7 -4
- package/dist/statewire.js.map +1 -1
- package/dist/transports/remote.js +2 -1
- package/dist/transports/remote.js.map +1 -1
- package/dist/transports/ws.d.ts.map +1 -1
- package/dist/transports/ws.js +4 -0
- package/dist/transports/ws.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +6 -1
- package/src/client/core.ts +11 -4
- package/src/client/session.ts +13 -5
- package/src/client/sessionStore.ts +3 -0
- package/src/client/timing.ts +1 -0
- package/src/host/socket.ts +10 -13
- package/src/statewire.ts +22 -4
- package/src/transports/remote.ts +6 -3
- package/src/transports/ws.ts +6 -0
package/dist/statewire.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"statewire.js","names":["StatewireDocuments","StatewireProtocol","StatewireHost","useCallback","useMemo","useRef","useState","useSyncExternalStore","createTapRoot","flushTapSync","resource","useContextProvider","useResource","useResources","withKey","ResourceElement","CONNECTED_CONNECTION","useStatewireCore","Statewire","TERMINAL","DocumentStorageContext","ProtocolContext","SessionStorageContext","useSessionTransport","transport","session","documents","protocol","StatewireClient","SessionStorage","Storage","Options","SessionTransport","useStatewire","Commands","options","TState","TCommands","Helpers","latest","current","storage","undefined","wireVersion","applicationProtocols","keyPrefixes","value","core","subscribe","listener","state","getState","isBusy","getBusy","failed","getFailed","connection","source","selection","invoke","transportInvoke","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","Readonly","Record","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","isNew","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 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 ProtocolContext,\n SessionStorageContext,\n} from \"./client/sessionStore\";\n\nconst useSessionTransport = ({\n transport,\n session,\n documents,\n protocol,\n}: {\n transport: ResourceElement<Statewire<any, any>>;\n session: StatewireClient.SessionStorage | undefined;\n documents: StatewireDocuments.Storage | undefined;\n protocol: StatewireProtocol.Options;\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 useResource(transport),\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 value = useResource(\n SessionTransport({\n transport: options.transport,\n session,\n documents,\n protocol,\n }),\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 const {\n documents: source,\n selection,\n invoke: transportInvoke,\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 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 ]);\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 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 keyPrefixes?: Readonly<Record<string, readonly string[]>>;\n wireVersion?: StatewireProtocol.Range;\n applicationProtocols?: readonly StatewireProtocol.Offer[] | \"*\";\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 | {\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 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 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 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 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","SessionTransport","useStatewire","Commands","options","TState","TCommands","Helpers","latest","current","storage","undefined","wireVersion","applicationProtocols","keyPrefixes","value","core","rematerialize","materializeKey","subscribe","listener","state","getState","isBusy","getBusy","failed","getFailed","connection","source","selection","invoke","transportInvoke","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","Readonly","Record","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: { isNew?: boolean | undefined };\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 () => (options.isNew === undefined ? {} : { isNew: options.isNew }),\n [options.isNew],\n );\n const value = useResource(\n SessionTransport({\n transport: options.transport,\n session,\n documents,\n protocol,\n link,\n }),\n );\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 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 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 ]);\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 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 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 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":";;;;;AA+BA,MAAMyB,uBAAuB,EAC3BC,WACAC,SACAC,WACAC,UACAC,WAQAlB,mBAAmBY,uBAAuBG,eAExCf,mBAAmBS,wBAAwBO,iBAEzChB,mBAAmBW,iBAAiBM,gBAElCjB,mBAAmBU,aAAaQ,YAE9BjB,YAAYa,SAAS,CACvB,CACF,CACF,CACF;AAIF,MAAMU,mBAAmBzB,SAASc,mBAAmB;AAErD,MAAaY,gBAIXE,YAC+C;CAC/C,MAAMI,SAASrC,OAAOiC,OAAO;CAC7BI,OAAOC,UAAUL;CAEjB,MAAMZ,UAAUb,aACdyB,QAAQM,SAASlB,YAAYmB,KAAAA,IACzB,CAAC/B,QAAQ,WAAWwB,QAAQM,QAAQlB,OAAO,CAAC,IAC5C,CAAA,CACN,CAAC,CAAC;CACF,MAAMC,YAAYd,aAChByB,QAAQM,SAASjB,cAAckB,KAAAA,IAC3B,CAAA,IACA,CAAC/B,QAAQ,aAAawB,QAAQM,QAAQjB,SAAS,CAAC,CACtD,CAAC,CAAC;CACF,MAAMC,WAAsC;EAC1C,GAAIU,QAAQV,aAAaiB,KAAAA,KAAa,EAAEjB,UAAUU,QAAQV,SAAS;EACnE,GAAIU,QAAQQ,gBAAgBD,KAAAA,KAAa,EACvCC,aAAaR,QAAQQ,YACvB;EACA,GAAIR,QAAQS,yBAAyBF,KAAAA,KAAa,EAChDE,sBAAsBT,QAAQS,qBAChC;EACA,GAAIT,QAAQU,gBAAgBH,KAAAA,KAAa,EACvCG,aAAaV,QAAQU,YACvB;CACF;CACA,MAAMnB,OAAOzB,cACJkC,QAAQJ,UAAUW,KAAAA,IAAY,CAAC,IAAI,EAAEX,OAAOI,QAAQJ,MAAM,GACjE,CAACI,QAAQJ,KAAK,CAChB;CACA,MAAMe,QAAQrC,YACZuB,iBAAiB;EACfV,WAAWa,QAAQb;EACnBC;EACAC;EACAC;EACAC;CACF,CAAC,CACH;CACA,MAAMqB,OAAOjC,uBAAuByB,OAAOC,SAASM,KAAK;CACzD9C,gBAAgB+C,KAAKC,cAAc,GAAG,CAACD,MAAMZ,QAAQc,cAAc,CAAC;CACpE,MAAM,CAACC,aAAa/C,gBACXgD,aACLJ,KAAKG,gBAAgB5C,aAAa6C,QAAQ,CAAC,CAC/C;CAEA,MAAMC,QAAQhD,qBACZ8C,WACAH,KAAKM,UACLN,KAAKM,QACP;CACA,MAAMC,SAASlD,qBAAqB8C,WAAWH,KAAKQ,SAASR,KAAKQ,OAAO;CACzE,MAAMC,SAASpD,qBACb8C,WACAH,KAAKU,WACLV,KAAKU,SACP;CACA,MAAMC,aAAaZ,MAAMY,cAAc7C;CACvC,MAAM,EACJW,WAAWmC,QACXC,WACAC,QAAQC,iBACRC,UAAUC,sBACRlB;CAEJ,MAAMe,SAAS9D,aACZ0B,YAAkBwC,QAAgBC,WAA+B;EAChE,IAAI,CAACJ,iBACH,MAAM,IAAIK,MACR,yDACF;EACF,OAAOL,gBAAgBrC,YAAUwC,QAAQC,MAAM;CACjD,GACA,CAACJ,eAAe,CAClB;CAEA,OAAO7D,cAAc;EACnB,MAAM8D,iBAAwD;GAC5D,IAAI,CAACC,mBACH,MAAM,IAAIG,MAAM,mDAAmD;GAErE,OAAOH;EACT;EACA,OAAO;GACLZ;GACA5B,WAAWmC;GACXC;GACAC;GACAR,UAAUN,KAAKM;GACfK;GACAW,UAAUtB,KAAKsB;GACff;GACAE;GACAe,cAAcC,UACZT,SAAS,CAAC,CAACW,MAAMF,KAAK;GACxBG,gBAAgBH,YACdT,SAAS,CAAC,CAACa,QAAQJ,OAAK;EAC5B;CACF,GAAG;EACDpB;EACAE;EACAE;EACAE;EACAX;EACAY;EACAC;EACAC;EACAG;CAAiB,CAClB;AACH;AAEA,MAAaa,qBAIX1C,YAEA5B,SAAS0B,YAA+B,CAAC,CAACE,OAAO;AAEnD,IAAaR,kBAAb,MAGE;CACA;CACA,WAAmB;CAEnBqD,YAAY7C,SAAqD;EAC/D,MAAM8C,mBAAmBhD,aAAgCE,OAAO;EAChE,KAAK2C,OAAOzE,cAAc4E,UAAU;CACtC;CAEA,IAAI7B,QAAgB;EAClB,OAAO,KAAK0B,KAAKI,SAAS,CAAC,CAAC7B,SAAS;CACvC;CAEA,IAAIK,aAAyC;EAC3C,OAAO,KAAKoB,KAAKI,SAAS,CAAC,CAACxB;CAC9B;CAEA,IAAIW,WAA+C;EACjD,OAAO,KAAKS,KAAKI,SAAS,CAAC,CAACb;CAC9B;CAEA,IAAI7C,YAAmD;EACrD,OAAO,KAAKsD,KAAKI,SAAS,CAAC,CAAC1D;CAC9B;CACA,IAAIoC,YAAqD;EACvD,OAAO,KAAKkB,KAAKI,SAAS,CAAC,CAACtB;CAC9B;CACAC,OACEpC,UACAwC,QACAC,QACkB;EAClB,OAAO,KAAKY,KAAKI,SAAS,CAAC,CAACrB,OAAOpC,UAAUwC,QAAQC,MAAM;CAC7D;CACA,IAAIZ,SAAkB;EACpB,OAAO,KAAKwB,KAAKI,SAAS,CAAC,CAAC5B;CAC9B;CAEA,IAAIE,SAAmD;EACrD,OAAO,KAAKsB,KAAKI,SAAS,CAAC,CAAC1B;CAC9B;CAEAe,YAAYC,OAAwD;EAClE,OAAO,KAAKM,KAAKI,SAAS,CAAC,CAACX,YAAYC,KAAK;CAC/C;CAEAG,cAAcH,OAA4C;EACxD,KAAKM,KAAKI,SAAS,CAAC,CAACP,cAAcH,KAAK;CAC1C;CAEAtB,UAAUC,UAAkC;EAC1C,OAAO,KAAK2B,KAAK5B,UAAUC,QAAQ;CACrC;CAEAoC,UAAgB;EACd,IAAI,KAAKR,UAAU;EACnB,KAAKA,WAAW;EAChB,KAAKD,KAAKU,QAAQ;CACpB;AACF"}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { ProtocolContext } from "../client/sessionStore.js";
|
|
1
|
+
import { LinkContext, ProtocolContext } from "../client/sessionStore.js";
|
|
2
2
|
import { discardFailed, documentSource, failedCommands, invokeProtocol, retryFailed, setTransport, useStatewireSession } from "../client/session.js";
|
|
3
3
|
import { useContext, useEffect, useMemo } from "@assistant-ui/tap/react-shim";
|
|
4
4
|
//#region src/transports/remote.ts
|
|
5
5
|
const useStatewireRemote = (options, useLink) => {
|
|
6
6
|
const configured = {
|
|
7
7
|
...useContext(ProtocolContext),
|
|
8
|
+
...useContext(LinkContext),
|
|
8
9
|
...options
|
|
9
10
|
};
|
|
10
11
|
const s = useStatewireSession(configured);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"remote.js","names":["useContext","useEffect","useMemo","ProtocolContext","Statewire","documentSource","invokeProtocol","discardFailed","failedCommands","retryFailed","setTransport","useStatewireSession","StatewireSessionOptions","StatewireClient","RemoteLink","StatewireRemoteProps","useStatewireRemote","url","onChange","connection","Connection","onDone","options","TOptions","useLink","ctx","
|
|
1
|
+
{"version":3,"file":"remote.js","names":["useContext","useEffect","useMemo","LinkContext","ProtocolContext","Statewire","documentSource","invokeProtocol","discardFailed","failedCommands","retryFailed","setTransport","useStatewireSession","StatewireSessionOptions","StatewireClient","RemoteLink","StatewireRemoteProps","useStatewireRemote","url","onChange","connection","Connection","onDone","options","TOptions","useLink","ctx","configured","s","conn","lanes","documents","invoke","protocol","method","params","failures","list","retry","entry","FailedCommand","discard","state","canonical","selection","commands","source","isBusy","idle"],"sources":["../../src/transports/remote.ts"],"sourcesContent":["import { useContext, useEffect, useMemo } from \"react\";\nimport { LinkContext, ProtocolContext } from \"../client/sessionStore\";\nimport type { Statewire } from \"../types\";\nimport {\n documentSource,\n invokeProtocol,\n discardFailed,\n failedCommands,\n retryFailed,\n setTransport,\n useStatewireSession,\n} from \"../client/session\";\nimport type { StatewireSessionOptions } from \"../client/session\";\nimport type { StatewireClient } from \"../statewire\";\nimport type { RemoteLink, StatewireRemoteProps } from \"./sse\";\n\nexport const useStatewireRemote = <\n TOptions extends {\n url: string;\n onChange?: (connection: StatewireClient.Connection) => void;\n onDone?: () => void;\n } & StatewireSessionOptions,\n>(\n options: TOptions,\n useLink: (options: TOptions, ctx: StatewireRemoteProps) => RemoteLink,\n): Statewire<any, any> => {\n const configured = {\n ...useContext(ProtocolContext),\n ...useContext(LinkContext),\n ...options,\n };\n const s = useStatewireSession(configured);\n const conn = useLink(configured, s.ctx);\n useEffect(() => setTransport(s, conn), [s, conn]);\n const lanes = useMemo(\n () => ({\n documents: documentSource(s),\n invoke: (protocol: string, method: string, params: readonly unknown[]) =>\n invokeProtocol(s, protocol, method, params),\n failures: {\n list: () => failedCommands(s),\n retry: (entry: StatewireClient.FailedCommand) => retryFailed(s, entry),\n discard: (entry: StatewireClient.FailedCommand) =>\n discardFailed(s, entry),\n },\n }),\n [s],\n );\n\n return {\n state: s.canonical,\n ...lanes,\n get selection() {\n return s.selection;\n },\n commands: s.commands,\n connection: conn.connection,\n source: s.source,\n get isBusy() {\n return !s.idle;\n },\n };\n};\n"],"mappings":";;;;AAgBA,MAAaiB,sBAOXM,SACAE,YACwB;CACxB,MAAME,aAAa;EACjB,GAAG3B,WAAWI,eAAe;EAC7B,GAAGJ,WAAWG,WAAW;EACzB,GAAGoB;CACL;CACA,MAAMK,IAAIhB,oBAAoBe,UAAU;CACxC,MAAME,OAAOJ,QAAQE,YAAYC,EAAEF,GAAG;CACtCzB,gBAAgBU,aAAaiB,GAAGC,IAAI,GAAG,CAACD,GAAGC,IAAI,CAAC;CAChD,MAAMC,QAAQ5B,eACL;EACL6B,WAAWzB,eAAesB,CAAC;EAC3BI,SAASC,UAAkBC,QAAgBC,WACzC5B,eAAeqB,GAAGK,UAAUC,QAAQC,MAAM;EAC5CC,UAAU;GACRC,YAAY5B,eAAemB,CAAC;GAC5BU,QAAQC,UAAyC7B,YAAYkB,GAAGW,KAAK;GACrEE,UAAUF,YACR/B,cAAcoB,GAAGW,OAAK;EAC1B;CACF,IACA,CAACX,CAAC,CACJ;CAEA,OAAO;EACLc,OAAOd,EAAEe;EACT,GAAGb;EACH,IAAIc,YAAY;GACd,OAAOhB,EAAEgB;EACX;EACAC,UAAUjB,EAAEiB;EACZzB,YAAYS,KAAKT;EACjB0B,QAAQlB,EAAEkB;EACV,IAAIC,SAAS;GACX,OAAO,CAACnB,EAAEoB;EACZ;CACF;AACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ws.d.ts","names":[],"sources":["../../src/transports/ws.ts"],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"ws.d.ts","names":[],"sources":["../../src/transports/ws.ts"],"mappings":";;;;;cA8La,qBACX,cACA,kBAAkB,UAAU,WAAW,UAAU,UAEjD,SAAS,gBAAgB,qBACtB,gBAAgB,UAAU,oBAAoB"}
|
package/dist/transports/ws.js
CHANGED
|
@@ -20,6 +20,7 @@ const socketUrl = async (options, clientId) => {
|
|
|
20
20
|
};
|
|
21
21
|
const runSocket = (c, ws, frame, abort) => new Promise((resolve, reject) => {
|
|
22
22
|
let timer;
|
|
23
|
+
let ping;
|
|
23
24
|
let timedOut = false;
|
|
24
25
|
let outcome = null;
|
|
25
26
|
let closed = false;
|
|
@@ -41,10 +42,12 @@ const runSocket = (c, ws, frame, abort) => new Promise((resolve, reject) => {
|
|
|
41
42
|
return;
|
|
42
43
|
}
|
|
43
44
|
ws.send(frame);
|
|
45
|
+
ping = setInterval(() => ws.send("{}"), timing.pingIntervalMs);
|
|
44
46
|
};
|
|
45
47
|
const stale = () => Boolean(outcome) || closed || abort.signal.aborted;
|
|
46
48
|
const fail = (error) => {
|
|
47
49
|
clearTimeout(timer);
|
|
50
|
+
clearInterval(ping);
|
|
48
51
|
reject(error instanceof Error ? error : /* @__PURE__ */ new Error("protocol"));
|
|
49
52
|
ws.close();
|
|
50
53
|
};
|
|
@@ -72,6 +75,7 @@ const runSocket = (c, ws, frame, abort) => new Promise((resolve, reject) => {
|
|
|
72
75
|
ws.onclose = () => {
|
|
73
76
|
closed = true;
|
|
74
77
|
clearTimeout(timer);
|
|
78
|
+
clearInterval(ping);
|
|
75
79
|
if (outcome) resolve(outcome);
|
|
76
80
|
else reject(/* @__PURE__ */ new Error(timedOut ? "timeout" : "network"));
|
|
77
81
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ws.js","names":["useState","flushTapSync","resource","ResourceElement","timing","attachFrame","handleEnvelope","unsupportedVersion","useLink","AttemptOutcome","Conn","StatewireEnvelope","Statewire","StatewireClient","useStatewireRemote","CLIENT_QUERY_PARAM","WS_SUBPROTOCOL","RemoteLink","StatewireFrame","StatewireSubmitResult","StatewireRemoteProps","WsChannel","socket","WebSocket","OPEN","socketUrl","options","WebsocketOptions","clientId","url","query","params","URLSearchParams","set","resolved","URL","globalThis","location","href","protocol","replace","pathname","search","toString","runSocket","c","ws","frame","abort","AbortController","Promise","resolve","reject","timer","ReturnType","setTimeout","timedOut","outcome","closed","arm","clearTimeout","close","heartbeatTimeoutMs","signal","addEventListener","onerror","onopen","negotiated","trim","send","stale","Boolean","aborted","fail","error","Error","onmessage","event","env","JSON","parse","String","data","SyntaxError","Object","keys","length","done","onclose","attemptSocket","w","ctx","body","headers","throwIfAborted","protocols","webSocket","WS","extra","undefined","sendFrame","readyState","stringify","type","useWsLink","$","_c","_temp","t0","t1","_","t2","attemptOnce","useStatewireWebsocket","StatewireWebsocket","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 {\n attachFrame,\n handleEnvelope,\n unsupportedVersion,\n useLink,\n} 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 { WS_SUBPROTOCOL } from \"../wire/version\";\nimport type {\n RemoteLink,\n StatewireFrame,\n StatewireSubmitResult,\n StatewireRemoteProps,\n} from \"./sse\";\n\ntype WsChannel = {\n socket: WebSocket | null;\n};\n\nconst OPEN = 1;\n\nexport { WS_SUBPROTOCOL };\n\nconst socketUrl = async (\n options: StatewireClient.WebsocketOptions,\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 resolved = new URL(url, globalThis.location?.href);\n resolved.protocol = resolved.protocol.replace(/^http/, \"ws\");\n resolved.pathname = `${resolved.pathname.replace(/\\/$/, \"\")}/ws`;\n resolved.search = params.toString();\n return resolved.href;\n};\n\nconst runSocket = (\n c: Conn,\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 = () => {\n const negotiated = ws.protocol.trim();\n if (negotiated !== WS_SUBPROTOCOL) {\n outcome = unsupportedVersion(c, negotiated);\n ws.close();\n return;\n }\n ws.send(frame);\n };\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 SyntaxError(\"invalid JSON packet\"));\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 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.WebsocketOptions,\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 { body } = await attachFrame(c, options.headers);\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, ws, body, 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.WebsocketOptions,\n ctx: StatewireRemoteProps,\n): RemoteLink => {\n const [w] = useState((): WsChannel => ({ socket: null }));\n return useLink({\n ...options,\n ctx,\n attemptOnce: (c, abort) => attemptSocket(c, w, options, abort),\n send: (_, frame) => sendFrame(w, frame),\n });\n};\n\nconst useStatewireWebsocket = (\n options: StatewireClient.WebsocketOptions,\n): Statewire<any, any> => useStatewireRemote(options, useWsLink);\n\nexport const StatewireWebsocket = resource(useStatewireWebsocket) as <\n TState = any,\n TCommands extends Statewire.Commands = Statewire.Commands,\n>(\n options: StatewireClient.WebsocketOptions,\n) => ResourceElement<Statewire<TState | undefined, TCommands>>;\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ws.js","names":["useState","flushTapSync","resource","ResourceElement","timing","attachFrame","handleEnvelope","unsupportedVersion","useLink","AttemptOutcome","Conn","StatewireEnvelope","Statewire","StatewireClient","useStatewireRemote","CLIENT_QUERY_PARAM","WS_SUBPROTOCOL","RemoteLink","StatewireFrame","StatewireSubmitResult","StatewireRemoteProps","WsChannel","socket","WebSocket","OPEN","socketUrl","options","WebsocketOptions","clientId","url","query","params","URLSearchParams","set","resolved","URL","globalThis","location","href","protocol","replace","pathname","search","toString","runSocket","c","ws","frame","abort","AbortController","Promise","resolve","reject","timer","ReturnType","setTimeout","ping","setInterval","timedOut","outcome","closed","arm","clearTimeout","close","heartbeatTimeoutMs","signal","addEventListener","onerror","onopen","negotiated","trim","send","pingIntervalMs","stale","Boolean","aborted","fail","error","clearInterval","Error","onmessage","event","env","JSON","parse","String","data","SyntaxError","Object","keys","length","done","onclose","attemptSocket","w","ctx","body","headers","throwIfAborted","protocols","webSocket","WS","extra","undefined","sendFrame","readyState","stringify","type","useWsLink","$","_c","_temp","t0","t1","_","t2","attemptOnce","useStatewireWebsocket","StatewireWebsocket","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 {\n attachFrame,\n handleEnvelope,\n unsupportedVersion,\n useLink,\n} 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 { WS_SUBPROTOCOL } from \"../wire/version\";\nimport type {\n RemoteLink,\n StatewireFrame,\n StatewireSubmitResult,\n StatewireRemoteProps,\n} from \"./sse\";\n\ntype WsChannel = {\n socket: WebSocket | null;\n};\n\nconst OPEN = 1;\n\nexport { WS_SUBPROTOCOL };\n\nconst socketUrl = async (\n options: StatewireClient.WebsocketOptions,\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 resolved = new URL(url, globalThis.location?.href);\n resolved.protocol = resolved.protocol.replace(/^http/, \"ws\");\n resolved.pathname = `${resolved.pathname.replace(/\\/$/, \"\")}/ws`;\n resolved.search = params.toString();\n return resolved.href;\n};\n\nconst runSocket = (\n c: Conn,\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 ping: ReturnType<typeof setInterval> | 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 = () => {\n const negotiated = ws.protocol.trim();\n if (negotiated !== WS_SUBPROTOCOL) {\n outcome = unsupportedVersion(c, negotiated);\n ws.close();\n return;\n }\n ws.send(frame);\n // A ping keeps a host that cannot run timers (a hibernating durable\n // object answers it without waking) talking before the silence timeout.\n ping = setInterval(() => ws.send(\"{}\"), timing.pingIntervalMs);\n };\n const stale = () => Boolean(outcome) || closed || abort.signal.aborted;\n const fail = (error: unknown) => {\n clearTimeout(timer);\n clearInterval(ping);\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 SyntaxError(\"invalid JSON packet\"));\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 outcome = done;\n ws.close();\n }\n } catch (error) {\n fail(error);\n }\n };\n ws.onclose = () => {\n closed = true;\n clearTimeout(timer);\n clearInterval(ping);\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.WebsocketOptions,\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 { body } = await attachFrame(c, options.headers);\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, ws, body, 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.WebsocketOptions,\n ctx: StatewireRemoteProps,\n): RemoteLink => {\n const [w] = useState((): WsChannel => ({ socket: null }));\n return useLink({\n ...options,\n ctx,\n attemptOnce: (c, abort) => attemptSocket(c, w, options, abort),\n send: (_, frame) => sendFrame(w, frame),\n });\n};\n\nconst useStatewireWebsocket = (\n options: StatewireClient.WebsocketOptions,\n): Statewire<any, any> => useStatewireRemote(options, useWsLink);\n\nexport const StatewireWebsocket = resource(useStatewireWebsocket) as <\n TState = any,\n TCommands extends Statewire.Commands = Statewire.Commands,\n>(\n options: StatewireClient.WebsocketOptions,\n) => ResourceElement<Statewire<TState | undefined, TCommands>>;\n"],"mappings":"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"}
|
package/package.json
CHANGED
package/src/client/connection.ts
CHANGED
|
@@ -341,6 +341,11 @@ const helloSynOf = (env: StatewireEnvelope): StatewireSyn => {
|
|
|
341
341
|
return env.syn as StatewireSyn;
|
|
342
342
|
};
|
|
343
343
|
|
|
344
|
+
// A host that woke on a retained socket re-sends its hello; the snapshot
|
|
345
|
+
// replaces the session the way a fresh attach would.
|
|
346
|
+
const isWakeHello = (env: StatewireEnvelope) =>
|
|
347
|
+
isObject(env.syn) && env.syn["version"] !== undefined;
|
|
348
|
+
|
|
344
349
|
const handleHello = (c: Conn, env: StatewireEnvelope) => {
|
|
345
350
|
const syn = helloSynOf(env);
|
|
346
351
|
if (c.negotiation !== undefined) validateSelection(syn, c.negotiation);
|
|
@@ -470,7 +475,7 @@ export const handleEnvelope = (
|
|
|
470
475
|
value: unknown,
|
|
471
476
|
): AttemptOutcome | null => {
|
|
472
477
|
const env = validateEnvelope(value);
|
|
473
|
-
if (c.awaitingHello) {
|
|
478
|
+
if (c.awaitingHello || isWakeHello(env)) {
|
|
474
479
|
if (env.fin !== undefined) return settleFin(c, env.fin);
|
|
475
480
|
handleHello(c, env);
|
|
476
481
|
return null;
|
package/src/client/core.ts
CHANGED
|
@@ -48,6 +48,7 @@ type Core = {
|
|
|
48
48
|
stale: boolean;
|
|
49
49
|
everAttached: boolean;
|
|
50
50
|
overlaysDirty: boolean;
|
|
51
|
+
materializeKey: unknown;
|
|
51
52
|
flushing: boolean;
|
|
52
53
|
scheduled: boolean;
|
|
53
54
|
listeners: Set<() => void>;
|
|
@@ -56,6 +57,7 @@ type Core = {
|
|
|
56
57
|
getState: () => unknown;
|
|
57
58
|
getBusy: () => boolean;
|
|
58
59
|
getFailed: () => readonly StatewireClient.FailedCommand[];
|
|
60
|
+
rematerialize: () => void;
|
|
59
61
|
commands: Statewire.CommandsProxy<any>;
|
|
60
62
|
subscribe: (listener: () => void) => () => void;
|
|
61
63
|
};
|
|
@@ -105,14 +107,17 @@ const dropCovered = (c: Core) => {
|
|
|
105
107
|
};
|
|
106
108
|
|
|
107
109
|
const recompute = (c: Core) => {
|
|
108
|
-
if (!c.
|
|
110
|
+
if (!c.value) return;
|
|
111
|
+
const { optimistic, materialize, materializeKey } = c.getOptions();
|
|
112
|
+
const rekeyed = !Object.is(materializeKey, c.materializeKey);
|
|
113
|
+
if (!c.stale && !rekeyed) return;
|
|
109
114
|
c.stale = false;
|
|
110
115
|
const prevCanonical = c.canonical;
|
|
111
116
|
drainCanonical(c);
|
|
112
117
|
if (c.canonical !== prevCanonical) dropCovered(c);
|
|
113
|
-
if (c.canonical === prevCanonical && !c.overlaysDirty) return;
|
|
118
|
+
if (c.canonical === prevCanonical && !c.overlaysDirty && !rekeyed) return;
|
|
114
119
|
c.overlaysDirty = false;
|
|
115
|
-
|
|
120
|
+
c.materializeKey = materializeKey;
|
|
116
121
|
let next = c.canonical;
|
|
117
122
|
let paths: readonly StatewirePath[] = [];
|
|
118
123
|
if (optimistic && c.canonical !== undefined) {
|
|
@@ -214,7 +219,7 @@ const flush = (c: Core, scheduled = false) => {
|
|
|
214
219
|
c.notifiedBusy = busy;
|
|
215
220
|
// The failed snapshot advances only on the scheduled pass: settles land
|
|
216
221
|
// mid-unmount, where a changed snapshot re-renders the unmounting tree.
|
|
217
|
-
const failed = c.value
|
|
222
|
+
const failed = c.value?.failures?.list() ?? NO_FAILED;
|
|
218
223
|
let failedChanged = false;
|
|
219
224
|
if (failed !== c.notifiedFailed) {
|
|
220
225
|
if (scheduled) {
|
|
@@ -314,6 +319,7 @@ export const useStatewireCore = (
|
|
|
314
319
|
stale: false,
|
|
315
320
|
everAttached: false,
|
|
316
321
|
overlaysDirty: false,
|
|
322
|
+
materializeKey: undefined,
|
|
317
323
|
flushing: false,
|
|
318
324
|
scheduled: false,
|
|
319
325
|
listeners: new Set(),
|
|
@@ -354,6 +360,7 @@ export const useStatewireCore = (
|
|
|
354
360
|
},
|
|
355
361
|
getBusy: () => c.value?.isBusy ?? true,
|
|
356
362
|
getFailed: () => c.notifiedFailed,
|
|
363
|
+
rematerialize: () => flush(c),
|
|
357
364
|
commands: createMethodProxy<Statewire.CommandsProxy<any>>(
|
|
358
365
|
(method, params) => {
|
|
359
366
|
if (!c.value) {
|
package/src/client/session.ts
CHANGED
|
@@ -140,7 +140,8 @@ const EMPTY_LANE: StatewireClient.SessionLane = { nextSeq: 1, commands: [] };
|
|
|
140
140
|
const laneOf = (
|
|
141
141
|
record: StatewireClient.SessionRecord,
|
|
142
142
|
protocol: string,
|
|
143
|
-
): StatewireClient.SessionLane =>
|
|
143
|
+
): StatewireClient.SessionLane =>
|
|
144
|
+
Object.hasOwn(record.lanes, protocol) ? record.lanes[protocol]! : EMPTY_LANE;
|
|
144
145
|
|
|
145
146
|
const withLane = (
|
|
146
147
|
record: StatewireClient.SessionRecord,
|
|
@@ -157,7 +158,7 @@ const isEphemeral = (s: Session, protocol: string) =>
|
|
|
157
158
|
) ?? false;
|
|
158
159
|
|
|
159
160
|
const durableSeqOf = (s: Session, protocol: string) =>
|
|
160
|
-
s.durableSeq[protocol]
|
|
161
|
+
Object.hasOwn(s.durableSeq, protocol) ? s.durableSeq[protocol]! : 0;
|
|
161
162
|
|
|
162
163
|
const laneEntries = (s: Session, protocol: string) =>
|
|
163
164
|
s.pending.filter((p) => p.command.protocol === protocol);
|
|
@@ -779,7 +780,9 @@ const handleHello = (
|
|
|
779
780
|
for (const lane of lanes) {
|
|
780
781
|
if (lane.record === "ephemeral") {
|
|
781
782
|
s.ephemeralSeqs[lane.name] = Math.max(
|
|
782
|
-
s.ephemeralSeqs
|
|
783
|
+
Object.hasOwn(s.ephemeralSeqs, lane.name)
|
|
784
|
+
? s.ephemeralSeqs[lane.name]!
|
|
785
|
+
: 0,
|
|
783
786
|
...laneEntries(s, lane.name).map((p) => p.seq),
|
|
784
787
|
);
|
|
785
788
|
} else s.durableSeq[lane.name] = lane.seq ?? 0;
|
|
@@ -901,7 +904,10 @@ const buildLanes = (
|
|
|
901
904
|
(cmdGate === "persisted" &&
|
|
902
905
|
(s.persistedRecord?.clientId !== s.record.clientId ||
|
|
903
906
|
entry.seq >= laneOf(s.persistedRecord, protocol).nextSeq)));
|
|
904
|
-
if (
|
|
907
|
+
if (
|
|
908
|
+
gated ||
|
|
909
|
+
(Object.hasOwn(last, protocol) && entry.seq !== last[protocol]! + 1)
|
|
910
|
+
) {
|
|
905
911
|
stopped.add(protocol);
|
|
906
912
|
continue;
|
|
907
913
|
}
|
|
@@ -1098,7 +1104,9 @@ const enqueueInner = (
|
|
|
1098
1104
|
const protocol = command.protocol;
|
|
1099
1105
|
const ephemeral = isEphemeral(s, protocol);
|
|
1100
1106
|
const seq = ephemeral
|
|
1101
|
-
? (s.ephemeralSeqs
|
|
1107
|
+
? (Object.hasOwn(s.ephemeralSeqs, protocol)
|
|
1108
|
+
? s.ephemeralSeqs[protocol]!
|
|
1109
|
+
: 0) + 1
|
|
1102
1110
|
: Math.max(
|
|
1103
1111
|
laneOf(s.record, protocol).nextSeq,
|
|
1104
1112
|
durableSeqOf(s, protocol) + 1,
|
|
@@ -20,6 +20,9 @@ export const createMemorySessionStore = (): StatewireClient.SessionStorage => {
|
|
|
20
20
|
|
|
21
21
|
export const ProtocolContext = createContext<StatewireProtocol.Options>({});
|
|
22
22
|
|
|
23
|
+
/** Link options `useStatewire` hands down to whichever transport it mounts. */
|
|
24
|
+
export const LinkContext = createContext<{ isNew?: boolean | undefined }>({});
|
|
25
|
+
|
|
23
26
|
export const DocumentStorageContext = createContext<
|
|
24
27
|
StatewireDocuments.Storage | undefined
|
|
25
28
|
>(undefined);
|
package/src/client/timing.ts
CHANGED
package/src/host/socket.ts
CHANGED
|
@@ -256,22 +256,15 @@ const cmdOf = (adm: Admission, outcome: CommandsOutcome): Cmd => {
|
|
|
256
256
|
...(failure.message !== undefined && { message: failure.message }),
|
|
257
257
|
};
|
|
258
258
|
case "rejected": {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
: failure.message;
|
|
264
|
-
return {
|
|
265
|
-
...at,
|
|
266
|
-
type: "rejected",
|
|
267
|
-
code: failure.code,
|
|
268
|
-
...(message !== undefined && { message }),
|
|
269
|
-
};
|
|
270
|
-
}
|
|
259
|
+
const message =
|
|
260
|
+
failure.code === "unknown-command"
|
|
261
|
+
? `unknown method: '${adm.method}'`
|
|
262
|
+
: failure.message;
|
|
271
263
|
return {
|
|
272
264
|
...at,
|
|
273
265
|
type: "rejected",
|
|
274
|
-
...(failure.
|
|
266
|
+
...(failure.code !== undefined && { code: failure.code }),
|
|
267
|
+
...(message !== undefined && { message }),
|
|
275
268
|
...encodePayload(failure.payload),
|
|
276
269
|
};
|
|
277
270
|
}
|
|
@@ -1046,6 +1039,10 @@ const message = (h: SocketHostState, conn: Conn, data: string) => {
|
|
|
1046
1039
|
closeConn(h, conn, 1009, "frame too large");
|
|
1047
1040
|
return;
|
|
1048
1041
|
}
|
|
1042
|
+
if (data === "{}") {
|
|
1043
|
+
if (conn.sawFrame) sendTo(h, conn, "{}");
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1049
1046
|
const first = !conn.sawFrame;
|
|
1050
1047
|
conn.sawFrame = true;
|
|
1051
1048
|
let admission: ReturnType<typeof admitFrame>;
|
package/src/statewire.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { StatewireProtocol } from "./wire/protocol";
|
|
|
3
3
|
import type { StatewireHost } from "./host";
|
|
4
4
|
import {
|
|
5
5
|
useCallback,
|
|
6
|
+
useEffect,
|
|
6
7
|
useMemo,
|
|
7
8
|
useRef,
|
|
8
9
|
useState,
|
|
@@ -23,6 +24,7 @@ import type { Statewire } from "./types";
|
|
|
23
24
|
import type { TERMINAL } from "./client/connection";
|
|
24
25
|
import {
|
|
25
26
|
DocumentStorageContext,
|
|
27
|
+
LinkContext,
|
|
26
28
|
ProtocolContext,
|
|
27
29
|
SessionStorageContext,
|
|
28
30
|
} from "./client/sessionStore";
|
|
@@ -32,11 +34,13 @@ const useSessionTransport = ({
|
|
|
32
34
|
session,
|
|
33
35
|
documents,
|
|
34
36
|
protocol,
|
|
37
|
+
link,
|
|
35
38
|
}: {
|
|
36
39
|
transport: ResourceElement<Statewire<any, any>>;
|
|
37
40
|
session: StatewireClient.SessionStorage | undefined;
|
|
38
41
|
documents: StatewireDocuments.Storage | undefined;
|
|
39
42
|
protocol: StatewireProtocol.Options;
|
|
43
|
+
link: { isNew?: boolean | undefined };
|
|
40
44
|
}): Statewire<unknown> =>
|
|
41
45
|
useContextProvider(SessionStorageContext, session, () =>
|
|
42
46
|
// oxlint-disable-next-line react-hooks/rules-of-hooks
|
|
@@ -44,7 +48,10 @@ const useSessionTransport = ({
|
|
|
44
48
|
// oxlint-disable-next-line react-hooks/rules-of-hooks
|
|
45
49
|
useContextProvider(ProtocolContext, protocol, () =>
|
|
46
50
|
// oxlint-disable-next-line react-hooks/rules-of-hooks
|
|
47
|
-
|
|
51
|
+
useContextProvider(LinkContext, link, () =>
|
|
52
|
+
// oxlint-disable-next-line react-hooks/rules-of-hooks
|
|
53
|
+
useResource(transport),
|
|
54
|
+
),
|
|
48
55
|
),
|
|
49
56
|
),
|
|
50
57
|
);
|
|
@@ -84,15 +91,21 @@ export const useStatewire = <
|
|
|
84
91
|
keyPrefixes: options.keyPrefixes,
|
|
85
92
|
}),
|
|
86
93
|
};
|
|
94
|
+
const link = useMemo(
|
|
95
|
+
() => (options.isNew === undefined ? {} : { isNew: options.isNew }),
|
|
96
|
+
[options.isNew],
|
|
97
|
+
);
|
|
87
98
|
const value = useResource(
|
|
88
99
|
SessionTransport({
|
|
89
100
|
transport: options.transport,
|
|
90
101
|
session,
|
|
91
102
|
documents,
|
|
92
103
|
protocol,
|
|
104
|
+
link,
|
|
93
105
|
}),
|
|
94
106
|
);
|
|
95
107
|
const core = useStatewireCore(() => latest.current, value);
|
|
108
|
+
useEffect(() => core.rematerialize(), [core, options.materializeKey]);
|
|
96
109
|
const [subscribe] = useState(
|
|
97
110
|
() => (listener: () => void) =>
|
|
98
111
|
core.subscribe(() => flushTapSync(listener)),
|
|
@@ -245,6 +258,8 @@ export namespace StatewireClient {
|
|
|
245
258
|
wireVersion?: StatewireProtocol.Range;
|
|
246
259
|
applicationProtocols?: readonly StatewireProtocol.Offer[] | "*";
|
|
247
260
|
transport: ResourceElement<Statewire<TState, TCommands>>;
|
|
261
|
+
/** 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. */
|
|
262
|
+
isNew?: boolean;
|
|
248
263
|
/** Durable session: the mounted storage holds this client's lane identity and its un-durable commands, which resend on the next attach. */
|
|
249
264
|
storage?:
|
|
250
265
|
| {
|
|
@@ -256,6 +271,8 @@ export namespace StatewireClient {
|
|
|
256
271
|
initialState?: TState;
|
|
257
272
|
optimistic?: Optimistic<NonNullable<TState>, TCommands>;
|
|
258
273
|
materialize?: Materialize<NonNullable<TState>>;
|
|
274
|
+
/** Republishes through `materialize` when it changes (`Object.is`); `materialize` itself is read fresh on every publish. */
|
|
275
|
+
materializeKey?: unknown;
|
|
259
276
|
onStateChange?: (
|
|
260
277
|
state: NonNullable<TState>,
|
|
261
278
|
ctx: { changes: Statewire.Changes },
|
|
@@ -354,9 +371,10 @@ export namespace StatewireClient {
|
|
|
354
371
|
};
|
|
355
372
|
|
|
356
373
|
/**
|
|
357
|
-
* Mutate the draft to
|
|
358
|
-
*
|
|
359
|
-
* optimistic folds on every
|
|
374
|
+
* Mutate the draft to fold client-known facts into the state: server-accepted,
|
|
375
|
+
* still-uncommitted intents (e.g. dispatch records into messages) or the live
|
|
376
|
+
* state of a sibling statewire. Runs after the optimistic folds on every
|
|
377
|
+
* published state; `materializeKey` republishes when the facts change.
|
|
360
378
|
*/
|
|
361
379
|
export type Materialize<TState> = (draft: TState) => void;
|
|
362
380
|
|
package/src/transports/remote.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useContext, useEffect, useMemo } from "react";
|
|
2
|
-
import { ProtocolContext } from "../client/sessionStore";
|
|
2
|
+
import { LinkContext, ProtocolContext } from "../client/sessionStore";
|
|
3
3
|
import type { Statewire } from "../types";
|
|
4
4
|
import {
|
|
5
5
|
documentSource,
|
|
@@ -24,8 +24,11 @@ export const useStatewireRemote = <
|
|
|
24
24
|
options: TOptions,
|
|
25
25
|
useLink: (options: TOptions, ctx: StatewireRemoteProps) => RemoteLink,
|
|
26
26
|
): Statewire<any, any> => {
|
|
27
|
-
const
|
|
28
|
-
|
|
27
|
+
const configured = {
|
|
28
|
+
...useContext(ProtocolContext),
|
|
29
|
+
...useContext(LinkContext),
|
|
30
|
+
...options,
|
|
31
|
+
};
|
|
29
32
|
const s = useStatewireSession(configured);
|
|
30
33
|
const conn = useLink(configured, s.ctx);
|
|
31
34
|
useEffect(() => setTransport(s, conn), [s, conn]);
|
package/src/transports/ws.ts
CHANGED
|
@@ -57,6 +57,7 @@ const runSocket = (
|
|
|
57
57
|
): Promise<AttemptOutcome> =>
|
|
58
58
|
new Promise((resolve, reject) => {
|
|
59
59
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
60
|
+
let ping: ReturnType<typeof setInterval> | undefined;
|
|
60
61
|
let timedOut = false;
|
|
61
62
|
let outcome: AttemptOutcome | null = null;
|
|
62
63
|
let closed = false;
|
|
@@ -78,10 +79,14 @@ const runSocket = (
|
|
|
78
79
|
return;
|
|
79
80
|
}
|
|
80
81
|
ws.send(frame);
|
|
82
|
+
// A ping keeps a host that cannot run timers (a hibernating durable
|
|
83
|
+
// object answers it without waking) talking before the silence timeout.
|
|
84
|
+
ping = setInterval(() => ws.send("{}"), timing.pingIntervalMs);
|
|
81
85
|
};
|
|
82
86
|
const stale = () => Boolean(outcome) || closed || abort.signal.aborted;
|
|
83
87
|
const fail = (error: unknown) => {
|
|
84
88
|
clearTimeout(timer);
|
|
89
|
+
clearInterval(ping);
|
|
85
90
|
reject(error instanceof Error ? error : new Error("protocol"));
|
|
86
91
|
ws.close();
|
|
87
92
|
};
|
|
@@ -116,6 +121,7 @@ const runSocket = (
|
|
|
116
121
|
ws.onclose = () => {
|
|
117
122
|
closed = true;
|
|
118
123
|
clearTimeout(timer);
|
|
124
|
+
clearInterval(ping);
|
|
119
125
|
if (outcome) resolve(outcome);
|
|
120
126
|
else reject(new Error(timedOut ? "timeout" : "network"));
|
|
121
127
|
};
|