tempest-react-sdk 0.75.0 → 0.77.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.
Files changed (147) hide show
  1. package/README.md +4 -2
  2. package/bin/create-tempest-app.e2e.test.mjs +112 -0
  3. package/bin/create-tempest-app.mjs +69 -28
  4. package/bin/lib/component/generate.mjs +191 -0
  5. package/bin/lib/component/generate.test.mjs +336 -0
  6. package/bin/lib/openapi/escape.mjs +210 -0
  7. package/bin/lib/openapi/generate.hostile.test.mjs +592 -0
  8. package/bin/lib/openapi/generate.mjs +93 -17
  9. package/bin/lib/openapi/schema-to-zod.mjs +41 -4
  10. package/bin/tempest.mjs +184 -3
  11. package/dist/auth/create-tempest-auth.cjs +1 -1
  12. package/dist/auth/create-tempest-auth.cjs.map +1 -1
  13. package/dist/auth/create-tempest-auth.js +127 -55
  14. package/dist/auth/create-tempest-auth.js.map +1 -1
  15. package/dist/auth/refresh-queue.cjs +1 -1
  16. package/dist/auth/refresh-queue.cjs.map +1 -1
  17. package/dist/auth/refresh-queue.js +13 -6
  18. package/dist/auth/refresh-queue.js.map +1 -1
  19. package/dist/br/pix.cjs +1 -1
  20. package/dist/br/pix.cjs.map +1 -1
  21. package/dist/br/pix.js +72 -62
  22. package/dist/br/pix.js.map +1 -1
  23. package/dist/br.d.ts +12 -4
  24. package/dist/components/Chat/ChatAttachments.cjs +1 -1
  25. package/dist/components/Chat/ChatAttachments.cjs.map +1 -1
  26. package/dist/components/Chat/ChatAttachments.js +46 -38
  27. package/dist/components/Chat/ChatAttachments.js.map +1 -1
  28. package/dist/components/Markdown/markdown-parse.cjs +7 -5
  29. package/dist/components/Markdown/markdown-parse.cjs.map +1 -1
  30. package/dist/components/Markdown/markdown-parse.js +151 -87
  31. package/dist/components/Markdown/markdown-parse.js.map +1 -1
  32. package/dist/components/Toast/Toast.module.cjs +1 -1
  33. package/dist/components/Toast/Toast.module.cjs.map +1 -1
  34. package/dist/components/Toast/Toast.module.js +7 -3
  35. package/dist/components/Toast/Toast.module.js.map +1 -1
  36. package/dist/components/Toast/ToastProvider.cjs +1 -1
  37. package/dist/components/Toast/ToastProvider.cjs.map +1 -1
  38. package/dist/components/Toast/ToastProvider.js +77 -51
  39. package/dist/components/Toast/ToastProvider.js.map +1 -1
  40. package/dist/data/create-data-provider.cjs +1 -1
  41. package/dist/data/create-data-provider.cjs.map +1 -1
  42. package/dist/data/create-data-provider.js +32 -21
  43. package/dist/data/create-data-provider.js.map +1 -1
  44. package/dist/http/api-client.cjs +1 -1
  45. package/dist/http/api-client.cjs.map +1 -1
  46. package/dist/http/api-client.js +81 -73
  47. package/dist/http/api-client.js.map +1 -1
  48. package/dist/http/errors.cjs +1 -1
  49. package/dist/http/errors.cjs.map +1 -1
  50. package/dist/http/errors.js +39 -35
  51. package/dist/http/errors.js.map +1 -1
  52. package/dist/neural.cjs +2 -0
  53. package/dist/neural.cjs.map +1 -0
  54. package/dist/neural.d.ts +110 -0
  55. package/dist/neural.js +48 -0
  56. package/dist/neural.js.map +1 -0
  57. package/dist/oauth/callback.cjs +2 -0
  58. package/dist/oauth/callback.cjs.map +1 -0
  59. package/dist/oauth/callback.js +42 -0
  60. package/dist/oauth/callback.js.map +1 -0
  61. package/dist/oauth/use-oauth-callback.cjs +1 -1
  62. package/dist/oauth/use-oauth-callback.cjs.map +1 -1
  63. package/dist/oauth/use-oauth-callback.js +13 -20
  64. package/dist/oauth/use-oauth-callback.js.map +1 -1
  65. package/dist/push/use-push-subscription.cjs +1 -1
  66. package/dist/push/use-push-subscription.cjs.map +1 -1
  67. package/dist/push/use-push-subscription.js +42 -42
  68. package/dist/push/use-push-subscription.js.map +1 -1
  69. package/dist/push/web-push-client.cjs +1 -1
  70. package/dist/push/web-push-client.cjs.map +1 -1
  71. package/dist/push/web-push-client.js +23 -3
  72. package/dist/push/web-push-client.js.map +1 -1
  73. package/dist/query/offline-persistence.cjs +1 -1
  74. package/dist/query/offline-persistence.cjs.map +1 -1
  75. package/dist/query/offline-persistence.js +68 -23
  76. package/dist/query/offline-persistence.js.map +1 -1
  77. package/dist/styles/component/Toast.css +6 -1
  78. package/dist/styles/feedback.css +6 -1
  79. package/dist/styles.css +1 -1
  80. package/dist/sw/background-sync.cjs +1 -1
  81. package/dist/sw/background-sync.cjs.map +1 -1
  82. package/dist/sw/background-sync.js +271 -60
  83. package/dist/sw/background-sync.js.map +1 -1
  84. package/dist/sw/cache-inspect.cjs.map +1 -1
  85. package/dist/sw/cache-inspect.js.map +1 -1
  86. package/dist/sw/cache.cjs +1 -1
  87. package/dist/sw/cache.cjs.map +1 -1
  88. package/dist/sw/cache.js +91 -64
  89. package/dist/sw/cache.js.map +1 -1
  90. package/dist/sw/credential-headers.cjs +2 -0
  91. package/dist/sw/credential-headers.cjs.map +1 -0
  92. package/dist/sw/credential-headers.js +17 -0
  93. package/dist/sw/credential-headers.js.map +1 -0
  94. package/dist/sw/session-data.cjs +2 -0
  95. package/dist/sw/session-data.cjs.map +1 -0
  96. package/dist/sw/session-data.js +17 -0
  97. package/dist/sw/session-data.js.map +1 -0
  98. package/dist/sw.cjs +1 -1
  99. package/dist/sw.d.ts +271 -23
  100. package/dist/sw.js +7 -5
  101. package/dist/telemetry/app-error-owner.cjs +2 -0
  102. package/dist/telemetry/app-error-owner.cjs.map +1 -0
  103. package/dist/telemetry/app-error-owner.js +18 -0
  104. package/dist/telemetry/app-error-owner.js.map +1 -0
  105. package/dist/telemetry/app-error-queue.cjs +1 -1
  106. package/dist/telemetry/app-error-queue.cjs.map +1 -1
  107. package/dist/telemetry/app-error-queue.js +19 -14
  108. package/dist/telemetry/app-error-queue.js.map +1 -1
  109. package/dist/telemetry/app-error-reporter.cjs +1 -1
  110. package/dist/telemetry/app-error-reporter.cjs.map +1 -1
  111. package/dist/telemetry/app-error-reporter.js +75 -34
  112. package/dist/telemetry/app-error-reporter.js.map +1 -1
  113. package/dist/telemetry/app-error-transport.cjs +1 -1
  114. package/dist/telemetry/app-error-transport.cjs.map +1 -1
  115. package/dist/telemetry/app-error-transport.js +16 -3
  116. package/dist/telemetry/app-error-transport.js.map +1 -1
  117. package/dist/tempest-react-sdk.cjs +1 -1
  118. package/dist/tempest-react-sdk.d.ts +979 -62
  119. package/dist/tempest-react-sdk.js +160 -156
  120. package/dist/utils/csv.cjs +3 -3
  121. package/dist/utils/csv.cjs.map +1 -1
  122. package/dist/utils/csv.js +16 -13
  123. package/dist/utils/csv.js.map +1 -1
  124. package/dist/utils/safe-url.cjs +2 -0
  125. package/dist/utils/safe-url.cjs.map +1 -0
  126. package/dist/utils/safe-url.js +39 -0
  127. package/dist/utils/safe-url.js.map +1 -0
  128. package/dist/vite/tempest-csp.cjs +2 -2
  129. package/dist/vite/tempest-csp.cjs.map +1 -1
  130. package/dist/vite/tempest-csp.js +118 -63
  131. package/dist/vite/tempest-csp.js.map +1 -1
  132. package/dist/vite.d.ts +21 -5
  133. package/dist/webrtc/peer-link.cjs +1 -1
  134. package/dist/webrtc/peer-link.cjs.map +1 -1
  135. package/dist/webrtc/peer-link.js +2 -10
  136. package/dist/webrtc/peer-link.js.map +1 -1
  137. package/dist/webrtc/peer-mesh.cjs +1 -1
  138. package/dist/webrtc/peer-mesh.cjs.map +1 -1
  139. package/dist/webrtc/peer-mesh.js +110 -72
  140. package/dist/webrtc/peer-mesh.js.map +1 -1
  141. package/package.json +11 -1
  142. package/template-pwa/src/pwa.tsx +22 -0
  143. package/template-pwa/src/sw.ts +27 -14
  144. package/dist/components/Markdown/markdown-url.cjs +0 -2
  145. package/dist/components/Markdown/markdown-url.cjs.map +0 -1
  146. package/dist/components/Markdown/markdown-url.js +0 -28
  147. package/dist/components/Markdown/markdown-url.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"tempest-csp.js","names":[],"sources":["../../src/vite/tempest-csp.ts"],"sourcesContent":["import { createHash } from \"node:crypto\";\nimport type { IndexHtmlTransformContext, Plugin, ResolvedConfig } from \"vite\";\n\nimport type { TempestVitePlugin } from \"./tempest-pwa-manifest\";\n\n/** Options for {@link tempestCsp}. */\nexport interface TempestCspOptions {\n /**\n * Endpoints the app calls at runtime, as full URLs. Only the origin\n * (`scheme://host[:port]`) enters `connect-src`: a source expression with a path\n * matches that path alone, so `https://idp/realms/x` would still block\n * `https://idp/realms/x/protocol/openid-connect/token`.\n *\n * Empty entries (`undefined`, `null`, `false`, `\"\"`) are skipped with a build\n * warning instead of leaving a hole in the directive, so passing an env var\n * that is not set is safe. A root-relative path (`/api`) is same-origin and\n * already covered by `'self'`. Anything else that is not an absolute\n * `http(s)`/`ws(s)` URL throws at config time — failing the build, not\n * production. A wildcard host (`https://*.example.com`) is kept as written.\n */\n connect?: ReadonlyArray<string | null | undefined | false>;\n /**\n * Read origins from the same environment the runtime reads. `true` (the\n * default) adds every variable Vite exposes to `import.meta.env` (the\n * `envPrefix` ones, `VITE_` by default) whose value is an absolute\n * `http(s)`/`ws(s)` URL. This is the structural bridge: an origin added to\n * `.env` for a new runtime call lands in the policy with no second list to\n * keep in sync. Pass a list of names to include exactly those — a name that\n * resolves to nothing is reported as a build warning — or `false` to rely on\n * {@link TempestCspOptions.connect} alone.\n */\n env?: boolean | readonly string[];\n /**\n * Also allow the WebSocket twin of every `http(s)` origin (`https://api` →\n * `wss://api`). Measured in Chromium (23/09/2026): `connect-src https://api`\n * blocks `new WebSocket(\"wss://api\")`, while `'self'` does cover a same-origin\n * `ws:` — so an app that derives its socket URL from the API URL breaks\n * without this. Default `true`.\n */\n sockets?: boolean;\n /**\n * Add `'wasm-unsafe-eval'` to `script-src`, which `onnxruntime-web` (the\n * `/vision` and `/tabular` subpaths) needs to compile its WebAssembly. Without\n * it `WebAssembly.compile` throws a `CompileError` naming `script-src`.\n * It grants WebAssembly compilation only, not `eval`. Default `true`.\n */\n wasm?: boolean;\n /**\n * Add `'unsafe-inline'` to `style-src`. React's `style` prop does **not** need\n * it — it writes through the CSSOM, which CSP does not govern (measured in\n * Chromium, 23/09/2026). What needs it is a `<style>` element or a `style=\"\"`\n * attribute, and the SDK's own `applyTheme` injects a `<style>`. Pass `false`\n * when the app never calls `applyTheme`; inline `<style>` blocks in\n * `index.html` are then hashed instead. Always on under `vite dev`, whose CSS\n * injection is a `<style>` element. Default `true`.\n */\n inlineStyles?: boolean;\n /**\n * Extra sources per directive, merged into the defaults (`{ \"img-src\":\n * [\"https://tiles.example.com\"] }`), or `false` to drop a directive.\n * `frame-ancestors`, `report-uri` and `sandbox` are ignored by browsers when\n * delivered by `<meta>`, so they reach {@link TempestCspPolicy.header} only.\n */\n directives?: Readonly<Record<string, readonly string[] | false>>;\n /**\n * Apply the policy under `vite dev` too. Default `true`: the failure this\n * plugin exists for is \"works in dev, blocked in production\", and a policy\n * enforced in dev moves every missing origin to the first page load on the\n * developer's machine. Dev differs from build only in `style-src\n * 'unsafe-inline'` and in allowing every SDK-known origin (see\n * {@link TempestCspPolicy.connect}), because the dev server cannot know which\n * modules the page will load.\n */\n dev?: boolean;\n /** Print the resolved `connect-src` and where each origin came from. Default `true`. */\n log?: boolean;\n /**\n * Receive the resolved policy — for asserting it in a test, or for writing the\n * same policy as an HTTP header (the only channel for `frame-ancestors`).\n */\n onPolicy?: (policy: TempestCspPolicy) => void;\n}\n\n/** One origin in `connect-src` and the input that put it there. */\nexport interface TempestCspSource {\n /** The source expression, e.g. `https://api.example.com`. */\n origin: string;\n /** Where it came from: `env VITE_API_URL`, `connect[0]`, `sockets`, `base` or an SDK module. */\n from: string;\n}\n\n/** The policy {@link tempestCsp} resolved for one `index.html`. */\nexport interface TempestCspPolicy {\n /** Every directive and its sources, including the header-only ones. */\n directives: Record<string, string[]>;\n /** The policy as written into the `<meta http-equiv>` tag. */\n meta: string;\n /** The full policy for a `Content-Security-Policy` response header. */\n header: string;\n /** Every origin added to `connect-src`, with its provenance. */\n connect: TempestCspSource[];\n /** `\"serve\"` under `vite dev`, `\"build\"` under `vite build`. */\n command: \"serve\" | \"build\";\n}\n\n/**\n * Directives a browser ignores when the policy arrives by `<meta>` (CSP3 §3.3),\n * printing a console warning for each.\n */\nconst HEADER_ONLY_DIRECTIVES: ReadonlySet<string> = new Set([\n \"frame-ancestors\",\n \"report-uri\",\n \"sandbox\",\n]);\n\n/**\n * Origins the SDK itself calls, keyed by the module that calls them. In a build\n * the origin enters only when the module is in the bundle; in dev always.\n */\nconst SDK_ORIGINS: ReadonlyArray<{ module: RegExp; origin: string; from: string }> = [\n {\n module: /[\\\\/]forms[\\\\/]use-viacep\\.(?:[cm]?js|tsx?)$/,\n origin: \"https://viacep.com.br\",\n from: \"tempest-react-sdk useViaCEP\",\n },\n];\n\nconst ABSOLUTE_URL = /^(?:https?|wss?):\\/\\//i;\nconst WILDCARD_SOURCE = /^((?:https?|wss?):\\/\\/\\*\\.[a-z0-9.-]+(?::\\d+)?)(?:[/?#].*)?$/i;\n\n/**\n * Reduce a URL to the origin a CSP source expression should carry.\n *\n * @param value - The URL as the app configured it.\n * @param from - Its provenance, quoted in the error.\n * @returns The origin, or `undefined` for a root-relative path (covered by `'self'`).\n * @throws {Error} When the value is neither an absolute `http(s)`/`ws(s)` URL nor a path.\n */\nexport function cspOrigin(value: string, from: string): string | undefined {\n const trimmed = value.trim();\n if (trimmed.startsWith(\"/\") && !trimmed.startsWith(\"//\")) return undefined;\n const wildcard = WILDCARD_SOURCE.exec(trimmed);\n if (wildcard) return wildcard[1]!.toLowerCase();\n if (ABSOLUTE_URL.test(trimmed)) {\n try {\n const url = new URL(trimmed);\n return `${url.protocol}//${url.host}`;\n } catch {\n throw new Error(`tempestCsp: ${from} is not a valid URL: \"${value}\"`);\n }\n }\n throw new Error(\n `tempestCsp: ${from} must be an absolute http(s)/ws(s) URL or a root-relative path, got \"${value}\"`,\n );\n}\n\n/**\n * Parse a serialized policy into directives, lowercasing names. A repeated\n * directive keeps its first occurrence, as browsers do.\n *\n * @param policy - A `Content-Security-Policy` value.\n * @returns Directive name → sources.\n */\nexport function parseCsp(policy: string): Record<string, string[]> {\n const out: Record<string, string[]> = {};\n for (const part of policy.split(\";\")) {\n const [name, ...sources] = part.trim().split(/\\s+/);\n if (!name) continue;\n const key = name.toLowerCase();\n if (!(key in out)) out[key] = sources;\n }\n return out;\n}\n\n/**\n * Serialize directives into a policy string, dropping header-only directives\n * when the target is a `<meta>` tag.\n *\n * @param directives - Directive name → sources.\n * @param target - `\"meta\"` or `\"header\"`.\n * @returns The policy value.\n */\nexport function serializeCsp(\n directives: Readonly<Record<string, readonly string[]>>,\n target: \"meta\" | \"header\",\n): string {\n return Object.entries(directives)\n .filter(([name]) => target === \"header\" || !HEADER_ONLY_DIRECTIVES.has(name))\n .map(([name, sources]) => [name, ...sources].join(\" \"))\n .join(\"; \");\n}\n\nfunction union(base: readonly string[], extra: readonly string[]): string[] {\n const out = base.filter((source) => source !== \"'none'\" || extra.length === 0);\n for (const source of extra) if (!out.includes(source)) out.push(source);\n return out.length === 0 ? [\"'none'\"] : out;\n}\n\nfunction sha256(content: string): string {\n return `'sha256-${createHash(\"sha256\").update(content, \"utf8\").digest(\"base64\")}'`;\n}\n\nfunction decodeEntities(value: string): string {\n return value\n .replace(/&#39;|&#x27;|&apos;/gi, \"'\")\n .replace(/&quot;/gi, '\"')\n .replace(/&amp;/gi, \"&\");\n}\n\nfunction readAttributes(tag: string): Record<string, string> {\n const out: Record<string, string> = {};\n const pattern = /([^\\s=<>/]+)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s>]+))/g;\n for (const match of tag.matchAll(pattern)) {\n out[match[1]!.toLowerCase()] = decodeEntities(match[2] ?? match[3] ?? match[4] ?? \"\");\n }\n return out;\n}\n\n/**\n * Find the policy `index.html` already declares and remove its tag, so the merged\n * policy replaces it instead of stacking a second one (two policies are\n * intersected by the browser, so the stricter would win silently).\n *\n * @throws {Error} When the page declares more than one CSP `<meta>`: merging an\n * intersection into a union would weaken it, so the plugin refuses.\n */\nfunction extractMetaPolicy(html: string): { html: string; declared?: Record<string, string[]> } {\n const tags = [...html.matchAll(/<meta\\b[^>]*>/gi)].filter(\n (match) =>\n readAttributes(match[0])[\"http-equiv\"]?.toLowerCase() === \"content-security-policy\",\n );\n if (tags.length === 0) return { html };\n if (tags.length > 1) {\n throw new Error(\n \"tempestCsp: index.html declares more than one Content-Security-Policy <meta>; merge them into one\",\n );\n }\n const tag = tags[0]!;\n return {\n html:\n html.slice(0, tag.index) +\n html.slice(tag.index! + tag[0].length).replace(/^[ \\t]*\\r?\\n/, \"\"),\n declared: parseCsp(readAttributes(tag[0]).content ?? \"\"),\n };\n}\n\nfunction inlineHashes(html: string, element: \"script\" | \"style\"): string[] {\n const pattern = new RegExp(`<${element}\\\\b([^>]*)>([\\\\s\\\\S]*?)</${element}>`, \"gi\");\n const hashes: string[] = [];\n for (const match of html.matchAll(pattern)) {\n const attributes = readAttributes(`<x ${match[1]}>`);\n if (element === \"script\" && \"src\" in attributes) continue;\n if (match[2]!.length === 0) continue;\n const hash = sha256(match[2]!);\n if (!hashes.includes(hash)) hashes.push(hash);\n }\n return hashes;\n}\n\n/**\n * Write the policy tag where it governs the whole page. A `<meta>` CSP applies\n * only to what follows it, so it goes right after `<meta charset>` — unless a\n * resource precedes the charset, as the React Refresh preamble does under\n * `vite dev` (`head-prepend`), in which case it goes right after `<head>`.\n */\nfunction insertMeta(html: string, policy: string): string {\n const escaped = policy.replace(/&/g, \"&amp;\").replace(/\"/g, \"&quot;\");\n const tag = `<meta http-equiv=\"Content-Security-Policy\" content=\"${escaped}\" />`;\n const charset = /<meta\\b[^>]*\\bcharset\\s*=[^>]*>/i.exec(html);\n const head = /<head\\b[^>]*>/i.exec(html);\n const resourceFirst =\n charset !== null &&\n head !== null &&\n /<(?:script|link|style)\\b/i.test(html.slice(head.index, charset.index));\n const anchor = charset && head && charset.index > head.index && !resourceFirst ? charset : head;\n if (!anchor) return `${tag}\\n${html}`;\n const at = anchor.index + anchor[0].length;\n return `${html.slice(0, at)}\\n ${tag}${html.slice(at)}`;\n}\n\nfunction bundleModuleIds(ctx: IndexHtmlTransformContext): string[] {\n const ids: string[] = [];\n for (const output of Object.values(ctx.bundle ?? {})) {\n if (output.type !== \"chunk\") continue;\n ids.push(...(output.moduleIds ?? Object.keys(output.modules ?? {})));\n }\n return ids;\n}\n\n/**\n * Vite plugin that writes a Content-Security-Policy `<meta>` into `index.html`\n * and derives `connect-src` from the origins the app actually calls.\n *\n * It exists because of how a missing origin fails: the request is blocked\n * before a connection opens, so there is no entry in the Network tab, nothing in\n * the server log, `curl` succeeds, and the app sees `TypeError: Failed to\n * fetch` — indistinguishable from offline, DNS or CORS. And a hand-written CSP\n * plugin is usually `apply: \"build\"`, so it only fails in production. This\n * plugin closes both ends:\n *\n * - origins come from the same `import.meta.env` the runtime reads\n * ({@link TempestCspOptions.env}), reduced to `scheme://host[:port]`, with\n * empty values skipped — no second list to drift;\n * - the policy is enforced under `vite dev` as well ({@link TempestCspOptions.dev});\n * - the build prints every origin and its provenance.\n *\n * Defaults cover what the SDK's stack needs and nothing more: `'self'`\n * everywhere, `'wasm-unsafe-eval'` for ONNX Runtime, `blob:` for workers, object\n * URLs and media, `data:` for images and fonts, `object-src 'none'`,\n * `base-uri 'self'`, `form-action 'self'`. Inline `<script>` blocks left in\n * `index.html` (a `themeInitScript()` anti-flash script, say) are allowed by\n * SHA-256 hash, never by `'unsafe-inline'`.\n *\n * An existing CSP `<meta>` in `index.html` is merged, not overwritten: every\n * directive it declares is kept verbatim — the plugin never loosens a choice the\n * app wrote — except `connect-src`, which gains the resolved origins, since\n * that is the list the plugin exists to maintain. Directives it does not declare\n * come from the defaults.\n *\n * @param options - See {@link TempestCspOptions}.\n * @returns A Vite plugin, to be placed in `plugins`. It runs as a `post` HTML\n * transform, so a plugin that rewrites inline scripts after it would\n * invalidate the hashes.\n *\n * @example\n * // vite.config.ts\n * import { createViteConfig, tempestCsp } from \"tempest-react-sdk/vite\";\n *\n * export default createViteConfig({\n * plugins: [tempestCsp({ connect: [\"https://sso.example.com/realms/app\"] })],\n * });\n */\nexport function tempestCsp(options: TempestCspOptions = {}): TempestVitePlugin {\n const {\n connect = [],\n env = true,\n sockets = true,\n wasm = true,\n inlineStyles = true,\n directives: extra = {},\n dev = true,\n log = true,\n onPolicy,\n } = options;\n\n let config: ResolvedConfig | undefined;\n let resolved: TempestCspSource[] = [];\n let lastLogged = \"\";\n\n function add(list: TempestCspSource[], origin: string | undefined, from: string): void {\n if (origin === undefined || list.some((source) => source.origin === origin)) return;\n list.push({ origin, from });\n }\n\n function resolveSources(resolvedConfig: ResolvedConfig): TempestCspSource[] {\n const list: TempestCspSource[] = [];\n const values: Record<string, unknown> = resolvedConfig.env ?? {};\n const prefixes = [resolvedConfig.envPrefix ?? \"VITE_\"].flat();\n if (env === true) {\n for (const [name, value] of Object.entries(values)) {\n if (!prefixes.some((prefix) => name.startsWith(prefix))) continue;\n if (typeof value !== \"string\" || !ABSOLUTE_URL.test(value.trim())) continue;\n add(list, cspOrigin(value, `env ${name}`), `env ${name}`);\n }\n } else if (Array.isArray(env)) {\n for (const name of env) {\n const value = values[name];\n if (typeof value !== \"string\" || value.trim() === \"\") {\n resolvedConfig.logger.warn(\n `tempestCsp: env ${name} is empty — nothing added to connect-src`,\n );\n continue;\n }\n add(list, cspOrigin(value, `env ${name}`), `env ${name}`);\n }\n }\n connect.forEach((value, index) => {\n if (!value || value.trim() === \"\") {\n resolvedConfig.logger.warn(\n `tempestCsp: connect[${index}] is empty — skipped, nothing added to connect-src`,\n );\n return;\n }\n add(list, cspOrigin(value, `connect[${index}]`), `connect[${index}]`);\n });\n return list;\n }\n\n function withSockets(list: readonly TempestCspSource[]): TempestCspSource[] {\n const out = [...list];\n if (!sockets) return out;\n for (const { origin } of list) {\n const twin = origin.replace(/^http(s?):/i, \"ws$1:\");\n if (twin !== origin) add(out, twin, `sockets (${origin})`);\n }\n return out;\n }\n\n function defaults(baseOrigin: string | undefined, serve: boolean): Record<string, string[]> {\n const self = baseOrigin ? [\"'self'\", baseOrigin] : [\"'self'\"];\n const policy: Record<string, string[]> = {\n \"default-src\": [...self],\n \"script-src\": wasm ? [...self, \"'wasm-unsafe-eval'\"] : [...self],\n \"style-src\": inlineStyles || serve ? [...self, \"'unsafe-inline'\"] : [...self],\n \"img-src\": [...self, \"data:\", \"blob:\"],\n \"font-src\": [...self, \"data:\"],\n \"media-src\": [...self, \"blob:\"],\n \"connect-src\": [\"'self'\", \"blob:\", \"data:\"],\n \"worker-src\": [...self, \"blob:\"],\n \"manifest-src\": [...self],\n \"object-src\": [\"'none'\"],\n \"base-uri\": [\"'self'\"],\n \"form-action\": [\"'self'\"],\n };\n for (const [name, sources] of Object.entries(extra)) {\n const key = name.toLowerCase();\n if (sources === false) delete policy[key];\n else policy[key] = union(policy[key] ?? [], sources);\n }\n return policy;\n }\n\n const plugin: Plugin = {\n name: \"tempest-csp\",\n configResolved(resolvedConfig) {\n config = resolvedConfig;\n resolved = resolveSources(resolvedConfig);\n },\n transformIndexHtml: {\n order: \"post\",\n handler(source, ctx) {\n const serve = config?.command === \"serve\";\n if (serve && !dev) return source;\n\n const connectSources = withSockets(resolved);\n const base = config?.base ?? \"/\";\n const baseOrigin = ABSOLUTE_URL.test(base) ? cspOrigin(base, \"base\") : undefined;\n if (baseOrigin) add(connectSources, baseOrigin, \"base\");\n const moduleIds = serve ? [] : bundleModuleIds(ctx);\n for (const known of SDK_ORIGINS) {\n if (serve || moduleIds.some((id) => known.module.test(id))) {\n add(connectSources, known.origin, known.from);\n }\n }\n const allSources = connectSources;\n\n const { html, declared = {} } = extractMetaPolicy(source);\n const policy = defaults(baseOrigin, serve);\n if (!(\"script-src\" in declared)) {\n policy[\"script-src\"] = union(\n policy[\"script-src\"] ?? [],\n inlineHashes(html, \"script\"),\n );\n }\n if (\n !(\"style-src\" in declared) &&\n !policy[\"style-src\"]?.includes(\"'unsafe-inline'\")\n ) {\n policy[\"style-src\"] = union(\n policy[\"style-src\"] ?? [],\n inlineHashes(html, \"style\"),\n );\n }\n const merged: Record<string, string[]> = { ...policy };\n for (const [name, sources] of Object.entries(declared)) merged[name] = sources;\n merged[\"connect-src\"] = union(\n merged[\"connect-src\"] ?? [\"'self'\"],\n allSources.map((entry) => entry.origin),\n );\n\n const result: TempestCspPolicy = {\n directives: merged,\n meta: serializeCsp(merged, \"meta\"),\n header: serializeCsp(merged, \"header\"),\n connect: allSources,\n command: serve ? \"serve\" : \"build\",\n };\n\n if (log && config && result.header !== lastLogged) {\n lastLogged = result.header;\n const lines = allSources.map((entry) => ` ${entry.origin} ← ${entry.from}`);\n config.logger.info(\n [\n `tempest-csp: connect-src ${merged[\"connect-src\"].join(\" \")}`,\n ...(lines.length > 0\n ? lines\n : [\" (no cross-origin endpoint: only 'self')\"]),\n ].join(\"\\n\"),\n );\n }\n onPolicy?.(result);\n return insertMeta(html, result.meta);\n },\n },\n };\n\n return plugin as TempestVitePlugin;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"tempest-csp.js","names":[],"sources":["../../src/vite/tempest-csp.ts"],"sourcesContent":["import { createHash } from \"node:crypto\";\nimport type { IndexHtmlTransformContext, Plugin, ResolvedConfig } from \"vite\";\n\nimport type { TempestVitePlugin } from \"./tempest-pwa-manifest\";\n\n/** Options for {@link tempestCsp}. */\nexport interface TempestCspOptions {\n /**\n * Endpoints the app calls at runtime, as full URLs. Only the origin\n * (`scheme://host[:port]`) enters `connect-src`: a source expression with a path\n * matches that path alone, so `https://idp/realms/x` would still block\n * `https://idp/realms/x/protocol/openid-connect/token`.\n *\n * Empty entries (`undefined`, `null`, `false`, `\"\"`) are skipped with a build\n * warning instead of leaving a hole in the directive, so passing an env var\n * that is not set is safe. A root-relative path (`/api`) is same-origin and\n * already covered by `'self'`. Anything else that is not an absolute\n * `http(s)`/`ws(s)` URL throws at config time — failing the build, not\n * production. A wildcard host (`https://*.example.com`) is kept as written.\n */\n connect?: ReadonlyArray<string | null | undefined | false>;\n /**\n * Read origins from the same environment the runtime reads. `true` (the\n * default) adds every variable Vite exposes to `import.meta.env` (the\n * `envPrefix` ones, `VITE_` by default) whose value is an absolute\n * `http(s)`/`ws(s)` URL. This is the structural bridge: an origin added to\n * `.env` for a new runtime call lands in the policy with no second list to\n * keep in sync. Pass a list of names to include exactly those — a name that\n * resolves to nothing is reported as a build warning — or `false` to rely on\n * {@link TempestCspOptions.connect} alone.\n */\n env?: boolean | readonly string[];\n /**\n * Also allow the WebSocket twin of every `http(s)` origin (`https://api` →\n * `wss://api`). Measured in Chromium (23/09/2026): `connect-src https://api`\n * blocks `new WebSocket(\"wss://api\")`, while `'self'` does cover a same-origin\n * `ws:` — so an app that derives its socket URL from the API URL breaks\n * without this. Default `true`.\n */\n sockets?: boolean;\n /**\n * Add `'wasm-unsafe-eval'` to `script-src`, which `onnxruntime-web` (the\n * `/vision` and `/tabular` subpaths) needs to compile its WebAssembly. Without\n * it `WebAssembly.compile` throws a `CompileError` naming `script-src`.\n * It grants WebAssembly compilation only, not `eval`. Default `true`.\n */\n wasm?: boolean;\n /**\n * Add `'unsafe-inline'` to `style-src`. React's `style` prop does **not** need\n * it — it writes through the CSSOM, which CSP does not govern (measured in\n * Chromium, 23/09/2026). What needs it is a `<style>` element or a `style=\"\"`\n * attribute, and the SDK's own `applyTheme` injects a `<style>`. Pass `false`\n * when the app never calls `applyTheme`; inline `<style>` blocks in\n * `index.html` are then hashed instead. Always on under `vite dev`, whose CSS\n * injection is a `<style>` element. Default `true`.\n */\n inlineStyles?: boolean;\n /**\n * Extra sources per directive, merged into the defaults (`{ \"img-src\":\n * [\"https://tiles.example.com\"] }`), or `false` to drop a directive.\n * `frame-ancestors`, `report-uri` and `sandbox` are ignored by browsers when\n * delivered by `<meta>`, so they reach {@link TempestCspPolicy.header} only.\n */\n directives?: Readonly<Record<string, readonly string[] | false>>;\n /**\n * Apply the policy under `vite dev` too. Default `true`: the failure this\n * plugin exists for is \"works in dev, blocked in production\", and a policy\n * enforced in dev moves every missing origin to the first page load on the\n * developer's machine. Dev differs from build only in `style-src\n * 'unsafe-inline'` and in allowing every SDK-known origin (see\n * {@link TempestCspPolicy.connect}), because the dev server cannot know which\n * modules the page will load.\n */\n dev?: boolean;\n /** Print the resolved `connect-src` and where each origin came from. Default `true`. */\n log?: boolean;\n /**\n * Receive the resolved policy — for asserting it in a test, or for writing the\n * same policy as an HTTP header (the only channel for `frame-ancestors`).\n */\n onPolicy?: (policy: TempestCspPolicy) => void;\n}\n\n/** One origin in `connect-src` and the input that put it there. */\nexport interface TempestCspSource {\n /** The source expression, e.g. `https://api.example.com`. */\n origin: string;\n /** Where it came from: `env VITE_API_URL`, `connect[0]`, `sockets`, `base` or an SDK module. */\n from: string;\n}\n\n/** The policy {@link tempestCsp} resolved for one `index.html`. */\nexport interface TempestCspPolicy {\n /** Every directive and its sources, including the header-only ones. */\n directives: Record<string, string[]>;\n /** The policy as written into the `<meta http-equiv>` tag. */\n meta: string;\n /** The full policy for a `Content-Security-Policy` response header. */\n header: string;\n /** Every origin added to `connect-src`, with its provenance. */\n connect: TempestCspSource[];\n /** `\"serve\"` under `vite dev`, `\"build\"` under `vite build`. */\n command: \"serve\" | \"build\";\n}\n\n/**\n * Directives a browser ignores when the policy arrives by `<meta>` (CSP3 §3.3),\n * printing a console warning for each.\n */\nconst HEADER_ONLY_DIRECTIVES: ReadonlySet<string> = new Set([\n \"frame-ancestors\",\n \"report-uri\",\n \"sandbox\",\n]);\n\n/**\n * Origins the SDK itself calls, keyed by the module that calls them. In a build\n * the origin enters only when the module is in the bundle; in dev always.\n */\nconst SDK_ORIGINS: ReadonlyArray<{ module: RegExp; origin: string; from: string }> = [\n {\n module: /[\\\\/]forms[\\\\/]use-viacep\\.(?:[cm]?js|tsx?)$/,\n origin: \"https://viacep.com.br\",\n from: \"tempest-react-sdk useViaCEP\",\n },\n];\n\n/**\n * Where each fetch directive falls back when a policy does not declare it, in\n * order. Ported from CSP3 §6.8.3, \"Get fetch directive fallback list\"\n * (https://w3c.github.io/webappsec-csp/#directive-fallback-list, checked\n * 11/10/2026), which lists twelve effective directives: `script-src-elem`,\n * `script-src-attr`, `style-src-elem`, `style-src-attr`, `worker-src`,\n * `connect-src`, `manifest-src`, `object-src`, `frame-src`, `media-src`,\n * `font-src` and `img-src`. Four more are listed here on purpose:\n *\n * - `script-src`, `style-src` and `child-src` are never an effective directive,\n * but every chain that reaches them continues to `default-src`;\n * - `fenced-frame-src` comes from the Fenced Frame spec, which extends the\n * `frame-src` chain;\n * - `prefetch-src` was in earlier CSP3 drafts (→ `default-src`) and is gone from\n * the current one; a browser that still resolves it does so through\n * `default-src`, so it is treated as inherited.\n *\n * A directive absent from this map does not fall back at all (`base-uri`,\n * `form-action`, `frame-ancestors`, …): leaving it out of a policy allows\n * everything, so adding it only ever restricts.\n */\nconst CSP_FALLBACK: Readonly<Record<string, readonly string[]>> = {\n \"script-src-elem\": [\"script-src\", \"default-src\"],\n \"script-src-attr\": [\"script-src\", \"default-src\"],\n \"style-src-elem\": [\"style-src\", \"default-src\"],\n \"style-src-attr\": [\"style-src\", \"default-src\"],\n \"worker-src\": [\"child-src\", \"script-src\", \"default-src\"],\n \"frame-src\": [\"child-src\", \"default-src\"],\n \"fenced-frame-src\": [\"frame-src\", \"child-src\", \"default-src\"],\n \"child-src\": [\"default-src\"],\n \"connect-src\": [\"default-src\"],\n \"font-src\": [\"default-src\"],\n \"img-src\": [\"default-src\"],\n \"manifest-src\": [\"default-src\"],\n \"media-src\": [\"default-src\"],\n \"object-src\": [\"default-src\"],\n \"prefetch-src\": [\"default-src\"],\n \"script-src\": [\"default-src\"],\n \"style-src\": [\"default-src\"],\n};\n\n/**\n * The sources a declared policy already applies to a directive it does not\n * name, by walking {@link CSP_FALLBACK}.\n *\n * @param declared - The policy the app declared.\n * @param name - A directive the app did not declare.\n * @returns The inherited sources, or `undefined` when nothing governs the directive.\n */\nfunction inheritedSources(\n declared: Readonly<Record<string, string[]>>,\n name: string,\n): string[] | undefined {\n for (const parent of CSP_FALLBACK[name] ?? []) {\n if (parent in declared) return declared[parent];\n }\n return undefined;\n}\n\nconst ABSOLUTE_URL = /^(?:https?|wss?):\\/\\//i;\nconst WILDCARD_SOURCE = /^((?:https?|wss?):\\/\\/\\*\\.[a-z0-9.-]+(?::\\d+)?)(?:[/?#].*)?$/i;\n\n/**\n * Reduce a URL to the origin a CSP source expression should carry.\n *\n * @param value - The URL as the app configured it.\n * @param from - Its provenance, quoted in the error.\n * @returns The origin, or `undefined` for a root-relative path (covered by `'self'`).\n * @throws {Error} When the value is neither an absolute `http(s)`/`ws(s)` URL nor a path.\n */\nexport function cspOrigin(value: string, from: string): string | undefined {\n const trimmed = value.trim();\n if (trimmed.startsWith(\"/\") && !trimmed.startsWith(\"//\")) return undefined;\n const wildcard = WILDCARD_SOURCE.exec(trimmed);\n if (wildcard) return wildcard[1]!.toLowerCase();\n if (ABSOLUTE_URL.test(trimmed)) {\n try {\n const url = new URL(trimmed);\n return `${url.protocol}//${url.host}`;\n } catch {\n throw new Error(`tempestCsp: ${from} is not a valid URL: \"${value}\"`);\n }\n }\n throw new Error(\n `tempestCsp: ${from} must be an absolute http(s)/ws(s) URL or a root-relative path, got \"${value}\"`,\n );\n}\n\n/**\n * Parse a serialized policy into directives, lowercasing names. A repeated\n * directive keeps its first occurrence, as browsers do.\n *\n * @param policy - A `Content-Security-Policy` value.\n * @returns Directive name → sources.\n */\nexport function parseCsp(policy: string): Record<string, string[]> {\n const out: Record<string, string[]> = {};\n for (const part of policy.split(\";\")) {\n const [name, ...sources] = part.trim().split(/\\s+/);\n if (!name) continue;\n const key = name.toLowerCase();\n if (!(key in out)) out[key] = sources;\n }\n return out;\n}\n\n/**\n * Serialize directives into a policy string, dropping header-only directives\n * when the target is a `<meta>` tag.\n *\n * @param directives - Directive name → sources.\n * @param target - `\"meta\"` or `\"header\"`.\n * @returns The policy value.\n */\nexport function serializeCsp(\n directives: Readonly<Record<string, readonly string[]>>,\n target: \"meta\" | \"header\",\n): string {\n return Object.entries(directives)\n .filter(([name]) => target === \"header\" || !HEADER_ONLY_DIRECTIVES.has(name))\n .map(([name, sources]) => [name, ...sources].join(\" \"))\n .join(\"; \");\n}\n\nfunction union(base: readonly string[], extra: readonly string[]): string[] {\n const out = base.filter((source) => source !== \"'none'\" || extra.length === 0);\n for (const source of extra) if (!out.includes(source)) out.push(source);\n return out.length === 0 ? [\"'none'\"] : out;\n}\n\nfunction sha256(content: string): string {\n return `'sha256-${createHash(\"sha256\").update(content, \"utf8\").digest(\"base64\")}'`;\n}\n\nfunction decodeEntities(value: string): string {\n return value\n .replace(/&#39;|&#x27;|&apos;/gi, \"'\")\n .replace(/&quot;/gi, '\"')\n .replace(/&amp;/gi, \"&\");\n}\n\nfunction readAttributes(tag: string): Record<string, string> {\n const out: Record<string, string> = {};\n const pattern = /([^\\s=<>/]+)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s>]+))/g;\n for (const match of tag.matchAll(pattern)) {\n out[match[1]!.toLowerCase()] = decodeEntities(match[2] ?? match[3] ?? match[4] ?? \"\");\n }\n return out;\n}\n\n/**\n * Find the policy `index.html` already declares and remove its tag, so the merged\n * policy replaces it instead of stacking a second one (two policies are\n * intersected by the browser, so the stricter would win silently).\n *\n * @throws {Error} When the page declares more than one CSP `<meta>`: merging an\n * intersection into a union would weaken it, so the plugin refuses.\n */\nfunction extractMetaPolicy(html: string): { html: string; declared?: Record<string, string[]> } {\n const tags = [...html.matchAll(/<meta\\b[^>]*>/gi)].filter(\n (match) =>\n readAttributes(match[0])[\"http-equiv\"]?.toLowerCase() === \"content-security-policy\",\n );\n if (tags.length === 0) return { html };\n if (tags.length > 1) {\n throw new Error(\n \"tempestCsp: index.html declares more than one Content-Security-Policy <meta>; merge them into one\",\n );\n }\n const tag = tags[0]!;\n return {\n html:\n html.slice(0, tag.index) +\n html.slice(tag.index! + tag[0].length).replace(/^[ \\t]*\\r?\\n/, \"\"),\n declared: parseCsp(readAttributes(tag[0]).content ?? \"\"),\n };\n}\n\nfunction inlineHashes(html: string, element: \"script\" | \"style\"): string[] {\n const pattern = new RegExp(`<${element}\\\\b([^>]*)>([\\\\s\\\\S]*?)</${element}>`, \"gi\");\n const hashes: string[] = [];\n for (const match of html.matchAll(pattern)) {\n const attributes = readAttributes(`<x ${match[1]}>`);\n if (element === \"script\" && \"src\" in attributes) continue;\n if (match[2]!.length === 0) continue;\n const hash = sha256(match[2]!);\n if (!hashes.includes(hash)) hashes.push(hash);\n }\n return hashes;\n}\n\n/**\n * Write the policy tag where it governs the whole page. A `<meta>` CSP applies\n * only to what follows it, so it goes right after `<meta charset>` — unless a\n * resource precedes the charset, as the React Refresh preamble does under\n * `vite dev` (`head-prepend`), in which case it goes right after `<head>`.\n */\nfunction insertMeta(html: string, policy: string): string {\n const escaped = policy.replace(/&/g, \"&amp;\").replace(/\"/g, \"&quot;\");\n const tag = `<meta http-equiv=\"Content-Security-Policy\" content=\"${escaped}\" />`;\n const charset = /<meta\\b[^>]*\\bcharset\\s*=[^>]*>/i.exec(html);\n const head = /<head\\b[^>]*>/i.exec(html);\n const resourceFirst =\n charset !== null &&\n head !== null &&\n /<(?:script|link|style)\\b/i.test(html.slice(head.index, charset.index));\n const anchor = charset && head && charset.index > head.index && !resourceFirst ? charset : head;\n if (!anchor) return `${tag}\\n${html}`;\n const at = anchor.index + anchor[0].length;\n return `${html.slice(0, at)}\\n ${tag}${html.slice(at)}`;\n}\n\nfunction bundleModuleIds(ctx: IndexHtmlTransformContext): string[] {\n const ids: string[] = [];\n for (const output of Object.values(ctx.bundle ?? {})) {\n if (output.type !== \"chunk\") continue;\n ids.push(...(output.moduleIds ?? Object.keys(output.modules ?? {})));\n }\n return ids;\n}\n\n/**\n * Vite plugin that writes a Content-Security-Policy `<meta>` into `index.html`\n * and derives `connect-src` from the origins the app actually calls.\n *\n * It exists because of how a missing origin fails: the request is blocked\n * before a connection opens, so there is no entry in the Network tab, nothing in\n * the server log, `curl` succeeds, and the app sees `TypeError: Failed to\n * fetch` — indistinguishable from offline, DNS or CORS. And a hand-written CSP\n * plugin is usually `apply: \"build\"`, so it only fails in production. This\n * plugin closes both ends:\n *\n * - origins come from the same `import.meta.env` the runtime reads\n * ({@link TempestCspOptions.env}), reduced to `scheme://host[:port]`, with\n * empty values skipped — no second list to drift;\n * - the policy is enforced under `vite dev` as well ({@link TempestCspOptions.dev});\n * - the build prints every origin and its provenance.\n *\n * Defaults cover what the SDK's stack needs and nothing more: `'self'`\n * everywhere, `'wasm-unsafe-eval'` for ONNX Runtime, `blob:` for workers, object\n * URLs and media, `data:` for images and fonts, `object-src 'none'`,\n * `base-uri 'self'`, `form-action 'self'`. Inline `<script>` blocks left in\n * `index.html` (a `themeInitScript()` anti-flash script, say) are allowed by\n * SHA-256 hash, never by `'unsafe-inline'`.\n *\n * An existing CSP `<meta>` in `index.html` is merged, not overwritten, and the\n * merge never loosens what the app wrote:\n *\n * - a directive it declares is kept verbatim, except `connect-src`, which gains\n * the resolved origins (the list the plugin exists to maintain) unless it is\n * `'none'` — declared or inherited from `default-src` — in which case the\n * origins stay out and each one is reported as a build warning. Sources in\n * {@link TempestCspOptions.directives} for a declared directive are not added\n * (a build warning names them); `false` there removes it;\n * - a fetch directive it does not declare but already governs through the CSP3\n * fallback chain (§6.8.3 — `img-src` → `default-src`,\n * `worker-src` → `child-src` → `script-src` → `default-src`) is left out, so it\n * keeps falling back. Filling it from the defaults would replace a\n * `default-src 'none'` with `'self' data: blob:`. No inline-script hash and no\n * `'wasm-unsafe-eval'` is added to it, and an inline `<script>` is judged by a\n * declared `script-src-elem` before `script-src`; `connect-src` starts from the inherited\n * value, and so does any directive passed in {@link TempestCspOptions.directives};\n * - a default that is exactly `'none'` (`object-src`), or a directive that does\n * not fall back (`base-uri`, `form-action`), is still added: it can only\n * restrict;\n * - a directive nothing in the declared policy governs comes from the defaults.\n *\n * @param options - See {@link TempestCspOptions}.\n * @returns A Vite plugin, to be placed in `plugins`. It runs as a `post` HTML\n * transform, so a plugin that rewrites inline scripts after it would\n * invalidate the hashes.\n *\n * @example\n * // vite.config.ts\n * import { createViteConfig, tempestCsp } from \"tempest-react-sdk/vite\";\n *\n * export default createViteConfig({\n * plugins: [tempestCsp({ connect: [\"https://sso.example.com/realms/app\"] })],\n * });\n */\nexport function tempestCsp(options: TempestCspOptions = {}): TempestVitePlugin {\n const {\n connect = [],\n env = true,\n sockets = true,\n wasm = true,\n inlineStyles = true,\n directives: extra = {},\n dev = true,\n log = true,\n onPolicy,\n } = options;\n\n const configured: Record<string, readonly string[] | false> = Object.fromEntries(\n Object.entries(extra).map(([name, sources]) => [name.toLowerCase(), sources]),\n );\n let config: ResolvedConfig | undefined;\n let resolved: TempestCspSource[] = [];\n let lastLogged = \"\";\n\n function add(list: TempestCspSource[], origin: string | undefined, from: string): void {\n if (origin === undefined || list.some((source) => source.origin === origin)) return;\n list.push({ origin, from });\n }\n\n function resolveSources(resolvedConfig: ResolvedConfig): TempestCspSource[] {\n const list: TempestCspSource[] = [];\n const values: Record<string, unknown> = resolvedConfig.env ?? {};\n const prefixes = [resolvedConfig.envPrefix ?? \"VITE_\"].flat();\n if (env === true) {\n for (const [name, value] of Object.entries(values)) {\n if (!prefixes.some((prefix) => name.startsWith(prefix))) continue;\n if (typeof value !== \"string\" || !ABSOLUTE_URL.test(value.trim())) continue;\n add(list, cspOrigin(value, `env ${name}`), `env ${name}`);\n }\n } else if (Array.isArray(env)) {\n for (const name of env) {\n const value = values[name];\n if (typeof value !== \"string\" || value.trim() === \"\") {\n resolvedConfig.logger.warn(\n `tempestCsp: env ${name} is empty — nothing added to connect-src`,\n );\n continue;\n }\n add(list, cspOrigin(value, `env ${name}`), `env ${name}`);\n }\n }\n connect.forEach((value, index) => {\n if (!value || value.trim() === \"\") {\n resolvedConfig.logger.warn(\n `tempestCsp: connect[${index}] is empty — skipped, nothing added to connect-src`,\n );\n return;\n }\n add(list, cspOrigin(value, `connect[${index}]`), `connect[${index}]`);\n });\n return list;\n }\n\n function withSockets(list: readonly TempestCspSource[]): TempestCspSource[] {\n const out = [...list];\n if (!sockets) return out;\n for (const { origin } of list) {\n const twin = origin.replace(/^http(s?):/i, \"ws$1:\");\n if (twin !== origin) add(out, twin, `sockets (${origin})`);\n }\n return out;\n }\n\n function defaults(baseOrigin: string | undefined, serve: boolean): Record<string, string[]> {\n const self = baseOrigin ? [\"'self'\", baseOrigin] : [\"'self'\"];\n const policy: Record<string, string[]> = {\n \"default-src\": [...self],\n \"script-src\": wasm ? [...self, \"'wasm-unsafe-eval'\"] : [...self],\n \"style-src\": inlineStyles || serve ? [...self, \"'unsafe-inline'\"] : [...self],\n \"img-src\": [...self, \"data:\", \"blob:\"],\n \"font-src\": [...self, \"data:\"],\n \"media-src\": [...self, \"blob:\"],\n \"connect-src\": [\"'self'\", \"blob:\", \"data:\"],\n \"worker-src\": [...self, \"blob:\"],\n \"manifest-src\": [...self],\n \"object-src\": [\"'none'\"],\n \"base-uri\": [\"'self'\"],\n \"form-action\": [\"'self'\"],\n };\n for (const [name, sources] of Object.entries(configured)) {\n if (sources === false) delete policy[name];\n else policy[name] = union(policy[name] ?? [], sources);\n }\n return policy;\n }\n\n /**\n * Merge the defaults, the declared policy and the configured\n * {@link TempestCspOptions.directives}, never loosening what was declared. A\n * declared directive is kept verbatim: configured sources for it are not\n * added — adding would loosen the app's policy — and each ignored source is\n * reported as a build warning, while a configured `false` removes it, since\n * removing is an explicit choice. An inherited directive (see\n * {@link inheritedSources}) is omitted unless the default is `'none'` or the\n * config adds sources, which then extend the inherited value instead of the\n * defaults.\n */\n function mergePolicy(\n policy: Readonly<Record<string, string[]>>,\n declared: Readonly<Record<string, string[]>>,\n ): Record<string, string[]> {\n const merged: Record<string, string[]> = {};\n for (const [name, sources] of Object.entries(policy)) {\n if (name in declared) {\n merged[name] = declared[name]!;\n continue;\n }\n const inherited = inheritedSources(declared, name);\n const added = configured[name];\n if (inherited === undefined) merged[name] = sources;\n else if (Array.isArray(added)) merged[name] = union(inherited, added);\n else if (sources.length === 1 && sources[0] === \"'none'\") merged[name] = sources;\n }\n for (const [name, sources] of Object.entries(declared)) merged[name] ??= sources;\n for (const [name, sources] of Object.entries(configured)) {\n if (!(name in declared)) continue;\n if (sources === false) {\n delete merged[name];\n continue;\n }\n const ignored = sources.filter((source) => !declared[name]!.includes(source));\n if (ignored.length > 0) {\n config?.logger.warn(\n `tempestCsp: index.html declares ${name}, so directives[\"${name}\"] is not added to it ` +\n `(${ignored.join(\" \")}); the plugin never loosens a declared policy — add them to the <meta> instead`,\n );\n }\n }\n return merged;\n }\n\n /**\n * Report every inline `<script>` the final policy blocks. It only happens when\n * the app's declared policy governs `script-src` — directly or through\n * `default-src` — since the plugin then adds no hash, and the symptom would\n * otherwise be a script that silently never runs (under `vite dev`, the React\n * Refresh preamble, which takes the page down with it).\n *\n * `<script>` elements are governed by `script-src-elem` when the policy has\n * one, so that is the directive judged and named.\n *\n * @param html - The page, without its CSP `<meta>`.\n * @param directive - `\"script-src-elem\"` when declared, else `\"script-src\"`.\n * @param effective - The sources the browser will apply to inline `<script>`\n * elements, or `undefined` when no directive governs them.\n */\n function warnBlockedInlineScripts(\n html: string,\n directive: string,\n effective: readonly string[] | undefined,\n ): void {\n if (!effective || !config) return;\n const unsafeInline =\n effective.includes(\"'unsafe-inline'\") &&\n !effective.some((source) => /^'(?:sha\\d{3}|nonce)-/.test(source));\n if (unsafeInline) return;\n const blocked = inlineHashes(html, \"script\").filter((hash) => !effective.includes(hash));\n if (blocked.length === 0) return;\n config.logger.warn(\n `tempestCsp: the Content-Security-Policy declared in index.html blocks ${blocked.length} inline <script> ` +\n `(${directive} ${effective.join(\" \")}); the plugin never loosens a declared policy, so add ${blocked.join(\" \")} to ${directive}`,\n );\n }\n\n const plugin: Plugin = {\n name: \"tempest-csp\",\n configResolved(resolvedConfig) {\n config = resolvedConfig;\n resolved = resolveSources(resolvedConfig);\n },\n transformIndexHtml: {\n order: \"post\",\n handler(source, ctx) {\n const serve = config?.command === \"serve\";\n if (serve && !dev) return source;\n\n const connectSources = withSockets(resolved);\n const base = config?.base ?? \"/\";\n const baseOrigin = ABSOLUTE_URL.test(base) ? cspOrigin(base, \"base\") : undefined;\n if (baseOrigin) add(connectSources, baseOrigin, \"base\");\n const moduleIds = serve ? [] : bundleModuleIds(ctx);\n for (const known of SDK_ORIGINS) {\n if (serve || moduleIds.some((id) => known.module.test(id))) {\n add(connectSources, known.origin, known.from);\n }\n }\n const allSources = connectSources;\n\n const { html, declared = {} } = extractMetaPolicy(source);\n const policy = defaults(baseOrigin, serve);\n const filled = (name: string): boolean =>\n !(name in declared) && inheritedSources(declared, name) === undefined;\n if (filled(\"script-src\") && !(\"script-src-elem\" in declared)) {\n policy[\"script-src\"] = union(\n policy[\"script-src\"] ?? [],\n inlineHashes(html, \"script\"),\n );\n }\n if (\n filled(\"style-src\") &&\n !(\"style-src-elem\" in declared) &&\n !policy[\"style-src\"]?.includes(\"'unsafe-inline'\")\n ) {\n policy[\"style-src\"] = union(\n policy[\"style-src\"] ?? [],\n inlineHashes(html, \"style\"),\n );\n }\n const merged = mergePolicy(policy, declared);\n const origins = allSources.map((entry) => entry.origin);\n const inheritedConnect = inheritedSources(declared, \"connect-src\");\n const declaredConnect = merged[\"connect-src\"] ?? inheritedConnect;\n const declaredNone =\n declaredConnect?.length === 1 &&\n declaredConnect[0] === \"'none'\" &&\n (\"connect-src\" in declared || inheritedConnect !== undefined);\n if (declaredNone) {\n if (origins.length > 0) {\n config?.logger.warn(\n `tempestCsp: index.html declares connect-src 'none'${\"connect-src\" in declared ? \"\" : \" (through default-src)\"}, ` +\n `so these origins stay out and requests to them will be blocked: ${origins.join(\" \")}`,\n );\n }\n } else if (\n \"connect-src\" in merged ||\n origins.length > 0 ||\n inheritedConnect === undefined\n ) {\n merged[\"connect-src\"] = union(declaredConnect ?? [\"'self'\"], origins);\n }\n\n const scriptElem = \"script-src-elem\" in declared ? \"script-src-elem\" : \"script-src\";\n warnBlockedInlineScripts(\n html,\n scriptElem,\n merged[scriptElem] ?? inheritedSources(declared, scriptElem),\n );\n\n const result: TempestCspPolicy = {\n directives: merged,\n meta: serializeCsp(merged, \"meta\"),\n header: serializeCsp(merged, \"header\"),\n connect: allSources,\n command: serve ? \"serve\" : \"build\",\n };\n\n if (log && config && result.header !== lastLogged) {\n lastLogged = result.header;\n const lines = allSources.map((entry) => ` ${entry.origin} ← ${entry.from}`);\n config.logger.info(\n [\n `tempest-csp: connect-src ${(merged[\"connect-src\"] ?? inheritedConnect ?? []).join(\" \")}`,\n ...(lines.length > 0\n ? lines\n : [\" (no cross-origin endpoint: only 'self')\"]),\n ].join(\"\\n\"),\n );\n }\n onPolicy?.(result);\n return insertMeta(html, result.meta);\n },\n },\n };\n\n return plugin as TempestVitePlugin;\n}\n"],"mappings":"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"}
