tempest-react-sdk 0.81.0 → 0.83.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 +9 -10
- package/bin/create-tempest-app.e2e.test.mjs +31 -0
- package/bin/create-tempest-app.mjs +11 -1
- package/bin/lib/doctor/doctor.e2e.test.mjs +29 -0
- package/bin/lib/doctor/vitest.mjs +120 -0
- package/bin/lib/doctor/vitest.test.mjs +139 -0
- package/bin/lib/fs/contain.mjs +7 -0
- package/bin/lib/openapi/escape.mjs +68 -4
- package/bin/lib/openapi/generate.hostile.test.mjs +228 -0
- package/bin/lib/openapi/generate.mjs +119 -15
- package/bin/lib/openapi/pattern-risk.mjs +134 -0
- package/bin/lib/openapi/pattern-risk.test.mjs +45 -0
- package/bin/tempest-gen.test.mjs +100 -0
- package/bin/tempest.mjs +24 -8
- package/dist/access/use-can.cjs +1 -1
- package/dist/access/use-can.cjs.map +1 -1
- package/dist/access/use-can.js +25 -28
- package/dist/access/use-can.js.map +1 -1
- package/dist/auth/create-tempest-auth.cjs +1 -1
- package/dist/auth/create-tempest-auth.cjs.map +1 -1
- package/dist/auth/create-tempest-auth.js +189 -143
- package/dist/auth/create-tempest-auth.js.map +1 -1
- package/dist/auth/session-identity.cjs +2 -0
- package/dist/auth/session-identity.cjs.map +1 -0
- package/dist/auth/session-identity.js +12 -0
- package/dist/auth/session-identity.js.map +1 -0
- package/dist/br/boleto.cjs.map +1 -1
- package/dist/br/boleto.js.map +1 -1
- package/dist/components/AuthForm/AuthLinks.cjs +1 -1
- package/dist/components/AuthForm/AuthLinks.cjs.map +1 -1
- package/dist/components/AuthForm/AuthLinks.js +18 -15
- package/dist/components/AuthForm/AuthLinks.js.map +1 -1
- package/dist/components/AuthPage/AuthPage.cjs +1 -1
- package/dist/components/AuthPage/AuthPage.cjs.map +1 -1
- package/dist/components/AuthPage/AuthPage.js +58 -52
- package/dist/components/AuthPage/AuthPage.js.map +1 -1
- package/dist/components/Breadcrumbs/Breadcrumbs.cjs +1 -1
- package/dist/components/Breadcrumbs/Breadcrumbs.cjs.map +1 -1
- package/dist/components/Breadcrumbs/Breadcrumbs.js +22 -21
- package/dist/components/Breadcrumbs/Breadcrumbs.js.map +1 -1
- package/dist/components/Button/Button.cjs +1 -1
- package/dist/components/Button/Button.cjs.map +1 -1
- package/dist/components/Button/Button.js +41 -40
- package/dist/components/Button/Button.js.map +1 -1
- package/dist/components/Chat/ChatAttachments.cjs +1 -1
- package/dist/components/Chat/ChatAttachments.cjs.map +1 -1
- package/dist/components/Chat/ChatAttachments.js +38 -38
- package/dist/components/Form/Form.cjs +1 -1
- package/dist/components/Form/Form.cjs.map +1 -1
- package/dist/components/Form/Form.js +30 -32
- package/dist/components/Form/Form.js.map +1 -1
- package/dist/components/Layout/Layout.cjs +1 -1
- package/dist/components/Layout/Layout.cjs.map +1 -1
- package/dist/components/Layout/Layout.js +25 -24
- package/dist/components/Layout/Layout.js.map +1 -1
- package/dist/components/Layout/gap.cjs +2 -0
- package/dist/components/Layout/gap.cjs.map +1 -0
- package/dist/components/Layout/gap.js +15 -0
- package/dist/components/Layout/gap.js.map +1 -0
- package/dist/components/NavigationMenu/NavigationMenu.cjs +1 -1
- package/dist/components/NavigationMenu/NavigationMenu.cjs.map +1 -1
- package/dist/components/NavigationMenu/NavigationMenu.js +71 -70
- package/dist/components/NavigationMenu/NavigationMenu.js.map +1 -1
- package/dist/components/Sidebar/Sidebar.cjs.map +1 -1
- package/dist/components/Sidebar/Sidebar.js.map +1 -1
- package/dist/components/Sidebar/SidebarEntry.cjs +1 -1
- package/dist/components/Sidebar/SidebarEntry.cjs.map +1 -1
- package/dist/components/Sidebar/SidebarEntry.js +30 -29
- package/dist/components/Sidebar/SidebarEntry.js.map +1 -1
- package/dist/data/create-data-provider.cjs +1 -1
- package/dist/data/create-data-provider.cjs.map +1 -1
- package/dist/data/create-data-provider.js +1 -2
- package/dist/data/create-data-provider.js.map +1 -1
- package/dist/forms/validate-form.cjs +1 -1
- package/dist/forms/validate-form.cjs.map +1 -1
- package/dist/forms/validate-form.js +6 -1
- package/dist/forms/validate-form.js.map +1 -1
- package/dist/forms/zod-resolver.cjs +1 -1
- package/dist/forms/zod-resolver.cjs.map +1 -1
- package/dist/forms/zod-resolver.js +8 -3
- package/dist/forms/zod-resolver.js.map +1 -1
- package/dist/http/api-client.cjs +1 -1
- package/dist/http/api-client.cjs.map +1 -1
- package/dist/http/api-client.js +90 -72
- package/dist/http/api-client.js.map +1 -1
- package/dist/http/credential-scope.cjs +1 -1
- package/dist/http/credential-scope.cjs.map +1 -1
- package/dist/http/credential-scope.js +33 -9
- package/dist/http/credential-scope.js.map +1 -1
- package/dist/http/presigned-post.cjs.map +1 -1
- package/dist/http/presigned-post.js.map +1 -1
- package/dist/http/resumable-upload.cjs +1 -1
- package/dist/http/resumable-upload.cjs.map +1 -1
- package/dist/http/resumable-upload.js +167 -112
- package/dist/http/resumable-upload.js.map +1 -1
- package/dist/http/upload-with-progress.cjs +1 -1
- package/dist/http/upload-with-progress.cjs.map +1 -1
- package/dist/http/upload-with-progress.js +100 -44
- package/dist/http/upload-with-progress.js.map +1 -1
- package/dist/oauth/GoogleSignIn.cjs +1 -1
- package/dist/oauth/GoogleSignIn.cjs.map +1 -1
- package/dist/oauth/GoogleSignIn.js +5 -4
- package/dist/oauth/GoogleSignIn.js.map +1 -1
- package/dist/offline/create-offline-sync.cjs +1 -1
- package/dist/offline/create-offline-sync.cjs.map +1 -1
- package/dist/offline/create-offline-sync.js +87 -69
- package/dist/offline/create-offline-sync.js.map +1 -1
- package/dist/query/offline-persistence.cjs +1 -1
- package/dist/query/offline-persistence.cjs.map +1 -1
- package/dist/query/offline-persistence.js +67 -44
- package/dist/query/offline-persistence.js.map +1 -1
- package/dist/sse/create-event-stream.cjs +1 -1
- package/dist/sse/create-event-stream.cjs.map +1 -1
- package/dist/sse/create-event-stream.js +48 -41
- package/dist/sse/create-event-stream.js.map +1 -1
- package/dist/sse/ticket-event-stream.cjs +1 -1
- package/dist/sse/ticket-event-stream.cjs.map +1 -1
- package/dist/sse/ticket-event-stream.js +48 -19
- package/dist/sse/ticket-event-stream.js.map +1 -1
- package/dist/sse/use-event-stream.cjs +1 -1
- package/dist/sse/use-event-stream.cjs.map +1 -1
- package/dist/sse/use-event-stream.js +19 -14
- package/dist/sse/use-event-stream.js.map +1 -1
- package/dist/sw/background-sync.cjs +1 -1
- package/dist/sw/background-sync.cjs.map +1 -1
- package/dist/sw/background-sync.js +10 -8
- package/dist/sw/background-sync.js.map +1 -1
- package/dist/sw/cache.cjs +1 -1
- package/dist/sw/cache.cjs.map +1 -1
- package/dist/sw/cache.js +111 -55
- package/dist/sw/cache.js.map +1 -1
- package/dist/sw/create-push-handler.cjs +1 -1
- package/dist/sw/create-push-handler.cjs.map +1 -1
- package/dist/sw/create-push-handler.js +20 -10
- package/dist/sw/create-push-handler.js.map +1 -1
- package/dist/sw.d.ts +65 -14
- package/dist/tabular/compact.cjs +1 -1
- package/dist/tabular/compact.cjs.map +1 -1
- package/dist/tabular/compact.js +35 -16
- package/dist/tabular/compact.js.map +1 -1
- package/dist/tabular/manifest.cjs +1 -1
- package/dist/tabular/manifest.cjs.map +1 -1
- package/dist/tabular/manifest.js +33 -15
- package/dist/tabular/manifest.js.map +1 -1
- package/dist/tabular.d.ts +11 -1
- package/dist/tempest-react-sdk.cjs +1 -1
- package/dist/tempest-react-sdk.d.ts +543 -70
- package/dist/tempest-react-sdk.js +224 -224
- package/dist/theme/initial-theme.cjs +4 -4
- package/dist/theme/initial-theme.cjs.map +1 -1
- package/dist/theme/initial-theme.js +8 -5
- package/dist/theme/initial-theme.js.map +1 -1
- package/dist/utils/compressed-storage.cjs +1 -1
- package/dist/utils/compressed-storage.cjs.map +1 -1
- package/dist/utils/compressed-storage.js +13 -7
- package/dist/utils/compressed-storage.js.map +1 -1
- package/dist/utils/objects.cjs +1 -1
- package/dist/utils/objects.cjs.map +1 -1
- package/dist/utils/objects.js +13 -7
- package/dist/utils/objects.js.map +1 -1
- package/dist/utils/safe-url.cjs +1 -1
- package/dist/utils/safe-url.cjs.map +1 -1
- package/dist/utils/safe-url.js +4 -1
- package/dist/utils/safe-url.js.map +1 -1
- package/dist/vite/base-url.cjs +1 -1
- package/dist/vite/base-url.cjs.map +1 -1
- package/dist/vite/base-url.js +5 -1
- package/dist/vite/base-url.js.map +1 -1
- package/dist/vite/tempest-pwa-dev-sw.cjs +1 -1
- package/dist/vite/tempest-pwa-dev-sw.cjs.map +1 -1
- package/dist/vite/tempest-pwa-dev-sw.js +96 -31
- package/dist/vite/tempest-pwa-dev-sw.js.map +1 -1
- package/dist/vite/tempest-pwa-manifest.cjs +2 -2
- package/dist/vite/tempest-pwa-manifest.cjs.map +1 -1
- package/dist/vite/tempest-pwa-manifest.js +32 -16
- package/dist/vite/tempest-pwa-manifest.js.map +1 -1
- package/dist/vite.cjs +1 -1
- package/dist/vite.d.ts +63 -12
- package/dist/vite.js +4 -4
- package/dist/webrtc/peer-mesh.cjs +1 -1
- package/dist/webrtc/peer-mesh.cjs.map +1 -1
- package/dist/webrtc/peer-mesh.js +7 -1
- package/dist/webrtc/peer-mesh.js.map +1 -1
- 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 +77 -61
- package/dist/ws/create-web-socket.js.map +1 -1
- 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 +4 -3
- package/dist/ws/use-web-socket.js.map +1 -1
- package/package.json +1 -1
- package/template-pwa/src/pwa.tsx +42 -0
- package/template-pwa/src/sw.ts +5 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"peer-mesh.cjs","names":["createLinkStatsSampler","scaleForRoom","resolveDegradation","applyQualityToLink","slotOf","createPeerConnection","drainCandidates","acceptCandidate","require_link_stats","require_mesh_quality","require_peer_link","GONE","createPeerMesh","options","links","peers","localTracks","slotWrites","iceServers","describe","connection","description","quality","stopped","samplers","statsTimer","emptyStreams","slots","slot","emitPeers","peer","isLive","link","whileLive","value","step","error","sampleLinks","changed","sampler","settings","stats","syncStatsTimer","wanted","refreshState","states","state","applyQuality","next","effective","degradation","makeOffer","offer","send","streamFor","event","attachRemoteTrack","current","enqueueWrite","queued","write","writeSlot","transceiver","index","attachLocalTracks","removePeer","peerId","addPeer","pc","transceivers","offerer","acceptOffer","from","answer","message","applied","track","entry","servers"],"sources":["../../src/webrtc/peer-mesh.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines — the mesh is one object's lifetime: a link's\n * transceivers, the offer it owes, the tracks published on it and the share of\n * the uplink it costs all change together, and every method here reads or writes\n * the same two maps. The pieces that *could* stand alone already do —\n * `peer-link.ts` for the per-connection mechanics and `mesh-quality.ts` for the\n * division — and what is left is the coordination between them.\n */\nimport { applyQualityToLink, resolveDegradation, scaleForRoom } from \"./mesh-quality\";\nimport {\n acceptCandidate,\n adoptTransceivers,\n createPeerConnection,\n drainCandidates,\n slotOf,\n type PeerLink,\n} from \"./peer-link\";\nimport { createLinkStatsSampler, type LinkStats, type LinkStatsSampler } from \"./link-stats\";\nimport type {\n MeshMessage,\n MeshPeer,\n MeshQuality,\n MeshStatsOptions,\n PeerMeshOptions,\n} from \"./mesh-types\";\n\n/** What {@link createPeerMesh}'s negotiation steps return once their link is gone. */\nconst GONE = Symbol(\"gone\");\n\n/** A running mesh. */\nexport interface PeerMesh {\n /**\n * Open a link to a peer.\n *\n * `offerer` decides which side sends the offer, and exactly one side of each\n * link must say `true`. Drive it from the arrival order your server already\n * imposes — the peer that joined **later** offers, the one already in the\n * room answers. That avoids glare by construction, without the rollback\n * dance of perfect negotiation and without depending on a browser\n * implementing argument-less `setLocalDescription`.\n */\n addPeer: (peerId: string, options: { offerer: boolean }) => Promise<void>;\n /** Close one link and give its share of the uplink back to the others. */\n removePeer: (peerId: string) => void;\n /** Feed the mesh an `offer`, `answer` or `ice` message from `from`. */\n accept: (message: MeshMessage & { from: string }) => Promise<void>;\n /**\n * Publish or retract one slot across every live link.\n *\n * `null` retracts: the sender keeps its negotiated transceiver and stops\n * producing, which is what mutes a microphone without disturbing the other\n * slots or the connection. Nothing renegotiates — the m-lines were allocated\n * before any track existed, which is what makes a mesh survive toggling: a\n * renegotiation per toggle is N-1 simultaneous offer/answer rounds every time\n * somebody unmutes.\n */\n setLocalTrack: (slot: string, track: MediaStreamTrack | null) => Promise<void>;\n /** Apply encoder limits, divided by the current size of the room. */\n applyQuality: (quality: MeshQuality) => Promise<void>;\n /** Replace the ICE servers used for links opened from now on. */\n setIceServers: (servers: RTCIceServer[]) => void;\n /**\n * Close every link, forget the local tracks and report `closed` — for good.\n *\n * Stopping is terminal. The signalling socket usually outlives the call, so\n * an offer still in flight from a former participant, or from anyone the\n * server relays to this user, keeps reaching {@link PeerMesh.accept} after\n * the hang-up. A stopped mesh drops it: no connection is opened, nothing is\n * answered and no track is published, so a call that reports `closed` never\n * sends the microphone or camera again. The same holds for `addPeer`,\n * `setLocalTrack` and `applyQuality`, and for every negotiation that was\n * already awaiting the browser when `stop()` ran. Calling it twice is a\n * no-op.\n *\n * The tracks themselves are not stopped: they belong to the caller, who may\n * still be showing them in a preview or reusing them on the next call. To\n * join again, create a new mesh.\n */\n stop: () => void;\n /** The peers as the mesh currently sees them. */\n readonly peers: MeshPeer[];\n /**\n * The connection carrying one peer's link, or `null`.\n *\n * The escape hatch for everything this mesh does not model. `getStats()`,\n * `getSenders()`, an `RTCDataChannel`, an encoding tweak the `quality`\n * shape has no field for — all of it lives on the connection, and a mesh\n * that keeps it private turns adopting the mesh into losing a feature.\n *\n * {@link MeshPeer.connection} is the *state*, which is what a view needs;\n * this is the object, which is what a measurement needs. For the common\n * case of measuring, {@link PeerMeshOptions.stats} spares you the loop —\n * including the one-sampler-per-connection rule that a hand-rolled version\n * gets wrong.\n *\n * @param peerId - The id the peer was added under.\n * @returns The connection, or `null` when there is no link for that id.\n */\n getConnection: (peerId: string) => RTCPeerConnection | null;\n}\n\n/**\n * One `RTCPeerConnection` per peer, with the slots negotiated up front.\n *\n * The signalling protocol stays yours: this produces and accepts three message\n * shapes (`offer`, `answer`, `ice`) and leaves delivery, room membership and\n * identity to your server. What it owns is the part that is the same in every\n * mesh and wrong in most of them — see {@link PeerMesh} for the traps each\n * method exists to avoid.\n *\n * @param options - See {@link PeerMeshOptions}.\n * @returns The mesh.\n *\n * @example\n * const mesh = createPeerMesh({\n * slots: [\n * { name: \"mic\", kind: \"audio\" },\n * { name: \"cam\", kind: \"video\" },\n * ],\n * send: (message) => socket.send(message),\n * onPeers: setPeers,\n * });\n * socket.onMessage = (message) => void mesh.accept(message);\n * await mesh.addPeer(\"peer-2\", { offerer: true });\n * await mesh.setLocalTrack(\"mic\", micTrack);\n */\nexport function createPeerMesh(options: PeerMeshOptions): PeerMesh {\n const { slots, send } = options;\n const links = new Map<string, PeerLink>();\n const peers = new Map<string, MeshPeer>();\n const localTracks = new Map<string, MediaStreamTrack | null>();\n const slotWrites = new Map<string, Promise<void>>();\n let iceServers = options.iceServers ?? [];\n const describe =\n options.setLocalDescription ??\n ((connection: RTCPeerConnection, description: RTCSessionDescriptionInit) =>\n connection.setLocalDescription(description));\n let quality: MeshQuality = options.quality ?? {};\n let stopped = false;\n const samplers = new Map<string, LinkStatsSampler>();\n let statsTimer: ReturnType<typeof setInterval> | null = null;\n\n const emptyStreams = (): Record<string, MediaStream | null> =>\n Object.fromEntries(slots.map((slot) => [slot.name, null]));\n\n const emitPeers = (): void => {\n options.onPeers?.([...peers.values()].map((peer) => ({ ...peer })));\n };\n\n /**\n * Whether a link is still the one the mesh holds for its peer.\n *\n * Every negotiation step awaits the browser, and `stop()` or `removePeer()`\n * can close the link inside any of those awaits. A continuation that does\n * not re-check keeps negotiating on a closed connection — which a browser\n * answers with `InvalidStateError` — or, worse, signals an answer for a call\n * that already hung up.\n *\n * @param link - The link a continuation is about to touch.\n * @returns `false` once the mesh stopped or the link was removed or replaced.\n */\n const isLive = (link: PeerLink): boolean => !stopped && links.get(link.peerId) === link;\n\n /**\n * Await one negotiation step, and tell the caller whether to go on.\n *\n * A browser settles a call that `close()` ran under in one of two ways:\n * it rejects with `InvalidStateError`, or — what Chromium was measured\n * doing — it never settles at all. The second needs nothing here; the\n * first would surface as a rejected `accept()`, which the documented\n * `void mesh.accept(message)` wiring turns into an unhandled rejection\n * for a call that already hung up. So a rejection on a link that is no\n * longer live is swallowed, and one on a live link is rethrown untouched.\n *\n * @param link - The link the step negotiates on.\n * @param step - The pending browser call.\n * @returns The step's value, or {@link GONE} when the link stopped being live.\n */\n const whileLive = async <T>(link: PeerLink, step: Promise<T>): Promise<T | typeof GONE> => {\n try {\n const value = await step;\n return isLive(link) ? value : GONE;\n } catch (error) {\n if (!isLive(link)) return GONE;\n throw error;\n }\n };\n\n /**\n * Sample every connected link once.\n *\n * Only `connected` links are asked. A link still gathering candidates has\n * no traffic to measure, and asking anyway spends a `getStats()` round trip\n * to learn that — per link, on every tick.\n *\n * `settings` arrives as an argument rather than being read from `options`\n * here: the timer is only ever armed past the check that they exist, so\n * reading them again would add a branch that nothing can reach.\n *\n * Each link is sampled on its own: a `getStats()` that rejects — a link\n * closing under the call does that — skips that link for this tick and\n * leaves the others measured. Across the await the link is re-checked by\n * identity, not by id: a peer removed and re-added under the same id holds\n * a new connection, and the old one's sample must not land on it.\n *\n * @param settings - The measuring options, already known to be present.\n */\n const sampleLinks = async (settings: MeshStatsOptions): Promise<void> => {\n if (stopped) return;\n let changed = false;\n\n for (const link of [...links.values()]) {\n if (link.pc.connectionState !== \"connected\") continue;\n let sampler = samplers.get(link.peerId);\n if (sampler === undefined) {\n sampler = createLinkStatsSampler(\n settings.kind === undefined ? {} : { kind: settings.kind },\n );\n samplers.set(link.peerId, sampler);\n }\n let stats: LinkStats;\n try {\n stats = await sampler.sample(link.pc);\n } catch {\n continue;\n }\n const peer = peers.get(link.peerId);\n if (!isLive(link) || peer === undefined) continue;\n peer.stats = stats;\n changed = true;\n settings.onStats?.(link.peerId, stats);\n }\n\n if (changed) emitPeers();\n };\n\n /**\n * Keep the timer alive exactly while there is something to measure.\n *\n * One timer for the whole mesh, not one per peer — and none at all in an\n * empty room, which is where a call sits for as long as the first person is\n * early.\n */\n const syncStatsTimer = (): void => {\n const settings = options.stats;\n if (settings === undefined) return;\n const wanted = !stopped && links.size > 0;\n if (wanted && statsTimer === null) {\n statsTimer = setInterval(() => void sampleLinks(settings), settings.intervalMs ?? 2000);\n return;\n }\n if (!wanted && statsTimer !== null) {\n clearInterval(statsTimer);\n statsTimer = null;\n }\n };\n\n /**\n * Collapse the per-link states into the one badge a call shows.\n *\n * No links at all is `connected`, not a failure: it means nobody else is in\n * the room yet, which is a legitimate state a call sits in for as long as the\n * first person is early.\n */\n const refreshState = (): void => {\n if (stopped) return;\n const states = [...links.values()].map((link) => link.pc.connectionState);\n if (states.some((state) => state === \"connected\")) options.onState?.(\"connected\");\n else if (states.length > 0) options.onState?.(\"connecting\", \"negotiating\");\n else options.onState?.(\"connected\", \"alone\");\n };\n\n const applyQuality = async (next: MeshQuality): Promise<void> => {\n if (stopped) return;\n quality = next;\n const effective = scaleForRoom(next, links.size);\n const degradation = resolveDegradation(next, effective);\n for (const link of links.values()) {\n await applyQualityToLink(link.transceivers, slots, effective, degradation);\n }\n };\n\n const makeOffer = async (link: PeerLink): Promise<void> => {\n if (link.makingOffer) return;\n link.makingOffer = true;\n try {\n const offer = await link.pc.createOffer();\n if (!isLive(link)) return;\n if (!offer.sdp) {\n options.onNotice?.(\"empty_local_offer_sdp\");\n return;\n }\n await describe(link.pc, offer);\n if (!isLive(link)) return;\n send({\n type: \"offer\",\n to: link.peerId,\n sdp: link.pc.localDescription?.sdp ?? offer.sdp,\n });\n } catch {\n if (isLive(link)) options.onNotice?.(\"offer_failed\");\n } finally {\n link.makingOffer = false;\n }\n };\n\n /**\n * Wrap an inbound track in the stream the caller renders.\n *\n * The event's own stream is preferred: it is the one the sending side\n * grouped the track into, so a caller that reads `stream.id` sees what the\n * peer published rather than a wrapper invented here. Browsers only fill it\n * when the offer named a stream, hence the fallback — and the guard after\n * that is for a runtime with no `MediaStream` at all, where routing still\n * has to not throw.\n */\n const streamFor = (event: RTCTrackEvent): MediaStream | null => {\n const provided = event.streams?.[0];\n if (provided) return provided;\n return typeof MediaStream === \"undefined\" ? null : new MediaStream([event.track]);\n };\n\n const attachRemoteTrack = (link: PeerLink, event: RTCTrackEvent): void => {\n const slot = slotOf(event.transceiver, link, slots);\n const peer = peers.get(link.peerId);\n if (!slot || !peer) return;\n\n peer.streams = { ...peer.streams, [slot.name]: streamFor(event) };\n event.track.onended = (): void => {\n const current = peers.get(link.peerId);\n if (!current) return;\n current.streams = { ...current.streams, [slot.name]: null };\n emitPeers();\n };\n emitPeers();\n };\n\n /**\n * Queue one write behind every earlier write to the same slot.\n *\n * Every write that puts a local track on a sender goes through here — the\n * fan-out of `setLocalTrack` and the first write onto a link that just\n * got its transceivers alike. Two writes to one slot therefore never run\n * at once, and a write queued later always finishes later.\n *\n * @param slot - The slot name.\n * @param write - The write to run once the earlier ones have finished.\n * @returns The queued write.\n */\n const enqueueWrite = (slot: string, write: () => Promise<void>): Promise<void> => {\n const queued = (slotWrites.get(slot) ?? Promise.resolve()).then(write);\n slotWrites.set(slot, queued);\n return queued;\n };\n\n /**\n * Put a slot's current track on one link's sender.\n *\n * The track is read when the write runs, not when it was queued, so the\n * last `setLocalTrack` wins whichever write lands last. A refusal\n * belongs to a link that is tearing down, whose state handler removes it,\n * so it is not an error here.\n *\n * @param link - The link to write onto.\n * @param slot - The slot name.\n * @param index - The slot's position in `slots`, which is its transceiver.\n */\n const writeSlot = async (link: PeerLink, slot: string, index: number): Promise<void> => {\n const transceiver = link.transceivers[index];\n if (!transceiver || !isLive(link)) return;\n try {\n await transceiver.sender.replaceTrack(localTracks.get(slot) ?? null);\n } catch {\n return;\n }\n };\n\n /**\n * Publish every held local track onto a link whose transceivers now exist.\n *\n * Up front for the offerer, after the offer lands for the answerer, so a\n * microphone that was already live when the peer joined starts flowing\n * without waiting for a toggle. Each slot's write joins that slot's queue,\n * so a mute issued while it is in flight still has the last word.\n *\n * @param link - The link to publish onto.\n */\n const attachLocalTracks = (link: PeerLink): Promise<unknown> =>\n Promise.all(\n slots.map((slot, index) =>\n enqueueWrite(slot.name, () => writeSlot(link, slot.name, index)),\n ),\n );\n\n const removePeer = (peerId: string): void => {\n if (stopped) return;\n const link = links.get(peerId);\n if (link) {\n link.pc.close();\n links.delete(peerId);\n }\n peers.delete(peerId);\n samplers.delete(peerId);\n void applyQuality(quality);\n syncStatsTimer();\n emitPeers();\n refreshState();\n };\n\n const addPeer = async (peerId: string, { offerer }: { offerer: boolean }): Promise<void> => {\n if (stopped || links.has(peerId)) return;\n\n const pc = createPeerConnection(iceServers);\n let transceivers: RTCRtpTransceiver[] = [];\n if (offerer) {\n try {\n transceivers = slots.map((slot) =>\n pc.addTransceiver(slot.kind, { direction: \"sendrecv\" }),\n );\n } catch (error) {\n pc.close();\n throw error;\n }\n }\n const link: PeerLink = {\n peerId,\n pc,\n transceivers,\n pendingCandidates: [],\n remoteReady: false,\n makingOffer: false,\n isOfferer: offerer,\n };\n links.set(peerId, link);\n peers.set(peerId, {\n peerId,\n connection: pc.connectionState,\n streams: peers.get(peerId)?.streams ?? emptyStreams(),\n });\n\n if (offerer) {\n await attachLocalTracks(link);\n if (!isLive(link)) return;\n }\n\n pc.onicecandidate = (event): void => {\n send({\n type: \"ice\",\n to: peerId,\n candidate: event.candidate?.candidate ?? null,\n sdpMid: event.candidate?.sdpMid ?? null,\n sdpMLineIndex: event.candidate?.sdpMLineIndex ?? null,\n });\n };\n\n pc.ontrack = (event): void => attachRemoteTrack(link, event);\n\n pc.onconnectionstatechange = (): void => {\n if (!isLive(link)) return;\n const peer = peers.get(peerId);\n if (peer) peer.connection = pc.connectionState;\n if (pc.connectionState === \"failed\" || pc.connectionState === \"closed\") {\n removePeer(peerId);\n return;\n }\n emitPeers();\n refreshState();\n };\n\n pc.onnegotiationneeded = (): void => {\n if (link.isOfferer) void makeOffer(link);\n };\n\n syncStatsTimer();\n emitPeers();\n refreshState();\n if (offerer) await makeOffer(link);\n };\n\n const acceptOffer = async (from: string, sdp: string): Promise<void> => {\n if (!links.has(from)) await addPeer(from, { offerer: false });\n const link = links.get(from);\n if (!link || !isLive(link)) return;\n\n if (\n (await whileLive(link, link.pc.setRemoteDescription({ type: \"offer\", sdp }))) === GONE\n ) {\n return;\n }\n link.remoteReady = true;\n await drainCandidates(link);\n\n if (!link.isOfferer) {\n adoptTransceivers(link);\n await attachLocalTracks(link);\n }\n if (!isLive(link)) return;\n\n const answer = await whileLive(link, link.pc.createAnswer());\n if (answer === GONE) return;\n if (!answer.sdp) {\n options.onNotice?.(\"empty_local_answer_sdp\");\n return;\n }\n if ((await whileLive(link, describe(link.pc, answer))) === GONE) return;\n send({ type: \"answer\", to: from, sdp: link.pc.localDescription?.sdp ?? answer.sdp });\n await applyQuality(quality);\n };\n\n const accept = async (message: MeshMessage & { from: string }): Promise<void> => {\n if (stopped) return;\n if (message.type === \"offer\") {\n await acceptOffer(message.from, message.sdp);\n return;\n }\n\n const link = links.get(message.from);\n if (!link) return;\n\n if (message.type === \"answer\") {\n const applied = link.pc.setRemoteDescription({ type: \"answer\", sdp: message.sdp });\n if ((await whileLive(link, applied)) === GONE) return;\n link.remoteReady = true;\n await drainCandidates(link);\n await applyQuality(quality);\n return;\n }\n\n if (message.candidate === null) return;\n await acceptCandidate(link, {\n candidate: message.candidate,\n sdpMid: message.sdpMid ?? undefined,\n sdpMLineIndex: message.sdpMLineIndex ?? undefined,\n });\n };\n\n /**\n * Publish or retract one slot, one write at a time per slot.\n *\n * Writes to a slot are chained, and each publishes whatever the slot holds\n * when it runs. Without the chain, an unmute whose `replaceTrack` is still\n * in flight could finish after the mute that followed it, leaving the\n * sender on the microphone while the mesh believes the slot is empty.\n */\n const setLocalTrack = async (slot: string, track: MediaStreamTrack | null): Promise<void> => {\n if (stopped) return;\n localTracks.set(slot, track);\n const index = slots.findIndex((entry) => entry.name === slot);\n if (index < 0) return;\n\n await enqueueWrite(slot, async () => {\n for (const link of [...links.values()]) await writeSlot(link, slot, index);\n });\n await applyQuality(quality);\n };\n\n const stop = (): void => {\n if (stopped) return;\n stopped = true;\n for (const link of links.values()) link.pc.close();\n links.clear();\n peers.clear();\n samplers.clear();\n localTracks.clear();\n syncStatsTimer();\n emitPeers();\n options.onState?.(\"closed\");\n };\n\n options.onState?.(\"connecting\", \"signaling\");\n\n return {\n addPeer,\n removePeer,\n accept,\n setLocalTrack,\n applyQuality,\n setIceServers: (servers): void => {\n iceServers = servers;\n },\n stop,\n getConnection: (peerId: string): RTCPeerConnection | null => links.get(peerId)?.pc ?? null,\n get peers(): MeshPeer[] {\n return [...peers.values()].map((peer) => ({ ...peer }));\n },\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"peer-mesh.cjs","names":["createLinkStatsSampler","scaleForRoom","resolveDegradation","applyQualityToLink","slotOf","createPeerConnection","drainCandidates","acceptCandidate","require_link_stats","require_mesh_quality","require_peer_link","GONE","createPeerMesh","options","links","peers","localTracks","slotWrites","iceServers","describe","connection","description","quality","stopped","samplers","statsTimer","emptyStreams","slots","slot","emitPeers","peer","isLive","link","whileLive","value","step","error","sampleLinks","changed","sampler","settings","stats","syncStatsTimer","wanted","refreshState","states","state","applyQuality","next","effective","degradation","makeOffer","offer","send","streamFor","event","attachRemoteTrack","current","enqueueWrite","queued","write","writeSlot","transceiver","index","attachLocalTracks","removePeer","peerId","addPeer","pc","transceivers","offerer","acceptOffer","from","answer","message","applied","track","entry","servers"],"sources":["../../src/webrtc/peer-mesh.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines — the mesh is one object's lifetime: a link's\n * transceivers, the offer it owes, the tracks published on it and the share of\n * the uplink it costs all change together, and every method here reads or writes\n * the same two maps. The pieces that *could* stand alone already do —\n * `peer-link.ts` for the per-connection mechanics and `mesh-quality.ts` for the\n * division — and what is left is the coordination between them.\n */\nimport { applyQualityToLink, resolveDegradation, scaleForRoom } from \"./mesh-quality\";\nimport {\n acceptCandidate,\n adoptTransceivers,\n createPeerConnection,\n drainCandidates,\n slotOf,\n type PeerLink,\n} from \"./peer-link\";\nimport { createLinkStatsSampler, type LinkStats, type LinkStatsSampler } from \"./link-stats\";\nimport type {\n MeshMessage,\n MeshPeer,\n MeshQuality,\n MeshStatsOptions,\n PeerMeshOptions,\n} from \"./mesh-types\";\n\n/** What {@link createPeerMesh}'s negotiation steps return once their link is gone. */\nconst GONE = Symbol(\"gone\");\n\n/** A running mesh. */\nexport interface PeerMesh {\n /**\n * Open a link to a peer.\n *\n * `offerer` decides which side sends the offer, and exactly one side of each\n * link must say `true`. Drive it from the arrival order your server already\n * imposes — the peer that joined **later** offers, the one already in the\n * room answers. That avoids glare by construction, without the rollback\n * dance of perfect negotiation and without depending on a browser\n * implementing argument-less `setLocalDescription`.\n */\n addPeer: (peerId: string, options: { offerer: boolean }) => Promise<void>;\n /** Close one link and give its share of the uplink back to the others. */\n removePeer: (peerId: string) => void;\n /** Feed the mesh an `offer`, `answer` or `ice` message from `from`. */\n accept: (message: MeshMessage & { from: string }) => Promise<void>;\n /**\n * Publish or retract one slot across every live link.\n *\n * `null` retracts: the sender keeps its negotiated transceiver and stops\n * producing, which is what mutes a microphone without disturbing the other\n * slots or the connection. Nothing renegotiates — the m-lines were allocated\n * before any track existed, which is what makes a mesh survive toggling: a\n * renegotiation per toggle is N-1 simultaneous offer/answer rounds every time\n * somebody unmutes.\n */\n setLocalTrack: (slot: string, track: MediaStreamTrack | null) => Promise<void>;\n /** Apply encoder limits, divided by the current size of the room. */\n applyQuality: (quality: MeshQuality) => Promise<void>;\n /** Replace the ICE servers used for links opened from now on. */\n setIceServers: (servers: RTCIceServer[]) => void;\n /**\n * Close every link, forget the local tracks and report `closed` — for good.\n *\n * Stopping is terminal. The signalling socket usually outlives the call, so\n * an offer still in flight from a former participant, or from anyone the\n * server relays to this user, keeps reaching {@link PeerMesh.accept} after\n * the hang-up. A stopped mesh drops it: no connection is opened, nothing is\n * answered and no track is published, so a call that reports `closed` never\n * sends the microphone or camera again. The same holds for `addPeer`,\n * `setLocalTrack` and `applyQuality`, and for every negotiation that was\n * already awaiting the browser when `stop()` ran. Calling it twice is a\n * no-op.\n *\n * The tracks themselves are not stopped: they belong to the caller, who may\n * still be showing them in a preview or reusing them on the next call. To\n * join again, create a new mesh.\n */\n stop: () => void;\n /** The peers as the mesh currently sees them. */\n readonly peers: MeshPeer[];\n /**\n * The connection carrying one peer's link, or `null`.\n *\n * The escape hatch for everything this mesh does not model. `getStats()`,\n * `getSenders()`, an `RTCDataChannel`, an encoding tweak the `quality`\n * shape has no field for — all of it lives on the connection, and a mesh\n * that keeps it private turns adopting the mesh into losing a feature.\n *\n * {@link MeshPeer.connection} is the *state*, which is what a view needs;\n * this is the object, which is what a measurement needs. For the common\n * case of measuring, {@link PeerMeshOptions.stats} spares you the loop —\n * including the one-sampler-per-connection rule that a hand-rolled version\n * gets wrong.\n *\n * @param peerId - The id the peer was added under.\n * @returns The connection, or `null` when there is no link for that id.\n */\n getConnection: (peerId: string) => RTCPeerConnection | null;\n}\n\n/**\n * One `RTCPeerConnection` per peer, with the slots negotiated up front.\n *\n * The signalling protocol stays yours: this produces and accepts three message\n * shapes (`offer`, `answer`, `ice`) and leaves delivery, room membership and\n * identity to your server. What it owns is the part that is the same in every\n * mesh and wrong in most of them — see {@link PeerMesh} for the traps each\n * method exists to avoid.\n *\n * @param options - See {@link PeerMeshOptions}.\n * @returns The mesh.\n *\n * @example\n * const mesh = createPeerMesh({\n * slots: [\n * { name: \"mic\", kind: \"audio\" },\n * { name: \"cam\", kind: \"video\" },\n * ],\n * send: (message) => socket.send(message),\n * onPeers: setPeers,\n * });\n * socket.onMessage = (message) => void mesh.accept(message);\n * await mesh.addPeer(\"peer-2\", { offerer: true });\n * await mesh.setLocalTrack(\"mic\", micTrack);\n */\nexport function createPeerMesh(options: PeerMeshOptions): PeerMesh {\n const { slots, send } = options;\n const links = new Map<string, PeerLink>();\n const peers = new Map<string, MeshPeer>();\n const localTracks = new Map<string, MediaStreamTrack | null>();\n const slotWrites = new Map<string, Promise<void>>();\n let iceServers = options.iceServers ?? [];\n const describe =\n options.setLocalDescription ??\n ((connection: RTCPeerConnection, description: RTCSessionDescriptionInit) =>\n connection.setLocalDescription(description));\n let quality: MeshQuality = options.quality ?? {};\n let stopped = false;\n const samplers = new Map<string, LinkStatsSampler>();\n let statsTimer: ReturnType<typeof setInterval> | null = null;\n\n const emptyStreams = (): Record<string, MediaStream | null> =>\n Object.fromEntries(slots.map((slot) => [slot.name, null]));\n\n const emitPeers = (): void => {\n options.onPeers?.([...peers.values()].map((peer) => ({ ...peer })));\n };\n\n /**\n * Whether a link is still the one the mesh holds for its peer.\n *\n * Every negotiation step awaits the browser, and `stop()` or `removePeer()`\n * can close the link inside any of those awaits. A continuation that does\n * not re-check keeps negotiating on a closed connection — which a browser\n * answers with `InvalidStateError` — or, worse, signals an answer for a call\n * that already hung up.\n *\n * @param link - The link a continuation is about to touch.\n * @returns `false` once the mesh stopped or the link was removed or replaced.\n */\n const isLive = (link: PeerLink): boolean => !stopped && links.get(link.peerId) === link;\n\n /**\n * Await one negotiation step, and tell the caller whether to go on.\n *\n * A browser settles a call that `close()` ran under in one of two ways:\n * it rejects with `InvalidStateError`, or — what Chromium was measured\n * doing — it never settles at all. The second needs nothing here; the\n * first would surface as a rejected `accept()`, which the documented\n * `void mesh.accept(message)` wiring turns into an unhandled rejection\n * for a call that already hung up. So a rejection on a link that is no\n * longer live is swallowed, and one on a live link is rethrown untouched.\n *\n * @param link - The link the step negotiates on.\n * @param step - The pending browser call.\n * @returns The step's value, or {@link GONE} when the link stopped being live.\n */\n const whileLive = async <T>(link: PeerLink, step: Promise<T>): Promise<T | typeof GONE> => {\n try {\n const value = await step;\n return isLive(link) ? value : GONE;\n } catch (error) {\n if (!isLive(link)) return GONE;\n throw error;\n }\n };\n\n /**\n * Sample every connected link once.\n *\n * Only `connected` links are asked. A link still gathering candidates has\n * no traffic to measure, and asking anyway spends a `getStats()` round trip\n * to learn that — per link, on every tick.\n *\n * `settings` arrives as an argument rather than being read from `options`\n * here: the timer is only ever armed past the check that they exist, so\n * reading them again would add a branch that nothing can reach.\n *\n * Each link is sampled on its own: a `getStats()` that rejects — a link\n * closing under the call does that — skips that link for this tick and\n * leaves the others measured. Across the await the link is re-checked by\n * identity, not by id: a peer removed and re-added under the same id holds\n * a new connection, and the old one's sample must not land on it.\n *\n * @param settings - The measuring options, already known to be present.\n */\n const sampleLinks = async (settings: MeshStatsOptions): Promise<void> => {\n if (stopped) return;\n let changed = false;\n\n for (const link of [...links.values()]) {\n if (link.pc.connectionState !== \"connected\") continue;\n let sampler = samplers.get(link.peerId);\n if (sampler === undefined) {\n sampler = createLinkStatsSampler(\n settings.kind === undefined ? {} : { kind: settings.kind },\n );\n samplers.set(link.peerId, sampler);\n }\n let stats: LinkStats;\n try {\n stats = await sampler.sample(link.pc);\n } catch {\n continue;\n }\n const peer = peers.get(link.peerId);\n if (!isLive(link) || peer === undefined) continue;\n peer.stats = stats;\n changed = true;\n settings.onStats?.(link.peerId, stats);\n }\n\n if (changed) emitPeers();\n };\n\n /**\n * Keep the timer alive exactly while there is something to measure.\n *\n * One timer for the whole mesh, not one per peer — and none at all in an\n * empty room, which is where a call sits for as long as the first person is\n * early.\n */\n const syncStatsTimer = (): void => {\n const settings = options.stats;\n if (settings === undefined) return;\n const wanted = !stopped && links.size > 0;\n if (wanted && statsTimer === null) {\n statsTimer = setInterval(() => void sampleLinks(settings), settings.intervalMs ?? 2000);\n return;\n }\n if (!wanted && statsTimer !== null) {\n clearInterval(statsTimer);\n statsTimer = null;\n }\n };\n\n /**\n * Collapse the per-link states into the one badge a call shows.\n *\n * No links at all is `connected`, not a failure: it means nobody else is in\n * the room yet, which is a legitimate state a call sits in for as long as the\n * first person is early.\n */\n const refreshState = (): void => {\n if (stopped) return;\n const states = [...links.values()].map((link) => link.pc.connectionState);\n if (states.some((state) => state === \"connected\")) options.onState?.(\"connected\");\n else if (states.length > 0) options.onState?.(\"connecting\", \"negotiating\");\n else options.onState?.(\"connected\", \"alone\");\n };\n\n const applyQuality = async (next: MeshQuality): Promise<void> => {\n if (stopped) return;\n quality = next;\n const effective = scaleForRoom(next, links.size);\n const degradation = resolveDegradation(next, effective);\n for (const link of links.values()) {\n await applyQualityToLink(link.transceivers, slots, effective, degradation);\n }\n };\n\n const makeOffer = async (link: PeerLink): Promise<void> => {\n if (link.makingOffer) return;\n link.makingOffer = true;\n try {\n const offer = await link.pc.createOffer();\n if (!isLive(link)) return;\n if (!offer.sdp) {\n options.onNotice?.(\"empty_local_offer_sdp\");\n return;\n }\n await describe(link.pc, offer);\n if (!isLive(link)) return;\n send({\n type: \"offer\",\n to: link.peerId,\n sdp: link.pc.localDescription?.sdp ?? offer.sdp,\n });\n } catch {\n if (isLive(link)) options.onNotice?.(\"offer_failed\");\n } finally {\n link.makingOffer = false;\n }\n };\n\n /**\n * Wrap an inbound track in the stream the caller renders.\n *\n * The event's own stream is preferred: it is the one the sending side\n * grouped the track into, so a caller that reads `stream.id` sees what the\n * peer published rather than a wrapper invented here. Browsers only fill it\n * when the offer named a stream, hence the fallback — and the guard after\n * that is for a runtime with no `MediaStream` at all, where routing still\n * has to not throw.\n */\n const streamFor = (event: RTCTrackEvent): MediaStream | null => {\n const provided = event.streams?.[0];\n if (provided) return provided;\n return typeof MediaStream === \"undefined\" ? null : new MediaStream([event.track]);\n };\n\n const attachRemoteTrack = (link: PeerLink, event: RTCTrackEvent): void => {\n const slot = slotOf(event.transceiver, link, slots);\n const peer = peers.get(link.peerId);\n if (!slot || !peer) return;\n\n peer.streams = { ...peer.streams, [slot.name]: streamFor(event) };\n event.track.onended = (): void => {\n const current = peers.get(link.peerId);\n if (!current) return;\n current.streams = { ...current.streams, [slot.name]: null };\n emitPeers();\n };\n emitPeers();\n };\n\n /**\n * Queue one write behind every earlier write to the same slot.\n *\n * Every write that puts a local track on a sender goes through here — the\n * fan-out of `setLocalTrack` and the first write onto a link that just\n * got its transceivers alike. Two writes to one slot therefore never run\n * at once, and a write queued later always finishes later.\n *\n * @param slot - The slot name.\n * @param write - The write to run once the earlier ones have finished.\n * @returns The queued write.\n */\n const enqueueWrite = (slot: string, write: () => Promise<void>): Promise<void> => {\n const queued = (slotWrites.get(slot) ?? Promise.resolve()).then(write);\n slotWrites.set(slot, queued);\n return queued;\n };\n\n /**\n * Put a slot's current track on one link's sender.\n *\n * The track is read when the write runs, not when it was queued, so the\n * last `setLocalTrack` wins whichever write lands last. A refusal\n * belongs to a link that is tearing down, whose state handler removes it,\n * so it is not an error here.\n *\n * @param link - The link to write onto.\n * @param slot - The slot name.\n * @param index - The slot's position in `slots`, which is its transceiver.\n */\n const writeSlot = async (link: PeerLink, slot: string, index: number): Promise<void> => {\n const transceiver = link.transceivers[index];\n if (!transceiver || !isLive(link)) return;\n try {\n await transceiver.sender.replaceTrack(localTracks.get(slot) ?? null);\n } catch {\n return;\n }\n };\n\n /**\n * Publish every held local track onto a link whose transceivers now exist.\n *\n * Up front for the offerer, after the offer lands for the answerer, so a\n * microphone that was already live when the peer joined starts flowing\n * without waiting for a toggle. Each slot's write joins that slot's queue,\n * so a mute issued while it is in flight still has the last word.\n *\n * @param link - The link to publish onto.\n */\n const attachLocalTracks = (link: PeerLink): Promise<unknown> =>\n Promise.all(\n slots.map((slot, index) =>\n enqueueWrite(slot.name, () => writeSlot(link, slot.name, index)),\n ),\n );\n\n const removePeer = (peerId: string): void => {\n if (stopped) return;\n const link = links.get(peerId);\n if (link) {\n link.pc.close();\n links.delete(peerId);\n }\n peers.delete(peerId);\n samplers.delete(peerId);\n void applyQuality(quality);\n syncStatsTimer();\n emitPeers();\n refreshState();\n };\n\n const addPeer = async (peerId: string, { offerer }: { offerer: boolean }): Promise<void> => {\n if (stopped || links.has(peerId)) return;\n\n const pc = createPeerConnection(iceServers);\n let transceivers: RTCRtpTransceiver[] = [];\n if (offerer) {\n try {\n transceivers = slots.map((slot) =>\n pc.addTransceiver(slot.kind, { direction: \"sendrecv\" }),\n );\n } catch (error) {\n pc.close();\n throw error;\n }\n }\n const link: PeerLink = {\n peerId,\n pc,\n transceivers,\n pendingCandidates: [],\n remoteReady: false,\n makingOffer: false,\n isOfferer: offerer,\n };\n links.set(peerId, link);\n peers.set(peerId, {\n peerId,\n connection: pc.connectionState,\n streams: peers.get(peerId)?.streams ?? emptyStreams(),\n });\n\n if (offerer) {\n await attachLocalTracks(link);\n if (!isLive(link)) return;\n }\n\n pc.onicecandidate = (event): void => {\n send({\n type: \"ice\",\n to: peerId,\n candidate: event.candidate?.candidate ?? null,\n sdpMid: event.candidate?.sdpMid ?? null,\n sdpMLineIndex: event.candidate?.sdpMLineIndex ?? null,\n });\n };\n\n pc.ontrack = (event): void => attachRemoteTrack(link, event);\n\n pc.onconnectionstatechange = (): void => {\n if (!isLive(link)) return;\n const peer = peers.get(peerId);\n if (peer) peer.connection = pc.connectionState;\n if (pc.connectionState === \"failed\" || pc.connectionState === \"closed\") {\n removePeer(peerId);\n return;\n }\n emitPeers();\n refreshState();\n };\n\n pc.onnegotiationneeded = (): void => {\n if (link.isOfferer) void makeOffer(link);\n };\n\n syncStatsTimer();\n emitPeers();\n refreshState();\n if (offerer) await makeOffer(link);\n };\n\n const acceptOffer = async (from: string, sdp: string): Promise<void> => {\n if (!links.has(from)) {\n if (options.acceptPeer && !options.acceptPeer(from)) {\n options.onNotice?.(\"offer_refused\");\n return;\n }\n await addPeer(from, { offerer: false });\n }\n const link = links.get(from);\n if (!link || !isLive(link)) return;\n\n if (\n (await whileLive(link, link.pc.setRemoteDescription({ type: \"offer\", sdp }))) === GONE\n ) {\n return;\n }\n link.remoteReady = true;\n await drainCandidates(link);\n\n if (!link.isOfferer) {\n adoptTransceivers(link);\n await attachLocalTracks(link);\n }\n if (!isLive(link)) return;\n\n const answer = await whileLive(link, link.pc.createAnswer());\n if (answer === GONE) return;\n if (!answer.sdp) {\n options.onNotice?.(\"empty_local_answer_sdp\");\n return;\n }\n if ((await whileLive(link, describe(link.pc, answer))) === GONE) return;\n send({ type: \"answer\", to: from, sdp: link.pc.localDescription?.sdp ?? answer.sdp });\n await applyQuality(quality);\n };\n\n const accept = async (message: MeshMessage & { from: string }): Promise<void> => {\n if (stopped) return;\n if (message.type === \"offer\") {\n await acceptOffer(message.from, message.sdp);\n return;\n }\n\n const link = links.get(message.from);\n if (!link) return;\n\n if (message.type === \"answer\") {\n const applied = link.pc.setRemoteDescription({ type: \"answer\", sdp: message.sdp });\n if ((await whileLive(link, applied)) === GONE) return;\n link.remoteReady = true;\n await drainCandidates(link);\n await applyQuality(quality);\n return;\n }\n\n if (message.candidate === null) return;\n await acceptCandidate(link, {\n candidate: message.candidate,\n sdpMid: message.sdpMid ?? undefined,\n sdpMLineIndex: message.sdpMLineIndex ?? undefined,\n });\n };\n\n /**\n * Publish or retract one slot, one write at a time per slot.\n *\n * Writes to a slot are chained, and each publishes whatever the slot holds\n * when it runs. Without the chain, an unmute whose `replaceTrack` is still\n * in flight could finish after the mute that followed it, leaving the\n * sender on the microphone while the mesh believes the slot is empty.\n */\n const setLocalTrack = async (slot: string, track: MediaStreamTrack | null): Promise<void> => {\n if (stopped) return;\n localTracks.set(slot, track);\n const index = slots.findIndex((entry) => entry.name === slot);\n if (index < 0) return;\n\n await enqueueWrite(slot, async () => {\n for (const link of [...links.values()]) await writeSlot(link, slot, index);\n });\n await applyQuality(quality);\n };\n\n const stop = (): void => {\n if (stopped) return;\n stopped = true;\n for (const link of links.values()) link.pc.close();\n links.clear();\n peers.clear();\n samplers.clear();\n localTracks.clear();\n syncStatsTimer();\n emitPeers();\n options.onState?.(\"closed\");\n };\n\n options.onState?.(\"connecting\", \"signaling\");\n\n return {\n addPeer,\n removePeer,\n accept,\n setLocalTrack,\n applyQuality,\n setIceServers: (servers): void => {\n iceServers = servers;\n },\n stop,\n getConnection: (peerId: string): RTCPeerConnection | null => links.get(peerId)?.pc ?? null,\n get peers(): MeshPeer[] {\n return [...peers.values()].map((peer) => ({ ...peer }));\n },\n };\n}\n"],"mappings":"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"}
|
package/dist/webrtc/peer-mesh.js
CHANGED
|
@@ -130,7 +130,13 @@ function u(u) {
|
|
|
130
130
|
i.isOfferer && j(i);
|
|
131
131
|
}, O(), w(), k(), t && await j(i));
|
|
132
132
|
}, z = async (e, t) => {
|
|
133
|
-
p.has(e)
|
|
133
|
+
if (!p.has(e)) {
|
|
134
|
+
if (u.acceptPeer && !u.acceptPeer(e)) {
|
|
135
|
+
u.onNotice?.("offer_refused");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
await R(e, { offerer: !1 });
|
|
139
|
+
}
|
|
134
140
|
let n = p.get(e);
|
|
135
141
|
if (!n || !T(n) || await E(n, n.pc.setRemoteDescription({
|
|
136
142
|
type: "offer",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"peer-mesh.js","names":["GONE","createPeerMesh","options","links","peers","localTracks","slotWrites","iceServers","describe","connection","description","quality","stopped","samplers","statsTimer","emptyStreams","slots","slot","emitPeers","peer","isLive","link","whileLive","value","step","error","sampleLinks","changed","sampler","createLinkStatsSampler","settings","stats","syncStatsTimer","wanted","refreshState","states","state","applyQuality","next","effective","scaleForRoom","degradation","resolveDegradation","applyQualityToLink","makeOffer","offer","streamFor","event","attachRemoteTrack","slotOf","current","enqueueWrite","queued","write","writeSlot","transceiver","index","attachLocalTracks","removePeer","peerId","addPeer","pc","createPeerConnection","transceivers","offerer","acceptOffer","from","drainCandidates","adoptTransceivers","answer","send","message","applied","acceptCandidate","track","entry","servers"],"sources":["../../src/webrtc/peer-mesh.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines — the mesh is one object's lifetime: a link's\n * transceivers, the offer it owes, the tracks published on it and the share of\n * the uplink it costs all change together, and every method here reads or writes\n * the same two maps. The pieces that *could* stand alone already do —\n * `peer-link.ts` for the per-connection mechanics and `mesh-quality.ts` for the\n * division — and what is left is the coordination between them.\n */\nimport { applyQualityToLink, resolveDegradation, scaleForRoom } from \"./mesh-quality\";\nimport {\n acceptCandidate,\n adoptTransceivers,\n createPeerConnection,\n drainCandidates,\n slotOf,\n type PeerLink,\n} from \"./peer-link\";\nimport { createLinkStatsSampler, type LinkStats, type LinkStatsSampler } from \"./link-stats\";\nimport type {\n MeshMessage,\n MeshPeer,\n MeshQuality,\n MeshStatsOptions,\n PeerMeshOptions,\n} from \"./mesh-types\";\n\n/** What {@link createPeerMesh}'s negotiation steps return once their link is gone. */\nconst GONE = Symbol(\"gone\");\n\n/** A running mesh. */\nexport interface PeerMesh {\n /**\n * Open a link to a peer.\n *\n * `offerer` decides which side sends the offer, and exactly one side of each\n * link must say `true`. Drive it from the arrival order your server already\n * imposes — the peer that joined **later** offers, the one already in the\n * room answers. That avoids glare by construction, without the rollback\n * dance of perfect negotiation and without depending on a browser\n * implementing argument-less `setLocalDescription`.\n */\n addPeer: (peerId: string, options: { offerer: boolean }) => Promise<void>;\n /** Close one link and give its share of the uplink back to the others. */\n removePeer: (peerId: string) => void;\n /** Feed the mesh an `offer`, `answer` or `ice` message from `from`. */\n accept: (message: MeshMessage & { from: string }) => Promise<void>;\n /**\n * Publish or retract one slot across every live link.\n *\n * `null` retracts: the sender keeps its negotiated transceiver and stops\n * producing, which is what mutes a microphone without disturbing the other\n * slots or the connection. Nothing renegotiates — the m-lines were allocated\n * before any track existed, which is what makes a mesh survive toggling: a\n * renegotiation per toggle is N-1 simultaneous offer/answer rounds every time\n * somebody unmutes.\n */\n setLocalTrack: (slot: string, track: MediaStreamTrack | null) => Promise<void>;\n /** Apply encoder limits, divided by the current size of the room. */\n applyQuality: (quality: MeshQuality) => Promise<void>;\n /** Replace the ICE servers used for links opened from now on. */\n setIceServers: (servers: RTCIceServer[]) => void;\n /**\n * Close every link, forget the local tracks and report `closed` — for good.\n *\n * Stopping is terminal. The signalling socket usually outlives the call, so\n * an offer still in flight from a former participant, or from anyone the\n * server relays to this user, keeps reaching {@link PeerMesh.accept} after\n * the hang-up. A stopped mesh drops it: no connection is opened, nothing is\n * answered and no track is published, so a call that reports `closed` never\n * sends the microphone or camera again. The same holds for `addPeer`,\n * `setLocalTrack` and `applyQuality`, and for every negotiation that was\n * already awaiting the browser when `stop()` ran. Calling it twice is a\n * no-op.\n *\n * The tracks themselves are not stopped: they belong to the caller, who may\n * still be showing them in a preview or reusing them on the next call. To\n * join again, create a new mesh.\n */\n stop: () => void;\n /** The peers as the mesh currently sees them. */\n readonly peers: MeshPeer[];\n /**\n * The connection carrying one peer's link, or `null`.\n *\n * The escape hatch for everything this mesh does not model. `getStats()`,\n * `getSenders()`, an `RTCDataChannel`, an encoding tweak the `quality`\n * shape has no field for — all of it lives on the connection, and a mesh\n * that keeps it private turns adopting the mesh into losing a feature.\n *\n * {@link MeshPeer.connection} is the *state*, which is what a view needs;\n * this is the object, which is what a measurement needs. For the common\n * case of measuring, {@link PeerMeshOptions.stats} spares you the loop —\n * including the one-sampler-per-connection rule that a hand-rolled version\n * gets wrong.\n *\n * @param peerId - The id the peer was added under.\n * @returns The connection, or `null` when there is no link for that id.\n */\n getConnection: (peerId: string) => RTCPeerConnection | null;\n}\n\n/**\n * One `RTCPeerConnection` per peer, with the slots negotiated up front.\n *\n * The signalling protocol stays yours: this produces and accepts three message\n * shapes (`offer`, `answer`, `ice`) and leaves delivery, room membership and\n * identity to your server. What it owns is the part that is the same in every\n * mesh and wrong in most of them — see {@link PeerMesh} for the traps each\n * method exists to avoid.\n *\n * @param options - See {@link PeerMeshOptions}.\n * @returns The mesh.\n *\n * @example\n * const mesh = createPeerMesh({\n * slots: [\n * { name: \"mic\", kind: \"audio\" },\n * { name: \"cam\", kind: \"video\" },\n * ],\n * send: (message) => socket.send(message),\n * onPeers: setPeers,\n * });\n * socket.onMessage = (message) => void mesh.accept(message);\n * await mesh.addPeer(\"peer-2\", { offerer: true });\n * await mesh.setLocalTrack(\"mic\", micTrack);\n */\nexport function createPeerMesh(options: PeerMeshOptions): PeerMesh {\n const { slots, send } = options;\n const links = new Map<string, PeerLink>();\n const peers = new Map<string, MeshPeer>();\n const localTracks = new Map<string, MediaStreamTrack | null>();\n const slotWrites = new Map<string, Promise<void>>();\n let iceServers = options.iceServers ?? [];\n const describe =\n options.setLocalDescription ??\n ((connection: RTCPeerConnection, description: RTCSessionDescriptionInit) =>\n connection.setLocalDescription(description));\n let quality: MeshQuality = options.quality ?? {};\n let stopped = false;\n const samplers = new Map<string, LinkStatsSampler>();\n let statsTimer: ReturnType<typeof setInterval> | null = null;\n\n const emptyStreams = (): Record<string, MediaStream | null> =>\n Object.fromEntries(slots.map((slot) => [slot.name, null]));\n\n const emitPeers = (): void => {\n options.onPeers?.([...peers.values()].map((peer) => ({ ...peer })));\n };\n\n /**\n * Whether a link is still the one the mesh holds for its peer.\n *\n * Every negotiation step awaits the browser, and `stop()` or `removePeer()`\n * can close the link inside any of those awaits. A continuation that does\n * not re-check keeps negotiating on a closed connection — which a browser\n * answers with `InvalidStateError` — or, worse, signals an answer for a call\n * that already hung up.\n *\n * @param link - The link a continuation is about to touch.\n * @returns `false` once the mesh stopped or the link was removed or replaced.\n */\n const isLive = (link: PeerLink): boolean => !stopped && links.get(link.peerId) === link;\n\n /**\n * Await one negotiation step, and tell the caller whether to go on.\n *\n * A browser settles a call that `close()` ran under in one of two ways:\n * it rejects with `InvalidStateError`, or — what Chromium was measured\n * doing — it never settles at all. The second needs nothing here; the\n * first would surface as a rejected `accept()`, which the documented\n * `void mesh.accept(message)` wiring turns into an unhandled rejection\n * for a call that already hung up. So a rejection on a link that is no\n * longer live is swallowed, and one on a live link is rethrown untouched.\n *\n * @param link - The link the step negotiates on.\n * @param step - The pending browser call.\n * @returns The step's value, or {@link GONE} when the link stopped being live.\n */\n const whileLive = async <T>(link: PeerLink, step: Promise<T>): Promise<T | typeof GONE> => {\n try {\n const value = await step;\n return isLive(link) ? value : GONE;\n } catch (error) {\n if (!isLive(link)) return GONE;\n throw error;\n }\n };\n\n /**\n * Sample every connected link once.\n *\n * Only `connected` links are asked. A link still gathering candidates has\n * no traffic to measure, and asking anyway spends a `getStats()` round trip\n * to learn that — per link, on every tick.\n *\n * `settings` arrives as an argument rather than being read from `options`\n * here: the timer is only ever armed past the check that they exist, so\n * reading them again would add a branch that nothing can reach.\n *\n * Each link is sampled on its own: a `getStats()` that rejects — a link\n * closing under the call does that — skips that link for this tick and\n * leaves the others measured. Across the await the link is re-checked by\n * identity, not by id: a peer removed and re-added under the same id holds\n * a new connection, and the old one's sample must not land on it.\n *\n * @param settings - The measuring options, already known to be present.\n */\n const sampleLinks = async (settings: MeshStatsOptions): Promise<void> => {\n if (stopped) return;\n let changed = false;\n\n for (const link of [...links.values()]) {\n if (link.pc.connectionState !== \"connected\") continue;\n let sampler = samplers.get(link.peerId);\n if (sampler === undefined) {\n sampler = createLinkStatsSampler(\n settings.kind === undefined ? {} : { kind: settings.kind },\n );\n samplers.set(link.peerId, sampler);\n }\n let stats: LinkStats;\n try {\n stats = await sampler.sample(link.pc);\n } catch {\n continue;\n }\n const peer = peers.get(link.peerId);\n if (!isLive(link) || peer === undefined) continue;\n peer.stats = stats;\n changed = true;\n settings.onStats?.(link.peerId, stats);\n }\n\n if (changed) emitPeers();\n };\n\n /**\n * Keep the timer alive exactly while there is something to measure.\n *\n * One timer for the whole mesh, not one per peer — and none at all in an\n * empty room, which is where a call sits for as long as the first person is\n * early.\n */\n const syncStatsTimer = (): void => {\n const settings = options.stats;\n if (settings === undefined) return;\n const wanted = !stopped && links.size > 0;\n if (wanted && statsTimer === null) {\n statsTimer = setInterval(() => void sampleLinks(settings), settings.intervalMs ?? 2000);\n return;\n }\n if (!wanted && statsTimer !== null) {\n clearInterval(statsTimer);\n statsTimer = null;\n }\n };\n\n /**\n * Collapse the per-link states into the one badge a call shows.\n *\n * No links at all is `connected`, not a failure: it means nobody else is in\n * the room yet, which is a legitimate state a call sits in for as long as the\n * first person is early.\n */\n const refreshState = (): void => {\n if (stopped) return;\n const states = [...links.values()].map((link) => link.pc.connectionState);\n if (states.some((state) => state === \"connected\")) options.onState?.(\"connected\");\n else if (states.length > 0) options.onState?.(\"connecting\", \"negotiating\");\n else options.onState?.(\"connected\", \"alone\");\n };\n\n const applyQuality = async (next: MeshQuality): Promise<void> => {\n if (stopped) return;\n quality = next;\n const effective = scaleForRoom(next, links.size);\n const degradation = resolveDegradation(next, effective);\n for (const link of links.values()) {\n await applyQualityToLink(link.transceivers, slots, effective, degradation);\n }\n };\n\n const makeOffer = async (link: PeerLink): Promise<void> => {\n if (link.makingOffer) return;\n link.makingOffer = true;\n try {\n const offer = await link.pc.createOffer();\n if (!isLive(link)) return;\n if (!offer.sdp) {\n options.onNotice?.(\"empty_local_offer_sdp\");\n return;\n }\n await describe(link.pc, offer);\n if (!isLive(link)) return;\n send({\n type: \"offer\",\n to: link.peerId,\n sdp: link.pc.localDescription?.sdp ?? offer.sdp,\n });\n } catch {\n if (isLive(link)) options.onNotice?.(\"offer_failed\");\n } finally {\n link.makingOffer = false;\n }\n };\n\n /**\n * Wrap an inbound track in the stream the caller renders.\n *\n * The event's own stream is preferred: it is the one the sending side\n * grouped the track into, so a caller that reads `stream.id` sees what the\n * peer published rather than a wrapper invented here. Browsers only fill it\n * when the offer named a stream, hence the fallback — and the guard after\n * that is for a runtime with no `MediaStream` at all, where routing still\n * has to not throw.\n */\n const streamFor = (event: RTCTrackEvent): MediaStream | null => {\n const provided = event.streams?.[0];\n if (provided) return provided;\n return typeof MediaStream === \"undefined\" ? null : new MediaStream([event.track]);\n };\n\n const attachRemoteTrack = (link: PeerLink, event: RTCTrackEvent): void => {\n const slot = slotOf(event.transceiver, link, slots);\n const peer = peers.get(link.peerId);\n if (!slot || !peer) return;\n\n peer.streams = { ...peer.streams, [slot.name]: streamFor(event) };\n event.track.onended = (): void => {\n const current = peers.get(link.peerId);\n if (!current) return;\n current.streams = { ...current.streams, [slot.name]: null };\n emitPeers();\n };\n emitPeers();\n };\n\n /**\n * Queue one write behind every earlier write to the same slot.\n *\n * Every write that puts a local track on a sender goes through here — the\n * fan-out of `setLocalTrack` and the first write onto a link that just\n * got its transceivers alike. Two writes to one slot therefore never run\n * at once, and a write queued later always finishes later.\n *\n * @param slot - The slot name.\n * @param write - The write to run once the earlier ones have finished.\n * @returns The queued write.\n */\n const enqueueWrite = (slot: string, write: () => Promise<void>): Promise<void> => {\n const queued = (slotWrites.get(slot) ?? Promise.resolve()).then(write);\n slotWrites.set(slot, queued);\n return queued;\n };\n\n /**\n * Put a slot's current track on one link's sender.\n *\n * The track is read when the write runs, not when it was queued, so the\n * last `setLocalTrack` wins whichever write lands last. A refusal\n * belongs to a link that is tearing down, whose state handler removes it,\n * so it is not an error here.\n *\n * @param link - The link to write onto.\n * @param slot - The slot name.\n * @param index - The slot's position in `slots`, which is its transceiver.\n */\n const writeSlot = async (link: PeerLink, slot: string, index: number): Promise<void> => {\n const transceiver = link.transceivers[index];\n if (!transceiver || !isLive(link)) return;\n try {\n await transceiver.sender.replaceTrack(localTracks.get(slot) ?? null);\n } catch {\n return;\n }\n };\n\n /**\n * Publish every held local track onto a link whose transceivers now exist.\n *\n * Up front for the offerer, after the offer lands for the answerer, so a\n * microphone that was already live when the peer joined starts flowing\n * without waiting for a toggle. Each slot's write joins that slot's queue,\n * so a mute issued while it is in flight still has the last word.\n *\n * @param link - The link to publish onto.\n */\n const attachLocalTracks = (link: PeerLink): Promise<unknown> =>\n Promise.all(\n slots.map((slot, index) =>\n enqueueWrite(slot.name, () => writeSlot(link, slot.name, index)),\n ),\n );\n\n const removePeer = (peerId: string): void => {\n if (stopped) return;\n const link = links.get(peerId);\n if (link) {\n link.pc.close();\n links.delete(peerId);\n }\n peers.delete(peerId);\n samplers.delete(peerId);\n void applyQuality(quality);\n syncStatsTimer();\n emitPeers();\n refreshState();\n };\n\n const addPeer = async (peerId: string, { offerer }: { offerer: boolean }): Promise<void> => {\n if (stopped || links.has(peerId)) return;\n\n const pc = createPeerConnection(iceServers);\n let transceivers: RTCRtpTransceiver[] = [];\n if (offerer) {\n try {\n transceivers = slots.map((slot) =>\n pc.addTransceiver(slot.kind, { direction: \"sendrecv\" }),\n );\n } catch (error) {\n pc.close();\n throw error;\n }\n }\n const link: PeerLink = {\n peerId,\n pc,\n transceivers,\n pendingCandidates: [],\n remoteReady: false,\n makingOffer: false,\n isOfferer: offerer,\n };\n links.set(peerId, link);\n peers.set(peerId, {\n peerId,\n connection: pc.connectionState,\n streams: peers.get(peerId)?.streams ?? emptyStreams(),\n });\n\n if (offerer) {\n await attachLocalTracks(link);\n if (!isLive(link)) return;\n }\n\n pc.onicecandidate = (event): void => {\n send({\n type: \"ice\",\n to: peerId,\n candidate: event.candidate?.candidate ?? null,\n sdpMid: event.candidate?.sdpMid ?? null,\n sdpMLineIndex: event.candidate?.sdpMLineIndex ?? null,\n });\n };\n\n pc.ontrack = (event): void => attachRemoteTrack(link, event);\n\n pc.onconnectionstatechange = (): void => {\n if (!isLive(link)) return;\n const peer = peers.get(peerId);\n if (peer) peer.connection = pc.connectionState;\n if (pc.connectionState === \"failed\" || pc.connectionState === \"closed\") {\n removePeer(peerId);\n return;\n }\n emitPeers();\n refreshState();\n };\n\n pc.onnegotiationneeded = (): void => {\n if (link.isOfferer) void makeOffer(link);\n };\n\n syncStatsTimer();\n emitPeers();\n refreshState();\n if (offerer) await makeOffer(link);\n };\n\n const acceptOffer = async (from: string, sdp: string): Promise<void> => {\n if (!links.has(from)) await addPeer(from, { offerer: false });\n const link = links.get(from);\n if (!link || !isLive(link)) return;\n\n if (\n (await whileLive(link, link.pc.setRemoteDescription({ type: \"offer\", sdp }))) === GONE\n ) {\n return;\n }\n link.remoteReady = true;\n await drainCandidates(link);\n\n if (!link.isOfferer) {\n adoptTransceivers(link);\n await attachLocalTracks(link);\n }\n if (!isLive(link)) return;\n\n const answer = await whileLive(link, link.pc.createAnswer());\n if (answer === GONE) return;\n if (!answer.sdp) {\n options.onNotice?.(\"empty_local_answer_sdp\");\n return;\n }\n if ((await whileLive(link, describe(link.pc, answer))) === GONE) return;\n send({ type: \"answer\", to: from, sdp: link.pc.localDescription?.sdp ?? answer.sdp });\n await applyQuality(quality);\n };\n\n const accept = async (message: MeshMessage & { from: string }): Promise<void> => {\n if (stopped) return;\n if (message.type === \"offer\") {\n await acceptOffer(message.from, message.sdp);\n return;\n }\n\n const link = links.get(message.from);\n if (!link) return;\n\n if (message.type === \"answer\") {\n const applied = link.pc.setRemoteDescription({ type: \"answer\", sdp: message.sdp });\n if ((await whileLive(link, applied)) === GONE) return;\n link.remoteReady = true;\n await drainCandidates(link);\n await applyQuality(quality);\n return;\n }\n\n if (message.candidate === null) return;\n await acceptCandidate(link, {\n candidate: message.candidate,\n sdpMid: message.sdpMid ?? undefined,\n sdpMLineIndex: message.sdpMLineIndex ?? undefined,\n });\n };\n\n /**\n * Publish or retract one slot, one write at a time per slot.\n *\n * Writes to a slot are chained, and each publishes whatever the slot holds\n * when it runs. Without the chain, an unmute whose `replaceTrack` is still\n * in flight could finish after the mute that followed it, leaving the\n * sender on the microphone while the mesh believes the slot is empty.\n */\n const setLocalTrack = async (slot: string, track: MediaStreamTrack | null): Promise<void> => {\n if (stopped) return;\n localTracks.set(slot, track);\n const index = slots.findIndex((entry) => entry.name === slot);\n if (index < 0) return;\n\n await enqueueWrite(slot, async () => {\n for (const link of [...links.values()]) await writeSlot(link, slot, index);\n });\n await applyQuality(quality);\n };\n\n const stop = (): void => {\n if (stopped) return;\n stopped = true;\n for (const link of links.values()) link.pc.close();\n links.clear();\n peers.clear();\n samplers.clear();\n localTracks.clear();\n syncStatsTimer();\n emitPeers();\n options.onState?.(\"closed\");\n };\n\n options.onState?.(\"connecting\", \"signaling\");\n\n return {\n addPeer,\n removePeer,\n accept,\n setLocalTrack,\n applyQuality,\n setIceServers: (servers): void => {\n iceServers = servers;\n },\n stop,\n getConnection: (peerId: string): RTCPeerConnection | null => links.get(peerId)?.pc ?? null,\n get peers(): MeshPeer[] {\n return [...peers.values()].map((peer) => ({ ...peer }));\n },\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"peer-mesh.js","names":["GONE","createPeerMesh","options","links","peers","localTracks","slotWrites","iceServers","describe","connection","description","quality","stopped","samplers","statsTimer","emptyStreams","slots","slot","emitPeers","peer","isLive","link","whileLive","value","step","error","sampleLinks","changed","sampler","createLinkStatsSampler","settings","stats","syncStatsTimer","wanted","refreshState","states","state","applyQuality","next","effective","scaleForRoom","degradation","resolveDegradation","applyQualityToLink","makeOffer","offer","streamFor","event","attachRemoteTrack","slotOf","current","enqueueWrite","queued","write","writeSlot","transceiver","index","attachLocalTracks","removePeer","peerId","addPeer","pc","createPeerConnection","transceivers","offerer","acceptOffer","from","drainCandidates","adoptTransceivers","answer","send","message","applied","acceptCandidate","track","entry","servers"],"sources":["../../src/webrtc/peer-mesh.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines — the mesh is one object's lifetime: a link's\n * transceivers, the offer it owes, the tracks published on it and the share of\n * the uplink it costs all change together, and every method here reads or writes\n * the same two maps. The pieces that *could* stand alone already do —\n * `peer-link.ts` for the per-connection mechanics and `mesh-quality.ts` for the\n * division — and what is left is the coordination between them.\n */\nimport { applyQualityToLink, resolveDegradation, scaleForRoom } from \"./mesh-quality\";\nimport {\n acceptCandidate,\n adoptTransceivers,\n createPeerConnection,\n drainCandidates,\n slotOf,\n type PeerLink,\n} from \"./peer-link\";\nimport { createLinkStatsSampler, type LinkStats, type LinkStatsSampler } from \"./link-stats\";\nimport type {\n MeshMessage,\n MeshPeer,\n MeshQuality,\n MeshStatsOptions,\n PeerMeshOptions,\n} from \"./mesh-types\";\n\n/** What {@link createPeerMesh}'s negotiation steps return once their link is gone. */\nconst GONE = Symbol(\"gone\");\n\n/** A running mesh. */\nexport interface PeerMesh {\n /**\n * Open a link to a peer.\n *\n * `offerer` decides which side sends the offer, and exactly one side of each\n * link must say `true`. Drive it from the arrival order your server already\n * imposes — the peer that joined **later** offers, the one already in the\n * room answers. That avoids glare by construction, without the rollback\n * dance of perfect negotiation and without depending on a browser\n * implementing argument-less `setLocalDescription`.\n */\n addPeer: (peerId: string, options: { offerer: boolean }) => Promise<void>;\n /** Close one link and give its share of the uplink back to the others. */\n removePeer: (peerId: string) => void;\n /** Feed the mesh an `offer`, `answer` or `ice` message from `from`. */\n accept: (message: MeshMessage & { from: string }) => Promise<void>;\n /**\n * Publish or retract one slot across every live link.\n *\n * `null` retracts: the sender keeps its negotiated transceiver and stops\n * producing, which is what mutes a microphone without disturbing the other\n * slots or the connection. Nothing renegotiates — the m-lines were allocated\n * before any track existed, which is what makes a mesh survive toggling: a\n * renegotiation per toggle is N-1 simultaneous offer/answer rounds every time\n * somebody unmutes.\n */\n setLocalTrack: (slot: string, track: MediaStreamTrack | null) => Promise<void>;\n /** Apply encoder limits, divided by the current size of the room. */\n applyQuality: (quality: MeshQuality) => Promise<void>;\n /** Replace the ICE servers used for links opened from now on. */\n setIceServers: (servers: RTCIceServer[]) => void;\n /**\n * Close every link, forget the local tracks and report `closed` — for good.\n *\n * Stopping is terminal. The signalling socket usually outlives the call, so\n * an offer still in flight from a former participant, or from anyone the\n * server relays to this user, keeps reaching {@link PeerMesh.accept} after\n * the hang-up. A stopped mesh drops it: no connection is opened, nothing is\n * answered and no track is published, so a call that reports `closed` never\n * sends the microphone or camera again. The same holds for `addPeer`,\n * `setLocalTrack` and `applyQuality`, and for every negotiation that was\n * already awaiting the browser when `stop()` ran. Calling it twice is a\n * no-op.\n *\n * The tracks themselves are not stopped: they belong to the caller, who may\n * still be showing them in a preview or reusing them on the next call. To\n * join again, create a new mesh.\n */\n stop: () => void;\n /** The peers as the mesh currently sees them. */\n readonly peers: MeshPeer[];\n /**\n * The connection carrying one peer's link, or `null`.\n *\n * The escape hatch for everything this mesh does not model. `getStats()`,\n * `getSenders()`, an `RTCDataChannel`, an encoding tweak the `quality`\n * shape has no field for — all of it lives on the connection, and a mesh\n * that keeps it private turns adopting the mesh into losing a feature.\n *\n * {@link MeshPeer.connection} is the *state*, which is what a view needs;\n * this is the object, which is what a measurement needs. For the common\n * case of measuring, {@link PeerMeshOptions.stats} spares you the loop —\n * including the one-sampler-per-connection rule that a hand-rolled version\n * gets wrong.\n *\n * @param peerId - The id the peer was added under.\n * @returns The connection, or `null` when there is no link for that id.\n */\n getConnection: (peerId: string) => RTCPeerConnection | null;\n}\n\n/**\n * One `RTCPeerConnection` per peer, with the slots negotiated up front.\n *\n * The signalling protocol stays yours: this produces and accepts three message\n * shapes (`offer`, `answer`, `ice`) and leaves delivery, room membership and\n * identity to your server. What it owns is the part that is the same in every\n * mesh and wrong in most of them — see {@link PeerMesh} for the traps each\n * method exists to avoid.\n *\n * @param options - See {@link PeerMeshOptions}.\n * @returns The mesh.\n *\n * @example\n * const mesh = createPeerMesh({\n * slots: [\n * { name: \"mic\", kind: \"audio\" },\n * { name: \"cam\", kind: \"video\" },\n * ],\n * send: (message) => socket.send(message),\n * onPeers: setPeers,\n * });\n * socket.onMessage = (message) => void mesh.accept(message);\n * await mesh.addPeer(\"peer-2\", { offerer: true });\n * await mesh.setLocalTrack(\"mic\", micTrack);\n */\nexport function createPeerMesh(options: PeerMeshOptions): PeerMesh {\n const { slots, send } = options;\n const links = new Map<string, PeerLink>();\n const peers = new Map<string, MeshPeer>();\n const localTracks = new Map<string, MediaStreamTrack | null>();\n const slotWrites = new Map<string, Promise<void>>();\n let iceServers = options.iceServers ?? [];\n const describe =\n options.setLocalDescription ??\n ((connection: RTCPeerConnection, description: RTCSessionDescriptionInit) =>\n connection.setLocalDescription(description));\n let quality: MeshQuality = options.quality ?? {};\n let stopped = false;\n const samplers = new Map<string, LinkStatsSampler>();\n let statsTimer: ReturnType<typeof setInterval> | null = null;\n\n const emptyStreams = (): Record<string, MediaStream | null> =>\n Object.fromEntries(slots.map((slot) => [slot.name, null]));\n\n const emitPeers = (): void => {\n options.onPeers?.([...peers.values()].map((peer) => ({ ...peer })));\n };\n\n /**\n * Whether a link is still the one the mesh holds for its peer.\n *\n * Every negotiation step awaits the browser, and `stop()` or `removePeer()`\n * can close the link inside any of those awaits. A continuation that does\n * not re-check keeps negotiating on a closed connection — which a browser\n * answers with `InvalidStateError` — or, worse, signals an answer for a call\n * that already hung up.\n *\n * @param link - The link a continuation is about to touch.\n * @returns `false` once the mesh stopped or the link was removed or replaced.\n */\n const isLive = (link: PeerLink): boolean => !stopped && links.get(link.peerId) === link;\n\n /**\n * Await one negotiation step, and tell the caller whether to go on.\n *\n * A browser settles a call that `close()` ran under in one of two ways:\n * it rejects with `InvalidStateError`, or — what Chromium was measured\n * doing — it never settles at all. The second needs nothing here; the\n * first would surface as a rejected `accept()`, which the documented\n * `void mesh.accept(message)` wiring turns into an unhandled rejection\n * for a call that already hung up. So a rejection on a link that is no\n * longer live is swallowed, and one on a live link is rethrown untouched.\n *\n * @param link - The link the step negotiates on.\n * @param step - The pending browser call.\n * @returns The step's value, or {@link GONE} when the link stopped being live.\n */\n const whileLive = async <T>(link: PeerLink, step: Promise<T>): Promise<T | typeof GONE> => {\n try {\n const value = await step;\n return isLive(link) ? value : GONE;\n } catch (error) {\n if (!isLive(link)) return GONE;\n throw error;\n }\n };\n\n /**\n * Sample every connected link once.\n *\n * Only `connected` links are asked. A link still gathering candidates has\n * no traffic to measure, and asking anyway spends a `getStats()` round trip\n * to learn that — per link, on every tick.\n *\n * `settings` arrives as an argument rather than being read from `options`\n * here: the timer is only ever armed past the check that they exist, so\n * reading them again would add a branch that nothing can reach.\n *\n * Each link is sampled on its own: a `getStats()` that rejects — a link\n * closing under the call does that — skips that link for this tick and\n * leaves the others measured. Across the await the link is re-checked by\n * identity, not by id: a peer removed and re-added under the same id holds\n * a new connection, and the old one's sample must not land on it.\n *\n * @param settings - The measuring options, already known to be present.\n */\n const sampleLinks = async (settings: MeshStatsOptions): Promise<void> => {\n if (stopped) return;\n let changed = false;\n\n for (const link of [...links.values()]) {\n if (link.pc.connectionState !== \"connected\") continue;\n let sampler = samplers.get(link.peerId);\n if (sampler === undefined) {\n sampler = createLinkStatsSampler(\n settings.kind === undefined ? {} : { kind: settings.kind },\n );\n samplers.set(link.peerId, sampler);\n }\n let stats: LinkStats;\n try {\n stats = await sampler.sample(link.pc);\n } catch {\n continue;\n }\n const peer = peers.get(link.peerId);\n if (!isLive(link) || peer === undefined) continue;\n peer.stats = stats;\n changed = true;\n settings.onStats?.(link.peerId, stats);\n }\n\n if (changed) emitPeers();\n };\n\n /**\n * Keep the timer alive exactly while there is something to measure.\n *\n * One timer for the whole mesh, not one per peer — and none at all in an\n * empty room, which is where a call sits for as long as the first person is\n * early.\n */\n const syncStatsTimer = (): void => {\n const settings = options.stats;\n if (settings === undefined) return;\n const wanted = !stopped && links.size > 0;\n if (wanted && statsTimer === null) {\n statsTimer = setInterval(() => void sampleLinks(settings), settings.intervalMs ?? 2000);\n return;\n }\n if (!wanted && statsTimer !== null) {\n clearInterval(statsTimer);\n statsTimer = null;\n }\n };\n\n /**\n * Collapse the per-link states into the one badge a call shows.\n *\n * No links at all is `connected`, not a failure: it means nobody else is in\n * the room yet, which is a legitimate state a call sits in for as long as the\n * first person is early.\n */\n const refreshState = (): void => {\n if (stopped) return;\n const states = [...links.values()].map((link) => link.pc.connectionState);\n if (states.some((state) => state === \"connected\")) options.onState?.(\"connected\");\n else if (states.length > 0) options.onState?.(\"connecting\", \"negotiating\");\n else options.onState?.(\"connected\", \"alone\");\n };\n\n const applyQuality = async (next: MeshQuality): Promise<void> => {\n if (stopped) return;\n quality = next;\n const effective = scaleForRoom(next, links.size);\n const degradation = resolveDegradation(next, effective);\n for (const link of links.values()) {\n await applyQualityToLink(link.transceivers, slots, effective, degradation);\n }\n };\n\n const makeOffer = async (link: PeerLink): Promise<void> => {\n if (link.makingOffer) return;\n link.makingOffer = true;\n try {\n const offer = await link.pc.createOffer();\n if (!isLive(link)) return;\n if (!offer.sdp) {\n options.onNotice?.(\"empty_local_offer_sdp\");\n return;\n }\n await describe(link.pc, offer);\n if (!isLive(link)) return;\n send({\n type: \"offer\",\n to: link.peerId,\n sdp: link.pc.localDescription?.sdp ?? offer.sdp,\n });\n } catch {\n if (isLive(link)) options.onNotice?.(\"offer_failed\");\n } finally {\n link.makingOffer = false;\n }\n };\n\n /**\n * Wrap an inbound track in the stream the caller renders.\n *\n * The event's own stream is preferred: it is the one the sending side\n * grouped the track into, so a caller that reads `stream.id` sees what the\n * peer published rather than a wrapper invented here. Browsers only fill it\n * when the offer named a stream, hence the fallback — and the guard after\n * that is for a runtime with no `MediaStream` at all, where routing still\n * has to not throw.\n */\n const streamFor = (event: RTCTrackEvent): MediaStream | null => {\n const provided = event.streams?.[0];\n if (provided) return provided;\n return typeof MediaStream === \"undefined\" ? null : new MediaStream([event.track]);\n };\n\n const attachRemoteTrack = (link: PeerLink, event: RTCTrackEvent): void => {\n const slot = slotOf(event.transceiver, link, slots);\n const peer = peers.get(link.peerId);\n if (!slot || !peer) return;\n\n peer.streams = { ...peer.streams, [slot.name]: streamFor(event) };\n event.track.onended = (): void => {\n const current = peers.get(link.peerId);\n if (!current) return;\n current.streams = { ...current.streams, [slot.name]: null };\n emitPeers();\n };\n emitPeers();\n };\n\n /**\n * Queue one write behind every earlier write to the same slot.\n *\n * Every write that puts a local track on a sender goes through here — the\n * fan-out of `setLocalTrack` and the first write onto a link that just\n * got its transceivers alike. Two writes to one slot therefore never run\n * at once, and a write queued later always finishes later.\n *\n * @param slot - The slot name.\n * @param write - The write to run once the earlier ones have finished.\n * @returns The queued write.\n */\n const enqueueWrite = (slot: string, write: () => Promise<void>): Promise<void> => {\n const queued = (slotWrites.get(slot) ?? Promise.resolve()).then(write);\n slotWrites.set(slot, queued);\n return queued;\n };\n\n /**\n * Put a slot's current track on one link's sender.\n *\n * The track is read when the write runs, not when it was queued, so the\n * last `setLocalTrack` wins whichever write lands last. A refusal\n * belongs to a link that is tearing down, whose state handler removes it,\n * so it is not an error here.\n *\n * @param link - The link to write onto.\n * @param slot - The slot name.\n * @param index - The slot's position in `slots`, which is its transceiver.\n */\n const writeSlot = async (link: PeerLink, slot: string, index: number): Promise<void> => {\n const transceiver = link.transceivers[index];\n if (!transceiver || !isLive(link)) return;\n try {\n await transceiver.sender.replaceTrack(localTracks.get(slot) ?? null);\n } catch {\n return;\n }\n };\n\n /**\n * Publish every held local track onto a link whose transceivers now exist.\n *\n * Up front for the offerer, after the offer lands for the answerer, so a\n * microphone that was already live when the peer joined starts flowing\n * without waiting for a toggle. Each slot's write joins that slot's queue,\n * so a mute issued while it is in flight still has the last word.\n *\n * @param link - The link to publish onto.\n */\n const attachLocalTracks = (link: PeerLink): Promise<unknown> =>\n Promise.all(\n slots.map((slot, index) =>\n enqueueWrite(slot.name, () => writeSlot(link, slot.name, index)),\n ),\n );\n\n const removePeer = (peerId: string): void => {\n if (stopped) return;\n const link = links.get(peerId);\n if (link) {\n link.pc.close();\n links.delete(peerId);\n }\n peers.delete(peerId);\n samplers.delete(peerId);\n void applyQuality(quality);\n syncStatsTimer();\n emitPeers();\n refreshState();\n };\n\n const addPeer = async (peerId: string, { offerer }: { offerer: boolean }): Promise<void> => {\n if (stopped || links.has(peerId)) return;\n\n const pc = createPeerConnection(iceServers);\n let transceivers: RTCRtpTransceiver[] = [];\n if (offerer) {\n try {\n transceivers = slots.map((slot) =>\n pc.addTransceiver(slot.kind, { direction: \"sendrecv\" }),\n );\n } catch (error) {\n pc.close();\n throw error;\n }\n }\n const link: PeerLink = {\n peerId,\n pc,\n transceivers,\n pendingCandidates: [],\n remoteReady: false,\n makingOffer: false,\n isOfferer: offerer,\n };\n links.set(peerId, link);\n peers.set(peerId, {\n peerId,\n connection: pc.connectionState,\n streams: peers.get(peerId)?.streams ?? emptyStreams(),\n });\n\n if (offerer) {\n await attachLocalTracks(link);\n if (!isLive(link)) return;\n }\n\n pc.onicecandidate = (event): void => {\n send({\n type: \"ice\",\n to: peerId,\n candidate: event.candidate?.candidate ?? null,\n sdpMid: event.candidate?.sdpMid ?? null,\n sdpMLineIndex: event.candidate?.sdpMLineIndex ?? null,\n });\n };\n\n pc.ontrack = (event): void => attachRemoteTrack(link, event);\n\n pc.onconnectionstatechange = (): void => {\n if (!isLive(link)) return;\n const peer = peers.get(peerId);\n if (peer) peer.connection = pc.connectionState;\n if (pc.connectionState === \"failed\" || pc.connectionState === \"closed\") {\n removePeer(peerId);\n return;\n }\n emitPeers();\n refreshState();\n };\n\n pc.onnegotiationneeded = (): void => {\n if (link.isOfferer) void makeOffer(link);\n };\n\n syncStatsTimer();\n emitPeers();\n refreshState();\n if (offerer) await makeOffer(link);\n };\n\n const acceptOffer = async (from: string, sdp: string): Promise<void> => {\n if (!links.has(from)) {\n if (options.acceptPeer && !options.acceptPeer(from)) {\n options.onNotice?.(\"offer_refused\");\n return;\n }\n await addPeer(from, { offerer: false });\n }\n const link = links.get(from);\n if (!link || !isLive(link)) return;\n\n if (\n (await whileLive(link, link.pc.setRemoteDescription({ type: \"offer\", sdp }))) === GONE\n ) {\n return;\n }\n link.remoteReady = true;\n await drainCandidates(link);\n\n if (!link.isOfferer) {\n adoptTransceivers(link);\n await attachLocalTracks(link);\n }\n if (!isLive(link)) return;\n\n const answer = await whileLive(link, link.pc.createAnswer());\n if (answer === GONE) return;\n if (!answer.sdp) {\n options.onNotice?.(\"empty_local_answer_sdp\");\n return;\n }\n if ((await whileLive(link, describe(link.pc, answer))) === GONE) return;\n send({ type: \"answer\", to: from, sdp: link.pc.localDescription?.sdp ?? answer.sdp });\n await applyQuality(quality);\n };\n\n const accept = async (message: MeshMessage & { from: string }): Promise<void> => {\n if (stopped) return;\n if (message.type === \"offer\") {\n await acceptOffer(message.from, message.sdp);\n return;\n }\n\n const link = links.get(message.from);\n if (!link) return;\n\n if (message.type === \"answer\") {\n const applied = link.pc.setRemoteDescription({ type: \"answer\", sdp: message.sdp });\n if ((await whileLive(link, applied)) === GONE) return;\n link.remoteReady = true;\n await drainCandidates(link);\n await applyQuality(quality);\n return;\n }\n\n if (message.candidate === null) return;\n await acceptCandidate(link, {\n candidate: message.candidate,\n sdpMid: message.sdpMid ?? undefined,\n sdpMLineIndex: message.sdpMLineIndex ?? undefined,\n });\n };\n\n /**\n * Publish or retract one slot, one write at a time per slot.\n *\n * Writes to a slot are chained, and each publishes whatever the slot holds\n * when it runs. Without the chain, an unmute whose `replaceTrack` is still\n * in flight could finish after the mute that followed it, leaving the\n * sender on the microphone while the mesh believes the slot is empty.\n */\n const setLocalTrack = async (slot: string, track: MediaStreamTrack | null): Promise<void> => {\n if (stopped) return;\n localTracks.set(slot, track);\n const index = slots.findIndex((entry) => entry.name === slot);\n if (index < 0) return;\n\n await enqueueWrite(slot, async () => {\n for (const link of [...links.values()]) await writeSlot(link, slot, index);\n });\n await applyQuality(quality);\n };\n\n const stop = (): void => {\n if (stopped) return;\n stopped = true;\n for (const link of links.values()) link.pc.close();\n links.clear();\n peers.clear();\n samplers.clear();\n localTracks.clear();\n syncStatsTimer();\n emitPeers();\n options.onState?.(\"closed\");\n };\n\n options.onState?.(\"connecting\", \"signaling\");\n\n return {\n addPeer,\n removePeer,\n accept,\n setLocalTrack,\n applyQuality,\n setIceServers: (servers): void => {\n iceServers = servers;\n },\n stop,\n getConnection: (peerId: string): RTCPeerConnection | null => links.get(peerId)?.pc ?? null,\n get peers(): MeshPeer[] {\n return [...peers.values()].map((peer) => ({ ...peer }));\n },\n };\n}\n"],"mappings":"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"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=require("../utils/json-frame.cjs"),
|
|
1
|
+
const e=require("../utils/owner-fingerprint.cjs"),t=require("../utils/json-frame.cjs"),n=require("./resilience.cjs");function r(r,i={}){let{protocols:a,maxRetries:o=10,initialBackoff:ee=1e3,maxBackoff:s=3e4,jitter:c=.3,handshakeTimeout:l=8e3,silenceTimeout:u=0,waitForOnline:d=!0,pingInterval:f=0,pingPayload:te=JSON.stringify({type:`ping`}),respondToPing:p=!0,pongPayload:m=JSON.stringify({type:`pong`}),queueWhileClosed:h=!1,maxQueuedMessages:g=100,parser:ne,onOpen:re,onMessage:ie,onClose:ae,onError:oe,onParseError:se,schema:ce,onValidationError:_,onStatusChange:v,onReconnecting:y,onReconnected:b,onLost:x}=i,{getPrincipal:S}=i,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)=>{F=e,I=t});L.catch(()=>void 0);function R(e){return typeof e==`object`&&!!e&&e.type===`ping`}function z(e){if(!e.includes(`"ping"`))return!1;try{return R(JSON.parse(e))}catch{return!1}}function B(){if(!S)return null;try{return e.ownerFingerprint(`p`,S())}catch{return null}}function V(e){let t=B();for(;P.length>0&&e.readyState===WebSocket.OPEN;){let n=P.shift();S&&n.owner!==t||e.send(n.payload)}}function H(e){j!==e&&(j=e,v?.(e))}function U(){T&&=(clearInterval(T),null)}function W(){E&&=(clearTimeout(E),null)}function G(){D&&=(clearTimeout(D),null)}function le(){!f||f<=0||(U(),T=setInterval(()=>{C?.readyState===WebSocket.OPEN&&C.send(te)},f))}function K(){G(),!(M||k<=0)&&(D=setTimeout(q,k))}function q(){G(),!M&&C&&(J(C),C=null,H(`closed`),X())}function ue(e){W(),!(M||e.readyState!==WebSocket.CONNECTING)&&(J(e),C===e&&(C=null),H(`closed`),X())}function J(e){e.onopen=null,e.onmessage=null,e.onerror=null,e.onclose=null;try{e.close()}catch{}}function Y(e){if(G(),W(),Z(),e===`rejected`&&(M=!0),H(`error`),!N&&I){let t=I;I=null,F=null,t(Error(`websocket_${e}`))}x?.(e)}function X(){if(M)return;if(A>=o){Y(`exhausted`);return}let e=n.backoffDelay(A,{initialBackoff:ee,maxBackoff:s,jitter:c});A+=1,y?.(A,o),d&&typeof navigator<`u`&&navigator.onLine===!1?de():w=setTimeout(Q,e)}function de(){if(O||typeof window>`u`)return;let e=()=>{Z(),M||Q()};O=e,window.addEventListener(`online`,e)}function Z(){O&&typeof window<`u`&&(window.removeEventListener(`online`,O),O=null)}function Q(){if(M)return;if(w=null,W(),C){let e=C;e.onmessage=null,e.onclose=null,e.onerror=null,e.readyState!==WebSocket.CONNECTING&&(e.onopen=null),$(e)}H(`connecting`);let e=new WebSocket(r,a);C=e,l>0&&(E=setTimeout(()=>ue(e),l)),e.onopen=t=>{W();let n=A>0;if(A=0,N=!0,H(`open`),le(),K(),V(e),F){let e=F;F=null,I=null,e()}re?.(t),n&&b?.()},e.onmessage=n=>{K();let r=typeof n.data==`string`?n.data:``,i=t.decodeFrame(r,`createWebSocket`,{parser:ne,onParseError:se,schema:ce,onValidationError:_});if(!i.delivered){p&&e.readyState===WebSocket.OPEN&&z(r)&&e.send(m);return}let a=i.data;p&&R(a)&&e.readyState===WebSocket.OPEN&&e.send(m),ie?.({data:a,raw:n})},e.onerror=e=>{oe?.(e)},e.onclose=e=>{W(),U(),G(),ae?.(e),C=null,H(`closed`),!M&&(n.isRejectionCloseCode(e.code)?Y(`rejected`):n.shouldRetryClose(e.code,e.wasClean)?X():N||Y(`rejected`))}}function fe(e){return C?.readyState===WebSocket.OPEN?(C.send(e),!0):!h||M?!1:(P.length>=g&&P.shift(),P.push({payload:e,owner:B()}),!0)}function pe(e,t){if(M=!0,w&&=(clearTimeout(w),null),U(),W(),G(),Z(),A=0,P.length=0,I){let e=I;I=null,F=null,e(Error(`websocket_closed`))}C&&=(H(`closing`),$(C,e,t),null),H(`closed`)}function $(e,t,n){e.readyState===WebSocket.CONNECTING?(e.onopen=()=>e.close(t,n),e.onmessage=null,e.onerror=null,e.onclose=null):e.close(t,n)}function me(){w&&=(clearTimeout(w),null),Z(),A=0,M=!1,Q()}function he(e){k=Number.isFinite(e)&&e>0?e:0,C?.readyState===WebSocket.OPEN?K():G()}return Q(),{send:fe,close:pe,reconnect:me,setSilenceTimeout:he,opened:L,get status(){return j}}}exports.createWebSocket=r;
|
|
2
2
|
//# sourceMappingURL=create-web-socket.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"create-web-socket.cjs","names":["backoffDelay","decodeFrame","isRejectionCloseCode","shouldRetryClose","require_json_frame","require_resilience","createWebSocket","options","socket","retryTimer","pingTimer","handshakeTimer","silenceTimer","onlineListener","silenceWindow","silenceTimeout","retries","status","closed","everOpened","outbox","settleOpen","failOpen","opened","resolve","reject","isServerPing","data","isServerPingFrame","raw","flushOutbox","ws","setStatus","next","onStatusChange","clearPing","clearHandshake","clearSilence","startPing","pingInterval","pingPayload","armSilence","onSilence","detach","scheduleReconnect","abandonHandshake","lose","clearNetworkWait","reason","onLost","maxRetries","delay","onReconnecting","waitForOnline","waitForNetwork","connect","listener","previous","closeSocket","url","protocols","handshakeTimeout","recovered","onOpen","event","onReconnected","decoded","respondToPing","pongPayload","onMessage","onError","onClose","send","payload","queueWhileClosed","maxQueuedMessages","close","code","reconnect","setSilenceTimeout","ms"],"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\";\nimport { decodeFrame } from \"../utils/json-frame\";\nimport type { SchemaIssue, SchemaLike } from \"../utils/schema-like\";\n\nexport type WebSocketStatus = \"idle\" | \"connecting\" | \"open\" | \"closing\" | \"closed\" | \"error\";\n\nexport interface WebSocketMessage<T> {\n /** Parsed payload — validated when `schema` is set, 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 /**\n * A frame arrived that is not valid JSON, and no `parser` was supplied.\n *\n * Registering this drops the frame instead of delivering it: the previous\n * behaviour handed `onMessage` the raw `string` announced as your message\n * type, so the failure surfaced later, at the first property access, with\n * nothing left pointing at the parse. Leave it out and that behaviour is\n * kept, with a one-time warning in development builds.\n */\n onParseError?: (error: unknown, raw: string) => void;\n /**\n * Schema every decoded frame must satisfy, from zod, valibot, arktype or\n * anything else exposing `~standard` or `.safeParse`.\n *\n * Without it nothing changes: the payload reaches `onMessage` announced as\n * `T` on the strength of the type argument alone, which is a promise about\n * the server that TypeScript cannot keep. With it, a frame that does not\n * match is **not** delivered — the same rule `onParseError` already follows —\n * and `onValidationError` hears why. The value delivered is the schema's\n * output, so coercions and defaults are honoured.\n *\n * When `parser` is also supplied, it decodes first and the schema validates\n * what it returned.\n *\n * A server ping is still answered when the schema drops it: the heartbeat is\n * the transport's contract with the server, not the app's with its payload,\n * and a socket that stops sending `pong` is closed with `4408` once per\n * timeout. The validation itself must be synchronous — a frame is decoded\n * inside the `message` handler and delivered from it, so an async schema\n * would deliver frames in whatever order their validations settled; that\n * case is reported through `onValidationError` instead of awaited.\n */\n schema?: SchemaLike<T>;\n /**\n * A frame was decoded but the `schema` refused it, so it was dropped.\n *\n * The one signal that does not depend on how the app's bundler resolves\n * `process`: the one-time development warning behind `onParseError` needs\n * `isDevBuild()` to be able to answer, and this callback is the app's own.\n */\n onValidationError?: (issues: SchemaIssue[], raw: string) => void;\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\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,\n onOpen,\n onMessage,\n onClose,\n onError,\n onParseError,\n schema,\n onValidationError,\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 /**\n * Whether an undelivered frame was a server ping, read from its raw text.\n *\n * The delivered path tests the decoded payload, which is what `parser` was\n * given the frame to produce. A frame `schema` refused never reaches that\n * path, and a heartbeat the app's schema does not describe is the normal\n * case rather than an exotic one — so the reply is decided from the wire\n * text instead, since the server closes with `4408` when no `pong` arrives.\n *\n * The substring test in front keeps the ordinary frame at one scan: only a\n * frame that mentions `\"ping\"` at all is worth parsing a second time.\n *\n * @param raw - The frame body as text.\n * @returns Whether a `pong` is owed.\n */\n function isServerPingFrame(raw: string): boolean {\n if (!raw.includes('\"ping\"')) return false;\n try {\n return isServerPing(JSON.parse(raw));\n } catch {\n return false;\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 decoded = decodeFrame<T>(raw, \"createWebSocket\", {\n parser,\n onParseError,\n schema,\n onValidationError,\n });\n if (!decoded.delivered) {\n if (respondToPing && ws.readyState === WebSocket.OPEN && isServerPingFrame(raw)) {\n ws.send(pongPayload);\n }\n return;\n }\n const data = decoded.data;\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
|
+
{"version":3,"file":"create-web-socket.cjs","names":["ownerFingerprint","backoffDelay","decodeFrame","isRejectionCloseCode","shouldRetryClose","require_owner_fingerprint","require_json_frame","require_resilience","createWebSocket","options","socket","retryTimer","pingTimer","handshakeTimer","silenceTimer","onlineListener","silenceWindow","silenceTimeout","retries","status","closed","everOpened","outbox","settleOpen","failOpen","opened","resolve","reject","isServerPing","data","isServerPingFrame","raw","currentOwner","getPrincipal","flushOutbox","owner","ws","queued","setStatus","next","onStatusChange","clearPing","clearHandshake","clearSilence","startPing","pingInterval","pingPayload","armSilence","onSilence","detach","scheduleReconnect","abandonHandshake","lose","clearNetworkWait","reason","onLost","maxRetries","delay","onReconnecting","waitForOnline","waitForNetwork","connect","listener","previous","closeSocket","url","protocols","handshakeTimeout","recovered","onOpen","event","onReconnected","decoded","respondToPing","pongPayload","onMessage","onError","onClose","send","payload","queueWhileClosed","maxQueuedMessages","close","code","reconnect","setSilenceTimeout","ms"],"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\";\nimport { decodeFrame } from \"../utils/json-frame\";\nimport { ownerFingerprint } from \"../utils/owner-fingerprint\";\nimport type { SchemaIssue, SchemaLike } from \"../utils/schema-like\";\n\nexport type WebSocketStatus = \"idle\" | \"connecting\" | \"open\" | \"closing\" | \"closed\" | \"error\";\n\nexport interface WebSocketMessage<T> {\n /** Parsed payload — validated when `schema` is set, 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 /**\n * Who is signed in right now — a stable, non-secret id such as the user id;\n * `null`/`undefined` when nobody is. Read when a payload is buffered by\n * `queueWhileClosed` and again when the buffer drains on the next open: a\n * payload goes out only while the same principal is signed in, and is\n * dropped otherwise.\n *\n * The URL and the protocols are fixed for the controller's life, so a socket\n * that authenticates by cookie re-handshakes as whoever is signed in at\n * reconnect time. Without this, a frame buffered by one account during the\n * backoff was sent on the reconnect under the next account's cookie when the\n * app did not `close()` the socket on logout (#531).\n *\n * Read synchronously. A non-string, non-number value (the `Promise` of an\n * `async` getter) or a getter that throws reads as nobody. Default: no\n * owner check.\n */\n getPrincipal?: () => string | number | null | undefined;\n /** Parse incoming frames. Default: JSON with raw-string fallback. */\n parser?: (raw: string) => T;\n /**\n * A frame arrived that is not valid JSON, and no `parser` was supplied.\n *\n * Registering this drops the frame instead of delivering it: the previous\n * behaviour handed `onMessage` the raw `string` announced as your message\n * type, so the failure surfaced later, at the first property access, with\n * nothing left pointing at the parse. Leave it out and that behaviour is\n * kept, with a one-time warning in development builds.\n */\n onParseError?: (error: unknown, raw: string) => void;\n /**\n * Schema every decoded frame must satisfy, from zod, valibot, arktype or\n * anything else exposing `~standard` or `.safeParse`.\n *\n * Without it nothing changes: the payload reaches `onMessage` announced as\n * `T` on the strength of the type argument alone, which is a promise about\n * the server that TypeScript cannot keep. With it, a frame that does not\n * match is **not** delivered — the same rule `onParseError` already follows —\n * and `onValidationError` hears why. The value delivered is the schema's\n * output, so coercions and defaults are honoured.\n *\n * When `parser` is also supplied, it decodes first and the schema validates\n * what it returned.\n *\n * A server ping is still answered when the schema drops it: the heartbeat is\n * the transport's contract with the server, not the app's with its payload,\n * and a socket that stops sending `pong` is closed with `4408` once per\n * timeout. The validation itself must be synchronous — a frame is decoded\n * inside the `message` handler and delivered from it, so an async schema\n * would deliver frames in whatever order their validations settled; that\n * case is reported through `onValidationError` instead of awaited.\n */\n schema?: SchemaLike<T>;\n /**\n * A frame was decoded but the `schema` refused it, so it was dropped.\n *\n * The one signal that does not depend on how the app's bundler resolves\n * `process`: the one-time development warning behind `onParseError` needs\n * `isDevBuild()` to be able to answer, and this callback is the app's own.\n */\n onValidationError?: (issues: SchemaIssue[], raw: string) => void;\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\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,\n onOpen,\n onMessage,\n onClose,\n onError,\n onParseError,\n schema,\n onValidationError,\n onStatusChange,\n onReconnecting,\n onReconnected,\n onLost,\n } = options;\n\n const { getPrincipal } = options;\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<{ payload: string | Blob | BufferSource; owner: string | null }> = [];\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 /**\n * Whether an undelivered frame was a server ping, read from its raw text.\n *\n * The delivered path tests the decoded payload, which is what `parser` was\n * given the frame to produce. A frame `schema` refused never reaches that\n * path, and a heartbeat the app's schema does not describe is the normal\n * case rather than an exotic one — so the reply is decided from the wire\n * text instead, since the server closes with `4408` when no `pong` arrives.\n *\n * The substring test in front keeps the ordinary frame at one scan: only a\n * frame that mentions `\"ping\"` at all is worth parsing a second time.\n *\n * @param raw - The frame body as text.\n * @returns Whether a `pong` is owed.\n */\n function isServerPingFrame(raw: string): boolean {\n if (!raw.includes('\"ping\"')) return false;\n try {\n return isServerPing(JSON.parse(raw));\n } catch {\n return false;\n }\n }\n\n /**\n * Fingerprint whoever `getPrincipal` says is signed in.\n *\n * @returns The fingerprint, or `null` without `getPrincipal`, for nobody, for\n * a non-identity value or for a getter that throws.\n */\n function currentOwner(): string | null {\n if (!getPrincipal) return null;\n try {\n return ownerFingerprint(\"p\", getPrincipal());\n } catch {\n return null;\n }\n }\n\n /**\n * Send everything buffered while the socket was down, oldest first.\n *\n * With `getPrincipal`, a payload buffered under another principal — or by a\n * signed-in user when nobody is signed in now — is dropped instead of sent:\n * the new handshake carries the current session, not the one that queued it.\n */\n function flushOutbox(ws: WebSocket): void {\n const owner = currentOwner();\n while (outbox.length > 0 && ws.readyState === WebSocket.OPEN) {\n const queued = outbox.shift()!;\n if (getPrincipal && queued.owner !== owner) continue;\n ws.send(queued.payload);\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 decoded = decodeFrame<T>(raw, \"createWebSocket\", {\n parser,\n onParseError,\n schema,\n onValidationError,\n });\n if (!decoded.delivered) {\n if (respondToPing && ws.readyState === WebSocket.OPEN && isServerPingFrame(raw)) {\n ws.send(pongPayload);\n }\n return;\n }\n const data = decoded.data;\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, owner: currentOwner() });\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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"}
|