statewire 0.13.1 → 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.
@@ -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","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 { 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 /** 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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"}
1
+ {"version":3,"file":"statewire.js","names":["useMemo","useRef","useState","useSyncExternalStore","createTapRoot","flushTapSync","resource","useContextProvider","useResource","useResources","withKey","ResourceElement","CONNECTED_CONNECTION","useStatewireCore","Statewire","TERMINAL","SessionStorageContext","useSessionTransport","transport","session","StatewireClient","SessionStorage","SessionTransport","useStatewire","Commands","options","Options","TState","TCommands","Helpers","latest","current","storage","undefined","value","core","subscribe","listener","state","getState","isBusy","getBusy","failed","getFailed","connection","failures","NonNullable","Error","commands","CommandsProxy","setContext","contextValue","Record","retryFailed","entry","FailedCommand","retry","discardFailed","discard","StatewireResource","root","disposed","constructor","ClientRoot","getValue","Connection","Promise","dispose","unmount","initialState","optimistic","Optimistic","materialize","Materialize","onStateChange","ctx","changes","Changes","onCommandChange","update","CommandUpdate","onConnectionChange","failureCovered","ConnectionState","status","reason","message","payload","attempt","cause","nextRetryAt","lastError","reconnect","degraded","ConnectionStatus","draft","command","Command","OptimisticContext","ts","seq","clientId","SendFailure","fate","code","predecessorLost","OnSessionLost","pending","ReadonlyArray","method","params","meta","failure","SessionCommand","SessionRecord","nextSeq","ctxNextSeq","verdicts","SessionVerdict","type","load","save","record","lock","fn","T","HeadersOption","HeadersInit","staleAuth","SSEOptions","sessionStore","url","isNew","headers","fetch","globalThis","onSessionLost","onChange","onDone","WSOptions","protocols","query","webSocket","WebSocket"],"sources":["../src/statewire.ts"],"sourcesContent":["import { useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport {\n createTapRoot,\n flushTapSync,\n resource,\n useContextProvider,\n useResource,\n useResources,\n withKey,\n} from \"@assistant-ui/tap\";\nimport type { ResourceElement } from \"@assistant-ui/tap\";\nimport { CONNECTED_CONNECTION, useStatewireCore } from \"./client/core\";\nimport type { Statewire } from \"./types\";\nimport type { TERMINAL } from \"./client/connection\";\nimport { SessionStorageContext } from \"./client/sessionStore\";\n\nconst useSessionTransport = ({\n transport,\n session,\n}: {\n transport: ResourceElement<Statewire<any, any>>;\n session: StatewireClient.SessionStorage | undefined;\n}): Statewire<unknown> =>\n useContextProvider(SessionStorageContext, session, () =>\n // oxlint-disable-next-line react-hooks/rules-of-hooks\n useResource(transport),\n );\n\n// The transport owns the session; it reads the storage off the context this\n// wrapper's own fiber provides, so plain-React callers get a provider too.\nconst SessionTransport = resource(useSessionTransport);\n\nexport const useStatewire = <\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n>(\n options: StatewireClient.Options<TState, TCommands>,\n): StatewireClient.Helpers<TState, TCommands> => {\n const latest = useRef(options);\n latest.current = options;\n\n const session = useResources(\n options.storage?.session !== undefined\n ? [withKey(\"session\", options.storage.session)]\n : [],\n )[0];\n const value = useResource(\n SessionTransport({ transport: options.transport, session }),\n );\n const core = useStatewireCore(() => latest.current, value);\n const [subscribe] = useState(\n () => (listener: () => void) =>\n core.subscribe(() => flushTapSync(listener)),\n );\n\n const state = useSyncExternalStore(\n subscribe,\n core.getState,\n core.getState,\n ) as TState;\n const isBusy = useSyncExternalStore(subscribe, core.getBusy, core.getBusy);\n const failed = useSyncExternalStore(\n subscribe,\n core.getFailed,\n core.getFailed,\n );\n const connection = value.connection ?? CONNECTED_CONNECTION;\n\n return useMemo(() => {\n const failures = (): NonNullable<typeof value.failures> => {\n if (!value.failures) {\n throw new Error(\"statewire: this transport has no failed-send lane\");\n }\n return value.failures;\n };\n return {\n state,\n getState: core.getState as () => TState,\n connection,\n commands: core.commands as Statewire.CommandsProxy<TCommands>,\n isBusy,\n setContext: (contextValue: Record<string, unknown>) => {\n if (!value.setContext) {\n throw new Error(\n \"statewire: this transport has no client context channel\",\n );\n }\n value.setContext(contextValue);\n },\n failed,\n retryFailed: (entry: StatewireClient.FailedCommand) =>\n failures().retry(entry),\n discardFailed: (entry: StatewireClient.FailedCommand) =>\n failures().discard(entry),\n };\n }, [state, isBusy, failed, connection, core, value]);\n};\n\nexport const StatewireResource = <\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n>(\n options: StatewireClient.Options<TState, TCommands>,\n): ResourceElement<StatewireClient.Helpers<TState, TCommands>> =>\n resource(useStatewire<TState, TCommands>)(options);\n\nexport class StatewireClient<\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n> {\n private readonly root;\n private disposed = false;\n\n constructor(options: StatewireClient.Options<TState, TCommands>) {\n const ClientRoot = () => useStatewire<TState, TCommands>(options);\n this.root = createTapRoot(ClientRoot);\n }\n\n get state(): TState {\n return this.root.getValue().getState();\n }\n\n get connection(): StatewireClient.Connection {\n return this.root.getValue().connection;\n }\n\n get commands(): Statewire.CommandsProxy<TCommands> {\n return this.root.getValue().commands;\n }\n\n get isBusy(): boolean {\n return this.root.getValue().isBusy;\n }\n\n setContext(value: Record<string, unknown>): void {\n this.root.getValue().setContext(value);\n }\n\n get failed(): readonly StatewireClient.FailedCommand[] {\n return this.root.getValue().failed;\n }\n\n retryFailed(entry: StatewireClient.FailedCommand): Promise<unknown> {\n return this.root.getValue().retryFailed(entry);\n }\n\n discardFailed(entry: StatewireClient.FailedCommand): void {\n this.root.getValue().discardFailed(entry);\n }\n\n subscribe(listener: () => void): () => void {\n return this.root.subscribe(listener);\n }\n\n dispose(): void {\n if (this.disposed) return;\n this.disposed = true;\n this.root.unmount();\n }\n}\n\nexport namespace StatewireClient {\n export type Options<\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n > = {\n transport: ResourceElement<Statewire<TState, TCommands>>;\n /** Durable session: the mounted storage holds this client's lane identity and its un-durable commands, which resend on the next attach. */\n storage?:\n | { session?: ResourceElement<SessionStorage> | undefined }\n | undefined;\n /** Served as the canonical state until the attach snapshot; own commands fold over it. */\n initialState?: TState;\n optimistic?: Optimistic<NonNullable<TState>, TCommands>;\n materialize?: Materialize<NonNullable<TState>>;\n onStateChange?: (\n state: NonNullable<TState>,\n ctx: { changes: Statewire.Changes },\n ) => void;\n onCommandChange?: (\n update: Statewire.CommandUpdate<TCommands>,\n ctx: { state: NonNullable<TState> },\n ) => void;\n onConnectionChange?: (connection: Connection) => void;\n /** Drops a retained failed entry once a canonical snapshot shows its effect landed; checked against each new snapshot. */\n failureCovered?: (\n state: NonNullable<TState>,\n entry: FailedCommand,\n ) => boolean;\n };\n\n export type Helpers<\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n > = {\n /** The statewire's state type — remote statewires include `undefined` until the first attach snapshot applies. */\n state: TState;\n /** Identity-stable across renders; reads the current canonical state without subscribing the caller. */\n getState: () => TState;\n connection: Connection;\n commands: Statewire.CommandsProxy<TCommands>;\n /** Negation of the server's broadcast idle level (a transport without idle is always busy). */\n isBusy: boolean;\n /** Replace this client's ambient context on the server; throws on transports without a client context channel. */\n setContext: (value: Record<string, unknown>) => void;\n /** Non-accepted sends retained in the session record; reload with it. */\n failed: readonly FailedCommand[];\n /** Re-enqueues the entry's command on fresh clocks and removes the entry. */\n retryFailed: (entry: FailedCommand) => Promise<unknown>;\n /** Removes the entry. */\n discardFailed: (entry: FailedCommand) => void;\n };\n\n /** `cause`/`nextRetryAt`/`lastError` are present only while waiting out the backoff timer; a dial in flight carries `attempt` alone. */\n export type ConnectionState =\n | {\n status: \"disconnected\";\n reason: \"deferred\" | \"idle\" | \"gone\";\n message?: string;\n payload?: unknown;\n }\n | {\n status: \"connecting\";\n /** 0 = the episode's first dial, >0 = retry. */\n attempt: number;\n cause?: \"dropped\" | \"evicted\" | \"error\" | \"unauthorized\";\n nextRetryAt?: number;\n lastError?: string;\n message?: string;\n }\n | { status: \"connected\" };\n export type Connection = ConnectionState & {\n reconnect: () => void;\n /** True while retrying (`connecting` with `attempt > 0`) or parked `gone`. */\n degraded: boolean;\n };\n export type ConnectionStatus = Connection[\"status\"];\n\n /**\n * Mutate the draft in place; the client records the next state plus the exact\n * changed paths. Re-run against the new base on every rebase.\n */\n export type Optimistic<\n TState,\n TCommands extends Statewire.Commands = Statewire.Commands,\n > = (\n draft: TState,\n command: Statewire.Command<TCommands>,\n ctx: OptimisticContext,\n ) => void;\n\n export type OptimisticContext = {\n ts: number;\n seq: number;\n clientId?: string;\n };\n\n /**\n * Mutate the draft to make server-accepted, still-uncommitted intents in\n * the state concrete (e.g. dispatch records into messages). Runs after the\n * optimistic folds on every published state.\n */\n export type Materialize<TState> = (draft: TState) => void;\n\n /**\n * fate: \"aborted\" = never sent, \"rejected\" = the server refused it\n * (`code` \"unknown-command\" | \"invalid-params\" for the static rejects,\n * code-less for handler rejects), \"lost\" = sent but no verdict will ever\n * arrive — the record is gone (a proven seq gap) or the channel died: it\n * may or may not have run, \"failed\" = the handler ran and threw,\n * \"crashed\" = the handler died involuntarily, \"expired\" = settled —\n * result, rejected, failed, or crashed — but the verdict outlived the\n * server's retention.\n * cause is which channel settled it: \"server\" = the server's answer;\n * the rest are channel deaths the client settled locally.\n */\n export type SendFailure =\n | {\n fate: \"rejected\";\n cause: \"server\";\n code?: string;\n message?: string;\n payload?: unknown;\n }\n | { fate: \"failed\"; cause: \"server\"; message?: string }\n | { fate: \"crashed\"; cause: \"server\" }\n | { fate: \"expired\"; cause: \"server\" }\n | {\n fate: \"aborted\";\n cause: \"unmount\" | \"stream-gone\" | \"session-lost\";\n predecessorLost?: true;\n }\n | {\n fate: \"lost\";\n cause: \"unmount\" | \"stream-gone\" | \"session-lost\";\n };\n\n /** Held commands a proven record gap settled `{fate: \"lost\", cause: \"session-lost\"}`; return \"requeue\" to re-enqueue them on the reset clocks. */\n export type OnSessionLost = (\n pending: ReadonlyArray<{\n method: string;\n params: readonly unknown[];\n ts: number;\n seq: number;\n clientId?: string;\n }>,\n ) => \"requeue\" | \"drop\";\n\n /** A non-accepted send retained for the user: the wire statement, its failure, and its original clocks. */\n export type FailedCommand = {\n readonly command: string;\n readonly params: readonly unknown[];\n readonly meta?: unknown;\n readonly failure: SendFailure;\n readonly ts: number;\n readonly seq: number;\n };\n\n /** A command as persisted while un-durable: its lane seq plus the wire statement. */\n export type SessionCommand = {\n seq: number;\n method: string;\n params: unknown[];\n meta?: unknown;\n };\n\n /** The persisted session: the lane identity, the lane clocks, and the commands the server has not marked durable. */\n export type SessionRecord = {\n clientId: string;\n nextSeq: number;\n ctxNextSeq: number;\n commands: readonly SessionCommand[];\n /** Terminal verdicts parked for the sharer that minted the seq (shared storage only). */\n verdicts?: readonly SessionVerdict[];\n /** Non-accepted sends retained for the user; survive record rebases and resets. */\n failed?: readonly FailedCommand[];\n };\n\n /** A terminal verdict one sharer's connection carried, parked for the sharer awaiting the seq. */\n export type SessionVerdict = {\n seq: number;\n type: (typeof TERMINAL)[number];\n code?: string;\n message?: string;\n payload?: unknown;\n meta?: unknown;\n };\n\n /** Session storage seam; `save` must persist synchronously in the caller's flow. */\n export type SessionStorage = {\n load: () => SessionRecord | null;\n save: (record: SessionRecord) => void;\n /** Serializes record mutations across sharers; presence marks the storage shared. */\n lock?: <T>(fn: () => T | Promise<T>) => Promise<T>;\n /** Notifies of record saves by other sharers; returns unsubscribe. Required alongside `lock`. */\n subscribe?: (listener: () => void) => () => void;\n };\n\n /** Static headers, or a resolver invoked per request/attach; `staleAuth` is true right after an auth rejection. */\n export type HeadersOption =\n | HeadersInit\n | ((ctx: { staleAuth: boolean }) => HeadersInit | Promise<HeadersInit>);\n\n export type SSEOptions = {\n sessionStore?: SessionStorage | undefined;\n url: string;\n /** The session does not exist on the server yet: the first attach waits for the first command send (or an explicit `reconnect()`). */\n isNew?: boolean;\n headers?: HeadersOption;\n fetch?: typeof globalThis.fetch;\n onSessionLost?: OnSessionLost;\n onChange?: (connection: Connection) => void;\n /** The broadcast idle flag's rising edge: the instance went quiet. */\n onDone?: () => void;\n };\n\n export type WSOptions = {\n sessionStore?: SessionStorage | undefined;\n url: string;\n /** The session does not exist on the server yet: the first attach waits for the first command send (or an explicit `reconnect()`). */\n isNew?: boolean;\n headers?: HeadersOption;\n protocols?: string | string[];\n query?:\n | Record<string, string>\n | (() => Record<string, string> | Promise<Record<string, string>>);\n webSocket?: new (url: string, protocols?: string | string[]) => WebSocket;\n onSessionLost?: OnSessionLost;\n onChange?: (connection: Connection) => void;\n /** The broadcast idle flag's rising edge: the instance went quiet. */\n onDone?: () => void;\n };\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "statewire",
3
- "version": "0.13.1",
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": {
@@ -108,11 +108,35 @@ export type Conn = {
108
108
  reconnect: () => void;
109
109
  };
110
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
+
111
131
  const setStatus = (c: Conn, next: ConnectionState) => {
112
- const changed = next.status !== c.connection.status;
113
- 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
+ };
114
138
  c.notify(c.connection);
115
- if (changed) c.onChange(c.connection);
139
+ c.onChange(c.connection);
116
140
  };