package/dist/vite.d.ts CHANGED
@@ -113,11 +113,27 @@ export declare interface StyleManifest {
113
113
  * `index.html` (a `themeInitScript()` anti-flash script, say) are allowed by
114
114
  * SHA-256 hash, never by `'unsafe-inline'`.
115
115
  *
116
- * An existing CSP `<meta>` in `index.html` is merged, not overwritten: every
117
- * directive it declares is kept verbatim — the plugin never loosens a choice the
118
- * app wrote — except `connect-src`, which gains the resolved origins, since
119
- * that is the list the plugin exists to maintain. Directives it does not declare
120
- * come from the defaults.
116
+ * An existing CSP `<meta>` in `index.html` is merged, not overwritten, and the
117
+ * merge never loosens what the app wrote:
118
+ *
119
+ * - a directive it declares is kept verbatim, except `connect-src`, which gains
120
+ * the resolved origins (the list the plugin exists to maintain) unless it is
121
+ * `'none'` — declared or inherited from `default-src` — in which case the
122
+ * origins stay out and each one is reported as a build warning. Sources in
123
+ * {@link TempestCspOptions.directives} for a declared directive are not added
124
+ * (a build warning names them); `false` there removes it;
125
+ * - a fetch directive it does not declare but already governs through the CSP3
126
+ * fallback chain (§6.8.3 — `img-src` → `default-src`,
127
+ * `worker-src` → `child-src` → `script-src` → `default-src`) is left out, so it
128
+ * keeps falling back. Filling it from the defaults would replace a
129
+ * `default-src 'none'` with `'self' data: blob:`. No inline-script hash and no
130
+ * `'wasm-unsafe-eval'` is added to it, and an inline `<script>` is judged by a
131
+ * declared `script-src-elem` before `script-src`; `connect-src` starts from the inherited
132
+ * value, and so does any directive passed in {@link TempestCspOptions.directives};
133
+ * - a default that is exactly `'none'` (`object-src`), or a directive that does
134
+ * not fall back (`base-uri`, `form-action`), is still added: it can only
135
+ * restrict;
136
+ * - a directive nothing in the declared policy governs comes from the defaults.
121
137
  *
122
138
  * @param options - See {@link TempestCspOptions}.
123
139
  * @returns A Vite plugin, to be placed in `plugins`. It runs as a `post` HTML
@@ -1,2 +1,2 @@
1
- function e(e){return new RTCPeerConnection({iceServers:e,bundlePolicy:`max-bundle`,rtcpMuxPolicy:`require`})}function t(e,t,n){let r=Number(e.mid);if(e.mid!==null&&Number.isInteger(r)&&n[r])return n[r];let i=t.transceivers.indexOf(e);return i>=0?n[i]??null:null}async function n(e){let t=e.pendingCandidates.splice(0,e.pendingCandidates.length);for(let n of t)try{await e.pc.addIceCandidate(n)}catch{}}async function r(e,t){if(!e.remoteReady){e.pendingCandidates.push(t);return}try{await e.pc.addIceCandidate(t)}catch{}}async function i(e,t,n){for(let[r,i]of t.entries()){let t=e.transceivers[r],a=n.get(i.name)??null;if(t&&a)try{await t.sender.replaceTrack(a)}catch{}}}function a(e){e.transceivers=e.pc.getTransceivers().filter(e=>e.mid!==null).sort((e,t)=>Number(e.mid)-Number(t.mid));for(let t of e.transceivers)t.direction!==`sendrecv`&&(t.direction=`sendrecv`)}exports.acceptCandidate=r,exports.adoptTransceivers=a,exports.attachLocalTracks=i,exports.createPeerConnection=e,exports.drainCandidates=n,exports.slotOf=t;
1
+ function e(e){return new RTCPeerConnection({iceServers:e,bundlePolicy:`max-bundle`,rtcpMuxPolicy:`require`})}function t(e,t,n){let r=Number(e.mid);if(e.mid!==null&&Number.isInteger(r)&&n[r])return n[r];let i=t.transceivers.indexOf(e);return i>=0?n[i]??null:null}async function n(e){let t=e.pendingCandidates.splice(0,e.pendingCandidates.length);for(let n of t)try{await e.pc.addIceCandidate(n)}catch{}}async function r(e,t){if(!e.remoteReady){e.pendingCandidates.push(t);return}try{await e.pc.addIceCandidate(t)}catch{}}function i(e){e.transceivers=e.pc.getTransceivers().filter(e=>e.mid!==null).sort((e,t)=>Number(e.mid)-Number(t.mid));for(let t of e.transceivers)t.direction!==`sendrecv`&&(t.direction=`sendrecv`)}exports.acceptCandidate=r,exports.adoptTransceivers=i,exports.createPeerConnection=e,exports.drainCandidates=n,exports.slotOf=t;
2
2
  //# sourceMappingURL=peer-link.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"peer-link.cjs","names":[],"sources":["../../src/webrtc/peer-link.ts"],"sourcesContent":["import type { MeshSlot } from \"./mesh-types\";\n\n/** One remote peer's connection and everything the mesh tracks about it. */\nexport interface PeerLink {\n peerId: string;\n pc: RTCPeerConnection;\n /**\n * Transceivers in slot order.\n *\n * Held explicitly rather than re-read from `getTransceivers()` so slot\n * lookup stays O(1) and cannot drift if a browser reorders.\n */\n transceivers: RTCRtpTransceiver[];\n /** Candidates that arrived before the remote description they belong to. */\n pendingCandidates: RTCIceCandidateInit[];\n /** Whether `setRemoteDescription` has landed, which gates the queue. */\n remoteReady: boolean;\n /** Whether an offer is in flight, so a re-entrant call does not stack one. */\n makingOffer: boolean;\n /** Whether this side offers. Exactly one side of a link does. */\n isOfferer: boolean;\n}\n\n/** Open a connection with the policies a mesh needs. */\nexport function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {\n return new RTCPeerConnection({\n iceServers,\n bundlePolicy: \"max-bundle\",\n rtcpMuxPolicy: \"require\",\n });\n}\n\n/**\n * Resolve which slot a transceiver carries.\n *\n * The offerer allocates the m-lines in slot order, so a transceiver's `mid` is\n * its ordinal and both ends read the same one. That makes `mid` the slot's wire\n * identity, and the **only** identifier correct on both sides of a link.\n *\n * Position in the link's own array is a fallback for a `mid` that is not a plain\n * ordinal, and never the primary: a browser answering an offer appends the\n * negotiated transceivers *after* any it already held, so positional lookup\n * silently resolves to the wrong slot — the camera arriving in the screen tile —\n * or to none at all, dropping the track.\n *\n * @param transceiver - The transceiver a track arrived on.\n * @param link - The link it belongs to.\n * @param slots - The mesh's slot list.\n * @returns The slot, or `null` when neither route names one.\n */\nexport function slotOf(\n transceiver: RTCRtpTransceiver,\n link: PeerLink,\n slots: readonly MeshSlot[],\n): MeshSlot | null {\n const ordinal = Number(transceiver.mid);\n if (transceiver.mid !== null && Number.isInteger(ordinal) && slots[ordinal]) {\n return slots[ordinal];\n }\n const index = link.transceivers.indexOf(transceiver);\n return index >= 0 ? (slots[index] ?? null) : null;\n}\n\n/**\n * Feed a link every candidate that arrived before its remote description.\n *\n * `addIceCandidate` throws while `remoteDescription` is `null`, and candidates\n * routinely beat the offer or answer they belong to. Without the queue the\n * connection loses them and sits in `checking` until it times out — a failure\n * with no error anywhere.\n *\n * @param link - The link whose queue is drained.\n */\nexport async function drainCandidates(link: PeerLink): Promise<void> {\n const queued = link.pendingCandidates.splice(0, link.pendingCandidates.length);\n for (const candidate of queued) {\n try {\n await link.pc.addIceCandidate(candidate);\n } catch {\n /* a candidate the browser rejects is not fatal to the connection */\n }\n }\n}\n\n/**\n * Hand a candidate to the connection, or park it until the description lands.\n *\n * @param link - The link the candidate belongs to.\n * @param init - The candidate, already in `RTCIceCandidateInit` shape.\n */\nexport async function acceptCandidate(link: PeerLink, init: RTCIceCandidateInit): Promise<void> {\n if (!link.remoteReady) {\n link.pendingCandidates.push(init);\n return;\n }\n try {\n await link.pc.addIceCandidate(init);\n } catch {\n /* a candidate the browser rejects is not fatal to the connection */\n }\n}\n\n/**\n * Publish every held local track onto a link's slots.\n *\n * Called once the transceivers exist — up front for the offerer, after the offer\n * lands for the answerer — so a microphone that was already live when the peer\n * joined starts flowing without waiting for a toggle.\n *\n * @param link - The link to publish onto.\n * @param slots - The mesh's slot list.\n * @param tracks - The current local track per slot name.\n */\nexport async function attachLocalTracks(\n link: PeerLink,\n slots: readonly MeshSlot[],\n tracks: Map<string, MediaStreamTrack | null>,\n): Promise<void> {\n for (const [index, slot] of slots.entries()) {\n const transceiver = link.transceivers[index];\n const track = tracks.get(slot.name) ?? null;\n if (!transceiver || !track) continue;\n try {\n await transceiver.sender.replaceTrack(track);\n } catch {\n /* the browser refused the track; the slot simply stays silent */\n }\n }\n}\n\n/**\n * Adopt the transceivers a remote offer created, in `mid` order.\n *\n * The answering side allocates **nothing** up front, and that is the whole\n * subtlety: applying a remote offer creates one transceiver per m-line, in\n * m-line order, and browsers do not reuse transceivers the answerer made\n * beforehand. Pre-allocating leaves dead, never-negotiated transceivers sitting\n * in front of the live ones, which is how slot lookup ends up pointing at the\n * wrong media or at nothing.\n *\n * Each adopted transceiver is switched to `sendrecv` so this side may publish on\n * it too; without that a peer that answered could hear but never be heard.\n *\n * @param link - The link that just applied a remote offer.\n */\nexport function adoptTransceivers(link: PeerLink): void {\n link.transceivers = link.pc\n .getTransceivers()\n .filter((transceiver) => transceiver.mid !== null)\n .sort((a, b) => Number(a.mid) - Number(b.mid));\n for (const transceiver of link.transceivers) {\n if (transceiver.direction !== \"sendrecv\") transceiver.direction = \"sendrecv\";\n }\n}\n"],"mappings":"AAwBA,SAAgB,EAAqB,EAA+C,CAChF,OAAO,IAAI,kBAAkB,CACzB,aACA,aAAc,aACd,cAAe,SACnB,CAAC,CACL,CAoBA,SAAgB,EACZ,EACA,EACA,EACe,CACf,IAAM,EAAU,OAAO,EAAY,GAAG,EACtC,GAAI,EAAY,MAAQ,MAAQ,OAAO,UAAU,CAAO,GAAK,EAAM,GAC/D,OAAO,EAAM,GAEjB,IAAM,EAAQ,EAAK,aAAa,QAAQ,CAAW,EACnD,OAAO,GAAS,EAAK,EAAM,IAAU,KAAQ,IACjD,CAYA,eAAsB,EAAgB,EAA+B,CACjE,IAAM,EAAS,EAAK,kBAAkB,OAAO,EAAG,EAAK,kBAAkB,MAAM,EAC7E,IAAK,IAAM,KAAa,EACpB,GAAI,CACA,MAAM,EAAK,GAAG,gBAAgB,CAAS,CAC3C,MAAQ,CAER,CAER,CAQA,eAAsB,EAAgB,EAAgB,EAA0C,CAC5F,GAAI,CAAC,EAAK,YAAa,CACnB,EAAK,kBAAkB,KAAK,CAAI,EAChC,MACJ,CACA,GAAI,CACA,MAAM,EAAK,GAAG,gBAAgB,CAAI,CACtC,MAAQ,CAER,CACJ,CAaA,eAAsB,EAClB,EACA,EACA,EACa,CACb,IAAK,GAAM,CAAC,EAAO,KAAS,EAAM,QAAQ,EAAG,CACzC,IAAM,EAAc,EAAK,aAAa,GAChC,EAAQ,EAAO,IAAI,EAAK,IAAI,GAAK,KACnC,GAAC,GAAgB,EACrB,GAAI,CACA,MAAM,EAAY,OAAO,aAAa,CAAK,CAC/C,MAAQ,CAER,CACJ,CACJ,CAiBA,SAAgB,EAAkB,EAAsB,CACpD,EAAK,aAAe,EAAK,GACpB,gBAAgB,CAAC,CACjB,OAAQ,GAAgB,EAAY,MAAQ,IAAI,CAAC,CACjD,MAAM,EAAG,IAAM,OAAO,EAAE,GAAG,EAAI,OAAO,EAAE,GAAG,CAAC,EACjD,IAAK,IAAM,KAAe,EAAK,aACvB,EAAY,YAAc,aAAY,EAAY,UAAY,WAE1E"}
1
+ {"version":3,"file":"peer-link.cjs","names":[],"sources":["../../src/webrtc/peer-link.ts"],"sourcesContent":["import type { MeshSlot } from \"./mesh-types\";\n\n/** One remote peer's connection and everything the mesh tracks about it. */\nexport interface PeerLink {\n peerId: string;\n pc: RTCPeerConnection;\n /**\n * Transceivers in slot order.\n *\n * Held explicitly rather than re-read from `getTransceivers()` so slot\n * lookup stays O(1) and cannot drift if a browser reorders.\n */\n transceivers: RTCRtpTransceiver[];\n /** Candidates that arrived before the remote description they belong to. */\n pendingCandidates: RTCIceCandidateInit[];\n /** Whether `setRemoteDescription` has landed, which gates the queue. */\n remoteReady: boolean;\n /** Whether an offer is in flight, so a re-entrant call does not stack one. */\n makingOffer: boolean;\n /** Whether this side offers. Exactly one side of a link does. */\n isOfferer: boolean;\n}\n\n/** Open a connection with the policies a mesh needs. */\nexport function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {\n return new RTCPeerConnection({\n iceServers,\n bundlePolicy: \"max-bundle\",\n rtcpMuxPolicy: \"require\",\n });\n}\n\n/**\n * Resolve which slot a transceiver carries.\n *\n * The offerer allocates the m-lines in slot order, so a transceiver's `mid` is\n * its ordinal and both ends read the same one. That makes `mid` the slot's wire\n * identity, and the **only** identifier correct on both sides of a link.\n *\n * Position in the link's own array is a fallback for a `mid` that is not a plain\n * ordinal, and never the primary: a browser answering an offer appends the\n * negotiated transceivers *after* any it already held, so positional lookup\n * silently resolves to the wrong slot — the camera arriving in the screen tile —\n * or to none at all, dropping the track.\n *\n * @param transceiver - The transceiver a track arrived on.\n * @param link - The link it belongs to.\n * @param slots - The mesh's slot list.\n * @returns The slot, or `null` when neither route names one.\n */\nexport function slotOf(\n transceiver: RTCRtpTransceiver,\n link: PeerLink,\n slots: readonly MeshSlot[],\n): MeshSlot | null {\n const ordinal = Number(transceiver.mid);\n if (transceiver.mid !== null && Number.isInteger(ordinal) && slots[ordinal]) {\n return slots[ordinal];\n }\n const index = link.transceivers.indexOf(transceiver);\n return index >= 0 ? (slots[index] ?? null) : null;\n}\n\n/**\n * Feed a link every candidate that arrived before its remote description.\n *\n * `addIceCandidate` throws while `remoteDescription` is `null`, and candidates\n * routinely beat the offer or answer they belong to. Without the queue the\n * connection loses them and sits in `checking` until it times out — a failure\n * with no error anywhere.\n *\n * @param link - The link whose queue is drained.\n */\nexport async function drainCandidates(link: PeerLink): Promise<void> {\n const queued = link.pendingCandidates.splice(0, link.pendingCandidates.length);\n for (const candidate of queued) {\n try {\n await link.pc.addIceCandidate(candidate);\n } catch {\n /* a candidate the browser rejects is not fatal to the connection */\n }\n }\n}\n\n/**\n * Hand a candidate to the connection, or park it until the description lands.\n *\n * @param link - The link the candidate belongs to.\n * @param init - The candidate, already in `RTCIceCandidateInit` shape.\n */\nexport async function acceptCandidate(link: PeerLink, init: RTCIceCandidateInit): Promise<void> {\n if (!link.remoteReady) {\n link.pendingCandidates.push(init);\n return;\n }\n try {\n await link.pc.addIceCandidate(init);\n } catch {\n /* a candidate the browser rejects is not fatal to the connection */\n }\n}\n\n/**\n * Adopt the transceivers a remote offer created, in `mid` order.\n *\n * The answering side allocates **nothing** up front, and that is the whole\n * subtlety: applying a remote offer creates one transceiver per m-line, in\n * m-line order, and browsers do not reuse transceivers the answerer made\n * beforehand. Pre-allocating leaves dead, never-negotiated transceivers sitting\n * in front of the live ones, which is how slot lookup ends up pointing at the\n * wrong media or at nothing.\n *\n * Each adopted transceiver is switched to `sendrecv` so this side may publish on\n * it too; without that a peer that answered could hear but never be heard.\n *\n * @param link - The link that just applied a remote offer.\n */\nexport function adoptTransceivers(link: PeerLink): void {\n link.transceivers = link.pc\n .getTransceivers()\n .filter((transceiver) => transceiver.mid !== null)\n .sort((a, b) => Number(a.mid) - Number(b.mid));\n for (const transceiver of link.transceivers) {\n if (transceiver.direction !== \"sendrecv\") transceiver.direction = \"sendrecv\";\n }\n}\n"],"mappings":"AAwBA,SAAgB,EAAqB,EAA+C,CAChF,OAAO,IAAI,kBAAkB,CACzB,aACA,aAAc,aACd,cAAe,SACnB,CAAC,CACL,CAoBA,SAAgB,EACZ,EACA,EACA,EACe,CACf,IAAM,EAAU,OAAO,EAAY,GAAG,EACtC,GAAI,EAAY,MAAQ,MAAQ,OAAO,UAAU,CAAO,GAAK,EAAM,GAC/D,OAAO,EAAM,GAEjB,IAAM,EAAQ,EAAK,aAAa,QAAQ,CAAW,EACnD,OAAO,GAAS,EAAK,EAAM,IAAU,KAAQ,IACjD,CAYA,eAAsB,EAAgB,EAA+B,CACjE,IAAM,EAAS,EAAK,kBAAkB,OAAO,EAAG,EAAK,kBAAkB,MAAM,EAC7E,IAAK,IAAM,KAAa,EACpB,GAAI,CACA,MAAM,EAAK,GAAG,gBAAgB,CAAS,CAC3C,MAAQ,CAER,CAER,CAQA,eAAsB,EAAgB,EAAgB,EAA0C,CAC5F,GAAI,CAAC,EAAK,YAAa,CACnB,EAAK,kBAAkB,KAAK,CAAI,EAChC,MACJ,CACA,GAAI,CACA,MAAM,EAAK,GAAG,gBAAgB,CAAI,CACtC,MAAQ,CAER,CACJ,CAiBA,SAAgB,EAAkB,EAAsB,CACpD,EAAK,aAAe,EAAK,GACpB,gBAAgB,CAAC,CACjB,OAAQ,GAAgB,EAAY,MAAQ,IAAI,CAAC,CACjD,MAAM,EAAG,IAAM,OAAO,EAAE,GAAG,EAAI,OAAO,EAAE,GAAG,CAAC,EACjD,IAAK,IAAM,KAAe,EAAK,aACvB,EAAY,YAAc,aAAY,EAAY,UAAY,WAE1E"}
@@ -27,19 +27,11 @@ async function r(e, t) {
27
27
  await e.pc.addIceCandidate(t);
28
28
  } catch {}
29
29
  }
