tempest-react-sdk 0.51.0 → 0.53.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 +35 -5
- package/dist/audio/audio-bus.cjs +2 -0
- package/dist/audio/audio-bus.cjs.map +1 -0
- package/dist/audio/audio-bus.js +116 -0
- package/dist/audio/audio-bus.js.map +1 -0
- package/dist/audio/use-audio-bus.cjs +2 -0
- package/dist/audio/use-audio-bus.cjs.map +1 -0
- package/dist/audio/use-audio-bus.js +11 -0
- package/dist/audio/use-audio-bus.js.map +1 -0
- package/dist/auth/refresh-queue.cjs +1 -1
- package/dist/auth/refresh-queue.cjs.map +1 -1
- package/dist/auth/refresh-queue.js +6 -6
- package/dist/auth/refresh-queue.js.map +1 -1
- package/dist/components/RangeSlider/RangeSlider.cjs +1 -1
- package/dist/components/RangeSlider/RangeSlider.cjs.map +1 -1
- package/dist/components/RangeSlider/RangeSlider.js +23 -23
- package/dist/components/RangeSlider/RangeSlider.js.map +1 -1
- package/dist/components/Scheduler/Scheduler.cjs +1 -1
- package/dist/components/Scheduler/Scheduler.cjs.map +1 -1
- package/dist/components/Scheduler/Scheduler.js +1 -1
- package/dist/components/Scheduler/Scheduler.js.map +1 -1
- package/dist/components/Scheduler/Scheduler.module.cjs.map +1 -1
- package/dist/components/Scheduler/Scheduler.module.js.map +1 -1
- package/dist/components/Slider/Slider.cjs +1 -1
- package/dist/components/Slider/Slider.cjs.map +1 -1
- package/dist/components/Slider/Slider.js +9 -9
- package/dist/components/Slider/Slider.js.map +1 -1
- package/dist/forms/FormField.cjs +1 -1
- package/dist/forms/FormField.cjs.map +1 -1
- package/dist/forms/FormField.js +23 -21
- package/dist/forms/FormField.js.map +1 -1
- package/dist/http/describe-api-error.cjs +1 -1
- package/dist/http/describe-api-error.cjs.map +1 -1
- package/dist/http/describe-api-error.js +4 -2
- package/dist/http/describe-api-error.js.map +1 -1
- package/dist/http/use-describe-api-error.cjs +1 -1
- package/dist/http/use-describe-api-error.cjs.map +1 -1
- package/dist/http/use-describe-api-error.js +3 -2
- package/dist/http/use-describe-api-error.js.map +1 -1
- package/dist/offline/create-offline-database.cjs +2 -0
- package/dist/offline/create-offline-database.cjs.map +1 -0
- package/dist/offline/create-offline-database.js +29 -0
- package/dist/offline/create-offline-database.js.map +1 -0
- package/dist/offline/create-offline-store.cjs +1 -1
- package/dist/offline/create-offline-store.cjs.map +1 -1
- package/dist/offline/create-offline-store.js +29 -25
- package/dist/offline/create-offline-store.js.map +1 -1
- package/dist/query/QueryProvider.cjs +1 -1
- package/dist/query/QueryProvider.cjs.map +1 -1
- package/dist/query/QueryProvider.js +13 -11
- package/dist/query/QueryProvider.js.map +1 -1
- package/dist/query/foreign-client-warning.cjs +2 -0
- package/dist/query/foreign-client-warning.cjs.map +1 -0
- package/dist/query/foreign-client-warning.js +12 -0
- package/dist/query/foreign-client-warning.js.map +1 -0
- package/dist/tempest-react-sdk.cjs +1 -1
- package/dist/tempest-react-sdk.d.ts +695 -41
- package/dist/tempest-react-sdk.js +52 -46
- package/dist/utils/duplicate-instance.cjs +2 -0
- package/dist/utils/duplicate-instance.cjs.map +1 -0
- package/dist/utils/duplicate-instance.js +6 -0
- package/dist/utils/duplicate-instance.js.map +1 -0
- package/dist/utils/format.cjs +1 -1
- package/dist/utils/format.cjs.map +1 -1
- package/dist/utils/format.js +8 -3
- package/dist/utils/format.js.map +1 -1
- package/dist/webrtc/opus-sdp.cjs +3 -0
- package/dist/webrtc/opus-sdp.cjs.map +1 -0
- package/dist/webrtc/opus-sdp.js +94 -0
- package/dist/webrtc/opus-sdp.js.map +1 -0
- package/dist/webrtc/sender-bitrate.cjs +2 -0
- package/dist/webrtc/sender-bitrate.cjs.map +1 -0
- package/dist/webrtc/sender-bitrate.js +15 -0
- package/dist/webrtc/sender-bitrate.js.map +1 -0
- package/dist/ws/create-web-socket.cjs +1 -1
- package/dist/ws/create-web-socket.cjs.map +1 -1
- package/dist/ws/create-web-socket.js +123 -47
- package/dist/ws/create-web-socket.js.map +1 -1
- package/dist/ws/resilience.cjs +2 -0
- package/dist/ws/resilience.cjs.map +1 -0
- package/dist/ws/resilience.js +21 -0
- package/dist/ws/resilience.js.map +1 -0
- package/dist/ws/use-web-socket.cjs +1 -1
- package/dist/ws/use-web-socket.cjs.map +1 -1
- package/dist/ws/use-web-socket.js +37 -23
- package/dist/ws/use-web-socket.js.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"create-web-socket.cjs","names":[],"sources":["../../src/ws/create-web-socket.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines, function-lines — reconnect with backoff, heartbeat,\n * the send queue that survives a disconnect and the listener set that must be re-\n * attached to each new socket — one connection's lifetime, one closure. The queue\n * and the reconnect timer are the same decision seen twice.\n */\nexport type WebSocketStatus = \"idle\" | \"connecting\" | \"open\" | \"closing\" | \"closed\" | \"error\";\n\nexport interface WebSocketMessage<T> {\n /** Parsed payload — JSON-decoded when possible, raw string otherwise. */\n data: T;\n /** The original `MessageEvent`. */\n raw: MessageEvent;\n}\n\nexport interface CreateWebSocketOptions<T> {\n /** Subprotocol(s) forwarded to the `WebSocket` constructor. */\n protocols?: string | string[];\n /** Max reconnect attempts. Default: 10. Pass 0 to disable. */\n maxRetries?: number;\n /** Initial backoff (ms). Doubles each attempt, capped at `maxBackoff`. Default: 1000. */\n initialBackoff?: number;\n /** Maximum backoff (ms). Default: 30000. */\n maxBackoff?: number;\n /**\n * Ping interval (ms). When set, the client sends `pingPayload` periodically\n * to keep the socket alive. Default: 0 (disabled).\n *\n * Leave it off against a `tempest-fastapi-sdk` server: that server pings on\n * its own and answers a client-sent `{\"type\":\"ping\"}` with nothing, while a\n * strict handler rejects the unknown frame. What it needs from the client\n * is the `pong` reply, which `respondToPing` sends for you.\n */\n pingInterval?: number;\n /** Payload sent on each ping. Default: `JSON.stringify({ type: \"ping\" })`. */\n pingPayload?: string | Blob | BufferSource;\n /**\n * Reply to a server `{\"type\":\"ping\"}` with `pongPayload`. Default: true.\n *\n * `tempest-fastapi-sdk` closes a socket with code `4408` when no `pong`\n * arrives within `WS_HEARTBEAT_TIMEOUT_SECONDS`, so a client that stays\n * silent is dropped once per timeout. The ping is still forwarded to\n * `onMessage` — the reply is sent before your handler runs.\n */\n respondToPing?: boolean;\n /** Payload sent in reply to a server ping. Default: `JSON.stringify({ type: \"pong\" })`. */\n pongPayload?: string | Blob | BufferSource;\n /**\n * Buffer payloads sent while the socket is not open and flush them on the\n * next `open`. Default: false — `send()` returns false and drops.\n *\n * Without it, an action fired during reconnect backoff vanishes and the UI\n * cannot tell \"never sent\" from \"sent and ignored\".\n */\n queueWhileClosed?: boolean;\n /** Cap on buffered payloads when `queueWhileClosed` is on. Default: 100. */\n maxQueuedMessages?: number;\n /** Parse incoming frames. Default: JSON with raw-string fallback. */\n parser?: (raw: string) => T;\n onOpen?: (event: Event) => void;\n onMessage?: (message: WebSocketMessage<T>) => void;\n onClose?: (event: CloseEvent) => void;\n onError?: (event: Event) => void;\n onStatusChange?: (status: WebSocketStatus) => void;\n}\n\nexport interface WebSocketController {\n /** Send a payload over the current connection. No-op when not open. */\n send: (payload: string | Blob | BufferSource) => boolean;\n /** Close the connection and stop reconnecting. */\n close: (code?: number, reason?: string) => void;\n /** Force an immediate reconnect, resetting the retry counter. */\n reconnect: () => void;\n /** Current connection status. */\n readonly status: WebSocketStatus;\n}\n\nfunction defaultParser<T>(raw: string): T {\n try {\n return JSON.parse(raw) as T;\n } catch {\n return raw as unknown as T;\n }\n}\n\n/**\n * Open a WebSocket with automatic exponential-backoff reconnect, optional\n * heartbeat pings, and typed JSON parsing.\n *\n * @param url - Full ws:// or wss:// URL.\n * @param options - Connection configuration and callbacks.\n * @returns Controller exposing `send`, `close`, `reconnect`, and `status`.\n */\nexport function createWebSocket<T = unknown>(\n url: string,\n options: CreateWebSocketOptions<T> = {},\n): WebSocketController {\n const {\n protocols,\n maxRetries = 10,\n initialBackoff = 1000,\n maxBackoff = 30000,\n pingInterval = 0,\n pingPayload = JSON.stringify({ type: \"ping\" }),\n respondToPing = true,\n pongPayload = JSON.stringify({ type: \"pong\" }),\n queueWhileClosed = false,\n maxQueuedMessages = 100,\n parser = defaultParser<T>,\n onOpen,\n onMessage,\n onClose,\n onError,\n onStatusChange,\n } = options;\n\n let socket: WebSocket | null = null;\n let retryTimer: ReturnType<typeof setTimeout> | null = null;\n let pingTimer: ReturnType<typeof setInterval> | null = null;\n let retries = 0;\n let status: WebSocketStatus = \"idle\";\n let closed = false;\n const outbox: Array<string | Blob | BufferSource> = [];\n\n /** True for a decoded frame that is the server's heartbeat ping. */\n function isServerPing(data: unknown): boolean {\n return (\n typeof data === \"object\" &&\n data !== null &&\n (data as { type?: unknown }).type === \"ping\"\n );\n }\n\n /** Send everything buffered while the socket was down, oldest first. */\n function flushOutbox(ws: WebSocket): void {\n while (outbox.length > 0 && ws.readyState === WebSocket.OPEN) {\n ws.send(outbox.shift()!);\n }\n }\n\n function setStatus(next: WebSocketStatus): void {\n if (status === next) return;\n status = next;\n onStatusChange?.(next);\n }\n\n function clearPing(): void {\n if (pingTimer) {\n clearInterval(pingTimer);\n pingTimer = null;\n }\n }\n\n function startPing(): void {\n if (!pingInterval || pingInterval <= 0) return;\n clearPing();\n pingTimer = setInterval(() => {\n if (socket?.readyState === WebSocket.OPEN) {\n socket.send(pingPayload);\n }\n }, pingInterval);\n }\n\n function scheduleReconnect(): void {\n if (closed) return;\n if (retries >= maxRetries) {\n setStatus(\"error\");\n return;\n }\n const delay = Math.min(initialBackoff * 2 ** retries, maxBackoff);\n retries += 1;\n retryTimer = setTimeout(connect, delay);\n }\n\n function connect(): void {\n if (closed) return;\n if (socket) {\n const previous = socket;\n previous.onmessage = null;\n previous.onclose = null;\n previous.onerror = null;\n if (previous.readyState !== WebSocket.CONNECTING) previous.onopen = null;\n closeSocket(previous);\n }\n setStatus(\"connecting\");\n\n const ws = new WebSocket(url, protocols);\n socket = ws;\n\n ws.onopen = (event) => {\n retries = 0;\n setStatus(\"open\");\n startPing();\n flushOutbox(ws);\n onOpen?.(event);\n };\n\n ws.onmessage = (event) => {\n const raw = typeof event.data === \"string\" ? event.data : \"\";\n const data = parser(raw);\n if (respondToPing && isServerPing(data) && ws.readyState === WebSocket.OPEN) {\n ws.send(pongPayload);\n }\n onMessage?.({ data, raw: event });\n };\n\n ws.onerror = (event) => {\n onError?.(event);\n };\n\n ws.onclose = (event) => {\n clearPing();\n onClose?.(event);\n socket = null;\n setStatus(\"closed\");\n if (!closed && !event.wasClean) {\n scheduleReconnect();\n }\n };\n }\n\n function send(payload: string | Blob | BufferSource): boolean {\n if (socket?.readyState === WebSocket.OPEN) {\n socket.send(payload);\n return true;\n }\n if (!queueWhileClosed || closed) return false;\n if (outbox.length >= maxQueuedMessages) outbox.shift();\n outbox.push(payload);\n return true;\n }\n\n function close(code?: number, reason?: string): void {\n closed = true;\n if (retryTimer) {\n clearTimeout(retryTimer);\n retryTimer = null;\n }\n clearPing();\n retries = 0;\n outbox.length = 0;\n if (socket) {\n setStatus(\"closing\");\n closeSocket(socket, code, reason);\n socket = null;\n }\n setStatus(\"closed\");\n }\n\n /**\n * Close a socket without the \"closed before the connection is established\"\n * console warning.\n *\n * A socket still in `CONNECTING` cannot be closed cleanly — the browser\n * logs that warning on every attempt. React's StrictMode mounts, unmounts\n * and remounts each component in development, so the first socket is\n * always torn down mid-handshake and the message shows up in every dev\n * session of every app using the hook. Deferring the close to `onopen`\n * costs one round trip and keeps the console usable.\n */\n function closeSocket(ws: WebSocket, code?: number, reason?: string): void {\n if (ws.readyState === WebSocket.CONNECTING) {\n ws.onopen = () => ws.close(code, reason);\n ws.onmessage = null;\n ws.onerror = null;\n ws.onclose = null;\n return;\n }\n ws.close(code, reason);\n }\n\n function reconnect(): void {\n if (retryTimer) {\n clearTimeout(retryTimer);\n retryTimer = null;\n }\n retries = 0;\n closed = false;\n connect();\n }\n\n connect();\n\n return {\n send,\n close,\n reconnect,\n get status() {\n return status;\n },\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"create-web-socket.cjs","names":[],"sources":["../../src/ws/create-web-socket.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines, function-lines — reconnect with backoff, heartbeat,\n * the handshake and silence timers that detect a link which never fails out loud,\n * the send queue that survives a disconnect and the listener set that must be re-\n * attached to each new socket — one connection's lifetime, one closure. The queue\n * and the reconnect timer are the same decision seen twice.\n */\nimport {\n backoffDelay,\n isRejectionCloseCode,\n shouldRetryClose,\n type WebSocketLostReason,\n} from \"./resilience\";\n\nexport type WebSocketStatus = \"idle\" | \"connecting\" | \"open\" | \"closing\" | \"closed\" | \"error\";\n\nexport interface WebSocketMessage<T> {\n /** Parsed payload — JSON-decoded when possible, raw string otherwise. */\n data: T;\n /** The original `MessageEvent`. */\n raw: MessageEvent;\n}\n\nexport interface CreateWebSocketOptions<T> {\n /** Subprotocol(s) forwarded to the `WebSocket` constructor. */\n protocols?: string | string[];\n /** Max reconnect attempts. Default: 10. Pass 0 to disable. */\n maxRetries?: number;\n /** Initial backoff (ms). Doubles each attempt, capped at `maxBackoff`. Default: 1000. */\n initialBackoff?: number;\n /** Maximum backoff (ms). Default: 30000. */\n maxBackoff?: number;\n /**\n * Fraction of each backoff delay added at random, 0–1. Default: 0.3.\n *\n * Matters when the *server* is what went down: every client retries on the\n * same schedule, so the box comes back up into a synchronized stampede. Pass\n * `0` for a fixed schedule.\n */\n jitter?: number;\n /**\n * How long one handshake may stay in `CONNECTING` before the attempt is\n * abandoned and retried (ms). Default: 8000. Pass 0 to disable.\n *\n * A `WebSocket` that cannot reach its server does not necessarily fail: it\n * sits in `CONNECTING` firing neither `open` nor `close` nor `error`. A retry\n * chain built only on those events stops on its first hung attempt and never\n * moves again — and hung, rather than refused, is precisely how a bad mobile\n * link behaves, which is the case reconnection exists for.\n */\n handshakeTimeout?: number;\n /**\n * Silence tolerated on an open socket before the link is treated as dead (ms).\n * Default: 0 (off).\n *\n * The socket only reports a connection that closes cleanly. A link that dies\n * mid-flight leaves `readyState` at `OPEN` on this side with nothing ever\n * arriving again, so silence is the only symptom available. The timer is\n * re-armed by **any** inbound frame, not just by pings — traffic is traffic.\n *\n * Set it to a comfortable multiple of the server's ping interval (2.5× is a\n * good default) so one dropped ping is not mistaken for an outage. When the\n * server announces its own interval in the handshake, feed that back with\n * {@link WebSocketController.setSilenceTimeout} instead of hard-coding the\n * value on both ends.\n */\n silenceTimeout?: number;\n /**\n * Suspend the retry schedule while `navigator.onLine` is false, and resume on\n * the `online` event. Default: true.\n *\n * Burning retries against a radio that is switched off is how a phone\n * exhausts its budget inside a tunnel and gives up exactly when it comes out\n * the other side.\n */\n waitForOnline?: boolean;\n /**\n * Ping interval (ms). When set, the client sends `pingPayload` periodically\n * to keep the socket alive. Default: 0 (disabled).\n *\n * Leave it off against a `tempest-fastapi-sdk` server: that server pings on\n * its own and answers a client-sent `{\"type\":\"ping\"}` with nothing, while a\n * strict handler rejects the unknown frame. What it needs from the client\n * is the `pong` reply, which `respondToPing` sends for you.\n */\n pingInterval?: number;\n /** Payload sent on each ping. Default: `JSON.stringify({ type: \"ping\" })`. */\n pingPayload?: string | Blob | BufferSource;\n /**\n * Reply to a server `{\"type\":\"ping\"}` with `pongPayload`. Default: true.\n *\n * `tempest-fastapi-sdk` closes a socket with code `4408` when no `pong`\n * arrives within `WS_HEARTBEAT_TIMEOUT_SECONDS`, so a client that stays\n * silent is dropped once per timeout. The ping is still forwarded to\n * `onMessage` — the reply is sent before your handler runs.\n */\n respondToPing?: boolean;\n /** Payload sent in reply to a server ping. Default: `JSON.stringify({ type: \"pong\" })`. */\n pongPayload?: string | Blob | BufferSource;\n /**\n * Buffer payloads sent while the socket is not open and flush them on the\n * next `open`. Default: false — `send()` returns false and drops.\n *\n * Without it, an action fired during reconnect backoff vanishes and the UI\n * cannot tell \"never sent\" from \"sent and ignored\".\n */\n queueWhileClosed?: boolean;\n /** Cap on buffered payloads when `queueWhileClosed` is on. Default: 100. */\n maxQueuedMessages?: number;\n /** Parse incoming frames. Default: JSON with raw-string fallback. */\n parser?: (raw: string) => T;\n onOpen?: (event: Event) => void;\n onMessage?: (message: WebSocketMessage<T>) => void;\n onClose?: (event: CloseEvent) => void;\n onError?: (event: Event) => void;\n onStatusChange?: (status: WebSocketStatus) => void;\n /**\n * A retry has been scheduled. `attempt` is 1-based, `total` is `maxRetries`.\n *\n * Reconnecting is not an error and reads badly as one: announcing every\n * attempt puts a fresh \"the connection dropped\" in front of someone whose\n * session is in the middle of coming back on its own. Show a quiet\n * reconnecting state here and treat {@link CreateWebSocketOptions.onLost} as\n * the failure.\n */\n onReconnecting?: (attempt: number, total: number) => void;\n /**\n * The socket is back up after at least one retry.\n *\n * Nothing is resumed for you: a server that keys state by connection sees a\n * brand-new client, so this is where the caller re-subscribes, re-joins or\n * refetches whatever the gap invalidated.\n */\n onReconnected?: () => void;\n /**\n * No further attempt will be made — `\"rejected\"` when the server refused the\n * client outright (close code 4400–4499, minus the 4408 heartbeat timeout),\n * `\"exhausted\"` when the schedule ran out.\n *\n * This is the one that deserves UI, because it is the only state the caller\n * can act on: offer a \"try again\" that calls\n * {@link WebSocketController.reconnect}.\n */\n onLost?: (reason: WebSocketLostReason) => void;\n}\n\nexport interface WebSocketController {\n /** Send a payload over the current connection. No-op when not open. */\n send: (payload: string | Blob | BufferSource) => boolean;\n /** Close the connection and stop reconnecting. */\n close: (code?: number, reason?: string) => void;\n /** Force an immediate reconnect, resetting the retry counter. */\n reconnect: () => void;\n /**\n * Change the silence watchdog at runtime, in ms. `0` disables it.\n *\n * For the common case where the server announces its heartbeat interval in\n * the first frame, so the tolerated silence is not hard-coded on both ends:\n *\n * ```ts\n * onMessage: ({ data }) => {\n * if (data.type === \"welcome\") socket.setSilenceTimeout(data.heartbeat_seconds * 2500);\n * }\n * ```\n */\n setSilenceTimeout: (ms: number) => void;\n /**\n * Resolves on the first successful open, rejects when the socket is lost\n * before ever opening.\n *\n * Joining and dropping are different events: a call that never connected has\n * to be reported, while one that dropped mid-session should reconnect\n * quietly. Await this for the join, handle\n * {@link CreateWebSocketOptions.onLost} for the drop. Pair it with\n * `maxRetries: 0` when the first attempt should fail fast instead of\n * spending the whole schedule on a server that is not there.\n */\n opened: Promise<void>;\n /** Current connection status. */\n readonly status: WebSocketStatus;\n}\n\nfunction defaultParser<T>(raw: string): T {\n try {\n return JSON.parse(raw) as T;\n } catch {\n return raw as unknown as T;\n }\n}\n\n/**\n * Open a WebSocket that survives a bad network: exponential backoff with jitter,\n * a handshake timeout, a silence watchdog, optional heartbeat pings and typed\n * JSON parsing.\n *\n * Three failure modes are covered that an event-driven retry loop misses on its\n * own, because none of them fire an event: a handshake that hangs instead of\n * failing, an open socket whose link died mid-flight, and a device with its\n * radio off burning the retry budget. See `handshakeTimeout`, `silenceTimeout`\n * and `waitForOnline`.\n *\n * @param url - Full ws:// or wss:// URL.\n * @param options - Connection configuration and callbacks.\n * @returns Controller exposing `send`, `close`, `reconnect`, `setSilenceTimeout`,\n * `opened` and `status`.\n *\n * @example\n * const socket = createWebSocket(url, {\n * silenceTimeout: 75_000,\n * onReconnecting: (n, total) => setBanner(`Reconectando ${n}/${total}…`),\n * onReconnected: () => refetchEverything(),\n * onLost: (reason) => setBanner(reason === \"rejected\" ? \"Acesso negado\" : \"Sem conexão\"),\n * });\n * await socket.opened;\n */\nexport function createWebSocket<T = unknown>(\n url: string,\n options: CreateWebSocketOptions<T> = {},\n): WebSocketController {\n const {\n protocols,\n maxRetries = 10,\n initialBackoff = 1000,\n maxBackoff = 30000,\n jitter = 0.3,\n handshakeTimeout = 8000,\n silenceTimeout = 0,\n waitForOnline = true,\n pingInterval = 0,\n pingPayload = JSON.stringify({ type: \"ping\" }),\n respondToPing = true,\n pongPayload = JSON.stringify({ type: \"pong\" }),\n queueWhileClosed = false,\n maxQueuedMessages = 100,\n parser = defaultParser<T>,\n onOpen,\n onMessage,\n onClose,\n onError,\n onStatusChange,\n onReconnecting,\n onReconnected,\n onLost,\n } = options;\n\n let socket: WebSocket | null = null;\n let retryTimer: ReturnType<typeof setTimeout> | null = null;\n let pingTimer: ReturnType<typeof setInterval> | null = null;\n let handshakeTimer: ReturnType<typeof setTimeout> | null = null;\n let silenceTimer: ReturnType<typeof setTimeout> | null = null;\n let onlineListener: (() => void) | null = null;\n let silenceWindow = silenceTimeout;\n let retries = 0;\n let status: WebSocketStatus = \"idle\";\n let closed = false;\n let everOpened = false;\n const outbox: Array<string | Blob | BufferSource> = [];\n\n let settleOpen: (() => void) | null = null;\n let failOpen: ((error: Error) => void) | null = null;\n const opened = new Promise<void>((resolve, reject) => {\n settleOpen = resolve;\n failOpen = reject;\n });\n opened.catch(() => undefined);\n\n /** True for a decoded frame that is the server's heartbeat ping. */\n function isServerPing(data: unknown): boolean {\n return (\n typeof data === \"object\" &&\n data !== null &&\n (data as { type?: unknown }).type === \"ping\"\n );\n }\n\n /** Send everything buffered while the socket was down, oldest first. */\n function flushOutbox(ws: WebSocket): void {\n while (outbox.length > 0 && ws.readyState === WebSocket.OPEN) {\n ws.send(outbox.shift()!);\n }\n }\n\n function setStatus(next: WebSocketStatus): void {\n if (status === next) return;\n status = next;\n onStatusChange?.(next);\n }\n\n function clearPing(): void {\n if (pingTimer) {\n clearInterval(pingTimer);\n pingTimer = null;\n }\n }\n\n function clearHandshake(): void {\n if (handshakeTimer) {\n clearTimeout(handshakeTimer);\n handshakeTimer = null;\n }\n }\n\n function clearSilence(): void {\n if (silenceTimer) {\n clearTimeout(silenceTimer);\n silenceTimer = null;\n }\n }\n\n function startPing(): void {\n if (!pingInterval || pingInterval <= 0) return;\n clearPing();\n pingTimer = setInterval(() => {\n if (socket?.readyState === WebSocket.OPEN) {\n socket.send(pingPayload);\n }\n }, pingInterval);\n }\n\n /**\n * Restart the silence timer, because something just arrived.\n *\n * Armed off any inbound frame rather than off pongs alone: a busy exchange\n * already proves the link is carrying data, and a protocol whose pings the\n * client never sees would otherwise reconnect in the middle of working\n * traffic.\n */\n function armSilence(): void {\n clearSilence();\n if (closed || silenceWindow <= 0) return;\n silenceTimer = setTimeout(onSilence, silenceWindow);\n }\n\n /**\n * Treat a socket that went quiet as dead and start reconnecting.\n *\n * Handlers are detached before closing so the synthetic `close` does not also\n * schedule a retry — that would advance the backoff twice for one failure and\n * halve the time the connection is given to recover.\n */\n function onSilence(): void {\n clearSilence();\n if (closed || !socket) return;\n detach(socket);\n socket = null;\n setStatus(\"closed\");\n scheduleReconnect();\n }\n\n /**\n * Abandon a handshake that never resolved either way.\n *\n * The socket is closed while still `CONNECTING`, which is the one case the\n * console warns about — and the right trade here, because the alternative is\n * deferring the close to an `open` event that by definition is not coming.\n */\n function abandonHandshake(ws: WebSocket): void {\n clearHandshake();\n if (closed || ws.readyState !== WebSocket.CONNECTING) return;\n detach(ws);\n if (socket === ws) socket = null;\n setStatus(\"closed\");\n scheduleReconnect();\n }\n\n /** Drop every handler and close, so the socket can die without being heard. */\n function detach(ws: WebSocket): void {\n ws.onopen = null;\n ws.onmessage = null;\n ws.onerror = null;\n ws.onclose = null;\n try {\n ws.close();\n } catch {\n /* already unusable — nothing to release and nothing to report */\n }\n }\n\n /** Stop for good, telling the caller which of the two dead ends it is. */\n function lose(reason: WebSocketLostReason): void {\n clearSilence();\n clearHandshake();\n clearNetworkWait();\n if (reason === \"rejected\") closed = true;\n setStatus(\"error\");\n if (!everOpened && failOpen) {\n const reject = failOpen;\n failOpen = null;\n settleOpen = null;\n reject(new Error(`websocket_${reason}`));\n }\n onLost?.(reason);\n }\n\n /**\n * Queue the next attempt, or wait for the network when there is none.\n *\n * While the browser reports no connectivity the schedule is suspended and the\n * `online` event drives the next attempt instead, so a device in a tunnel\n * does not spend its whole budget before coming out the other side.\n */\n function scheduleReconnect(): void {\n if (closed) return;\n if (retries >= maxRetries) {\n lose(\"exhausted\");\n return;\n }\n const delay = backoffDelay(retries, { initialBackoff, maxBackoff, jitter });\n retries += 1;\n onReconnecting?.(retries, maxRetries);\n\n if (waitForOnline && typeof navigator !== \"undefined\" && navigator.onLine === false) {\n waitForNetwork();\n return;\n }\n retryTimer = setTimeout(connect, delay);\n }\n\n function waitForNetwork(): void {\n if (onlineListener || typeof window === \"undefined\") return;\n const listener = (): void => {\n clearNetworkWait();\n if (!closed) connect();\n };\n onlineListener = listener;\n window.addEventListener(\"online\", listener);\n }\n\n function clearNetworkWait(): void {\n if (!onlineListener || typeof window === \"undefined\") return;\n window.removeEventListener(\"online\", onlineListener);\n onlineListener = null;\n }\n\n /**\n * Open a socket, replacing whatever is there.\n *\n * The handshake timer is cleared first because it belongs to the socket\n * being replaced, and it holds a reference to it: left armed, it fires later\n * against a connection nobody is waiting for, clears the *new* socket's\n * timer on its way through, and schedules a retry that drops a connection\n * still in flight. `reconnect()` on a hung socket is the path that reaches\n * it.\n */\n function connect(): void {\n if (closed) return;\n retryTimer = null;\n clearHandshake();\n if (socket) {\n const previous = socket;\n previous.onmessage = null;\n previous.onclose = null;\n previous.onerror = null;\n if (previous.readyState !== WebSocket.CONNECTING) previous.onopen = null;\n closeSocket(previous);\n }\n setStatus(\"connecting\");\n\n const ws = new WebSocket(url, protocols);\n socket = ws;\n\n if (handshakeTimeout > 0) {\n handshakeTimer = setTimeout(() => abandonHandshake(ws), handshakeTimeout);\n }\n\n ws.onopen = (event) => {\n clearHandshake();\n const recovered = retries > 0;\n retries = 0;\n everOpened = true;\n setStatus(\"open\");\n startPing();\n armSilence();\n flushOutbox(ws);\n if (settleOpen) {\n const resolve = settleOpen;\n settleOpen = null;\n failOpen = null;\n resolve();\n }\n onOpen?.(event);\n if (recovered) onReconnected?.();\n };\n\n ws.onmessage = (event) => {\n armSilence();\n const raw = typeof event.data === \"string\" ? event.data : \"\";\n const data = parser(raw);\n if (respondToPing && isServerPing(data) && ws.readyState === WebSocket.OPEN) {\n ws.send(pongPayload);\n }\n onMessage?.({ data, raw: event });\n };\n\n ws.onerror = (event) => {\n onError?.(event);\n };\n\n /**\n * Classify the close before deciding anything.\n *\n * Three outcomes, in order: a refusal never gets better by trying again;\n * a died-in-flight or temporarily-unavailable close is retried; an\n * ordinary goodbye (a clean 1000) is the session ending on purpose and\n * deserves no error. The one exception is a goodbye on a socket that\n * never opened — the server hung up during the handshake, which the\n * caller awaiting `opened` has to hear about.\n */\n ws.onclose = (event) => {\n clearHandshake();\n clearPing();\n clearSilence();\n onClose?.(event);\n socket = null;\n setStatus(\"closed\");\n if (closed) return;\n if (isRejectionCloseCode(event.code)) {\n lose(\"rejected\");\n return;\n }\n if (shouldRetryClose(event.code, event.wasClean)) {\n scheduleReconnect();\n return;\n }\n if (!everOpened) lose(\"rejected\");\n };\n }\n\n function send(payload: string | Blob | BufferSource): boolean {\n if (socket?.readyState === WebSocket.OPEN) {\n socket.send(payload);\n return true;\n }\n if (!queueWhileClosed || closed) return false;\n if (outbox.length >= maxQueuedMessages) outbox.shift();\n outbox.push(payload);\n return true;\n }\n\n function close(code?: number, reason?: string): void {\n closed = true;\n if (retryTimer) {\n clearTimeout(retryTimer);\n retryTimer = null;\n }\n clearPing();\n clearHandshake();\n clearSilence();\n clearNetworkWait();\n retries = 0;\n outbox.length = 0;\n if (failOpen) {\n const reject = failOpen;\n failOpen = null;\n settleOpen = null;\n reject(new Error(\"websocket_closed\"));\n }\n if (socket) {\n setStatus(\"closing\");\n closeSocket(socket, code, reason);\n socket = null;\n }\n setStatus(\"closed\");\n }\n\n /**\n * Close a socket without the \"closed before the connection is established\"\n * console warning.\n *\n * A socket still in `CONNECTING` cannot be closed cleanly — the browser\n * logs that warning on every attempt. React's StrictMode mounts, unmounts\n * and remounts each component in development, so the first socket is\n * always torn down mid-handshake and the message shows up in every dev\n * session of every app using the hook. Deferring the close to `onopen`\n * costs one round trip and keeps the console usable.\n */\n function closeSocket(ws: WebSocket, code?: number, reason?: string): void {\n if (ws.readyState === WebSocket.CONNECTING) {\n ws.onopen = () => ws.close(code, reason);\n ws.onmessage = null;\n ws.onerror = null;\n ws.onclose = null;\n return;\n }\n ws.close(code, reason);\n }\n\n function reconnect(): void {\n if (retryTimer) {\n clearTimeout(retryTimer);\n retryTimer = null;\n }\n clearNetworkWait();\n retries = 0;\n closed = false;\n connect();\n }\n\n function setSilenceTimeout(ms: number): void {\n silenceWindow = Number.isFinite(ms) && ms > 0 ? ms : 0;\n if (socket?.readyState === WebSocket.OPEN) armSilence();\n else clearSilence();\n }\n\n connect();\n\n return {\n send,\n close,\n reconnect,\n setSilenceTimeout,\n opened,\n get status() {\n return status;\n },\n };\n}\n"],"mappings":"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"}
|
|
@@ -1,87 +1,163 @@
|
|
|
1
|
+
import { backoffDelay as e, isRejectionCloseCode as t, shouldRetryClose as n } from "./resilience.js";
|
|
1
2
|
//#region src/ws/create-web-socket.ts
|
|
2
|
-
function
|
|
3
|
+
function r(e) {
|
|
3
4
|
try {
|
|
4
5
|
return JSON.parse(e);
|
|
5
6
|
} catch {
|
|
6
7
|
return e;
|
|
7
8
|
}
|
|
8
9
|
}
|
|
9
|
-
function
|
|
10
|
-
let { protocols:
|
|
11
|
-
|
|
10
|
+
function i(i, a = {}) {
|
|
11
|
+
let { protocols: o, maxRetries: s = 10, initialBackoff: ee = 1e3, maxBackoff: c = 3e4, jitter: te = .3, handshakeTimeout: l = 8e3, silenceTimeout: u = 0, waitForOnline: d = !0, pingInterval: f = 0, pingPayload: p = JSON.stringify({ type: "ping" }), respondToPing: m = !0, pongPayload: h = JSON.stringify({ type: "pong" }), queueWhileClosed: g = !1, maxQueuedMessages: _ = 100, parser: v = r, onOpen: y, onMessage: b, onClose: x, onError: S, onStatusChange: ne, onReconnecting: re, onReconnected: ie, onLost: ae } = a, C = null, w = null, T = null, E = null, D = null, O = null, k = u, A = 0, j = "idle", M = !1, N = !1, P = [], F = null, I = null, L = new Promise((e, t) => {
|
|
12
|
+
F = e, I = t;
|
|
13
|
+
});
|
|
14
|
+
L.catch(() => void 0);
|
|
15
|
+
function R(e) {
|
|
12
16
|
return typeof e == "object" && !!e && e.type === "ping";
|
|
13
17
|
}
|
|
14
|
-
function
|
|
15
|
-
for (;
|
|
18
|
+
function z(e) {
|
|
19
|
+
for (; P.length > 0 && e.readyState === WebSocket.OPEN;) e.send(P.shift());
|
|
16
20
|
}
|
|
17
|
-
function
|
|
18
|
-
|
|
21
|
+
function B(e) {
|
|
22
|
+
j !== e && (j = e, ne?.(e));
|
|
19
23
|
}
|
|
20
|
-
function
|
|
21
|
-
|
|
24
|
+
function V() {
|
|
25
|
+
T &&= (clearInterval(T), null);
|
|
22
26
|
}
|
|
23
|
-
function
|
|
24
|
-
|
|
25
|
-
y?.readyState === WebSocket.OPEN && y.send(c);
|
|
26
|
-
}, s));
|
|
27
|
+
function H() {
|
|
28
|
+
E &&= (clearTimeout(E), null);
|
|
27
29
|
}
|
|
28
|
-
function
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
function U() {
|
|
31
|
+
D &&= (clearTimeout(D), null);
|
|
32
|
+
}
|
|
33
|
+
function W() {
|
|
34
|
+
!f || f <= 0 || (V(), T = setInterval(() => {
|
|
35
|
+
C?.readyState === WebSocket.OPEN && C.send(p);
|
|
36
|
+
}, f));
|
|
37
|
+
}
|
|
38
|
+
function G() {
|
|
39
|
+
U(), !(M || k <= 0) && (D = setTimeout(K, k));
|
|
40
|
+
}
|
|
41
|
+
function K() {
|
|
42
|
+
U(), !(M || !C) && (J(C), C = null, B("closed"), X());
|
|
43
|
+
}
|
|
44
|
+
function q(e) {
|
|
45
|
+
H(), !(M || e.readyState !== WebSocket.CONNECTING) && (J(e), C === e && (C = null), B("closed"), X());
|
|
46
|
+
}
|
|
47
|
+
function J(e) {
|
|
48
|
+
e.onopen = null, e.onmessage = null, e.onerror = null, e.onclose = null;
|
|
49
|
+
try {
|
|
50
|
+
e.close();
|
|
51
|
+
} catch {}
|
|
52
|
+
}
|
|
53
|
+
function Y(e) {
|
|
54
|
+
if (U(), H(), Z(), e === "rejected" && (M = !0), B("error"), !N && I) {
|
|
55
|
+
let t = I;
|
|
56
|
+
I = null, F = null, t(/* @__PURE__ */ Error(`websocket_${e}`));
|
|
57
|
+
}
|
|
58
|
+
ae?.(e);
|
|
59
|
+
}
|
|
60
|
+
function X() {
|
|
61
|
+
if (M) return;
|
|
62
|
+
if (A >= s) {
|
|
63
|
+
Y("exhausted");
|
|
32
64
|
return;
|
|
33
65
|
}
|
|
34
|
-
let
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
|
|
66
|
+
let t = e(A, {
|
|
67
|
+
initialBackoff: ee,
|
|
68
|
+
maxBackoff: c,
|
|
69
|
+
jitter: te
|
|
70
|
+
});
|
|
71
|
+
if (A += 1, re?.(A, s), d && typeof navigator < "u" && navigator.onLine === !1) {
|
|
72
|
+
oe();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
w = setTimeout(Q, t);
|
|
76
|
+
}
|
|
77
|
+
function oe() {
|
|
78
|
+
if (O || typeof window > "u") return;
|
|
79
|
+
let e = () => {
|
|
80
|
+
Z(), M || Q();
|
|
81
|
+
};
|
|
82
|
+
O = e, window.addEventListener("online", e);
|
|
83
|
+
}
|
|
84
|
+
function Z() {
|
|
85
|
+
!O || typeof window > "u" || (window.removeEventListener("online", O), O = null);
|
|
86
|
+
}
|
|
87
|
+
function Q() {
|
|
88
|
+
if (M) return;
|
|
89
|
+
if (w = null, H(), C) {
|
|
90
|
+
let e = C;
|
|
91
|
+
e.onmessage = null, e.onclose = null, e.onerror = null, e.readyState !== WebSocket.CONNECTING && (e.onopen = null), $(e);
|
|
42
92
|
}
|
|
43
|
-
|
|
44
|
-
let e = new WebSocket(
|
|
45
|
-
|
|
46
|
-
|
|
93
|
+
B("connecting");
|
|
94
|
+
let e = new WebSocket(i, o);
|
|
95
|
+
C = e, l > 0 && (E = setTimeout(() => q(e), l)), e.onopen = (t) => {
|
|
96
|
+
H();
|
|
97
|
+
let n = A > 0;
|
|
98
|
+
if (A = 0, N = !0, B("open"), W(), G(), z(e), F) {
|
|
99
|
+
let e = F;
|
|
100
|
+
F = null, I = null, e();
|
|
101
|
+
}
|
|
102
|
+
y?.(t), n && ie?.();
|
|
47
103
|
}, e.onmessage = (t) => {
|
|
48
|
-
|
|
49
|
-
|
|
104
|
+
G();
|
|
105
|
+
let n = typeof t.data == "string" ? t.data : "", r = v(n);
|
|
106
|
+
m && R(r) && e.readyState === WebSocket.OPEN && e.send(h), b?.({
|
|
50
107
|
data: r,
|
|
51
108
|
raw: t
|
|
52
109
|
});
|
|
53
110
|
}, e.onerror = (e) => {
|
|
54
|
-
|
|
111
|
+
S?.(e);
|
|
55
112
|
}, e.onclose = (e) => {
|
|
56
|
-
|
|
113
|
+
if (H(), V(), U(), x?.(e), C = null, B("closed"), !M) {
|
|
114
|
+
if (t(e.code)) {
|
|
115
|
+
Y("rejected");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (n(e.code, e.wasClean)) {
|
|
119
|
+
X();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
N || Y("rejected");
|
|
123
|
+
}
|
|
57
124
|
};
|
|
58
125
|
}
|
|
59
|
-
function
|
|
60
|
-
return
|
|
126
|
+
function se(e) {
|
|
127
|
+
return C?.readyState === WebSocket.OPEN ? (C.send(e), !0) : !g || M ? !1 : (P.length >= _ && P.shift(), P.push(e), !0);
|
|
61
128
|
}
|
|
62
|
-
function
|
|
63
|
-
|
|
129
|
+
function ce(e, t) {
|
|
130
|
+
if (M = !0, w &&= (clearTimeout(w), null), V(), H(), U(), Z(), A = 0, P.length = 0, I) {
|
|
131
|
+
let e = I;
|
|
132
|
+
I = null, F = null, e(/* @__PURE__ */ Error("websocket_closed"));
|
|
133
|
+
}
|
|
134
|
+
C &&= (B("closing"), $(C, e, t), null), B("closed");
|
|
64
135
|
}
|
|
65
|
-
function
|
|
136
|
+
function $(e, t, n) {
|
|
66
137
|
if (e.readyState === WebSocket.CONNECTING) {
|
|
67
138
|
e.onopen = () => e.close(t, n), e.onmessage = null, e.onerror = null, e.onclose = null;
|
|
68
139
|
return;
|
|
69
140
|
}
|
|
70
141
|
e.close(t, n);
|
|
71
142
|
}
|
|
72
|
-
function
|
|
73
|
-
|
|
143
|
+
function le() {
|
|
144
|
+
w &&= (clearTimeout(w), null), Z(), A = 0, M = !1, Q();
|
|
145
|
+
}
|
|
146
|
+
function ue(e) {
|
|
147
|
+
k = Number.isFinite(e) && e > 0 ? e : 0, C?.readyState === WebSocket.OPEN ? G() : U();
|
|
74
148
|
}
|
|
75
|
-
return
|
|
76
|
-
send:
|
|
77
|
-
close:
|
|
78
|
-
reconnect:
|
|
149
|
+
return Q(), {
|
|
150
|
+
send: se,
|
|
151
|
+
close: ce,
|
|
152
|
+
reconnect: le,
|
|
153
|
+
setSilenceTimeout: ue,
|
|
154
|
+
opened: L,
|
|
79
155
|
get status() {
|
|
80
|
-
return
|
|
156
|
+
return j;
|
|
81
157
|
}
|
|
82
158
|
};
|
|
83
159
|
}
|
|
84
160
|
//#endregion
|
|
85
|
-
export {
|
|
161
|
+
export { i as createWebSocket };
|
|
86
162
|
|
|
87
163
|
//# sourceMappingURL=create-web-socket.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"create-web-socket.js","names":[],"sources":["../../src/ws/create-web-socket.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines, function-lines — reconnect with backoff, heartbeat,\n * the send queue that survives a disconnect and the listener set that must be re-\n * attached to each new socket — one connection's lifetime, one closure. The queue\n * and the reconnect timer are the same decision seen twice.\n */\nexport type WebSocketStatus = \"idle\" | \"connecting\" | \"open\" | \"closing\" | \"closed\" | \"error\";\n\nexport interface WebSocketMessage<T> {\n /** Parsed payload — JSON-decoded when possible, raw string otherwise. */\n data: T;\n /** The original `MessageEvent`. */\n raw: MessageEvent;\n}\n\nexport interface CreateWebSocketOptions<T> {\n /** Subprotocol(s) forwarded to the `WebSocket` constructor. */\n protocols?: string | string[];\n /** Max reconnect attempts. Default: 10. Pass 0 to disable. */\n maxRetries?: number;\n /** Initial backoff (ms). Doubles each attempt, capped at `maxBackoff`. Default: 1000. */\n initialBackoff?: number;\n /** Maximum backoff (ms). Default: 30000. */\n maxBackoff?: number;\n /**\n * Ping interval (ms). When set, the client sends `pingPayload` periodically\n * to keep the socket alive. Default: 0 (disabled).\n *\n * Leave it off against a `tempest-fastapi-sdk` server: that server pings on\n * its own and answers a client-sent `{\"type\":\"ping\"}` with nothing, while a\n * strict handler rejects the unknown frame. What it needs from the client\n * is the `pong` reply, which `respondToPing` sends for you.\n */\n pingInterval?: number;\n /** Payload sent on each ping. Default: `JSON.stringify({ type: \"ping\" })`. */\n pingPayload?: string | Blob | BufferSource;\n /**\n * Reply to a server `{\"type\":\"ping\"}` with `pongPayload`. Default: true.\n *\n * `tempest-fastapi-sdk` closes a socket with code `4408` when no `pong`\n * arrives within `WS_HEARTBEAT_TIMEOUT_SECONDS`, so a client that stays\n * silent is dropped once per timeout. The ping is still forwarded to\n * `onMessage` — the reply is sent before your handler runs.\n */\n respondToPing?: boolean;\n /** Payload sent in reply to a server ping. Default: `JSON.stringify({ type: \"pong\" })`. */\n pongPayload?: string | Blob | BufferSource;\n /**\n * Buffer payloads sent while the socket is not open and flush them on the\n * next `open`. Default: false — `send()` returns false and drops.\n *\n * Without it, an action fired during reconnect backoff vanishes and the UI\n * cannot tell \"never sent\" from \"sent and ignored\".\n */\n queueWhileClosed?: boolean;\n /** Cap on buffered payloads when `queueWhileClosed` is on. Default: 100. */\n maxQueuedMessages?: number;\n /** Parse incoming frames. Default: JSON with raw-string fallback. */\n parser?: (raw: string) => T;\n onOpen?: (event: Event) => void;\n onMessage?: (message: WebSocketMessage<T>) => void;\n onClose?: (event: CloseEvent) => void;\n onError?: (event: Event) => void;\n onStatusChange?: (status: WebSocketStatus) => void;\n}\n\nexport interface WebSocketController {\n /** Send a payload over the current connection. No-op when not open. */\n send: (payload: string | Blob | BufferSource) => boolean;\n /** Close the connection and stop reconnecting. */\n close: (code?: number, reason?: string) => void;\n /** Force an immediate reconnect, resetting the retry counter. */\n reconnect: () => void;\n /** Current connection status. */\n readonly status: WebSocketStatus;\n}\n\nfunction defaultParser<T>(raw: string): T {\n try {\n return JSON.parse(raw) as T;\n } catch {\n return raw as unknown as T;\n }\n}\n\n/**\n * Open a WebSocket with automatic exponential-backoff reconnect, optional\n * heartbeat pings, and typed JSON parsing.\n *\n * @param url - Full ws:// or wss:// URL.\n * @param options - Connection configuration and callbacks.\n * @returns Controller exposing `send`, `close`, `reconnect`, and `status`.\n */\nexport function createWebSocket<T = unknown>(\n url: string,\n options: CreateWebSocketOptions<T> = {},\n): WebSocketController {\n const {\n protocols,\n maxRetries = 10,\n initialBackoff = 1000,\n maxBackoff = 30000,\n pingInterval = 0,\n pingPayload = JSON.stringify({ type: \"ping\" }),\n respondToPing = true,\n pongPayload = JSON.stringify({ type: \"pong\" }),\n queueWhileClosed = false,\n maxQueuedMessages = 100,\n parser = defaultParser<T>,\n onOpen,\n onMessage,\n onClose,\n onError,\n onStatusChange,\n } = options;\n\n let socket: WebSocket | null = null;\n let retryTimer: ReturnType<typeof setTimeout> | null = null;\n let pingTimer: ReturnType<typeof setInterval> | null = null;\n let retries = 0;\n let status: WebSocketStatus = \"idle\";\n let closed = false;\n const outbox: Array<string | Blob | BufferSource> = [];\n\n /** True for a decoded frame that is the server's heartbeat ping. */\n function isServerPing(data: unknown): boolean {\n return (\n typeof data === \"object\" &&\n data !== null &&\n (data as { type?: unknown }).type === \"ping\"\n );\n }\n\n /** Send everything buffered while the socket was down, oldest first. */\n function flushOutbox(ws: WebSocket): void {\n while (outbox.length > 0 && ws.readyState === WebSocket.OPEN) {\n ws.send(outbox.shift()!);\n }\n }\n\n function setStatus(next: WebSocketStatus): void {\n if (status === next) return;\n status = next;\n onStatusChange?.(next);\n }\n\n function clearPing(): void {\n if (pingTimer) {\n clearInterval(pingTimer);\n pingTimer = null;\n }\n }\n\n function startPing(): void {\n if (!pingInterval || pingInterval <= 0) return;\n clearPing();\n pingTimer = setInterval(() => {\n if (socket?.readyState === WebSocket.OPEN) {\n socket.send(pingPayload);\n }\n }, pingInterval);\n }\n\n function scheduleReconnect(): void {\n if (closed) return;\n if (retries >= maxRetries) {\n setStatus(\"error\");\n return;\n }\n const delay = Math.min(initialBackoff * 2 ** retries, maxBackoff);\n retries += 1;\n retryTimer = setTimeout(connect, delay);\n }\n\n function connect(): void {\n if (closed) return;\n if (socket) {\n const previous = socket;\n previous.onmessage = null;\n previous.onclose = null;\n previous.onerror = null;\n if (previous.readyState !== WebSocket.CONNECTING) previous.onopen = null;\n closeSocket(previous);\n }\n setStatus(\"connecting\");\n\n const ws = new WebSocket(url, protocols);\n socket = ws;\n\n ws.onopen = (event) => {\n retries = 0;\n setStatus(\"open\");\n startPing();\n flushOutbox(ws);\n onOpen?.(event);\n };\n\n ws.onmessage = (event) => {\n const raw = typeof event.data === \"string\" ? event.data : \"\";\n const data = parser(raw);\n if (respondToPing && isServerPing(data) && ws.readyState === WebSocket.OPEN) {\n ws.send(pongPayload);\n }\n onMessage?.({ data, raw: event });\n };\n\n ws.onerror = (event) => {\n onError?.(event);\n };\n\n ws.onclose = (event) => {\n clearPing();\n onClose?.(event);\n socket = null;\n setStatus(\"closed\");\n if (!closed && !event.wasClean) {\n scheduleReconnect();\n }\n };\n }\n\n function send(payload: string | Blob | BufferSource): boolean {\n if (socket?.readyState === WebSocket.OPEN) {\n socket.send(payload);\n return true;\n }\n if (!queueWhileClosed || closed) return false;\n if (outbox.length >= maxQueuedMessages) outbox.shift();\n outbox.push(payload);\n return true;\n }\n\n function close(code?: number, reason?: string): void {\n closed = true;\n if (retryTimer) {\n clearTimeout(retryTimer);\n retryTimer = null;\n }\n clearPing();\n retries = 0;\n outbox.length = 0;\n if (socket) {\n setStatus(\"closing\");\n closeSocket(socket, code, reason);\n socket = null;\n }\n setStatus(\"closed\");\n }\n\n /**\n * Close a socket without the \"closed before the connection is established\"\n * console warning.\n *\n * A socket still in `CONNECTING` cannot be closed cleanly — the browser\n * logs that warning on every attempt. React's StrictMode mounts, unmounts\n * and remounts each component in development, so the first socket is\n * always torn down mid-handshake and the message shows up in every dev\n * session of every app using the hook. Deferring the close to `onopen`\n * costs one round trip and keeps the console usable.\n */\n function closeSocket(ws: WebSocket, code?: number, reason?: string): void {\n if (ws.readyState === WebSocket.CONNECTING) {\n ws.onopen = () => ws.close(code, reason);\n ws.onmessage = null;\n ws.onerror = null;\n ws.onclose = null;\n return;\n }\n ws.close(code, reason);\n }\n\n function reconnect(): void {\n if (retryTimer) {\n clearTimeout(retryTimer);\n retryTimer = null;\n }\n retries = 0;\n closed = false;\n connect();\n }\n\n connect();\n\n return {\n send,\n close,\n reconnect,\n get status() {\n return status;\n },\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"create-web-socket.js","names":[],"sources":["../../src/ws/create-web-socket.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines, function-lines — reconnect with backoff, heartbeat,\n * the handshake and silence timers that detect a link which never fails out loud,\n * the send queue that survives a disconnect and the listener set that must be re-\n * attached to each new socket — one connection's lifetime, one closure. The queue\n * and the reconnect timer are the same decision seen twice.\n */\nimport {\n backoffDelay,\n isRejectionCloseCode,\n shouldRetryClose,\n type WebSocketLostReason,\n} from \"./resilience\";\n\nexport type WebSocketStatus = \"idle\" | \"connecting\" | \"open\" | \"closing\" | \"closed\" | \"error\";\n\nexport interface WebSocketMessage<T> {\n /** Parsed payload — JSON-decoded when possible, raw string otherwise. */\n data: T;\n /** The original `MessageEvent`. */\n raw: MessageEvent;\n}\n\nexport interface CreateWebSocketOptions<T> {\n /** Subprotocol(s) forwarded to the `WebSocket` constructor. */\n protocols?: string | string[];\n /** Max reconnect attempts. Default: 10. Pass 0 to disable. */\n maxRetries?: number;\n /** Initial backoff (ms). Doubles each attempt, capped at `maxBackoff`. Default: 1000. */\n initialBackoff?: number;\n /** Maximum backoff (ms). Default: 30000. */\n maxBackoff?: number;\n /**\n * Fraction of each backoff delay added at random, 0–1. Default: 0.3.\n *\n * Matters when the *server* is what went down: every client retries on the\n * same schedule, so the box comes back up into a synchronized stampede. Pass\n * `0` for a fixed schedule.\n */\n jitter?: number;\n /**\n * How long one handshake may stay in `CONNECTING` before the attempt is\n * abandoned and retried (ms). Default: 8000. Pass 0 to disable.\n *\n * A `WebSocket` that cannot reach its server does not necessarily fail: it\n * sits in `CONNECTING` firing neither `open` nor `close` nor `error`. A retry\n * chain built only on those events stops on its first hung attempt and never\n * moves again — and hung, rather than refused, is precisely how a bad mobile\n * link behaves, which is the case reconnection exists for.\n */\n handshakeTimeout?: number;\n /**\n * Silence tolerated on an open socket before the link is treated as dead (ms).\n * Default: 0 (off).\n *\n * The socket only reports a connection that closes cleanly. A link that dies\n * mid-flight leaves `readyState` at `OPEN` on this side with nothing ever\n * arriving again, so silence is the only symptom available. The timer is\n * re-armed by **any** inbound frame, not just by pings — traffic is traffic.\n *\n * Set it to a comfortable multiple of the server's ping interval (2.5× is a\n * good default) so one dropped ping is not mistaken for an outage. When the\n * server announces its own interval in the handshake, feed that back with\n * {@link WebSocketController.setSilenceTimeout} instead of hard-coding the\n * value on both ends.\n */\n silenceTimeout?: number;\n /**\n * Suspend the retry schedule while `navigator.onLine` is false, and resume on\n * the `online` event. Default: true.\n *\n * Burning retries against a radio that is switched off is how a phone\n * exhausts its budget inside a tunnel and gives up exactly when it comes out\n * the other side.\n */\n waitForOnline?: boolean;\n /**\n * Ping interval (ms). When set, the client sends `pingPayload` periodically\n * to keep the socket alive. Default: 0 (disabled).\n *\n * Leave it off against a `tempest-fastapi-sdk` server: that server pings on\n * its own and answers a client-sent `{\"type\":\"ping\"}` with nothing, while a\n * strict handler rejects the unknown frame. What it needs from the client\n * is the `pong` reply, which `respondToPing` sends for you.\n */\n pingInterval?: number;\n /** Payload sent on each ping. Default: `JSON.stringify({ type: \"ping\" })`. */\n pingPayload?: string | Blob | BufferSource;\n /**\n * Reply to a server `{\"type\":\"ping\"}` with `pongPayload`. Default: true.\n *\n * `tempest-fastapi-sdk` closes a socket with code `4408` when no `pong`\n * arrives within `WS_HEARTBEAT_TIMEOUT_SECONDS`, so a client that stays\n * silent is dropped once per timeout. The ping is still forwarded to\n * `onMessage` — the reply is sent before your handler runs.\n */\n respondToPing?: boolean;\n /** Payload sent in reply to a server ping. Default: `JSON.stringify({ type: \"pong\" })`. */\n pongPayload?: string | Blob | BufferSource;\n /**\n * Buffer payloads sent while the socket is not open and flush them on the\n * next `open`. Default: false — `send()` returns false and drops.\n *\n * Without it, an action fired during reconnect backoff vanishes and the UI\n * cannot tell \"never sent\" from \"sent and ignored\".\n */\n queueWhileClosed?: boolean;\n /** Cap on buffered payloads when `queueWhileClosed` is on. Default: 100. */\n maxQueuedMessages?: number;\n /** Parse incoming frames. Default: JSON with raw-string fallback. */\n parser?: (raw: string) => T;\n onOpen?: (event: Event) => void;\n onMessage?: (message: WebSocketMessage<T>) => void;\n onClose?: (event: CloseEvent) => void;\n onError?: (event: Event) => void;\n onStatusChange?: (status: WebSocketStatus) => void;\n /**\n * A retry has been scheduled. `attempt` is 1-based, `total` is `maxRetries`.\n *\n * Reconnecting is not an error and reads badly as one: announcing every\n * attempt puts a fresh \"the connection dropped\" in front of someone whose\n * session is in the middle of coming back on its own. Show a quiet\n * reconnecting state here and treat {@link CreateWebSocketOptions.onLost} as\n * the failure.\n */\n onReconnecting?: (attempt: number, total: number) => void;\n /**\n * The socket is back up after at least one retry.\n *\n * Nothing is resumed for you: a server that keys state by connection sees a\n * brand-new client, so this is where the caller re-subscribes, re-joins or\n * refetches whatever the gap invalidated.\n */\n onReconnected?: () => void;\n /**\n * No further attempt will be made — `\"rejected\"` when the server refused the\n * client outright (close code 4400–4499, minus the 4408 heartbeat timeout),\n * `\"exhausted\"` when the schedule ran out.\n *\n * This is the one that deserves UI, because it is the only state the caller\n * can act on: offer a \"try again\" that calls\n * {@link WebSocketController.reconnect}.\n */\n onLost?: (reason: WebSocketLostReason) => void;\n}\n\nexport interface WebSocketController {\n /** Send a payload over the current connection. No-op when not open. */\n send: (payload: string | Blob | BufferSource) => boolean;\n /** Close the connection and stop reconnecting. */\n close: (code?: number, reason?: string) => void;\n /** Force an immediate reconnect, resetting the retry counter. */\n reconnect: () => void;\n /**\n * Change the silence watchdog at runtime, in ms. `0` disables it.\n *\n * For the common case where the server announces its heartbeat interval in\n * the first frame, so the tolerated silence is not hard-coded on both ends:\n *\n * ```ts\n * onMessage: ({ data }) => {\n * if (data.type === \"welcome\") socket.setSilenceTimeout(data.heartbeat_seconds * 2500);\n * }\n * ```\n */\n setSilenceTimeout: (ms: number) => void;\n /**\n * Resolves on the first successful open, rejects when the socket is lost\n * before ever opening.\n *\n * Joining and dropping are different events: a call that never connected has\n * to be reported, while one that dropped mid-session should reconnect\n * quietly. Await this for the join, handle\n * {@link CreateWebSocketOptions.onLost} for the drop. Pair it with\n * `maxRetries: 0` when the first attempt should fail fast instead of\n * spending the whole schedule on a server that is not there.\n */\n opened: Promise<void>;\n /** Current connection status. */\n readonly status: WebSocketStatus;\n}\n\nfunction defaultParser<T>(raw: string): T {\n try {\n return JSON.parse(raw) as T;\n } catch {\n return raw as unknown as T;\n }\n}\n\n/**\n * Open a WebSocket that survives a bad network: exponential backoff with jitter,\n * a handshake timeout, a silence watchdog, optional heartbeat pings and typed\n * JSON parsing.\n *\n * Three failure modes are covered that an event-driven retry loop misses on its\n * own, because none of them fire an event: a handshake that hangs instead of\n * failing, an open socket whose link died mid-flight, and a device with its\n * radio off burning the retry budget. See `handshakeTimeout`, `silenceTimeout`\n * and `waitForOnline`.\n *\n * @param url - Full ws:// or wss:// URL.\n * @param options - Connection configuration and callbacks.\n * @returns Controller exposing `send`, `close`, `reconnect`, `setSilenceTimeout`,\n * `opened` and `status`.\n *\n * @example\n * const socket = createWebSocket(url, {\n * silenceTimeout: 75_000,\n * onReconnecting: (n, total) => setBanner(`Reconectando ${n}/${total}…`),\n * onReconnected: () => refetchEverything(),\n * onLost: (reason) => setBanner(reason === \"rejected\" ? \"Acesso negado\" : \"Sem conexão\"),\n * });\n * await socket.opened;\n */\nexport function createWebSocket<T = unknown>(\n url: string,\n options: CreateWebSocketOptions<T> = {},\n): WebSocketController {\n const {\n protocols,\n maxRetries = 10,\n initialBackoff = 1000,\n maxBackoff = 30000,\n jitter = 0.3,\n handshakeTimeout = 8000,\n silenceTimeout = 0,\n waitForOnline = true,\n pingInterval = 0,\n pingPayload = JSON.stringify({ type: \"ping\" }),\n respondToPing = true,\n pongPayload = JSON.stringify({ type: \"pong\" }),\n queueWhileClosed = false,\n maxQueuedMessages = 100,\n parser = defaultParser<T>,\n onOpen,\n onMessage,\n onClose,\n onError,\n onStatusChange,\n onReconnecting,\n onReconnected,\n onLost,\n } = options;\n\n let socket: WebSocket | null = null;\n let retryTimer: ReturnType<typeof setTimeout> | null = null;\n let pingTimer: ReturnType<typeof setInterval> | null = null;\n let handshakeTimer: ReturnType<typeof setTimeout> | null = null;\n let silenceTimer: ReturnType<typeof setTimeout> | null = null;\n let onlineListener: (() => void) | null = null;\n let silenceWindow = silenceTimeout;\n let retries = 0;\n let status: WebSocketStatus = \"idle\";\n let closed = false;\n let everOpened = false;\n const outbox: Array<string | Blob | BufferSource> = [];\n\n let settleOpen: (() => void) | null = null;\n let failOpen: ((error: Error) => void) | null = null;\n const opened = new Promise<void>((resolve, reject) => {\n settleOpen = resolve;\n failOpen = reject;\n });\n opened.catch(() => undefined);\n\n /** True for a decoded frame that is the server's heartbeat ping. */\n function isServerPing(data: unknown): boolean {\n return (\n typeof data === \"object\" &&\n data !== null &&\n (data as { type?: unknown }).type === \"ping\"\n );\n }\n\n /** Send everything buffered while the socket was down, oldest first. */\n function flushOutbox(ws: WebSocket): void {\n while (outbox.length > 0 && ws.readyState === WebSocket.OPEN) {\n ws.send(outbox.shift()!);\n }\n }\n\n function setStatus(next: WebSocketStatus): void {\n if (status === next) return;\n status = next;\n onStatusChange?.(next);\n }\n\n function clearPing(): void {\n if (pingTimer) {\n clearInterval(pingTimer);\n pingTimer = null;\n }\n }\n\n function clearHandshake(): void {\n if (handshakeTimer) {\n clearTimeout(handshakeTimer);\n handshakeTimer = null;\n }\n }\n\n function clearSilence(): void {\n if (silenceTimer) {\n clearTimeout(silenceTimer);\n silenceTimer = null;\n }\n }\n\n function startPing(): void {\n if (!pingInterval || pingInterval <= 0) return;\n clearPing();\n pingTimer = setInterval(() => {\n if (socket?.readyState === WebSocket.OPEN) {\n socket.send(pingPayload);\n }\n }, pingInterval);\n }\n\n /**\n * Restart the silence timer, because something just arrived.\n *\n * Armed off any inbound frame rather than off pongs alone: a busy exchange\n * already proves the link is carrying data, and a protocol whose pings the\n * client never sees would otherwise reconnect in the middle of working\n * traffic.\n */\n function armSilence(): void {\n clearSilence();\n if (closed || silenceWindow <= 0) return;\n silenceTimer = setTimeout(onSilence, silenceWindow);\n }\n\n /**\n * Treat a socket that went quiet as dead and start reconnecting.\n *\n * Handlers are detached before closing so the synthetic `close` does not also\n * schedule a retry — that would advance the backoff twice for one failure and\n * halve the time the connection is given to recover.\n */\n function onSilence(): void {\n clearSilence();\n if (closed || !socket) return;\n detach(socket);\n socket = null;\n setStatus(\"closed\");\n scheduleReconnect();\n }\n\n /**\n * Abandon a handshake that never resolved either way.\n *\n * The socket is closed while still `CONNECTING`, which is the one case the\n * console warns about — and the right trade here, because the alternative is\n * deferring the close to an `open` event that by definition is not coming.\n */\n function abandonHandshake(ws: WebSocket): void {\n clearHandshake();\n if (closed || ws.readyState !== WebSocket.CONNECTING) return;\n detach(ws);\n if (socket === ws) socket = null;\n setStatus(\"closed\");\n scheduleReconnect();\n }\n\n /** Drop every handler and close, so the socket can die without being heard. */\n function detach(ws: WebSocket): void {\n ws.onopen = null;\n ws.onmessage = null;\n ws.onerror = null;\n ws.onclose = null;\n try {\n ws.close();\n } catch {\n /* already unusable — nothing to release and nothing to report */\n }\n }\n\n /** Stop for good, telling the caller which of the two dead ends it is. */\n function lose(reason: WebSocketLostReason): void {\n clearSilence();\n clearHandshake();\n clearNetworkWait();\n if (reason === \"rejected\") closed = true;\n setStatus(\"error\");\n if (!everOpened && failOpen) {\n const reject = failOpen;\n failOpen = null;\n settleOpen = null;\n reject(new Error(`websocket_${reason}`));\n }\n onLost?.(reason);\n }\n\n /**\n * Queue the next attempt, or wait for the network when there is none.\n *\n * While the browser reports no connectivity the schedule is suspended and the\n * `online` event drives the next attempt instead, so a device in a tunnel\n * does not spend its whole budget before coming out the other side.\n */\n function scheduleReconnect(): void {\n if (closed) return;\n if (retries >= maxRetries) {\n lose(\"exhausted\");\n return;\n }\n const delay = backoffDelay(retries, { initialBackoff, maxBackoff, jitter });\n retries += 1;\n onReconnecting?.(retries, maxRetries);\n\n if (waitForOnline && typeof navigator !== \"undefined\" && navigator.onLine === false) {\n waitForNetwork();\n return;\n }\n retryTimer = setTimeout(connect, delay);\n }\n\n function waitForNetwork(): void {\n if (onlineListener || typeof window === \"undefined\") return;\n const listener = (): void => {\n clearNetworkWait();\n if (!closed) connect();\n };\n onlineListener = listener;\n window.addEventListener(\"online\", listener);\n }\n\n function clearNetworkWait(): void {\n if (!onlineListener || typeof window === \"undefined\") return;\n window.removeEventListener(\"online\", onlineListener);\n onlineListener = null;\n }\n\n /**\n * Open a socket, replacing whatever is there.\n *\n * The handshake timer is cleared first because it belongs to the socket\n * being replaced, and it holds a reference to it: left armed, it fires later\n * against a connection nobody is waiting for, clears the *new* socket's\n * timer on its way through, and schedules a retry that drops a connection\n * still in flight. `reconnect()` on a hung socket is the path that reaches\n * it.\n */\n function connect(): void {\n if (closed) return;\n retryTimer = null;\n clearHandshake();\n if (socket) {\n const previous = socket;\n previous.onmessage = null;\n previous.onclose = null;\n previous.onerror = null;\n if (previous.readyState !== WebSocket.CONNECTING) previous.onopen = null;\n closeSocket(previous);\n }\n setStatus(\"connecting\");\n\n const ws = new WebSocket(url, protocols);\n socket = ws;\n\n if (handshakeTimeout > 0) {\n handshakeTimer = setTimeout(() => abandonHandshake(ws), handshakeTimeout);\n }\n\n ws.onopen = (event) => {\n clearHandshake();\n const recovered = retries > 0;\n retries = 0;\n everOpened = true;\n setStatus(\"open\");\n startPing();\n armSilence();\n flushOutbox(ws);\n if (settleOpen) {\n const resolve = settleOpen;\n settleOpen = null;\n failOpen = null;\n resolve();\n }\n onOpen?.(event);\n if (recovered) onReconnected?.();\n };\n\n ws.onmessage = (event) => {\n armSilence();\n const raw = typeof event.data === \"string\" ? event.data : \"\";\n const data = parser(raw);\n if (respondToPing && isServerPing(data) && ws.readyState === WebSocket.OPEN) {\n ws.send(pongPayload);\n }\n onMessage?.({ data, raw: event });\n };\n\n ws.onerror = (event) => {\n onError?.(event);\n };\n\n /**\n * Classify the close before deciding anything.\n *\n * Three outcomes, in order: a refusal never gets better by trying again;\n * a died-in-flight or temporarily-unavailable close is retried; an\n * ordinary goodbye (a clean 1000) is the session ending on purpose and\n * deserves no error. The one exception is a goodbye on a socket that\n * never opened — the server hung up during the handshake, which the\n * caller awaiting `opened` has to hear about.\n */\n ws.onclose = (event) => {\n clearHandshake();\n clearPing();\n clearSilence();\n onClose?.(event);\n socket = null;\n setStatus(\"closed\");\n if (closed) return;\n if (isRejectionCloseCode(event.code)) {\n lose(\"rejected\");\n return;\n }\n if (shouldRetryClose(event.code, event.wasClean)) {\n scheduleReconnect();\n return;\n }\n if (!everOpened) lose(\"rejected\");\n };\n }\n\n function send(payload: string | Blob | BufferSource): boolean {\n if (socket?.readyState === WebSocket.OPEN) {\n socket.send(payload);\n return true;\n }\n if (!queueWhileClosed || closed) return false;\n if (outbox.length >= maxQueuedMessages) outbox.shift();\n outbox.push(payload);\n return true;\n }\n\n function close(code?: number, reason?: string): void {\n closed = true;\n if (retryTimer) {\n clearTimeout(retryTimer);\n retryTimer = null;\n }\n clearPing();\n clearHandshake();\n clearSilence();\n clearNetworkWait();\n retries = 0;\n outbox.length = 0;\n if (failOpen) {\n const reject = failOpen;\n failOpen = null;\n settleOpen = null;\n reject(new Error(\"websocket_closed\"));\n }\n if (socket) {\n setStatus(\"closing\");\n closeSocket(socket, code, reason);\n socket = null;\n }\n setStatus(\"closed\");\n }\n\n /**\n * Close a socket without the \"closed before the connection is established\"\n * console warning.\n *\n * A socket still in `CONNECTING` cannot be closed cleanly — the browser\n * logs that warning on every attempt. React's StrictMode mounts, unmounts\n * and remounts each component in development, so the first socket is\n * always torn down mid-handshake and the message shows up in every dev\n * session of every app using the hook. Deferring the close to `onopen`\n * costs one round trip and keeps the console usable.\n */\n function closeSocket(ws: WebSocket, code?: number, reason?: string): void {\n if (ws.readyState === WebSocket.CONNECTING) {\n ws.onopen = () => ws.close(code, reason);\n ws.onmessage = null;\n ws.onerror = null;\n ws.onclose = null;\n return;\n }\n ws.close(code, reason);\n }\n\n function reconnect(): void {\n if (retryTimer) {\n clearTimeout(retryTimer);\n retryTimer = null;\n }\n clearNetworkWait();\n retries = 0;\n closed = false;\n connect();\n }\n\n function setSilenceTimeout(ms: number): void {\n silenceWindow = Number.isFinite(ms) && ms > 0 ? ms : 0;\n if (socket?.readyState === WebSocket.OPEN) armSilence();\n else clearSilence();\n }\n\n connect();\n\n return {\n send,\n close,\n reconnect,\n setSilenceTimeout,\n opened,\n get status() {\n return status;\n },\n };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var e=4400,t=4499,n=4408,r=new Set([1001,1011,1012,1013]);function i(e){return e>=4400&&e<=4499&&e!==4408}function a(e,t){return i(e)?!1:!t||e===4408||r.has(e)}function o(e,{initialBackoff:t,maxBackoff:n,jitter:r},i=Math.random){let a=Math.min(t*2**e,n);return r<=0?a:a+i()*a*r}exports.HEARTBEAT_CLOSE_CODE=n,exports.REJECTION_CLOSE_MAX=t,exports.REJECTION_CLOSE_MIN=e,exports.backoffDelay=o,exports.isRejectionCloseCode=i,exports.shouldRetryClose=a;
|
|
2
|
+
//# sourceMappingURL=resilience.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resilience.cjs","names":[],"sources":["../../src/ws/resilience.ts"],"sourcesContent":["/** Why a socket stopped trying to come back. */\nexport type WebSocketLostReason = \"rejected\" | \"exhausted\";\n\n/**\n * First close code in the range a server uses to refuse a peer outright.\n *\n * `tempest-fastapi-sdk` closes with 4400 (invalid room), 4401 (unauthorized),\n * 4403 (forbidden) and 4409 (full) — none of which get better by trying again,\n * so retrying only reproduces the rejection while the person watches a spinner.\n */\nexport const REJECTION_CLOSE_MIN = 4400;\n\n/** Last close code in the rejection range. */\nexport const REJECTION_CLOSE_MAX = 4499;\n\n/**\n * Heartbeat timeout, which sits inside the rejection range but is not one.\n *\n * `tempest-fastapi-sdk` closes with 4408 when no `pong` arrived within\n * `WS_HEARTBEAT_TIMEOUT_SECONDS`. That is the *link* failing, not the server\n * refusing the peer — exactly the case reconnection exists for. Treating the\n * whole 4400–4499 range as fatal, which is the obvious reading, makes one\n * missed pong permanent.\n */\nexport const HEARTBEAT_CLOSE_CODE = 4408;\n\n/**\n * Clean close codes that still deserve a retry.\n *\n * A close is normally taken as final when the closing handshake completed\n * (`wasClean`), because that means the server said so on purpose. These four\n * say the opposite: the server is going away for a reason that ends —\n * 1001 going away, 1011 internal error, 1012 service restart (a deploy),\n * 1013 try again later — and the socket comes back if anyone asks again.\n */\nconst RETRYABLE_CLEAN_CLOSE_CODES: ReadonlySet<number> = new Set([1001, 1011, 1012, 1013]);\n\n/**\n * Whether a close code means the server refused this client for good.\n *\n * @param code - The `CloseEvent.code`.\n * @returns `true` when reconnecting can only reproduce the refusal.\n *\n * @example\n * isRejectionCloseCode(4401); // true — unauthorized\n * isRejectionCloseCode(4408); // false — heartbeat timeout, retry it\n */\nexport function isRejectionCloseCode(code: number): boolean {\n return (\n code >= REJECTION_CLOSE_MIN && code <= REJECTION_CLOSE_MAX && code !== HEARTBEAT_CLOSE_CODE\n );\n}\n\n/**\n * Whether a closed socket should be reopened.\n *\n * An unclean close is always retried: the connection died rather than ended.\n * A clean one is retried only for the codes that describe a server which is\n * temporarily unavailable, plus the heartbeat timeout — a server that closes\n * cleanly with 1000 meant it.\n *\n * @param code - The `CloseEvent.code`.\n * @param wasClean - The `CloseEvent.wasClean` flag.\n * @returns `true` when the connection is worth reopening.\n */\nexport function shouldRetryClose(code: number, wasClean: boolean): boolean {\n if (isRejectionCloseCode(code)) return false;\n if (!wasClean) return true;\n return code === HEARTBEAT_CLOSE_CODE || RETRYABLE_CLEAN_CLOSE_CODES.has(code);\n}\n\n/** Shape of the backoff schedule. */\nexport interface BackoffOptions {\n /** Delay before the first retry (ms), doubled each attempt. */\n initialBackoff: number;\n /** Ceiling for the doubling (ms). */\n maxBackoff: number;\n /** Fraction of the delay added at random, 0–1. */\n jitter: number;\n}\n\n/**\n * Delay before retry number `attempt` (0-based), with jitter.\n *\n * The jitter matters when the *server* is what went down: every client that was\n * connected to it wakes on the same schedule and retries in the same\n * millisecond, so the box comes back up into a synchronized stampede and drops\n * the connections again. Spreading each delay by a fraction of itself breaks\n * the alignment, and it only ever adds time — the floor stays predictable.\n *\n * @param attempt - Zero-based retry index.\n * @param options - Schedule shape.\n * @param random - Source of randomness, injectable for tests. Defaults to `Math.random`.\n * @returns Delay in milliseconds.\n *\n * @example\n * backoffDelay(0, { initialBackoff: 1000, maxBackoff: 30000, jitter: 0.3 });\n * // 1000–1300\n */\nexport function backoffDelay(\n attempt: number,\n { initialBackoff, maxBackoff, jitter }: BackoffOptions,\n random: () => number = Math.random,\n): number {\n const base = Math.min(initialBackoff * 2 ** attempt, maxBackoff);\n if (jitter <= 0) return base;\n return base + random() * base * jitter;\n}\n"],"mappings":"AAUA,IAAa,EAAsB,KAGtB,EAAsB,KAWtB,EAAuB,KAW9B,EAAmD,IAAI,IAAI,CAAC,KAAM,KAAM,KAAM,IAAI,CAAC,EAYzF,SAAgB,EAAqB,EAAuB,CACxD,OACI,GAAA,MAA+B,GAAA,MAA+B,IAAA,IAEtE,CAcA,SAAgB,EAAiB,EAAc,EAA4B,CAGvE,OAFI,EAAqB,CAAI,EAAU,GACvC,CAAK,GACE,IAAA,MAAiC,EAA4B,IAAI,CAAI,CAChF,CA8BA,SAAgB,EACZ,EACA,CAAE,iBAAgB,aAAY,UAC9B,EAAuB,KAAK,OACtB,CACN,IAAM,EAAO,KAAK,IAAI,EAAiB,GAAK,EAAS,CAAU,EAE/D,OADI,GAAU,EAAU,EACjB,EAAO,EAAO,EAAI,EAAO,CACpC"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
//#region src/ws/resilience.ts
|
|
2
|
+
var e = 4400, t = 4499, n = 4408, r = /* @__PURE__ */ new Set([
|
|
3
|
+
1001,
|
|
4
|
+
1011,
|
|
5
|
+
1012,
|
|
6
|
+
1013
|
|
7
|
+
]);
|
|
8
|
+
function i(e) {
|
|
9
|
+
return e >= 4400 && e <= 4499 && e !== 4408;
|
|
10
|
+
}
|
|
11
|
+
function a(e, t) {
|
|
12
|
+
return i(e) ? !1 : !t || e === 4408 || r.has(e);
|
|
13
|
+
}
|
|
14
|
+
function o(e, { initialBackoff: t, maxBackoff: n, jitter: r }, i = Math.random) {
|
|
15
|
+
let a = Math.min(t * 2 ** e, n);
|
|
16
|
+
return r <= 0 ? a : a + i() * a * r;
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
export { n as HEARTBEAT_CLOSE_CODE, t as REJECTION_CLOSE_MAX, e as REJECTION_CLOSE_MIN, o as backoffDelay, i as isRejectionCloseCode, a as shouldRetryClose };
|
|
20
|
+
|
|
21
|
+
//# sourceMappingURL=resilience.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resilience.js","names":[],"sources":["../../src/ws/resilience.ts"],"sourcesContent":["/** Why a socket stopped trying to come back. */\nexport type WebSocketLostReason = \"rejected\" | \"exhausted\";\n\n/**\n * First close code in the range a server uses to refuse a peer outright.\n *\n * `tempest-fastapi-sdk` closes with 4400 (invalid room), 4401 (unauthorized),\n * 4403 (forbidden) and 4409 (full) — none of which get better by trying again,\n * so retrying only reproduces the rejection while the person watches a spinner.\n */\nexport const REJECTION_CLOSE_MIN = 4400;\n\n/** Last close code in the rejection range. */\nexport const REJECTION_CLOSE_MAX = 4499;\n\n/**\n * Heartbeat timeout, which sits inside the rejection range but is not one.\n *\n * `tempest-fastapi-sdk` closes with 4408 when no `pong` arrived within\n * `WS_HEARTBEAT_TIMEOUT_SECONDS`. That is the *link* failing, not the server\n * refusing the peer — exactly the case reconnection exists for. Treating the\n * whole 4400–4499 range as fatal, which is the obvious reading, makes one\n * missed pong permanent.\n */\nexport const HEARTBEAT_CLOSE_CODE = 4408;\n\n/**\n * Clean close codes that still deserve a retry.\n *\n * A close is normally taken as final when the closing handshake completed\n * (`wasClean`), because that means the server said so on purpose. These four\n * say the opposite: the server is going away for a reason that ends —\n * 1001 going away, 1011 internal error, 1012 service restart (a deploy),\n * 1013 try again later — and the socket comes back if anyone asks again.\n */\nconst RETRYABLE_CLEAN_CLOSE_CODES: ReadonlySet<number> = new Set([1001, 1011, 1012, 1013]);\n\n/**\n * Whether a close code means the server refused this client for good.\n *\n * @param code - The `CloseEvent.code`.\n * @returns `true` when reconnecting can only reproduce the refusal.\n *\n * @example\n * isRejectionCloseCode(4401); // true — unauthorized\n * isRejectionCloseCode(4408); // false — heartbeat timeout, retry it\n */\nexport function isRejectionCloseCode(code: number): boolean {\n return (\n code >= REJECTION_CLOSE_MIN && code <= REJECTION_CLOSE_MAX && code !== HEARTBEAT_CLOSE_CODE\n );\n}\n\n/**\n * Whether a closed socket should be reopened.\n *\n * An unclean close is always retried: the connection died rather than ended.\n * A clean one is retried only for the codes that describe a server which is\n * temporarily unavailable, plus the heartbeat timeout — a server that closes\n * cleanly with 1000 meant it.\n *\n * @param code - The `CloseEvent.code`.\n * @param wasClean - The `CloseEvent.wasClean` flag.\n * @returns `true` when the connection is worth reopening.\n */\nexport function shouldRetryClose(code: number, wasClean: boolean): boolean {\n if (isRejectionCloseCode(code)) return false;\n if (!wasClean) return true;\n return code === HEARTBEAT_CLOSE_CODE || RETRYABLE_CLEAN_CLOSE_CODES.has(code);\n}\n\n/** Shape of the backoff schedule. */\nexport interface BackoffOptions {\n /** Delay before the first retry (ms), doubled each attempt. */\n initialBackoff: number;\n /** Ceiling for the doubling (ms). */\n maxBackoff: number;\n /** Fraction of the delay added at random, 0–1. */\n jitter: number;\n}\n\n/**\n * Delay before retry number `attempt` (0-based), with jitter.\n *\n * The jitter matters when the *server* is what went down: every client that was\n * connected to it wakes on the same schedule and retries in the same\n * millisecond, so the box comes back up into a synchronized stampede and drops\n * the connections again. Spreading each delay by a fraction of itself breaks\n * the alignment, and it only ever adds time — the floor stays predictable.\n *\n * @param attempt - Zero-based retry index.\n * @param options - Schedule shape.\n * @param random - Source of randomness, injectable for tests. Defaults to `Math.random`.\n * @returns Delay in milliseconds.\n *\n * @example\n * backoffDelay(0, { initialBackoff: 1000, maxBackoff: 30000, jitter: 0.3 });\n * // 1000–1300\n */\nexport function backoffDelay(\n attempt: number,\n { initialBackoff, maxBackoff, jitter }: BackoffOptions,\n random: () => number = Math.random,\n): number {\n const base = Math.min(initialBackoff * 2 ** attempt, maxBackoff);\n if (jitter <= 0) return base;\n return base + random() * base * jitter;\n}\n"],"mappings":";AAUA,IAAa,IAAsB,MAGtB,IAAsB,MAWtB,IAAuB,MAW9B,oBAAmD,IAAI,IAAI;CAAC;CAAM;CAAM;CAAM;AAAI,CAAC;AAYzF,SAAgB,EAAqB,GAAuB;CACxD,OACI,KAAA,QAA+B,KAAA,QAA+B,MAAA;AAEtE;AAcA,SAAgB,EAAiB,GAAc,GAA4B;CAGvE,OAFI,EAAqB,CAAI,IAAU,KACvC,CAAK,KACE,MAAA,QAAiC,EAA4B,IAAI,CAAI;AAChF;AA8BA,SAAgB,EACZ,GACA,EAAE,mBAAgB,eAAY,aAC9B,IAAuB,KAAK,QACtB;CACN,IAAM,IAAO,KAAK,IAAI,IAAiB,KAAK,GAAS,CAAU;CAE/D,OADI,KAAU,IAAU,IACjB,IAAO,EAAO,IAAI,IAAO;AACpC"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=require("../hooks/use-latest-ref.cjs"),t=require("./create-web-socket.cjs");let n=require("react");function r(e){try{return JSON.parse(e)}catch{return e}}function i(i,a={}){let{enabled:o=!0,protocols:s,maxRetries:c,initialBackoff:l,maxBackoff:u,
|
|
1
|
+
const e=require("../hooks/use-latest-ref.cjs"),t=require("./create-web-socket.cjs");let n=require("react");function r(e){try{return JSON.parse(e)}catch{return e}}function i(i,a={}){let{enabled:o=!0,protocols:s,maxRetries:c,initialBackoff:l,maxBackoff:u,jitter:d,handshakeTimeout:f,silenceTimeout:p,waitForOnline:m,pingInterval:h,respondToPing:g,queueWhileClosed:_,maxQueuedMessages:v}=a,[y,b]=(0,n.useState)(`idle`),[x,S]=(0,n.useState)(null),C=(0,n.useRef)(null),w=e.useLatestRef(a),T=Array.isArray(s)?s.join(`,`):s??``;return(0,n.useEffect)(()=>{if(!o||!i){b(`idle`);return}let e=t.createWebSocket(i,{protocols:w.current.protocols,maxRetries:c,initialBackoff:l,maxBackoff:u,jitter:d,handshakeTimeout:f,silenceTimeout:p,waitForOnline:m,pingInterval:h,pingPayload:w.current.pingPayload,respondToPing:g,pongPayload:w.current.pongPayload,queueWhileClosed:_,maxQueuedMessages:v,parser:e=>(w.current.parser??r)(e),onStatusChange:b,onOpen:e=>w.current.onOpen?.(e),onClose:e=>w.current.onClose?.(e),onError:e=>w.current.onError?.(e),onReconnecting:(e,t)=>w.current.onReconnecting?.(e,t),onReconnected:()=>w.current.onReconnected?.(),onLost:e=>w.current.onLost?.(e),onMessage:e=>{S(e),w.current.onMessage?.(e)}});return C.current=e,()=>{e.close(),C.current=null}},[i,o,T,c,l,u,d,f,p,m,h,g,_,v,w]),{status:y,lastMessage:x,send:(0,n.useCallback)(e=>C.current?.send(e)??!1,[]),reconnect:(0,n.useCallback)(()=>{C.current?.reconnect()},[]),setSilenceTimeout:(0,n.useCallback)(e=>{C.current?.setSilenceTimeout(e)},[])}}exports.useWebSocket=i;
|
|
2
2
|
//# sourceMappingURL=use-web-socket.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-web-socket.cjs","names":[],"sources":["../../src/ws/use-web-socket.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\nimport { useLatestRef } from \"@/hooks/use-latest-ref\";\nimport {\n createWebSocket,\n type CreateWebSocketOptions,\n type WebSocketController,\n type WebSocketMessage,\n type WebSocketStatus,\n} from \"./create-web-socket\";\n\nexport interface UseWebSocketOptions<T> extends Omit<CreateWebSocketOptions<T>, \"onStatusChange\"> {\n /** When false, the socket is not opened. Default: true. */\n enabled?: boolean;\n}\n\nexport interface UseWebSocketResult<T> {\n status: WebSocketStatus;\n /**\n * Last decoded frame received.\n *\n * A snapshot, not a stream: two frames arriving in the same tick collapse\n * into a single render and only the later one is ever visible. One server\n * action often emits several frames in a row, so anything that must see\n * every message has to use `onMessage`, which fires once per frame. Read\n * `lastMessage` for \"what is the current state\" rendering only.\n */\n lastMessage: WebSocketMessage<T> | null;\n /** Send a payload through the active connection. Returns false when not open. */\n send: (payload: string | Blob | BufferSource) => boolean;\n /** Force a reconnect, resetting the retry counter. */\n reconnect: () => void;\n}\n\n/** Mirror of `createWebSocket`'s default parser: JSON, raw string on failure. */\nfunction defaultParse<T>(raw: string): T {\n try {\n return JSON.parse(raw) as T;\n } catch {\n return raw as unknown as T;\n }\n}\n\n/**\n * React hook around {@link createWebSocket}. Manages the connection lifecycle\n * for the host component and tears it down on unmount.\n *\n * Every callback is read through a ref, so `onOpen` / `onMessage` / `onClose` /\n * `onError` always run the latest closure — an inline arrow function is fine
|
|
1
|
+
{"version":3,"file":"use-web-socket.cjs","names":[],"sources":["../../src/ws/use-web-socket.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\nimport { useLatestRef } from \"@/hooks/use-latest-ref\";\nimport {\n createWebSocket,\n type CreateWebSocketOptions,\n type WebSocketController,\n type WebSocketMessage,\n type WebSocketStatus,\n} from \"./create-web-socket\";\n\nexport interface UseWebSocketOptions<T> extends Omit<CreateWebSocketOptions<T>, \"onStatusChange\"> {\n /** When false, the socket is not opened. Default: true. */\n enabled?: boolean;\n}\n\nexport interface UseWebSocketResult<T> {\n status: WebSocketStatus;\n /**\n * Last decoded frame received.\n *\n * A snapshot, not a stream: two frames arriving in the same tick collapse\n * into a single render and only the later one is ever visible. One server\n * action often emits several frames in a row, so anything that must see\n * every message has to use `onMessage`, which fires once per frame. Read\n * `lastMessage` for \"what is the current state\" rendering only.\n */\n lastMessage: WebSocketMessage<T> | null;\n /** Send a payload through the active connection. Returns false when not open. */\n send: (payload: string | Blob | BufferSource) => boolean;\n /** Force a reconnect, resetting the retry counter. */\n reconnect: () => void;\n /**\n * Change the silence watchdog at runtime, in ms. `0` disables it.\n *\n * For a server that announces its own heartbeat interval in the first frame,\n * so the tolerated silence is not hard-coded on both ends.\n */\n setSilenceTimeout: (ms: number) => void;\n}\n\n/** Mirror of `createWebSocket`'s default parser: JSON, raw string on failure. */\nfunction defaultParse<T>(raw: string): T {\n try {\n return JSON.parse(raw) as T;\n } catch {\n return raw as unknown as T;\n }\n}\n\n/**\n * React hook around {@link createWebSocket}. Manages the connection lifecycle\n * for the host component and tears it down on unmount.\n *\n * Every callback is read through a ref, so `onOpen` / `onMessage` / `onClose` /\n * `onError` / `onReconnecting` / `onReconnected` / `onLost` always run the\n * latest closure — an inline arrow function is fine and never reopens the\n * socket. Connection-shaping options (`protocols`, `maxRetries`,\n * `initialBackoff`, `maxBackoff`, `jitter`, `handshakeTimeout`,\n * `silenceTimeout`, `waitForOnline`, `pingInterval`, `queueWhileClosed`) are\n * baked into the connection, so changing one reopens it with the new value\n * rather than being silently ignored.\n *\n * @param url - Full ws:// or wss:// URL.\n * @param options - Connection configuration and callbacks.\n * @returns Status, last frame, and the `send` / `reconnect` controls.\n */\nexport function useWebSocket<T = unknown>(\n url: string,\n options: UseWebSocketOptions<T> = {},\n): UseWebSocketResult<T> {\n const {\n enabled = true,\n protocols,\n maxRetries,\n initialBackoff,\n maxBackoff,\n jitter,\n handshakeTimeout,\n silenceTimeout,\n waitForOnline,\n pingInterval,\n respondToPing,\n queueWhileClosed,\n maxQueuedMessages,\n } = options;\n const [status, setStatus] = useState<WebSocketStatus>(\"idle\");\n const [lastMessage, setLastMessage] = useState<WebSocketMessage<T> | null>(null);\n const controllerRef = useRef<WebSocketController | null>(null);\n\n const optionsRef = useLatestRef(options);\n\n const protocolsKey = Array.isArray(protocols) ? protocols.join(\",\") : (protocols ?? \"\");\n\n useEffect(() => {\n if (!enabled || !url) {\n setStatus(\"idle\");\n return;\n }\n\n const controller = createWebSocket<T>(url, {\n protocols: optionsRef.current.protocols,\n maxRetries,\n initialBackoff,\n maxBackoff,\n jitter,\n handshakeTimeout,\n silenceTimeout,\n waitForOnline,\n pingInterval,\n pingPayload: optionsRef.current.pingPayload,\n respondToPing,\n pongPayload: optionsRef.current.pongPayload,\n queueWhileClosed,\n maxQueuedMessages,\n parser: (raw) => (optionsRef.current.parser ?? defaultParse<T>)(raw),\n onStatusChange: setStatus,\n onOpen: (event) => optionsRef.current.onOpen?.(event),\n onClose: (event) => optionsRef.current.onClose?.(event),\n onError: (event) => optionsRef.current.onError?.(event),\n onReconnecting: (attempt, total) => optionsRef.current.onReconnecting?.(attempt, total),\n onReconnected: () => optionsRef.current.onReconnected?.(),\n onLost: (reason) => optionsRef.current.onLost?.(reason),\n onMessage: (message) => {\n setLastMessage(message);\n optionsRef.current.onMessage?.(message);\n },\n });\n controllerRef.current = controller;\n\n return () => {\n controller.close();\n controllerRef.current = null;\n };\n }, [\n url,\n enabled,\n protocolsKey,\n maxRetries,\n initialBackoff,\n maxBackoff,\n jitter,\n handshakeTimeout,\n silenceTimeout,\n waitForOnline,\n pingInterval,\n respondToPing,\n queueWhileClosed,\n maxQueuedMessages,\n optionsRef,\n ]);\n\n const send = useCallback((payload: string | Blob | BufferSource): boolean => {\n return controllerRef.current?.send(payload) ?? false;\n }, []);\n\n const reconnect = useCallback((): void => {\n controllerRef.current?.reconnect();\n }, []);\n\n const setSilenceTimeout = useCallback((ms: number): void => {\n controllerRef.current?.setSilenceTimeout(ms);\n }, []);\n\n return { status, lastMessage, send, reconnect, setSilenceTimeout };\n}\n"],"mappings":"2GAyCA,SAAS,EAAgB,EAAgB,CACrC,GAAI,CACA,OAAO,KAAK,MAAM,CAAG,CACzB,MAAQ,CACJ,OAAO,CACX,CACJ,CAmBA,SAAgB,EACZ,EACA,EAAkC,CAAC,EACd,CACrB,GAAM,CACF,UAAU,GACV,YACA,aACA,iBACA,aACA,SACA,mBACA,iBACA,gBACA,eACA,gBACA,mBACA,qBACA,EACE,CAAC,EAAQ,IAAA,EAAa,EAAA,SAAA,CAA0B,MAAM,EACtD,CAAC,EAAa,IAAA,EAAkB,EAAA,SAAA,CAAqC,IAAI,EACzE,GAAA,EAAgB,EAAA,OAAA,CAAmC,IAAI,EAEvD,EAAa,EAAA,aAAa,CAAO,EAEjC,EAAe,MAAM,QAAQ,CAAS,EAAI,EAAU,KAAK,GAAG,EAAK,GAAa,GAwEpF,OAtEA,EAAA,EAAA,UAAA,KAAgB,CACZ,GAAI,CAAC,GAAW,CAAC,EAAK,CAClB,EAAU,MAAM,EAChB,MACJ,CAEA,IAAM,EAAa,EAAA,gBAAmB,EAAK,CACvC,UAAW,EAAW,QAAQ,UAC9B,aACA,iBACA,aACA,SACA,mBACA,iBACA,gBACA,eACA,YAAa,EAAW,QAAQ,YAChC,gBACA,YAAa,EAAW,QAAQ,YAChC,mBACA,oBACA,OAAS,IAAS,EAAW,QAAQ,QAAU,EAAA,CAAiB,CAAG,EACnE,eAAgB,EAChB,OAAS,GAAU,EAAW,QAAQ,SAAS,CAAK,EACpD,QAAU,GAAU,EAAW,QAAQ,UAAU,CAAK,EACtD,QAAU,GAAU,EAAW,QAAQ,UAAU,CAAK,EACtD,gBAAiB,EAAS,IAAU,EAAW,QAAQ,iBAAiB,EAAS,CAAK,EACtF,kBAAqB,EAAW,QAAQ,gBAAgB,EACxD,OAAS,GAAW,EAAW,QAAQ,SAAS,CAAM,EACtD,UAAY,GAAY,CACpB,EAAe,CAAO,EACtB,EAAW,QAAQ,YAAY,CAAO,CAC1C,CACJ,CAAC,EAGD,MAFA,GAAc,QAAU,MAEX,CACT,EAAW,MAAM,EACjB,EAAc,QAAU,IAC5B,CACJ,EAAG,CACC,EACA,EACA,EACA,EACA,EACA,EACA,EACA,EACA,EACA,EACA,EACA,EACA,EACA,EACA,CACJ,CAAC,EAcM,CAAE,SAAQ,cAAa,MAAA,EAZjB,EAAA,YAAA,CAAa,GACf,EAAc,SAAS,KAAK,CAAO,GAAK,GAChD,CAAC,CAU0B,EAAM,WAAA,EARlB,EAAA,YAAA,KAAwB,CACtC,EAAc,SAAS,UAAU,CACrC,EAAG,CAAC,CAMgC,EAAW,mBAAA,EAJrB,EAAA,YAAA,CAAa,GAAqB,CACxD,EAAc,SAAS,kBAAkB,CAAE,CAC/C,EAAG,CAAC,CAE2C,CAAkB,CACrE"}
|
|
@@ -10,39 +10,46 @@ function o(e) {
|
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
function s(s, c = {}) {
|
|
13
|
-
let { enabled: l = !0, protocols: u, maxRetries: d, initialBackoff: f, maxBackoff: p,
|
|
13
|
+
let { enabled: l = !0, protocols: u, maxRetries: d, initialBackoff: f, maxBackoff: p, jitter: m, handshakeTimeout: h, silenceTimeout: g, waitForOnline: _, pingInterval: v, respondToPing: y, queueWhileClosed: b, maxQueuedMessages: x } = c, [S, C] = a("idle"), [w, T] = a(null), E = i(null), D = e(c), O = Array.isArray(u) ? u.join(",") : u ?? "";
|
|
14
14
|
return r(() => {
|
|
15
15
|
if (!l || !s) {
|
|
16
|
-
|
|
16
|
+
C("idle");
|
|
17
17
|
return;
|
|
18
18
|
}
|
|
19
19
|
let e = t(s, {
|
|
20
|
-
protocols:
|
|
20
|
+
protocols: D.current.protocols,
|
|
21
21
|
maxRetries: d,
|
|
22
22
|
initialBackoff: f,
|
|
23
23
|
maxBackoff: p,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
24
|
+
jitter: m,
|
|
25
|
+
handshakeTimeout: h,
|
|
26
|
+
silenceTimeout: g,
|
|
27
|
+
waitForOnline: _,
|
|
28
|
+
pingInterval: v,
|
|
29
|
+
pingPayload: D.current.pingPayload,
|
|
30
|
+
respondToPing: y,
|
|
31
|
+
pongPayload: D.current.pongPayload,
|
|
32
|
+
queueWhileClosed: b,
|
|
33
|
+
maxQueuedMessages: x,
|
|
34
|
+
parser: (e) => (D.current.parser ?? o)(e),
|
|
35
|
+
onStatusChange: C,
|
|
36
|
+
onOpen: (e) => D.current.onOpen?.(e),
|
|
37
|
+
onClose: (e) => D.current.onClose?.(e),
|
|
38
|
+
onError: (e) => D.current.onError?.(e),
|
|
39
|
+
onReconnecting: (e, t) => D.current.onReconnecting?.(e, t),
|
|
40
|
+
onReconnected: () => D.current.onReconnected?.(),
|
|
41
|
+
onLost: (e) => D.current.onLost?.(e),
|
|
35
42
|
onMessage: (e) => {
|
|
36
|
-
|
|
43
|
+
T(e), D.current.onMessage?.(e);
|
|
37
44
|
}
|
|
38
45
|
});
|
|
39
|
-
return
|
|
40
|
-
e.close(),
|
|
46
|
+
return E.current = e, () => {
|
|
47
|
+
e.close(), E.current = null;
|
|
41
48
|
};
|
|
42
49
|
}, [
|
|
43
50
|
s,
|
|
44
51
|
l,
|
|
45
|
-
|
|
52
|
+
O,
|
|
46
53
|
d,
|
|
47
54
|
f,
|
|
48
55
|
p,
|
|
@@ -50,13 +57,20 @@ function s(s, c = {}) {
|
|
|
50
57
|
h,
|
|
51
58
|
g,
|
|
52
59
|
_,
|
|
53
|
-
|
|
60
|
+
v,
|
|
61
|
+
y,
|
|
62
|
+
b,
|
|
63
|
+
x,
|
|
64
|
+
D
|
|
54
65
|
]), {
|
|
55
|
-
status:
|
|
56
|
-
lastMessage:
|
|
57
|
-
send: n((e) =>
|
|
66
|
+
status: S,
|
|
67
|
+
lastMessage: w,
|
|
68
|
+
send: n((e) => E.current?.send(e) ?? !1, []),
|
|
58
69
|
reconnect: n(() => {
|
|
59
|
-
|
|
70
|
+
E.current?.reconnect();
|
|
71
|
+
}, []),
|
|
72
|
+
setSilenceTimeout: n((e) => {
|
|
73
|
+
E.current?.setSilenceTimeout(e);
|
|
60
74
|
}, [])
|
|
61
75
|
};
|
|
62
76
|
}
|