117
141
 
118
142
  const protocolError = (message: string): never => {
@@ -274,7 +298,7 @@ const handleHello = (c: Conn, env: StatewireEnvelope) => {
274
298
  c.liveAt = Date.now();
275
299
  c.authDenials = 0;
276
300
  c.episode = null;
277
- setStatus(c, { status: "live" });
301
+ setStatus(c, { status: "connected" });
278
302
  };
279
303
 
280
304
  let warnedUnknownSynFact = false;
@@ -349,7 +373,7 @@ const settleFin = (c: Conn, fin: StatewireHost.Fin): AttemptOutcome => {
349
373
  c.ctx.onIdle(true);
350
374
  c.attempt = 0;
351
375
  c.episode = null;
352
- setStatus(c, { status: "idle" });
376
+ setStatus(c, { status: "disconnected", reason: "idle" });
353
377
  return "idle";
354
378
  }
355
379
  // An auth denial gets one immediate redial; a consecutive repeat parks as gone.
@@ -366,7 +390,8 @@ const settleFin = (c: Conn, fin: StatewireHost.Fin): AttemptOutcome => {
366
390
  return "reattach";
367
391
  }
368
392
  setStatus(c, {
369
- status: "gone",
393
+ status: "disconnected",
394
+ reason: "gone",
370
395
  ...(finish.message !== undefined && { message: finish.message }),
371
396
  ...(finish.payload !== undefined && { payload: finish.payload }),
372
397
  });
@@ -385,14 +410,17 @@ export const handleEnvelope = (
385
410
  }
386
411
  deliverEnvelope(c, env);
387
412
  if (env.fin !== undefined) return settleFin(c, env.fin);
388
- if (c.connection.status !== "live") {
413
+ if (c.connection.status !== "connected") {
389
414
  c.attempt = 0;
390
415
  c.episode = null;
391
- setStatus(c, { status: "live" });
416
+ setStatus(c, { status: "connected" });
392
417
  }
393
418
  return null;
394
419
  };
395
420
 
421
+ const dialing = (c: Conn) =>
422
+ setStatus(c, { status: "connecting", attempt: c.attempt });
423
+
396
424
  const scheduleRetry = (
397
425
  c: Conn,
398
426
  failure: string | undefined,
@@ -404,11 +432,12 @@ const scheduleRetry = (
404
432
  c.liveAt = null;
405
433
  c.attempt += 1;
406
434
  c.episode ??= { cause: "dropped" };
407
- const delay = immediate ? 0 : backoff(c.attempt);
435
+ if (immediate) return 0;
436
+ const delay = backoff(c.attempt);
408
437
  setStatus(c, {
409
- status: "reconnecting",
410
- ...c.episode,
438
+ status: "connecting",
411
439
  attempt: c.attempt,
440
+ ...c.episode,
412
441
  nextRetryAt: Date.now() + delay,
413
442
  ...(failure !== undefined && { lastError: failure }),
414
443
  });
@@ -417,13 +446,12 @@ const scheduleRetry = (
417
446
 
418
447
  const reconnectConn = (c: Conn) => {
419
448
  if (!c.lifetime) return;
420
- 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.
421
451
  c.attempt = 0;
422
452
  c.authDenials = 0;
423
- c.episode = { cause: "dropped" };
424
- flushTapSync(() => {
425
- scheduleRetry(c, undefined, true);
426
- });
453
+ c.episode = null;
454
+ flushTapSync(() => dialing(c));
427
455
  }
428
456
  openGate(c.gate);
429
457
  c.abort?.abort();
@@ -437,6 +465,8 @@ const run = async (c: Conn, lifetime: AbortController, gate: Gate) => {
437
465
  c.parked = false;
438
466
  }
439
467
  while (alive()) {
468
+ // The dial is in flight: attempt alone, no backoff fields.
469
+ flushTapSync(() => dialing(c));
440
470
  const abort = new AbortController();
441
471
  c.abort = abort;
442
472
  c.awaitingHello = true;
@@ -500,7 +530,13 @@ export const useStatewireConn = (props: {
500
530
  attemptOnce: (abort) => latest.current.attemptOnce(conn, abort),
501
531
  notify: setConnection,
502
532
  reconnect: () => reconnectConn(conn),
503
- 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
+ },
504
540
  };
505
541
  return conn;
506
542
  });
@@ -512,9 +548,12 @@ export const useStatewireConn = (props: {
512
548
  c.authDenials = 0;
513
549
  c.liveAt = null;
514
550
  c.episode = null;
515
- if (c.connection.status !== "connecting") {
516
- setStatus(c, { status: "connecting" });
517
- }
551
+ setStatus(
552
+ c,
553
+ c.parked
554
+ ? { status: "disconnected", reason: "deferred" }
555
+ : { status: "connecting", attempt: 0 },
556
+ );
518
557
  const lifetime = new AbortController();
519
558
  c.lifetime = lifetime;
520
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;
@@ -713,7 +713,8 @@ const pump = async (s: Session) => {
713
713
  if (buildLanes(s) === null) return;
714
714
  if (s.attach !== "attached" || !transport) {
715
715
  if (
716
- transport?.connection.status === "idle" ||
716
+ (transport?.connection.status === "disconnected" &&
717
+ transport.connection.reason === "idle") ||
717
718
  (transport?.parked && s.pending.length > 0)
718
719
  ) {
719
720
  transport.connection.reconnect();
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;
@@ -53,6 +53,14 @@ export type Conn = {
53
53
  lease: string | null;
54
54
  };
55
55
 
56
+ type PendingConn = {
57
+ socket: StatewireHostInternal.Socket;
58
+ open: boolean;
59
+ authorizing: boolean;
60
+ timer: ReturnType<typeof setTimeout> | null;
61
+ connection: StatewireHostInternal.SocketConnection | null;
62
+ };
63
+
56
64
  // ─── frame parsing ──────────────────────────────────────
57
65
 
58
66
  const violation = (reason: string): never => {
@@ -247,11 +255,13 @@ const cmdOf = (adm: Admission, outcome: CommandsOutcome): StatewireCmd => {
247
255
 
248
256
  export type SocketHostState = {
249
257
  heartbeatMs: number;
258
+ wsInitTimeoutMs: number;
250
259
  clientRecordLimit: number;
251
260
  finOnIdle: boolean;
252
261
  authorize: StatewireHostInternal.SocketHostOptions["authorize"];
253
262
  clients: Map<string, ClientRec>;
254
263
  conns: Set<Conn>;
264
+ pendingConns: Set<PendingConn>;
255
265
  pendingOps: WireOp[];
256
266
  pendingApplied: Map<string, number[]>;
257
267
  pendingCmds: Map<string, StatewireCmd[]>;
@@ -267,6 +277,7 @@ export type SocketHostState = {
267
277
  connect: (
268
278
  clientId: string,
269
279
  socket: StatewireHostInternal.Socket,
280
+ options?: StatewireHostInternal.SocketConnectOptions,
270
281
  ) => StatewireHostInternal.SocketConnection | null;
271
282
  finish: (fin: StatewireHost.Fin) => void;
272
283
  stream: (request: Request) => Promise<Response>;
@@ -667,6 +678,198 @@ const message = (h: SocketHostState, conn: Conn, data: string) => {
667
678
  }
668
679
  };
669
680
 
681
+ const closePending = (
682
+ h: SocketHostState,
683
+ pending: PendingConn,
684
+ code?: number,
685
+ reason?: string,
686
+ ) => {
687
+ if (!pending.open) return;
688
+ pending.open = false;
689
+ h.pendingConns.delete(pending);
690
+ if (pending.timer !== null) clearTimeout(pending.timer);
691
+ try {
692
+ pending.socket.close(code, reason);
693
+ } catch {
694
+ /* peer already gone */
695
+ }
696
+ };
697
+
698
+ const sendPending = (pending: PendingConn, data: string): boolean => {
699
+ if (!pending.open) return false;
700
+ try {
701
+ pending.socket.send(data);
702
+ return true;
703
+ } catch {
704
+ return false;
705
+ }
706
+ };
707
+
708
+ const rejectPendingAuthorization = (
709
+ h: SocketHostState,
710
+ pending: PendingConn,
711
+ error: unknown,
712
+ ) => {
713
+ const response = error instanceof Response ? error : null;
714
+ const unauthorized = response?.status === 401 || response?.status === 403;
715
+ const status = response?.status;
716
+ const message = unauthorized
717
+ ? "unauthorized"
718
+ : response?.statusText ||
719
+ (status === undefined ? "authorization failed" : `HTTP ${status}`);
720
+ sendPending(
721
+ pending,
722
+ JSON.stringify({
723
+ fin: {
724
+ reason: unauthorized ? "unauthorized" : "error",
725
+ message,
726
+ ...(status !== undefined && { status }),
727
+ },
728
+ }),
729
+ );
730
+ closePending(h, pending, unauthorized ? 1008 : 1011, message);
731
+ };
732
+
733
+ const normalizedHeaders = (
734
+ headers: Record<string, string>,
735
+ ): Record<string, string> => Object.fromEntries(new Headers(headers));
736
+
737
+ const connectAuthorized = (
738
+ h: SocketHostState,
739
+ clientId: string,
740
+ socket: StatewireHostInternal.Socket,
741
+ options: StatewireHostInternal.SocketConnectOptions,
742
+ ): StatewireHostInternal.SocketConnection | null => {
743
+ const authorize = h.authorize;
744
+ if (authorize === undefined || options.authorized === true) {
745
+ const registration = register(
746
+ h,
747
+ clientId,
748
+ socket,
749
+ options.authorized === true,
750
+ );
751
+ return registration?.connection ?? null;
752
+ }
753
+ const refuse = (code: number, reason: string) => {
754
+ try {
755
+ socket.close(code, reason);
756
+ } catch {
757
+ /* peer already gone */
758
+ }
759
+ return null;
760
+ };
761
+ if (h.disposed) return refuse(1001, "disposed");
762
+ if (!CLIENT_ID_RE.test(clientId)) return refuse(1008, "invalid client id");
763
+ flush(h);
764
+ if (h.finished !== null) {
765
+ try {
766
+ socket.send(JSON.stringify({ fin: h.finished }));
767
+ } catch {
768
+ /* peer already gone */
769
+ }
770
+ return refuse(1000, "finished");
771
+ }
772
+ const pending: PendingConn = {
773
+ socket,
774
+ open: true,
775
+ authorizing: false,
776
+ timer: null,
777
+ connection: null,
778
+ };
779
+ h.pendingConns.add(pending);
780
+ pending.timer = setTimeout(
781
+ () =>
782
+ rejectPendingAuthorization(
783
+ h,
784
+ pending,
785
+ new Response(null, { status: 401 }),
786
+ ),
787
+ h.wsInitTimeoutMs,
788
+ );
789
+ (pending.timer as unknown as { unref?: () => void }).unref?.();
790
+ return {
791
+ clientId,
792
+ message: (data) => {
793
+ if (pending.connection !== null) {
794
+ pending.connection.message(data);
795
+ return;
796
+ }
797
+ if (!pending.open || h.disposed || h.finished !== null) return;
798
+ if (pending.authorizing) {
799
+ closePending(h, pending, 1008, "authorization pending");
800
+ return;
801
+ }
802
+ let frame: ParsedFrame;
803
+ try {
804
+ frame = parseFrame(data, true);
805
+ } catch (error) {
806
+ const reason = violationReason(error);
807
+ if (reason === null) throw error;
808
+ sendPending(
809
+ pending,
810
+ JSON.stringify({
811
+ fin: {
812
+ reason: "error",
813
+ message: `protocol violation: ${reason}`,
814
+ },
815
+ }),
816
+ );
817
+ closePending(h, pending, 1008, reason);
818
+ return;
819
+ }
820
+ if (frame.headers === null) {
821
+ rejectPendingAuthorization(
822
+ h,
823
+ pending,
824
+ new Response(null, { status: 401 }),
825
+ );
826
+ return;
827
+ }
828
+ let headers: Record<string, string>;
829
+ try {
830
+ headers = normalizedHeaders(frame.headers);
831
+ } catch {
832
+ sendPending(
833
+ pending,
834
+ JSON.stringify({
835
+ fin: {
836
+ reason: "error",
837
+ message: "protocol violation: malformed headers",
838
+ },
839
+ }),
840
+ );
841
+ closePending(h, pending, 1008, "malformed headers");
842
+ return;
843
+ }
844
+ pending.authorizing = true;
845
+ Promise.resolve()
846
+ .then(() => authorize({ headers }))
847
+ .then(() => {
848
+ if (!pending.open || h.disposed || h.finished !== null) return;
849
+ options.onAuthorized?.();
850
+ pending.open = false;
851
+ h.pendingConns.delete(pending);
852
+ if (pending.timer !== null) clearTimeout(pending.timer);
853
+ const registration = register(h, clientId, socket, true);
854
+ if (registration === null) return;
855
+ pending.connection = registration.connection;
856
+ if (frame.ctx.length === 0 && frame.cmd.length === 0) return;
857
+ const admission = admitFrame(h, registration.rec, frame);
858
+ if (admission.type === "refused") {
859
+ sendTo(h, registration.conn, JSON.stringify(admission.syn));
860
+ }
861
+ })
862
+ .catch((error: unknown) => {
863
+ if (pending.open) rejectPendingAuthorization(h, pending, error);
864
+ });
865
+ },
866
+ close: () => {
867
+ if (pending.connection !== null) pending.connection.close();
868
+ else closePending(h, pending);
869
+ },
870
+ };
871
+ };
872
+
670
873
  const gcClients = (h: SocketHostState) => {
671
874
  let excess = h.clients.size - h.clientRecordLimit;
672
875
  if (excess <= 0) return;
@@ -690,6 +893,7 @@ export const register = (
690
893
  h: SocketHostState,
691
894
  clientId: string,
692
895
  socket: StatewireHostInternal.Socket,
896
+ sawFrame = false,
693
897
  ): Registration | null => {
694
898
  const refuse = (code: number, reason: string) => {
695
899
  try {
@@ -732,7 +936,7 @@ export const register = (
732
936
  clientId,
733
937
  socket,
734
938
  open: true,
735
- sawFrame: false,
939
+ sawFrame,
736
940
  lastSentAt: 0,
737
941
  lease: null,
738
942
  };
@@ -768,14 +972,18 @@ const connect = (
768
972
  h: SocketHostState,
769
973
  clientId: string,
770
974
  socket: StatewireHostInternal.Socket,
975
+ options: StatewireHostInternal.SocketConnectOptions = {},
771
976
  ): StatewireHostInternal.SocketConnection | null => {
772
- const registration = register(h, clientId, socket);
773
- return registration?.connection ?? null;
977
+ return connectAuthorized(h, clientId, socket, options);
774
978
  };
775
979
 
776
980
  const finish = (h: SocketHostState, fin: StatewireHost.Fin) => {
777
981
  if (h.finished !== null || h.disposed) return;
778
982
  h.finished = fin;
983
+ for (const pending of [...h.pendingConns]) {
984
+ sendPending(pending, JSON.stringify({ fin }));
985
+ closePending(h, pending, 1000, fin.reason);
986
+ }
779
987
  flush(h, fin);
780
988
  };
781
989
 
@@ -803,11 +1011,13 @@ const useSocketHost = (
803
1011
  const [h] = useState((): SocketHostState => {
804
1012
  const host: SocketHostState = {
805
1013
  heartbeatMs: 15_000,
1014
+ wsInitTimeoutMs: 5_000,
806
1015
  clientRecordLimit: CLIENT_RECORD_LIMIT,
807
1016
  finOnIdle: false,
808
1017
  authorize: undefined,
809
1018
  clients: new Map(),
810
1019
  conns: new Set(),
1020
+ pendingConns: new Set(),
811
1021
  pendingOps: [],
812
1022
  pendingApplied: new Map(),
813
1023
  pendingCmds: new Map(),
@@ -823,7 +1033,8 @@ const useSocketHost = (
823
1033
  host.flushScheduled = false;
824
1034
  flush(host);
825
1035
  }),
826
- connect: (clientId, socket) => connect(host, clientId, socket),
1036
+ connect: (clientId, socket, connectOptions) =>
1037
+ connect(host, clientId, socket, connectOptions),
827
1038
  finish: (fin) => finish(host, fin),
828
1039
  stream: (request) => serveStream(host, request),
829
1040
  frames: (request) => serveFrames(host, request),
@@ -831,6 +1042,7 @@ const useSocketHost = (
831
1042
  return host;
832
1043
  });
833
1044
  setHeartbeatMs(h, options?.heartbeatIntervalMs ?? 15_000);
1045
+ h.wsInitTimeoutMs = options?.wsInitTimeoutMs ?? 5_000;
834
1046
  h.clientRecordLimit = options?.clientRecordLimit ?? CLIENT_RECORD_LIMIT;
835
1047
  h.finOnIdle = options?.finOnIdle ?? false;
836
1048
  h.authorize = options?.authorize;
@@ -862,8 +1074,10 @@ export const StatewireSocketHost = (
862
1074
  const root = createTapRoot(SocketHostRoot);
863
1075
  let disposed = false;
864
1076
  return {
865
- connect: (clientId, socket) =>
866
- flushTapSync(() => root.getValue().connect(clientId, socket)),
1077
+ connect: (clientId, socket, connectOptions) =>
1078
+ flushTapSync(() =>
1079
+ root.getValue().connect(clientId, socket, connectOptions),
1080
+ ),
867
1081
  stream: (request) => flushTapSync(() => root.getValue().stream(request)),
868
1082
  frames: (request) => flushTapSync(() => root.getValue().frames(request)),
869
1083
  finish: (fin) => flushTapSync(() => root.getValue().finish(fin)),