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.
- package/README.md +2 -0
- package/dist/client/connection.d.ts +3 -2
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +48 -19
- package/dist/client/connection.js.map +1 -1
- package/dist/client/core.d.ts +2 -2
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +5 -4
- package/dist/client/core.js.map +1 -1
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +1 -1
- package/dist/client/session.js.map +1 -1
- package/dist/host/http.d.ts +2 -1
- package/dist/host/http.d.ts.map +1 -1
- package/dist/host/http.js +36 -3
- package/dist/host/http.js.map +1 -1
- package/dist/host/socket.d.ts +11 -2
- package/dist/host/socket.d.ts.map +1 -1
- package/dist/host/socket.js +135 -6
- package/dist/host/socket.js.map +1 -1
- package/dist/host-internal.d.ts +9 -1
- package/dist/host-internal.d.ts.map +1 -1
- package/dist/statewire.d.ts +12 -11
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +2 -2
- package/dist/statewire.js.map +1 -1
- package/package.json +1 -1
- package/src/client/connection.ts +59 -20
- package/src/client/core.ts +4 -3
- package/src/client/session.ts +2 -1
- package/src/host/http.ts +40 -2
- package/src/host/socket.ts +220 -6
- package/src/host-internal.ts +14 -1
- package/src/statewire.ts +19 -10
package/dist/statewire.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"statewire.js","names":["useMemo","useRef","useState","useSyncExternalStore","createTapRoot","flushTapSync","resource","useContextProvider","useResource","useResources","withKey","ResourceElement","LIVE_CONNECTION","useStatewireCore","Statewire","TERMINAL","SessionStorageContext","useSessionTransport","transport","session","StatewireClient","SessionStorage","SessionTransport","useStatewire","Commands","options","Options","TState","TCommands","Helpers","latest","current","storage","undefined","value","core","subscribe","listener","state","getState","isBusy","getBusy","failed","getFailed","connection","failures","NonNullable","Error","commands","CommandsProxy","setContext","contextValue","Record","retryFailed","entry","FailedCommand","retry","discardFailed","discard","StatewireResource","root","disposed","constructor","ClientRoot","getValue","Connection","Promise","dispose","unmount","initialState","optimistic","Optimistic","materialize","Materialize","onStateChange","ctx","changes","Changes","onCommandChange","update","CommandUpdate","onConnectionChange","failureCovered","ConnectionState","status","cause","attempt","nextRetryAt","lastError","message","payload","reconnect","ConnectionStatus","draft","command","Command","OptimisticContext","ts","seq","clientId","SendFailure","fate","code","predecessorLost","OnSessionLost","pending","ReadonlyArray","method","params","meta","failure","SessionCommand","SessionRecord","nextSeq","ctxNextSeq","verdicts","SessionVerdict","type","load","save","record","lock","fn","T","HeadersOption","HeadersInit","staleAuth","SSEOptions","sessionStore","url","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
package/src/client/connection.ts
CHANGED
|
@@ -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
|
-
|
|
113
|
-
c.connection = {
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
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 !== "
|
|
413
|
+
if (c.connection.status !== "connected") {
|
|
389
414
|
c.attempt = 0;
|
|
390
415
|
c.episode = null;
|
|
391
|
-
setStatus(c, { status: "
|
|
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
|
-
|
|
435
|
+
if (immediate) return 0;
|
|
436
|
+
const delay = backoff(c.attempt);
|
|
408
437
|
setStatus(c, {
|
|
409
|
-
status: "
|
|
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 === "
|
|
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 =
|
|
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: {
|
|
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
|
-
|
|
516
|
-
|
|
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();
|
package/src/client/core.ts
CHANGED
|
@@ -10,8 +10,9 @@ import { StatewireSendError } from "../types";
|
|
|
10
10
|
import type { Statewire } from "../types";
|
|
11
11
|
import type { StatewireClient } from "../statewire";
|
|
12
12
|
|
|
13
|
-
export const
|
|
14
|
-
status: "
|
|
13
|
+
export const CONNECTED_CONNECTION: StatewireClient.Connection = {
|
|
14
|
+
status: "connected",
|
|
15
|
+
degraded: false,
|
|
15
16
|
reconnect: () => {},
|
|
16
17
|
};
|
|
17
18
|
|
|
@@ -160,7 +161,7 @@ const recompute = (c: Core) => {
|
|
|
160
161
|
};
|
|
161
162
|
|
|
162
163
|
const getConnection = (c: Core): StatewireClient.Connection =>
|
|
163
|
-
c.value?.connection ??
|
|
164
|
+
c.value?.connection ?? CONNECTED_CONNECTION;
|
|
164
165
|
|
|
165
166
|
const flush = (c: Core, scheduled = false) => {
|
|
166
167
|
if (!c.value) return;
|
package/src/client/session.ts
CHANGED
|
@@ -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 === "
|
|
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(
|
|
215
|
+
body = JSON.parse(raw);
|
|
182
216
|
} catch {
|
|
183
217
|
return Response.json({ detail: "body must be JSON" }, { status: 400 });
|
|
184
218
|
}
|
|
@@ -264,9 +298,13 @@ export const frames = async (
|
|
|
264
298
|
if (h.disposed || h.finished !== null || rec === undefined || !live) {
|
|
265
299
|
return Response.json({ error: "stale-lease" }, { status: 423 });
|
|
266
300
|
}
|
|
301
|
+
const raw = await readFrameBody(request);
|
|
302
|
+
if (raw === null) {
|
|
303
|
+
return Response.json({ error: "frame body too large" }, { status: 413 });
|
|
304
|
+
}
|
|
267
305
|
let frame: ParsedFrame;
|
|
268
306
|
try {
|
|
269
|
-
frame = parseFrame(
|
|
307
|
+
frame = parseFrame(raw, false);
|
|
270
308
|
} catch (error) {
|
|
271
309
|
if (!(error instanceof Error) || error.name !== "StatewireViolation") {
|
|
272
310
|
throw error;
|
package/src/host/socket.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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) =>
|
|
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(() =>
|
|
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)),
|