30
- async function i(e, t, n) {
31
- for (let [r, i] of t.entries()) {
32
- let t = e.transceivers[r], a = n.get(i.name) ?? null;
33
- if (t && a) try {
34
- await t.sender.replaceTrack(a);
35
- } catch {}
36
- }
37
- }
38
- function a(e) {
30
+ function i(e) {
39
31
  e.transceivers = e.pc.getTransceivers().filter((e) => e.mid !== null).sort((e, t) => Number(e.mid) - Number(t.mid));
40
32
  for (let t of e.transceivers) t.direction !== "sendrecv" && (t.direction = "sendrecv");
41
33
  }
42
34
  //#endregion
43
- export { r as acceptCandidate, a as adoptTransceivers, i as attachLocalTracks, e as createPeerConnection, n as drainCandidates, t as slotOf };
35
+ export { r as acceptCandidate, i as adoptTransceivers, e as createPeerConnection, n as drainCandidates, t as slotOf };
44
36
 
45
37
  //# sourceMappingURL=peer-link.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"peer-link.js","names":[],"sources":["../../src/webrtc/peer-link.ts"],"sourcesContent":["import type { MeshSlot } from \"./mesh-types\";\n\n/** One remote peer's connection and everything the mesh tracks about it. */\nexport interface PeerLink {\n peerId: string;\n pc: RTCPeerConnection;\n /**\n * Transceivers in slot order.\n *\n * Held explicitly rather than re-read from `getTransceivers()` so slot\n * lookup stays O(1) and cannot drift if a browser reorders.\n */\n transceivers: RTCRtpTransceiver[];\n /** Candidates that arrived before the remote description they belong to. */\n pendingCandidates: RTCIceCandidateInit[];\n /** Whether `setRemoteDescription` has landed, which gates the queue. */\n remoteReady: boolean;\n /** Whether an offer is in flight, so a re-entrant call does not stack one. */\n makingOffer: boolean;\n /** Whether this side offers. Exactly one side of a link does. */\n isOfferer: boolean;\n}\n\n/** Open a connection with the policies a mesh needs. */\nexport function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {\n return new RTCPeerConnection({\n iceServers,\n bundlePolicy: \"max-bundle\",\n rtcpMuxPolicy: \"require\",\n });\n}\n\n/**\n * Resolve which slot a transceiver carries.\n *\n * The offerer allocates the m-lines in slot order, so a transceiver's `mid` is\n * its ordinal and both ends read the same one. That makes `mid` the slot's wire\n * identity, and the **only** identifier correct on both sides of a link.\n *\n * Position in the link's own array is a fallback for a `mid` that is not a plain\n * ordinal, and never the primary: a browser answering an offer appends the\n * negotiated transceivers *after* any it already held, so positional lookup\n * silently resolves to the wrong slot — the camera arriving in the screen tile —\n * or to none at all, dropping the track.\n *\n * @param transceiver - The transceiver a track arrived on.\n * @param link - The link it belongs to.\n * @param slots - The mesh's slot list.\n * @returns The slot, or `null` when neither route names one.\n */\nexport function slotOf(\n transceiver: RTCRtpTransceiver,\n link: PeerLink,\n slots: readonly MeshSlot[],\n): MeshSlot | null {\n const ordinal = Number(transceiver.mid);\n if (transceiver.mid !== null && Number.isInteger(ordinal) && slots[ordinal]) {\n return slots[ordinal];\n }\n const index = link.transceivers.indexOf(transceiver);\n return index >= 0 ? (slots[index] ?? null) : null;\n}\n\n/**\n * Feed a link every candidate that arrived before its remote description.\n *\n * `addIceCandidate` throws while `remoteDescription` is `null`, and candidates\n * routinely beat the offer or answer they belong to. Without the queue the\n * connection loses them and sits in `checking` until it times out — a failure\n * with no error anywhere.\n *\n * @param link - The link whose queue is drained.\n */\nexport async function drainCandidates(link: PeerLink): Promise<void> {\n const queued = link.pendingCandidates.splice(0, link.pendingCandidates.length);\n for (const candidate of queued) {\n try {\n await link.pc.addIceCandidate(candidate);\n } catch {\n /* a candidate the browser rejects is not fatal to the connection */\n }\n }\n}\n\n/**\n * Hand a candidate to the connection, or park it until the description lands.\n *\n * @param link - The link the candidate belongs to.\n * @param init - The candidate, already in `RTCIceCandidateInit` shape.\n */\nexport async function acceptCandidate(link: PeerLink, init: RTCIceCandidateInit): Promise<void> {\n if (!link.remoteReady) {\n link.pendingCandidates.push(init);\n return;\n }\n try {\n await link.pc.addIceCandidate(init);\n } catch {\n /* a candidate the browser rejects is not fatal to the connection */\n }\n}\n\n/**\n * Publish every held local track onto a link's slots.\n *\n * Called once the transceivers exist — up front for the offerer, after the offer\n * lands for the answerer — so a microphone that was already live when the peer\n * joined starts flowing without waiting for a toggle.\n *\n * @param link - The link to publish onto.\n * @param slots - The mesh's slot list.\n * @param tracks - The current local track per slot name.\n */\nexport async function attachLocalTracks(\n link: PeerLink,\n slots: readonly MeshSlot[],\n tracks: Map<string, MediaStreamTrack | null>,\n): Promise<void> {\n for (const [index, slot] of slots.entries()) {\n const transceiver = link.transceivers[index];\n const track = tracks.get(slot.name) ?? null;\n if (!transceiver || !track) continue;\n try {\n await transceiver.sender.replaceTrack(track);\n } catch {\n /* the browser refused the track; the slot simply stays silent */\n }\n }\n}\n\n/**\n * Adopt the transceivers a remote offer created, in `mid` order.\n *\n * The answering side allocates **nothing** up front, and that is the whole\n * subtlety: applying a remote offer creates one transceiver per m-line, in\n * m-line order, and browsers do not reuse transceivers the answerer made\n * beforehand. Pre-allocating leaves dead, never-negotiated transceivers sitting\n * in front of the live ones, which is how slot lookup ends up pointing at the\n * wrong media or at nothing.\n *\n * Each adopted transceiver is switched to `sendrecv` so this side may publish on\n * it too; without that a peer that answered could hear but never be heard.\n *\n * @param link - The link that just applied a remote offer.\n */\nexport function adoptTransceivers(link: PeerLink): void {\n link.transceivers = link.pc\n .getTransceivers()\n .filter((transceiver) => transceiver.mid !== null)\n .sort((a, b) => Number(a.mid) - Number(b.mid));\n for (const transceiver of link.transceivers) {\n if (transceiver.direction !== \"sendrecv\") transceiver.direction = \"sendrecv\";\n }\n}\n"],"mappings":";AAwBA,SAAgB,EAAqB,GAA+C;CAChF,OAAO,IAAI,kBAAkB;EACzB;EACA,cAAc;EACd,eAAe;CACnB,CAAC;AACL;AAoBA,SAAgB,EACZ,GACA,GACA,GACe;CACf,IAAM,IAAU,OAAO,EAAY,GAAG;CACtC,IAAI,EAAY,QAAQ,QAAQ,OAAO,UAAU,CAAO,KAAK,EAAM,IAC/D,OAAO,EAAM;CAEjB,IAAM,IAAQ,EAAK,aAAa,QAAQ,CAAW;CACnD,OAAO,KAAS,IAAK,EAAM,MAAU,OAAQ;AACjD;AAYA,eAAsB,EAAgB,GAA+B;CACjE,IAAM,IAAS,EAAK,kBAAkB,OAAO,GAAG,EAAK,kBAAkB,MAAM;CAC7E,KAAK,IAAM,KAAa,GACpB,IAAI;EACA,MAAM,EAAK,GAAG,gBAAgB,CAAS;CAC3C,QAAQ,CAER;AAER;AAQA,eAAsB,EAAgB,GAAgB,GAA0C;CAC5F,IAAI,CAAC,EAAK,aAAa;EACnB,EAAK,kBAAkB,KAAK,CAAI;EAChC;CACJ;CACA,IAAI;EACA,MAAM,EAAK,GAAG,gBAAgB,CAAI;CACtC,QAAQ,CAER;AACJ;AAaA,eAAsB,EAClB,GACA,GACA,GACa;CACb,KAAK,IAAM,CAAC,GAAO,MAAS,EAAM,QAAQ,GAAG;EACzC,IAAM,IAAc,EAAK,aAAa,IAChC,IAAQ,EAAO,IAAI,EAAK,IAAI,KAAK;EACnC,IAAC,KAAgB,GACrB,IAAI;GACA,MAAM,EAAY,OAAO,aAAa,CAAK;EAC/C,QAAQ,CAER;CACJ;AACJ;AAiBA,SAAgB,EAAkB,GAAsB;CACpD,EAAK,eAAe,EAAK,GACpB,gBAAgB,CAAC,CACjB,QAAQ,MAAgB,EAAY,QAAQ,IAAI,CAAC,CACjD,MAAM,GAAG,MAAM,OAAO,EAAE,GAAG,IAAI,OAAO,EAAE,GAAG,CAAC;CACjD,KAAK,IAAM,KAAe,EAAK,cAC3B,AAAI,EAAY,cAAc,eAAY,EAAY,YAAY;AAE1E"}
1
+ {"version":3,"file":"peer-link.js","names":[],"sources":["../../src/webrtc/peer-link.ts"],"sourcesContent":["import type { MeshSlot } from \"./mesh-types\";\n\n/** One remote peer's connection and everything the mesh tracks about it. */\nexport interface PeerLink {\n peerId: string;\n pc: RTCPeerConnection;\n /**\n * Transceivers in slot order.\n *\n * Held explicitly rather than re-read from `getTransceivers()` so slot\n * lookup stays O(1) and cannot drift if a browser reorders.\n */\n transceivers: RTCRtpTransceiver[];\n /** Candidates that arrived before the remote description they belong to. */\n pendingCandidates: RTCIceCandidateInit[];\n /** Whether `setRemoteDescription` has landed, which gates the queue. */\n remoteReady: boolean;\n /** Whether an offer is in flight, so a re-entrant call does not stack one. */\n makingOffer: boolean;\n /** Whether this side offers. Exactly one side of a link does. */\n isOfferer: boolean;\n}\n\n/** Open a connection with the policies a mesh needs. */\nexport function createPeerConnection(iceServers: RTCIceServer[]): RTCPeerConnection {\n return new RTCPeerConnection({\n iceServers,\n bundlePolicy: \"max-bundle\",\n rtcpMuxPolicy: \"require\",\n });\n}\n\n/**\n * Resolve which slot a transceiver carries.\n *\n * The offerer allocates the m-lines in slot order, so a transceiver's `mid` is\n * its ordinal and both ends read the same one. That makes `mid` the slot's wire\n * identity, and the **only** identifier correct on both sides of a link.\n *\n * Position in the link's own array is a fallback for a `mid` that is not a plain\n * ordinal, and never the primary: a browser answering an offer appends the\n * negotiated transceivers *after* any it already held, so positional lookup\n * silently resolves to the wrong slot — the camera arriving in the screen tile —\n * or to none at all, dropping the track.\n *\n * @param transceiver - The transceiver a track arrived on.\n * @param link - The link it belongs to.\n * @param slots - The mesh's slot list.\n * @returns The slot, or `null` when neither route names one.\n */\nexport function slotOf(\n transceiver: RTCRtpTransceiver,\n link: PeerLink,\n slots: readonly MeshSlot[],\n): MeshSlot | null {\n const ordinal = Number(transceiver.mid);\n if (transceiver.mid !== null && Number.isInteger(ordinal) && slots[ordinal]) {\n return slots[ordinal];\n }\n const index = link.transceivers.indexOf(transceiver);\n return index >= 0 ? (slots[index] ?? null) : null;\n}\n\n/**\n * Feed a link every candidate that arrived before its remote description.\n *\n * `addIceCandidate` throws while `remoteDescription` is `null`, and candidates\n * routinely beat the offer or answer they belong to. Without the queue the\n * connection loses them and sits in `checking` until it times out — a failure\n * with no error anywhere.\n *\n * @param link - The link whose queue is drained.\n */\nexport async function drainCandidates(link: PeerLink): Promise<void> {\n const queued = link.pendingCandidates.splice(0, link.pendingCandidates.length);\n for (const candidate of queued) {\n try {\n await link.pc.addIceCandidate(candidate);\n } catch {\n /* a candidate the browser rejects is not fatal to the connection */\n }\n }\n}\n\n/**\n * Hand a candidate to the connection, or park it until the description lands.\n *\n * @param link - The link the candidate belongs to.\n * @param init - The candidate, already in `RTCIceCandidateInit` shape.\n */\nexport async function acceptCandidate(link: PeerLink, init: RTCIceCandidateInit): Promise<void> {\n if (!link.remoteReady) {\n link.pendingCandidates.push(init);\n return;\n }\n try {\n await link.pc.addIceCandidate(init);\n } catch {\n /* a candidate the browser rejects is not fatal to the connection */\n }\n}\n\n/**\n * Adopt the transceivers a remote offer created, in `mid` order.\n *\n * The answering side allocates **nothing** up front, and that is the whole\n * subtlety: applying a remote offer creates one transceiver per m-line, in\n * m-line order, and browsers do not reuse transceivers the answerer made\n * beforehand. Pre-allocating leaves dead, never-negotiated transceivers sitting\n * in front of the live ones, which is how slot lookup ends up pointing at the\n * wrong media or at nothing.\n *\n * Each adopted transceiver is switched to `sendrecv` so this side may publish on\n * it too; without that a peer that answered could hear but never be heard.\n *\n * @param link - The link that just applied a remote offer.\n */\nexport function adoptTransceivers(link: PeerLink): void {\n link.transceivers = link.pc\n .getTransceivers()\n .filter((transceiver) => transceiver.mid !== null)\n .sort((a, b) => Number(a.mid) - Number(b.mid));\n for (const transceiver of link.transceivers) {\n if (transceiver.direction !== \"sendrecv\") transceiver.direction = \"sendrecv\";\n }\n}\n"],"mappings":";AAwBA,SAAgB,EAAqB,GAA+C;CAChF,OAAO,IAAI,kBAAkB;EACzB;EACA,cAAc;EACd,eAAe;CACnB,CAAC;AACL;AAoBA,SAAgB,EACZ,GACA,GACA,GACe;CACf,IAAM,IAAU,OAAO,EAAY,GAAG;CACtC,IAAI,EAAY,QAAQ,QAAQ,OAAO,UAAU,CAAO,KAAK,EAAM,IAC/D,OAAO,EAAM;CAEjB,IAAM,IAAQ,EAAK,aAAa,QAAQ,CAAW;CACnD,OAAO,KAAS,IAAK,EAAM,MAAU,OAAQ;AACjD;AAYA,eAAsB,EAAgB,GAA+B;CACjE,IAAM,IAAS,EAAK,kBAAkB,OAAO,GAAG,EAAK,kBAAkB,MAAM;CAC7E,KAAK,IAAM,KAAa,GACpB,IAAI;EACA,MAAM,EAAK,GAAG,gBAAgB,CAAS;CAC3C,QAAQ,CAER;AAER;AAQA,eAAsB,EAAgB,GAAgB,GAA0C;CAC5F,IAAI,CAAC,EAAK,aAAa;EACnB,EAAK,kBAAkB,KAAK,CAAI;EAChC;CACJ;CACA,IAAI;EACA,MAAM,EAAK,GAAG,gBAAgB,CAAI;CACtC,QAAQ,CAER;AACJ;AAiBA,SAAgB,EAAkB,GAAsB;CACpD,EAAK,eAAe,EAAK,GACpB,gBAAgB,CAAC,CACjB,QAAQ,MAAgB,EAAY,QAAQ,IAAI,CAAC,CACjD,MAAM,GAAG,MAAM,OAAO,EAAE,GAAG,IAAI,OAAO,EAAE,GAAG,CAAC;CACjD,KAAK,IAAM,KAAe,EAAK,cAC3B,AAAI,EAAY,cAAc,eAAY,EAAY,YAAY;AAE1E"}
@@ -1,2 +1,2 @@
1
- const e=require("./link-stats.cjs"),t=require("./mesh-quality.cjs"),n=require("./peer-link.cjs");function r(r){let{slots:i,send:a}=r,o=new Map,s=new Map,c=new Map,l=r.iceServers??[],u=r.setLocalDescription??((e,t)=>e.setLocalDescription(t)),d=r.quality??{},f=!1,p=new Map,m=null,h=()=>Object.fromEntries(i.map(e=>[e.name,null])),g=()=>{r.onPeers?.([...s.values()].map(e=>({...e})))},_=async t=>{if(f)return;let n=!1;for(let r of[...o.values()]){if(r.pc.connectionState!==`connected`)continue;let i=p.get(r.peerId);i===void 0&&(i=e.createLinkStatsSampler(t.kind===void 0?{}:{kind:t.kind}),p.set(r.peerId,i));let a=await i.sample(r.pc),o=s.get(r.peerId);o!==void 0&&(o.stats=a,n=!0,t.onStats?.(r.peerId,a))}n&&g()},v=()=>{let e=r.stats;if(e===void 0)return;let t=!f&&o.size>0;if(t&&m===null){m=setInterval(()=>void _(e),e.intervalMs??2e3);return}!t&&m!==null&&(clearInterval(m),m=null)},y=()=>{if(f)return;let e=[...o.values()].map(e=>e.pc.connectionState);e.some(e=>e===`connected`)?r.onState?.(`connected`):e.length>0?r.onState?.(`connecting`,`negotiating`):r.onState?.(`connected`,`alone`)},b=async e=>{d=e;let n=t.scaleForRoom(e,o.size),r=t.resolveDegradation(e,n);for(let e of o.values())await t.applyQualityToLink(e.transceivers,i,n,r)},x=async e=>{if(!e.makingOffer){e.makingOffer=!0;try{let t=await e.pc.createOffer();if(!t.sdp){r.onNotice?.(`empty_local_offer_sdp`);return}await u(e.pc,t),a({type:`offer`,to:e.peerId,sdp:e.pc.localDescription?.sdp??t.sdp})}catch{r.onNotice?.(`offer_failed`)}finally{e.makingOffer=!1}}},S=e=>e.streams?.[0]||(typeof MediaStream>`u`?null:new MediaStream([e.track])),C=(e,t)=>{let r=n.slotOf(t.transceiver,e,i),a=s.get(e.peerId);r&&a&&(a.streams={...a.streams,[r.name]:S(t)},t.track.onended=()=>{let t=s.get(e.peerId);t&&(t.streams={...t.streams,[r.name]:null},g())},g())},w=e=>{let t=o.get(e);t&&(t.pc.close(),o.delete(e)),s.delete(e),p.delete(e),b(d),v(),g(),y()},T=async(e,{offerer:t})=>{if(o.has(e))return;let r=n.createPeerConnection(l),u={peerId:e,pc:r,transceivers:[],pendingCandidates:[],remoteReady:!1,makingOffer:!1,isOfferer:t};o.set(e,u),s.set(e,{peerId:e,connection:r.connectionState,streams:s.get(e)?.streams??h()}),t&&(u.transceivers=i.map(e=>r.addTransceiver(e.kind,{direction:`sendrecv`})),await n.attachLocalTracks(u,i,c)),r.onicecandidate=t=>{a({type:`ice`,to:e,candidate:t.candidate?.candidate??null,sdpMid:t.candidate?.sdpMid??null,sdpMLineIndex:t.candidate?.sdpMLineIndex??null})},r.ontrack=e=>C(u,e),r.onconnectionstatechange=()=>{let t=s.get(e);if(t&&(t.connection=r.connectionState),r.connectionState===`failed`||r.connectionState===`closed`){w(e);return}g(),y()},r.onnegotiationneeded=()=>{u.isOfferer&&x(u)},v(),g(),y(),t&&await x(u)},E=async(e,t)=>{o.has(e)||await T(e,{offerer:!1});let s=o.get(e);if(!s)return;await s.pc.setRemoteDescription({type:`offer`,sdp:t}),s.remoteReady=!0,await n.drainCandidates(s),s.isOfferer||(n.adoptTransceivers(s),await n.attachLocalTracks(s,i,c));let l=await s.pc.createAnswer();if(!l.sdp){r.onNotice?.(`empty_local_answer_sdp`);return}await u(s.pc,l),a({type:`answer`,to:e,sdp:s.pc.localDescription?.sdp??l.sdp}),await b(d)};return r.onState?.(`connecting`,`signaling`),{addPeer:T,removePeer:w,accept:async e=>{if(e.type===`offer`){await E(e.from,e.sdp);return}let t=o.get(e.from);if(t){if(e.type===`answer`){await t.pc.setRemoteDescription({type:`answer`,sdp:e.sdp}),t.remoteReady=!0,await n.drainCandidates(t),await b(d);return}e.candidate!==null&&await n.acceptCandidate(t,{candidate:e.candidate,sdpMid:e.sdpMid??void 0,sdpMLineIndex:e.sdpMLineIndex??void 0})}},setLocalTrack:async(e,t)=>{c.set(e,t);let n=i.findIndex(t=>t.name===e);if(!(n<0)){for(let e of o.values()){let r=e.transceivers[n];if(r)try{await r.sender.replaceTrack(t)}catch{}}await b(d)}},applyQuality:b,setIceServers:e=>{l=e},stop:()=>{for(let e of o.values())e.pc.close();o.clear(),s.clear(),p.clear(),f=!0,v(),g(),r.onState?.(`closed`)},getConnection:e=>o.get(e)?.pc??null,get peers(){return[...s.values()].map(e=>({...e}))}}}exports.createPeerMesh=r;
1
+ const e=require("./link-stats.cjs"),t=require("./mesh-quality.cjs"),n=require("./peer-link.cjs");var r=Symbol(`gone`);function i(i){let{slots:a,send:o}=i,s=new Map,c=new Map,l=new Map,u=new Map,d=i.iceServers??[],f=i.setLocalDescription??((e,t)=>e.setLocalDescription(t)),p=i.quality??{},m=!1,h=new Map,g=null,_=()=>Object.fromEntries(a.map(e=>[e.name,null])),v=()=>{i.onPeers?.([...c.values()].map(e=>({...e})))},y=e=>!m&&s.get(e.peerId)===e,b=async(e,t)=>{try{let n=await t;return y(e)?n:r}catch(t){if(!y(e))return r;throw t}},x=async t=>{if(m)return;let n=!1;for(let r of[...s.values()]){if(r.pc.connectionState!==`connected`)continue;let i=h.get(r.peerId);i===void 0&&(i=e.createLinkStatsSampler(t.kind===void 0?{}:{kind:t.kind}),h.set(r.peerId,i));let a;try{a=await i.sample(r.pc)}catch{continue}let o=c.get(r.peerId);y(r)&&o!==void 0&&(o.stats=a,n=!0,t.onStats?.(r.peerId,a))}n&&v()},S=()=>{let e=i.stats;if(e===void 0)return;let t=!m&&s.size>0;if(t&&g===null){g=setInterval(()=>void x(e),e.intervalMs??2e3);return}!t&&g!==null&&(clearInterval(g),g=null)},C=()=>{if(m)return;let e=[...s.values()].map(e=>e.pc.connectionState);e.some(e=>e===`connected`)?i.onState?.(`connected`):e.length>0?i.onState?.(`connecting`,`negotiating`):i.onState?.(`connected`,`alone`)},w=async e=>{if(m)return;p=e;let n=t.scaleForRoom(e,s.size),r=t.resolveDegradation(e,n);for(let e of s.values())await t.applyQualityToLink(e.transceivers,a,n,r)},T=async e=>{if(!e.makingOffer){e.makingOffer=!0;try{let t=await e.pc.createOffer();if(!y(e))return;if(!t.sdp){i.onNotice?.(`empty_local_offer_sdp`);return}if(await f(e.pc,t),!y(e))return;o({type:`offer`,to:e.peerId,sdp:e.pc.localDescription?.sdp??t.sdp})}catch{y(e)&&i.onNotice?.(`offer_failed`)}finally{e.makingOffer=!1}}},E=e=>e.streams?.[0]||(typeof MediaStream>`u`?null:new MediaStream([e.track])),D=(e,t)=>{let r=n.slotOf(t.transceiver,e,a),i=c.get(e.peerId);r&&i&&(i.streams={...i.streams,[r.name]:E(t)},t.track.onended=()=>{let t=c.get(e.peerId);t&&(t.streams={...t.streams,[r.name]:null},v())},v())},O=(e,t)=>{let n=(u.get(e)??Promise.resolve()).then(t);return u.set(e,n),n},k=async(e,t,n)=>{let r=e.transceivers[n];if(r&&y(e))try{await r.sender.replaceTrack(l.get(t)??null)}catch{return}},A=e=>Promise.all(a.map((t,n)=>O(t.name,()=>k(e,t.name,n)))),j=e=>{if(m)return;let t=s.get(e);t&&(t.pc.close(),s.delete(e)),c.delete(e),h.delete(e),w(p),S(),v(),C()},M=async(e,{offerer:t})=>{if(m||s.has(e))return;let r=n.createPeerConnection(d),i=[];if(t)try{i=a.map(e=>r.addTransceiver(e.kind,{direction:`sendrecv`}))}catch(e){throw r.close(),e}let l={peerId:e,pc:r,transceivers:i,pendingCandidates:[],remoteReady:!1,makingOffer:!1,isOfferer:t};s.set(e,l),c.set(e,{peerId:e,connection:r.connectionState,streams:c.get(e)?.streams??_()}),!(t&&(await A(l),!y(l)))&&(r.onicecandidate=t=>{o({type:`ice`,to:e,candidate:t.candidate?.candidate??null,sdpMid:t.candidate?.sdpMid??null,sdpMLineIndex:t.candidate?.sdpMLineIndex??null})},r.ontrack=e=>D(l,e),r.onconnectionstatechange=()=>{if(!y(l))return;let t=c.get(e);if(t&&(t.connection=r.connectionState),r.connectionState===`failed`||r.connectionState===`closed`){j(e);return}v(),C()},r.onnegotiationneeded=()=>{l.isOfferer&&T(l)},S(),v(),C(),t&&await T(l))},N=async(e,t)=>{s.has(e)||await M(e,{offerer:!1});let a=s.get(e);if(!a||!y(a)||await b(a,a.pc.setRemoteDescription({type:`offer`,sdp:t}))===r||(a.remoteReady=!0,await n.drainCandidates(a),a.isOfferer||(n.adoptTransceivers(a),await A(a)),!y(a)))return;let c=await b(a,a.pc.createAnswer());if(c!==r){if(!c.sdp){i.onNotice?.(`empty_local_answer_sdp`);return}await b(a,f(a.pc,c))!==r&&(o({type:`answer`,to:e,sdp:a.pc.localDescription?.sdp??c.sdp}),await w(p))}};return i.onState?.(`connecting`,`signaling`),{addPeer:M,removePeer:j,accept:async e=>{if(m)return;if(e.type===`offer`){await N(e.from,e.sdp);return}let t=s.get(e.from);if(t){if(e.type===`answer`){let i=t.pc.setRemoteDescription({type:`answer`,sdp:e.sdp});if(await b(t,i)===r)return;t.remoteReady=!0,await n.drainCandidates(t),await w(p);return}e.candidate!==null&&await n.acceptCandidate(t,{candidate:e.candidate,sdpMid:e.sdpMid??void 0,sdpMLineIndex:e.sdpMLineIndex??void 0})}},setLocalTrack:async(e,t)=>{if(m)return;l.set(e,t);let n=a.findIndex(t=>t.name===e);n<0||(await O(e,async()=>{for(let t of[...s.values()])await k(t,e,n)}),await w(p))},applyQuality:w,setIceServers:e=>{d=e},stop:()=>{if(!m){m=!0;for(let e of s.values())e.pc.close();s.clear(),c.clear(),h.clear(),l.clear(),S(),v(),i.onState?.(`closed`)}},getConnection:e=>s.get(e)?.pc??null,get peers(){return[...c.values()].map(e=>({...e}))}}}exports.createPeerMesh=i;
2
2
  //# sourceMappingURL=peer-mesh.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"peer-mesh.cjs","names":[],"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 attachLocalTracks,\n createPeerConnection,\n drainCandidates,\n slotOf,\n type PeerLink,\n} from \"./peer-link\";\nimport { createLinkStatsSampler, type LinkStatsSampler } from \"./link-stats\";\nimport type {\n MeshMessage,\n MeshPeer,\n MeshQuality,\n MeshStatsOptions,\n PeerMeshOptions,\n} from \"./mesh-types\";\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 /** Close every link and report `closed`. */\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 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 * 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 * @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 const stats = await sampler.sample(link.pc);\n const peer = peers.get(link.peerId);\n /*\n * Belt and braces, and the belt is the state check above: a peer\n * removed from inside `onStats` has its connection closed first, so\n * the next iteration skips it there rather than here. This catches\n * the entry disappearing any other way across the await.\n */\n if (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 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 (!offer.sdp) {\n options.onNotice?.(\"empty_local_offer_sdp\");\n return;\n }\n await describe(link.pc, offer);\n send({\n type: \"offer\",\n to: link.peerId,\n sdp: link.pc.localDescription?.sdp ?? offer.sdp,\n });\n } catch {\n 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 const removePeer = (peerId: string): void => {\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 (links.has(peerId)) return;\n\n const pc = createPeerConnection(iceServers);\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 link.transceivers = slots.map((slot) =>\n pc.addTransceiver(slot.kind, { direction: \"sendrecv\" }),\n );\n await attachLocalTracks(link, slots, localTracks);\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 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) return;\n\n await link.pc.setRemoteDescription({ type: \"offer\", sdp });\n link.remoteReady = true;\n await drainCandidates(link);\n\n if (!link.isOfferer) {\n adoptTransceivers(link);\n await attachLocalTracks(link, slots, localTracks);\n }\n\n const answer = await link.pc.createAnswer();\n if (!answer.sdp) {\n options.onNotice?.(\"empty_local_answer_sdp\");\n return;\n }\n await describe(link.pc, answer);\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 (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 await link.pc.setRemoteDescription({ type: \"answer\", sdp: message.sdp });\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 const setLocalTrack = async (slot: string, track: MediaStreamTrack | null): Promise<void> => {\n localTracks.set(slot, track);\n const index = slots.findIndex((entry) => entry.name === slot);\n if (index < 0) return;\n\n for (const link of links.values()) {\n const transceiver = link.transceivers[index];\n if (!transceiver) continue;\n try {\n await transceiver.sender.replaceTrack(track);\n } catch {\n /* the link is tearing down; its state handler will remove it */\n }\n }\n await applyQuality(quality);\n };\n\n const stop = (): void => {\n for (const link of links.values()) link.pc.close();\n links.clear();\n peers.clear();\n samplers.clear();\n stopped = true;\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":[],"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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"}