From e2ea32c0f3cd973679ed7e979d582d30e2fabc5a Mon Sep 17 00:00:00 2001 From: golgetahir Date: Tue, 12 Aug 2025 13:21:32 +0300 Subject: [PATCH 01/31] Switch to event handler's and implement a common join --- src/main/js/webrtc_adaptor.js | 159 ++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/src/main/js/webrtc_adaptor.js b/src/main/js/webrtc_adaptor.js index 99465196..afa1cc07 100644 --- a/src/main/js/webrtc_adaptor.js +++ b/src/main/js/webrtc_adaptor.js @@ -398,6 +398,11 @@ export class WebRTCAdaptor { }, }); + // New additive fields for promise-based readiness and event helpers + this._readyPromise = null; + this._readyResolve = null; + this._eventHandlers = new Map(); + //Initialize the local stream (if needed) and web socket connection if (this.initializeComponents) { this.initialize(); @@ -429,6 +434,28 @@ export class WebRTCAdaptor { this.errorEventListeners.push(errorListener); } + // Additive ergonomic event helpers (non-breaking) + on(eventName, handler) { + if (!this._eventHandlers.has(eventName)) { + this._eventHandlers.set(eventName, new Set()); + } + this._eventHandlers.get(eventName).add(handler); + } + + off(eventName, handler) { + if (this._eventHandlers.has(eventName)) { + this._eventHandlers.get(eventName).delete(handler); + } + } + + once(eventName, handler) { + const wrapper = (payload) => { + this.off(eventName, wrapper); + try { handler(payload); } catch(e) { Logger.warn(e); } + }; + this.on(eventName, wrapper); + } + /** * Notify event listeners and callback method * @param {*} info @@ -441,6 +468,13 @@ export class WebRTCAdaptor { if (this.callback != null) { this.callback(info, obj); } + // Dispatch to additive event handlers specific to the event name + const handlers = this._eventHandlers && typeof this._eventHandlers.get === "function" ? this._eventHandlers.get(info) : null; + if (handlers) { + handlers.forEach((fn) => { + try { fn(obj); } catch(e) { Logger.warn(e); } + }); + } } /** @@ -455,6 +489,19 @@ export class WebRTCAdaptor { if (this.callbackError != null) { this.callbackError(error, message); } + // Also emit a generic 'error' event and a specific error-code event + const genericHandlers = this._eventHandlers && typeof this._eventHandlers.get === "function" ? this._eventHandlers.get("error") : null; + if (genericHandlers) { + genericHandlers.forEach((fn) => { + try { fn({ error: error, message: message }); } catch(e) { Logger.warn(e); } + }); + } + const specificHandlers = this._eventHandlers && typeof this._eventHandlers.get === "function" ? this._eventHandlers.get(error) : null; + if (specificHandlers) { + specificHandlers.forEach((fn) => { + try { fn(message); } catch(e) { Logger.warn(e); } + }); + } } @@ -485,6 +532,20 @@ export class WebRTCAdaptor { }); } + // Additive readiness promise (non-breaking) + ready() { + if (this.webSocketAdaptor && this.webSocketAdaptor.isConnected()) { + return Promise.resolve(); + } + if (this._readyPromise) { + return this._readyPromise; + } + this._readyPromise = new Promise((resolve) => { + this._readyResolve = resolve; + }); + return this._readyPromise; + } + /** * Called to get the ICE server configuration from the server * if user hasn't provided any ICE servers in initialization @@ -1896,6 +1957,16 @@ export class WebRTCAdaptor { this.reconnectIfRequired(0, true); } + // Resolve readiness on initialized + if (info == "initialized") { + if (this._readyResolve) { + const resolveFn = this._readyResolve; + this._readyResolve = null; + this._readyPromise = null; + try { resolveFn(); } catch(e) { Logger.warn(e); } + } + } + this.notifyEventListeners(info, obj); } @@ -2458,6 +2529,94 @@ export class WebRTCAdaptor { closeStream() { return this.mediaManager.closeStream(); }; + + // High-level join helper (non-breaking, optional to use) + join(options) { + const params = options || {}; + const desiredRole = (params.role || "viewer").toLowerCase(); + const streamId = params.streamId; + const timeoutMs = typeof params.timeoutMs !== 'undefined' && params.timeoutMs != null ? params.timeoutMs : 15000; + if (!streamId) { + return Promise.reject(new Error("join_requires_streamId")); + } + + return this.ready().then(() => { + return new Promise((resolve, reject) => { + let timeoutId = -1; + + const complete = (state) => { + cleanup(); + resolve({ streamId: streamId, state: state }); + }; + const fail = (err, message) => { + cleanup(); + reject(typeof err === 'string' ? new Error(err) : err || new Error("join_failed")); + }; + + const onIce = (obj) => { + if (obj && obj.streamId === streamId && (obj.state === "connected" || obj.state === "completed")) { + complete(obj.state); + } + }; + const onTrack = (obj) => { + if (obj && obj.streamId === streamId) { + complete("track_added"); + } + }; + const onErr = (payload) => { + if (!payload) { + return; + } + const errStreamId = payload.streamId || (payload.message && payload.message.streamId) || null; + if (errStreamId == null || errStreamId === streamId) { + fail(payload.error || "error", payload.message); + } + }; + + this.on("ice_connection_state_changed", onIce); + this.on("newTrackAvailable", onTrack); + this.on("newStreamAvailable", onTrack); + this.on("error", onErr); + + const cleanup = () => { + clearTimeout(timeoutId); + this.off("ice_connection_state_changed", onIce); + this.off("newTrackAvailable", onTrack); + this.off("newStreamAvailable", onTrack); + this.off("error", onErr); + }; + + timeoutId = setTimeout(() => { + fail("join_timeout"); + }, timeoutMs); + + if (desiredRole === "publisher" || desiredRole === "publish") { + this.publish(streamId, + params.token, + params.subscriberId, + params.subscriberCode, + params.streamName, + params.mainTrack, + params.metaData, + params.roleHint); + } else { + const playParams = { + streamId: streamId, + token: params.token, + roomId: params.roomId, + enableTracks: params.enableTracks, + subscriberId: params.subscriberId, + subscriberName: params.subscriberName, + subscriberCode: params.subscriberCode, + metaData: params.metaData, + role: params.roleHint, + disableTracksByDefault: params.disableTracksByDefault + }; + this.playStream(playParams); + } + }); + }); + } } From da731cc097966375946442af9987604312bd1c4a Mon Sep 17 00:00:00 2001 From: golgetahir Date: Mon, 18 Aug 2025 13:57:04 +0300 Subject: [PATCH 02/31] Implement SDK v2 first commit --- .gitignore | 1 + packages/webrtc-sdk/.prettierrc.json | 11 + packages/webrtc-sdk/README.md | 99 + packages/webrtc-sdk/eslint.config.js | 64 + packages/webrtc-sdk/examples/play.html | 145 + packages/webrtc-sdk/examples/publish.html | 192 + packages/webrtc-sdk/examples/room.html | 156 + packages/webrtc-sdk/package-lock.json | 5565 +++++++++++++++++ packages/webrtc-sdk/package.json | 37 + packages/webrtc-sdk/src/core/emitter.ts | 44 + packages/webrtc-sdk/src/core/errors.ts | 30 + packages/webrtc-sdk/src/core/events.ts | 40 + packages/webrtc-sdk/src/core/media-manager.ts | 199 + packages/webrtc-sdk/src/core/peer-stats.ts | 33 + packages/webrtc-sdk/src/core/types.ts | 105 + .../webrtc-sdk/src/core/webrtc-adaptor.ts | 1054 ++++ .../webrtc-sdk/src/core/websocket-adaptor.ts | 142 + packages/webrtc-sdk/src/index.ts | 9 + packages/webrtc-sdk/src/utils/logger.ts | 46 + packages/webrtc-sdk/src/utils/utility.ts | 19 + .../webrtc-sdk/test/candidate-queue.test.ts | 50 + packages/webrtc-sdk/test/data-channel.test.ts | 131 + packages/webrtc-sdk/test/emitter.test.ts | 37 + packages/webrtc-sdk/test/errors.test.ts | 49 + .../webrtc-sdk/test/notifications.test.ts | 65 + packages/webrtc-sdk/test/ping.test.ts | 23 + packages/webrtc-sdk/test/reconnect.test.ts | 73 + .../test/stats-disable-close.test.ts | 40 + packages/webrtc-sdk/test/stats-parity.test.ts | 43 + packages/webrtc-sdk/test/utility.test.ts | 19 + .../test/webrtc-adaptor.play.test.ts | 48 + .../test/webrtc-adaptor.publish.test.ts | 75 + packages/webrtc-sdk/tsconfig.json | 20 + packages/webrtc-sdk/typedoc.json | 14 + src/main/webapp/index-new.html | 164 + .../webapp/samples/publish_webrtc_new.html | 796 +++ 36 files changed, 9638 insertions(+) create mode 100644 packages/webrtc-sdk/.prettierrc.json create mode 100644 packages/webrtc-sdk/README.md create mode 100644 packages/webrtc-sdk/eslint.config.js create mode 100644 packages/webrtc-sdk/examples/play.html create mode 100644 packages/webrtc-sdk/examples/publish.html create mode 100644 packages/webrtc-sdk/examples/room.html create mode 100644 packages/webrtc-sdk/package-lock.json create mode 100644 packages/webrtc-sdk/package.json create mode 100644 packages/webrtc-sdk/src/core/emitter.ts create mode 100644 packages/webrtc-sdk/src/core/errors.ts create mode 100644 packages/webrtc-sdk/src/core/events.ts create mode 100644 packages/webrtc-sdk/src/core/media-manager.ts create mode 100644 packages/webrtc-sdk/src/core/peer-stats.ts create mode 100644 packages/webrtc-sdk/src/core/types.ts create mode 100644 packages/webrtc-sdk/src/core/webrtc-adaptor.ts create mode 100644 packages/webrtc-sdk/src/core/websocket-adaptor.ts create mode 100644 packages/webrtc-sdk/src/index.ts create mode 100644 packages/webrtc-sdk/src/utils/logger.ts create mode 100644 packages/webrtc-sdk/src/utils/utility.ts create mode 100644 packages/webrtc-sdk/test/candidate-queue.test.ts create mode 100644 packages/webrtc-sdk/test/data-channel.test.ts create mode 100644 packages/webrtc-sdk/test/emitter.test.ts create mode 100644 packages/webrtc-sdk/test/errors.test.ts create mode 100644 packages/webrtc-sdk/test/notifications.test.ts create mode 100644 packages/webrtc-sdk/test/ping.test.ts create mode 100644 packages/webrtc-sdk/test/reconnect.test.ts create mode 100644 packages/webrtc-sdk/test/stats-disable-close.test.ts create mode 100644 packages/webrtc-sdk/test/stats-parity.test.ts create mode 100644 packages/webrtc-sdk/test/utility.test.ts create mode 100644 packages/webrtc-sdk/test/webrtc-adaptor.play.test.ts create mode 100644 packages/webrtc-sdk/test/webrtc-adaptor.publish.test.ts create mode 100644 packages/webrtc-sdk/tsconfig.json create mode 100644 packages/webrtc-sdk/typedoc.json create mode 100644 src/main/webapp/index-new.html create mode 100644 src/main/webapp/samples/publish_webrtc_new.html diff --git a/.gitignore b/.gitignore index 8b879778..4df83c37 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ node_modules /coverage/ /tsbuild /types +/.vscode \ No newline at end of file diff --git a/packages/webrtc-sdk/.prettierrc.json b/packages/webrtc-sdk/.prettierrc.json new file mode 100644 index 00000000..3ed9654e --- /dev/null +++ b/packages/webrtc-sdk/.prettierrc.json @@ -0,0 +1,11 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": false, + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "arrowParens": "avoid", + "endOfLine": "lf" +} \ No newline at end of file diff --git a/packages/webrtc-sdk/README.md b/packages/webrtc-sdk/README.md new file mode 100644 index 00000000..785fa83a --- /dev/null +++ b/packages/webrtc-sdk/README.md @@ -0,0 +1,99 @@ +# WebRTC SDK v2 (TypeScript) + +Modern, strictly-typed client SDK for Ant Media Server. + +## Install + +This package is currently private for development. Build locally: + +```bash +npm install +npm run build +``` + +## Usage + +```ts +import { WebRTCAdaptor, getWebSocketURL } from 'webrtc-sdk'; + +const adaptor = new WebRTCAdaptor({ + websocketURL: getWebSocketURL('wss://example.com:5443/LiveApp/websocket'), + localVideo: document.getElementById('local') as HTMLVideoElement, + remoteVideo: document.getElementById('remote') as HTMLVideoElement, + mediaConstraints: { audio: true, video: true }, +}); + +await adaptor.ready(); +await adaptor.join({ role: 'publisher', streamId: 'stream1' }); + +adaptor.on('play_started', ({ streamId }) => console.log('playing', streamId)); +``` + +### Events quick reference + +Common events emitted by the SDK (see TypeDoc for full list): + +- `initialized`: signaling is ready +- `publish_started` / `publish_finished` +- `play_started` / `play_finished` +- `newTrackAvailable` { stream, track, streamId } +- `ice_connection_state_changed` { state, streamId } +- `data_channel_opened` / `data_channel_closed` +- `data_received` { streamId, data: string | ArrayBuffer } +- `updated_stats` PeerStats +- `devices_updated` GroupedDevices +- `error` { error, message? } + +### Stats helpers + +```ts +// One-off snapshot and event +const stats = await adaptor.getStats('s1'); +adaptor.on('updated_stats', (ps) => console.log(ps)); + +// Poll every 2s +adaptor.enableStats('s1', 2000); +``` + +## Documentation + +Generated API docs are available in the `docs/` folder. To regenerate: + +```bash +npm run docs +``` + +Open `docs/index.html` in a browser. + +### Room / Multitrack quick start + +```ts +// Join a room +await adaptor.joinRoom({ roomId: 'my-room', streamId: 'publisher1' }); + +// Selectively play only some subtracks of a main stream +await adaptor.playSelective({ + streamId: 'mainStreamId', + enableTracks: ['camera_user1', 'screen_user2'], + disableTracksByDefault: true, +}); + +// Enable/disable a specific subtrack +adaptor.enableTrack('mainStreamId', 'camera_user3', true); + +// Force quality (ABR) +adaptor.forceStreamQuality('mainStreamId', 720); // or 'auto' +``` + +## Examples + +- `examples/publish.html` +- `examples/play.html` +- `examples/room.html` (rooms/multitrack, enable/disable subtracks, force quality, selective play) + +## Development + +- Lint: `npm run lint` +- Tests: `npm test` +- Docs: `npm run docs` + diff --git a/packages/webrtc-sdk/eslint.config.js b/packages/webrtc-sdk/eslint.config.js new file mode 100644 index 00000000..86cba8fb --- /dev/null +++ b/packages/webrtc-sdk/eslint.config.js @@ -0,0 +1,64 @@ +// ESLint flat config +// Requires ESLint v9+ +import js from '@eslint/js'; +import ts from 'typescript-eslint'; +import eslintPluginImport from 'eslint-plugin-import'; +import eslintPluginPromise from 'eslint-plugin-promise'; +import eslintPluginPrettier from 'eslint-plugin-prettier'; +import eslintConfigPrettier from 'eslint-config-prettier'; +import eslintPluginUnusedImports from 'eslint-plugin-unused-imports'; + +export default [ + js.configs.recommended, + ...ts.configs.recommended, + eslintConfigPrettier, + { + name: 'webrtc-sdk/ts', + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + parserOptions: { + project: ['./tsconfig.json'], + }, + }, + plugins: { + import: eslintPluginImport, + promise: eslintPluginPromise, + prettier: eslintPluginPrettier, + 'unused-imports': eslintPluginUnusedImports, + }, + rules: { + 'prettier/prettier': 'error', + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/explicit-module-boundary-types': 'error', + '@typescript-eslint/consistent-type-definitions': ['error', 'interface'], + 'import/no-unresolved': 'off', + 'promise/no-nesting': 'off', + 'import/order': [ + 'error', + { + groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'], + 'newlines-between': 'always' + } + ], + 'unused-imports/no-unused-imports': 'error', + 'unused-imports/no-unused-vars': [ + 'warn', + { + vars: 'all', + varsIgnorePattern: '^_', + args: 'after-used', + argsIgnorePattern: '^_' + } + ] + }, + + linterOptions: { + reportUnusedDisableDirectives: true, + }, + }, + { + ignores: ['dist', 'node_modules', 'examples', 'test'] + } +]; \ No newline at end of file diff --git a/packages/webrtc-sdk/examples/play.html b/packages/webrtc-sdk/examples/play.html new file mode 100644 index 00000000..a2654b08 --- /dev/null +++ b/packages/webrtc-sdk/examples/play.html @@ -0,0 +1,145 @@ + + + + + + webrtc-sdk play sample + + + +

Play Sample (TS v2)

+
+ + +
+
+ + + + +
+
+
+ Audio Output + + +
+
+
+
+ Data Channel + + + + +
+
+
+
+ Stats + +
+
+
+

+
+    
+  
+
diff --git a/packages/webrtc-sdk/examples/publish.html b/packages/webrtc-sdk/examples/publish.html
new file mode 100644
index 00000000..f8d003b8
--- /dev/null
+++ b/packages/webrtc-sdk/examples/publish.html
@@ -0,0 +1,192 @@
+
+
+  
+    
+    
+    webrtc-sdk publish sample
+    
+  
+  
+    

Publish Sample (TS v2)

+
+
+ + +
+
+
+ + + + +
+
+ + + + + +
+
+
+ Controls + + +
+
+
+
+ Data Channel + + + +
+
+
+
+ Screen Share + + +
+
+
+
+ Stats + +
+
+
+

+
+    
+  
+
diff --git a/packages/webrtc-sdk/examples/room.html b/packages/webrtc-sdk/examples/room.html
new file mode 100644
index 00000000..27fc01db
--- /dev/null
+++ b/packages/webrtc-sdk/examples/room.html
@@ -0,0 +1,156 @@
+
+
+  
+    
+    
+    webrtc-sdk room/multitrack sample
+    
+  
+  
+    

Room/Multitrack Sample (TS v2)

+
+
+ + +
+
+
+ + + + +
+ +
+ Controls +
+ + + + +
+ + + + + + +
+ + + +
+
+ +
+

+
+    
+  
+  
+
+
diff --git a/packages/webrtc-sdk/package-lock.json b/packages/webrtc-sdk/package-lock.json
new file mode 100644
index 00000000..da3376a0
--- /dev/null
+++ b/packages/webrtc-sdk/package-lock.json
@@ -0,0 +1,5565 @@
+{
+  "name": "webrtc-sdk",
+  "version": "2.0.0-beta.1",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "webrtc-sdk",
+      "version": "2.0.0-beta.1",
+      "dependencies": {
+        "prettier": "^3.6.2"
+      },
+      "devDependencies": {
+        "@eslint/js": "^9.12.0",
+        "@types/node": "^22.5.4",
+        "eslint": "^9.33.0",
+        "eslint-config-prettier": "^9.1.2",
+        "eslint-plugin-import": "^2.29.1",
+        "eslint-plugin-prettier": "^5.5.4",
+        "eslint-plugin-promise": "^7.1.0",
+        "eslint-plugin-unused-imports": "^4.1.4",
+        "rimraf": "^5.0.5",
+        "typedoc": "^0.25.13",
+        "typescript": "^5.4.5",
+        "typescript-eslint": "^8.10.0",
+        "vitest": "^2.1.9"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+      "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+      "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+      "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+      "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+      "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+      "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+      "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+      "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+      "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+      "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+      "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+      "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+      "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+      "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+      "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+      "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+      "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+      "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+      "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@eslint-community/eslint-utils": {
+      "version": "4.7.0",
+      "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz",
+      "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "eslint-visitor-keys": "^3.4.3"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+      }
+    },
+    "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+      "version": "3.4.3",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+      "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/@eslint-community/regexpp": {
+      "version": "4.12.1",
+      "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz",
+      "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+      }
+    },
+    "node_modules/@eslint/config-array": {
+      "version": "0.21.0",
+      "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz",
+      "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@eslint/object-schema": "^2.1.6",
+        "debug": "^4.3.1",
+        "minimatch": "^3.1.2"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@eslint/config-array/node_modules/brace-expansion": {
+      "version": "1.1.12",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+      "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/@eslint/config-array/node_modules/minimatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/@eslint/config-helpers": {
+      "version": "0.3.1",
+      "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz",
+      "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@eslint/core": {
+      "version": "0.15.2",
+      "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz",
+      "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@types/json-schema": "^7.0.15"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@eslint/eslintrc": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
+      "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ajv": "^6.12.4",
+        "debug": "^4.3.2",
+        "espree": "^10.0.1",
+        "globals": "^14.0.0",
+        "ignore": "^5.2.0",
+        "import-fresh": "^3.2.1",
+        "js-yaml": "^4.1.0",
+        "minimatch": "^3.1.2",
+        "strip-json-comments": "^3.1.1"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
+      "version": "1.1.12",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+      "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/@eslint/eslintrc/node_modules/minimatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/@eslint/js": {
+      "version": "9.33.0",
+      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.33.0.tgz",
+      "integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://eslint.org/donate"
+      }
+    },
+    "node_modules/@eslint/object-schema": {
+      "version": "2.1.6",
+      "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz",
+      "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@eslint/plugin-kit": {
+      "version": "0.3.5",
+      "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz",
+      "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@eslint/core": "^0.15.2",
+        "levn": "^0.4.1"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      }
+    },
+    "node_modules/@humanfs/core": {
+      "version": "0.19.1",
+      "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
+      "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=18.18.0"
+      }
+    },
+    "node_modules/@humanfs/node": {
+      "version": "0.16.6",
+      "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz",
+      "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@humanfs/core": "^0.19.1",
+        "@humanwhocodes/retry": "^0.3.0"
+      },
+      "engines": {
+        "node": ">=18.18.0"
+      }
+    },
+    "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": {
+      "version": "0.3.1",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz",
+      "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=18.18"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/nzakas"
+      }
+    },
+    "node_modules/@humanwhocodes/module-importer": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+      "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=12.22"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/nzakas"
+      }
+    },
+    "node_modules/@humanwhocodes/retry": {
+      "version": "0.4.3",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+      "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=18.18"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/nzakas"
+      }
+    },
+    "node_modules/@isaacs/cliui": {
+      "version": "8.0.2",
+      "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+      "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "string-width": "^5.1.2",
+        "string-width-cjs": "npm:string-width@^4.2.0",
+        "strip-ansi": "^7.0.1",
+        "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+        "wrap-ansi": "^8.1.0",
+        "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@nodelib/fs.scandir": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+      "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@nodelib/fs.stat": "2.0.5",
+        "run-parallel": "^1.1.9"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.stat": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+      "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.walk": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+      "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@nodelib/fs.scandir": "2.1.5",
+        "fastq": "^1.6.0"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@pkgjs/parseargs": {
+      "version": "0.11.0",
+      "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+      "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/@pkgr/core": {
+      "version": "0.2.9",
+      "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
+      "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^12.20.0 || ^14.18.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/pkgr"
+      }
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.46.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.2.tgz",
+      "integrity": "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.46.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.2.tgz",
+      "integrity": "sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.46.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.2.tgz",
+      "integrity": "sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.46.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.2.tgz",
+      "integrity": "sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.46.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.2.tgz",
+      "integrity": "sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.46.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.2.tgz",
+      "integrity": "sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.46.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.2.tgz",
+      "integrity": "sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.46.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.2.tgz",
+      "integrity": "sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.46.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.2.tgz",
+      "integrity": "sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.46.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.2.tgz",
+      "integrity": "sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
+      "version": "4.46.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.2.tgz",
+      "integrity": "sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.46.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.2.tgz",
+      "integrity": "sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.46.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.2.tgz",
+      "integrity": "sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.46.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.2.tgz",
+      "integrity": "sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.46.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.2.tgz",
+      "integrity": "sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.46.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.2.tgz",
+      "integrity": "sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.46.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.2.tgz",
+      "integrity": "sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.46.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.2.tgz",
+      "integrity": "sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.46.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.2.tgz",
+      "integrity": "sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.46.2",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.2.tgz",
+      "integrity": "sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rtsao/scc": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
+      "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/json-schema": {
+      "version": "7.0.15",
+      "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+      "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/json5": {
+      "version": "0.0.29",
+      "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
+      "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/node": {
+      "version": "22.17.1",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.1.tgz",
+      "integrity": "sha512-y3tBaz+rjspDTylNjAX37jEC3TETEFGNJL6uQDxwF9/8GLLIjW1rvVHlynyuUKMnMr1Roq8jOv3vkopBjC4/VA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/@typescript-eslint/eslint-plugin": {
+      "version": "8.39.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.1.tgz",
+      "integrity": "sha512-yYegZ5n3Yr6eOcqgj2nJH8cH/ZZgF+l0YIdKILSDjYFRjgYQMgv/lRjV5Z7Up04b9VYUondt8EPMqg7kTWgJ2g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@eslint-community/regexpp": "^4.10.0",
+        "@typescript-eslint/scope-manager": "8.39.1",
+        "@typescript-eslint/type-utils": "8.39.1",
+        "@typescript-eslint/utils": "8.39.1",
+        "@typescript-eslint/visitor-keys": "8.39.1",
+        "graphemer": "^1.4.0",
+        "ignore": "^7.0.0",
+        "natural-compare": "^1.4.0",
+        "ts-api-utils": "^2.1.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "@typescript-eslint/parser": "^8.39.1",
+        "eslint": "^8.57.0 || ^9.0.0",
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+      "version": "7.0.5",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+      "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 4"
+      }
+    },
+    "node_modules/@typescript-eslint/parser": {
+      "version": "8.39.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.39.1.tgz",
+      "integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/scope-manager": "8.39.1",
+        "@typescript-eslint/types": "8.39.1",
+        "@typescript-eslint/typescript-estree": "8.39.1",
+        "@typescript-eslint/visitor-keys": "8.39.1",
+        "debug": "^4.3.4"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^8.57.0 || ^9.0.0",
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/project-service": {
+      "version": "8.39.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.1.tgz",
+      "integrity": "sha512-8fZxek3ONTwBu9ptw5nCKqZOSkXshZB7uAxuFF0J/wTMkKydjXCzqqga7MlFMpHi9DoG4BadhmTkITBcg8Aybw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/tsconfig-utils": "^8.39.1",
+        "@typescript-eslint/types": "^8.39.1",
+        "debug": "^4.3.4"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/scope-manager": {
+      "version": "8.39.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.39.1.tgz",
+      "integrity": "sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/types": "8.39.1",
+        "@typescript-eslint/visitor-keys": "8.39.1"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@typescript-eslint/tsconfig-utils": {
+      "version": "8.39.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.1.tgz",
+      "integrity": "sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/type-utils": {
+      "version": "8.39.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.39.1.tgz",
+      "integrity": "sha512-gu9/ahyatyAdQbKeHnhT4R+y3YLtqqHyvkfDxaBYk97EcbfChSJXyaJnIL3ygUv7OuZatePHmQvuH5ru0lnVeA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/types": "8.39.1",
+        "@typescript-eslint/typescript-estree": "8.39.1",
+        "@typescript-eslint/utils": "8.39.1",
+        "debug": "^4.3.4",
+        "ts-api-utils": "^2.1.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^8.57.0 || ^9.0.0",
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/types": {
+      "version": "8.39.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.1.tgz",
+      "integrity": "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@typescript-eslint/typescript-estree": {
+      "version": "8.39.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.1.tgz",
+      "integrity": "sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/project-service": "8.39.1",
+        "@typescript-eslint/tsconfig-utils": "8.39.1",
+        "@typescript-eslint/types": "8.39.1",
+        "@typescript-eslint/visitor-keys": "8.39.1",
+        "debug": "^4.3.4",
+        "fast-glob": "^3.3.2",
+        "is-glob": "^4.0.3",
+        "minimatch": "^9.0.4",
+        "semver": "^7.6.0",
+        "ts-api-utils": "^2.1.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+      "version": "7.7.2",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
+      "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/@typescript-eslint/utils": {
+      "version": "8.39.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.39.1.tgz",
+      "integrity": "sha512-VF5tZ2XnUSTuiqZFXCZfZs1cgkdd3O/sSYmdo2EpSyDlC86UM/8YytTmKnehOW3TGAlivqTDT6bS87B/GQ/jyg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.7.0",
+        "@typescript-eslint/scope-manager": "8.39.1",
+        "@typescript-eslint/types": "8.39.1",
+        "@typescript-eslint/typescript-estree": "8.39.1"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^8.57.0 || ^9.0.0",
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/visitor-keys": {
+      "version": "8.39.1",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.1.tgz",
+      "integrity": "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/types": "8.39.1",
+        "eslint-visitor-keys": "^4.2.1"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@vitest/expect": {
+      "version": "2.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
+      "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/spy": "2.1.9",
+        "@vitest/utils": "2.1.9",
+        "chai": "^5.1.2",
+        "tinyrainbow": "^1.2.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/mocker": {
+      "version": "2.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
+      "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/spy": "2.1.9",
+        "estree-walker": "^3.0.3",
+        "magic-string": "^0.30.12"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "msw": "^2.4.9",
+        "vite": "^5.0.0"
+      },
+      "peerDependenciesMeta": {
+        "msw": {
+          "optional": true
+        },
+        "vite": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vitest/pretty-format": {
+      "version": "2.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
+      "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tinyrainbow": "^1.2.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/runner": {
+      "version": "2.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
+      "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/utils": "2.1.9",
+        "pathe": "^1.1.2"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/snapshot": {
+      "version": "2.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
+      "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "2.1.9",
+        "magic-string": "^0.30.12",
+        "pathe": "^1.1.2"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/spy": {
+      "version": "2.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
+      "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tinyspy": "^3.0.2"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/utils": {
+      "version": "2.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
+      "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "2.1.9",
+        "loupe": "^3.1.2",
+        "tinyrainbow": "^1.2.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/acorn": {
+      "version": "8.15.0",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+      "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/acorn-jsx": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+      "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/ajv": {
+      "version": "6.12.6",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fast-deep-equal": "^3.1.1",
+        "fast-json-stable-stringify": "^2.0.0",
+        "json-schema-traverse": "^0.4.1",
+        "uri-js": "^4.2.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/ansi-regex": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
+      "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+      }
+    },
+    "node_modules/ansi-sequence-parser": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/ansi-sequence-parser/-/ansi-sequence-parser-1.1.3.tgz",
+      "integrity": "sha512-+fksAx9eG3Ab6LDnLs3ZqZa8KVJ/jYnX+D4Qe1azX+LFGFAXqynCQLOdLpNYN/l9e7l6hMWwZbrnctqr6eSQSw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/ansi-styles": {
+      "version": "6.2.1",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
+      "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+      "dev": true,
+      "license": "Python-2.0"
+    },
+    "node_modules/array-buffer-byte-length": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
+      "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "is-array-buffer": "^3.0.5"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/array-includes": {
+      "version": "3.1.9",
+      "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
+      "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.4",
+        "define-properties": "^1.2.1",
+        "es-abstract": "^1.24.0",
+        "es-object-atoms": "^1.1.1",
+        "get-intrinsic": "^1.3.0",
+        "is-string": "^1.1.1",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/array.prototype.findlastindex": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz",
+      "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.4",
+        "define-properties": "^1.2.1",
+        "es-abstract": "^1.23.9",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "es-shim-unscopables": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/array.prototype.flat": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz",
+      "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "define-properties": "^1.2.1",
+        "es-abstract": "^1.23.5",
+        "es-shim-unscopables": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/array.prototype.flatmap": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz",
+      "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "define-properties": "^1.2.1",
+        "es-abstract": "^1.23.5",
+        "es-shim-unscopables": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/arraybuffer.prototype.slice": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz",
+      "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "array-buffer-byte-length": "^1.0.1",
+        "call-bind": "^1.0.8",
+        "define-properties": "^1.2.1",
+        "es-abstract": "^1.23.5",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "is-array-buffer": "^3.0.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/assertion-error": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+      "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/async-function": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
+      "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/available-typed-arrays": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
+      "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "possible-typed-array-names": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/brace-expansion": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+      "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/braces": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+      "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fill-range": "^7.1.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/cac": {
+      "version": "6.7.14",
+      "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+      "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/call-bind": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
+      "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.0",
+        "es-define-property": "^1.0.0",
+        "get-intrinsic": "^1.2.4",
+        "set-function-length": "^1.2.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/callsites": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/chai": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.1.tgz",
+      "integrity": "sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "assertion-error": "^2.0.1",
+        "check-error": "^2.1.1",
+        "deep-eql": "^5.0.1",
+        "loupe": "^3.1.0",
+        "pathval": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/chalk": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/chalk?sponsor=1"
+      }
+    },
+    "node_modules/chalk/node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/check-error": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
+      "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 16"
+      }
+    },
+    "node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/cross-spawn": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/data-view-buffer": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
+      "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "es-errors": "^1.3.0",
+        "is-data-view": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/data-view-byte-length": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz",
+      "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "es-errors": "^1.3.0",
+        "is-data-view": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/inspect-js"
+      }
+    },
+    "node_modules/data-view-byte-offset": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz",
+      "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "is-data-view": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
+      "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/deep-eql": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+      "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/deep-is": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+      "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/define-data-property": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+      "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-define-property": "^1.0.0",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/define-properties": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+      "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "define-data-property": "^1.0.1",
+        "has-property-descriptors": "^1.0.0",
+        "object-keys": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/doctrine": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
+      "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "esutils": "^2.0.2"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/eastasianwidth": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+      "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/emoji-regex": {
+      "version": "9.2.2",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+      "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/es-abstract": {
+      "version": "1.24.0",
+      "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz",
+      "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "array-buffer-byte-length": "^1.0.2",
+        "arraybuffer.prototype.slice": "^1.0.4",
+        "available-typed-arrays": "^1.0.7",
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.4",
+        "data-view-buffer": "^1.0.2",
+        "data-view-byte-length": "^1.0.2",
+        "data-view-byte-offset": "^1.0.1",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "es-set-tostringtag": "^2.1.0",
+        "es-to-primitive": "^1.3.0",
+        "function.prototype.name": "^1.1.8",
+        "get-intrinsic": "^1.3.0",
+        "get-proto": "^1.0.1",
+        "get-symbol-description": "^1.1.0",
+        "globalthis": "^1.0.4",
+        "gopd": "^1.2.0",
+        "has-property-descriptors": "^1.0.2",
+        "has-proto": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "internal-slot": "^1.1.0",
+        "is-array-buffer": "^3.0.5",
+        "is-callable": "^1.2.7",
+        "is-data-view": "^1.0.2",
+        "is-negative-zero": "^2.0.3",
+        "is-regex": "^1.2.1",
+        "is-set": "^2.0.3",
+        "is-shared-array-buffer": "^1.0.4",
+        "is-string": "^1.1.1",
+        "is-typed-array": "^1.1.15",
+        "is-weakref": "^1.1.1",
+        "math-intrinsics": "^1.1.0",
+        "object-inspect": "^1.13.4",
+        "object-keys": "^1.1.1",
+        "object.assign": "^4.1.7",
+        "own-keys": "^1.0.1",
+        "regexp.prototype.flags": "^1.5.4",
+        "safe-array-concat": "^1.1.3",
+        "safe-push-apply": "^1.0.0",
+        "safe-regex-test": "^1.1.0",
+        "set-proto": "^1.0.0",
+        "stop-iteration-iterator": "^1.1.0",
+        "string.prototype.trim": "^1.2.10",
+        "string.prototype.trimend": "^1.0.9",
+        "string.prototype.trimstart": "^1.0.8",
+        "typed-array-buffer": "^1.0.3",
+        "typed-array-byte-length": "^1.0.3",
+        "typed-array-byte-offset": "^1.0.4",
+        "typed-array-length": "^1.0.7",
+        "unbox-primitive": "^1.1.0",
+        "which-typed-array": "^1.1.19"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-module-lexer": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+      "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-shim-unscopables": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz",
+      "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-to-primitive": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz",
+      "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-callable": "^1.2.7",
+        "is-date-object": "^1.0.5",
+        "is-symbol": "^1.0.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+      "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.21.5",
+        "@esbuild/android-arm": "0.21.5",
+        "@esbuild/android-arm64": "0.21.5",
+        "@esbuild/android-x64": "0.21.5",
+        "@esbuild/darwin-arm64": "0.21.5",
+        "@esbuild/darwin-x64": "0.21.5",
+        "@esbuild/freebsd-arm64": "0.21.5",
+        "@esbuild/freebsd-x64": "0.21.5",
+        "@esbuild/linux-arm": "0.21.5",
+        "@esbuild/linux-arm64": "0.21.5",
+        "@esbuild/linux-ia32": "0.21.5",
+        "@esbuild/linux-loong64": "0.21.5",
+        "@esbuild/linux-mips64el": "0.21.5",
+        "@esbuild/linux-ppc64": "0.21.5",
+        "@esbuild/linux-riscv64": "0.21.5",
+        "@esbuild/linux-s390x": "0.21.5",
+        "@esbuild/linux-x64": "0.21.5",
+        "@esbuild/netbsd-x64": "0.21.5",
+        "@esbuild/openbsd-x64": "0.21.5",
+        "@esbuild/sunos-x64": "0.21.5",
+        "@esbuild/win32-arm64": "0.21.5",
+        "@esbuild/win32-ia32": "0.21.5",
+        "@esbuild/win32-x64": "0.21.5"
+      }
+    },
+    "node_modules/escape-string-regexp": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+      "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/eslint": {
+      "version": "9.33.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.33.0.tgz",
+      "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.2.0",
+        "@eslint-community/regexpp": "^4.12.1",
+        "@eslint/config-array": "^0.21.0",
+        "@eslint/config-helpers": "^0.3.1",
+        "@eslint/core": "^0.15.2",
+        "@eslint/eslintrc": "^3.3.1",
+        "@eslint/js": "9.33.0",
+        "@eslint/plugin-kit": "^0.3.5",
+        "@humanfs/node": "^0.16.6",
+        "@humanwhocodes/module-importer": "^1.0.1",
+        "@humanwhocodes/retry": "^0.4.2",
+        "@types/estree": "^1.0.6",
+        "@types/json-schema": "^7.0.15",
+        "ajv": "^6.12.4",
+        "chalk": "^4.0.0",
+        "cross-spawn": "^7.0.6",
+        "debug": "^4.3.2",
+        "escape-string-regexp": "^4.0.0",
+        "eslint-scope": "^8.4.0",
+        "eslint-visitor-keys": "^4.2.1",
+        "espree": "^10.4.0",
+        "esquery": "^1.5.0",
+        "esutils": "^2.0.2",
+        "fast-deep-equal": "^3.1.3",
+        "file-entry-cache": "^8.0.0",
+        "find-up": "^5.0.0",
+        "glob-parent": "^6.0.2",
+        "ignore": "^5.2.0",
+        "imurmurhash": "^0.1.4",
+        "is-glob": "^4.0.0",
+        "json-stable-stringify-without-jsonify": "^1.0.1",
+        "lodash.merge": "^4.6.2",
+        "minimatch": "^3.1.2",
+        "natural-compare": "^1.4.0",
+        "optionator": "^0.9.3"
+      },
+      "bin": {
+        "eslint": "bin/eslint.js"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://eslint.org/donate"
+      },
+      "peerDependencies": {
+        "jiti": "*"
+      },
+      "peerDependenciesMeta": {
+        "jiti": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/eslint-config-prettier": {
+      "version": "9.1.2",
+      "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz",
+      "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "eslint-config-prettier": "bin/cli.js"
+      },
+      "peerDependencies": {
+        "eslint": ">=7.0.0"
+      }
+    },
+    "node_modules/eslint-import-resolver-node": {
+      "version": "0.3.9",
+      "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
+      "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^3.2.7",
+        "is-core-module": "^2.13.0",
+        "resolve": "^1.22.4"
+      }
+    },
+    "node_modules/eslint-import-resolver-node/node_modules/debug": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+      "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.1"
+      }
+    },
+    "node_modules/eslint-module-utils": {
+      "version": "2.12.1",
+      "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz",
+      "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^3.2.7"
+      },
+      "engines": {
+        "node": ">=4"
+      },
+      "peerDependenciesMeta": {
+        "eslint": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/eslint-module-utils/node_modules/debug": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+      "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.1"
+      }
+    },
+    "node_modules/eslint-plugin-import": {
+      "version": "2.32.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
+      "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@rtsao/scc": "^1.1.0",
+        "array-includes": "^3.1.9",
+        "array.prototype.findlastindex": "^1.2.6",
+        "array.prototype.flat": "^1.3.3",
+        "array.prototype.flatmap": "^1.3.3",
+        "debug": "^3.2.7",
+        "doctrine": "^2.1.0",
+        "eslint-import-resolver-node": "^0.3.9",
+        "eslint-module-utils": "^2.12.1",
+        "hasown": "^2.0.2",
+        "is-core-module": "^2.16.1",
+        "is-glob": "^4.0.3",
+        "minimatch": "^3.1.2",
+        "object.fromentries": "^2.0.8",
+        "object.groupby": "^1.0.3",
+        "object.values": "^1.2.1",
+        "semver": "^6.3.1",
+        "string.prototype.trimend": "^1.0.9",
+        "tsconfig-paths": "^3.15.0"
+      },
+      "engines": {
+        "node": ">=4"
+      },
+      "peerDependencies": {
+        "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9"
+      }
+    },
+    "node_modules/eslint-plugin-import/node_modules/brace-expansion": {
+      "version": "1.1.12",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+      "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/eslint-plugin-import/node_modules/debug": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+      "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.1"
+      }
+    },
+    "node_modules/eslint-plugin-import/node_modules/minimatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/eslint-plugin-prettier": {
+      "version": "5.5.4",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz",
+      "integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "prettier-linter-helpers": "^1.0.0",
+        "synckit": "^0.11.7"
+      },
+      "engines": {
+        "node": "^14.18.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint-plugin-prettier"
+      },
+      "peerDependencies": {
+        "@types/eslint": ">=8.0.0",
+        "eslint": ">=8.0.0",
+        "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0",
+        "prettier": ">=3.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/eslint": {
+          "optional": true
+        },
+        "eslint-config-prettier": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/eslint-plugin-promise": {
+      "version": "7.2.1",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-7.2.1.tgz",
+      "integrity": "sha512-SWKjd+EuvWkYaS+uN2csvj0KoP43YTu7+phKQ5v+xw6+A0gutVX2yqCeCkC3uLCJFiPfR2dD8Es5L7yUsmvEaA==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.4.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0"
+      }
+    },
+    "node_modules/eslint-plugin-unused-imports": {
+      "version": "4.1.4",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.1.4.tgz",
+      "integrity": "sha512-YptD6IzQjDardkl0POxnnRBhU1OEePMV0nd6siHaRBbd+lyh6NAhFEobiznKU7kTsSsDeSD62Pe7kAM1b7dAZQ==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0",
+        "eslint": "^9.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@typescript-eslint/eslint-plugin": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/eslint-scope": {
+      "version": "8.4.0",
+      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+      "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "esrecurse": "^4.3.0",
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/eslint-visitor-keys": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+      "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/eslint/node_modules/brace-expansion": {
+      "version": "1.1.12",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+      "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/eslint/node_modules/minimatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/espree": {
+      "version": "10.4.0",
+      "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+      "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "acorn": "^8.15.0",
+        "acorn-jsx": "^5.3.2",
+        "eslint-visitor-keys": "^4.2.1"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/esquery": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
+      "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "estraverse": "^5.1.0"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/esrecurse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+      "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/estraverse": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+      "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0"
+      }
+    },
+    "node_modules/esutils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/expect-type": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz",
+      "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/fast-diff": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
+      "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
+      "dev": true,
+      "license": "Apache-2.0"
+    },
+    "node_modules/fast-glob": {
+      "version": "3.3.3",
+      "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+      "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@nodelib/fs.stat": "^2.0.2",
+        "@nodelib/fs.walk": "^1.2.3",
+        "glob-parent": "^5.1.2",
+        "merge2": "^1.3.0",
+        "micromatch": "^4.0.8"
+      },
+      "engines": {
+        "node": ">=8.6.0"
+      }
+    },
+    "node_modules/fast-glob/node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fast-json-stable-stringify": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/fast-levenshtein": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+      "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/fastq": {
+      "version": "1.19.1",
+      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
+      "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "reusify": "^1.0.4"
+      }
+    },
+    "node_modules/file-entry-cache": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+      "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "flat-cache": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=16.0.0"
+      }
+    },
+    "node_modules/fill-range": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+      "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "to-regex-range": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/find-up": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+      "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "locate-path": "^6.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/flat-cache": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+      "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "flatted": "^3.2.9",
+        "keyv": "^4.5.4"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/flatted": {
+      "version": "3.3.3",
+      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
+      "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/for-each": {
+      "version": "0.3.5",
+      "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
+      "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-callable": "^1.2.7"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/foreground-child": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+      "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "cross-spawn": "^7.0.6",
+        "signal-exit": "^4.0.1"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/function.prototype.name": {
+      "version": "1.1.8",
+      "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz",
+      "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.3",
+        "define-properties": "^1.2.1",
+        "functions-have-names": "^1.2.3",
+        "hasown": "^2.0.2",
+        "is-callable": "^1.2.7"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/functions-have-names": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+      "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/get-symbol-description": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
+      "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/glob": {
+      "version": "10.4.5",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
+      "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "foreground-child": "^3.1.0",
+        "jackspeak": "^3.1.2",
+        "minimatch": "^9.0.4",
+        "minipass": "^7.1.2",
+        "package-json-from-dist": "^1.0.0",
+        "path-scurry": "^1.11.1"
+      },
+      "bin": {
+        "glob": "dist/esm/bin.mjs"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/glob-parent": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+      "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "is-glob": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/globals": {
+      "version": "14.0.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+      "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/globalthis": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
+      "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "define-properties": "^1.2.1",
+        "gopd": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/graphemer": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
+      "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/has-bigints": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
+      "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/has-property-descriptors": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+      "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-define-property": "^1.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-proto": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz",
+      "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ignore": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+      "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 4"
+      }
+    },
+    "node_modules/import-fresh": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+      "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "parent-module": "^1.0.0",
+        "resolve-from": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/imurmurhash": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+      "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.8.19"
+      }
+    },
+    "node_modules/internal-slot": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
+      "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "hasown": "^2.0.2",
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/is-array-buffer": {
+      "version": "3.0.5",
+      "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
+      "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.3",
+        "get-intrinsic": "^1.2.6"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-async-function": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
+      "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "async-function": "^1.0.0",
+        "call-bound": "^1.0.3",
+        "get-proto": "^1.0.1",
+        "has-tostringtag": "^1.0.2",
+        "safe-regex-test": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-bigint": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz",
+      "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-bigints": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-boolean-object": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
+      "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "has-tostringtag": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-callable": {
+      "version": "1.2.7",
+      "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+      "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-core-module": {
+      "version": "2.16.1",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+      "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-data-view": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz",
+      "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "get-intrinsic": "^1.2.6",
+        "is-typed-array": "^1.1.13"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-date-object": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
+      "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "has-tostringtag": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-finalizationregistry": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz",
+      "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-generator-function": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz",
+      "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "get-proto": "^1.0.0",
+        "has-tostringtag": "^1.0.2",
+        "safe-regex-test": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-map": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
+      "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-negative-zero": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
+      "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.12.0"
+      }
+    },
+    "node_modules/is-number-object": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz",
+      "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "has-tostringtag": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-regex": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
+      "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "gopd": "^1.2.0",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-set": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz",
+      "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-shared-array-buffer": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz",
+      "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-string": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
+      "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "has-tostringtag": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-symbol": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz",
+      "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "has-symbols": "^1.1.0",
+        "safe-regex-test": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-typed-array": {
+      "version": "1.1.15",
+      "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
+      "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "which-typed-array": "^1.1.16"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-weakmap": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
+      "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-weakref": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz",
+      "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-weakset": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz",
+      "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "get-intrinsic": "^1.2.6"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/isarray": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+      "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/jackspeak": {
+      "version": "3.4.3",
+      "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
+      "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
+      "dev": true,
+      "license": "BlueOak-1.0.0",
+      "dependencies": {
+        "@isaacs/cliui": "^8.0.2"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      },
+      "optionalDependencies": {
+        "@pkgjs/parseargs": "^0.11.0"
+      }
+    },
+    "node_modules/js-yaml": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+      "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "argparse": "^2.0.1"
+      },
+      "bin": {
+        "js-yaml": "bin/js-yaml.js"
+      }
+    },
+    "node_modules/json-buffer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+      "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/json-schema-traverse": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/json-stable-stringify-without-jsonify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+      "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/json5": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
+      "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "minimist": "^1.2.0"
+      },
+      "bin": {
+        "json5": "lib/cli.js"
+      }
+    },
+    "node_modules/jsonc-parser": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
+      "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/keyv": {
+      "version": "4.5.4",
+      "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+      "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "json-buffer": "3.0.1"
+      }
+    },
+    "node_modules/levn": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+      "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "prelude-ls": "^1.2.1",
+        "type-check": "~0.4.0"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/locate-path": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+      "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "p-locate": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/lodash.merge": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/loupe": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.0.tgz",
+      "integrity": "sha512-2NCfZcT5VGVNX9mSZIxLRkEAegDGBpuQZBy13desuHeVORmBDyAET4TkJr4SjqQy3A8JDofMN6LpkK8Xcm/dlw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/lru-cache": {
+      "version": "10.4.3",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+      "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/lunr": {
+      "version": "2.3.9",
+      "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz",
+      "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.17",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
+      "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.0"
+      }
+    },
+    "node_modules/marked": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz",
+      "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "marked": "bin/marked.js"
+      },
+      "engines": {
+        "node": ">= 12"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/merge2": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+      "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/micromatch": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+      "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "braces": "^3.0.3",
+        "picomatch": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=8.6"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "9.0.5",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+      "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/minimist": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+      "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/minipass": {
+      "version": "7.1.2",
+      "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
+      "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/natural-compare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+      "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/object-keys": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+      "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/object.assign": {
+      "version": "4.1.7",
+      "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
+      "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.3",
+        "define-properties": "^1.2.1",
+        "es-object-atoms": "^1.0.0",
+        "has-symbols": "^1.1.0",
+        "object-keys": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/object.fromentries": {
+      "version": "2.0.8",
+      "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz",
+      "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.7",
+        "define-properties": "^1.2.1",
+        "es-abstract": "^1.23.2",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/object.groupby": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz",
+      "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.7",
+        "define-properties": "^1.2.1",
+        "es-abstract": "^1.23.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/object.values": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz",
+      "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.3",
+        "define-properties": "^1.2.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/optionator": {
+      "version": "0.9.4",
+      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+      "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "deep-is": "^0.1.3",
+        "fast-levenshtein": "^2.0.6",
+        "levn": "^0.4.1",
+        "prelude-ls": "^1.2.1",
+        "type-check": "^0.4.0",
+        "word-wrap": "^1.2.5"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/own-keys": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
+      "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "get-intrinsic": "^1.2.6",
+        "object-keys": "^1.1.1",
+        "safe-push-apply": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/p-limit": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+      "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "yocto-queue": "^0.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-locate": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+      "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "p-limit": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/package-json-from-dist": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+      "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+      "dev": true,
+      "license": "BlueOak-1.0.0"
+    },
+    "node_modules/parent-module": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+      "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "callsites": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/path-exists": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-parse": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/path-scurry": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+      "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+      "dev": true,
+      "license": "BlueOak-1.0.0",
+      "dependencies": {
+        "lru-cache": "^10.2.0",
+        "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/pathe": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
+      "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/pathval": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+      "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14.16"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/possible-typed-array-names": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+      "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.6",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+      "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.11",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/prelude-ls": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+      "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/prettier": {
+      "version": "3.6.2",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
+      "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
+      "license": "MIT",
+      "bin": {
+        "prettier": "bin/prettier.cjs"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/prettier/prettier?sponsor=1"
+      }
+    },
+    "node_modules/prettier-linter-helpers": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz",
+      "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fast-diff": "^1.1.2"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/punycode": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+      "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/queue-microtask": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+      "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/reflect.getprototypeof": {
+      "version": "1.0.10",
+      "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
+      "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "define-properties": "^1.2.1",
+        "es-abstract": "^1.23.9",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.0.0",
+        "get-intrinsic": "^1.2.7",
+        "get-proto": "^1.0.1",
+        "which-builtin-type": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/regexp.prototype.flags": {
+      "version": "1.5.4",
+      "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
+      "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "define-properties": "^1.2.1",
+        "es-errors": "^1.3.0",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "set-function-name": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/resolve": {
+      "version": "1.22.10",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
+      "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-core-module": "^2.16.0",
+        "path-parse": "^1.0.7",
+        "supports-preserve-symlinks-flag": "^1.0.0"
+      },
+      "bin": {
+        "resolve": "bin/resolve"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/resolve-from": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+      "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/reusify": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+      "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "iojs": ">=1.0.0",
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/rimraf": {
+      "version": "5.0.10",
+      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz",
+      "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "glob": "^10.3.7"
+      },
+      "bin": {
+        "rimraf": "dist/esm/bin.mjs"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "4.46.2",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz",
+      "integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.8"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.46.2",
+        "@rollup/rollup-android-arm64": "4.46.2",
+        "@rollup/rollup-darwin-arm64": "4.46.2",
+        "@rollup/rollup-darwin-x64": "4.46.2",
+        "@rollup/rollup-freebsd-arm64": "4.46.2",
+        "@rollup/rollup-freebsd-x64": "4.46.2",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.46.2",
+        "@rollup/rollup-linux-arm-musleabihf": "4.46.2",
+        "@rollup/rollup-linux-arm64-gnu": "4.46.2",
+        "@rollup/rollup-linux-arm64-musl": "4.46.2",
+        "@rollup/rollup-linux-loongarch64-gnu": "4.46.2",
+        "@rollup/rollup-linux-ppc64-gnu": "4.46.2",
+        "@rollup/rollup-linux-riscv64-gnu": "4.46.2",
+        "@rollup/rollup-linux-riscv64-musl": "4.46.2",
+        "@rollup/rollup-linux-s390x-gnu": "4.46.2",
+        "@rollup/rollup-linux-x64-gnu": "4.46.2",
+        "@rollup/rollup-linux-x64-musl": "4.46.2",
+        "@rollup/rollup-win32-arm64-msvc": "4.46.2",
+        "@rollup/rollup-win32-ia32-msvc": "4.46.2",
+        "@rollup/rollup-win32-x64-msvc": "4.46.2",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/run-parallel": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+      "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "queue-microtask": "^1.2.2"
+      }
+    },
+    "node_modules/safe-array-concat": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
+      "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.2",
+        "get-intrinsic": "^1.2.6",
+        "has-symbols": "^1.1.0",
+        "isarray": "^2.0.5"
+      },
+      "engines": {
+        "node": ">=0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/safe-push-apply": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
+      "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "isarray": "^2.0.5"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/safe-regex-test": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
+      "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "is-regex": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/semver": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/set-function-length": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+      "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "define-data-property": "^1.1.4",
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2",
+        "get-intrinsic": "^1.2.4",
+        "gopd": "^1.0.1",
+        "has-property-descriptors": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/set-function-name": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
+      "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "define-data-property": "^1.1.4",
+        "es-errors": "^1.3.0",
+        "functions-have-names": "^1.2.3",
+        "has-property-descriptors": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/set-proto": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz",
+      "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "shebang-regex": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shiki": {
+      "version": "0.14.7",
+      "resolved": "https://registry.npmjs.org/shiki/-/shiki-0.14.7.tgz",
+      "integrity": "sha512-dNPAPrxSc87ua2sKJ3H5dQ/6ZaY8RNnaAqK+t0eG7p0Soi2ydiqbGOTaZCqaYvA/uZYfS1LJnemt3Q+mSfcPCg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-sequence-parser": "^1.1.0",
+        "jsonc-parser": "^3.2.0",
+        "vscode-oniguruma": "^1.7.0",
+        "vscode-textmate": "^8.0.0"
+      }
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3",
+        "side-channel-list": "^1.0.0",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+      "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/siginfo": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+      "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/signal-exit": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+      "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/stackback": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+      "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/std-env": {
+      "version": "3.9.0",
+      "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz",
+      "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/stop-iteration-iterator": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
+      "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "internal-slot": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/string-width": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+      "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "eastasianwidth": "^0.2.0",
+        "emoji-regex": "^9.2.2",
+        "strip-ansi": "^7.0.1"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/string-width-cjs": {
+      "name": "string-width",
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/string-width-cjs/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/string-width-cjs/node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/string-width-cjs/node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/string.prototype.trim": {
+      "version": "1.2.10",
+      "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz",
+      "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.2",
+        "define-data-property": "^1.1.4",
+        "define-properties": "^1.2.1",
+        "es-abstract": "^1.23.5",
+        "es-object-atoms": "^1.0.0",
+        "has-property-descriptors": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/string.prototype.trimend": {
+      "version": "1.0.9",
+      "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz",
+      "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.2",
+        "define-properties": "^1.2.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/string.prototype.trimstart": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz",
+      "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.7",
+        "define-properties": "^1.2.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/strip-ansi": {
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+      "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+      }
+    },
+    "node_modules/strip-ansi-cjs": {
+      "name": "strip-ansi",
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-bom": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
+      "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/strip-json-comments": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+      "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/supports-preserve-symlinks-flag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/synckit": {
+      "version": "0.11.11",
+      "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",
+      "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@pkgr/core": "^0.2.9"
+      },
+      "engines": {
+        "node": "^14.18.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/synckit"
+      }
+    },
+    "node_modules/tinybench": {
+      "version": "2.9.0",
+      "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+      "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tinyexec": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+      "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tinypool": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+      "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      }
+    },
+    "node_modules/tinyrainbow": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
+      "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/tinyspy": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
+      "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-number": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=8.0"
+      }
+    },
+    "node_modules/ts-api-utils": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
+      "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.12"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.8.4"
+      }
+    },
+    "node_modules/tsconfig-paths": {
+      "version": "3.15.0",
+      "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
+      "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/json5": "^0.0.29",
+        "json5": "^1.0.2",
+        "minimist": "^1.2.6",
+        "strip-bom": "^3.0.0"
+      }
+    },
+    "node_modules/type-check": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+      "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "prelude-ls": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/typed-array-buffer": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
+      "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "es-errors": "^1.3.0",
+        "is-typed-array": "^1.1.14"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/typed-array-byte-length": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz",
+      "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.8",
+        "for-each": "^0.3.3",
+        "gopd": "^1.2.0",
+        "has-proto": "^1.2.0",
+        "is-typed-array": "^1.1.14"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/typed-array-byte-offset": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz",
+      "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "available-typed-arrays": "^1.0.7",
+        "call-bind": "^1.0.8",
+        "for-each": "^0.3.3",
+        "gopd": "^1.2.0",
+        "has-proto": "^1.2.0",
+        "is-typed-array": "^1.1.15",
+        "reflect.getprototypeof": "^1.0.9"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/typed-array-length": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz",
+      "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind": "^1.0.7",
+        "for-each": "^0.3.3",
+        "gopd": "^1.0.1",
+        "is-typed-array": "^1.1.13",
+        "possible-typed-array-names": "^1.0.0",
+        "reflect.getprototypeof": "^1.0.6"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/typedoc": {
+      "version": "0.25.13",
+      "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.25.13.tgz",
+      "integrity": "sha512-pQqiwiJ+Z4pigfOnnysObszLiU3mVLWAExSPf+Mu06G/qsc3wzbuM56SZQvONhHLncLUhYzOVkjFFpFfL5AzhQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "lunr": "^2.3.9",
+        "marked": "^4.3.0",
+        "minimatch": "^9.0.3",
+        "shiki": "^0.14.7"
+      },
+      "bin": {
+        "typedoc": "bin/typedoc"
+      },
+      "engines": {
+        "node": ">= 16"
+      },
+      "peerDependencies": {
+        "typescript": "4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.4.5",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
+      "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/typescript-eslint": {
+      "version": "8.39.1",
+      "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.39.1.tgz",
+      "integrity": "sha512-GDUv6/NDYngUlNvwaHM1RamYftxf782IyEDbdj3SeaIHHv8fNQVRC++fITT7kUJV/5rIA/tkoRSSskt6osEfqg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typescript-eslint/eslint-plugin": "8.39.1",
+        "@typescript-eslint/parser": "8.39.1",
+        "@typescript-eslint/typescript-estree": "8.39.1",
+        "@typescript-eslint/utils": "8.39.1"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^8.57.0 || ^9.0.0",
+        "typescript": ">=4.8.4 <6.0.0"
+      }
+    },
+    "node_modules/unbox-primitive": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
+      "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.3",
+        "has-bigints": "^1.0.2",
+        "has-symbols": "^1.1.0",
+        "which-boxed-primitive": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "6.21.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/uri-js": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+      "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "punycode": "^2.1.0"
+      }
+    },
+    "node_modules/vite": {
+      "version": "5.4.19",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
+      "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.21.3",
+        "postcss": "^8.4.43",
+        "rollup": "^4.20.0"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^18.0.0 || >=20.0.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.4.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vite-node": {
+      "version": "2.1.9",
+      "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
+      "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cac": "^6.7.14",
+        "debug": "^4.3.7",
+        "es-module-lexer": "^1.5.4",
+        "pathe": "^1.1.2",
+        "vite": "^5.0.0"
+      },
+      "bin": {
+        "vite-node": "vite-node.mjs"
+      },
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/vitest": {
+      "version": "2.1.9",
+      "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
+      "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/expect": "2.1.9",
+        "@vitest/mocker": "2.1.9",
+        "@vitest/pretty-format": "^2.1.9",
+        "@vitest/runner": "2.1.9",
+        "@vitest/snapshot": "2.1.9",
+        "@vitest/spy": "2.1.9",
+        "@vitest/utils": "2.1.9",
+        "chai": "^5.1.2",
+        "debug": "^4.3.7",
+        "expect-type": "^1.1.0",
+        "magic-string": "^0.30.12",
+        "pathe": "^1.1.2",
+        "std-env": "^3.8.0",
+        "tinybench": "^2.9.0",
+        "tinyexec": "^0.3.1",
+        "tinypool": "^1.0.1",
+        "tinyrainbow": "^1.2.0",
+        "vite": "^5.0.0",
+        "vite-node": "2.1.9",
+        "why-is-node-running": "^2.3.0"
+      },
+      "bin": {
+        "vitest": "vitest.mjs"
+      },
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "@edge-runtime/vm": "*",
+        "@types/node": "^18.0.0 || >=20.0.0",
+        "@vitest/browser": "2.1.9",
+        "@vitest/ui": "2.1.9",
+        "happy-dom": "*",
+        "jsdom": "*"
+      },
+      "peerDependenciesMeta": {
+        "@edge-runtime/vm": {
+          "optional": true
+        },
+        "@types/node": {
+          "optional": true
+        },
+        "@vitest/browser": {
+          "optional": true
+        },
+        "@vitest/ui": {
+          "optional": true
+        },
+        "happy-dom": {
+          "optional": true
+        },
+        "jsdom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vscode-oniguruma": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz",
+      "integrity": "sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/vscode-textmate": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-8.0.0.tgz",
+      "integrity": "sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "node-which": "bin/node-which"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/which-boxed-primitive": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz",
+      "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-bigint": "^1.1.0",
+        "is-boolean-object": "^1.2.1",
+        "is-number-object": "^1.1.1",
+        "is-string": "^1.1.1",
+        "is-symbol": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/which-builtin-type": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz",
+      "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "function.prototype.name": "^1.1.6",
+        "has-tostringtag": "^1.0.2",
+        "is-async-function": "^2.0.0",
+        "is-date-object": "^1.1.0",
+        "is-finalizationregistry": "^1.1.0",
+        "is-generator-function": "^1.0.10",
+        "is-regex": "^1.2.1",
+        "is-weakref": "^1.0.2",
+        "isarray": "^2.0.5",
+        "which-boxed-primitive": "^1.1.0",
+        "which-collection": "^1.0.2",
+        "which-typed-array": "^1.1.16"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/which-collection": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz",
+      "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-map": "^2.0.3",
+        "is-set": "^2.0.3",
+        "is-weakmap": "^2.0.2",
+        "is-weakset": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/which-typed-array": {
+      "version": "1.1.19",
+      "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
+      "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "available-typed-arrays": "^1.0.7",
+        "call-bind": "^1.0.8",
+        "call-bound": "^1.0.4",
+        "for-each": "^0.3.5",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-tostringtag": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/why-is-node-running": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+      "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "siginfo": "^2.0.0",
+        "stackback": "0.0.2"
+      },
+      "bin": {
+        "why-is-node-running": "cli.js"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/word-wrap": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+      "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/wrap-ansi": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+      "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^6.1.0",
+        "string-width": "^5.0.1",
+        "strip-ansi": "^7.0.1"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+      }
+    },
+    "node_modules/wrap-ansi-cjs": {
+      "name": "wrap-ansi",
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+      }
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/yocto-queue": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+      "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    }
+  }
+}
diff --git a/packages/webrtc-sdk/package.json b/packages/webrtc-sdk/package.json
new file mode 100644
index 00000000..48ef2b56
--- /dev/null
+++ b/packages/webrtc-sdk/package.json
@@ -0,0 +1,37 @@
+{
+  "name": "webrtc-sdk",
+  "version": "2.0.0-beta.1",
+  "private": true,
+  "type": "module",
+  "main": "dist/index.cjs",
+  "module": "dist/index.mjs",
+  "types": "dist/index.d.ts",
+  "scripts": {
+    "build": "tsc -p tsconfig.json",
+    "clean": "rimraf dist",
+    "typecheck": "tsc -p tsconfig.json --noEmit",
+    "docs": "typedoc --options typedoc.json",
+    "lint": "eslint .",
+    "lint:fix": "eslint . --fix",
+    "test": "vitest run",
+    "test:watch": "vitest"
+  },
+  "devDependencies": {
+    "@eslint/js": "^9.12.0",
+    "@types/node": "^22.5.4",
+    "eslint": "^9.33.0",
+    "eslint-config-prettier": "^9.1.2",
+    "eslint-plugin-import": "^2.29.1",
+    "eslint-plugin-prettier": "^5.5.4",
+    "eslint-plugin-promise": "^7.1.0",
+    "eslint-plugin-unused-imports": "^4.1.4",
+    "rimraf": "^5.0.5",
+    "typedoc": "^0.25.13",
+    "typescript": "^5.4.5",
+    "typescript-eslint": "^8.10.0",
+    "vitest": "^2.1.9"
+  },
+  "dependencies": {
+    "prettier": "^3.6.2"
+  }
+}
diff --git a/packages/webrtc-sdk/src/core/emitter.ts b/packages/webrtc-sdk/src/core/emitter.ts
new file mode 100644
index 00000000..ded20b97
--- /dev/null
+++ b/packages/webrtc-sdk/src/core/emitter.ts
@@ -0,0 +1,44 @@
+export class Emitter> {
+  private handlers: Map void>> =
+    new Map();
+
+  on(event: K, handler: (payload: EventMap[K]) => void): void {
+    if (!this.handlers.has(event)) this.handlers.set(event, new Set());
+    // TypeScript cannot narrow Set element type per key; wrap to preserve type safety
+    const wrapped = ((p: unknown) => handler(p as EventMap[K])) as (
+      payload: EventMap[keyof EventMap]
+    ) => void;
+    (wrapped as unknown as { original?: typeof handler }).original = handler;
+    this.handlers.get(event)!.add(wrapped);
+  }
+
+  off(event: K, handler: (payload: EventMap[K]) => void): void {
+    const set = this.handlers.get(event);
+    if (!set) return;
+    for (const fn of Array.from(set)) {
+      if ((fn as unknown as { original?: unknown }).original === handler) {
+        set.delete(fn);
+      }
+    }
+  }
+
+  once(event: K, handler: (payload: EventMap[K]) => void): void {
+    const wrap = (payload: EventMap[K]) => {
+      this.off(event, wrap as unknown as (p: EventMap[K]) => void);
+      handler(payload);
+    };
+    this.on(event, wrap);
+  }
+
+  emit(event: K, payload: EventMap[K]): void {
+    const set = this.handlers.get(event);
+    if (!set) return;
+    for (const fn of Array.from(set)) {
+      try {
+        (fn as (p: EventMap[K]) => void)(payload);
+      } catch (e) {
+        console.warn(e);
+      }
+    }
+  }
+}
diff --git a/packages/webrtc-sdk/src/core/errors.ts b/packages/webrtc-sdk/src/core/errors.ts
new file mode 100644
index 00000000..9165f1f8
--- /dev/null
+++ b/packages/webrtc-sdk/src/core/errors.ts
@@ -0,0 +1,30 @@
+/**
+ * Well-known error codes emitted by the SDK.
+ */
+export type ErrorCode =
+  | "WebSocketNotConnected"
+  | "WebSocketNotSupported"
+  | "UnsecureContext"
+  | "getUserMediaIsNotAllowed"
+  | "ScreenSharePermissionDenied"
+  | "notSetRemoteDescription"
+  | "protocol_not_supported"
+  | "data_channel_error"
+  | "data_channel_blob_parse_failed"
+  | "join_timeout"
+  | "join_failed";
+
+/**
+ * Standardized error type produced by the SDK. Use {@link code} for programmatic handling.
+ */
+export class SDKError extends Error {
+  readonly code: ErrorCode;
+  readonly info?: unknown;
+
+  constructor(code: ErrorCode, message?: string, info?: unknown) {
+    super(message ?? code);
+    this.name = "SDKError";
+    this.code = code;
+    this.info = info;
+  }
+}
diff --git a/packages/webrtc-sdk/src/core/events.ts b/packages/webrtc-sdk/src/core/events.ts
new file mode 100644
index 00000000..841d640b
--- /dev/null
+++ b/packages/webrtc-sdk/src/core/events.ts
@@ -0,0 +1,40 @@
+import type { GroupedDevices } from "./types";
+import type { PeerStats } from "./peer-stats";
+
+/**
+ * Typed events emitted by {@link WebRTCAdaptor} and helpers.
+ */
+export interface EventMap {
+  [key: string]: unknown;
+  initialized: void;
+  closed: unknown;
+  server_will_stop: unknown;
+  publish_started: { streamId: string };
+  publish_finished: { streamId: string };
+  play_started: { streamId: string };
+  play_finished: { streamId: string };
+  ice_connection_state_changed: { state: string; streamId: string };
+  updated_stats: PeerStats;
+  data_received: { streamId: string; data: string | ArrayBuffer };
+  data_channel_opened: { streamId: string };
+  data_channel_closed: { streamId: string };
+  newTrackAvailable: { stream: MediaStream; track: MediaStreamTrack; streamId: string };
+  devices_updated: GroupedDevices;
+  error: { error: string; message?: unknown };
+  // dynamic notification channel e.g. notification:subscriberCount -> payload from server
+  [k: `notification:${string}`]: unknown;
+  // commonly used server notifications as first-class events
+  subscriber_count?: { streamId?: string; count?: number } | unknown;
+  subscriber_list?: unknown;
+  room_information?: unknown;
+  broadcast_object?: unknown;
+  room_joined?: unknown;
+  room_left?: unknown;
+  video_track_assignments?: unknown;
+}
+
+export interface TypedEmitter> {
+  on(event: K, handler: (payload: M[K]) => void): void;
+  off(event: K, handler: (payload: M[K]) => void): void;
+  once(event: K, handler: (payload: M[K]) => void): void;
+}
diff --git a/packages/webrtc-sdk/src/core/media-manager.ts b/packages/webrtc-sdk/src/core/media-manager.ts
new file mode 100644
index 00000000..892712f0
--- /dev/null
+++ b/packages/webrtc-sdk/src/core/media-manager.ts
@@ -0,0 +1,199 @@
+import type { GroupedDevices } from "./types.js";
+import { Emitter } from "./emitter.js";
+import type { EventMap } from "./events.js";
+
+export interface MediaManagerOptions {
+  mediaConstraints?: MediaStreamConstraints;
+  localVideo?: HTMLVideoElement | null;
+  debug?: boolean;
+}
+
+/**
+ * Manages local media acquisition and device switching.
+ */
+export class MediaManager extends Emitter {
+  private localStream: MediaStream | null = null;
+  private localVideo: HTMLVideoElement | null;
+  private constraints: MediaStreamConstraints;
+  private screenVideoTrack: MediaStreamTrack | null = null;
+  private selectedVideoInputId: string | null = null;
+  private selectedAudioInputId: string | null = null;
+
+  /**
+   * @param opts Media constraints and optional local preview element.
+   */
+  constructor(opts: MediaManagerOptions) {
+    super();
+    this.localVideo = opts.localVideo ?? null;
+    this.constraints = opts.mediaConstraints ?? { video: true, audio: true };
+  }
+
+  /** Return the currently active local stream, if any. */
+  getLocalStream(): MediaStream | null {
+    return this.localStream;
+  }
+
+  /** (Re)initialize local stream using current constraints and emit `devices_updated`. */
+  async initLocalStream(): Promise {
+    const stream = await navigator.mediaDevices.getUserMedia(this.constraints);
+    this.localStream = stream;
+    if (this.localVideo) this.localVideo.srcObject = stream;
+    await this.refreshDevices();
+  }
+
+  async refreshDevices(): Promise {
+    const devices = await navigator.mediaDevices.enumerateDevices();
+    const grouped: GroupedDevices = {
+      videoInputs: devices
+        .filter(d => d.kind === "videoinput")
+        .map(d => ({ deviceId: d.deviceId, label: d.label })),
+      audioInputs: devices
+        .filter(d => d.kind === "audioinput")
+        .map(d => ({ deviceId: d.deviceId, label: d.label })),
+      audioOutputs: devices
+        .filter(d => d.kind === "audiooutput")
+        .map(d => ({ deviceId: d.deviceId, label: d.label })),
+      selectedVideoInputId: this.selectedVideoInputId || undefined,
+      selectedAudioInputId: this.selectedAudioInputId || undefined,
+    };
+    // audio output selection (sinkId) is media element specific; we expose last selected if available
+    grouped.selectedAudioOutputId = undefined;
+    this.emit("devices_updated", grouped);
+    return grouped;
+  }
+
+  /** Enumerate and group available input/output devices. */
+  async listDevices(): Promise {
+    return this.refreshDevices();
+  }
+
+  /** Update video constraints to use a specific deviceId or facingMode and refresh stream. */
+  async selectVideoInput(source: string | { facingMode: "user" | "environment" }): Promise {
+    const video: MediaTrackConstraints =
+      typeof source === "string"
+        ? { deviceId: { exact: source } }
+        : { facingMode: source.facingMode };
+    this.constraints = { ...this.constraints, video };
+    this.selectedVideoInputId = typeof source === "string" ? source : null;
+    await this.initLocalStream();
+  }
+
+  /** Update audio constraints to use a specific deviceId and refresh stream. */
+  async selectAudioInput(deviceId: string): Promise {
+    const audio: MediaTrackConstraints = { deviceId: { exact: deviceId } };
+    this.constraints = { ...this.constraints, audio };
+    this.selectedAudioInputId = deviceId;
+    await this.initLocalStream();
+  }
+
+  /**
+   * Turn off the camera device: stop and remove local video track(s).
+   * This turns off the camera light without renegotiation. Remote side will see video muted.
+   */
+  turnOffLocalCamera(): void {
+    if (!this.localStream) return;
+    const videoTracks = this.localStream.getVideoTracks();
+    for (const track of videoTracks) {
+      try {
+        track.stop();
+      } catch (err) {
+        console.error(err);
+      }
+      this.localStream.removeTrack(track);
+    }
+    if (this.localVideo && this.localVideo.srcObject !== this.localStream) {
+      this.localVideo.srcObject = this.localStream;
+    }
+  }
+
+  /**
+   * Re-enable camera: if no local video track exists, reacquire one with current constraints and add it.
+   * Does not renegotiate; callers should use replaceTrack on senders (handled by adaptor.applyLocalTracks).
+   */
+  async turnOnLocalCamera(): Promise {
+    if (!this.localStream) {
+      await this.initLocalStream();
+      return;
+    }
+    const existing = this.localStream.getVideoTracks();
+    if (existing.length > 0) {
+      for (const track of existing) track.enabled = true;
+      return;
+    }
+    const videoConstraints =
+      (this.constraints && (this.constraints as MediaStreamConstraints).video) ?? true;
+    const cam = await navigator.mediaDevices.getUserMedia({
+      video: videoConstraints,
+      audio: false,
+    });
+    const vtrack = cam.getVideoTracks()[0];
+    if (vtrack) this.replaceLocalVideoTrack(vtrack);
+  }
+
+  /** Disable sending from the current local audio track(s). */
+  muteLocalMic(): void {
+    if (!this.localStream) return;
+    for (const track of this.localStream.getAudioTracks()) {
+      track.enabled = false;
+    }
+  }
+
+  /** Enable sending from the current local audio track(s). */
+  unmuteLocalMic(): void {
+    if (!this.localStream) return;
+    for (const track of this.localStream.getAudioTracks()) {
+      track.enabled = true;
+    }
+  }
+
+  /** Replace local video track with a screen capture track. */
+  async startScreenShare(): Promise {
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const screen = await (navigator.mediaDevices as any).getDisplayMedia({
+      video: true,
+      audio: false,
+    });
+    const vtrack = (screen as MediaStream).getVideoTracks()[0];
+    if (!vtrack) return;
+    this.replaceLocalVideoTrack(vtrack);
+    this.screenVideoTrack = vtrack;
+    // auto-stop when user ends share
+    vtrack.onended = () => {
+      void this.stopScreenShare();
+    };
+  }
+
+  /** Restore camera video track by reinitializing getUserMedia with current constraints. */
+  async stopScreenShare(): Promise {
+    if (this.screenVideoTrack) {
+      try {
+        this.screenVideoTrack.stop();
+      } catch (err) {
+        console.error(err);
+      }
+      this.screenVideoTrack = null;
+    }
+    await this.initLocalStream();
+  }
+
+  private replaceLocalVideoTrack(newTrack: MediaStreamTrack): void {
+    if (!this.localStream) {
+      this.localStream = new MediaStream([newTrack]);
+      if (this.localVideo) this.localVideo.srcObject = this.localStream;
+      return;
+    }
+    // remove existing video tracks
+    for (const t of this.localStream.getVideoTracks()) {
+      this.localStream.removeTrack(t);
+      try {
+        t.stop();
+      } catch (err) {
+        console.error(err);
+      }
+    }
+    this.localStream.addTrack(newTrack);
+    if (this.localVideo && this.localVideo.srcObject !== this.localStream) {
+      this.localVideo.srcObject = this.localStream;
+    }
+  }
+}
diff --git a/packages/webrtc-sdk/src/core/peer-stats.ts b/packages/webrtc-sdk/src/core/peer-stats.ts
new file mode 100644
index 00000000..0f698569
--- /dev/null
+++ b/packages/webrtc-sdk/src/core/peer-stats.ts
@@ -0,0 +1,33 @@
+export class PeerStats {
+  streamId: string;
+  averageOutgoingBitrate?: number;
+  currentOutgoingBitrate?: number;
+  averageIncomingBitrate?: number;
+  currentIncomingBitrate?: number;
+  totalBytesSent?: number;
+  totalBytesReceived?: number;
+  currentTimestamp?: number;
+  // Extended parity fields
+  audioPacketsLost?: number;
+  videoPacketsLost?: number;
+  audioPacketsSent?: number;
+  videoPacketsSent?: number;
+  audioPacketsReceived?: number;
+  videoPacketsReceived?: number;
+  audioRoundTripTime?: number;
+  videoRoundTripTime?: number;
+  audioJitter?: number;
+  videoJitter?: number;
+  frameWidth?: number;
+  frameHeight?: number;
+  framesEncoded?: number;
+  framesDecoded?: number;
+  framesDropped?: number;
+  framesReceived?: number;
+  availableOutgoingBitrateKbps?: number;
+  currentRoundTripTime?: number;
+
+  constructor(streamId: string) {
+    this.streamId = streamId;
+  }
+}
diff --git a/packages/webrtc-sdk/src/core/types.ts b/packages/webrtc-sdk/src/core/types.ts
new file mode 100644
index 00000000..b8622fe8
--- /dev/null
+++ b/packages/webrtc-sdk/src/core/types.ts
@@ -0,0 +1,105 @@
+/**
+ * The role of the client in a session.
+ * - `publisher`: sends local media to Ant Media Server
+ * - `viewer`: receives remote media from Ant Media Server
+ */
+export type Role = "publisher" | "viewer";
+
+/**
+ * Options to configure {@link WebRTCAdaptor}.
+ */
+export interface WebRTCAdaptorOptions {
+  /** WebSocket signaling URL (e.g. wss://host:5443/App/websocket) */
+  websocketURL?: string;
+  /** HTTP REST endpoint of Ant Media (used as fallback by signaling layer) */
+  httpEndpointUrl?: string;
+  /** If true, initializes in play-only mode and skips getUserMedia */
+  isPlayMode?: boolean;
+  /** Default media constraints used for getUserMedia */
+  mediaConstraints?: MediaStreamConstraints;
+  /** Local preview element for publisher (srcObject will be assigned) */
+  localVideo?: HTMLVideoElement | null;
+  /** Remote element to render incoming media (viewer side) */
+  remoteVideo?: HTMLVideoElement | null;
+  /** Enable verbose logging */
+  debug?: boolean;
+  /** Enable automatic reconnection on ICE failure/disconnect (default: true) */
+  autoReconnect?: boolean;
+}
+
+/**
+ * Options for the one-liner {@link WebRTCAdaptor.join} flow.
+ */
+export interface JoinOptions {
+  /** Whether to publish or view a stream */
+  role: Role;
+  /** Unique stream identifier */
+  streamId: string;
+  /** Optional JWT/token for secured streams */
+  token?: string;
+  /** Optional subscriber identification fields */
+  subscriberId?: string;
+  subscriberCode?: string;
+  /** Optional metadata fields propagated to server */
+  streamName?: string;
+  mainTrack?: string;
+  metaData?: unknown;
+  roomId?: string;
+  /** Track configuration helpers */
+  enableTracks?: string[];
+  disableTracksByDefault?: boolean;
+  /** Timeout for join to resolve before rejecting */
+  timeoutMs?: number;
+}
+
+/**
+ * Result returned by {@link WebRTCAdaptor.join} when connection is established.
+ */
+export interface JoinResult {
+  /** Stream identifier */
+  streamId: string;
+  /**
+   * ICE state or first-track state observed that marks the session ready.
+   * - `connected` | `completed`: ICE connected
+   * - `track_added`: first remote or local track became active
+   */
+  state: "connected" | "completed" | "track_added";
+}
+
+/**
+ * Convenience structure of media devices grouped by kind.
+ */
+export interface GroupedDevices {
+  videoInputs: Array<{ deviceId: string; label: string }>;
+  audioInputs: Array<{ deviceId: string; label: string }>;
+  audioOutputs: Array<{ deviceId: string; label: string }>;
+  /** Currently selected input device ids, when available */
+  selectedVideoInputId?: string;
+  selectedAudioInputId?: string;
+  selectedAudioOutputId?: string;
+}
+
+export interface RoomJoinOptions {
+  roomId: string;
+  streamId?: string;
+  role?: string;
+  metaData?: unknown;
+}
+
+export interface UpdateVideoTrackAssignmentsOptions {
+  streamId: string;
+  offset: number;
+  size: number;
+}
+
+export interface PlaySelectiveOptions {
+  streamId: string;
+  token?: string;
+  roomId?: string;
+  enableTracks?: string[];
+  subscriberId?: string;
+  subscriberCode?: string;
+  metaData?: unknown;
+  role?: string;
+  disableTracksByDefault?: boolean;
+}
diff --git a/packages/webrtc-sdk/src/core/webrtc-adaptor.ts b/packages/webrtc-sdk/src/core/webrtc-adaptor.ts
new file mode 100644
index 00000000..d5eac460
--- /dev/null
+++ b/packages/webrtc-sdk/src/core/webrtc-adaptor.ts
@@ -0,0 +1,1054 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { Logger } from "../utils/logger.js";
+
+import { Emitter } from "./emitter.js";
+import type { EventMap } from "./events.js";
+import type {
+  GroupedDevices,
+  JoinOptions,
+  JoinResult,
+  WebRTCAdaptorOptions,
+  RoomJoinOptions,
+  PlaySelectiveOptions,
+} from "./types.js";
+import { WebSocketAdaptor } from "./websocket-adaptor.js";
+import { MediaManager } from "./media-manager.js";
+
+interface PeerContext {
+  pc: RTCPeerConnection;
+  dc?: RTCDataChannel;
+  mode?: "publish" | "play";
+}
+
+/**
+ * Ant Media Server WebRTC client SDK (v2). Orchestrates media, signaling, peer connections,
+ * and provides a promise-based API with typed events.
+ */
+export class WebRTCAdaptor extends Emitter {
+  private ws?: WebSocketAdaptor;
+  private media: MediaManager;
+  private isReady = false;
+  isPlayMode: boolean;
+  private peers: Map = new Map();
+  private log = new Logger("debug");
+  private peerConfig: RTCConfiguration = {
+    iceServers: [{ urls: "stun:stun1.l.google.com:19302" }],
+  };
+  private remoteDescriptionSet: Map = new Map();
+  private candidateQueue: Map = new Map();
+  private remoteVideo: HTMLVideoElement | null;
+  private candidateTypes: Array<"udp" | "tcp"> = ["udp", "tcp"];
+  private rxChunks: Map =
+    new Map();
+  private autoReconnect = true;
+  private activeStreams: Map = new Map();
+  private reconnectTimers: Map> = new Map();
+  private lastReconnectAt: Map = new Map();
+
+  /**
+   * Create a new adaptor instance.
+   * @param opts See {@link WebRTCAdaptorOptions}
+   */
+  constructor(opts: WebRTCAdaptorOptions) {
+    super();
+    this.isPlayMode = !!opts.isPlayMode;
+    this.autoReconnect = opts.autoReconnect ?? true;
+    this.media = new MediaManager({
+      mediaConstraints: opts.mediaConstraints,
+      localVideo: opts.localVideo,
+    });
+    this.remoteVideo = opts.remoteVideo ?? null;
+
+    this.media.on("devices_updated", g => this.emit("devices_updated", g));
+
+    if (!this.isPlayMode) {
+      this.media.initLocalStream().catch(() => {
+        this.emit("error", { error: "getUserMediaIsNotAllowed" });
+      });
+    }
+
+    if (opts.websocketURL || opts.httpEndpointUrl) {
+      this.ws = new WebSocketAdaptor({
+        websocketURL: opts.websocketURL,
+        httpEndpointUrl: opts.httpEndpointUrl,
+        webrtcadaptor: {
+          notifyEventListeners: (info: string, obj?: unknown) =>
+            this.notify(info as keyof EventMap, obj as never),
+        },
+        debug: opts.debug,
+      });
+      this.on("initialized", () => {
+        this.isReady = true;
+        this.log.info("adaptor initialized");
+        this.ws?.send(JSON.stringify({ command: "getIceServerConfig" }));
+      });
+    }
+  }
+
+  private notify(info: E, obj: EventMap[E]): void {
+    if (info === "initialized") this.isReady = true;
+
+    if (info === "start") {
+      const { streamId } = obj as unknown as { streamId: string };
+      this.log.debug("start received for %s", streamId);
+      void this.startPublishing(streamId);
+    } else if (info === "takeConfiguration") {
+      const { streamId, sdp, type } = obj as unknown as {
+        streamId: string;
+        sdp: string;
+        type: RTCSdpType;
+      };
+      this.log.debug("takeConfiguration %s %s", streamId, type);
+      if (type === "answer") {
+        const ctx = this.peers.get(streamId);
+        if (ctx) {
+          ctx.pc.setRemoteDescription(new RTCSessionDescription({ type, sdp })).then(() => {
+            this.remoteDescriptionSet.set(streamId, true);
+            const queued = this.candidateQueue.get(streamId) || [];
+            queued.forEach(c => ctx.pc.addIceCandidate(new RTCIceCandidate(c)));
+            this.candidateQueue.set(streamId, []);
+          });
+        }
+      } else if (type === "offer") {
+        const pc = this.createPeer(streamId);
+        pc.setRemoteDescription(new RTCSessionDescription({ type, sdp }))
+          .then(async () => {
+            const answer = await pc.createAnswer();
+            await pc.setLocalDescription(answer);
+            this.sendTakeConfiguration(streamId, answer.type, answer.sdp ?? "");
+            this.remoteDescriptionSet.set(streamId, true);
+
+            const queued = this.candidateQueue.get(streamId) || [];
+            queued.forEach(c => pc.addIceCandidate(new RTCIceCandidate(c)));
+            this.candidateQueue.set(streamId, []);
+            this.emit("play_started", { streamId });
+          })
+          .catch(e => this.log.warn("setRemoteDescription failed", e));
+      }
+    } else if (info === "takeCandidate") {
+      const { streamId, label, candidate } = obj as unknown as {
+        streamId: string;
+        label: number | null;
+        candidate: string;
+      };
+      this.log.debug("takeCandidate %s", streamId);
+      const ice: RTCIceCandidateInit = { sdpMLineIndex: label ?? undefined, candidate };
+      const ctx = this.peers.get(streamId);
+      if (ctx) {
+        if (this.remoteDescriptionSet.get(streamId)) {
+          ctx.pc
+            .addIceCandidate(new RTCIceCandidate(ice))
+            .catch(e => this.log.warn("addIceCandidate failed", e));
+        } else {
+          const q = this.candidateQueue.get(streamId) || [];
+          q.push(ice);
+          this.candidateQueue.set(streamId, q);
+        }
+      }
+    } else if (info === "iceServerConfig") {
+      const cfg = obj as unknown as {
+        stunServerUri?: string;
+        turnServerUsername?: string;
+        turnServerCredential?: string;
+      };
+
+      if (cfg.stunServerUri) {
+        if (cfg.stunServerUri.startsWith("turn:")) {
+          this.peerConfig.iceServers = [
+            { urls: "stun:stun1.l.google.com:19302" },
+            {
+              urls: cfg.stunServerUri,
+              username: cfg.turnServerUsername || "",
+              credential: cfg.turnServerCredential || "",
+            },
+          ];
+        } else if (cfg.stunServerUri.startsWith("stun:")) {
+          this.peerConfig.iceServers = [{ urls: cfg.stunServerUri }];
+        }
+        this.log.info("updated ice servers");
+      }
+    } else if (info === "stop") {
+      const { streamId } = obj as unknown as { streamId: string };
+
+      this.log.info("stop received for %s", streamId);
+      this.stop(streamId);
+    } else if (info === "notification") {
+      const payload = obj as unknown as {
+        definition?: string;
+        streamId?: string;
+        [k: string]: unknown;
+      };
+      const def = payload.definition || "";
+      const streamId = payload.streamId || "";
+
+      if (def === "publish_started") this.emit("publish_started", { streamId });
+      if (def === "publish_finished") this.emit("publish_finished", { streamId });
+      if (def === "play_started") this.emit("play_started", { streamId });
+      if (def === "play_finished") this.emit("play_finished", { streamId });
+      if (def === "subscriberCount") this.emit("subscriber_count" as keyof EventMap, obj as never);
+      if (def === "subscriberList") this.emit("subscriber_list" as keyof EventMap, obj as never);
+      if (def === "roomInformation") this.emit("room_information" as keyof EventMap, obj as never);
+      if (def === "broadcastObject") this.emit("broadcast_object" as keyof EventMap, obj as never);
+      if (def === "joinedTheRoom") this.emit("room_joined" as keyof EventMap, obj as never);
+      if (def === "leavedTheRoom") this.emit("room_left" as keyof EventMap, obj as never);
+      // Also emit dynamic channel for other notifications
+      if (def) this.emit(`notification:${def}` as keyof EventMap, obj as never);
+    } else if (info === "closed") {
+      this.emit("closed", obj);
+      return; // prevent double-emit below
+    } else if (info === "server_will_stop") {
+      this.emit("server_will_stop", obj);
+      return; // prevent double-emit below
+    }
+
+    this.emit(info, obj);
+  }
+
+  /**
+   * Resolves when underlying signaling is initialized and ready.
+   */
+  async ready(): Promise {
+    if (this.isReady) return;
+    await new Promise(resolve => {
+      this.once("initialized", () => resolve());
+    });
+  }
+
+  private createPeer(streamId: string): RTCPeerConnection {
+    const pc = new RTCPeerConnection(this.peerConfig);
+    pc.onicecandidate = ev => {
+      if (ev.candidate && this.ws) {
+        const cand = ev.candidate.candidate || "";
+        // protocol filtering similar to v1
+        const protocolSupported = this.candidateTypes.some(p => cand.toLowerCase().includes(p));
+        if (!protocolSupported && cand !== "") {
+          this.log.debug("Skipping candidate due to protocol filter: %s", cand);
+          return;
+        }
+        const msg = {
+          command: "takeCandidate",
+          streamId,
+          label: ev.candidate.sdpMLineIndex ?? 0,
+          id: ev.candidate.sdpMid,
+          candidate: ev.candidate.candidate,
+        };
+        this.log.debug("send candidate %s", streamId);
+        this.ws.send(JSON.stringify(msg));
+      }
+    };
+    pc.oniceconnectionstatechange = () => {
+      this.log.info("ice state %s %s", streamId, pc.iceConnectionState);
+      this.emit("ice_connection_state_changed", { state: pc.iceConnectionState, streamId });
+      // Reconnect strategy similar to v1
+      if (!this.autoReconnect) return;
+      if (!this.activeStreams.has(streamId)) return;
+      const state = pc.iceConnectionState;
+      if (state === "failed" || state === "closed") {
+        this.reconnectIfRequired(streamId, 0, false);
+      } else if (state === "disconnected") {
+        this.reconnectIfRequired(streamId, 3000, false);
+      }
+    };
+    pc.ontrack = (event: RTCTrackEvent) => {
+      this.log.debug("ontrack %s", streamId);
+      const stream = event.streams[0];
+      if (this.remoteVideo && this.remoteVideo.srcObject !== stream) {
+        this.remoteVideo.srcObject = stream;
+      }
+      this.emit("newTrackAvailable", { stream, track: event.track, streamId });
+    };
+    this.peers.set(streamId, { pc });
+    return pc;
+  }
+
+  private setupDataChannel(streamId: string, dc: RTCDataChannel): void {
+    const ctx = this.peers.get(streamId);
+    if (ctx) ctx.dc = dc;
+    // Prefer ArrayBuffer delivery for binary frames
+    try {
+      (dc as unknown as { binaryType?: string }).binaryType = "arraybuffer";
+    } catch (e) {
+      this.log.warn("setting binaryType failed", e);
+    }
+    dc.onerror = error => {
+      this.log.warn("data channel error", error);
+      if (dc.readyState !== "closed")
+        this.emit("error", { error: "data_channel_error", message: error });
+    };
+    dc.onopen = () => {
+      this.log.debug("data channel opened %s", streamId);
+      this.emit("data_channel_opened", { streamId });
+    };
+    dc.onclose = () => {
+      this.log.debug("data channel closed %s", streamId);
+      this.emit("data_channel_closed", { streamId });
+    };
+    dc.onmessage = event => {
+      const raw = event.data;
+      const processBuffer = (u8: Uint8Array) => {
+        if (u8.byteLength === 8) {
+          // header [token:int32, total:int32]
+          const view = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
+          const token = view.getInt32(0, true);
+          const total = view.getInt32(4, true);
+          this.rxChunks.set(token, { expected: total, received: 0, buffers: [] });
+          return;
+        }
+        if (u8.byteLength >= 4) {
+          const view = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
+          const token = view.getInt32(0, true);
+          const dataPart = u8.subarray(4);
+          const st = this.rxChunks.get(token);
+          if (!st) {
+            // Not a chunked transfer we know; pass through
+            this.emit("data_received", {
+              streamId,
+              data: u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength),
+            });
+            return;
+          }
+          st.buffers.push(dataPart);
+          st.received += dataPart.byteLength;
+          if (st.received >= st.expected) {
+            const full = new Uint8Array(st.expected);
+            let offset = 0;
+            for (const b of st.buffers) {
+              full.set(b, offset);
+              offset += b.byteLength;
+            }
+            this.rxChunks.delete(token);
+            this.emit("data_received", { streamId, data: full.buffer });
+          }
+          return;
+        }
+        // Fallback
+        this.emit("data_received", {
+          streamId,
+          data: u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength),
+        });
+      };
+
+      if (typeof raw === "string") {
+        this.emit("data_received", { streamId, data: raw });
+        return;
+      }
+      // Blob (WebKit) → ArrayBuffer
+      if (typeof Blob !== "undefined" && raw instanceof Blob) {
+        raw
+          .arrayBuffer()
+          .then(ab => processBuffer(new Uint8Array(ab)))
+          .catch(() => {
+            this.emit("error", { error: "data_channel_blob_parse_failed", message: raw });
+          });
+        return;
+      }
+      // ArrayBuffer
+      if (raw instanceof ArrayBuffer) {
+        processBuffer(new Uint8Array(raw));
+        return;
+      }
+      // TypedArray/DataView
+      if (ArrayBuffer.isView(raw)) {
+        const view = raw as ArrayBufferView;
+        processBuffer(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
+        return;
+      }
+      // Unknown type, forward as-is
+      this.emit("data_received", { streamId, data: raw });
+    };
+  }
+
+  private async startPublishing(streamId: string): Promise {
+    const pc = this.peers.get(streamId)?.pc ?? this.createPeer(streamId);
+    const stream = this.media.getLocalStream();
+    if (!stream) throw new Error("no_local_stream");
+    if (pc.getSenders().length === 0) {
+      for (const track of stream.getTracks()) pc.addTrack(track, stream);
+    }
+    // create data channel in publish mode like v1
+    try {
+      const dc = pc.createDataChannel
+        ? pc.createDataChannel(streamId, { ordered: true })
+        : undefined;
+      if (dc) this.setupDataChannel(streamId, dc);
+    } catch (e) {
+      this.log.warn("createDataChannel not supported", e);
+    }
+    const offer = await pc.createOffer({ offerToReceiveAudio: false, offerToReceiveVideo: false });
+    await pc.setLocalDescription(offer);
+    this.sendTakeConfiguration(streamId, offer.type, offer.sdp ?? "");
+  }
+
+  private sendTakeConfiguration(streamId: string, type: RTCSdpType, sdp: string): void {
+    if (!this.ws) return;
+    const msg = { command: "takeConfiguration", streamId, type, sdp };
+    this.log.debug("send takeConfiguration %s %s", streamId, type);
+    this.ws.send(JSON.stringify(msg));
+    if (type === "offer") {
+      this.emit("publish_started", { streamId });
+    }
+  }
+
+  /**
+   * Start publishing local tracks to the server for the given stream.
+   * Sends a `publish` command first; upon `start` from server, creates SDP offer.
+   *
+   * Example:
+   * ```ts
+   * const sdk = new WebRTCAdaptor({ websocketURL, mediaConstraints: { audio: true, video: true } });
+   * await sdk.ready();
+   * await sdk.publish('stream1', 'OPTIONAL_TOKEN');
+   * sdk.on('publish_started', ({ streamId }) => console.log('publishing', streamId));
+   * ```
+   */
+  async publish(streamId: string, token?: string): Promise {
+    await this.ready();
+
+    this.log.info("publish %s", streamId);
+    this.activeStreams.set(streamId, { mode: "publish", token });
+
+    const stream = this.media.getLocalStream();
+    const hasVideo = !!stream && stream.getVideoTracks().length > 0;
+    const hasAudio = !!stream && stream.getAudioTracks().length > 0;
+
+    if (this.ws) {
+      const jsCmd = {
+        command: "publish",
+        streamId,
+        token: token ?? "",
+        video: hasVideo,
+        audio: hasAudio,
+      };
+      this.log.debug("send publish %s", streamId);
+      this.ws.send(JSON.stringify(jsCmd));
+    }
+  }
+
+  /**
+   * Start playing the given stream. The server will send an SDP offer that we answer.
+   *
+   * Example:
+   * ```ts
+   * const sdk = new WebRTCAdaptor({ websocketURL, isPlayMode: true, remoteVideo });
+   * await sdk.ready();
+   * await sdk.play('stream1');
+   * sdk.on('play_started', ({ streamId }) => console.log('playing', streamId));
+   * ```
+   */
+  async play(streamId: string, token?: string): Promise {
+    await this.ready();
+
+    this.log.info("play %s", streamId);
+    this.activeStreams.set(streamId, { mode: "play", token });
+
+    const pc = this.createPeer(streamId);
+    // data channel for player: server opens it
+    pc.ondatachannel = ev => this.setupDataChannel(streamId, ev.channel);
+    const ctx = this.peers.get(streamId);
+    if (ctx) ctx.mode = "play";
+
+    if (this.ws) {
+      const jsCmd = {
+        command: "play",
+        streamId,
+        token: token ?? "",
+        room: "",
+        trackList: [],
+        subscriberId: "",
+        subscriberCode: "",
+        viewerInfo: "",
+        role: "",
+        userPublishId: "",
+      };
+      this.ws.send(JSON.stringify(jsCmd));
+    }
+  }
+
+  /**
+   * Selective play helper to fetch only specific subtracks and/or default-disable tracks.
+   *
+   * Example:
+   * ```ts
+   * await sdk.playSelective({
+   *   streamId: 'mainStreamId',
+   *   enableTracks: ['camera_user1', 'screen_user2'],
+   *   disableTracksByDefault: true,
+   * });
+   * ```
+   */
+  async playSelective(opts: PlaySelectiveOptions): Promise {
+    await this.ready();
+    this.log.info("playSelective %s", opts.streamId);
+    this.activeStreams.set(opts.streamId, { mode: "play", token: opts.token });
+    const pc = this.createPeer(opts.streamId);
+    pc.ondatachannel = ev => this.setupDataChannel(opts.streamId, ev.channel);
+    if (this.ws) {
+      const jsCmd = {
+        command: "play",
+        streamId: opts.streamId,
+        token: opts.token ?? "",
+        room: opts.roomId ?? "",
+        trackList: opts.enableTracks ?? [],
+        subscriberId: opts.subscriberId ?? "",
+        subscriberCode: opts.subscriberCode ?? "",
+        viewerInfo: opts.metaData ?? "",
+        role: opts.role ?? "",
+        userPublishId: "",
+        disableTracksByDefault: opts.disableTracksByDefault ?? false,
+      } as any;
+      this.ws.send(JSON.stringify(jsCmd));
+    }
+  }
+
+  /**
+   * Stop an active stream (publish or play) and close its peer connection.
+   */
+  stop(streamId: string): void {
+    const ctx = this.peers.get(streamId);
+    if (ctx) {
+      try {
+        ctx.pc.close();
+      } catch (e) {
+        this.log.warn("pc.close failed", e);
+      }
+      this.peers.delete(streamId);
+    }
+    // mark as intentionally stopped; prevents reconnect
+    this.activeStreams.delete(streamId);
+
+    if (this.ws) {
+      this.ws.send(JSON.stringify({ command: "stop", streamId }));
+    }
+    // optimistic finish events
+    this.emit("publish_finished", { streamId });
+    this.emit("play_finished", { streamId });
+  }
+
+  /**
+   * High-level one-liner to start a session. Resolves when ICE connects or first track is added.
+   *
+   * Examples:
+   * ```ts
+   * // Publish
+   * const sdk = new WebRTCAdaptor({ websocketURL, mediaConstraints: { audio: true, video: true } });
+   * await sdk.ready();
+   * await sdk.join({ role: 'publisher', streamId: 's1', token: 'OPTIONAL' });
+   *
+   * // Play
+   * const viewer = new WebRTCAdaptor({ websocketURL, isPlayMode: true, remoteVideo });
+   * await viewer.ready();
+   * await viewer.join({ role: 'viewer', streamId: 's1' });
+   * ```
+   */
+  async join(options: JoinOptions): Promise {
+    await this.ready();
+    const timeout = options.timeoutMs ?? 15000;
+
+    return await new Promise((resolve, reject) => {
+      const to = setTimeout(() => reject(new Error("join_timeout")), timeout);
+      const onIce = (obj: EventMap["ice_connection_state_changed"]) => {
+        if (
+          obj.streamId === options.streamId &&
+          (obj.state === "connected" || obj.state === "completed")
+        ) {
+          cleanup();
+          resolve({ streamId: options.streamId, state: obj.state });
+        }
+      };
+      const onPlayStarted = (obj: EventMap["play_started"]) => {
+        cleanup();
+        resolve({ streamId: obj.streamId, state: "track_added" });
+      };
+      const onPublishStarted = (obj: EventMap["publish_started"]) => {
+        cleanup();
+        resolve({ streamId: obj.streamId, state: "track_added" });
+      };
+      const onErr = () => {
+        cleanup();
+        reject(new Error("join_failed"));
+      };
+      const cleanup = () => {
+        clearTimeout(to);
+        this.off("ice_connection_state_changed", onIce);
+        this.off("play_started", onPlayStarted);
+        this.off("publish_started", onPublishStarted);
+        this.off("error", onErr);
+      };
+      this.on("ice_connection_state_changed", onIce);
+      this.on("play_started", onPlayStarted);
+      this.on("publish_started", onPublishStarted);
+      this.on("error", onErr);
+
+      if (options.role === "publisher") {
+        this.publish(options.streamId, options.token).catch(onErr);
+      } else {
+        this.play(options.streamId, options.token).catch(onErr);
+      }
+    });
+  }
+
+  /**
+   * Enumerate and group available media devices.
+   */
+  async listDevices(): Promise {
+    return this.media.listDevices();
+  }
+
+  /**
+   * Switch the active camera. Uses replaceTrack under the hood for ongoing sessions.
+   *
+   * Examples:
+   * ```ts
+   * // By deviceId
+   * await sdk.selectVideoInput('abcd-device-id');
+   *
+   * // By facingMode (mobile)
+   * await sdk.selectVideoInput({ facingMode: 'environment' });
+   * ```
+   */
+  async selectVideoInput(source: string | { facingMode: "user" | "environment" }): Promise {
+    await this.media.selectVideoInput(source);
+    await this.applyLocalTracks();
+  }
+
+  /**
+   * Switch the active microphone. Uses replaceTrack under the hood for ongoing sessions.
+   *
+   * Example:
+   * ```ts
+   * await sdk.selectAudioInput('mic-device-id');
+   * ```
+   */
+  async selectAudioInput(deviceId: string): Promise {
+    await this.media.selectAudioInput(deviceId);
+    await this.applyLocalTracks();
+  }
+
+  /**
+   * Join a room for conference/multitrack scenarios.
+   *
+   * Example:
+   * ```ts
+   * await sdk.joinRoom({ roomId: 'my-room', streamId: 'publisher1' });
+   * ```
+   */
+  async joinRoom(opts: RoomJoinOptions): Promise {
+    await this.ready();
+    if (!this.ws) return;
+    const jsCmd = {
+      command: "joinRoom",
+      room: opts.roomId,
+      mainTrack: opts.roomId,
+      streamId: opts.streamId ?? "",
+      mode: "mcu",
+      streamName: "",
+      role: opts.role ?? "",
+      metadata: opts.metaData ?? "",
+    };
+    this.ws.send(JSON.stringify(jsCmd));
+  }
+
+  /**
+   * Leave a previously joined room.
+   *
+   * Example:
+   * ```ts
+   * await sdk.leaveRoom('my-room', 'publisher1');
+   * ```
+   */
+  async leaveRoom(roomId: string, streamId?: string): Promise {
+    await this.ready();
+    if (!this.ws) return;
+    const jsCmd = {
+      command: "leaveFromRoom",
+      room: roomId,
+      mainTrack: roomId,
+      streamId: streamId ?? "",
+    };
+    this.ws.send(JSON.stringify(jsCmd));
+  }
+
+  /**
+   * Enable/disable a specific track under a main track on the server.
+   *
+   * Example:
+   * ```ts
+   * sdk.enableTrack('mainStreamId', 'camera_user3', true);
+   * ```
+   */
+  enableTrack(mainTrackId: string, trackId: string, enabled: boolean): void {
+    if (!this.ws) return;
+    const jsCmd = {
+      command: "enableTrack",
+      streamId: mainTrackId,
+      trackId,
+      enabled,
+    };
+    this.ws.send(JSON.stringify(jsCmd));
+  }
+
+  /**
+   * Force the stream quality to a given height for ABR scenarios.
+   *
+   * Example:
+   * ```ts
+   * sdk.forceStreamQuality('mainStreamId', 720); // or 'auto'
+   * ```
+   */
+  forceStreamQuality(streamId: string, height: number | "auto"): void {
+    if (!this.ws) return;
+    const jsCmd = {
+      command: "forceStreamQuality",
+      streamId,
+      streamHeight: height === "auto" ? "auto" : height,
+    };
+    this.ws.send(JSON.stringify(jsCmd));
+  }
+
+  /**
+   * Begin screen sharing by replacing the outgoing video track; auto-restores when the share ends.
+   *
+   * Example:
+   * ```ts
+   * await sdk.startScreenShare();
+   * ```
+   */
+  async startScreenShare(): Promise {
+    await this.media.startScreenShare();
+    await this.applyLocalTracks();
+  }
+
+  /**
+   * Stop screen sharing and restore the camera track.
+   */
+  async stopScreenShare(): Promise {
+    await this.media.stopScreenShare();
+    await this.applyLocalTracks();
+  }
+
+  /**
+   * Turn off camera hardware: stop local camera track and detach from sender without renegotiation.
+   */
+  async turnOffLocalCamera(): Promise {
+    this.media.turnOffLocalCamera();
+    // Detach track and pause sender encoding to avoid sending frames
+    for (const ctx of this.peers.values()) {
+      const sender = ctx.pc.getSenders().find(s => s.track && s.track.kind === "video");
+      if (!sender) continue;
+      try {
+        await sender.replaceTrack(null);
+      } catch (e) {
+        this.log.warn("replaceTrack(null) failed", e);
+      }
+      try {
+        const params = sender.getParameters();
+        if (params && Array.isArray(params.encodings)) {
+          for (const enc of params.encodings) {
+            (enc as { active?: boolean }).active = false;
+          }
+          await sender.setParameters(params);
+        }
+      } catch (e) {
+        this.log.warn("setParameters pause failed", e);
+      }
+    }
+  }
+
+  /**
+   * Turn on camera hardware: reacquire a camera track if needed and reattach to sender without renegotiation.
+   */
+  async turnOnLocalCamera(): Promise {
+    await this.media.turnOnLocalCamera();
+    await this.applyLocalTracks();
+    // Resume encodings
+    for (const ctx of this.peers.values()) {
+      const sender = ctx.pc.getSenders().find(s => s.track && s.track.kind === "video");
+      if (!sender) continue;
+      try {
+        const params = sender.getParameters();
+        if (params && Array.isArray(params.encodings)) {
+          for (const enc of params.encodings) {
+            (enc as { active?: boolean }).active = true;
+          }
+          await sender.setParameters(params);
+        }
+      } catch (e) {
+        this.log.warn("setParameters resume failed", e);
+      }
+    }
+  }
+
+  /** Mute local microphone (pause audio track). */
+  muteLocalMic(): void {
+    this.media.muteLocalMic();
+  }
+
+  /** Unmute local microphone (resume audio track). */
+  unmuteLocalMic(): void {
+    this.media.unmuteLocalMic();
+  }
+
+  /**
+   * Get a snapshot of WebRTC stats for a given stream and emit `updated_stats`.
+   *
+   * Example:
+   * ```ts
+   * const stats = await sdk.getStats('s1');
+   * if (stats) {
+   *   console.log('bytes sent', stats.totalBytesSent);
+   * }
+   * ```
+   */
+  async getStats(streamId: string): Promise {
+    const ctx = this.peers.get(streamId);
+    if (!ctx) return false;
+    try {
+      const stats = await ctx.pc.getStats();
+      const ps = new (await import("./peer-stats.js")).PeerStats(streamId);
+      let bytesSent = 0,
+        bytesRecv = 0,
+        now = 0;
+      // iterate RTCStats entries and collect parity fields similar to v1
+      stats.forEach(r => {
+        // totals
+        if (r.type === "outbound-rtp") {
+          bytesSent += (r as any).bytesSent || 0;
+          if ((r as any).packetsSent) {
+            if ((r as any).kind === "audio") ps.audioPacketsSent = (r as any).packetsSent;
+            if ((r as any).kind === "video") {
+              ps.videoPacketsSent = (r as any).packetsSent;
+              ps.frameWidth = (r as any).frameWidth ?? ps.frameWidth;
+              ps.frameHeight = (r as any).frameHeight ?? ps.frameHeight;
+              if ((r as any).framesEncoded != null) ps.framesEncoded = (r as any).framesEncoded;
+            }
+          }
+          now = (r as any).timestamp || now;
+        } else if (r.type === "inbound-rtp") {
+          bytesRecv += (r as any).bytesReceived || 0;
+          if ((r as any).packetsReceived) {
+            if ((r as any).kind === "audio") ps.audioPacketsReceived = (r as any).packetsReceived;
+            if ((r as any).kind === "video") ps.videoPacketsReceived = (r as any).packetsReceived;
+          }
+          now = (r as any).timestamp || now;
+        } else if (r.type === "remote-inbound-rtp") {
+          if ((r as any).kind === "audio") {
+            if ((r as any).packetsLost != null) ps.audioPacketsLost = (r as any).packetsLost;
+            if ((r as any).roundTripTime != null) ps.audioRoundTripTime = (r as any).roundTripTime;
+            if ((r as any).jitter != null) ps.audioJitter = (r as any).jitter;
+          } else if ((r as any).kind === "video") {
+            if ((r as any).packetsLost != null) ps.videoPacketsLost = (r as any).packetsLost;
+            if ((r as any).roundTripTime != null) ps.videoRoundTripTime = (r as any).roundTripTime;
+            if ((r as any).jitter != null) ps.videoJitter = (r as any).jitter;
+          }
+        } else if (r.type === "track") {
+          if ((r as any).kind === "video") {
+            if ((r as any).frameWidth != null) ps.frameWidth = (r as any).frameWidth;
+            if ((r as any).frameHeight != null) ps.frameHeight = (r as any).frameHeight;
+            if ((r as any).framesDecoded != null) ps.framesDecoded = (r as any).framesDecoded;
+            if ((r as any).framesDropped != null) ps.framesDropped = (r as any).framesDropped;
+            if ((r as any).framesReceived != null) ps.framesReceived = (r as any).framesReceived;
+          }
+        } else if (r.type === "candidate-pair" && (r as any).state === "succeeded") {
+          if ((r as any).availableOutgoingBitrate != null)
+            ps.availableOutgoingBitrateKbps =
+              ((r as any).availableOutgoingBitrate as number) / 1000;
+          if ((r as any).currentRoundTripTime != null)
+            ps.currentRoundTripTime = (r as any).currentRoundTripTime as number;
+        }
+      });
+      ps.totalBytesSent = bytesSent;
+      ps.totalBytesReceived = bytesRecv;
+      ps.currentTimestamp = now;
+      this.emit("updated_stats", ps);
+      return ps;
+    } catch {
+      return false;
+    }
+  }
+
+  /**
+   * Periodically poll stats for the given stream and emit `updated_stats`.
+   *
+   * Example:
+   * ```ts
+   * sdk.on('updated_stats', (ps) => console.log('stats', ps));
+   * sdk.enableStats('s1', 2000);
+   * ```
+   */
+  enableStats(streamId: string, periodMs = 5000): void {
+    const key = `__stats_${streamId}`;
+
+    if ((this as any)[key]) return;
+
+    (this as any)[key] = setInterval(() => {
+      this.getStats(streamId);
+    }, periodMs);
+  }
+
+  /** Stop periodic stats polling previously enabled by enableStats. */
+  disableStats(streamId: string): void {
+    const key = `__stats_${streamId}`;
+    const timer = (this as unknown as Record)[key] as unknown as
+      | ReturnType
+      | undefined;
+    if (timer) {
+      clearInterval(timer);
+      delete (this as unknown as Record)[key];
+    }
+  }
+
+  /**
+   * Send data over the data channel. Strings are sent as-is; ArrayBuffers are chunked with backpressure.
+   *
+   * Examples:
+   * ```ts
+   * // Text message
+   * sdk.sendData('s1', 'hello world');
+   *
+   * // Binary (ArrayBuffer)
+   * const bytes = new Uint8Array([1,2,3,4]).buffer;
+   * await sdk.sendData('s1', bytes);
+   *
+   * // Listen
+   * sdk.on('data_received', ({ streamId, data }) => {
+   *   if (typeof data === 'string') console.log('text', data);
+   *   else console.log('binary', new Uint8Array(data));
+   * });
+   * ```
+   */
+  async sendData(streamId: string, data: string | ArrayBuffer): Promise {
+    const ctx = this.peers.get(streamId);
+    if (!ctx || !ctx.dc) {
+      this.log.warn("sendData: data channel not available for %s", streamId);
+      throw new Error("data_channel_not_available");
+    }
+    const dc = ctx.dc;
+    if (typeof data === "string") {
+      dc.send(data);
+      return;
+    }
+    // chunked binary similar to v1
+    const CHUNK_SIZE = 16000;
+    const length = (data as ArrayBuffer).byteLength;
+    const token = Math.floor(Math.random() * 999999) | 0;
+    const header = new Int32Array(2);
+    header[0] = token;
+    header[1] = length;
+    dc.send(header);
+
+    let sent = 0;
+    // backpressure
+    dc.bufferedAmountLowThreshold = 1 << 20; // 1MB
+    while (sent < length) {
+      const size = Math.min(length - sent, CHUNK_SIZE);
+      const buffer = new Uint8Array(size + 4);
+      const tokenArray = new Int32Array(1);
+      tokenArray[0] = token;
+      buffer.set(new Uint8Array(tokenArray.buffer, 0, 4), 0);
+      const chunk = new Uint8Array(data as ArrayBuffer, sent, size);
+      buffer.set(chunk, 4);
+      // wait if congested
+      if (dc.bufferedAmount > dc.bufferedAmountLowThreshold) {
+        await new Promise(resolve => {
+          const onlow = () => {
+            (
+              dc as unknown as {
+                removeEventListener: (type: string, listener: (...args: unknown[]) => void) => void;
+              }
+            ).removeEventListener("bufferedamountlow", onlow);
+            resolve();
+          };
+          (
+            dc as unknown as {
+              addEventListener: (
+                type: string,
+                listener: (...args: unknown[]) => void,
+                options?: unknown
+              ) => void;
+            }
+          ).addEventListener("bufferedamountlow", onlow, { once: true } as unknown);
+        });
+      }
+      dc.send(buffer);
+      sent += size;
+    }
+  }
+
+  /** Close signaling and all peers; emit closed. */
+  close(): void {
+    for (const streamId of Array.from(this.peers.keys())) {
+      this.stop(streamId);
+    }
+    try {
+      this.ws?.close();
+    } catch (e) {
+      this.log.warn("ws close failed", e);
+    }
+    this.emit("closed", undefined as unknown as never);
+  }
+
+  private reconnectIfRequired(streamId: string, delayMs = 3000, forceReconnect = false): void {
+    if (!this.autoReconnect) return;
+    if (!this.activeStreams.has(streamId)) return;
+    if (delayMs <= 0) delayMs = 500;
+    if (this.reconnectTimers.has(streamId)) return;
+    const now = Date.now();
+    const last = this.lastReconnectAt.get(streamId) ?? 0;
+    if (!forceReconnect && now - last < 1000) {
+      delayMs = Math.max(delayMs, 1000);
+    }
+    const timer = setTimeout(() => {
+      this.reconnectTimers.delete(streamId);
+      this.tryAgain(streamId, forceReconnect);
+    }, delayMs);
+    this.reconnectTimers.set(streamId, timer);
+  }
+
+  private tryAgain(streamId: string, _forceReconnect: boolean): void {
+    this.lastReconnectAt.set(streamId, Date.now());
+    if (_forceReconnect) {
+      this.log.info("Force reconnect requested for %s", streamId);
+    }
+    const active = this.activeStreams.get(streamId);
+    if (!active) return;
+    // stop first to clean up
+    try {
+      this.stop(streamId);
+    } catch (e) {
+      this.log.warn("stop during reconnect failed", e);
+    }
+    setTimeout(() => {
+      if (active.mode === "publish") {
+        this.log.info("Re-publish attempt for %s", streamId);
+        void this.publish(streamId, active.token).catch(e => this.log.warn("republish failed", e));
+      } else {
+        this.log.info("Re-play attempt for %s", streamId);
+        void this.play(streamId, active.token).catch(e => this.log.warn("replay failed", e));
+      }
+    }, 500);
+  }
+
+  private async applyLocalTracks(): Promise {
+    const stream = this.media.getLocalStream();
+    if (!stream) return;
+    for (const ctx of this.peers.values()) {
+      const senders = ctx.pc.getSenders();
+      for (const track of stream.getTracks()) {
+        const sender = senders.find(s => s.track && s.track.kind === track.kind);
+        if (sender && sender.replaceTrack) {
+          try {
+            await sender.replaceTrack(track);
+          } catch (e) {
+            this.log.warn("replaceTrack failed", e);
+          }
+        } else {
+          try {
+            ctx.pc.addTrack(track, stream);
+          } catch (e) {
+            this.log.warn("addTrack failed", e);
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/packages/webrtc-sdk/src/core/websocket-adaptor.ts b/packages/webrtc-sdk/src/core/websocket-adaptor.ts
new file mode 100644
index 00000000..c84ac4d3
--- /dev/null
+++ b/packages/webrtc-sdk/src/core/websocket-adaptor.ts
@@ -0,0 +1,142 @@
+import { Logger, type LogLevel } from "../utils/logger.js";
+
+import { Emitter } from "./emitter.js";
+import type { EventMap } from "./events.js";
+
+/**
+ * Minimal interface implemented by the signaling transport.
+ */
+export interface IWebSocketAdaptor {
+  isConnected(): boolean;
+  isConnecting(): boolean;
+  send(text: string): void;
+  close(): void;
+}
+
+/**
+ * Configuration options for {@link WebSocketAdaptor}.
+ */
+export interface WebSocketAdaptorOptions {
+  websocketURL?: string;
+  httpEndpointUrl?: string;
+  webrtcadaptor: { notifyEventListeners: (info: string, obj?: unknown) => void };
+  debug?: boolean | LogLevel;
+}
+
+/**
+ * Thin wrapper around WebSocket that adapts Ant Media's signaling protocol
+ * and emits typed events to the adaptor.
+ */
+export class WebSocketAdaptor extends Emitter implements IWebSocketAdaptor {
+  private ws?: WebSocket;
+  private connecting = false;
+  private connected = false;
+  private opts: WebSocketAdaptorOptions;
+  private log: Logger;
+  private pingTimer: ReturnType | null = null;
+
+  /**
+   * Create a new WebSocket adaptor.
+   */
+  constructor(opts: WebSocketAdaptorOptions) {
+    super();
+    this.opts = opts;
+    this.log = new Logger(
+      typeof opts.debug === "string" ? opts.debug : opts.debug ? "debug" : "info"
+    );
+    if (opts.websocketURL || opts.httpEndpointUrl) {
+      this.init();
+    }
+  }
+
+  private startPing(): void {
+    this.clearPing();
+    this.pingTimer = setInterval(() => {
+      this.send(JSON.stringify({ command: "ping" }));
+    }, 3000);
+  }
+
+  private clearPing(): void {
+    if (this.pingTimer) {
+      clearInterval(this.pingTimer);
+      this.pingTimer = null;
+    }
+  }
+
+  private init(): void {
+    if (!this.opts.websocketURL) return;
+    this.connecting = true;
+    this.connected = false;
+    this.log.info("connecting to websocket %s", this.opts.websocketURL);
+    const ws = new WebSocket(this.opts.websocketURL);
+    this.ws = ws;
+
+    ws.onopen = () => {
+      this.connected = true;
+      this.connecting = false;
+      this.log.info("websocket connected");
+      this.startPing();
+      this.opts.webrtcadaptor.notifyEventListeners("initialized");
+    };
+
+    ws.onmessage = ev => {
+      this.log.debug("ws message: %s", ev.data);
+      try {
+        const obj = JSON.parse(ev.data);
+        if (obj && obj.command) {
+          this.opts.webrtcadaptor.notifyEventListeners(obj.command, obj);
+        }
+      } catch (e) {
+        this.log.warn("ws message parse failed", e);
+      }
+    };
+
+    ws.onerror = e => {
+      this.connected = false;
+      this.connecting = false;
+      this.clearPing();
+      this.log.error("websocket error", e);
+      this.opts.webrtcadaptor.notifyEventListeners("error", {
+        error: "WebSocketNotConnected",
+        message: "websocket error",
+      });
+    };
+
+    ws.onclose = ev => {
+      this.connected = false;
+      this.connecting = false;
+      this.clearPing();
+      this.log.warn("websocket closed");
+      this.opts.webrtcadaptor.notifyEventListeners("closed", ev);
+    };
+  }
+
+  isConnected(): boolean {
+    return this.connected;
+  }
+
+  isConnecting(): boolean {
+    return this.connecting;
+  }
+
+  send(text: string): void {
+    if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
+      this.log.warn("send while not connected");
+      try {
+        this.ws?.send(text);
+      } catch {}
+      this.opts.webrtcadaptor.notifyEventListeners("error", {
+        error: "WebSocketNotConnected",
+        message: text,
+      });
+      return;
+    }
+    this.log.debug("send: %s", text);
+    this.ws.send(text);
+  }
+
+  close(): void {
+    this.clearPing();
+    this.ws?.close();
+  }
+}
diff --git a/packages/webrtc-sdk/src/index.ts b/packages/webrtc-sdk/src/index.ts
new file mode 100644
index 00000000..f2e2e1a7
--- /dev/null
+++ b/packages/webrtc-sdk/src/index.ts
@@ -0,0 +1,9 @@
+export * from "./core/types.js";
+export * from "./core/events.js";
+export * from "./core/peer-stats.js";
+export * from "./core/errors.js";
+export * from "./core/emitter.js";
+export * from "./core/websocket-adaptor.js";
+export * from "./core/media-manager.js";
+export * from "./core/webrtc-adaptor.js";
+export * from "./utils/utility.js";
diff --git a/packages/webrtc-sdk/src/utils/logger.ts b/packages/webrtc-sdk/src/utils/logger.ts
new file mode 100644
index 00000000..1693bd55
--- /dev/null
+++ b/packages/webrtc-sdk/src/utils/logger.ts
@@ -0,0 +1,46 @@
+export type LogLevel = "debug" | "info" | "warn" | "error" | "none";
+
+export interface ILogger {
+  level: LogLevel;
+  debug(message: string, ...args: unknown[]): void;
+  info(message: string, ...args: unknown[]): void;
+  warn(message: string, ...args: unknown[]): void;
+  error(message: string, ...args: unknown[]): void;
+}
+
+const order: Record, number> = {
+  debug: 10,
+  info: 20,
+  warn: 30,
+  error: 40,
+};
+
+export class Logger implements ILogger {
+  level: LogLevel;
+
+  constructor(level: LogLevel = "info") {
+    this.level = level;
+  }
+
+  private enabled(target: Exclude): boolean {
+    if (this.level === "none") return false;
+    if (this.level === target) return true;
+    // allow higher-severity logs when level is lower number (debug < info < warn < error)
+    const min = order[this.level as Exclude];
+    const cur = order[target];
+    return cur >= min;
+  }
+
+  debug(message: string, ...args: unknown[]): void {
+    if (this.enabled("debug")) console.debug(`[AMS][DEBUG] ${message}`, ...args);
+  }
+  info(message: string, ...args: unknown[]): void {
+    if (this.enabled("info")) console.info(`[AMS][INFO] ${message}`, ...args);
+  }
+  warn(message: string, ...args: unknown[]): void {
+    if (this.enabled("warn")) console.warn(`[AMS][WARN] ${message}`, ...args);
+  }
+  error(message: string, ...args: unknown[]): void {
+    if (this.enabled("error")) console.error(`[AMS][ERROR] ${message}`, ...args);
+  }
+}
diff --git a/packages/webrtc-sdk/src/utils/utility.ts b/packages/webrtc-sdk/src/utils/utility.ts
new file mode 100644
index 00000000..51cc571a
--- /dev/null
+++ b/packages/webrtc-sdk/src/utils/utility.ts
@@ -0,0 +1,19 @@
+export function generateRandomString(n: number): string {
+  const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+  let out = "";
+  for (let i = 0; i < n; i++) out += chars.charAt(Math.floor(Math.random() * chars.length));
+  return out;
+}
+
+export function getWebSocketURL(location: Location, rtmpForward?: string): string {
+  const appName = location.pathname.substring(1, location.pathname.indexOf("/", 1) + 1);
+  let path = `${location.hostname}:${location.port}/${appName}websocket`;
+  if (typeof rtmpForward !== "undefined") {
+    path += `?rtmpForward=${rtmpForward}`;
+  }
+  let websocketURL = `ws://${path}`;
+  if (location.protocol.startsWith("https")) {
+    websocketURL = `wss://${path}`;
+  }
+  return websocketURL;
+}
diff --git a/packages/webrtc-sdk/test/candidate-queue.test.ts b/packages/webrtc-sdk/test/candidate-queue.test.ts
new file mode 100644
index 00000000..087b6479
--- /dev/null
+++ b/packages/webrtc-sdk/test/candidate-queue.test.ts
@@ -0,0 +1,50 @@
+import { describe, it, expect } from 'vitest';
+import { WebRTCAdaptor } from '../src/core/webrtc-adaptor.js';
+
+class MockPC {
+  localDescription: any;
+  iceConnectionState = 'new';
+  onicecandidate: ((ev: any) => void) | null = null;
+  oniceconnectionstatechange: (() => void) | null = null;
+  ondatachannel: ((ev: any) => void) | null = null;
+  addCount = 0;
+  getSenders() { return []; }
+  addTrack() {}
+  async createAnswer() { return { type: 'answer' as const, sdp: 'v=0\n' }; }
+  async setLocalDescription(desc: any) { this.localDescription = desc; }
+  async setRemoteDescription() {}
+  async addIceCandidate() { this.addCount++; }
+}
+// @ts-ignore
+(global as any).RTCPeerConnection = MockPC;
+// @ts-ignore
+(global as any).RTCSessionDescription = function (x: any) { return x; };
+// @ts-ignore
+(global as any).RTCIceCandidate = function (x: any) { return x; };
+
+
+describe('candidate queueing', () => {
+  it('queues until remote description set then flushes', async () => {
+    const sent: any[] = [];
+    const adaptor = new WebRTCAdaptor({ websocketURL: 'wss://x', isPlayMode: true });
+    // @ts-ignore
+    adaptor['ws'] = { send: (t: string) => sent.push(JSON.parse(t)) } as any;
+    // @ts-ignore
+    adaptor['notify']('initialized', undefined as any);
+
+    await adaptor.play('q1');
+
+    // Before remote description, deliver candidate
+    // @ts-ignore
+    adaptor['notify']('takeCandidate', { streamId: 'q1', label: 0, candidate: 'candidate udp 0' } as any);
+
+    // Now deliver offer to trigger setRemote and answer
+    // @ts-ignore
+    adaptor['notify']('takeConfiguration', { streamId: 'q1', sdp: 'v=0\n', type: 'offer' } as any);
+
+    await new Promise((r) => setTimeout(r, 0));
+
+    const pc: MockPC = (adaptor as any)['peers'].get('q1').pc;
+    expect(pc.addCount).toBe(1);
+  });
+});
diff --git a/packages/webrtc-sdk/test/data-channel.test.ts b/packages/webrtc-sdk/test/data-channel.test.ts
new file mode 100644
index 00000000..3a802483
--- /dev/null
+++ b/packages/webrtc-sdk/test/data-channel.test.ts
@@ -0,0 +1,131 @@
+import { describe, it, expect, vi } from 'vitest';
+import { WebRTCAdaptor } from '../src/core/webrtc-adaptor.js';
+
+if (typeof (global as any).MediaStream === 'undefined') {
+  (global as any).MediaStream = class {} as any;
+}
+
+// mock MediaStream/getUserMedia
+try {
+  Object.defineProperty(globalThis, 'navigator', {
+    value: {
+      mediaDevices: {
+        getUserMedia: vi.fn(async () => {
+          const ms = new MediaStream() as any;
+          ms.getTracks = () => [
+            { kind: 'audio', enabled: true },
+            { kind: 'video', enabled: true },
+          ];
+          ms.getVideoTracks = () => [{ kind: 'video', enabled: true }];
+          ms.getAudioTracks = () => [{ kind: 'audio', enabled: true }];
+          return ms as MediaStream;
+        }),
+        enumerateDevices: vi.fn(async () => []),
+      },
+    },
+    configurable: true,
+  });
+} catch {}
+
+class MockDC {
+  readyState = 'open';
+  onmessage: ((ev: MessageEvent) => void) | null = null;
+  onerror: ((ev: any) => void) | null = null;
+  onopen: (() => void) | null = null;
+  onclose: (() => void) | null = null;
+  bufferedAmount = 0;
+  bufferedAmountLowThreshold = 0;
+  private listeners: Record = {};
+  send(_d: any) {}
+  addEventListener(type: string, fn: any) {
+    this.listeners[type] ||= [];
+    this.listeners[type].push(fn);
+  }
+  removeEventListener(type: string, fn: any) {
+    this.listeners[type] = (this.listeners[type]||[]).filter(f => f!==fn);
+  }
+  emit(type: string) {
+    (this.listeners[type]||[]).forEach(f => f());
+  }
+}
+
+class MockPC {
+  ondatachannel: ((ev: any) => void) | null = null;
+  onicecandidate: ((ev: any) => void) | null = null;
+  oniceconnectionstatechange: (() => void) | null = null;
+  iceConnectionState = 'connected';
+  getSenders(){ return []; }
+  addTrack(){}
+  createDataChannel(_label: string){ return new MockDC() as any; }
+  async createOffer(){ return { type: 'offer' as const, sdp: 'v=0\n' }; }
+  async setLocalDescription(){ }
+}
+// @ts-ignore
+(global as any).RTCPeerConnection = MockPC;
+// @ts-ignore
+(global as any).RTCSessionDescription = function(x:any){ return x; };
+
+describe('Data channel chunking and reassembly', () => {
+  it('reassembles binary chunks into a single ArrayBuffer', async () => {
+    const sent: any[] = [];
+    const adaptor = new WebRTCAdaptor({ websocketURL: 'wss://x', isPlayMode: false, mediaConstraints: { video: false, audio: false } });
+    // @ts-ignore
+    adaptor['ws'] = { send: (t: string) => sent.push(JSON.parse(t)) } as any;
+    // @ts-ignore
+    adaptor['notify']('initialized', undefined as any);
+
+    const streamId = 'sbin';
+    await adaptor.publish(streamId);
+    // trigger start -> creates DC
+    // @ts-ignore
+    adaptor['notify']('start', { streamId } as any);
+
+    // wait until data channel is set
+    let dc: MockDC | undefined;
+    for (let i = 0; i < 10; i++) {
+      // @ts-ignore
+      const ctx = adaptor['peers'].get(streamId);
+      dc = ctx?.dc as unknown as MockDC | undefined;
+      if (dc) break;
+      await new Promise((r) => setTimeout(r, 0));
+    }
+    expect(dc).toBeTruthy();
+
+    const received: any[] = [];
+    adaptor.on('data_received', (e) => received.push(e));
+
+    // simulate incoming header and chunks
+    const payload = new Uint8Array(50000);
+    for (let i=0;i {
+  it('emits and listens', () => {
+    const e = new Emitter();
+    const fn = vi.fn();
+    e.on('a', fn);
+    e.emit('a', { x: 1 });
+    expect(fn).toHaveBeenCalledTimes(1);
+    expect(fn).toHaveBeenCalledWith({ x: 1 });
+  });
+
+  it('once only fires once', () => {
+    const e = new Emitter();
+    const fn = vi.fn();
+    e.once('a', fn);
+    e.emit('a', { x: 1 });
+    e.emit('a', { x: 2 });
+    expect(fn).toHaveBeenCalledTimes(1);
+  });
+
+  it('off removes handler', () => {
+    const e = new Emitter();
+    const fn = vi.fn();
+    e.on('a', fn);
+    e.off('a', fn);
+    e.emit('a', { x: 3 });
+    expect(fn).not.toHaveBeenCalled();
+  });
+});
diff --git a/packages/webrtc-sdk/test/errors.test.ts b/packages/webrtc-sdk/test/errors.test.ts
new file mode 100644
index 00000000..e90beb8f
--- /dev/null
+++ b/packages/webrtc-sdk/test/errors.test.ts
@@ -0,0 +1,49 @@
+import { describe, it, expect } from 'vitest';
+import { WebRTCAdaptor } from '../src/core/webrtc-adaptor.js';
+
+if (typeof (global as any).MediaStream === 'undefined') {
+  (global as any).MediaStream = class {} as any;
+}
+
+class MockPC {
+  onicecandidate: ((ev: any) => void) | null = null;
+  oniceconnectionstatechange: (() => void) | null = null;
+  getSenders() { return []; }
+  addTrack() {}
+}
+// @ts-ignore
+(global as any).RTCPeerConnection = MockPC;
+
+describe('error codes', () => {
+  it('emits structured error events for data channel parse failures', async () => {
+    const adaptor = new WebRTCAdaptor({ websocketURL: 'wss://x', isPlayMode: true });
+    // @ts-ignore
+    adaptor['notify']('initialized', undefined as any);
+
+    // install fake peer and dc (trigger onmessage parsing path)
+    const dc: any = { readyState: 'open', onerror: null, onopen: null, onclose: null, onmessage: null, addEventListener(){}, removeEventListener(){} };
+    // @ts-ignore
+    adaptor['peers'].set('s1', { pc: new RTCPeerConnection() as any, dc });
+    // @ts-ignore
+    adaptor['setupDataChannel']('s1', dc);
+
+    let err: any = null;
+    adaptor.on('error', (e) => { err = e; });
+    // simulate blob parse failure path
+    class FakeBlob {
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      constructor(public _v: any) {}
+      async arrayBuffer(): Promise {
+        return Promise.reject(new Error('fail'));
+      }
+    }
+    // @ts-ignore
+    globalThis.Blob = FakeBlob as any;
+    const blob = new FakeBlob('x') as unknown as Blob;
+    dc.onmessage({ data: blob });
+    await new Promise((r) => setTimeout(r, 0));
+    expect(err).toBeTruthy();
+  });
+});
+
+
diff --git a/packages/webrtc-sdk/test/notifications.test.ts b/packages/webrtc-sdk/test/notifications.test.ts
new file mode 100644
index 00000000..a7d0942d
--- /dev/null
+++ b/packages/webrtc-sdk/test/notifications.test.ts
@@ -0,0 +1,65 @@
+import { describe, it, expect } from 'vitest';
+import { WebRTCAdaptor } from '../src/core/webrtc-adaptor.js';
+
+if (typeof (global as any).MediaStream === 'undefined') {
+  (global as any).MediaStream = class {} as any;
+}
+
+class MockPC {
+  onicecandidate: ((ev: any) => void) | null = null;
+  oniceconnectionstatechange: (() => void) | null = null;
+  getSenders() { return []; }
+  addTrack() {}
+  async createOffer(){ return { type: 'offer' as const, sdp: 'v=0\n' }; }
+  async setLocalDescription() {}
+}
+// @ts-ignore
+(global as any).RTCPeerConnection = MockPC;
+// @ts-ignore
+(global as any).RTCSessionDescription = function(x:any){ return x; };
+
+describe('notifications mapping', () => {
+  it('maps server notifications and closed/server_will_stop', async () => {
+    const adaptor = new WebRTCAdaptor({ websocketURL: 'wss://x', isPlayMode: false, mediaConstraints: { video: false, audio: false } });
+    const events: string[] = [];
+    adaptor.on('publish_started', () => events.push('publish_started'));
+    adaptor.on('publish_finished', () => events.push('publish_finished'));
+    adaptor.on('play_started', () => events.push('play_started'));
+    adaptor.on('play_finished', () => events.push('play_finished'));
+    adaptor.on('closed', () => events.push('closed'));
+    adaptor.on('server_will_stop', () => events.push('server_will_stop'));
+    adaptor.on('notification:subscriberCount' as any, () => events.push('notif:subscriberCount'));
+    adaptor.on('subscriber_count' as any, () => events.push('evt:subscriber_count'));
+
+    // @ts-ignore
+    adaptor['notify']('initialized', undefined as any);
+    // @ts-ignore
+    adaptor['notify']('notification', { definition: 'publish_started', streamId: 's' } as any);
+    // @ts-ignore
+    adaptor['notify']('notification', { definition: 'publish_finished', streamId: 's' } as any);
+    // @ts-ignore
+    adaptor['notify']('notification', { definition: 'play_started', streamId: 's' } as any);
+    // @ts-ignore
+    adaptor['notify']('notification', { definition: 'play_finished', streamId: 's' } as any);
+    // @ts-ignore
+    adaptor['notify']('closed', {} as any);
+    // @ts-ignore
+    adaptor['notify']('server_will_stop', {} as any);
+    // dynamic
+    // @ts-ignore
+    adaptor['notify']('notification', { definition: 'subscriberCount', streamId: 's' } as any);
+
+    expect(events).toEqual([
+      'publish_started',
+      'publish_finished',
+      'play_started',
+      'play_finished',
+      'closed',
+      'server_will_stop',
+      'evt:subscriber_count',
+      'notif:subscriberCount',
+    ]);
+  });
+});
+
+
diff --git a/packages/webrtc-sdk/test/ping.test.ts b/packages/webrtc-sdk/test/ping.test.ts
new file mode 100644
index 00000000..1c9a2c67
--- /dev/null
+++ b/packages/webrtc-sdk/test/ping.test.ts
@@ -0,0 +1,23 @@
+import { describe, it, expect } from 'vitest';
+import { WebSocketAdaptor } from '../src/core/websocket-adaptor.js';
+
+// Test ping without creating a real WebSocket
+
+describe('websocket ping', () => {
+  it('sends periodic ping after startPing', async () => {
+    const sends: any[] = [];
+    const wsa = new WebSocketAdaptor({ webrtcadaptor: { notifyEventListeners: () => {} } } as any);
+    // @ts-ignore private
+    wsa['ws'] = { readyState: 1, send: (t: string) => sends.push(JSON.parse(t)) } as any;
+    // @ts-ignore private
+    wsa['connected'] = true;
+    // @ts-ignore private
+    wsa['startPing']();
+
+    await new Promise((r) => setTimeout(r, 3100));
+    // @ts-ignore private
+    wsa['clearPing']();
+
+    expect(sends.find((m) => m.command === 'ping')).toBeTruthy();
+  });
+});
diff --git a/packages/webrtc-sdk/test/reconnect.test.ts b/packages/webrtc-sdk/test/reconnect.test.ts
new file mode 100644
index 00000000..bfef9b45
--- /dev/null
+++ b/packages/webrtc-sdk/test/reconnect.test.ts
@@ -0,0 +1,73 @@
+import { describe, it, expect } from 'vitest';
+import { WebRTCAdaptor } from '../src/core/webrtc-adaptor.js';
+
+if (typeof (global as any).MediaStream === 'undefined') {
+  (global as any).MediaStream = class {} as any;
+}
+
+// mock MediaStream/getUserMedia
+try {
+  Object.defineProperty(globalThis, 'navigator', {
+    value: {
+      mediaDevices: {
+        getUserMedia: async () => {
+          const ms = new MediaStream() as any;
+          ms.getTracks = () => [];
+          ms.getVideoTracks = () => [];
+          ms.getAudioTracks = () => [];
+          return ms as MediaStream;
+        },
+        enumerateDevices: async () => [],
+      },
+    },
+    configurable: true,
+  });
+} catch {}
+
+class MockPC {
+  localDescription: any;
+  iceConnectionState = 'connected';
+  onicecandidate: ((ev: any) => void) | null = null;
+  oniceconnectionstatechange: (() => void) | null = null;
+  getSenders() { return []; }
+  addTrack() {}
+  async createOffer(){ return { type: 'offer' as const, sdp: 'v=0\n' }; }
+  async setLocalDescription(desc: any) { this.localDescription = desc; }
+  close() { this.iceConnectionState = 'closed'; }
+}
+// @ts-ignore
+(global as any).RTCPeerConnection = MockPC;
+// @ts-ignore
+(global as any).RTCSessionDescription = function(x:any){ return x; };
+
+describe('auto reconnect', () => {
+  it('schedules reconnect on ice disconnected/failed', async () => {
+    const sent: any[] = [];
+    const adaptor = new WebRTCAdaptor({ websocketURL: 'wss://x', isPlayMode: false, mediaConstraints: { audio: false, video: false }, autoReconnect: true });
+    // @ts-ignore
+    adaptor['ws'] = { send: (t: string) => sent.push(JSON.parse(t)) } as any;
+    // @ts-ignore
+    adaptor['notify']('initialized', undefined as any);
+
+    await adaptor.publish('s1');
+    // Simulate server start to create offer
+    // @ts-ignore
+    adaptor['notify']('start', { streamId: 's1' } as any);
+    await new Promise((r) => setTimeout(r, 0));
+
+    // Flip state to failed (immediate reconnect path) and trigger
+    // @ts-ignore
+    const ctx = adaptor['peers'].get('s1');
+    ctx.pc.iceConnectionState = 'failed';
+    ctx.pc.oniceconnectionstatechange && ctx.pc.oniceconnectionstatechange();
+
+    // allow reconnect timers (~500ms + 500ms)
+    await new Promise((r) => setTimeout(r, 1400));
+
+    // Should attempt to stop and then publish again -> look for at least another publish after initial
+    const pubs = sent.filter(m => m.command === 'publish');
+    expect(pubs.length).toBeGreaterThanOrEqual(2);
+  });
+});
+
+
diff --git a/packages/webrtc-sdk/test/stats-disable-close.test.ts b/packages/webrtc-sdk/test/stats-disable-close.test.ts
new file mode 100644
index 00000000..5572a1eb
--- /dev/null
+++ b/packages/webrtc-sdk/test/stats-disable-close.test.ts
@@ -0,0 +1,40 @@
+import { describe, it, expect, vi } from 'vitest';
+import { WebRTCAdaptor } from '../src/core/webrtc-adaptor.js';
+
+if (typeof (global as any).MediaStream === 'undefined') {
+  (global as any).MediaStream = class {} as any;
+}
+
+class MockPC {
+  onicecandidate: ((ev: any) => void) | null = null;
+  oniceconnectionstatechange: (() => void) | null = null;
+  getSenders() { return []; }
+  addTrack() {}
+}
+// @ts-ignore
+(global as any).RTCPeerConnection = MockPC;
+
+describe('disableStats and close', () => {
+  it('stops stats timer and emits closed on close()', async () => {
+    const adaptor = new WebRTCAdaptor({ websocketURL: 'wss://x', isPlayMode: true });
+    // @ts-ignore
+    adaptor['notify']('initialized', undefined as any);
+
+    // Fake ws close
+    // @ts-ignore
+    adaptor['ws'] = { close: vi.fn() } as any;
+
+    // install a dummy timer
+    adaptor.enableStats('s1', 10);
+    adaptor.disableStats('s1');
+    // @ts-ignore check private map
+    expect((adaptor as any)['__stats_s1']).toBeUndefined();
+
+    let sawClosed = false;
+    adaptor.on('closed', () => { sawClosed = true; });
+    adaptor.close();
+    expect(sawClosed).toBe(true);
+  });
+});
+
+
diff --git a/packages/webrtc-sdk/test/stats-parity.test.ts b/packages/webrtc-sdk/test/stats-parity.test.ts
new file mode 100644
index 00000000..2cecb3cf
--- /dev/null
+++ b/packages/webrtc-sdk/test/stats-parity.test.ts
@@ -0,0 +1,43 @@
+import { describe, it, expect } from 'vitest';
+import { WebRTCAdaptor } from '../src/core/webrtc-adaptor.js';
+
+if (typeof (global as any).MediaStream === 'undefined') {
+  (global as any).MediaStream = class {} as any;
+}
+
+class MockPC {
+  async getStats() {
+    const base = Date.now();
+    return new Map([
+      ['out1', { type: 'outbound-rtp', kind: 'video', bytesSent: 5000, packetsSent: 50, frameWidth: 1280, frameHeight: 720, framesEncoded: 100, timestamp: base }],
+      ['in1', { type: 'inbound-rtp', kind: 'audio', bytesReceived: 3000, packetsReceived: 30, timestamp: base }],
+      ['rin1', { type: 'remote-inbound-rtp', kind: 'video', packetsLost: 2, roundTripTime: 0.06, jitter: 0.003 }],
+      ['trk1', { type: 'track', kind: 'video', frameWidth: 1280, frameHeight: 720, framesDecoded: 90, framesDropped: 3, framesReceived: 95 }],
+      ['pair', { type: 'candidate-pair', state: 'succeeded', availableOutgoingBitrate: 4000000, currentRoundTripTime: 0.08 }],
+    ]);
+  }
+}
+// @ts-ignore
+(global as any).RTCPeerConnection = MockPC;
+
+describe('stats parity', () => {
+  it('collects extended fields similar to v1', async () => {
+    const adaptor = new WebRTCAdaptor({ websocketURL: 'wss://x', isPlayMode: true });
+    // @ts-ignore
+    adaptor['notify']('initialized', undefined as any);
+    // @ts-ignore
+    adaptor['peers'].set('s1', { pc: new RTCPeerConnection() as any });
+    const ps = await adaptor.getStats('s1');
+    expect(ps).toBeTruthy();
+    // @ts-ignore
+    expect(ps.frameWidth).toBe(1280);
+    // @ts-ignore
+    expect(ps.videoPacketsSent).toBe(50);
+    // @ts-ignore
+    expect(ps.totalBytesSent).toBe(5000);
+    // @ts-ignore
+    expect(ps.availableOutgoingBitrateKbps).toBe(4000);
+  });
+});
+
+
diff --git a/packages/webrtc-sdk/test/utility.test.ts b/packages/webrtc-sdk/test/utility.test.ts
new file mode 100644
index 00000000..5f2436a2
--- /dev/null
+++ b/packages/webrtc-sdk/test/utility.test.ts
@@ -0,0 +1,19 @@
+import { describe, it, expect } from 'vitest';
+import { generateRandomString, getWebSocketURL } from '../src/utils/utility.js';
+
+describe('utility', () => {
+  it('generates string of length', () => {
+    const s = generateRandomString(8);
+    expect(s).toHaveLength(8);
+  });
+  it('builds ws URL', () => {
+    const loc = {
+      protocol: 'https:',
+      hostname: 'example.com',
+      port: '5443',
+      pathname: '/LiveApp/index.html',
+    } as unknown as Location;
+    const url = getWebSocketURL(loc);
+    expect(url).toBe('wss://example.com:5443/LiveApp/websocket');
+  });
+});
diff --git a/packages/webrtc-sdk/test/webrtc-adaptor.play.test.ts b/packages/webrtc-sdk/test/webrtc-adaptor.play.test.ts
new file mode 100644
index 00000000..34e5d2f1
--- /dev/null
+++ b/packages/webrtc-sdk/test/webrtc-adaptor.play.test.ts
@@ -0,0 +1,48 @@
+import { describe, it, expect } from 'vitest';
+import { WebRTCAdaptor } from '../src/core/webrtc-adaptor.js';
+
+if (typeof (global as any).MediaStream === 'undefined') {
+  (global as any).MediaStream = class {} as any;
+}
+
+// Mock PC with answer
+class MockPC {
+  localDescription: any;
+  iceConnectionState = 'new';
+  onicecandidate: ((ev: any) => void) | null = null;
+  oniceconnectionstatechange: (() => void) | null = null;
+  ondatachannel: ((ev: any) => void) | null = null;
+  getSenders() { return []; }
+  addTrack() {}
+  async createAnswer() { return { type: 'answer' as const, sdp: 'v=0\n' }; }
+  async setLocalDescription(desc: any) { this.localDescription = desc; }
+  async setRemoteDescription() {}
+}
+// @ts-ignore
+(global as any).RTCPeerConnection = MockPC;
+// @ts-ignore
+(global as any).RTCSessionDescription = function (x: any) { return x; };
+
+
+describe('WebRTCAdaptor play flow', () => {
+  it('answers on server offer', async () => {
+    const sent: any[] = [];
+    const adaptor = new WebRTCAdaptor({ websocketURL: 'wss://x', isPlayMode: true });
+    // @ts-ignore
+    adaptor['ws'] = { send: (t: string) => sent.push(JSON.parse(t)) } as any;
+    // @ts-ignore
+    adaptor['notify']('initialized', undefined as any);
+
+    await adaptor.play('v1');
+
+    // server sends offer
+    // @ts-ignore
+    adaptor['notify']('takeConfiguration', { streamId: 'v1', sdp: 'v=0\n', type: 'offer' } as any);
+    await new Promise((r) => setTimeout(r, 0));
+
+    // next should send takeConfiguration answer
+    const msg = sent.find((m) => m.command === 'takeConfiguration' && m.type === 'answer');
+    expect(msg).toBeTruthy();
+    expect(msg.streamId).toBe('v1');
+  });
+});
diff --git a/packages/webrtc-sdk/test/webrtc-adaptor.publish.test.ts b/packages/webrtc-sdk/test/webrtc-adaptor.publish.test.ts
new file mode 100644
index 00000000..a82d0671
--- /dev/null
+++ b/packages/webrtc-sdk/test/webrtc-adaptor.publish.test.ts
@@ -0,0 +1,75 @@
+import { describe, it, expect, vi } from 'vitest';
+import { WebRTCAdaptor } from '../src/core/webrtc-adaptor.js';
+
+// Provide MediaStream in Node if missing
+if (typeof (global as any).MediaStream === 'undefined') {
+  (global as any).MediaStream = class {} as any;
+}
+
+// mock MediaStream and getUserMedia for Node
+try {
+  Object.defineProperty(globalThis, 'navigator', {
+    value: {
+      mediaDevices: {
+        getUserMedia: vi.fn(async () => {
+          const ms = new MediaStream() as any;
+          ms.getTracks = () => [];
+          ms.getVideoTracks = () => [];
+          ms.getAudioTracks = () => [];
+          return ms as MediaStream;
+        }),
+      },
+    },
+    configurable: true,
+  });
+} catch {}
+
+// Mock RTCPeerConnection minimal API
+class MockPC {
+  localDescription: any;
+  iceConnectionState = 'new';
+  onicecandidate: ((ev: any) => void) | null = null;
+  oniceconnectionstatechange: (() => void) | null = null;
+  getSenders() { return []; }
+  addTrack() {}
+  async createOffer() { return { type: 'offer' as const, sdp: 'v=0\n' }; }
+  async setLocalDescription(desc: any) { this.localDescription = desc; }
+}
+// @ts-ignore
+(global as any).RTCPeerConnection = MockPC;
+// @ts-ignore
+(global as any).RTCSessionDescription = function (x: any) { return x; };
+
+// Mock WebSocketAdaptor inside instance by monkey patching send
+
+describe('WebRTCAdaptor publish flow', () => {
+  it('sends publish then takeConfiguration after start', async () => {
+    const sent: any[] = [];
+    const adaptor = new WebRTCAdaptor({ websocketURL: 'wss://x', isPlayMode: false, mediaConstraints: { video: false, audio: false } });
+    // @ts-ignore access private
+    adaptor['ws'] = { send: (t: string) => sent.push(JSON.parse(t)) } as any;
+
+    // Simulate initialized
+    // @ts-ignore
+    adaptor['notify']('initialized', undefined as any);
+
+    await adaptor.publish('s1');
+    // find first publish ignoring the initial getIceServerConfig
+    const firstPublish = sent.find((m) => m.command === 'publish');
+    expect(firstPublish).toBeTruthy();
+    expect(firstPublish.streamId).toBe('s1');
+
+    // Simulate server start
+    // @ts-ignore
+    adaptor['notify']('start', { streamId: 's1' } as any);
+
+    // wait a tick for async offer path
+    await new Promise((r) => setTimeout(r, 0));
+
+    // takeConfiguration should be sent next
+    const msg = sent.find((m) => m.command === 'takeConfiguration');
+    expect(msg).toBeTruthy();
+    expect(msg.streamId).toBe('s1');
+    expect(msg.type).toBe('offer');
+  });
+});
diff --git a/packages/webrtc-sdk/tsconfig.json b/packages/webrtc-sdk/tsconfig.json
new file mode 100644
index 00000000..2ccfbe47
--- /dev/null
+++ b/packages/webrtc-sdk/tsconfig.json
@@ -0,0 +1,20 @@
+{
+  "compilerOptions": {
+    "target": "ES2020",
+    "lib": ["ES2020", "DOM"],
+    "module": "ES2020",
+    "moduleResolution": "Bundler",
+    "declaration": true,
+    "declarationMap": true,
+    "outDir": "dist",
+    "rootDir": "src",
+    "strict": true,
+    "noImplicitAny": true,
+    "skipLibCheck": true,
+    "esModuleInterop": true,
+    "forceConsistentCasingInFileNames": true,
+    "types": ["vitest/globals", "node"]
+  },
+  "include": ["src/**/*", "test/**/*"],
+  "exclude": ["node_modules", "dist", "**/*.test.ts"]
+} 
\ No newline at end of file
diff --git a/packages/webrtc-sdk/typedoc.json b/packages/webrtc-sdk/typedoc.json
new file mode 100644
index 00000000..55e9da1e
--- /dev/null
+++ b/packages/webrtc-sdk/typedoc.json
@@ -0,0 +1,14 @@
+{
+  "$schema": "https://typedoc.org/schema.json",
+  "entryPoints": ["src/index.ts"],
+  "out": "docs",
+  "excludeExternals": true,
+  "excludePrivate": true,
+  "excludeProtected": false,
+  "hideGenerator": true,
+  "exclude": [
+    "**/examples/**",
+    "**/test/**",
+    "**/*.test.ts"
+  ]
+} 
\ No newline at end of file
diff --git a/src/main/webapp/index-new.html b/src/main/webapp/index-new.html
new file mode 100644
index 00000000..17300b7f
--- /dev/null
+++ b/src/main/webapp/index-new.html
@@ -0,0 +1,164 @@
+
+
+
+  WebRTC Samples > New Publish/Play (join/ready)
+  
+  
+  
+  
+
+
+  
+
+
+

WebRTC Samples > New SDK Demo

+
+
+ +
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ Status: Idle +
+
+
+
+ + + + \ No newline at end of file diff --git a/src/main/webapp/samples/publish_webrtc_new.html b/src/main/webapp/samples/publish_webrtc_new.html new file mode 100644 index 00000000..3b610f76 --- /dev/null +++ b/src/main/webapp/samples/publish_webrtc_new.html @@ -0,0 +1,796 @@ + + + + + WebRTC Samples > Publish (New API) + + + + + + + + + +
+
+
+ +
+ +
+ + +
+
+ +
+
+ + +
+ + + +
+ + Video Source + Your + browser doesn't support screen share. You can see supported browsers in this link + + Audio Source + + + Microphone Gain +
+ Audio Quality +
+
+ + +
+
+
+
+ + +
+
+ +
+
+ +
+
+
+ +
+
+ +
+
+ + +
+
+
+ +
+
+   +
+
+ Status: Offline +
+
+ + +
+ + +
+
+
+
+ +
Average Bitrate(Kbps):
+
Latest Bitrate(Kbps): +
+
PacketsLost:
+
Jitter(Secs):
+
Audio Level:
+
+
+
+ +
Round Trip Time(Secs):
+
Source WidthxHeight: x + +
+
On-going WidthxHeight: x
+
On-going FPS:
+ +
+
+
+
+
+ + + +
+ + + + + + + + \ No newline at end of file From 7be53ea1e46f8ab2dcf10c5d0e8be22c6b413df0 Mon Sep 17 00:00:00 2001 From: golgetahir Date: Mon, 18 Aug 2025 16:27:45 +0300 Subject: [PATCH 03/31] Imporve docs and rename webrtc client --- packages/webrtc-sdk/README.md | 20 +- packages/webrtc-sdk/docs/.nojekyll | 1 + packages/webrtc-sdk/docs/assets/highlight.css | 92 ++ packages/webrtc-sdk/docs/assets/icons.js | 15 + packages/webrtc-sdk/docs/assets/icons.svg | 1 + packages/webrtc-sdk/docs/assets/main.js | 59 + packages/webrtc-sdk/docs/assets/navigation.js | 1 + packages/webrtc-sdk/docs/assets/search.js | 1 + packages/webrtc-sdk/docs/assets/style.css | 1412 +++++++++++++++++ packages/webrtc-sdk/docs/classes/Emitter.html | 6 + .../webrtc-sdk/docs/classes/MediaManager.html | 33 + .../webrtc-sdk/docs/classes/PeerStats.html | 28 + .../webrtc-sdk/docs/classes/SDKError.html | 5 + .../webrtc-sdk/docs/classes/WebRTCClient.html | 115 ++ .../docs/classes/WebSocketAdaptor.html | 13 + .../docs/functions/generateRandomString.html | 1 + .../docs/functions/getWebSocketURL.html | 1 + packages/webrtc-sdk/docs/hierarchy.html | 1 + packages/webrtc-sdk/docs/index.html | 50 + .../webrtc-sdk/docs/interfaces/EventMap.html | 24 + .../docs/interfaces/GroupedDevices.html | 9 + .../docs/interfaces/IWebSocketAdaptor.html | 6 + .../docs/interfaces/JoinOptions.html | 21 + .../docs/interfaces/JoinResult.html | 10 + .../docs/interfaces/MediaManagerOptions.html | 4 + .../docs/interfaces/PlaySelectiveOptions.html | 10 + .../docs/interfaces/RoomJoinOptions.html | 5 + .../docs/interfaces/TypedEmitter.html | 4 + .../UpdateVideoTrackAssignmentsOptions.html | 4 + .../docs/interfaces/WebRTCAdaptorOptions.html | 18 + .../interfaces/WebSocketAdaptorOptions.html | 6 + packages/webrtc-sdk/docs/modules.html | 23 + packages/webrtc-sdk/docs/types/ErrorCode.html | 2 + packages/webrtc-sdk/docs/types/Role.html | 6 + packages/webrtc-sdk/examples/play.html | 4 +- packages/webrtc-sdk/examples/publish.html | 4 +- packages/webrtc-sdk/examples/room.html | 4 +- packages/webrtc-sdk/src/core/events.ts | 2 +- packages/webrtc-sdk/src/core/types.ts | 6 +- .../{webrtc-adaptor.ts => webrtc-client.ts} | 45 +- packages/webrtc-sdk/src/index.ts | 2 +- .../webrtc-sdk/test/candidate-queue.test.ts | 4 +- packages/webrtc-sdk/test/data-channel.test.ts | 4 +- packages/webrtc-sdk/test/errors.test.ts | 4 +- .../webrtc-sdk/test/notifications.test.ts | 4 +- packages/webrtc-sdk/test/reconnect.test.ts | 4 +- .../test/stats-disable-close.test.ts | 4 +- packages/webrtc-sdk/test/stats-parity.test.ts | 4 +- .../test/webrtc-adaptor.play.test.ts | 6 +- .../test/webrtc-adaptor.publish.test.ts | 6 +- 50 files changed, 2074 insertions(+), 40 deletions(-) create mode 100644 packages/webrtc-sdk/docs/.nojekyll create mode 100644 packages/webrtc-sdk/docs/assets/highlight.css create mode 100644 packages/webrtc-sdk/docs/assets/icons.js create mode 100644 packages/webrtc-sdk/docs/assets/icons.svg create mode 100644 packages/webrtc-sdk/docs/assets/main.js create mode 100644 packages/webrtc-sdk/docs/assets/navigation.js create mode 100644 packages/webrtc-sdk/docs/assets/search.js create mode 100644 packages/webrtc-sdk/docs/assets/style.css create mode 100644 packages/webrtc-sdk/docs/classes/Emitter.html create mode 100644 packages/webrtc-sdk/docs/classes/MediaManager.html create mode 100644 packages/webrtc-sdk/docs/classes/PeerStats.html create mode 100644 packages/webrtc-sdk/docs/classes/SDKError.html create mode 100644 packages/webrtc-sdk/docs/classes/WebRTCClient.html create mode 100644 packages/webrtc-sdk/docs/classes/WebSocketAdaptor.html create mode 100644 packages/webrtc-sdk/docs/functions/generateRandomString.html create mode 100644 packages/webrtc-sdk/docs/functions/getWebSocketURL.html create mode 100644 packages/webrtc-sdk/docs/hierarchy.html create mode 100644 packages/webrtc-sdk/docs/index.html create mode 100644 packages/webrtc-sdk/docs/interfaces/EventMap.html create mode 100644 packages/webrtc-sdk/docs/interfaces/GroupedDevices.html create mode 100644 packages/webrtc-sdk/docs/interfaces/IWebSocketAdaptor.html create mode 100644 packages/webrtc-sdk/docs/interfaces/JoinOptions.html create mode 100644 packages/webrtc-sdk/docs/interfaces/JoinResult.html create mode 100644 packages/webrtc-sdk/docs/interfaces/MediaManagerOptions.html create mode 100644 packages/webrtc-sdk/docs/interfaces/PlaySelectiveOptions.html create mode 100644 packages/webrtc-sdk/docs/interfaces/RoomJoinOptions.html create mode 100644 packages/webrtc-sdk/docs/interfaces/TypedEmitter.html create mode 100644 packages/webrtc-sdk/docs/interfaces/UpdateVideoTrackAssignmentsOptions.html create mode 100644 packages/webrtc-sdk/docs/interfaces/WebRTCAdaptorOptions.html create mode 100644 packages/webrtc-sdk/docs/interfaces/WebSocketAdaptorOptions.html create mode 100644 packages/webrtc-sdk/docs/modules.html create mode 100644 packages/webrtc-sdk/docs/types/ErrorCode.html create mode 100644 packages/webrtc-sdk/docs/types/Role.html rename packages/webrtc-sdk/src/core/{webrtc-adaptor.ts => webrtc-client.ts} (96%) diff --git a/packages/webrtc-sdk/README.md b/packages/webrtc-sdk/README.md index 785fa83a..38dad0a7 100644 --- a/packages/webrtc-sdk/README.md +++ b/packages/webrtc-sdk/README.md @@ -14,9 +14,9 @@ npm run build ## Usage ```ts -import { WebRTCAdaptor, getWebSocketURL } from 'webrtc-sdk'; +import { WebRTCClient, getWebSocketURL } from 'webrtc-sdk'; -const adaptor = new WebRTCAdaptor({ +const adaptor = new WebRTCClient({ websocketURL: getWebSocketURL('wss://example.com:5443/LiveApp/websocket'), localVideo: document.getElementById('local') as HTMLVideoElement, remoteVideo: document.getElementById('remote') as HTMLVideoElement, @@ -65,6 +65,22 @@ npm run docs Open `docs/index.html` in a browser. +### Architecture and usage guidance + +`WebRTCClient` is the primary API surface. It composes: + +- `WebSocketAdaptor`: handles signaling with Ant Media Server (WS commands, notifications). +- `MediaManager`: handles local media (getUserMedia, device switching, screen share). + +For most applications, call methods on `WebRTCClient` only. It exposes the common +operations you need: `ready()`, `join()`, `publish()`, `play()`, `stop()`, `listDevices()`, +`selectVideoInput()`, `selectAudioInput()`, `startScreenShare()`, `stopScreenShare()`, +`sendData()`, `enableStats()/disableStats()`, room/multitrack helpers, and emits typed events. + +Only use `WebSocketAdaptor` or `MediaManager` directly if you have advanced +customization needs (e.g., custom signaling transport or bespoke media capture). +Otherwise, prefer the higher-level `WebRTCClient` methods. + ### Room / Multitrack quick start ```ts diff --git a/packages/webrtc-sdk/docs/.nojekyll b/packages/webrtc-sdk/docs/.nojekyll new file mode 100644 index 00000000..e2ac6616 --- /dev/null +++ b/packages/webrtc-sdk/docs/.nojekyll @@ -0,0 +1 @@ +TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false. \ No newline at end of file diff --git a/packages/webrtc-sdk/docs/assets/highlight.css b/packages/webrtc-sdk/docs/assets/highlight.css new file mode 100644 index 00000000..c7161b1c --- /dev/null +++ b/packages/webrtc-sdk/docs/assets/highlight.css @@ -0,0 +1,92 @@ +:root { + --light-hl-0: #795E26; + --dark-hl-0: #DCDCAA; + --light-hl-1: #000000; + --dark-hl-1: #D4D4D4; + --light-hl-2: #A31515; + --dark-hl-2: #CE9178; + --light-hl-3: #AF00DB; + --dark-hl-3: #C586C0; + --light-hl-4: #001080; + --dark-hl-4: #9CDCFE; + --light-hl-5: #0000FF; + --dark-hl-5: #569CD6; + --light-hl-6: #0070C1; + --dark-hl-6: #4FC1FF; + --light-hl-7: #267F99; + --dark-hl-7: #4EC9B0; + --light-hl-8: #008000; + --dark-hl-8: #6A9955; + --light-hl-9: #098658; + --dark-hl-9: #B5CEA8; + --light-code-background: #FFFFFF; + --dark-code-background: #1E1E1E; +} + +@media (prefers-color-scheme: light) { :root { + --hl-0: var(--light-hl-0); + --hl-1: var(--light-hl-1); + --hl-2: var(--light-hl-2); + --hl-3: var(--light-hl-3); + --hl-4: var(--light-hl-4); + --hl-5: var(--light-hl-5); + --hl-6: var(--light-hl-6); + --hl-7: var(--light-hl-7); + --hl-8: var(--light-hl-8); + --hl-9: var(--light-hl-9); + --code-background: var(--light-code-background); +} } + +@media (prefers-color-scheme: dark) { :root { + --hl-0: var(--dark-hl-0); + --hl-1: var(--dark-hl-1); + --hl-2: var(--dark-hl-2); + --hl-3: var(--dark-hl-3); + --hl-4: var(--dark-hl-4); + --hl-5: var(--dark-hl-5); + --hl-6: var(--dark-hl-6); + --hl-7: var(--dark-hl-7); + --hl-8: var(--dark-hl-8); + --hl-9: var(--dark-hl-9); + --code-background: var(--dark-code-background); +} } + +:root[data-theme='light'] { + --hl-0: var(--light-hl-0); + --hl-1: var(--light-hl-1); + --hl-2: var(--light-hl-2); + --hl-3: var(--light-hl-3); + --hl-4: var(--light-hl-4); + --hl-5: var(--light-hl-5); + --hl-6: var(--light-hl-6); + --hl-7: var(--light-hl-7); + --hl-8: var(--light-hl-8); + --hl-9: var(--light-hl-9); + --code-background: var(--light-code-background); +} + +:root[data-theme='dark'] { + --hl-0: var(--dark-hl-0); + --hl-1: var(--dark-hl-1); + --hl-2: var(--dark-hl-2); + --hl-3: var(--dark-hl-3); + --hl-4: var(--dark-hl-4); + --hl-5: var(--dark-hl-5); + --hl-6: var(--dark-hl-6); + --hl-7: var(--dark-hl-7); + --hl-8: var(--dark-hl-8); + --hl-9: var(--dark-hl-9); + --code-background: var(--dark-code-background); +} + +.hl-0 { color: var(--hl-0); } +.hl-1 { color: var(--hl-1); } +.hl-2 { color: var(--hl-2); } +.hl-3 { color: var(--hl-3); } +.hl-4 { color: var(--hl-4); } +.hl-5 { color: var(--hl-5); } +.hl-6 { color: var(--hl-6); } +.hl-7 { color: var(--hl-7); } +.hl-8 { color: var(--hl-8); } +.hl-9 { color: var(--hl-9); } +pre, code { background: var(--code-background); } diff --git a/packages/webrtc-sdk/docs/assets/icons.js b/packages/webrtc-sdk/docs/assets/icons.js new file mode 100644 index 00000000..b79c9e89 --- /dev/null +++ b/packages/webrtc-sdk/docs/assets/icons.js @@ -0,0 +1,15 @@ +(function(svg) { + svg.innerHTML = ``; + svg.style.display = 'none'; + if (location.protocol === 'file:') { + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', updateUseElements); + else updateUseElements() + function updateUseElements() { + document.querySelectorAll('use').forEach(el => { + if (el.getAttribute('href').includes('#icon-')) { + el.setAttribute('href', el.getAttribute('href').replace(/.*#/, '#')); + } + }); + } + } +})(document.body.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'svg'))) \ No newline at end of file diff --git a/packages/webrtc-sdk/docs/assets/icons.svg b/packages/webrtc-sdk/docs/assets/icons.svg new file mode 100644 index 00000000..7dead611 --- /dev/null +++ b/packages/webrtc-sdk/docs/assets/icons.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/webrtc-sdk/docs/assets/main.js b/packages/webrtc-sdk/docs/assets/main.js new file mode 100644 index 00000000..d6f13886 --- /dev/null +++ b/packages/webrtc-sdk/docs/assets/main.js @@ -0,0 +1,59 @@ +"use strict"; +"use strict";(()=>{var Ce=Object.create;var ne=Object.defineProperty;var Pe=Object.getOwnPropertyDescriptor;var Oe=Object.getOwnPropertyNames;var _e=Object.getPrototypeOf,Re=Object.prototype.hasOwnProperty;var Me=(t,e)=>()=>(e||t((e={exports:{}}).exports,e),e.exports);var Fe=(t,e,n,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of Oe(e))!Re.call(t,i)&&i!==n&&ne(t,i,{get:()=>e[i],enumerable:!(r=Pe(e,i))||r.enumerable});return t};var De=(t,e,n)=>(n=t!=null?Ce(_e(t)):{},Fe(e||!t||!t.__esModule?ne(n,"default",{value:t,enumerable:!0}):n,t));var ae=Me((se,oe)=>{(function(){var t=function(e){var n=new t.Builder;return n.pipeline.add(t.trimmer,t.stopWordFilter,t.stemmer),n.searchPipeline.add(t.stemmer),e.call(n,n),n.build()};t.version="2.3.9";t.utils={},t.utils.warn=function(e){return function(n){e.console&&console.warn&&console.warn(n)}}(this),t.utils.asString=function(e){return e==null?"":e.toString()},t.utils.clone=function(e){if(e==null)return e;for(var n=Object.create(null),r=Object.keys(e),i=0;i0){var d=t.utils.clone(n)||{};d.position=[a,u],d.index=s.length,s.push(new t.Token(r.slice(a,o),d))}a=o+1}}return s},t.tokenizer.separator=/[\s\-]+/;t.Pipeline=function(){this._stack=[]},t.Pipeline.registeredFunctions=Object.create(null),t.Pipeline.registerFunction=function(e,n){n in this.registeredFunctions&&t.utils.warn("Overwriting existing registered function: "+n),e.label=n,t.Pipeline.registeredFunctions[e.label]=e},t.Pipeline.warnIfFunctionNotRegistered=function(e){var n=e.label&&e.label in this.registeredFunctions;n||t.utils.warn(`Function is not registered with pipeline. This may cause problems when serialising the index. +`,e)},t.Pipeline.load=function(e){var n=new t.Pipeline;return e.forEach(function(r){var i=t.Pipeline.registeredFunctions[r];if(i)n.add(i);else throw new Error("Cannot load unregistered function: "+r)}),n},t.Pipeline.prototype.add=function(){var e=Array.prototype.slice.call(arguments);e.forEach(function(n){t.Pipeline.warnIfFunctionNotRegistered(n),this._stack.push(n)},this)},t.Pipeline.prototype.after=function(e,n){t.Pipeline.warnIfFunctionNotRegistered(n);var r=this._stack.indexOf(e);if(r==-1)throw new Error("Cannot find existingFn");r=r+1,this._stack.splice(r,0,n)},t.Pipeline.prototype.before=function(e,n){t.Pipeline.warnIfFunctionNotRegistered(n);var r=this._stack.indexOf(e);if(r==-1)throw new Error("Cannot find existingFn");this._stack.splice(r,0,n)},t.Pipeline.prototype.remove=function(e){var n=this._stack.indexOf(e);n!=-1&&this._stack.splice(n,1)},t.Pipeline.prototype.run=function(e){for(var n=this._stack.length,r=0;r1&&(oe&&(r=s),o!=e);)i=r-n,s=n+Math.floor(i/2),o=this.elements[s*2];if(o==e||o>e)return s*2;if(ol?d+=2:a==l&&(n+=r[u+1]*i[d+1],u+=2,d+=2);return n},t.Vector.prototype.similarity=function(e){return this.dot(e)/this.magnitude()||0},t.Vector.prototype.toArray=function(){for(var e=new Array(this.elements.length/2),n=1,r=0;n0){var o=s.str.charAt(0),a;o in s.node.edges?a=s.node.edges[o]:(a=new t.TokenSet,s.node.edges[o]=a),s.str.length==1&&(a.final=!0),i.push({node:a,editsRemaining:s.editsRemaining,str:s.str.slice(1)})}if(s.editsRemaining!=0){if("*"in s.node.edges)var l=s.node.edges["*"];else{var l=new t.TokenSet;s.node.edges["*"]=l}if(s.str.length==0&&(l.final=!0),i.push({node:l,editsRemaining:s.editsRemaining-1,str:s.str}),s.str.length>1&&i.push({node:s.node,editsRemaining:s.editsRemaining-1,str:s.str.slice(1)}),s.str.length==1&&(s.node.final=!0),s.str.length>=1){if("*"in s.node.edges)var u=s.node.edges["*"];else{var u=new t.TokenSet;s.node.edges["*"]=u}s.str.length==1&&(u.final=!0),i.push({node:u,editsRemaining:s.editsRemaining-1,str:s.str.slice(1)})}if(s.str.length>1){var d=s.str.charAt(0),y=s.str.charAt(1),p;y in s.node.edges?p=s.node.edges[y]:(p=new t.TokenSet,s.node.edges[y]=p),s.str.length==1&&(p.final=!0),i.push({node:p,editsRemaining:s.editsRemaining-1,str:d+s.str.slice(2)})}}}return r},t.TokenSet.fromString=function(e){for(var n=new t.TokenSet,r=n,i=0,s=e.length;i=e;n--){var r=this.uncheckedNodes[n],i=r.child.toString();i in this.minimizedNodes?r.parent.edges[r.char]=this.minimizedNodes[i]:(r.child._str=i,this.minimizedNodes[i]=r.child),this.uncheckedNodes.pop()}};t.Index=function(e){this.invertedIndex=e.invertedIndex,this.fieldVectors=e.fieldVectors,this.tokenSet=e.tokenSet,this.fields=e.fields,this.pipeline=e.pipeline},t.Index.prototype.search=function(e){return this.query(function(n){var r=new t.QueryParser(e,n);r.parse()})},t.Index.prototype.query=function(e){for(var n=new t.Query(this.fields),r=Object.create(null),i=Object.create(null),s=Object.create(null),o=Object.create(null),a=Object.create(null),l=0;l1?this._b=1:this._b=e},t.Builder.prototype.k1=function(e){this._k1=e},t.Builder.prototype.add=function(e,n){var r=e[this._ref],i=Object.keys(this._fields);this._documents[r]=n||{},this.documentCount+=1;for(var s=0;s=this.length)return t.QueryLexer.EOS;var e=this.str.charAt(this.pos);return this.pos+=1,e},t.QueryLexer.prototype.width=function(){return this.pos-this.start},t.QueryLexer.prototype.ignore=function(){this.start==this.pos&&(this.pos+=1),this.start=this.pos},t.QueryLexer.prototype.backup=function(){this.pos-=1},t.QueryLexer.prototype.acceptDigitRun=function(){var e,n;do e=this.next(),n=e.charCodeAt(0);while(n>47&&n<58);e!=t.QueryLexer.EOS&&this.backup()},t.QueryLexer.prototype.more=function(){return this.pos1&&(e.backup(),e.emit(t.QueryLexer.TERM)),e.ignore(),e.more())return t.QueryLexer.lexText},t.QueryLexer.lexEditDistance=function(e){return e.ignore(),e.acceptDigitRun(),e.emit(t.QueryLexer.EDIT_DISTANCE),t.QueryLexer.lexText},t.QueryLexer.lexBoost=function(e){return e.ignore(),e.acceptDigitRun(),e.emit(t.QueryLexer.BOOST),t.QueryLexer.lexText},t.QueryLexer.lexEOS=function(e){e.width()>0&&e.emit(t.QueryLexer.TERM)},t.QueryLexer.termSeparator=t.tokenizer.separator,t.QueryLexer.lexText=function(e){for(;;){var n=e.next();if(n==t.QueryLexer.EOS)return t.QueryLexer.lexEOS;if(n.charCodeAt(0)==92){e.escapeCharacter();continue}if(n==":")return t.QueryLexer.lexField;if(n=="~")return e.backup(),e.width()>0&&e.emit(t.QueryLexer.TERM),t.QueryLexer.lexEditDistance;if(n=="^")return e.backup(),e.width()>0&&e.emit(t.QueryLexer.TERM),t.QueryLexer.lexBoost;if(n=="+"&&e.width()===1||n=="-"&&e.width()===1)return e.emit(t.QueryLexer.PRESENCE),t.QueryLexer.lexText;if(n.match(t.QueryLexer.termSeparator))return t.QueryLexer.lexTerm}},t.QueryParser=function(e,n){this.lexer=new t.QueryLexer(e),this.query=n,this.currentClause={},this.lexemeIdx=0},t.QueryParser.prototype.parse=function(){this.lexer.run(),this.lexemes=this.lexer.lexemes;for(var e=t.QueryParser.parseClause;e;)e=e(this);return this.query},t.QueryParser.prototype.peekLexeme=function(){return this.lexemes[this.lexemeIdx]},t.QueryParser.prototype.consumeLexeme=function(){var e=this.peekLexeme();return this.lexemeIdx+=1,e},t.QueryParser.prototype.nextClause=function(){var e=this.currentClause;this.query.clause(e),this.currentClause={}},t.QueryParser.parseClause=function(e){var n=e.peekLexeme();if(n!=null)switch(n.type){case t.QueryLexer.PRESENCE:return t.QueryParser.parsePresence;case t.QueryLexer.FIELD:return t.QueryParser.parseField;case t.QueryLexer.TERM:return t.QueryParser.parseTerm;default:var r="expected either a field or a term, found "+n.type;throw n.str.length>=1&&(r+=" with value '"+n.str+"'"),new t.QueryParseError(r,n.start,n.end)}},t.QueryParser.parsePresence=function(e){var n=e.consumeLexeme();if(n!=null){switch(n.str){case"-":e.currentClause.presence=t.Query.presence.PROHIBITED;break;case"+":e.currentClause.presence=t.Query.presence.REQUIRED;break;default:var r="unrecognised presence operator'"+n.str+"'";throw new t.QueryParseError(r,n.start,n.end)}var i=e.peekLexeme();if(i==null){var r="expecting term or field, found nothing";throw new t.QueryParseError(r,n.start,n.end)}switch(i.type){case t.QueryLexer.FIELD:return t.QueryParser.parseField;case t.QueryLexer.TERM:return t.QueryParser.parseTerm;default:var r="expecting term or field, found '"+i.type+"'";throw new t.QueryParseError(r,i.start,i.end)}}},t.QueryParser.parseField=function(e){var n=e.consumeLexeme();if(n!=null){if(e.query.allFields.indexOf(n.str)==-1){var r=e.query.allFields.map(function(o){return"'"+o+"'"}).join(", "),i="unrecognised field '"+n.str+"', possible fields: "+r;throw new t.QueryParseError(i,n.start,n.end)}e.currentClause.fields=[n.str];var s=e.peekLexeme();if(s==null){var i="expecting term, found nothing";throw new t.QueryParseError(i,n.start,n.end)}switch(s.type){case t.QueryLexer.TERM:return t.QueryParser.parseTerm;default:var i="expecting term, found '"+s.type+"'";throw new t.QueryParseError(i,s.start,s.end)}}},t.QueryParser.parseTerm=function(e){var n=e.consumeLexeme();if(n!=null){e.currentClause.term=n.str.toLowerCase(),n.str.indexOf("*")!=-1&&(e.currentClause.usePipeline=!1);var r=e.peekLexeme();if(r==null){e.nextClause();return}switch(r.type){case t.QueryLexer.TERM:return e.nextClause(),t.QueryParser.parseTerm;case t.QueryLexer.FIELD:return e.nextClause(),t.QueryParser.parseField;case t.QueryLexer.EDIT_DISTANCE:return t.QueryParser.parseEditDistance;case t.QueryLexer.BOOST:return t.QueryParser.parseBoost;case t.QueryLexer.PRESENCE:return e.nextClause(),t.QueryParser.parsePresence;default:var i="Unexpected lexeme type '"+r.type+"'";throw new t.QueryParseError(i,r.start,r.end)}}},t.QueryParser.parseEditDistance=function(e){var n=e.consumeLexeme();if(n!=null){var r=parseInt(n.str,10);if(isNaN(r)){var i="edit distance must be numeric";throw new t.QueryParseError(i,n.start,n.end)}e.currentClause.editDistance=r;var s=e.peekLexeme();if(s==null){e.nextClause();return}switch(s.type){case t.QueryLexer.TERM:return e.nextClause(),t.QueryParser.parseTerm;case t.QueryLexer.FIELD:return e.nextClause(),t.QueryParser.parseField;case t.QueryLexer.EDIT_DISTANCE:return t.QueryParser.parseEditDistance;case t.QueryLexer.BOOST:return t.QueryParser.parseBoost;case t.QueryLexer.PRESENCE:return e.nextClause(),t.QueryParser.parsePresence;default:var i="Unexpected lexeme type '"+s.type+"'";throw new t.QueryParseError(i,s.start,s.end)}}},t.QueryParser.parseBoost=function(e){var n=e.consumeLexeme();if(n!=null){var r=parseInt(n.str,10);if(isNaN(r)){var i="boost must be numeric";throw new t.QueryParseError(i,n.start,n.end)}e.currentClause.boost=r;var s=e.peekLexeme();if(s==null){e.nextClause();return}switch(s.type){case t.QueryLexer.TERM:return e.nextClause(),t.QueryParser.parseTerm;case t.QueryLexer.FIELD:return e.nextClause(),t.QueryParser.parseField;case t.QueryLexer.EDIT_DISTANCE:return t.QueryParser.parseEditDistance;case t.QueryLexer.BOOST:return t.QueryParser.parseBoost;case t.QueryLexer.PRESENCE:return e.nextClause(),t.QueryParser.parsePresence;default:var i="Unexpected lexeme type '"+s.type+"'";throw new t.QueryParseError(i,s.start,s.end)}}},function(e,n){typeof define=="function"&&define.amd?define(n):typeof se=="object"?oe.exports=n():e.lunr=n()}(this,function(){return t})})()});var re=[];function G(t,e){re.push({selector:e,constructor:t})}var U=class{constructor(){this.alwaysVisibleMember=null;this.createComponents(document.body),this.ensureFocusedElementVisible(),this.listenForCodeCopies(),window.addEventListener("hashchange",()=>this.ensureFocusedElementVisible()),document.body.style.display||(this.ensureFocusedElementVisible(),this.updateIndexVisibility(),this.scrollToHash())}createComponents(e){re.forEach(n=>{e.querySelectorAll(n.selector).forEach(r=>{r.dataset.hasInstance||(new n.constructor({el:r,app:this}),r.dataset.hasInstance=String(!0))})})}filterChanged(){this.ensureFocusedElementVisible()}showPage(){document.body.style.display&&(console.log("Show page"),document.body.style.removeProperty("display"),this.ensureFocusedElementVisible(),this.updateIndexVisibility(),this.scrollToHash())}scrollToHash(){if(location.hash){console.log("Scorlling");let e=document.getElementById(location.hash.substring(1));if(!e)return;e.scrollIntoView({behavior:"instant",block:"start"})}}ensureActivePageVisible(){let e=document.querySelector(".tsd-navigation .current"),n=e?.parentElement;for(;n&&!n.classList.contains(".tsd-navigation");)n instanceof HTMLDetailsElement&&(n.open=!0),n=n.parentElement;if(e&&!e.checkVisibility()){let r=e.getBoundingClientRect().top-document.documentElement.clientHeight/4;document.querySelector(".site-menu").scrollTop=r}}updateIndexVisibility(){let e=document.querySelector(".tsd-index-content"),n=e?.open;e&&(e.open=!0),document.querySelectorAll(".tsd-index-section").forEach(r=>{r.style.display="block";let i=Array.from(r.querySelectorAll(".tsd-index-link")).every(s=>s.offsetParent==null);r.style.display=i?"none":"block"}),e&&(e.open=n)}ensureFocusedElementVisible(){if(this.alwaysVisibleMember&&(this.alwaysVisibleMember.classList.remove("always-visible"),this.alwaysVisibleMember.firstElementChild.remove(),this.alwaysVisibleMember=null),!location.hash)return;let e=document.getElementById(location.hash.substring(1));if(!e)return;let n=e.parentElement;for(;n&&n.tagName!=="SECTION";)n=n.parentElement;if(n&&n.offsetParent==null){this.alwaysVisibleMember=n,n.classList.add("always-visible");let r=document.createElement("p");r.classList.add("warning"),r.textContent="This member is normally hidden due to your filter settings.",n.prepend(r)}}listenForCodeCopies(){document.querySelectorAll("pre > button").forEach(e=>{let n;e.addEventListener("click",()=>{e.previousElementSibling instanceof HTMLElement&&navigator.clipboard.writeText(e.previousElementSibling.innerText.trim()),e.textContent="Copied!",e.classList.add("visible"),clearTimeout(n),n=setTimeout(()=>{e.classList.remove("visible"),n=setTimeout(()=>{e.textContent="Copy"},100)},1e3)})})}};var ie=(t,e=100)=>{let n;return()=>{clearTimeout(n),n=setTimeout(()=>t(),e)}};var de=De(ae());async function le(t,e){if(!window.searchData)return;let n=await fetch(window.searchData),r=new Blob([await n.arrayBuffer()]).stream().pipeThrough(new DecompressionStream("gzip")),i=await new Response(r).json();t.data=i,t.index=de.Index.load(i.index),e.classList.remove("loading"),e.classList.add("ready")}function he(){let t=document.getElementById("tsd-search");if(!t)return;let e={base:t.dataset.base+"/"},n=document.getElementById("tsd-search-script");t.classList.add("loading"),n&&(n.addEventListener("error",()=>{t.classList.remove("loading"),t.classList.add("failure")}),n.addEventListener("load",()=>{le(e,t)}),le(e,t));let r=document.querySelector("#tsd-search input"),i=document.querySelector("#tsd-search .results");if(!r||!i)throw new Error("The input field or the result list wrapper was not found");let s=!1;i.addEventListener("mousedown",()=>s=!0),i.addEventListener("mouseup",()=>{s=!1,t.classList.remove("has-focus")}),r.addEventListener("focus",()=>t.classList.add("has-focus")),r.addEventListener("blur",()=>{s||(s=!1,t.classList.remove("has-focus"))}),Ae(t,i,r,e)}function Ae(t,e,n,r){n.addEventListener("input",ie(()=>{Ve(t,e,n,r)},200));let i=!1;n.addEventListener("keydown",s=>{i=!0,s.key=="Enter"?Ne(e,n):s.key=="Escape"?n.blur():s.key=="ArrowUp"?ue(e,-1):s.key==="ArrowDown"?ue(e,1):i=!1}),n.addEventListener("keypress",s=>{i&&s.preventDefault()}),document.body.addEventListener("keydown",s=>{s.altKey||s.ctrlKey||s.metaKey||!n.matches(":focus")&&s.key==="/"&&(n.focus(),s.preventDefault())})}function Ve(t,e,n,r){if(!r.index||!r.data)return;e.textContent="";let i=n.value.trim(),s;if(i){let o=i.split(" ").map(a=>a.length?`*${a}*`:"").join(" ");s=r.index.search(o)}else s=[];for(let o=0;oa.score-o.score);for(let o=0,a=Math.min(10,s.length);o`,d=ce(l.name,i);globalThis.DEBUG_SEARCH_WEIGHTS&&(d+=` (score: ${s[o].score.toFixed(2)})`),l.parent&&(d=` + ${ce(l.parent,i)}.${d}`);let y=document.createElement("li");y.classList.value=l.classes??"";let p=document.createElement("a");p.href=r.base+l.url,p.innerHTML=u+d,y.append(p),e.appendChild(y)}}function ue(t,e){let n=t.querySelector(".current");if(!n)n=t.querySelector(e==1?"li:first-child":"li:last-child"),n&&n.classList.add("current");else{let r=n;if(e===1)do r=r.nextElementSibling??void 0;while(r instanceof HTMLElement&&r.offsetParent==null);else do r=r.previousElementSibling??void 0;while(r instanceof HTMLElement&&r.offsetParent==null);r&&(n.classList.remove("current"),r.classList.add("current"))}}function Ne(t,e){let n=t.querySelector(".current");if(n||(n=t.querySelector("li:first-child")),n){let r=n.querySelector("a");r&&(window.location.href=r.href),e.blur()}}function ce(t,e){if(e==="")return t;let n=t.toLocaleLowerCase(),r=e.toLocaleLowerCase(),i=[],s=0,o=n.indexOf(r);for(;o!=-1;)i.push(K(t.substring(s,o)),`${K(t.substring(o,o+r.length))}`),s=o+r.length,o=n.indexOf(r,s);return i.push(K(t.substring(s))),i.join("")}var He={"&":"&","<":"<",">":">","'":"'",'"':"""};function K(t){return t.replace(/[&<>"'"]/g,e=>He[e])}var I=class{constructor(e){this.el=e.el,this.app=e.app}};var F="mousedown",fe="mousemove",H="mouseup",J={x:0,y:0},pe=!1,ee=!1,Be=!1,D=!1,me=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);document.documentElement.classList.add(me?"is-mobile":"not-mobile");me&&"ontouchstart"in document.documentElement&&(Be=!0,F="touchstart",fe="touchmove",H="touchend");document.addEventListener(F,t=>{ee=!0,D=!1;let e=F=="touchstart"?t.targetTouches[0]:t;J.y=e.pageY||0,J.x=e.pageX||0});document.addEventListener(fe,t=>{if(ee&&!D){let e=F=="touchstart"?t.targetTouches[0]:t,n=J.x-(e.pageX||0),r=J.y-(e.pageY||0);D=Math.sqrt(n*n+r*r)>10}});document.addEventListener(H,()=>{ee=!1});document.addEventListener("click",t=>{pe&&(t.preventDefault(),t.stopImmediatePropagation(),pe=!1)});var X=class extends I{constructor(e){super(e),this.className=this.el.dataset.toggle||"",this.el.addEventListener(H,n=>this.onPointerUp(n)),this.el.addEventListener("click",n=>n.preventDefault()),document.addEventListener(F,n=>this.onDocumentPointerDown(n)),document.addEventListener(H,n=>this.onDocumentPointerUp(n))}setActive(e){if(this.active==e)return;this.active=e,document.documentElement.classList.toggle("has-"+this.className,e),this.el.classList.toggle("active",e);let n=(this.active?"to-has-":"from-has-")+this.className;document.documentElement.classList.add(n),setTimeout(()=>document.documentElement.classList.remove(n),500)}onPointerUp(e){D||(this.setActive(!0),e.preventDefault())}onDocumentPointerDown(e){if(this.active){if(e.target.closest(".col-sidebar, .tsd-filter-group"))return;this.setActive(!1)}}onDocumentPointerUp(e){if(!D&&this.active&&e.target.closest(".col-sidebar")){let n=e.target.closest("a");if(n){let r=window.location.href;r.indexOf("#")!=-1&&(r=r.substring(0,r.indexOf("#"))),n.href.substring(0,r.length)==r&&setTimeout(()=>this.setActive(!1),250)}}}};var te;try{te=localStorage}catch{te={getItem(){return null},setItem(){}}}var Q=te;var ye=document.head.appendChild(document.createElement("style"));ye.dataset.for="filters";var Y=class extends I{constructor(e){super(e),this.key=`filter-${this.el.name}`,this.value=this.el.checked,this.el.addEventListener("change",()=>{this.setLocalStorage(this.el.checked)}),this.setLocalStorage(this.fromLocalStorage()),ye.innerHTML+=`html:not(.${this.key}) .tsd-is-${this.el.name} { display: none; } +`,this.app.updateIndexVisibility()}fromLocalStorage(){let e=Q.getItem(this.key);return e?e==="true":this.el.checked}setLocalStorage(e){Q.setItem(this.key,e.toString()),this.value=e,this.handleValueChange()}handleValueChange(){this.el.checked=this.value,document.documentElement.classList.toggle(this.key,this.value),this.app.filterChanged(),this.app.updateIndexVisibility()}};var Z=class extends I{constructor(e){super(e),this.summary=this.el.querySelector(".tsd-accordion-summary"),this.icon=this.summary.querySelector("svg"),this.key=`tsd-accordion-${this.summary.dataset.key??this.summary.textContent.trim().replace(/\s+/g,"-").toLowerCase()}`;let n=Q.getItem(this.key);this.el.open=n?n==="true":this.el.open,this.el.addEventListener("toggle",()=>this.update());let r=this.summary.querySelector("a");r&&r.addEventListener("click",()=>{location.assign(r.href)}),this.update()}update(){this.icon.style.transform=`rotate(${this.el.open?0:-90}deg)`,Q.setItem(this.key,this.el.open.toString())}};function ge(t){let e=Q.getItem("tsd-theme")||"os";t.value=e,ve(e),t.addEventListener("change",()=>{Q.setItem("tsd-theme",t.value),ve(t.value)})}function ve(t){document.documentElement.dataset.theme=t}var Le;function be(){let t=document.getElementById("tsd-nav-script");t&&(t.addEventListener("load",xe),xe())}async function xe(){let t=document.getElementById("tsd-nav-container");if(!t||!window.navigationData)return;let n=await(await fetch(window.navigationData)).arrayBuffer(),r=new Blob([n]).stream().pipeThrough(new DecompressionStream("gzip")),i=await new Response(r).json();Le=t.dataset.base+"/",t.innerHTML="";for(let s of i)we(s,t,[]);window.app.createComponents(t),window.app.showPage(),window.app.ensureActivePageVisible()}function we(t,e,n){let r=e.appendChild(document.createElement("li"));if(t.children){let i=[...n,t.text],s=r.appendChild(document.createElement("details"));s.className=t.class?`${t.class} tsd-index-accordion`:"tsd-index-accordion",s.dataset.key=i.join("$");let o=s.appendChild(document.createElement("summary"));o.className="tsd-accordion-summary",o.innerHTML='',Ee(t,o);let a=s.appendChild(document.createElement("div"));a.className="tsd-accordion-details";let l=a.appendChild(document.createElement("ul"));l.className="tsd-nested-navigation";for(let u of t.children)we(u,l,i)}else Ee(t,r,t.class)}function Ee(t,e,n){if(t.path){let r=e.appendChild(document.createElement("a"));r.href=Le+t.path,n&&(r.className=n),location.pathname===r.pathname&&r.classList.add("current"),t.kind&&(r.innerHTML=``),r.appendChild(document.createElement("span")).textContent=t.text}else e.appendChild(document.createElement("span")).textContent=t.text}G(X,"a[data-toggle]");G(Z,".tsd-index-accordion");G(Y,".tsd-filter-item input[type=checkbox]");var Se=document.getElementById("tsd-theme");Se&&ge(Se);var je=new U;Object.defineProperty(window,"app",{value:je});he();be();})(); +/*! Bundled license information: + +lunr/lunr.js: + (** + * lunr - http://lunrjs.com - A bit like Solr, but much smaller and not as bright - 2.3.9 + * Copyright (C) 2020 Oliver Nightingale + * @license MIT + *) + (*! + * lunr.utils + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.Set + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.tokenizer + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.Pipeline + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.Vector + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.stemmer + * Copyright (C) 2020 Oliver Nightingale + * Includes code from - http://tartarus.org/~martin/PorterStemmer/js.txt + *) + (*! + * lunr.stopWordFilter + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.trimmer + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.TokenSet + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.Index + * Copyright (C) 2020 Oliver Nightingale + *) + (*! + * lunr.Builder + * Copyright (C) 2020 Oliver Nightingale + *) +*/ diff --git a/packages/webrtc-sdk/docs/assets/navigation.js b/packages/webrtc-sdk/docs/assets/navigation.js new file mode 100644 index 00000000..f319c7c2 --- /dev/null +++ b/packages/webrtc-sdk/docs/assets/navigation.js @@ -0,0 +1 @@ +window.navigationData = "data:application/octet-stream;base64,H4sIAAAAAAAAE4XUXU/CMBQG4P/SayJCBJU7A8T4QSQb6IXxoraH0bC1TXsgEuN/N3x3rJzd8r7n2dno9vnLEH6Q9diwUIjgWINZjnPWYyLn3oNv7oOrORY5a7CF0pL1Wu27v8ZxdgRS8RHXPIsBYUopYwCXIkdfJY4RNZ8OXobOmcgGh4Sa/oDvZNLv5wo0VoUwrVFSIxaAD5JbjO1y3qC04Qo0jrg9KUojuBkXm/9lH5aBdqcbAI/OLC3IAayUAB9lyhUKe7p8c4FXaVHks1H6zaIyOr5ckNcxCfhljheVXUwh4TGldor0KHac83UKOQhUK6DcWJGCE2OKuud31qG4ydqCrHwEAissUNDUSo7wriSYieNi8eC9ynQBGj21av0YddHd+7k/cdRlYsUauHSaa+xYl+K3n6W+kXACcW03b/chOJu+vr9tddqlY5BXhje/1c1loMFxhIRraYoUndLZyZkttdgu34z1ynb3psTi8TFMk9e4WKpUsK9/Rgkjo5UGAAA=" \ No newline at end of file diff --git a/packages/webrtc-sdk/docs/assets/search.js b/packages/webrtc-sdk/docs/assets/search.js new file mode 100644 index 00000000..d8a7c962 --- /dev/null +++ b/packages/webrtc-sdk/docs/assets/search.js @@ -0,0 +1 @@ +window.searchData = "data:application/octet-stream;base64,H4sIAAAAAAAAE71dXZPbtpL9L/KrMiHAb78ljmvX98abrO0kD1MuFUfCzDCWSC1Jja/jyn/fAkhKjWY3CVLjPE1iodENnO5G44AEv66q8nO9enn7dfUpL3arl9JLYxHK9arIDmr1cvWu3KvVenWq9quXq+bLUdXf63+6eWwO+9V6td1nda3q1cvV6u/1uY8wOsv/oe7efXj1wy47NmX1y7HJy6I+95cXjarus62qv6fajSoRngzOWj6ru7rcflLNb+9+ntX7CyQJVK1Xx6xSRcMNgjHlsWmOr4vdscyL5rdqP8+aofD1BuX1r/vsy9typ+bZYsldb8ZB7fLsVVnUTZXlRTPPCV4Q0tebtC+32f73fKfKecZYctebUalD2agFdtiC1xuyU3enh3km9CLXK89OTflObcuiUNtmnhFYdJExMGn9q8yLkVwFfnZPURVMpCOdvega0oOAljGK6qZS2eHNzkkZaLxYYVN+UoWTtr7l8rGd7uptld+pynV8tsAzKH7FJNIR1a/Gcqgzov+j/8cd0675YqWHLC8+VNn2k5NO2Hq5StVkP2VN5qbx0nixwqosXQPl3HSxMlVkd3tlJskpq7xAAosV7/L60tGPX35S99lpT6bYgQms6PJkkR9UeWreuk0BbD1HJU7m71Q9MuT2V/dUPpVhQX9uCbYzj1WXNWzk27rahjMUwYn6r6o8HdXuJ/WUbxWJj93CfcKedInypjie6LKP6PaFLUKPCBnMFha7fKZyW+Rq5b+cmtnaLzLXqK/VXm0btfv9PJu011JmMLLPYc4P5/mdbw6SfTZz2glfaA8QnmkQDMB3ZXmYKD1RkznlJ7fUUV1OLXfY0gWJklQ7mS0dFXPFNjPWkYLbUeFY6UIqnSxfeMXQZX477rJuC2gW6R/qOn8oDqpo6hEvmpZ6nsXQUc807A7DZMwr7+9rRa78rsade3hu0+r8L9JJnWetlX8Gs6BHadLnvclt+ZMa8SGq3fN4DdvztJ+Qxs/dtvL6x/evc5Tz6ZjXPpGT56if2ovwRjhtSuaYMrWlH/EGl739MlO4Tb6LMaO7/TnmjK0pvCGTC8s8N6XX0TEnHVlM56h237Xyxszcvk6YB7Pk6ydVNG+zI2VP/5t7NsyLvMmzff6XIkPA6vCF3Zgeydk8RuF2X9Yuus7tlqmpVfWkqs3nfL/f1E05PVsvCIllqo+nu31eP27qJqsal6EOBZwVR2HoX1xjs9HHZLMV3rRy36WTmm+wqQvWWDdTbvouvktmW9V1MoXPfV7k9eMcgIDEP4RQr7GflxmTcTb22TAaGHMBKZ5v1xRK++zLjBCyW39rdIC2fjJcZgAaeT0qlBEXRKJ59rigMSNgUPN/Ag/sna4T8IxxQppxgSScadEEJvlWbbqTx7wsNoaC3Wwfs+LBaS0fl/7GiI1p72cumJ6u0UHM5bQXWHgzzns72TmBsrv7uVrceaP/Tc0+GRJgZ2TIfZ5tO26+rPLaZU22qdRW5U8uIYCbf2Oft9T1gMhpFGwzr3YT2oyLX8y1aMIRdsw20smo3dhucrE5xoULtd+UR1U4O8pA6J9wF1tpPzHCcU6Qyc/jOoxJFwdaZt0c3Fz3kLTQP4lbq/TmLDtnXjqDnxc1y6CbSVZzzLIJxAr1uaV/n7J8r7mQaUspkW+M1kBl79AOxdrQ3FGsrjHm5tzFXJsmUGq452ycLWtGn71ZbJi7f0/O2pudSy0518Bde6C66UoXhzgcCCyrclRVldW0ur7ZNw4go6afcYeasjXrGYZ21tl26bLsQDme+q7r7GH2wG8ucs9gxIXS32zLU0Fyz4i9HEosJE4vHe3zep7mTmCZYn3GtMmL+7I6ZHq7Ma2ZkFim+q4qs902q5tNefcn8wywrZqQuGLUf5a5Ux1qN75C4V7dOwwSNl2mzDxKtTErxCa7HANPq+YFnQ2BZyYfvhzV7vUhbxpFphf4+/j7JV6QXA74SR8d9PVixDMtwzg19/eOekzDKxQVWzLvUSPa8pmOUSXkRdOvSlXvLWag6+r78y+jMITi8prQ1ryacdo2YOWge3thN2VO4s6muRYmjLLpJwamNGVPqsoe1C+n5qHMi4cf86aC1BWjl5VaaMX2VOmmM61gpa6bizfFtjzMnouh1HVzMdMKVmqhFU3ZZPsfvzSqfq9AbcBoH7S+Wus7zLBNan43RbK5zvyH/KDqJjscHecctl/qefopy18z/aJe/XNZT8430X6hZrMIztBMtH+GMbv4GNH+Gcbsoplo/wxjdvVwRuYZxu5qASNzzRy8K0/F7kOVH3XwOM0Alrhm/LO0kxLXjP1fdok4Nuhz02tG66bPbrpQ332VHdQf+a55nFJntbxG23+r/OFxMoTtptfoq18X23I3HTS48VU6f1IzdF4aX6ezKo9HZ53nxlfpdM1Ig9aLK76Oh0M15L/vjlPbhhcTstfVILMyFCPjbgG+FeG1po+sh0TbqxHO/z7+7B/YhL3/6d+vLb6tH0L/w7NswazOnHZgZ7s4GODgOTUjD8JO9a8ZpYn+uybu/YNpxzREr8GFfnCddGuz7jLnk8zAuIox7s2d3KC7HiE15vAZjN0jPMZE7+qQD5c2q/euhXPvgLB684e6e28uB+luDaB4mUEjd+oqr1+1z3XQtCPd8wtbih7X0PIpE/KCvPlhyoZW7EojalXMmYCu+ZVKzfHpDK19+wVq7etwrGbjN+JQTd2fKne4FIdV4HwvDjkaxiC3q3F4m2bcjjPHrM/qrmq2GR/iUxNlSS81yfXgz92YqQceuJ7sEXEPFpRNfv/FMP4/53WjClXN9mLS3Bum5yvGMDzn+2aTTVo//cTQnGHQE8Sej/NX+vAjm7zVZyq8QKXFrqH9aj1r/XStvZgc7lCEzV45ic3Y8oX7qnXbUf3Ymr1oyR7XO7pcL1utJ1AeXaldNRLVNq0Old1Nvfsur7/Li0dV5S3U83UT9TijHBXmz6Kdqti5saPS/Tn0kzU9rX9Q3C/VD0q0t/qqubdZkT2osfKMaOZemrlchscpcL8LjxoJY9D4VXisKQ434c0wgl2sWP3jC9WEarBIwZYD14M/PsviNOjQaWGybGRC50E1P2tI3tvPOvKKBwLLdev3UucpH0os116p+0rVj/jyIl75QGC5bv3Elbtiu/Vyre0FNJfbeRxUEyLX6r9cx+Os3xJZrr85VcUv9/fGgV5lB1VlDhaQQlfaUCwwoXguCw6nRpm+3uZbB+Wo+XK9p2Km5oHAFZ6n35Z8v62UKt4/ZtWwWCE8byhyjf7yOFc9lliunShMhwpnFKVOOomClFA6oxh1GylRiFJjnVGEuuglC9Ch3lnFJ6MX7ZTffXj1ap9TT1zAH59rh2x36Lo7vtjIHd0ML5zmlTrfMj3QiZb/bPfFQV3fbrmm7g16B12Xlldo22cuw+qaXafnfPWJo0LYfrlm614QXuHoZSAuevQT2w56umbL9YwVg0N1LsWg0yxOFYPEjLoWg+76R4pBTr9DMeiKrr4xzxHhrukVKKvsSTkqhG2XawQXbznotFsv13pfVlvVbs/+95Tt88YlG5FC12SIiXKPyhaO5Z5rhpqr3q3cc9HusM0ZGjBjm+Nsw+g2hzHBaZvjYsHoZmOo3Gmr4aJ3Ypsz1Oy4zXHR/aAa+vWMoVbQ9NoM46rSbr1ca3dHm6ta1PyaFavYWbfrja1U56bL9bEnGLgKnzq9mNREn1wgNfNOLaZ10icWWOm80wqHkdInFYOxzjulmI4U5oQCh8jM0wlKbxSAfFAo/fzku6zYlYf3TQXP/u5Phbkfpv6eaja6Y7RUNOdTEvjUCOzdakF1/HG9youd+s/q5dfVk6pq/VLoy5W88W/03Xb3udrv9BenWp3r1bY86NcKVx+7335XegeqW7RNvvdW61tv7Sc3aRJ9/Li+7SXMD+YfTDOxWt+KdZDepLG0mgmrmVytb+U6CG6SKLCaSauZv1rf+lQz32oWrNa3AdUssJqFq/VtSDULrWbRan0bUc0iq1m8Wt/G1Ehjq1myWt8m60DcBGlqNUusZulqfZtSvaX29OrZFh45wQgIwQEmbCiEnnIh1jK6iURit7TREHrWBQmbsAEReuIFiZywMRF67gUJnrBhEXr6RUgO3UZGaARERLa0wREaBBFT8AgbH6FxECSQwoZIGohS0rltiKQGQnpkSxQuJl7IuJI2RlIDISXZ0sZIBizu0sZIaiAkiaa0MZIaCBmQ2m2MpAZCkmhKGyOpgZAkmtLGSGogJBmT0sbI10DIhGrp2xj5BiMyMn0bI18D4ZOh6aOsZtIaiaZvY+QHrNf5NkZ+yKLp2xj5EZcYfBsiP2aDw7ch8jUOPul0vg2Rn/Jm2hAFGgffp/oMbIgCjYNPOl1gQxQYiEinC2yIAp+1M0BrT8AmxcCGKAhZMAMboiBiU0hgYxTEbKINbIyChE20gY1RkLK4BzZGIVsUhDZEIZ/pQhui0EBERntoQxSaKCKjPbQhCjUOPhntIaoQNA4+Ge2hDVGocQjIaA9tiEKNQyDWMrmJI3s6QxuiMGGdLrQhCjUOARlwoQ1R5LHaIxujSLDaIxujSAMRkKEZ2RhFPq/dxijiV6PIxijSQARkuEeokIt47TZGUcxrtzGKNBABmUIiG6Mo5bXbGMUeu77GNkYxj1FsYxQbjMg4im2MYoMRXcnaGMUBO6LYxijmV6PYxig2GJGxGaN628QRGZuxjVGc8HbaGMX8chTbGCUaiJCM98TGKBGs9sTGKOEr78TGKNFAhGTFkNgYJTxGiY1RYrZDZA5JbIwSDURIxntiY5TwcZSgbZEGIiSjOLExSjQQIblvS2yMUj7XpTZGqWD7TG2MUg1ESMZRamOUGozIOEptjFINREj6fGpjlBqM6H2hjVFqdq2kf6Y2RqkGIiJ9KbUxSjUQEekhKdq9aiAi0kNSvIHVSEQk8O1vsK3GIgrXfnSTxmjT5aFdrKfRiCK6LdrHehqPKKbbop2spxGJSLDa32BbjUmUrv3wxvdwv2g360X8tttD+1nPcA3Mrh/taD2NTExTMB7a03oam5gEuP0NtDX0QkxCLAbUg8YmpjHG7IPhGGJ6W4/5B8MyxPTGHjMQhmeIyXAUmIMwTENMY4xZCMM1xGRICsxDGLYhoXHDTIThGxIaN8xFGMYhoXFDbIQwnENC44b4CGFYh4TGTWLWSGOT0LghTkIY5iGhcUOshDDcQ0LjhngJYdiHhMYNMRPC8A8JjRviJoRhIFIaN8ROCMNBpDRuiJ8QhoVIadwQQyEMD5HSuCGOQhgmIqVxQyyFMFxESuPmY75PY5PSuCGmQhg+gsl9iKsQhpFIaYwRWyEMKZHSGCPCQhhaIqUxRpSFMMQEZy/CzVATzDqEaAthyAlmHULEhTD0BLMOIepCGIJCeDSji9gL0dIXHu2VAeZqzdbLo3ldxGGIlsTwGGoXYdfyGB7N7iImQ7RUhheSFCsiM0TLZni0ayI+QxjWghkeQs/wFvTgEKUhDHEhPNqLEashDHdBl6YC8RoibMGjXR5RGyLk630RYqo95M4iBGI3RNgiR4cSIjiEoTGYUEIUh2g5DsbZEMshDJfBORsiOkTkjTgb4jpES3YwzoboDmFIDSb8EeEhDK3BhD+iPIQhNpjwR6SHiMKR8I/wSUlbW9ILGKI+hCE4yK2QQNyHMAwHeTQmEPkhDMVB+xpiP4ThOISgkxUiQIShORhfQxSIiNv9Nb3eIhZExC3jSy+4iAgRcctW0Ssu4kJER4bQU4zoEBG30NGTHONzrna7HdONEXhxyy4mdGMEX0eMkBS0QNSIMASIoOldgdgRYTgQoc+yqMYIwZYhkXS2QByJSNrjSTpbIJpEGDKEiWlElAhDhzAxjagSYQgRJqYRWSJatoSJacSXCMOKCPpsTSDKRBhihIkSRJqIljUhJw2xJiJtsaOLR0SciLTFjl6iEXci0hY7ejFF9IlI2yMXeoFEDIowPImgT/AEIlGEoUoEfYgnEI8i0ngkrhGVItJkJK4RmyLSdCSuEaEiDWki6ONEiRgVaVgTQR/VSUSpSEObCPoMTiJORRreRNDHcBKRKtIL+LiWiFWRXsjHtUS0iuxoFTLLScSrSC/ms5xExIr0Ej7LScSsSMOeMFlOImpFtg910OeSEnErUrQI0ufhiFyR7bMd9CGZROyKbB/voM/JJKJXZPuEB11AScSvSMOh0MlWIn5FGg6FTrYS8SvScCh0spWIX5Htox50spWIYJHt0x70QaDEz3u0D3zQZ4Gyo1jM01hPSn9e8E37VNbt7fnGk6+rTfeoViD6h8C+rsJ49fLr3+tV5HV//e5v1P1N279x0P1N2r+J6P52/5528nqf0f2H+eXvy0Nf+v+01eYyz1y/BlBDq2R0sUomY7J/dve5XWST8CKreaER4fLUDDTHQHM6Jnxs733dm7t+Lx3EoANN+033cPkIGhiEgL2MzkDXS23eHgN2pLCHaKyHSt+P2FT5sTH3IwIrfDiVHttHU1aq+1QdFAcWsDPZ3xVZdndF3rV3RX4yd0WCrqAlPmtJe+N43t31fdff9Q1mRYJ+PN4q0w+yyerHg/3ETD/DD1VceohADykHT/dQ8EVK57+L4qALWL1/NP8hWYfrP8B16cpPQOSzYuYFPYAD9G4/ZKXAG4VgyNAlvbCz3Q/6/0j6bNEPK+6GpStIRlN7w+cY4tBzBOc5XT9jiMOI9LiI7PphIyoN4PyJ8V6ay/3pwI4Ijsdnemi/3QfSPAA75ryN/GLbpY8QeGzCTST5tT5gB3CBmAsb9GlIIA28L2aH3t4NArIYMLv3sN75Is6xBt+LAvMAMEw4R+jeV+g+qQkC2IcLjOCWp07cfIOlvvuy679gDxYqD/bTDifkHKp9Ih2mEZi/gn6ZjvppSeI+n3DjU4fBwpvC8PY5cNv3R4h5gUu+YAdSnKfFlgZRrjcKk9K28hRKt0MPuCjpPpMF/AGk5KQrkVJ2AFp6kFVhUvdZ6/XNb4fMygZw1kJO0LyX134R5f/69/Lg3AVw9FxYmZulH7uLwkFwJbBE4BaE9lrqXX//Nhg59EPJ+Uwn3l+lDcRhUmYLxVZcFQPtCfRYyQHeilP5KIXVBFstGvnP7Y3uQDdcSSW3/vYvdVTmpY66e/cDggddV3AmPKjGXNnUf8gQdCDgGGLOhR5UQ4RsCHVzK8KDas6Xiuq3SmAPAUxibIH5UJWno9rt+velQRKErsvhp68PVd31ocgAkDX8fmngrBj/ZjdYpMCsRJxLtndYA0+CYeRzI9EXJ+XZPv8LVXLAlUIufrUs6wOwRmIX1rzeXu4xhGsJ9KCgr+ZCduh9N9iVPWhF0C9FIefTea0vIzigVAocorckZRE9++X5GldoDswtATcp7b0BMChgRmMLey1W9pfMQaWwyByRrVSN6wGAghyztjJvpEOLBcz/nFbzNvtQFmr1uEVP33ZARK9mMoHf9Yin3DJg3PepvXUO7CpAuPV1DOd7B31x3qB4gBU1p9tcvreFl++BAgCEX2/BaD+H/sY5OBfQ3WIOBShO+Y/lQBHnfefPZYIxgElIuVXgYCLF3lkIuI519bXflY8BF7v6XWmD5kG/Kw06g1WtSLpSTLLbrUJ9NnBml28WgyGBGU24iGgv1TWl1f5y6zCMSJiZ2exq3nAFPgldyuvL66DnxXpiTZ/ldHU2B1V5f18rK9BhqRtwflJaSSmCceb1m2o/7c3pDewJPE2Rcv1urTmOIPpeP6ygVxH1OTiJ+p65wDgqVQ1KjAhOvscVSe0tONCLoFUph5kW29znRV4/ohUcLCIRNxNG2tw3gYQBPBEXSFq4vlyjA02HI07HRnyWJ/IArMwC1oj+niKoHjoumwc6SXrywIrArt19B9T8gQwQclHRXecEDYcuzu6+ussfybUIMjMxN/GVOpSNGqxBkJfgJEs7O4Fh9mmhixq/ixW2Eh1+ehckPUi6cr5rfcoWRBtImOwiDL5LCyQB6CmXrLVkbpeOlrN3o+9SRjBmPFM+wUrG58K+3n0abOFT6Pa8pIm4y5GF7T8w58Q9g5lyEdD2pXaX3uypkQBHlie0OmkPM+xeYDHK8gp9L8arKVMASiz13nZy6cKeG6vC6qs01lHam8PhGgxjMzhXebwtxW5QpkC6W7K8aa2qJ1VtPuf7/aa9egxMJoAk5Lyzzv+ywhySKwGLo06Dtbk4qG4vDoKZDW6vEtmXRFyGMvtSCz5IjXX1Bltf4jEL6IeCTShajLUfbusSv7ef7wpvT0MwAcm4GEovFjvUKe7Si9/NRF8EBZ1hYVfC9icqfQUTdRHdH0n0u5W4k++B6ZlT/UbFmKXGLmgrXL9Y0cF35sEsgfzDljyDz8UDeRAfbN1xkccUprV8dnMTsAF+7gYBZjEAXSfcQqRPR8pTc7AJIbiX5RJeU35S9qYdhljnCAE3B+bDunf6w7oUJRhDHFl6/9LH4NgWziNLgw/2sCGYuYRzH301V3l/bzZe2+5uLhgtcDXuz/Yl68emt4LtDOaNPkgke1KqH0vYEWcKEZwOlptoL9+iN5QwB/QPKkj25Lc77tkMdiIBWMJYlrSVNotguy2t6/yh0E1qqloBXudzoWI625jeNqA7a44A9my9cVmZ7XiBFC4HjpElHrSAqzpLORlh7lkJ6HHsnhP2QD4rAaOXpcBhLzjoEsg6sDtU0wP/rAT0VMkFLvpqFKxxYP3IHuRZ8hQBBDoZ7WLbXTcMQwWOgD3YHOdMYdSzuzfcBUmEQudiFxLuYAGykt16THNRH9erY35U+7xQq5e3H//++/8BK++H6xm1AAA="; \ No newline at end of file diff --git a/packages/webrtc-sdk/docs/assets/style.css b/packages/webrtc-sdk/docs/assets/style.css new file mode 100644 index 00000000..778b9492 --- /dev/null +++ b/packages/webrtc-sdk/docs/assets/style.css @@ -0,0 +1,1412 @@ +:root { + /* Light */ + --light-color-background: #f2f4f8; + --light-color-background-secondary: #eff0f1; + --light-color-warning-text: #222; + --light-color-background-warning: #e6e600; + --light-color-icon-background: var(--light-color-background); + --light-color-accent: #c5c7c9; + --light-color-active-menu-item: var(--light-color-accent); + --light-color-text: #222; + --light-color-text-aside: #6e6e6e; + --light-color-link: #1f70c2; + + --light-color-ts-keyword: #056bd6; + --light-color-ts-project: #b111c9; + --light-color-ts-module: var(--light-color-ts-project); + --light-color-ts-namespace: var(--light-color-ts-project); + --light-color-ts-enum: #7e6f15; + --light-color-ts-enum-member: var(--light-color-ts-enum); + --light-color-ts-variable: #4760ec; + --light-color-ts-function: #572be7; + --light-color-ts-class: #1f70c2; + --light-color-ts-interface: #108024; + --light-color-ts-constructor: var(--light-color-ts-class); + --light-color-ts-property: var(--light-color-ts-variable); + --light-color-ts-method: var(--light-color-ts-function); + --light-color-ts-call-signature: var(--light-color-ts-method); + --light-color-ts-index-signature: var(--light-color-ts-property); + --light-color-ts-constructor-signature: var(--light-color-ts-constructor); + --light-color-ts-parameter: var(--light-color-ts-variable); + /* type literal not included as links will never be generated to it */ + --light-color-ts-type-parameter: #a55c0e; + --light-color-ts-accessor: var(--light-color-ts-property); + --light-color-ts-get-signature: var(--light-color-ts-accessor); + --light-color-ts-set-signature: var(--light-color-ts-accessor); + --light-color-ts-type-alias: #d51270; + /* reference not included as links will be colored with the kind that it points to */ + + --light-external-icon: url("data:image/svg+xml;utf8,"); + --light-color-scheme: light; + + /* Dark */ + --dark-color-background: #2b2e33; + --dark-color-background-secondary: #1e2024; + --dark-color-background-warning: #bebe00; + --dark-color-warning-text: #222; + --dark-color-icon-background: var(--dark-color-background-secondary); + --dark-color-accent: #9096a2; + --dark-color-active-menu-item: #5d5d6a; + --dark-color-text: #f5f5f5; + --dark-color-text-aside: #dddddd; + --dark-color-link: #00aff4; + + --dark-color-ts-keyword: #3399ff; + --dark-color-ts-project: #e358ff; + --dark-color-ts-module: var(--dark-color-ts-project); + --dark-color-ts-namespace: var(--dark-color-ts-project); + --dark-color-ts-enum: #f4d93e; + --dark-color-ts-enum-member: var(--dark-color-ts-enum); + --dark-color-ts-variable: #798dff; + --dark-color-ts-function: #a280ff; + --dark-color-ts-class: #8ac4ff; + --dark-color-ts-interface: #6cff87; + --dark-color-ts-constructor: var(--dark-color-ts-class); + --dark-color-ts-property: var(--dark-color-ts-variable); + --dark-color-ts-method: var(--dark-color-ts-function); + --dark-color-ts-call-signature: var(--dark-color-ts-method); + --dark-color-ts-index-signature: var(--dark-color-ts-property); + --dark-color-ts-constructor-signature: var(--dark-color-ts-constructor); + --dark-color-ts-parameter: var(--dark-color-ts-variable); + /* type literal not included as links will never be generated to it */ + --dark-color-ts-type-parameter: #e07d13; + --dark-color-ts-accessor: var(--dark-color-ts-property); + --dark-color-ts-get-signature: var(--dark-color-ts-accessor); + --dark-color-ts-set-signature: var(--dark-color-ts-accessor); + --dark-color-ts-type-alias: #ff6492; + /* reference not included as links will be colored with the kind that it points to */ + + --dark-external-icon: url("data:image/svg+xml;utf8,"); + --dark-color-scheme: dark; +} + +@media (prefers-color-scheme: light) { + :root { + --color-background: var(--light-color-background); + --color-background-secondary: var(--light-color-background-secondary); + --color-background-warning: var(--light-color-background-warning); + --color-warning-text: var(--light-color-warning-text); + --color-icon-background: var(--light-color-icon-background); + --color-accent: var(--light-color-accent); + --color-active-menu-item: var(--light-color-active-menu-item); + --color-text: var(--light-color-text); + --color-text-aside: var(--light-color-text-aside); + --color-link: var(--light-color-link); + + --color-ts-keyword: var(--light-color-ts-keyword); + --color-ts-module: var(--light-color-ts-module); + --color-ts-namespace: var(--light-color-ts-namespace); + --color-ts-enum: var(--light-color-ts-enum); + --color-ts-enum-member: var(--light-color-ts-enum-member); + --color-ts-variable: var(--light-color-ts-variable); + --color-ts-function: var(--light-color-ts-function); + --color-ts-class: var(--light-color-ts-class); + --color-ts-interface: var(--light-color-ts-interface); + --color-ts-constructor: var(--light-color-ts-constructor); + --color-ts-property: var(--light-color-ts-property); + --color-ts-method: var(--light-color-ts-method); + --color-ts-call-signature: var(--light-color-ts-call-signature); + --color-ts-index-signature: var(--light-color-ts-index-signature); + --color-ts-constructor-signature: var( + --light-color-ts-constructor-signature + ); + --color-ts-parameter: var(--light-color-ts-parameter); + --color-ts-type-parameter: var(--light-color-ts-type-parameter); + --color-ts-accessor: var(--light-color-ts-accessor); + --color-ts-get-signature: var(--light-color-ts-get-signature); + --color-ts-set-signature: var(--light-color-ts-set-signature); + --color-ts-type-alias: var(--light-color-ts-type-alias); + + --external-icon: var(--light-external-icon); + --color-scheme: var(--light-color-scheme); + } +} + +@media (prefers-color-scheme: dark) { + :root { + --color-background: var(--dark-color-background); + --color-background-secondary: var(--dark-color-background-secondary); + --color-background-warning: var(--dark-color-background-warning); + --color-warning-text: var(--dark-color-warning-text); + --color-icon-background: var(--dark-color-icon-background); + --color-accent: var(--dark-color-accent); + --color-active-menu-item: var(--dark-color-active-menu-item); + --color-text: var(--dark-color-text); + --color-text-aside: var(--dark-color-text-aside); + --color-link: var(--dark-color-link); + + --color-ts-keyword: var(--dark-color-ts-keyword); + --color-ts-module: var(--dark-color-ts-module); + --color-ts-namespace: var(--dark-color-ts-namespace); + --color-ts-enum: var(--dark-color-ts-enum); + --color-ts-enum-member: var(--dark-color-ts-enum-member); + --color-ts-variable: var(--dark-color-ts-variable); + --color-ts-function: var(--dark-color-ts-function); + --color-ts-class: var(--dark-color-ts-class); + --color-ts-interface: var(--dark-color-ts-interface); + --color-ts-constructor: var(--dark-color-ts-constructor); + --color-ts-property: var(--dark-color-ts-property); + --color-ts-method: var(--dark-color-ts-method); + --color-ts-call-signature: var(--dark-color-ts-call-signature); + --color-ts-index-signature: var(--dark-color-ts-index-signature); + --color-ts-constructor-signature: var( + --dark-color-ts-constructor-signature + ); + --color-ts-parameter: var(--dark-color-ts-parameter); + --color-ts-type-parameter: var(--dark-color-ts-type-parameter); + --color-ts-accessor: var(--dark-color-ts-accessor); + --color-ts-get-signature: var(--dark-color-ts-get-signature); + --color-ts-set-signature: var(--dark-color-ts-set-signature); + --color-ts-type-alias: var(--dark-color-ts-type-alias); + + --external-icon: var(--dark-external-icon); + --color-scheme: var(--dark-color-scheme); + } +} + +html { + color-scheme: var(--color-scheme); +} + +body { + margin: 0; +} + +:root[data-theme="light"] { + --color-background: var(--light-color-background); + --color-background-secondary: var(--light-color-background-secondary); + --color-background-warning: var(--light-color-background-warning); + --color-warning-text: var(--light-color-warning-text); + --color-icon-background: var(--light-color-icon-background); + --color-accent: var(--light-color-accent); + --color-active-menu-item: var(--light-color-active-menu-item); + --color-text: var(--light-color-text); + --color-text-aside: var(--light-color-text-aside); + --color-link: var(--light-color-link); + + --color-ts-keyword: var(--light-color-ts-keyword); + --color-ts-module: var(--light-color-ts-module); + --color-ts-namespace: var(--light-color-ts-namespace); + --color-ts-enum: var(--light-color-ts-enum); + --color-ts-enum-member: var(--light-color-ts-enum-member); + --color-ts-variable: var(--light-color-ts-variable); + --color-ts-function: var(--light-color-ts-function); + --color-ts-class: var(--light-color-ts-class); + --color-ts-interface: var(--light-color-ts-interface); + --color-ts-constructor: var(--light-color-ts-constructor); + --color-ts-property: var(--light-color-ts-property); + --color-ts-method: var(--light-color-ts-method); + --color-ts-call-signature: var(--light-color-ts-call-signature); + --color-ts-index-signature: var(--light-color-ts-index-signature); + --color-ts-constructor-signature: var( + --light-color-ts-constructor-signature + ); + --color-ts-parameter: var(--light-color-ts-parameter); + --color-ts-type-parameter: var(--light-color-ts-type-parameter); + --color-ts-accessor: var(--light-color-ts-accessor); + --color-ts-get-signature: var(--light-color-ts-get-signature); + --color-ts-set-signature: var(--light-color-ts-set-signature); + --color-ts-type-alias: var(--light-color-ts-type-alias); + + --external-icon: var(--light-external-icon); + --color-scheme: var(--light-color-scheme); +} + +:root[data-theme="dark"] { + --color-background: var(--dark-color-background); + --color-background-secondary: var(--dark-color-background-secondary); + --color-background-warning: var(--dark-color-background-warning); + --color-warning-text: var(--dark-color-warning-text); + --color-icon-background: var(--dark-color-icon-background); + --color-accent: var(--dark-color-accent); + --color-active-menu-item: var(--dark-color-active-menu-item); + --color-text: var(--dark-color-text); + --color-text-aside: var(--dark-color-text-aside); + --color-link: var(--dark-color-link); + + --color-ts-keyword: var(--dark-color-ts-keyword); + --color-ts-module: var(--dark-color-ts-module); + --color-ts-namespace: var(--dark-color-ts-namespace); + --color-ts-enum: var(--dark-color-ts-enum); + --color-ts-enum-member: var(--dark-color-ts-enum-member); + --color-ts-variable: var(--dark-color-ts-variable); + --color-ts-function: var(--dark-color-ts-function); + --color-ts-class: var(--dark-color-ts-class); + --color-ts-interface: var(--dark-color-ts-interface); + --color-ts-constructor: var(--dark-color-ts-constructor); + --color-ts-property: var(--dark-color-ts-property); + --color-ts-method: var(--dark-color-ts-method); + --color-ts-call-signature: var(--dark-color-ts-call-signature); + --color-ts-index-signature: var(--dark-color-ts-index-signature); + --color-ts-constructor-signature: var( + --dark-color-ts-constructor-signature + ); + --color-ts-parameter: var(--dark-color-ts-parameter); + --color-ts-type-parameter: var(--dark-color-ts-type-parameter); + --color-ts-accessor: var(--dark-color-ts-accessor); + --color-ts-get-signature: var(--dark-color-ts-get-signature); + --color-ts-set-signature: var(--dark-color-ts-set-signature); + --color-ts-type-alias: var(--dark-color-ts-type-alias); + + --external-icon: var(--dark-external-icon); + --color-scheme: var(--dark-color-scheme); +} + +.always-visible, +.always-visible .tsd-signatures { + display: inherit !important; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + line-height: 1.2; +} + +h1 > a:not(.link), +h2 > a:not(.link), +h3 > a:not(.link), +h4 > a:not(.link), +h5 > a:not(.link), +h6 > a:not(.link) { + text-decoration: none; + color: var(--color-text); +} + +h1 { + font-size: 1.875rem; + margin: 0.67rem 0; +} + +h2 { + font-size: 1.5rem; + margin: 0.83rem 0; +} + +h3 { + font-size: 1.25rem; + margin: 1rem 0; +} + +h4 { + font-size: 1.05rem; + margin: 1.33rem 0; +} + +h5 { + font-size: 1rem; + margin: 1.5rem 0; +} + +h6 { + font-size: 0.875rem; + margin: 2.33rem 0; +} + +.uppercase { + text-transform: uppercase; +} + +dl, +menu, +ol, +ul { + margin: 1em 0; +} + +dd { + margin: 0 0 0 40px; +} + +.container { + max-width: 1700px; + padding: 0 2rem; +} + +/* Footer */ +footer { + border-top: 1px solid var(--color-accent); + padding-top: 1rem; + padding-bottom: 1rem; + max-height: 3.5rem; +} +.tsd-generator { + margin: 0 1em; +} + +.container-main { + margin: 0 auto; + /* toolbar, footer, margin */ + min-height: calc(100vh - 41px - 56px - 4rem); +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@keyframes fade-out { + from { + opacity: 1; + visibility: visible; + } + to { + opacity: 0; + } +} +@keyframes fade-in-delayed { + 0% { + opacity: 0; + } + 33% { + opacity: 0; + } + 100% { + opacity: 1; + } +} +@keyframes fade-out-delayed { + 0% { + opacity: 1; + visibility: visible; + } + 66% { + opacity: 0; + } + 100% { + opacity: 0; + } +} +@keyframes pop-in-from-right { + from { + transform: translate(100%, 0); + } + to { + transform: translate(0, 0); + } +} +@keyframes pop-out-to-right { + from { + transform: translate(0, 0); + visibility: visible; + } + to { + transform: translate(100%, 0); + } +} +body { + background: var(--color-background); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", + Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + font-size: 16px; + color: var(--color-text); +} + +a { + color: var(--color-link); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} +a.external[target="_blank"] { + background-image: var(--external-icon); + background-position: top 3px right; + background-repeat: no-repeat; + padding-right: 13px; +} + +code, +pre { + font-family: Menlo, Monaco, Consolas, "Courier New", monospace; + padding: 0.2em; + margin: 0; + font-size: 0.875rem; + border-radius: 0.8em; +} + +pre { + position: relative; + white-space: pre; + white-space: pre-wrap; + word-wrap: break-word; + padding: 10px; + border: 1px solid var(--color-accent); +} +pre code { + padding: 0; + font-size: 100%; +} +pre > button { + position: absolute; + top: 10px; + right: 10px; + opacity: 0; + transition: opacity 0.1s; + box-sizing: border-box; +} +pre:hover > button, +pre > button.visible { + opacity: 1; +} + +blockquote { + margin: 1em 0; + padding-left: 1em; + border-left: 4px solid gray; +} + +.tsd-typography { + line-height: 1.333em; +} +.tsd-typography ul { + list-style: square; + padding: 0 0 0 20px; + margin: 0; +} +.tsd-typography .tsd-index-panel h3, +.tsd-index-panel .tsd-typography h3, +.tsd-typography h4, +.tsd-typography h5, +.tsd-typography h6 { + font-size: 1em; +} +.tsd-typography h5, +.tsd-typography h6 { + font-weight: normal; +} +.tsd-typography p, +.tsd-typography ul, +.tsd-typography ol { + margin: 1em 0; +} +.tsd-typography table { + border-collapse: collapse; + border: none; +} +.tsd-typography td, +.tsd-typography th { + padding: 6px 13px; + border: 1px solid var(--color-accent); +} +.tsd-typography thead, +.tsd-typography tr:nth-child(even) { + background-color: var(--color-background-secondary); +} + +.tsd-breadcrumb { + margin: 0; + padding: 0; + color: var(--color-text-aside); +} +.tsd-breadcrumb a { + color: var(--color-text-aside); + text-decoration: none; +} +.tsd-breadcrumb a:hover { + text-decoration: underline; +} +.tsd-breadcrumb li { + display: inline; +} +.tsd-breadcrumb li:after { + content: " / "; +} + +.tsd-comment-tags { + display: flex; + flex-direction: column; +} +dl.tsd-comment-tag-group { + display: flex; + align-items: center; + overflow: hidden; + margin: 0.5em 0; +} +dl.tsd-comment-tag-group dt { + display: flex; + margin-right: 0.5em; + font-size: 0.875em; + font-weight: normal; +} +dl.tsd-comment-tag-group dd { + margin: 0; +} +code.tsd-tag { + padding: 0.25em 0.4em; + border: 0.1em solid var(--color-accent); + margin-right: 0.25em; + font-size: 70%; +} +h1 code.tsd-tag:first-of-type { + margin-left: 0.25em; +} + +dl.tsd-comment-tag-group dd:before, +dl.tsd-comment-tag-group dd:after { + content: " "; +} +dl.tsd-comment-tag-group dd pre, +dl.tsd-comment-tag-group dd:after { + clear: both; +} +dl.tsd-comment-tag-group p { + margin: 0; +} + +.tsd-panel.tsd-comment .lead { + font-size: 1.1em; + line-height: 1.333em; + margin-bottom: 2em; +} +.tsd-panel.tsd-comment .lead:last-child { + margin-bottom: 0; +} + +.tsd-filter-visibility h4 { + font-size: 1rem; + padding-top: 0.75rem; + padding-bottom: 0.5rem; + margin: 0; +} +.tsd-filter-item:not(:last-child) { + margin-bottom: 0.5rem; +} +.tsd-filter-input { + display: flex; + width: fit-content; + width: -moz-fit-content; + align-items: center; + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + cursor: pointer; +} +.tsd-filter-input input[type="checkbox"] { + cursor: pointer; + position: absolute; + width: 1.5em; + height: 1.5em; + opacity: 0; +} +.tsd-filter-input input[type="checkbox"]:disabled { + pointer-events: none; +} +.tsd-filter-input svg { + cursor: pointer; + width: 1.5em; + height: 1.5em; + margin-right: 0.5em; + border-radius: 0.33em; + /* Leaving this at full opacity breaks event listeners on Firefox. + Don't remove unless you know what you're doing. */ + opacity: 0.99; +} +.tsd-filter-input input[type="checkbox"]:focus + svg { + transform: scale(0.95); +} +.tsd-filter-input input[type="checkbox"]:focus:not(:focus-visible) + svg { + transform: scale(1); +} +.tsd-checkbox-background { + fill: var(--color-accent); +} +input[type="checkbox"]:checked ~ svg .tsd-checkbox-checkmark { + stroke: var(--color-text); +} +.tsd-filter-input input:disabled ~ svg > .tsd-checkbox-background { + fill: var(--color-background); + stroke: var(--color-accent); + stroke-width: 0.25rem; +} +.tsd-filter-input input:disabled ~ svg > .tsd-checkbox-checkmark { + stroke: var(--color-accent); +} + +.tsd-theme-toggle { + padding-top: 0.75rem; +} +.tsd-theme-toggle > h4 { + display: inline; + vertical-align: middle; + margin-right: 0.75rem; +} + +.tsd-hierarchy { + list-style: square; + margin: 0; +} +.tsd-hierarchy .target { + font-weight: bold; +} + +.tsd-full-hierarchy:not(:last-child) { + margin-bottom: 1em; + padding-bottom: 1em; + border-bottom: 1px solid var(--color-accent); +} +.tsd-full-hierarchy, +.tsd-full-hierarchy ul { + list-style: none; + margin: 0; + padding: 0; +} +.tsd-full-hierarchy ul { + padding-left: 1.5rem; +} +.tsd-full-hierarchy a { + padding: 0.25rem 0 !important; + font-size: 1rem; + display: inline-flex; + align-items: center; + color: var(--color-text); +} + +.tsd-panel-group.tsd-index-group { + margin-bottom: 0; +} +.tsd-index-panel .tsd-index-list { + list-style: none; + line-height: 1.333em; + margin: 0; + padding: 0.25rem 0 0 0; + overflow: hidden; + display: grid; + grid-template-columns: repeat(3, 1fr); + column-gap: 1rem; + grid-template-rows: auto; +} +@media (max-width: 1024px) { + .tsd-index-panel .tsd-index-list { + grid-template-columns: repeat(2, 1fr); + } +} +@media (max-width: 768px) { + .tsd-index-panel .tsd-index-list { + grid-template-columns: repeat(1, 1fr); + } +} +.tsd-index-panel .tsd-index-list li { + -webkit-page-break-inside: avoid; + -moz-page-break-inside: avoid; + -ms-page-break-inside: avoid; + -o-page-break-inside: avoid; + page-break-inside: avoid; +} + +.tsd-flag { + display: inline-block; + padding: 0.25em 0.4em; + border-radius: 4px; + color: var(--color-comment-tag-text); + background-color: var(--color-comment-tag); + text-indent: 0; + font-size: 75%; + line-height: 1; + font-weight: normal; +} + +.tsd-anchor { + position: relative; + top: -100px; +} + +.tsd-member { + position: relative; +} +.tsd-member .tsd-anchor + h3 { + display: flex; + align-items: center; + margin-top: 0; + margin-bottom: 0; + border-bottom: none; +} + +.tsd-navigation.settings { + margin: 1rem 0; +} +.tsd-navigation > a, +.tsd-navigation .tsd-accordion-summary { + width: calc(100% - 0.25rem); + display: flex; + align-items: center; +} +.tsd-navigation a, +.tsd-navigation summary > span, +.tsd-page-navigation a { + display: flex; + width: calc(100% - 0.25rem); + align-items: center; + padding: 0.25rem; + color: var(--color-text); + text-decoration: none; + box-sizing: border-box; +} +.tsd-navigation a.current, +.tsd-page-navigation a.current { + background: var(--color-active-menu-item); +} +.tsd-navigation a:hover, +.tsd-page-navigation a:hover { + text-decoration: underline; +} +.tsd-navigation ul, +.tsd-page-navigation ul { + margin-top: 0; + margin-bottom: 0; + padding: 0; + list-style: none; +} +.tsd-navigation li, +.tsd-page-navigation li { + padding: 0; + max-width: 100%; +} +.tsd-nested-navigation { + margin-left: 3rem; +} +.tsd-nested-navigation > li > details { + margin-left: -1.5rem; +} +.tsd-small-nested-navigation { + margin-left: 1.5rem; +} +.tsd-small-nested-navigation > li > details { + margin-left: -1.5rem; +} + +.tsd-page-navigation ul { + padding-left: 1.75rem; +} + +#tsd-sidebar-links a { + margin-top: 0; + margin-bottom: 0.5rem; + line-height: 1.25rem; +} +#tsd-sidebar-links a:last-of-type { + margin-bottom: 0; +} + +a.tsd-index-link { + padding: 0.25rem 0 !important; + font-size: 1rem; + line-height: 1.25rem; + display: inline-flex; + align-items: center; + color: var(--color-text); +} +.tsd-accordion-summary { + list-style-type: none; /* hide marker on non-safari */ + outline: none; /* broken on safari, so just hide it */ +} +.tsd-accordion-summary::-webkit-details-marker { + display: none; /* hide marker on safari */ +} +.tsd-accordion-summary, +.tsd-accordion-summary a { + user-select: none; + -moz-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + + cursor: pointer; +} +.tsd-accordion-summary a { + width: calc(100% - 1.5rem); +} +.tsd-accordion-summary > * { + margin-top: 0; + margin-bottom: 0; + padding-top: 0; + padding-bottom: 0; +} +.tsd-index-accordion .tsd-accordion-summary > svg { + margin-left: 0.25rem; +} +.tsd-index-content > :not(:first-child) { + margin-top: 0.75rem; +} +.tsd-index-heading { + margin-top: 1.5rem; + margin-bottom: 0.75rem; +} + +.tsd-kind-icon { + margin-right: 0.5rem; + width: 1.25rem; + height: 1.25rem; + min-width: 1.25rem; + min-height: 1.25rem; +} +.tsd-kind-icon path { + transform-origin: center; + transform: scale(1.1); +} +.tsd-signature > .tsd-kind-icon { + margin-right: 0.8rem; +} + +.tsd-panel { + margin-bottom: 2.5rem; +} +.tsd-panel.tsd-member { + margin-bottom: 4rem; +} +.tsd-panel:empty { + display: none; +} +.tsd-panel > h1, +.tsd-panel > h2, +.tsd-panel > h3 { + margin: 1.5rem -1.5rem 0.75rem -1.5rem; + padding: 0 1.5rem 0.75rem 1.5rem; +} +.tsd-panel > h1.tsd-before-signature, +.tsd-panel > h2.tsd-before-signature, +.tsd-panel > h3.tsd-before-signature { + margin-bottom: 0; + border-bottom: none; +} + +.tsd-panel-group { + margin: 4rem 0; +} +.tsd-panel-group.tsd-index-group { + margin: 2rem 0; +} +.tsd-panel-group.tsd-index-group details { + margin: 2rem 0; +} + +#tsd-search { + transition: background-color 0.2s; +} +#tsd-search .title { + position: relative; + z-index: 2; +} +#tsd-search .field { + position: absolute; + left: 0; + top: 0; + right: 2.5rem; + height: 100%; +} +#tsd-search .field input { + box-sizing: border-box; + position: relative; + top: -50px; + z-index: 1; + width: 100%; + padding: 0 10px; + opacity: 0; + outline: 0; + border: 0; + background: transparent; + color: var(--color-text); +} +#tsd-search .field label { + position: absolute; + overflow: hidden; + right: -40px; +} +#tsd-search .field input, +#tsd-search .title, +#tsd-toolbar-links a { + transition: opacity 0.2s; +} +#tsd-search .results { + position: absolute; + visibility: hidden; + top: 40px; + width: 100%; + margin: 0; + padding: 0; + list-style: none; + box-shadow: 0 0 4px rgba(0, 0, 0, 0.25); +} +#tsd-search .results li { + background-color: var(--color-background); + line-height: initial; + padding: 4px; +} +#tsd-search .results li:nth-child(even) { + background-color: var(--color-background-secondary); +} +#tsd-search .results li.state { + display: none; +} +#tsd-search .results li.current:not(.no-results), +#tsd-search .results li:hover:not(.no-results) { + background-color: var(--color-accent); +} +#tsd-search .results a { + display: flex; + align-items: center; + padding: 0.25rem; + box-sizing: border-box; +} +#tsd-search .results a:before { + top: 10px; +} +#tsd-search .results span.parent { + color: var(--color-text-aside); + font-weight: normal; +} +#tsd-search.has-focus { + background-color: var(--color-accent); +} +#tsd-search.has-focus .field input { + top: 0; + opacity: 1; +} +#tsd-search.has-focus .title, +#tsd-search.has-focus #tsd-toolbar-links a { + z-index: 0; + opacity: 0; +} +#tsd-search.has-focus .results { + visibility: visible; +} +#tsd-search.loading .results li.state.loading { + display: block; +} +#tsd-search.failure .results li.state.failure { + display: block; +} + +#tsd-toolbar-links { + position: absolute; + top: 0; + right: 2rem; + height: 100%; + display: flex; + align-items: center; + justify-content: flex-end; +} +#tsd-toolbar-links a { + margin-left: 1.5rem; +} +#tsd-toolbar-links a:hover { + text-decoration: underline; +} + +.tsd-signature { + margin: 0 0 1rem 0; + padding: 1rem 0.5rem; + border: 1px solid var(--color-accent); + font-family: Menlo, Monaco, Consolas, "Courier New", monospace; + font-size: 14px; + overflow-x: auto; +} + +.tsd-signature-keyword { + color: var(--color-ts-keyword); + font-weight: normal; +} + +.tsd-signature-symbol { + color: var(--color-text-aside); + font-weight: normal; +} + +.tsd-signature-type { + font-style: italic; + font-weight: normal; +} + +.tsd-signatures { + padding: 0; + margin: 0 0 1em 0; + list-style-type: none; +} +.tsd-signatures .tsd-signature { + margin: 0; + border-color: var(--color-accent); + border-width: 1px 0; + transition: background-color 0.1s; +} +.tsd-description .tsd-signatures .tsd-signature { + border-width: 1px; +} + +ul.tsd-parameter-list, +ul.tsd-type-parameter-list { + list-style: square; + margin: 0; + padding-left: 20px; +} +ul.tsd-parameter-list > li.tsd-parameter-signature, +ul.tsd-type-parameter-list > li.tsd-parameter-signature { + list-style: none; + margin-left: -20px; +} +ul.tsd-parameter-list h5, +ul.tsd-type-parameter-list h5 { + font-size: 16px; + margin: 1em 0 0.5em 0; +} +.tsd-sources { + margin-top: 1rem; + font-size: 0.875em; +} +.tsd-sources a { + color: var(--color-text-aside); + text-decoration: underline; +} +.tsd-sources ul { + list-style: none; + padding: 0; +} + +.tsd-page-toolbar { + position: sticky; + z-index: 1; + top: 0; + left: 0; + width: 100%; + color: var(--color-text); + background: var(--color-background-secondary); + border-bottom: 1px var(--color-accent) solid; + transition: transform 0.3s ease-in-out; +} +.tsd-page-toolbar a { + color: var(--color-text); + text-decoration: none; +} +.tsd-page-toolbar a.title { + font-weight: bold; +} +.tsd-page-toolbar a.title:hover { + text-decoration: underline; +} +.tsd-page-toolbar .tsd-toolbar-contents { + display: flex; + justify-content: space-between; + height: 2.5rem; + margin: 0 auto; +} +.tsd-page-toolbar .table-cell { + position: relative; + white-space: nowrap; + line-height: 40px; +} +.tsd-page-toolbar .table-cell:first-child { + width: 100%; +} +.tsd-page-toolbar .tsd-toolbar-icon { + box-sizing: border-box; + line-height: 0; + padding: 12px 0; +} + +.tsd-widget { + display: inline-block; + overflow: hidden; + opacity: 0.8; + height: 40px; + transition: + opacity 0.1s, + background-color 0.2s; + vertical-align: bottom; + cursor: pointer; +} +.tsd-widget:hover { + opacity: 0.9; +} +.tsd-widget.active { + opacity: 1; + background-color: var(--color-accent); +} +.tsd-widget.no-caption { + width: 40px; +} +.tsd-widget.no-caption:before { + margin: 0; +} + +.tsd-widget.options, +.tsd-widget.menu { + display: none; +} +input[type="checkbox"] + .tsd-widget:before { + background-position: -120px 0; +} +input[type="checkbox"]:checked + .tsd-widget:before { + background-position: -160px 0; +} + +img { + max-width: 100%; +} + +.tsd-anchor-icon { + display: inline-flex; + align-items: center; + margin-left: 0.5rem; + vertical-align: middle; + color: var(--color-text); +} + +.tsd-anchor-icon svg { + width: 1em; + height: 1em; + visibility: hidden; +} + +.tsd-anchor-link:hover > .tsd-anchor-icon svg { + visibility: visible; +} + +.deprecated { + text-decoration: line-through !important; +} + +.warning { + padding: 1rem; + color: var(--color-warning-text); + background: var(--color-background-warning); +} + +.tsd-kind-project { + color: var(--color-ts-project); +} +.tsd-kind-module { + color: var(--color-ts-module); +} +.tsd-kind-namespace { + color: var(--color-ts-namespace); +} +.tsd-kind-enum { + color: var(--color-ts-enum); +} +.tsd-kind-enum-member { + color: var(--color-ts-enum-member); +} +.tsd-kind-variable { + color: var(--color-ts-variable); +} +.tsd-kind-function { + color: var(--color-ts-function); +} +.tsd-kind-class { + color: var(--color-ts-class); +} +.tsd-kind-interface { + color: var(--color-ts-interface); +} +.tsd-kind-constructor { + color: var(--color-ts-constructor); +} +.tsd-kind-property { + color: var(--color-ts-property); +} +.tsd-kind-method { + color: var(--color-ts-method); +} +.tsd-kind-call-signature { + color: var(--color-ts-call-signature); +} +.tsd-kind-index-signature { + color: var(--color-ts-index-signature); +} +.tsd-kind-constructor-signature { + color: var(--color-ts-constructor-signature); +} +.tsd-kind-parameter { + color: var(--color-ts-parameter); +} +.tsd-kind-type-literal { + color: var(--color-ts-type-literal); +} +.tsd-kind-type-parameter { + color: var(--color-ts-type-parameter); +} +.tsd-kind-accessor { + color: var(--color-ts-accessor); +} +.tsd-kind-get-signature { + color: var(--color-ts-get-signature); +} +.tsd-kind-set-signature { + color: var(--color-ts-set-signature); +} +.tsd-kind-type-alias { + color: var(--color-ts-type-alias); +} + +/* if we have a kind icon, don't color the text by kind */ +.tsd-kind-icon ~ span { + color: var(--color-text); +} + +* { + scrollbar-width: thin; + scrollbar-color: var(--color-accent) var(--color-icon-background); +} + +*::-webkit-scrollbar { + width: 0.75rem; +} + +*::-webkit-scrollbar-track { + background: var(--color-icon-background); +} + +*::-webkit-scrollbar-thumb { + background-color: var(--color-accent); + border-radius: 999rem; + border: 0.25rem solid var(--color-icon-background); +} + +/* mobile */ +@media (max-width: 769px) { + .tsd-widget.options, + .tsd-widget.menu { + display: inline-block; + } + + .container-main { + display: flex; + } + html .col-content { + float: none; + max-width: 100%; + width: 100%; + } + html .col-sidebar { + position: fixed !important; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + z-index: 1024; + top: 0 !important; + bottom: 0 !important; + left: auto !important; + right: 0 !important; + padding: 1.5rem 1.5rem 0 0; + width: 75vw; + visibility: hidden; + background-color: var(--color-background); + transform: translate(100%, 0); + } + html .col-sidebar > *:last-child { + padding-bottom: 20px; + } + html .overlay { + content: ""; + display: block; + position: fixed; + z-index: 1023; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.75); + visibility: hidden; + } + + .to-has-menu .overlay { + animation: fade-in 0.4s; + } + + .to-has-menu .col-sidebar { + animation: pop-in-from-right 0.4s; + } + + .from-has-menu .overlay { + animation: fade-out 0.4s; + } + + .from-has-menu .col-sidebar { + animation: pop-out-to-right 0.4s; + } + + .has-menu body { + overflow: hidden; + } + .has-menu .overlay { + visibility: visible; + } + .has-menu .col-sidebar { + visibility: visible; + transform: translate(0, 0); + display: flex; + flex-direction: column; + gap: 1.5rem; + max-height: 100vh; + padding: 1rem 2rem; + } + .has-menu .tsd-navigation { + max-height: 100%; + } +} + +/* one sidebar */ +@media (min-width: 770px) { + .container-main { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); + grid-template-areas: "sidebar content"; + margin: 2rem auto; + } + + .col-sidebar { + grid-area: sidebar; + } + .col-content { + grid-area: content; + padding: 0 1rem; + } +} +@media (min-width: 770px) and (max-width: 1399px) { + .col-sidebar { + max-height: calc(100vh - 2rem - 42px); + overflow: auto; + position: sticky; + top: 42px; + padding-top: 1rem; + } + .site-menu { + margin-top: 1rem; + } +} + +/* two sidebars */ +@media (min-width: 1200px) { + .container-main { + grid-template-columns: minmax(0, 1fr) minmax(0, 2.5fr) minmax(0, 20rem); + grid-template-areas: "sidebar content toc"; + } + + .col-sidebar { + display: contents; + } + + .page-menu { + grid-area: toc; + padding-left: 1rem; + } + .site-menu { + grid-area: sidebar; + } + + .site-menu { + margin-top: 1rem 0; + } + + .page-menu, + .site-menu { + max-height: calc(100vh - 2rem - 42px); + overflow: auto; + position: sticky; + top: 42px; + } +} diff --git a/packages/webrtc-sdk/docs/classes/Emitter.html b/packages/webrtc-sdk/docs/classes/Emitter.html new file mode 100644 index 00000000..8799487b --- /dev/null +++ b/packages/webrtc-sdk/docs/classes/Emitter.html @@ -0,0 +1,6 @@ +Emitter | webrtc-sdk

Class Emitter<EventMap>

Type Parameters

  • EventMap extends Record<string, unknown>

Hierarchy (view full)

Constructors

Methods

Constructors

Methods

  • Type Parameters

    • K extends string | number | symbol

    Parameters

    Returns void

  • Type Parameters

    • K extends string | number | symbol

    Parameters

    • event: K
    • handler: ((payload) => void)
        • (payload): void
        • Parameters

          Returns void

    Returns void

  • Type Parameters

    • K extends string | number | symbol

    Parameters

    • event: K
    • handler: ((payload) => void)
        • (payload): void
        • Parameters

          Returns void

    Returns void

  • Type Parameters

    • K extends string | number | symbol

    Parameters

    • event: K
    • handler: ((payload) => void)
        • (payload): void
        • Parameters

          Returns void

    Returns void

\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/classes/MediaManager.html b/packages/webrtc-sdk/docs/classes/MediaManager.html new file mode 100644 index 00000000..4786ce1e --- /dev/null +++ b/packages/webrtc-sdk/docs/classes/MediaManager.html @@ -0,0 +1,33 @@ +MediaManager | webrtc-sdk

Class MediaManager

Manages local media acquisition and device switching.

+

Hierarchy (view full)

Constructors

Methods

  • Return the currently active local stream, if any.

    +

    Returns null | MediaStream

  • (Re)initialize local stream using current constraints and emit devices_updated.

    +

    Returns Promise<void>

  • Type Parameters

    Parameters

    • event: K
    • handler: ((payload) => void)
        • (payload): void
        • Parameters

          Returns void

    Returns void

  • Type Parameters

    Parameters

    • event: K
    • handler: ((payload) => void)
        • (payload): void
        • Parameters

          Returns void

    Returns void

  • Update audio constraints to use a specific deviceId and refresh stream.

    +

    Parameters

    • deviceId: string

    Returns Promise<void>

  • Update video constraints to use a specific deviceId or facingMode and refresh stream.

    +

    Parameters

    • source: string | {
          facingMode: "user" | "environment";
      }

    Returns Promise<void>

  • Replace local video track with a screen capture track.

    +

    Returns Promise<void>

  • Restore camera video track by reinitializing getUserMedia with current constraints.

    +

    Returns Promise<void>

  • Turn off the camera device: stop and remove local video track(s). +This turns off the camera light without renegotiation. Remote side will see video muted.

    +

    Returns void

  • Re-enable camera: if no local video track exists, reacquire one with current constraints and add it. +Does not renegotiate; callers should use replaceTrack on senders (handled by adaptor.applyLocalTracks).

    +

    Returns Promise<void>

  • Enable sending from the current local audio track(s).

    +

    Returns void

\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/classes/PeerStats.html b/packages/webrtc-sdk/docs/classes/PeerStats.html new file mode 100644 index 00000000..8fbc987f --- /dev/null +++ b/packages/webrtc-sdk/docs/classes/PeerStats.html @@ -0,0 +1,28 @@ +PeerStats | webrtc-sdk

Class PeerStats

Constructors

Properties

audioJitter?: number
audioPacketsLost?: number
audioPacketsReceived?: number
audioPacketsSent?: number
audioRoundTripTime?: number
availableOutgoingBitrateKbps?: number
averageIncomingBitrate?: number
averageOutgoingBitrate?: number
currentIncomingBitrate?: number
currentOutgoingBitrate?: number
currentRoundTripTime?: number
currentTimestamp?: number
frameHeight?: number
frameWidth?: number
framesDecoded?: number
framesDropped?: number
framesEncoded?: number
framesReceived?: number
streamId: string
totalBytesReceived?: number
totalBytesSent?: number
videoJitter?: number
videoPacketsLost?: number
videoPacketsReceived?: number
videoPacketsSent?: number
videoRoundTripTime?: number
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/classes/SDKError.html b/packages/webrtc-sdk/docs/classes/SDKError.html new file mode 100644 index 00000000..c3ef16a0 --- /dev/null +++ b/packages/webrtc-sdk/docs/classes/SDKError.html @@ -0,0 +1,5 @@ +SDKError | webrtc-sdk

Class SDKError

Standardized error type produced by the SDK. Use code for programmatic handling.

+

Hierarchy

  • Error
    • SDKError

Constructors

Properties

Constructors

Properties

code: ErrorCode
info?: unknown
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/classes/WebRTCClient.html b/packages/webrtc-sdk/docs/classes/WebRTCClient.html new file mode 100644 index 00000000..d3243801 --- /dev/null +++ b/packages/webrtc-sdk/docs/classes/WebRTCClient.html @@ -0,0 +1,115 @@ +WebRTCClient | webrtc-sdk

Class WebRTCClient

Ant Media Server WebRTC client SDK (v2).

+

This class is the primary entry point you should use in applications. It +orchestrates local media (via MediaManager), signaling (via +WebSocketAdaptor), and peer connections, and exposes a modern, +promise-based API with typed events.

+

Guidance:

+
    +
  • Prefer using the methods on WebRTCClient (publish, play, join, +listDevices, selectVideoInput, startScreenShare, sendData, getStats, …).
  • +
  • The lower-level classes WebSocketAdaptor and MediaManager +are composed internally. Use them directly only for advanced +customizations (e.g., custom signaling transport, bespoke media capture +flows). For most apps, you should never need to instantiate or call them +yourself.
  • +
+

Hierarchy (view full)

Constructors

Properties

isPlayMode: boolean

Methods

  • Close signaling and all peers; emit closed.

    +

    Returns void

  • Stop periodic stats polling previously enabled by enableStats.

    +

    Parameters

    • streamId: string

    Returns void

  • Periodically poll stats for the given stream and emit updated_stats.

    +

    Example:

    +
    sdk.on('updated_stats', (ps) => console.log('stats', ps));
    sdk.enableStats('s1', 2000); +
    +

    Parameters

    • streamId: string
    • periodMs: number = 5000

    Returns void

  • Enable/disable a specific track under a main track on the server.

    +

    Example:

    +
    sdk.enableTrack('mainStreamId', 'camera_user3', true);
    +
    +

    Parameters

    • mainTrackId: string
    • trackId: string
    • enabled: boolean

    Returns void

  • Force the stream quality to a given height for ABR scenarios.

    +

    Example:

    +
    sdk.forceStreamQuality('mainStreamId', 720); // or 'auto'
    +
    +

    Parameters

    • streamId: string
    • height: number | "auto"

    Returns void

  • Get a snapshot of WebRTC stats for a given stream and emit updated_stats.

    +

    Example:

    +
    const stats = await sdk.getStats('s1');
    if (stats) {
    console.log('bytes sent', stats.totalBytesSent);
    } +
    +

    Parameters

    • streamId: string

    Returns Promise<false | PeerStats>

  • High-level one-liner to start a session. Resolves when ICE connects or first track is added.

    +

    Examples:

    +
    // Publish
    const sdk = new WebRTCClient({ websocketURL, mediaConstraints: { audio: true, video: true } });
    await sdk.ready();
    await sdk.join({ role: 'publisher', streamId: 's1', token: 'OPTIONAL' });

    // Play
    const viewer = new WebRTCClient({ websocketURL, isPlayMode: true, remoteVideo });
    await viewer.ready();
    await viewer.join({ role: 'viewer', streamId: 's1' }); +
    +

    Parameters

    Returns Promise<JoinResult>

  • Join a room for conference/multitrack scenarios.

    +

    Example:

    +
    await sdk.joinRoom({ roomId: 'my-room', streamId: 'publisher1' });
    +
    +

    Parameters

    Returns Promise<void>

  • Leave a previously joined room.

    +

    Example:

    +
    await sdk.leaveRoom('my-room', 'publisher1');
    +
    +

    Parameters

    • roomId: string
    • Optional streamId: string

    Returns Promise<void>

  • Enumerate and group available media devices.

    +

    Returns Promise<GroupedDevices>

  • Mute local microphone (pause audio track).

    +

    Returns void

  • Type Parameters

    Parameters

    • event: K
    • handler: ((payload) => void)
        • (payload): void
        • Parameters

          Returns void

    Returns void

  • Type Parameters

    Parameters

    • event: K
    • handler: ((payload) => void)
        • (payload): void
        • Parameters

          Returns void

    Returns void

  • Start playing the given stream. The server will send an SDP offer that we answer.

    +

    Example:

    +
    const sdk = new WebRTCClient({ websocketURL, isPlayMode: true, remoteVideo });
    await sdk.ready();
    await sdk.play('stream1');
    sdk.on('play_started', ({ streamId }) => console.log('playing', streamId)); +
    +

    Parameters

    • streamId: string
    • Optional token: string

    Returns Promise<void>

  • Selective play helper to fetch only specific subtracks and/or default-disable tracks.

    +

    Example:

    +
    await sdk.playSelective({
    streamId: 'mainStreamId',
    enableTracks: ['camera_user1', 'screen_user2'],
    disableTracksByDefault: true,
    }); +
    +

    Parameters

    Returns Promise<void>

  • Start publishing local tracks to the server for the given stream. +Sends a publish command first; upon start from server, creates SDP offer.

    +

    Example:

    +
    const sdk = new WebRTCClient({ websocketURL, mediaConstraints: { audio: true, video: true } });
    await sdk.ready();
    await sdk.publish('stream1', 'OPTIONAL_TOKEN');
    sdk.on('publish_started', ({ streamId }) => console.log('publishing', streamId)); +
    +

    Parameters

    • streamId: string
    • Optional token: string

    Returns Promise<void>

  • Resolves when underlying signaling is initialized and ready.

    +

    Returns Promise<void>

  • Switch the active microphone. Uses replaceTrack under the hood for ongoing sessions.

    +

    Example:

    +
    await sdk.selectAudioInput('mic-device-id');
    +
    +

    Parameters

    • deviceId: string

    Returns Promise<void>

  • Switch the active camera. Uses replaceTrack under the hood for ongoing sessions.

    +

    Examples:

    +
    // By deviceId
    await sdk.selectVideoInput('abcd-device-id');

    // By facingMode (mobile)
    await sdk.selectVideoInput({ facingMode: 'environment' }); +
    +

    Parameters

    • source: string | {
          facingMode: "user" | "environment";
      }

    Returns Promise<void>

  • Send data over the data channel. Strings are sent as-is; ArrayBuffers are chunked with backpressure.

    +

    Examples:

    +
    // Text message
    sdk.sendData('s1', 'hello world');

    // Binary (ArrayBuffer)
    const bytes = new Uint8Array([1,2,3,4]).buffer;
    await sdk.sendData('s1', bytes);

    // Listen
    sdk.on('data_received', ({ streamId, data }) => {
    if (typeof data === 'string') console.log('text', data);
    else console.log('binary', new Uint8Array(data));
    }); +
    +

    Parameters

    • streamId: string
    • data: string | ArrayBuffer

    Returns Promise<void>

  • Begin screen sharing by replacing the outgoing video track; auto-restores when the share ends.

    +

    Example:

    +
    await sdk.startScreenShare();
    +
    +

    Returns Promise<void>

  • Stop an active stream (publish or play) and close its peer connection.

    +

    Parameters

    • streamId: string

    Returns void

  • Stop screen sharing and restore the camera track.

    +

    Returns Promise<void>

  • Turn off camera hardware: stop local camera track and detach from sender without renegotiation.

    +

    Returns Promise<void>

  • Turn on camera hardware: reacquire a camera track if needed and reattach to sender without renegotiation.

    +

    Returns Promise<void>

  • Unmute local microphone (resume audio track).

    +

    Returns void

\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/classes/WebSocketAdaptor.html b/packages/webrtc-sdk/docs/classes/WebSocketAdaptor.html new file mode 100644 index 00000000..89ae26da --- /dev/null +++ b/packages/webrtc-sdk/docs/classes/WebSocketAdaptor.html @@ -0,0 +1,13 @@ +WebSocketAdaptor | webrtc-sdk

Class WebSocketAdaptor

Thin wrapper around WebSocket that adapts Ant Media's signaling protocol +and emits typed events to the adaptor.

+

Hierarchy (view full)

Implements

Constructors

Methods

  • Type Parameters

    Parameters

    • event: K
    • handler: ((payload) => void)
        • (payload): void
        • Parameters

          Returns void

    Returns void

  • Type Parameters

    Parameters

    • event: K
    • handler: ((payload) => void)
        • (payload): void
        • Parameters

          Returns void

    Returns void

\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/functions/generateRandomString.html b/packages/webrtc-sdk/docs/functions/generateRandomString.html new file mode 100644 index 00000000..024a7659 --- /dev/null +++ b/packages/webrtc-sdk/docs/functions/generateRandomString.html @@ -0,0 +1 @@ +generateRandomString | webrtc-sdk

Function generateRandomString

  • Parameters

    • n: number

    Returns string

\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/functions/getWebSocketURL.html b/packages/webrtc-sdk/docs/functions/getWebSocketURL.html new file mode 100644 index 00000000..829fbbc7 --- /dev/null +++ b/packages/webrtc-sdk/docs/functions/getWebSocketURL.html @@ -0,0 +1 @@ +getWebSocketURL | webrtc-sdk

Function getWebSocketURL

  • Parameters

    • location: Location
    • Optional rtmpForward: string

    Returns string

\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/hierarchy.html b/packages/webrtc-sdk/docs/hierarchy.html new file mode 100644 index 00000000..70667bec --- /dev/null +++ b/packages/webrtc-sdk/docs/hierarchy.html @@ -0,0 +1 @@ +webrtc-sdk
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/index.html b/packages/webrtc-sdk/docs/index.html new file mode 100644 index 00000000..ebeccb78 --- /dev/null +++ b/packages/webrtc-sdk/docs/index.html @@ -0,0 +1,50 @@ +webrtc-sdk

webrtc-sdk

WebRTC SDK v2 (TypeScript)

Modern, strictly-typed client SDK for Ant Media Server.

+

Install

This package is currently private for development. Build locally:

+
npm install
npm run build +
+

Usage

import { WebRTCClient, getWebSocketURL } from 'webrtc-sdk';

const adaptor = new WebRTCClient({
websocketURL: getWebSocketURL('wss://example.com:5443/LiveApp/websocket'),
localVideo: document.getElementById('local') as HTMLVideoElement,
remoteVideo: document.getElementById('remote') as HTMLVideoElement,
mediaConstraints: { audio: true, video: true },
});

await adaptor.ready();
await adaptor.join({ role: 'publisher', streamId: 'stream1' });

adaptor.on('play_started', ({ streamId }) => console.log('playing', streamId)); +
+

Events quick reference

Common events emitted by the SDK (see TypeDoc for full list):

+
    +
  • initialized: signaling is ready
  • +
  • publish_started / publish_finished
  • +
  • play_started / play_finished
  • +
  • newTrackAvailable { stream, track, streamId }
  • +
  • ice_connection_state_changed { state, streamId }
  • +
  • data_channel_opened / data_channel_closed
  • +
  • data_received { streamId, data: string | ArrayBuffer }
  • +
  • updated_stats PeerStats
  • +
  • devices_updated GroupedDevices
  • +
  • error { error, message? }
  • +
+

Stats helpers

// One-off snapshot and event
const stats = await adaptor.getStats('s1');
adaptor.on('updated_stats', (ps) => console.log(ps));

// Poll every 2s
adaptor.enableStats('s1', 2000); +
+

Documentation

Generated API docs are available in the docs/ folder. To regenerate:

+
npm run docs
+
+

Open docs/index.html in a browser.

+

Architecture and usage guidance

WebRTCClient is the primary API surface. It composes:

+
    +
  • WebSocketAdaptor: handles signaling with Ant Media Server (WS commands, notifications).
  • +
  • MediaManager: handles local media (getUserMedia, device switching, screen share).
  • +
+

For most applications, call methods on WebRTCClient only. It exposes the common +operations you need: ready(), join(), publish(), play(), stop(), listDevices(), +selectVideoInput(), selectAudioInput(), startScreenShare(), stopScreenShare(), +sendData(), enableStats()/disableStats(), room/multitrack helpers, and emits typed events.

+

Only use WebSocketAdaptor or MediaManager directly if you have advanced +customization needs (e.g., custom signaling transport or bespoke media capture). +Otherwise, prefer the higher-level WebRTCClient methods.

+

Room / Multitrack quick start

// Join a room
await adaptor.joinRoom({ roomId: 'my-room', streamId: 'publisher1' });

// Selectively play only some subtracks of a main stream
await adaptor.playSelective({
streamId: 'mainStreamId',
enableTracks: ['camera_user1', 'screen_user2'],
disableTracksByDefault: true,
});

// Enable/disable a specific subtrack
adaptor.enableTrack('mainStreamId', 'camera_user3', true);

// Force quality (ABR)
adaptor.forceStreamQuality('mainStreamId', 720); // or 'auto' +
+

Examples

    +
  • examples/publish.html
  • +
  • examples/play.html
  • +
  • examples/room.html (rooms/multitrack, enable/disable subtracks, force quality, selective play)
  • +
+

Development

    +
  • Lint: npm run lint
  • +
  • Tests: npm test
  • +
  • Docs: npm run docs
  • +
+
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/interfaces/EventMap.html b/packages/webrtc-sdk/docs/interfaces/EventMap.html new file mode 100644 index 00000000..6bbed25c --- /dev/null +++ b/packages/webrtc-sdk/docs/interfaces/EventMap.html @@ -0,0 +1,24 @@ +EventMap | webrtc-sdk

Interface EventMap

Typed events emitted by WebRTCClient and helpers.

+
interface EventMap {
    broadcast_object?: unknown;
    closed: unknown;
    data_channel_closed: {
        streamId: string;
    };
    data_channel_opened: {
        streamId: string;
    };
    data_received: {
        data: string | ArrayBuffer;
        streamId: string;
    };
    devices_updated: GroupedDevices;
    error: {
        error: string;
        message?: unknown;
    };
    ice_connection_state_changed: {
        state: string;
        streamId: string;
    };
    initialized: void;
    newTrackAvailable: {
        stream: MediaStream;
        streamId: string;
        track: MediaStreamTrack;
    };
    play_finished: {
        streamId: string;
    };
    play_started: {
        streamId: string;
    };
    publish_finished: {
        streamId: string;
    };
    publish_started: {
        streamId: string;
    };
    room_information?: unknown;
    room_joined?: unknown;
    room_left?: unknown;
    server_will_stop: unknown;
    subscriber_count?: unknown;
    subscriber_list?: unknown;
    updated_stats: PeerStats;
    video_track_assignments?: unknown;
    [key: string]: unknown;
}

Indexable

[key: string]: unknown

Properties

broadcast_object?: unknown
closed: unknown
data_channel_closed: {
    streamId: string;
}

Type declaration

  • streamId: string
data_channel_opened: {
    streamId: string;
}

Type declaration

  • streamId: string
data_received: {
    data: string | ArrayBuffer;
    streamId: string;
}

Type declaration

  • data: string | ArrayBuffer
  • streamId: string
devices_updated: GroupedDevices
error: {
    error: string;
    message?: unknown;
}

Type declaration

  • error: string
  • Optional message?: unknown
ice_connection_state_changed: {
    state: string;
    streamId: string;
}

Type declaration

  • state: string
  • streamId: string
initialized: void
newTrackAvailable: {
    stream: MediaStream;
    streamId: string;
    track: MediaStreamTrack;
}

Type declaration

  • stream: MediaStream
  • streamId: string
  • track: MediaStreamTrack
play_finished: {
    streamId: string;
}

Type declaration

  • streamId: string
play_started: {
    streamId: string;
}

Type declaration

  • streamId: string
publish_finished: {
    streamId: string;
}

Type declaration

  • streamId: string
publish_started: {
    streamId: string;
}

Type declaration

  • streamId: string
room_information?: unknown
room_joined?: unknown
room_left?: unknown
server_will_stop: unknown
subscriber_count?: unknown
subscriber_list?: unknown
updated_stats: PeerStats
video_track_assignments?: unknown
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/interfaces/GroupedDevices.html b/packages/webrtc-sdk/docs/interfaces/GroupedDevices.html new file mode 100644 index 00000000..d9399d75 --- /dev/null +++ b/packages/webrtc-sdk/docs/interfaces/GroupedDevices.html @@ -0,0 +1,9 @@ +GroupedDevices | webrtc-sdk

Interface GroupedDevices

Convenience structure of media devices grouped by kind.

+
interface GroupedDevices {
    audioInputs: {
        deviceId: string;
        label: string;
    }[];
    audioOutputs: {
        deviceId: string;
        label: string;
    }[];
    selectedAudioInputId?: string;
    selectedAudioOutputId?: string;
    selectedVideoInputId?: string;
    videoInputs: {
        deviceId: string;
        label: string;
    }[];
}

Properties

audioInputs: {
    deviceId: string;
    label: string;
}[]

Type declaration

  • deviceId: string
  • label: string
audioOutputs: {
    deviceId: string;
    label: string;
}[]

Type declaration

  • deviceId: string
  • label: string
selectedAudioInputId?: string
selectedAudioOutputId?: string
selectedVideoInputId?: string

Currently selected input device ids, when available

+
videoInputs: {
    deviceId: string;
    label: string;
}[]

Type declaration

  • deviceId: string
  • label: string
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/interfaces/IWebSocketAdaptor.html b/packages/webrtc-sdk/docs/interfaces/IWebSocketAdaptor.html new file mode 100644 index 00000000..b1f53c95 --- /dev/null +++ b/packages/webrtc-sdk/docs/interfaces/IWebSocketAdaptor.html @@ -0,0 +1,6 @@ +IWebSocketAdaptor | webrtc-sdk

Interface IWebSocketAdaptor

Minimal interface implemented by the signaling transport.

+
interface IWebSocketAdaptor {
    close(): void;
    isConnected(): boolean;
    isConnecting(): boolean;
    send(text): void;
}

Implemented by

Methods

\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/interfaces/JoinOptions.html b/packages/webrtc-sdk/docs/interfaces/JoinOptions.html new file mode 100644 index 00000000..260a62e2 --- /dev/null +++ b/packages/webrtc-sdk/docs/interfaces/JoinOptions.html @@ -0,0 +1,21 @@ +JoinOptions | webrtc-sdk

Interface JoinOptions

Options for the one-liner WebRTCClient.join flow.

+
interface JoinOptions {
    disableTracksByDefault?: boolean;
    enableTracks?: string[];
    mainTrack?: string;
    metaData?: unknown;
    role: Role;
    roomId?: string;
    streamId: string;
    streamName?: string;
    subscriberCode?: string;
    subscriberId?: string;
    timeoutMs?: number;
    token?: string;
}

Properties

disableTracksByDefault?: boolean
enableTracks?: string[]

Track configuration helpers

+
mainTrack?: string
metaData?: unknown
role: Role

Whether to publish or view a stream

+
roomId?: string
streamId: string

Unique stream identifier

+
streamName?: string

Optional metadata fields propagated to server

+
subscriberCode?: string
subscriberId?: string

Optional subscriber identification fields

+
timeoutMs?: number

Timeout for join to resolve before rejecting

+
token?: string

Optional JWT/token for secured streams

+
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/interfaces/JoinResult.html b/packages/webrtc-sdk/docs/interfaces/JoinResult.html new file mode 100644 index 00000000..056f52a5 --- /dev/null +++ b/packages/webrtc-sdk/docs/interfaces/JoinResult.html @@ -0,0 +1,10 @@ +JoinResult | webrtc-sdk

Interface JoinResult

Result returned by WebRTCClient.join when connection is established.

+
interface JoinResult {
    state: "connected" | "completed" | "track_added";
    streamId: string;
}

Properties

Properties

state: "connected" | "completed" | "track_added"

ICE state or first-track state observed that marks the session ready.

+
    +
  • connected | completed: ICE connected
  • +
  • track_added: first remote or local track became active
  • +
+
streamId: string

Stream identifier

+
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/interfaces/MediaManagerOptions.html b/packages/webrtc-sdk/docs/interfaces/MediaManagerOptions.html new file mode 100644 index 00000000..0ca8cf39 --- /dev/null +++ b/packages/webrtc-sdk/docs/interfaces/MediaManagerOptions.html @@ -0,0 +1,4 @@ +MediaManagerOptions | webrtc-sdk

Interface MediaManagerOptions

interface MediaManagerOptions {
    debug?: boolean;
    localVideo?: null | HTMLVideoElement;
    mediaConstraints?: MediaStreamConstraints;
}

Properties

debug?: boolean
localVideo?: null | HTMLVideoElement
mediaConstraints?: MediaStreamConstraints
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/interfaces/PlaySelectiveOptions.html b/packages/webrtc-sdk/docs/interfaces/PlaySelectiveOptions.html new file mode 100644 index 00000000..fc027ad6 --- /dev/null +++ b/packages/webrtc-sdk/docs/interfaces/PlaySelectiveOptions.html @@ -0,0 +1,10 @@ +PlaySelectiveOptions | webrtc-sdk

Interface PlaySelectiveOptions

interface PlaySelectiveOptions {
    disableTracksByDefault?: boolean;
    enableTracks?: string[];
    metaData?: unknown;
    role?: string;
    roomId?: string;
    streamId: string;
    subscriberCode?: string;
    subscriberId?: string;
    token?: string;
}

Properties

disableTracksByDefault?: boolean
enableTracks?: string[]
metaData?: unknown
role?: string
roomId?: string
streamId: string
subscriberCode?: string
subscriberId?: string
token?: string
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/interfaces/RoomJoinOptions.html b/packages/webrtc-sdk/docs/interfaces/RoomJoinOptions.html new file mode 100644 index 00000000..01ca5bed --- /dev/null +++ b/packages/webrtc-sdk/docs/interfaces/RoomJoinOptions.html @@ -0,0 +1,5 @@ +RoomJoinOptions | webrtc-sdk

Interface RoomJoinOptions

interface RoomJoinOptions {
    metaData?: unknown;
    role?: string;
    roomId: string;
    streamId?: string;
}

Properties

metaData?: unknown
role?: string
roomId: string
streamId?: string
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/interfaces/TypedEmitter.html b/packages/webrtc-sdk/docs/interfaces/TypedEmitter.html new file mode 100644 index 00000000..2d2f5800 --- /dev/null +++ b/packages/webrtc-sdk/docs/interfaces/TypedEmitter.html @@ -0,0 +1,4 @@ +TypedEmitter | webrtc-sdk

Interface TypedEmitter<M>

interface TypedEmitter<M> {
    off<K>(event, handler): void;
    on<K>(event, handler): void;
    once<K>(event, handler): void;
}

Type Parameters

  • M extends Record<string, unknown>

Methods

off +on +once +

Methods

  • Type Parameters

    • K extends string | number | symbol

    Parameters

    • event: K
    • handler: ((payload) => void)
        • (payload): void
        • Parameters

          • payload: M[K]

          Returns void

    Returns void

  • Type Parameters

    • K extends string | number | symbol

    Parameters

    • event: K
    • handler: ((payload) => void)
        • (payload): void
        • Parameters

          • payload: M[K]

          Returns void

    Returns void

  • Type Parameters

    • K extends string | number | symbol

    Parameters

    • event: K
    • handler: ((payload) => void)
        • (payload): void
        • Parameters

          • payload: M[K]

          Returns void

    Returns void

\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/interfaces/UpdateVideoTrackAssignmentsOptions.html b/packages/webrtc-sdk/docs/interfaces/UpdateVideoTrackAssignmentsOptions.html new file mode 100644 index 00000000..9adc56af --- /dev/null +++ b/packages/webrtc-sdk/docs/interfaces/UpdateVideoTrackAssignmentsOptions.html @@ -0,0 +1,4 @@ +UpdateVideoTrackAssignmentsOptions | webrtc-sdk

Interface UpdateVideoTrackAssignmentsOptions

interface UpdateVideoTrackAssignmentsOptions {
    offset: number;
    size: number;
    streamId: string;
}

Properties

Properties

offset: number
size: number
streamId: string
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/interfaces/WebRTCAdaptorOptions.html b/packages/webrtc-sdk/docs/interfaces/WebRTCAdaptorOptions.html new file mode 100644 index 00000000..ffc32439 --- /dev/null +++ b/packages/webrtc-sdk/docs/interfaces/WebRTCAdaptorOptions.html @@ -0,0 +1,18 @@ +WebRTCAdaptorOptions | webrtc-sdk

Interface WebRTCAdaptorOptions

Options to configure WebRTCClient.

+
interface WebRTCAdaptorOptions {
    autoReconnect?: boolean;
    debug?: boolean;
    httpEndpointUrl?: string;
    isPlayMode?: boolean;
    localVideo?: null | HTMLVideoElement;
    mediaConstraints?: MediaStreamConstraints;
    remoteVideo?: null | HTMLVideoElement;
    websocketURL?: string;
}

Properties

autoReconnect?: boolean

Enable automatic reconnection on ICE failure/disconnect (default: true)

+
debug?: boolean

Enable verbose logging

+
httpEndpointUrl?: string

HTTP REST endpoint of Ant Media (used as fallback by signaling layer)

+
isPlayMode?: boolean

If true, initializes in play-only mode and skips getUserMedia

+
localVideo?: null | HTMLVideoElement

Local preview element for publisher (srcObject will be assigned)

+
mediaConstraints?: MediaStreamConstraints

Default media constraints used for getUserMedia

+
remoteVideo?: null | HTMLVideoElement

Remote element to render incoming media (viewer side)

+
websocketURL?: string

WebSocket signaling URL (e.g. wss://host:5443/App/websocket)

+
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/interfaces/WebSocketAdaptorOptions.html b/packages/webrtc-sdk/docs/interfaces/WebSocketAdaptorOptions.html new file mode 100644 index 00000000..2ae26fd9 --- /dev/null +++ b/packages/webrtc-sdk/docs/interfaces/WebSocketAdaptorOptions.html @@ -0,0 +1,6 @@ +WebSocketAdaptorOptions | webrtc-sdk

Interface WebSocketAdaptorOptions

Configuration options for WebSocketAdaptor.

+
interface WebSocketAdaptorOptions {
    debug?: boolean | LogLevel;
    httpEndpointUrl?: string;
    webrtcadaptor: {
        notifyEventListeners: ((info, obj?) => void);
    };
    websocketURL?: string;
}

Properties

debug?: boolean | LogLevel
httpEndpointUrl?: string
webrtcadaptor: {
    notifyEventListeners: ((info, obj?) => void);
}

Type declaration

  • notifyEventListeners: ((info, obj?) => void)
      • (info, obj?): void
      • Parameters

        • info: string
        • Optional obj: unknown

        Returns void

websocketURL?: string
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/modules.html b/packages/webrtc-sdk/docs/modules.html new file mode 100644 index 00000000..87793c7d --- /dev/null +++ b/packages/webrtc-sdk/docs/modules.html @@ -0,0 +1,23 @@ +webrtc-sdk
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/types/ErrorCode.html b/packages/webrtc-sdk/docs/types/ErrorCode.html new file mode 100644 index 00000000..0e512699 --- /dev/null +++ b/packages/webrtc-sdk/docs/types/ErrorCode.html @@ -0,0 +1,2 @@ +ErrorCode | webrtc-sdk

Type alias ErrorCode

ErrorCode: "WebSocketNotConnected" | "WebSocketNotSupported" | "UnsecureContext" | "getUserMediaIsNotAllowed" | "ScreenSharePermissionDenied" | "notSetRemoteDescription" | "protocol_not_supported" | "data_channel_error" | "data_channel_blob_parse_failed" | "join_timeout" | "join_failed"

Well-known error codes emitted by the SDK.

+
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/types/Role.html b/packages/webrtc-sdk/docs/types/Role.html new file mode 100644 index 00000000..98d3d7c4 --- /dev/null +++ b/packages/webrtc-sdk/docs/types/Role.html @@ -0,0 +1,6 @@ +Role | webrtc-sdk

Type alias Role

Role: "publisher" | "viewer"

The role of the client in a session.

+
    +
  • publisher: sends local media to Ant Media Server
  • +
  • viewer: receives remote media from Ant Media Server
  • +
+
\ No newline at end of file diff --git a/packages/webrtc-sdk/examples/play.html b/packages/webrtc-sdk/examples/play.html index a2654b08..d835b8cb 100644 --- a/packages/webrtc-sdk/examples/play.html +++ b/packages/webrtc-sdk/examples/play.html @@ -47,7 +47,7 @@

Play Sample (TS v2)


 
     

Class Emitter<EventMap>

Type Parameters

  • EventMap extends Record<string, unknown>

Hierarchy (view full)

Constructors

Methods

Constructors

Methods

  • Type Parameters

    • K extends string | number | symbol

    Parameters

    Returns void

  • Type Parameters

    • K extends string | number | symbol

    Parameters

    • event: K
    • handler: ((payload) => void)
        • (payload): void
        • Parameters

          Returns void

    Returns void

  • Type Parameters

    • K extends string | number | symbol

    Parameters

    • event: K
    • handler: ((payload) => void)
        • (payload): void
        • Parameters

          Returns void

    Returns void

  • Type Parameters

    • K extends string | number | symbol

    Parameters

    • event: K
    • handler: ((payload) => void)
        • (payload): void
        • Parameters

          Returns void

    Returns void

\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/classes/MediaManager.html b/packages/webrtc-sdk/docs/classes/MediaManager.html deleted file mode 100644 index 4786ce1e..00000000 --- a/packages/webrtc-sdk/docs/classes/MediaManager.html +++ /dev/null @@ -1,33 +0,0 @@ -MediaManager | webrtc-sdk

Class MediaManager

Manages local media acquisition and device switching.

-

Hierarchy (view full)

Constructors

Methods

  • Return the currently active local stream, if any.

    -

    Returns null | MediaStream

  • (Re)initialize local stream using current constraints and emit devices_updated.

    -

    Returns Promise<void>

  • Type Parameters

    Parameters

    • event: K
    • handler: ((payload) => void)
        • (payload): void
        • Parameters

          Returns void

    Returns void

  • Type Parameters

    Parameters

    • event: K
    • handler: ((payload) => void)
        • (payload): void
        • Parameters

          Returns void

    Returns void

  • Update audio constraints to use a specific deviceId and refresh stream.

    -

    Parameters

    • deviceId: string

    Returns Promise<void>

  • Update video constraints to use a specific deviceId or facingMode and refresh stream.

    -

    Parameters

    • source: string | {
          facingMode: "user" | "environment";
      }

    Returns Promise<void>

  • Replace local video track with a screen capture track.

    -

    Returns Promise<void>

  • Restore camera video track by reinitializing getUserMedia with current constraints.

    -

    Returns Promise<void>

  • Turn off the camera device: stop and remove local video track(s). -This turns off the camera light without renegotiation. Remote side will see video muted.

    -

    Returns void

  • Re-enable camera: if no local video track exists, reacquire one with current constraints and add it. -Does not renegotiate; callers should use replaceTrack on senders (handled by adaptor.applyLocalTracks).

    -

    Returns Promise<void>

  • Enable sending from the current local audio track(s).

    -

    Returns void

\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/classes/PeerStats.html b/packages/webrtc-sdk/docs/classes/PeerStats.html deleted file mode 100644 index 8fbc987f..00000000 --- a/packages/webrtc-sdk/docs/classes/PeerStats.html +++ /dev/null @@ -1,28 +0,0 @@ -PeerStats | webrtc-sdk

Class PeerStats

Constructors

Properties

audioJitter?: number
audioPacketsLost?: number
audioPacketsReceived?: number
audioPacketsSent?: number
audioRoundTripTime?: number
availableOutgoingBitrateKbps?: number
averageIncomingBitrate?: number
averageOutgoingBitrate?: number
currentIncomingBitrate?: number
currentOutgoingBitrate?: number
currentRoundTripTime?: number
currentTimestamp?: number
frameHeight?: number
frameWidth?: number
framesDecoded?: number
framesDropped?: number
framesEncoded?: number
framesReceived?: number
streamId: string
totalBytesReceived?: number
totalBytesSent?: number
videoJitter?: number
videoPacketsLost?: number
videoPacketsReceived?: number
videoPacketsSent?: number
videoRoundTripTime?: number
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/classes/SDKError.html b/packages/webrtc-sdk/docs/classes/SDKError.html deleted file mode 100644 index c3ef16a0..00000000 --- a/packages/webrtc-sdk/docs/classes/SDKError.html +++ /dev/null @@ -1,5 +0,0 @@ -SDKError | webrtc-sdk

Class SDKError

Standardized error type produced by the SDK. Use code for programmatic handling.

-

Hierarchy

  • Error
    • SDKError

Constructors

Properties

Constructors

Properties

code: ErrorCode
info?: unknown
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/classes/WebRTCClient.html b/packages/webrtc-sdk/docs/classes/WebRTCClient.html deleted file mode 100644 index d3243801..00000000 --- a/packages/webrtc-sdk/docs/classes/WebRTCClient.html +++ /dev/null @@ -1,115 +0,0 @@ -WebRTCClient | webrtc-sdk

Class WebRTCClient

Ant Media Server WebRTC client SDK (v2).

-

This class is the primary entry point you should use in applications. It -orchestrates local media (via MediaManager), signaling (via -WebSocketAdaptor), and peer connections, and exposes a modern, -promise-based API with typed events.

-

Guidance:

-
    -
  • Prefer using the methods on WebRTCClient (publish, play, join, -listDevices, selectVideoInput, startScreenShare, sendData, getStats, …).
  • -
  • The lower-level classes WebSocketAdaptor and MediaManager -are composed internally. Use them directly only for advanced -customizations (e.g., custom signaling transport, bespoke media capture -flows). For most apps, you should never need to instantiate or call them -yourself.
  • -
-

Hierarchy (view full)

Constructors

Properties

isPlayMode: boolean

Methods

  • Close signaling and all peers; emit closed.

    -

    Returns void

  • Stop periodic stats polling previously enabled by enableStats.

    -

    Parameters

    • streamId: string

    Returns void

  • Periodically poll stats for the given stream and emit updated_stats.

    -

    Example:

    -
    sdk.on('updated_stats', (ps) => console.log('stats', ps));
    sdk.enableStats('s1', 2000); -
    -

    Parameters

    • streamId: string
    • periodMs: number = 5000

    Returns void

  • Enable/disable a specific track under a main track on the server.

    -

    Example:

    -
    sdk.enableTrack('mainStreamId', 'camera_user3', true);
    -
    -

    Parameters

    • mainTrackId: string
    • trackId: string
    • enabled: boolean

    Returns void

  • Force the stream quality to a given height for ABR scenarios.

    -

    Example:

    -
    sdk.forceStreamQuality('mainStreamId', 720); // or 'auto'
    -
    -

    Parameters

    • streamId: string
    • height: number | "auto"

    Returns void

  • Get a snapshot of WebRTC stats for a given stream and emit updated_stats.

    -

    Example:

    -
    const stats = await sdk.getStats('s1');
    if (stats) {
    console.log('bytes sent', stats.totalBytesSent);
    } -
    -

    Parameters

    • streamId: string

    Returns Promise<false | PeerStats>

  • High-level one-liner to start a session. Resolves when ICE connects or first track is added.

    -

    Examples:

    -
    // Publish
    const sdk = new WebRTCClient({ websocketURL, mediaConstraints: { audio: true, video: true } });
    await sdk.ready();
    await sdk.join({ role: 'publisher', streamId: 's1', token: 'OPTIONAL' });

    // Play
    const viewer = new WebRTCClient({ websocketURL, isPlayMode: true, remoteVideo });
    await viewer.ready();
    await viewer.join({ role: 'viewer', streamId: 's1' }); -
    -

    Parameters

    Returns Promise<JoinResult>

  • Join a room for conference/multitrack scenarios.

    -

    Example:

    -
    await sdk.joinRoom({ roomId: 'my-room', streamId: 'publisher1' });
    -
    -

    Parameters

    Returns Promise<void>

  • Leave a previously joined room.

    -

    Example:

    -
    await sdk.leaveRoom('my-room', 'publisher1');
    -
    -

    Parameters

    • roomId: string
    • Optional streamId: string

    Returns Promise<void>

  • Enumerate and group available media devices.

    -

    Returns Promise<GroupedDevices>

  • Mute local microphone (pause audio track).

    -

    Returns void

  • Type Parameters

    Parameters

    • event: K
    • handler: ((payload) => void)
        • (payload): void
        • Parameters

          Returns void

    Returns void

  • Type Parameters

    Parameters

    • event: K
    • handler: ((payload) => void)
        • (payload): void
        • Parameters

          Returns void

    Returns void

  • Start playing the given stream. The server will send an SDP offer that we answer.

    -

    Example:

    -
    const sdk = new WebRTCClient({ websocketURL, isPlayMode: true, remoteVideo });
    await sdk.ready();
    await sdk.play('stream1');
    sdk.on('play_started', ({ streamId }) => console.log('playing', streamId)); -
    -

    Parameters

    • streamId: string
    • Optional token: string

    Returns Promise<void>

  • Selective play helper to fetch only specific subtracks and/or default-disable tracks.

    -

    Example:

    -
    await sdk.playSelective({
    streamId: 'mainStreamId',
    enableTracks: ['camera_user1', 'screen_user2'],
    disableTracksByDefault: true,
    }); -
    -

    Parameters

    Returns Promise<void>

  • Start publishing local tracks to the server for the given stream. -Sends a publish command first; upon start from server, creates SDP offer.

    -

    Example:

    -
    const sdk = new WebRTCClient({ websocketURL, mediaConstraints: { audio: true, video: true } });
    await sdk.ready();
    await sdk.publish('stream1', 'OPTIONAL_TOKEN');
    sdk.on('publish_started', ({ streamId }) => console.log('publishing', streamId)); -
    -

    Parameters

    • streamId: string
    • Optional token: string

    Returns Promise<void>

  • Resolves when underlying signaling is initialized and ready.

    -

    Returns Promise<void>

  • Switch the active microphone. Uses replaceTrack under the hood for ongoing sessions.

    -

    Example:

    -
    await sdk.selectAudioInput('mic-device-id');
    -
    -

    Parameters

    • deviceId: string

    Returns Promise<void>

  • Switch the active camera. Uses replaceTrack under the hood for ongoing sessions.

    -

    Examples:

    -
    // By deviceId
    await sdk.selectVideoInput('abcd-device-id');

    // By facingMode (mobile)
    await sdk.selectVideoInput({ facingMode: 'environment' }); -
    -

    Parameters

    • source: string | {
          facingMode: "user" | "environment";
      }

    Returns Promise<void>

  • Send data over the data channel. Strings are sent as-is; ArrayBuffers are chunked with backpressure.

    -

    Examples:

    -
    // Text message
    sdk.sendData('s1', 'hello world');

    // Binary (ArrayBuffer)
    const bytes = new Uint8Array([1,2,3,4]).buffer;
    await sdk.sendData('s1', bytes);

    // Listen
    sdk.on('data_received', ({ streamId, data }) => {
    if (typeof data === 'string') console.log('text', data);
    else console.log('binary', new Uint8Array(data));
    }); -
    -

    Parameters

    • streamId: string
    • data: string | ArrayBuffer

    Returns Promise<void>

  • Begin screen sharing by replacing the outgoing video track; auto-restores when the share ends.

    -

    Example:

    -
    await sdk.startScreenShare();
    -
    -

    Returns Promise<void>

  • Stop an active stream (publish or play) and close its peer connection.

    -

    Parameters

    • streamId: string

    Returns void

  • Stop screen sharing and restore the camera track.

    -

    Returns Promise<void>

  • Turn off camera hardware: stop local camera track and detach from sender without renegotiation.

    -

    Returns Promise<void>

  • Turn on camera hardware: reacquire a camera track if needed and reattach to sender without renegotiation.

    -

    Returns Promise<void>

  • Unmute local microphone (resume audio track).

    -

    Returns void

\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/classes/WebSocketAdaptor.html b/packages/webrtc-sdk/docs/classes/WebSocketAdaptor.html deleted file mode 100644 index 89ae26da..00000000 --- a/packages/webrtc-sdk/docs/classes/WebSocketAdaptor.html +++ /dev/null @@ -1,13 +0,0 @@ -WebSocketAdaptor | webrtc-sdk

Class WebSocketAdaptor

Thin wrapper around WebSocket that adapts Ant Media's signaling protocol -and emits typed events to the adaptor.

-

Hierarchy (view full)

Implements

Constructors

Methods

  • Type Parameters

    Parameters

    • event: K
    • handler: ((payload) => void)
        • (payload): void
        • Parameters

          Returns void

    Returns void

  • Type Parameters

    Parameters

    • event: K
    • handler: ((payload) => void)
        • (payload): void
        • Parameters

          Returns void

    Returns void

\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/functions/generateRandomString.html b/packages/webrtc-sdk/docs/functions/generateRandomString.html deleted file mode 100644 index 024a7659..00000000 --- a/packages/webrtc-sdk/docs/functions/generateRandomString.html +++ /dev/null @@ -1 +0,0 @@ -generateRandomString | webrtc-sdk

Function generateRandomString

  • Parameters

    • n: number

    Returns string

\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/functions/getWebSocketURL.html b/packages/webrtc-sdk/docs/functions/getWebSocketURL.html deleted file mode 100644 index 829fbbc7..00000000 --- a/packages/webrtc-sdk/docs/functions/getWebSocketURL.html +++ /dev/null @@ -1 +0,0 @@ -getWebSocketURL | webrtc-sdk

Function getWebSocketURL

  • Parameters

    • location: Location
    • Optional rtmpForward: string

    Returns string

\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/hierarchy.html b/packages/webrtc-sdk/docs/hierarchy.html deleted file mode 100644 index 70667bec..00000000 --- a/packages/webrtc-sdk/docs/hierarchy.html +++ /dev/null @@ -1 +0,0 @@ -webrtc-sdk
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/index.html b/packages/webrtc-sdk/docs/index.html deleted file mode 100644 index ebeccb78..00000000 --- a/packages/webrtc-sdk/docs/index.html +++ /dev/null @@ -1,50 +0,0 @@ -webrtc-sdk

webrtc-sdk

WebRTC SDK v2 (TypeScript)

Modern, strictly-typed client SDK for Ant Media Server.

-

Install

This package is currently private for development. Build locally:

-
npm install
npm run build -
-

Usage

import { WebRTCClient, getWebSocketURL } from 'webrtc-sdk';

const adaptor = new WebRTCClient({
websocketURL: getWebSocketURL('wss://example.com:5443/LiveApp/websocket'),
localVideo: document.getElementById('local') as HTMLVideoElement,
remoteVideo: document.getElementById('remote') as HTMLVideoElement,
mediaConstraints: { audio: true, video: true },
});

await adaptor.ready();
await adaptor.join({ role: 'publisher', streamId: 'stream1' });

adaptor.on('play_started', ({ streamId }) => console.log('playing', streamId)); -
-

Events quick reference

Common events emitted by the SDK (see TypeDoc for full list):

-
    -
  • initialized: signaling is ready
  • -
  • publish_started / publish_finished
  • -
  • play_started / play_finished
  • -
  • newTrackAvailable { stream, track, streamId }
  • -
  • ice_connection_state_changed { state, streamId }
  • -
  • data_channel_opened / data_channel_closed
  • -
  • data_received { streamId, data: string | ArrayBuffer }
  • -
  • updated_stats PeerStats
  • -
  • devices_updated GroupedDevices
  • -
  • error { error, message? }
  • -
-

Stats helpers

// One-off snapshot and event
const stats = await adaptor.getStats('s1');
adaptor.on('updated_stats', (ps) => console.log(ps));

// Poll every 2s
adaptor.enableStats('s1', 2000); -
-

Documentation

Generated API docs are available in the docs/ folder. To regenerate:

-
npm run docs
-
-

Open docs/index.html in a browser.

-

Architecture and usage guidance

WebRTCClient is the primary API surface. It composes:

-
    -
  • WebSocketAdaptor: handles signaling with Ant Media Server (WS commands, notifications).
  • -
  • MediaManager: handles local media (getUserMedia, device switching, screen share).
  • -
-

For most applications, call methods on WebRTCClient only. It exposes the common -operations you need: ready(), join(), publish(), play(), stop(), listDevices(), -selectVideoInput(), selectAudioInput(), startScreenShare(), stopScreenShare(), -sendData(), enableStats()/disableStats(), room/multitrack helpers, and emits typed events.

-

Only use WebSocketAdaptor or MediaManager directly if you have advanced -customization needs (e.g., custom signaling transport or bespoke media capture). -Otherwise, prefer the higher-level WebRTCClient methods.

-

Room / Multitrack quick start

// Join a room
await adaptor.joinRoom({ roomId: 'my-room', streamId: 'publisher1' });

// Selectively play only some subtracks of a main stream
await adaptor.playSelective({
streamId: 'mainStreamId',
enableTracks: ['camera_user1', 'screen_user2'],
disableTracksByDefault: true,
});

// Enable/disable a specific subtrack
adaptor.enableTrack('mainStreamId', 'camera_user3', true);

// Force quality (ABR)
adaptor.forceStreamQuality('mainStreamId', 720); // or 'auto' -
-

Examples

    -
  • examples/publish.html
  • -
  • examples/play.html
  • -
  • examples/room.html (rooms/multitrack, enable/disable subtracks, force quality, selective play)
  • -
-

Development

    -
  • Lint: npm run lint
  • -
  • Tests: npm test
  • -
  • Docs: npm run docs
  • -
-
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/interfaces/EventMap.html b/packages/webrtc-sdk/docs/interfaces/EventMap.html deleted file mode 100644 index 6bbed25c..00000000 --- a/packages/webrtc-sdk/docs/interfaces/EventMap.html +++ /dev/null @@ -1,24 +0,0 @@ -EventMap | webrtc-sdk

Interface EventMap

Typed events emitted by WebRTCClient and helpers.

-
interface EventMap {
    broadcast_object?: unknown;
    closed: unknown;
    data_channel_closed: {
        streamId: string;
    };
    data_channel_opened: {
        streamId: string;
    };
    data_received: {
        data: string | ArrayBuffer;
        streamId: string;
    };
    devices_updated: GroupedDevices;
    error: {
        error: string;
        message?: unknown;
    };
    ice_connection_state_changed: {
        state: string;
        streamId: string;
    };
    initialized: void;
    newTrackAvailable: {
        stream: MediaStream;
        streamId: string;
        track: MediaStreamTrack;
    };
    play_finished: {
        streamId: string;
    };
    play_started: {
        streamId: string;
    };
    publish_finished: {
        streamId: string;
    };
    publish_started: {
        streamId: string;
    };
    room_information?: unknown;
    room_joined?: unknown;
    room_left?: unknown;
    server_will_stop: unknown;
    subscriber_count?: unknown;
    subscriber_list?: unknown;
    updated_stats: PeerStats;
    video_track_assignments?: unknown;
    [key: string]: unknown;
}

Indexable

[key: string]: unknown

Properties

broadcast_object?: unknown
closed: unknown
data_channel_closed: {
    streamId: string;
}

Type declaration

  • streamId: string
data_channel_opened: {
    streamId: string;
}

Type declaration

  • streamId: string
data_received: {
    data: string | ArrayBuffer;
    streamId: string;
}

Type declaration

  • data: string | ArrayBuffer
  • streamId: string
devices_updated: GroupedDevices
error: {
    error: string;
    message?: unknown;
}

Type declaration

  • error: string
  • Optional message?: unknown
ice_connection_state_changed: {
    state: string;
    streamId: string;
}

Type declaration

  • state: string
  • streamId: string
initialized: void
newTrackAvailable: {
    stream: MediaStream;
    streamId: string;
    track: MediaStreamTrack;
}

Type declaration

  • stream: MediaStream
  • streamId: string
  • track: MediaStreamTrack
play_finished: {
    streamId: string;
}

Type declaration

  • streamId: string
play_started: {
    streamId: string;
}

Type declaration

  • streamId: string
publish_finished: {
    streamId: string;
}

Type declaration

  • streamId: string
publish_started: {
    streamId: string;
}

Type declaration

  • streamId: string
room_information?: unknown
room_joined?: unknown
room_left?: unknown
server_will_stop: unknown
subscriber_count?: unknown
subscriber_list?: unknown
updated_stats: PeerStats
video_track_assignments?: unknown
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/interfaces/GroupedDevices.html b/packages/webrtc-sdk/docs/interfaces/GroupedDevices.html deleted file mode 100644 index d9399d75..00000000 --- a/packages/webrtc-sdk/docs/interfaces/GroupedDevices.html +++ /dev/null @@ -1,9 +0,0 @@ -GroupedDevices | webrtc-sdk

Interface GroupedDevices

Convenience structure of media devices grouped by kind.

-
interface GroupedDevices {
    audioInputs: {
        deviceId: string;
        label: string;
    }[];
    audioOutputs: {
        deviceId: string;
        label: string;
    }[];
    selectedAudioInputId?: string;
    selectedAudioOutputId?: string;
    selectedVideoInputId?: string;
    videoInputs: {
        deviceId: string;
        label: string;
    }[];
}

Properties

audioInputs: {
    deviceId: string;
    label: string;
}[]

Type declaration

  • deviceId: string
  • label: string
audioOutputs: {
    deviceId: string;
    label: string;
}[]

Type declaration

  • deviceId: string
  • label: string
selectedAudioInputId?: string
selectedAudioOutputId?: string
selectedVideoInputId?: string

Currently selected input device ids, when available

-
videoInputs: {
    deviceId: string;
    label: string;
}[]

Type declaration

  • deviceId: string
  • label: string
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/interfaces/IWebSocketAdaptor.html b/packages/webrtc-sdk/docs/interfaces/IWebSocketAdaptor.html deleted file mode 100644 index b1f53c95..00000000 --- a/packages/webrtc-sdk/docs/interfaces/IWebSocketAdaptor.html +++ /dev/null @@ -1,6 +0,0 @@ -IWebSocketAdaptor | webrtc-sdk

Interface IWebSocketAdaptor

Minimal interface implemented by the signaling transport.

-
interface IWebSocketAdaptor {
    close(): void;
    isConnected(): boolean;
    isConnecting(): boolean;
    send(text): void;
}

Implemented by

Methods

\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/interfaces/JoinOptions.html b/packages/webrtc-sdk/docs/interfaces/JoinOptions.html deleted file mode 100644 index 260a62e2..00000000 --- a/packages/webrtc-sdk/docs/interfaces/JoinOptions.html +++ /dev/null @@ -1,21 +0,0 @@ -JoinOptions | webrtc-sdk

Interface JoinOptions

Options for the one-liner WebRTCClient.join flow.

-
interface JoinOptions {
    disableTracksByDefault?: boolean;
    enableTracks?: string[];
    mainTrack?: string;
    metaData?: unknown;
    role: Role;
    roomId?: string;
    streamId: string;
    streamName?: string;
    subscriberCode?: string;
    subscriberId?: string;
    timeoutMs?: number;
    token?: string;
}

Properties

disableTracksByDefault?: boolean
enableTracks?: string[]

Track configuration helpers

-
mainTrack?: string
metaData?: unknown
role: Role

Whether to publish or view a stream

-
roomId?: string
streamId: string

Unique stream identifier

-
streamName?: string

Optional metadata fields propagated to server

-
subscriberCode?: string
subscriberId?: string

Optional subscriber identification fields

-
timeoutMs?: number

Timeout for join to resolve before rejecting

-
token?: string

Optional JWT/token for secured streams

-
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/interfaces/JoinResult.html b/packages/webrtc-sdk/docs/interfaces/JoinResult.html deleted file mode 100644 index 056f52a5..00000000 --- a/packages/webrtc-sdk/docs/interfaces/JoinResult.html +++ /dev/null @@ -1,10 +0,0 @@ -JoinResult | webrtc-sdk

Interface JoinResult

Result returned by WebRTCClient.join when connection is established.

-
interface JoinResult {
    state: "connected" | "completed" | "track_added";
    streamId: string;
}

Properties

Properties

state: "connected" | "completed" | "track_added"

ICE state or first-track state observed that marks the session ready.

-
    -
  • connected | completed: ICE connected
  • -
  • track_added: first remote or local track became active
  • -
-
streamId: string

Stream identifier

-
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/interfaces/MediaManagerOptions.html b/packages/webrtc-sdk/docs/interfaces/MediaManagerOptions.html deleted file mode 100644 index 0ca8cf39..00000000 --- a/packages/webrtc-sdk/docs/interfaces/MediaManagerOptions.html +++ /dev/null @@ -1,4 +0,0 @@ -MediaManagerOptions | webrtc-sdk

Interface MediaManagerOptions

interface MediaManagerOptions {
    debug?: boolean;
    localVideo?: null | HTMLVideoElement;
    mediaConstraints?: MediaStreamConstraints;
}

Properties

debug?: boolean
localVideo?: null | HTMLVideoElement
mediaConstraints?: MediaStreamConstraints
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/interfaces/PlaySelectiveOptions.html b/packages/webrtc-sdk/docs/interfaces/PlaySelectiveOptions.html deleted file mode 100644 index fc027ad6..00000000 --- a/packages/webrtc-sdk/docs/interfaces/PlaySelectiveOptions.html +++ /dev/null @@ -1,10 +0,0 @@ -PlaySelectiveOptions | webrtc-sdk

Interface PlaySelectiveOptions

interface PlaySelectiveOptions {
    disableTracksByDefault?: boolean;
    enableTracks?: string[];
    metaData?: unknown;
    role?: string;
    roomId?: string;
    streamId: string;
    subscriberCode?: string;
    subscriberId?: string;
    token?: string;
}

Properties

disableTracksByDefault?: boolean
enableTracks?: string[]
metaData?: unknown
role?: string
roomId?: string
streamId: string
subscriberCode?: string
subscriberId?: string
token?: string
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/interfaces/RoomJoinOptions.html b/packages/webrtc-sdk/docs/interfaces/RoomJoinOptions.html deleted file mode 100644 index 01ca5bed..00000000 --- a/packages/webrtc-sdk/docs/interfaces/RoomJoinOptions.html +++ /dev/null @@ -1,5 +0,0 @@ -RoomJoinOptions | webrtc-sdk

Interface RoomJoinOptions

interface RoomJoinOptions {
    metaData?: unknown;
    role?: string;
    roomId: string;
    streamId?: string;
}

Properties

metaData?: unknown
role?: string
roomId: string
streamId?: string
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/interfaces/TypedEmitter.html b/packages/webrtc-sdk/docs/interfaces/TypedEmitter.html deleted file mode 100644 index 2d2f5800..00000000 --- a/packages/webrtc-sdk/docs/interfaces/TypedEmitter.html +++ /dev/null @@ -1,4 +0,0 @@ -TypedEmitter | webrtc-sdk

Interface TypedEmitter<M>

interface TypedEmitter<M> {
    off<K>(event, handler): void;
    on<K>(event, handler): void;
    once<K>(event, handler): void;
}

Type Parameters

  • M extends Record<string, unknown>

Methods

off -on -once -

Methods

  • Type Parameters

    • K extends string | number | symbol

    Parameters

    • event: K
    • handler: ((payload) => void)
        • (payload): void
        • Parameters

          • payload: M[K]

          Returns void

    Returns void

  • Type Parameters

    • K extends string | number | symbol

    Parameters

    • event: K
    • handler: ((payload) => void)
        • (payload): void
        • Parameters

          • payload: M[K]

          Returns void

    Returns void

  • Type Parameters

    • K extends string | number | symbol

    Parameters

    • event: K
    • handler: ((payload) => void)
        • (payload): void
        • Parameters

          • payload: M[K]

          Returns void

    Returns void

\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/interfaces/UpdateVideoTrackAssignmentsOptions.html b/packages/webrtc-sdk/docs/interfaces/UpdateVideoTrackAssignmentsOptions.html deleted file mode 100644 index 9adc56af..00000000 --- a/packages/webrtc-sdk/docs/interfaces/UpdateVideoTrackAssignmentsOptions.html +++ /dev/null @@ -1,4 +0,0 @@ -UpdateVideoTrackAssignmentsOptions | webrtc-sdk

Interface UpdateVideoTrackAssignmentsOptions

interface UpdateVideoTrackAssignmentsOptions {
    offset: number;
    size: number;
    streamId: string;
}

Properties

Properties

offset: number
size: number
streamId: string
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/interfaces/WebRTCAdaptorOptions.html b/packages/webrtc-sdk/docs/interfaces/WebRTCAdaptorOptions.html deleted file mode 100644 index ffc32439..00000000 --- a/packages/webrtc-sdk/docs/interfaces/WebRTCAdaptorOptions.html +++ /dev/null @@ -1,18 +0,0 @@ -WebRTCAdaptorOptions | webrtc-sdk

Interface WebRTCAdaptorOptions

Options to configure WebRTCClient.

-
interface WebRTCAdaptorOptions {
    autoReconnect?: boolean;
    debug?: boolean;
    httpEndpointUrl?: string;
    isPlayMode?: boolean;
    localVideo?: null | HTMLVideoElement;
    mediaConstraints?: MediaStreamConstraints;
    remoteVideo?: null | HTMLVideoElement;
    websocketURL?: string;
}

Properties

autoReconnect?: boolean

Enable automatic reconnection on ICE failure/disconnect (default: true)

-
debug?: boolean

Enable verbose logging

-
httpEndpointUrl?: string

HTTP REST endpoint of Ant Media (used as fallback by signaling layer)

-
isPlayMode?: boolean

If true, initializes in play-only mode and skips getUserMedia

-
localVideo?: null | HTMLVideoElement

Local preview element for publisher (srcObject will be assigned)

-
mediaConstraints?: MediaStreamConstraints

Default media constraints used for getUserMedia

-
remoteVideo?: null | HTMLVideoElement

Remote element to render incoming media (viewer side)

-
websocketURL?: string

WebSocket signaling URL (e.g. wss://host:5443/App/websocket)

-
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/interfaces/WebSocketAdaptorOptions.html b/packages/webrtc-sdk/docs/interfaces/WebSocketAdaptorOptions.html deleted file mode 100644 index 2ae26fd9..00000000 --- a/packages/webrtc-sdk/docs/interfaces/WebSocketAdaptorOptions.html +++ /dev/null @@ -1,6 +0,0 @@ -WebSocketAdaptorOptions | webrtc-sdk

Interface WebSocketAdaptorOptions

Configuration options for WebSocketAdaptor.

-
interface WebSocketAdaptorOptions {
    debug?: boolean | LogLevel;
    httpEndpointUrl?: string;
    webrtcadaptor: {
        notifyEventListeners: ((info, obj?) => void);
    };
    websocketURL?: string;
}

Properties

debug?: boolean | LogLevel
httpEndpointUrl?: string
webrtcadaptor: {
    notifyEventListeners: ((info, obj?) => void);
}

Type declaration

  • notifyEventListeners: ((info, obj?) => void)
      • (info, obj?): void
      • Parameters

        • info: string
        • Optional obj: unknown

        Returns void

websocketURL?: string
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/modules.html b/packages/webrtc-sdk/docs/modules.html deleted file mode 100644 index 87793c7d..00000000 --- a/packages/webrtc-sdk/docs/modules.html +++ /dev/null @@ -1,23 +0,0 @@ -webrtc-sdk
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/types/ErrorCode.html b/packages/webrtc-sdk/docs/types/ErrorCode.html deleted file mode 100644 index 0e512699..00000000 --- a/packages/webrtc-sdk/docs/types/ErrorCode.html +++ /dev/null @@ -1,2 +0,0 @@ -ErrorCode | webrtc-sdk

Type alias ErrorCode

ErrorCode: "WebSocketNotConnected" | "WebSocketNotSupported" | "UnsecureContext" | "getUserMediaIsNotAllowed" | "ScreenSharePermissionDenied" | "notSetRemoteDescription" | "protocol_not_supported" | "data_channel_error" | "data_channel_blob_parse_failed" | "join_timeout" | "join_failed"

Well-known error codes emitted by the SDK.

-
\ No newline at end of file diff --git a/packages/webrtc-sdk/docs/types/Role.html b/packages/webrtc-sdk/docs/types/Role.html deleted file mode 100644 index 98d3d7c4..00000000 --- a/packages/webrtc-sdk/docs/types/Role.html +++ /dev/null @@ -1,6 +0,0 @@ -Role | webrtc-sdk

Type alias Role

Role: "publisher" | "viewer"

The role of the client in a session.

-
    -
  • publisher: sends local media to Ant Media Server
  • -
  • viewer: receives remote media from Ant Media Server
  • -
-
\ No newline at end of file diff --git a/packages/webrtc-sdk/src/core/types.ts b/packages/webrtc-sdk/src/core/types.ts index 95f46507..96a858f3 100644 --- a/packages/webrtc-sdk/src/core/types.ts +++ b/packages/webrtc-sdk/src/core/types.ts @@ -8,7 +8,7 @@ export type Role = "publisher" | "viewer"; /** * Options to configure {@link WebRTCClient}. */ -export interface WebRTCAdaptorOptions { +export interface WebRTCClientOptions { /** WebSocket signaling URL (e.g. wss://host:5443/App/websocket) */ websocketURL?: string; /** HTTP REST endpoint of Ant Media (used as fallback by signaling layer) */ diff --git a/packages/webrtc-sdk/src/core/webrtc-client.ts b/packages/webrtc-sdk/src/core/webrtc-client.ts index cfa17dbb..f62bb76f 100644 --- a/packages/webrtc-sdk/src/core/webrtc-client.ts +++ b/packages/webrtc-sdk/src/core/webrtc-client.ts @@ -7,7 +7,7 @@ import type { GroupedDevices, JoinOptions, JoinResult, - WebRTCAdaptorOptions, + WebRTCClientOptions, RoomJoinOptions, PlaySelectiveOptions, } from "./types.js"; @@ -60,9 +60,9 @@ export class WebRTCClient extends Emitter { /** * Create a new adaptor instance. - * @param opts See {@link WebRTCAdaptorOptions} + * @param opts See {@link WebRTCClientOptions} */ - constructor(opts: WebRTCAdaptorOptions) { + constructor(opts: WebRTCClientOptions) { super(); this.isPlayMode = !!opts.isPlayMode; this.autoReconnect = opts.autoReconnect ?? true; From 810e1e5bc307b9975963d45d84d119ab6df84daf Mon Sep 17 00:00:00 2001 From: golgetahir Date: Mon, 25 Aug 2025 11:24:12 +0300 Subject: [PATCH 05/31] Add features to v2 sdk --- packages/webrtc-sdk/README.md | 34 ++ packages/webrtc-sdk/examples/publish.html | 51 ++ packages/webrtc-sdk/src/core/events.ts | 9 + packages/webrtc-sdk/src/core/media-manager.ts | 429 +++++++++++++- packages/webrtc-sdk/src/core/types.ts | 4 + packages/webrtc-sdk/src/core/webrtc-client.ts | 539 ++++++++++++++++-- .../test/datachannel-sanitize.test.ts | 39 ++ .../webrtc-sdk/test/overlay-audio.test.ts | 92 +++ .../webrtc-sdk/test/room-assignments.test.ts | 45 ++ .../webrtc-sdk/test/signaling-parity.test.ts | 62 ++ 10 files changed, 1231 insertions(+), 73 deletions(-) create mode 100644 packages/webrtc-sdk/test/datachannel-sanitize.test.ts create mode 100644 packages/webrtc-sdk/test/overlay-audio.test.ts create mode 100644 packages/webrtc-sdk/test/room-assignments.test.ts create mode 100644 packages/webrtc-sdk/test/signaling-parity.test.ts diff --git a/packages/webrtc-sdk/README.md b/packages/webrtc-sdk/README.md index 38dad0a7..13681ff9 100644 --- a/packages/webrtc-sdk/README.md +++ b/packages/webrtc-sdk/README.md @@ -42,6 +42,10 @@ Common events emitted by the SDK (see TypeDoc for full list): - `data_received` { streamId, data: string | ArrayBuffer } - `updated_stats` PeerStats - `devices_updated` GroupedDevices +- `room_joined` / `room_left` +- `room_information`, `broadcast_object`, `subscriber_count`, `subscriber_list` +- `video_track_assignments` +- `reconnection_attempt_for_publisher` / `reconnection_attempt_for_player` - `error` { error, message? } ### Stats helpers @@ -101,6 +105,36 @@ adaptor.enableTrack('mainStreamId', 'camera_user3', true); adaptor.forceStreamQuality('mainStreamId', 720); // or 'auto' ``` +### Device management and screen share + +```ts +// Switch devices without renegotiation +await adaptor.selectVideoInput('camera-device-id'); +await adaptor.selectAudioInput('mic-device-id'); + +// Camera on/off keeps sender alive (black dummy track) +await adaptor.turnOffLocalCamera(); +await adaptor.turnOnLocalCamera(); + +// Screen share and overlay (PIP camera) +await adaptor.startScreenShare(); +await adaptor.stopScreenShare(); +await adaptor.startScreenWithCameraOverlay(); +await adaptor.stopScreenWithCameraOverlay(); +``` + +### Data channel + +```ts +// Text +await adaptor.sendData('s1', 'hello'); +// Binary +await adaptor.sendData('s1', new Uint8Array([1,2,3]).buffer); +// Optional sanitize received strings +adaptor.setSanitizeDataChannelStrings(true); +adaptor.on('data_received', ({ data }) => console.log('rx', data)); +``` + ## Examples - `examples/publish.html` diff --git a/packages/webrtc-sdk/examples/publish.html b/packages/webrtc-sdk/examples/publish.html index 86ede755..0191cce9 100644 --- a/packages/webrtc-sdk/examples/publish.html +++ b/packages/webrtc-sdk/examples/publish.html @@ -44,6 +44,7 @@

Publish Sample (TS v2)

+
@@ -51,6 +52,18 @@

Publish Sample (TS v2)

Screen Share + + + +
+
+
+ Audio + + + + + level: 0.00
@@ -77,12 +90,19 @@

Publish Sample (TS v2)

const dcText = document.getElementById('dcText'); const sendTextBtn = document.getElementById('sendText'); const sendBinBtn = document.getElementById('sendBin'); + const sanitizeCb = document.getElementById('sanitize'); const statsEl = document.getElementById('stats'); const enableStatsBtn = document.getElementById('enableStats'); const startShareBtn = document.getElementById('startShare'); const stopShareBtn = document.getElementById('stopShare'); + const startOverlayBtn = document.getElementById('startOverlay'); + const stopOverlayBtn = document.getElementById('stopOverlay'); const turnOffCamBtn = document.getElementById('turnOffCam'); const turnOnCamBtn = document.getElementById('turnOnCam'); + const vol = document.getElementById('volume'); + const meterOn = document.getElementById('meterOn'); + const meterOff = document.getElementById('meterOff'); + const level = document.getElementById('level'); function log(msg) { logEl.textContent += msg + '\n'; @@ -116,6 +136,7 @@

Publish Sample (TS v2)

if (typeof data === 'string') log('dc <- ' + data); else log('dc <- binary ' + (data.byteLength || 0) + ' bytes'); }); + sanitizeCb.onchange = () => adaptor.setSanitizeDataChannelStrings(sanitizeCb.checked); adaptor.on('updated_stats', (ps) => { statsEl.textContent = `bytesSent=${ps.totalBytesSent} bytesRecv=${ps.totalBytesReceived}`; }); @@ -179,6 +200,23 @@

Publish Sample (TS v2)

} catch (e) { log('stop share failed: ' + e.message); } }; + startOverlayBtn.onclick = async () => { + try { + await adaptor.startScreenWithCameraOverlay(); + startOverlayBtn.disabled = true; + stopOverlayBtn.disabled = false; + log('overlay started'); + } catch (e) { log('start overlay failed: ' + e.message); } + }; + stopOverlayBtn.onclick = async () => { + try { + await adaptor.stopScreenWithCameraOverlay(); + startOverlayBtn.disabled = false; + stopOverlayBtn.disabled = true; + log('overlay stopped'); + } catch (e) { log('stop overlay failed: ' + e.message); } + }; + turnOffCamBtn.onclick = () => { adaptor.turnOffLocalCamera(); log('camera turned off'); @@ -187,6 +225,19 @@

Publish Sample (TS v2)

adaptor.turnOnLocalCamera(); log('camera turned on'); }; + + vol.oninput = () => { + adaptor.setVolumeLevel(parseFloat(vol.value)); + }; + meterOn.onclick = async () => { + await adaptor.enableAudioLevelForLocalStream((v) => { + level.textContent = 'level: ' + v.toFixed(2); + }, 200); + }; + meterOff.onclick = () => { + adaptor.disableAudioLevelForLocalStream(); + level.textContent = 'level: 0.00'; + }; diff --git a/packages/webrtc-sdk/src/core/events.ts b/packages/webrtc-sdk/src/core/events.ts index ae4975aa..48a7753e 100644 --- a/packages/webrtc-sdk/src/core/events.ts +++ b/packages/webrtc-sdk/src/core/events.ts @@ -9,6 +9,7 @@ export interface EventMap { initialized: void; closed: unknown; server_will_stop: unknown; + /** Emitted when new local tracks are attached or replaced; used internally to refresh senders */ publish_started: { streamId: string }; publish_finished: { streamId: string }; play_started: { streamId: string }; @@ -20,6 +21,7 @@ export interface EventMap { data_channel_closed: { streamId: string }; newTrackAvailable: { stream: MediaStream; track: MediaStreamTrack; streamId: string }; devices_updated: GroupedDevices; + local_tracks_changed: void; error: { error: string; message?: unknown }; // dynamic notification channel e.g. notification:subscriberCount -> payload from server [k: `notification:${string}`]: unknown; @@ -31,6 +33,13 @@ export interface EventMap { room_joined?: unknown; room_left?: unknown; video_track_assignments?: unknown; + // additional common notifications + stream_information?: unknown; + track_list?: unknown; + subtrack_list?: unknown; + subtrack_count?: unknown; + reconnection_attempt_for_publisher?: string | { streamId: string }; + reconnection_attempt_for_player?: string | { streamId: string }; } export interface TypedEmitter> { diff --git a/packages/webrtc-sdk/src/core/media-manager.ts b/packages/webrtc-sdk/src/core/media-manager.ts index 892712f0..9b0403b2 100644 --- a/packages/webrtc-sdk/src/core/media-manager.ts +++ b/packages/webrtc-sdk/src/core/media-manager.ts @@ -16,8 +16,18 @@ export class MediaManager extends Emitter { private localVideo: HTMLVideoElement | null; private constraints: MediaStreamConstraints; private screenVideoTrack: MediaStreamTrack | null = null; + private screenShareAudioTrack: MediaStreamTrack | null = null; + private cameraOverlayTrack: MediaStreamTrack | null = null; + private overlayTimer: ReturnType | null = null; private selectedVideoInputId: string | null = null; private selectedAudioInputId: string | null = null; + private selectedAudioOutputId: string | null = null; + private cameraDisabled = false; + // v1 parity: keep a dummy canvas based black frame stream when camera is off + private dummyCanvas: HTMLCanvasElement = document.createElement("canvas"); + private blackVideoTrack: MediaStreamTrack | null = null; + private blackFrameTimer: ReturnType | null = null; + private replacementStream: MediaStream | null = null; /** * @param opts Media constraints and optional local preview element. @@ -35,7 +45,24 @@ export class MediaManager extends Emitter { /** (Re)initialize local stream using current constraints and emit `devices_updated`. */ async initLocalStream(): Promise { - const stream = await navigator.mediaDevices.getUserMedia(this.constraints); + // Stop and release any previously active tracks to avoid keeping devices (camera/mic) on + if (this.localStream) { + try { + for (const track of this.localStream.getTracks()) { + try { + track.stop(); + } catch { + // ignore + } + } + } catch { + // ignore + } + } + const requestConstraints: MediaStreamConstraints = this.cameraDisabled + ? { ...this.constraints, video: false } + : this.constraints; + const stream = await navigator.mediaDevices.getUserMedia(requestConstraints); this.localStream = stream; if (this.localVideo) this.localVideo.srcObject = stream; await this.refreshDevices(); @@ -57,7 +84,7 @@ export class MediaManager extends Emitter { selectedAudioInputId: this.selectedAudioInputId || undefined, }; // audio output selection (sinkId) is media element specific; we expose last selected if available - grouped.selectedAudioOutputId = undefined; + grouped.selectedAudioOutputId = this.selectedAudioOutputId || undefined; this.emit("devices_updated", grouped); return grouped; } @@ -75,7 +102,30 @@ export class MediaManager extends Emitter { : { facingMode: source.facingMode }; this.constraints = { ...this.constraints, video }; this.selectedVideoInputId = typeof source === "string" ? source : null; - await this.initLocalStream(); + // Reacquire only video for performance and replace in-place (v1 parity) + const cam = await navigator.mediaDevices.getUserMedia({ video, audio: false }); + const vtrack = cam.getVideoTracks()[0]; + if (vtrack) this.replaceLocalVideoTrack(vtrack); + await this.refreshDevices(); + } + + /** Set audio output device (sinkId) for a given media element; stores selection for future emits. */ + async setAudioOutput(deviceId: string, element?: HTMLMediaElement | null): Promise { + const target: HTMLMediaElement | null = element ?? (this.localVideo as HTMLMediaElement | null); + this.selectedAudioOutputId = deviceId || null; + if (!target) return; + const anyEl = target as unknown as { setSinkId?: (id: string) => Promise }; + if (typeof anyEl.setSinkId === "function") { + try { + await anyEl.setSinkId(deviceId); + } catch (e) { + // surface via event channel + this.emit("error", { error: "set_sink_id_failed", message: e } as never); + } + } else { + this.emit("error", { error: "set_sink_id_unsupported", message: target } as never); + } + await this.refreshDevices(); } /** Update audio constraints to use a specific deviceId and refresh stream. */ @@ -83,7 +133,11 @@ export class MediaManager extends Emitter { const audio: MediaTrackConstraints = { deviceId: { exact: deviceId } }; this.constraints = { ...this.constraints, audio }; this.selectedAudioInputId = deviceId; - await this.initLocalStream(); + // Reacquire only audio and replace in-place (v1 parity) + const onlyAudio = await navigator.mediaDevices.getUserMedia({ audio, video: false }); + const atrack = onlyAudio.getAudioTracks()[0]; + if (atrack) this.replaceLocalAudioTrack(atrack); + await this.refreshDevices(); } /** @@ -91,18 +145,13 @@ export class MediaManager extends Emitter { * This turns off the camera light without renegotiation. Remote side will see video muted. */ turnOffLocalCamera(): void { - if (!this.localStream) return; - const videoTracks = this.localStream.getVideoTracks(); - for (const track of videoTracks) { - try { - track.stop(); - } catch (err) { - console.error(err); - } - this.localStream.removeTrack(track); - } + this.cameraDisabled = true; + // Initialize black dummy frame and keep sending at intervals similar to v1 + this.getBlackVideoTrack(); + const vtrack = this.replacementStream?.getVideoTracks()[0] || this.blackVideoTrack; + if (vtrack) this.replaceLocalVideoTrack(vtrack); if (this.localVideo && this.localVideo.srcObject !== this.localStream) { - this.localVideo.srcObject = this.localStream; + this.localVideo.srcObject = this.localStream as MediaStream; } } @@ -111,15 +160,9 @@ export class MediaManager extends Emitter { * Does not renegotiate; callers should use replaceTrack on senders (handled by adaptor.applyLocalTracks). */ async turnOnLocalCamera(): Promise { - if (!this.localStream) { - await this.initLocalStream(); - return; - } - const existing = this.localStream.getVideoTracks(); - if (existing.length > 0) { - for (const track of existing) track.enabled = true; - return; - } + this.cameraDisabled = false; + this.clearBlackVideoTrackTimer(); + this.stopBlackVideoTrack(); const videoConstraints = (this.constraints && (this.constraints as MediaStreamConstraints).video) ?? true; const cam = await navigator.mediaDevices.getUserMedia({ @@ -130,6 +173,50 @@ export class MediaManager extends Emitter { if (vtrack) this.replaceLocalVideoTrack(vtrack); } + // v1 parity helpers for black dummy track + private getBlackVideoTrack(): MediaStreamTrack | null { + const ctx = this.dummyCanvas.getContext("2d"); + if (ctx) { + if (this.dummyCanvas.width !== 320) { + this.dummyCanvas.width = 320; + this.dummyCanvas.height = 240; + } + ctx.fillStyle = "black"; + ctx.fillRect(0, 0, this.dummyCanvas.width, this.dummyCanvas.height); + } + // Always recapture to ensure a fresh live track + this.replacementStream = this.dummyCanvas.captureStream(1); + if (!this.blackFrameTimer) { + this.blackFrameTimer = setInterval(() => { + const c = this.dummyCanvas.getContext("2d"); + if (!c) return; + c.fillStyle = "black"; + c.fillRect(0, 0, this.dummyCanvas.width, this.dummyCanvas.height); + }, 3000); + } + this.blackVideoTrack = this.replacementStream.getVideoTracks()[0] ?? null; + return this.blackVideoTrack; + } + + private clearBlackVideoTrackTimer(): void { + if (this.blackFrameTimer) { + clearInterval(this.blackFrameTimer); + this.blackFrameTimer = null; + } + } + + private stopBlackVideoTrack(): void { + if (this.blackVideoTrack) { + try { + this.blackVideoTrack.stop(); + } catch { + // ignore + } + this.blackVideoTrack = null; + } + this.replacementStream = null; + } + /** Disable sending from the current local audio track(s). */ muteLocalMic(): void { if (!this.localStream) return; @@ -146,18 +233,39 @@ export class MediaManager extends Emitter { } } - /** Replace local video track with a screen capture track. */ + /** Replace local video track with a screen capture track. If system audio is available, mix with mic. */ async startScreenShare(): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const screen = await (navigator.mediaDevices as any).getDisplayMedia({ + const screen = (await (navigator.mediaDevices as any).getDisplayMedia({ video: true, - audio: false, - }); - const vtrack = (screen as MediaStream).getVideoTracks()[0]; + audio: true, + })) as MediaStream; + const vtrack = screen.getVideoTracks()[0]; if (!vtrack) return; - this.replaceLocalVideoTrack(vtrack); this.screenVideoTrack = vtrack; - // auto-stop when user ends share + + // Mix system audio with mic if present + let mixed: MediaStream | null = null; + const hasSystemAudio = screen.getAudioTracks().length > 0; + if (hasSystemAudio) { + const mic = await navigator.mediaDevices.getUserMedia({ + audio: this.constraints.audio ?? true, + video: false, + }); + mixed = this.mixAudioStreams(screen, mic); + } + + this.replaceLocalVideoTrack(vtrack); + const audioTrack = mixed + ? mixed.getAudioTracks()[0] + : ( + await navigator.mediaDevices.getUserMedia({ + audio: this.constraints.audio ?? true, + video: false, + }) + ).getAudioTracks()[0]; + if (audioTrack) this.replaceLocalAudioTrack(audioTrack); + vtrack.onended = () => { void this.stopScreenShare(); }; @@ -173,9 +281,242 @@ export class MediaManager extends Emitter { } this.screenVideoTrack = null; } + if (this.screenShareAudioTrack) { + try { + this.screenShareAudioTrack.stop(); + } catch { + // ignore + } + this.screenShareAudioTrack = null; + } await this.initLocalStream(); } + /** Start screen+camera overlay mode using canvas composition (v1 parity). */ + async startScreenWithCameraOverlay(): Promise { + // get screen video + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const screen = (await (navigator.mediaDevices as any).getDisplayMedia({ + video: true, + audio: false, + })) as MediaStream; + const screenTrack = screen.getVideoTracks()[0]; + if (!screenTrack) return; + + // get camera video only + const cam = await navigator.mediaDevices.getUserMedia({ + video: this.constraints.video ?? true, + audio: false, + }); + const camTrack = cam.getVideoTracks()[0] ?? null; + this.cameraOverlayTrack = camTrack; + + // prepare elements and canvas + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + const screenVideo = document.createElement("video"); + const camVideo = document.createElement("video"); + screenVideo.srcObject = new MediaStream([screenTrack]); + camVideo.srcObject = camTrack ? new MediaStream([camTrack]) : null; + screenVideo.muted = true; + camVideo.muted = true; + await Promise.all([screenVideo.play().catch(() => {}), camVideo.play().catch(() => {})]); + + const canvasStream = canvas.captureStream(15); + + // attach onended to auto-restore + screenTrack.onended = () => { + void this.stopScreenWithCameraOverlay(); + this.emit("notification:screen_share_stopped" as keyof EventMap, undefined as never); + }; + + // draw loop roughly 15fps + const draw = () => { + if (!ctx || screenVideo.videoWidth === 0 || screenVideo.videoHeight === 0) return; + canvas.width = screenVideo.videoWidth; + canvas.height = screenVideo.videoHeight; + ctx.drawImage(screenVideo, 0, 0, canvas.width, canvas.height); + if (!this.cameraDisabled && camVideo.videoWidth > 0 && camVideo.videoHeight > 0) { + const insetW = Math.floor(canvas.width * 0.15); + const insetH = Math.floor((camVideo.videoHeight / camVideo.videoWidth) * insetW); + const x = canvas.width - insetW - 15; + const y = 15; // top-right + ctx.drawImage(camVideo, x, y, insetW, insetH); + } + }; + this.overlayTimer = setInterval(draw, 66); + + // replace outgoing video with canvas stream + const vtrack = canvasStream.getVideoTracks()[0]; + this.screenVideoTrack = screenTrack; + if (vtrack) this.replaceLocalVideoTrack(vtrack); + } + + /** Stop screen+camera overlay and restore camera. */ + async stopScreenWithCameraOverlay(): Promise { + if (this.overlayTimer) { + clearInterval(this.overlayTimer); + this.overlayTimer = null; + } + if (this.screenVideoTrack) { + try { + this.screenVideoTrack.stop(); + } catch { + // ignore + } + this.screenVideoTrack = null; + } + if (this.cameraOverlayTrack) { + try { + this.cameraOverlayTrack.stop(); + } catch { + // ignore + } + this.cameraOverlayTrack = null; + } + await this.initLocalStream(); + } + + // ===== Audio utilities (parity) ===== + private audioContext: AudioContext | null = null; + private primaryGainNode: GainNode | null = null; + private secondaryGainNode: GainNode | null = null; + private localMeterProc?: (level: number) => void; + private localMeterTimer: ReturnType | null = null; + private mutedProbeStream: MediaStream | null = null; + private lastVolume = 1; + private gainInputStream: MediaStream | null = null; + + /** Set output volume (0..1) for local publishing stream. */ + setVolumeLevel(level: number): void { + this.lastVolume = Math.max(0, Math.min(1, level)); + if (!this.primaryGainNode) { + this.installGainNodeForLocalAudio(); + } + if (this.primaryGainNode) this.primaryGainNode.gain.value = this.lastVolume; + } + + /** Ensure local audio runs through a GainNode for volume control and replace the track. + * Important: Use a dedicated mic capture stream as source to avoid feedback loops with localStream. + */ + private installGainNodeForLocalAudio(): void { + if (!this.localStream) return; + // stop previous dedicated mic stream if exists + if (this.gainInputStream) { + for (const t of this.gainInputStream.getTracks()) t.stop(); + this.gainInputStream = null; + } + const audioTracks = this.localStream.getAudioTracks(); + if (audioTracks.length === 0) return; + // Acquire a fresh mic-only stream for processing + const setup = async () => { + const mic = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); + this.gainInputStream = mic; + if (!this.audioContext) this.audioContext = new AudioContext(); + const ctx = this.audioContext; + const source = ctx.createMediaStreamSource(mic); + const destination = ctx.createMediaStreamDestination(); + this.primaryGainNode = ctx.createGain(); + this.primaryGainNode.gain.value = this.lastVolume; + source.connect(this.primaryGainNode).connect(destination); + const processedTrack = destination.stream.getAudioTracks()[0]; + if (processedTrack) this.replaceLocalAudioTrack(processedTrack); + }; + // Fire and forget; caller just needs the chain installed + void setup(); + } + + /** Mix system audio (screen) and mic into a single audio track. */ + private mixAudioStreams(screen: MediaStream, mic: MediaStream): MediaStream { + const composed = new MediaStream(); + // Keep screen video in composed stream + screen.getVideoTracks().forEach(t => composed.addTrack(t)); + if (!this.audioContext) this.audioContext = new AudioContext(); + const ctx = this.audioContext; + const destination = ctx.createMediaStreamDestination(); + // system audio (primary) + if (screen.getAudioTracks().length > 0) { + const sys = new MediaStream([screen.getAudioTracks()[0]]); + const s1 = ctx.createMediaStreamSource(sys); + this.primaryGainNode = this.primaryGainNode || ctx.createGain(); + this.primaryGainNode.gain.value = this.lastVolume; + s1.connect(this.primaryGainNode).connect(destination); + this.screenShareAudioTrack = screen.getAudioTracks()[0]; + } + // mic audio (secondary) + if (mic.getAudioTracks().length > 0) { + const m = new MediaStream([mic.getAudioTracks()[0]]); + const s2 = ctx.createMediaStreamSource(m); + this.secondaryGainNode = this.secondaryGainNode || ctx.createGain(); + this.secondaryGainNode.gain.value = 1; + s2.connect(this.secondaryGainNode).connect(destination); + } + destination.stream.getAudioTracks().forEach(t => composed.addTrack(t)); + return composed; + } + + /** Enable/disable secondary (mic) audio in mixed audio mode. */ + enableSecondStreamInMixedAudio(enable: boolean): void { + if (this.secondaryGainNode) this.secondaryGainNode.gain.value = enable ? 1 : 0; + } + + /** Enable simple audio level metering for the local stream. */ + async enableAudioLevelForLocalStream( + callback: (level: number) => void, + periodMs = 200 + ): Promise { + if (!this.localStream) return; + if (!this.audioContext) this.audioContext = new AudioContext(); + const ctx = this.audioContext; + const src = ctx.createMediaStreamSource(this.localStream); + const analyser = ctx.createAnalyser(); + analyser.fftSize = 256; + src.connect(analyser); + const data = new Uint8Array(analyser.frequencyBinCount); + this.localMeterProc = callback; + if (this.localMeterTimer) clearInterval(this.localMeterTimer); + this.localMeterTimer = setInterval(() => { + analyser.getByteTimeDomainData(data); + // rough RMS + let sum = 0; + for (let i = 0; i < data.length; i++) { + const v = (data[i] - 128) / 128; + sum += v * v; + } + const rms = Math.sqrt(sum / data.length); + callback(rms); + }, periodMs); + } + + disableAudioLevelForLocalStream(): void { + if (this.localMeterTimer) { + clearInterval(this.localMeterTimer); + this.localMeterTimer = null; + } + this.localMeterProc = undefined; + } + + /** Probe mic while muted to notify if speaking. */ + async enableAudioLevelWhenMuted( + callback: (speaking: boolean) => void, + threshold = 0.1 + ): Promise { + if (!this.audioContext) this.audioContext = new AudioContext(); + const probe = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); + this.mutedProbeStream = probe; + await this.enableAudioLevelForLocalStream(level => { + callback(level > threshold); + }, 200); + } + + disableAudioLevelWhenMuted(): void { + if (this.mutedProbeStream) { + for (const t of this.mutedProbeStream.getTracks()) t.stop(); + this.mutedProbeStream = null; + } + this.disableAudioLevelForLocalStream(); + } + private replaceLocalVideoTrack(newTrack: MediaStreamTrack): void { if (!this.localStream) { this.localStream = new MediaStream([newTrack]); @@ -196,4 +537,30 @@ export class MediaManager extends Emitter { this.localVideo.srcObject = this.localStream; } } + + private replaceLocalAudioTrack(newTrack: MediaStreamTrack): void { + if (!this.localStream) { + this.localStream = new MediaStream([newTrack]); + if (this.localVideo) this.localVideo.srcObject = this.localStream; + this.emit("local_tracks_changed", undefined as never); + return; + } + const prev = this.localStream.getAudioTracks()[0]; + // Add new first to keep continuity, then remove/stop previous shortly after + this.localStream.addTrack(newTrack); + this.emit("local_tracks_changed", undefined as never); + if (prev) { + setTimeout(() => { + try { + this.localStream?.removeTrack(prev); + prev.stop(); + } catch (err) { + console.error(err); + } + }, 50); + } + if (this.localVideo && this.localVideo.srcObject !== this.localStream) { + this.localVideo.srcObject = this.localStream; + } + } } diff --git a/packages/webrtc-sdk/src/core/types.ts b/packages/webrtc-sdk/src/core/types.ts index 96a858f3..3f63a8af 100644 --- a/packages/webrtc-sdk/src/core/types.ts +++ b/packages/webrtc-sdk/src/core/types.ts @@ -15,6 +15,8 @@ export interface WebRTCClientOptions { httpEndpointUrl?: string; /** If true, initializes in play-only mode and skips getUserMedia */ isPlayMode?: boolean; + /** If true, creates data-only sessions without capturing audio/video locally */ + onlyDataChannel?: boolean; /** Default media constraints used for getUserMedia */ mediaConstraints?: MediaStreamConstraints; /** Local preview element for publisher (srcObject will be assigned) */ @@ -25,6 +27,8 @@ export interface WebRTCClientOptions { debug?: boolean; /** Enable automatic reconnection on ICE failure/disconnect (default: true) */ autoReconnect?: boolean; + /** If true, sanitize string data-channel messages by escaping HTML brackets */ + sanitizeDataChannelStrings?: boolean; } /** diff --git a/packages/webrtc-sdk/src/core/webrtc-client.ts b/packages/webrtc-sdk/src/core/webrtc-client.ts index f62bb76f..191ce603 100644 --- a/packages/webrtc-sdk/src/core/webrtc-client.ts +++ b/packages/webrtc-sdk/src/core/webrtc-client.ts @@ -18,6 +18,8 @@ interface PeerContext { pc: RTCPeerConnection; dc?: RTCDataChannel; mode?: "publish" | "play"; + videoSender?: RTCRtpSender; + audioSender?: RTCRtpSender; } /** @@ -36,12 +38,26 @@ interface PeerContext { * customizations (e.g., custom signaling transport, bespoke media capture * flows). For most apps, you should never need to instantiate or call them * yourself. + * + * Quick start: + * ```ts + * const sdk = new WebRTCClient({ websocketURL, mediaConstraints: { audio: true, video: true }, localVideo }); + * await sdk.ready(); + * await sdk.join({ role: 'publisher', streamId: 's1' }); + * sdk.on('publish_started', ({ streamId }) => console.log('publishing', streamId)); + * ``` */ export class WebRTCClient extends Emitter { + // ===== Plugin API (v2 style) ===== + static pluginInitMethods: Array<(sdk: WebRTCClient) => void> = []; + static register(initMethod: (sdk: WebRTCClient) => void): void { + WebRTCClient.pluginInitMethods.push(initMethod); + } private ws?: WebSocketAdaptor; private media: MediaManager; private isReady = false; isPlayMode: boolean; + private onlyDataChannel = false; private peers: Map = new Map(); private log = new Logger("debug"); private peerConfig: RTCConfiguration = { @@ -54,9 +70,21 @@ export class WebRTCClient extends Emitter { private rxChunks: Map = new Map(); private autoReconnect = true; + private sanitizeDcStrings = false; private activeStreams: Map = new Map(); private reconnectTimers: Map> = new Map(); private lastReconnectAt: Map = new Map(); + private remoteStreams: Map = new Map(); + private audioContext: AudioContext | null = null; + private remoteMeters: Map< + string, + { + analyser: AnalyserNode; + timer: ReturnType; + data: Uint8Array; + source: MediaStreamAudioSourceNode; + } + > = new Map(); /** * Create a new adaptor instance. @@ -66,6 +94,8 @@ export class WebRTCClient extends Emitter { super(); this.isPlayMode = !!opts.isPlayMode; this.autoReconnect = opts.autoReconnect ?? true; + this.sanitizeDcStrings = !!opts.sanitizeDataChannelStrings; + this.onlyDataChannel = !!opts.onlyDataChannel; this.media = new MediaManager({ mediaConstraints: opts.mediaConstraints, localVideo: opts.localVideo, @@ -73,8 +103,11 @@ export class WebRTCClient extends Emitter { this.remoteVideo = opts.remoteVideo ?? null; this.media.on("devices_updated", g => this.emit("devices_updated", g)); + this.media.on("local_tracks_changed", () => { + void this.applyLocalTracks(); + }); - if (!this.isPlayMode) { + if (!this.isPlayMode && !this.onlyDataChannel) { this.media.initLocalStream().catch(() => { this.emit("error", { error: "getUserMediaIsNotAllowed" }); }); @@ -96,6 +129,15 @@ export class WebRTCClient extends Emitter { this.ws?.send(JSON.stringify({ command: "getIceServerConfig" })); }); } + + // Initialize plugins + for (const init of WebRTCClient.pluginInitMethods) { + try { + init(this); + } catch (e) { + this.log.warn("plugin init failed", e); + } + } } private notify(info: E, obj: EventMap[E]): void { @@ -207,6 +249,13 @@ export class WebRTCClient extends Emitter { if (def === "subscriberList") this.emit("subscriber_list" as keyof EventMap, obj as never); if (def === "roomInformation") this.emit("room_information" as keyof EventMap, obj as never); if (def === "broadcastObject") this.emit("broadcast_object" as keyof EventMap, obj as never); + if (def === "videoTrackAssignmentList") + this.emit("video_track_assignments" as keyof EventMap, obj as never); + if (def === "streamInformation") + this.emit("stream_information" as keyof EventMap, obj as never); + if (def === "trackList") this.emit("track_list" as keyof EventMap, obj as never); + if (def === "subtrackList") this.emit("subtrack_list" as keyof EventMap, obj as never); + if (def === "subtrackCount") this.emit("subtrack_count" as keyof EventMap, obj as never); if (def === "joinedTheRoom") this.emit("room_joined" as keyof EventMap, obj as never); if (def === "leavedTheRoom") this.emit("room_left" as keyof EventMap, obj as never); // Also emit dynamic channel for other notifications @@ -276,6 +325,7 @@ export class WebRTCClient extends Emitter { if (this.remoteVideo && this.remoteVideo.srcObject !== stream) { this.remoteVideo.srcObject = stream; } + if (stream) this.remoteStreams.set(streamId, stream); this.emit("newTrackAvailable", { stream, track: event.track, streamId }); }; @@ -354,7 +404,8 @@ export class WebRTCClient extends Emitter { }; if (typeof raw === "string") { - this.emit("data_received", { streamId, data: raw }); + const text = this.sanitizeDcStrings ? raw.replace(//g, ">") : raw; + this.emit("data_received", { streamId, data: text }); return; } // Blob (WebKit) → ArrayBuffer @@ -386,11 +437,22 @@ export class WebRTCClient extends Emitter { private async startPublishing(streamId: string): Promise { const pc = this.peers.get(streamId)?.pc ?? this.createPeer(streamId); const stream = this.media.getLocalStream(); + if (!stream && !this.onlyDataChannel) throw new Error("no_local_stream"); - if (!stream) throw new Error("no_local_stream"); - - if (pc.getSenders().length === 0) { - for (const track of stream.getTracks()) pc.addTrack(track, stream); + if (!this.onlyDataChannel && pc.getSenders().length === 0 && stream) { + for (const track of stream.getTracks()) { + const sender = pc.addTrack(track, stream); + if (track.kind === "video") (this.peers.get(streamId) as any).videoSender = sender; + if (track.kind === "audio") (this.peers.get(streamId) as any).audioSender = sender; + } + } else { + // Refresh cached senders if missing + const ctx = this.peers.get(streamId); + if (ctx) { + const senders = pc.getSenders(); + ctx.videoSender = ctx.videoSender || senders.find(s => s.track?.kind === "video"); + ctx.audioSender = ctx.audioSender || senders.find(s => s.track?.kind === "audio"); + } } // create data channel in publish mode like v1 @@ -436,8 +498,8 @@ export class WebRTCClient extends Emitter { this.activeStreams.set(streamId, { mode: "publish", token }); const stream = this.media.getLocalStream(); - const hasVideo = !!stream && stream.getVideoTracks().length > 0; - const hasAudio = !!stream && stream.getAudioTracks().length > 0; + const hasVideo = this.onlyDataChannel ? false : !!stream && stream.getVideoTracks().length > 0; + const hasAudio = this.onlyDataChannel ? false : !!stream && stream.getAudioTracks().length > 0; if (this.ws) { const jsCmd = { @@ -625,6 +687,11 @@ export class WebRTCClient extends Emitter { return this.media.listDevices(); } + /** Set audio output device for a media element (or local preview by default). */ + async setAudioOutput(deviceId: string, element?: HTMLMediaElement | null): Promise { + await this.media.setAudioOutput(deviceId, element); + } + /** * Switch the active camera. Uses replaceTrack under the hood for ongoing sessions. * @@ -652,6 +719,7 @@ export class WebRTCClient extends Emitter { */ async selectAudioInput(deviceId: string): Promise { await this.media.selectAudioInput(deviceId); + // If camera is disabled, there may be no video track; still ensure audio sender gets replaced await this.applyLocalTracks(); } @@ -757,30 +825,35 @@ export class WebRTCClient extends Emitter { await this.applyLocalTracks(); } + /** Begin screen share with camera overlay (canvas composition). */ + async startScreenWithCameraOverlay(): Promise { + await this.media.startScreenWithCameraOverlay(); + await this.applyLocalTracks(); + } + + /** Stop screen+camera overlay and restore camera track. */ + async stopScreenWithCameraOverlay(): Promise { + await this.media.stopScreenWithCameraOverlay(); + await this.applyLocalTracks(); + } + /** * Turn off camera hardware: stop local camera track and detach from sender without renegotiation. */ async turnOffLocalCamera(): Promise { this.media.turnOffLocalCamera(); - // Detach track and pause sender encoding to avoid sending frames + // Replace with black dummy track similar to v1 (keeps sender alive) for (const ctx of this.peers.values()) { - const sender = ctx.pc.getSenders().find(s => s.track && s.track.kind === "video"); + const primary = ctx.videoSender; + let sender = primary as RTCRtpSender | undefined; + if (!sender) sender = ctx.pc.getSenders().find(s => s.track && s.track.kind === "video"); if (!sender) continue; try { - await sender.replaceTrack(null); - } catch (e) { - this.log.warn("replaceTrack(null) failed", e); - } - try { - const params = sender.getParameters(); - if (params && Array.isArray(params.encodings)) { - for (const enc of params.encodings) { - (enc as { active?: boolean }).active = false; - } - await sender.setParameters(params); - } + const stream = this.media.getLocalStream(); + const blackTrack = stream?.getVideoTracks()[0] || null; + await sender.replaceTrack(blackTrack); } catch (e) { - this.log.warn("setParameters pause failed", e); + this.log.warn("replaceTrack(black) failed", e); } } } @@ -791,22 +864,6 @@ export class WebRTCClient extends Emitter { async turnOnLocalCamera(): Promise { await this.media.turnOnLocalCamera(); await this.applyLocalTracks(); - // Resume encodings - for (const ctx of this.peers.values()) { - const sender = ctx.pc.getSenders().find(s => s.track && s.track.kind === "video"); - if (!sender) continue; - try { - const params = sender.getParameters(); - if (params && Array.isArray(params.encodings)) { - for (const enc of params.encodings) { - (enc as { active?: boolean }).active = true; - } - await sender.setParameters(params); - } - } catch (e) { - this.log.warn("setParameters resume failed", e); - } - } } /** Mute local microphone (pause audio track). */ @@ -819,6 +876,51 @@ export class WebRTCClient extends Emitter { this.media.unmuteLocalMic(); } + /** + * Set outgoing audio volume for the published stream (0..1). + * This controls what remote peers hear by applying a GainNode to the audio track. + * @param level Value between 0.0 (mute) and 1.0 (full volume) + */ + setVolumeLevel(level: number): void { + this.media.setVolumeLevel(level); + } + + /** + * Enable local audio level metering. + * Emits sampled RMS levels to the provided callback at the specified interval. + * @param callback Function receiving a level value (0..1 approx) + * @param periodMs Sampling interval in milliseconds (default 200ms) + */ + async enableAudioLevelForLocalStream( + callback: (level: number) => void, + periodMs = 200 + ): Promise { + await this.media.enableAudioLevelForLocalStream(callback, periodMs); + } + + /** Disable the local audio level metering started by enableAudioLevelForLocalStream. */ + disableAudioLevelForLocalStream(): void { + this.media.disableAudioLevelForLocalStream(); + } + + /** + * Enable speaking detection while muted. + * Useful to notify users when they are speaking but their mic is muted. + * @param callback Called with true when level > threshold, else false + * @param threshold Sensitivity threshold (default 0.1) + */ + async enableAudioLevelWhenMuted( + callback: (speaking: boolean) => void, + threshold = 0.1 + ): Promise { + await this.media.enableAudioLevelWhenMuted(callback, threshold); + } + + /** Disable speaking detection started by enableAudioLevelWhenMuted. */ + disableAudioLevelWhenMuted(): void { + this.media.disableAudioLevelWhenMuted(); + } + /** * Get a snapshot of WebRTC stats for a given stream and emit `updated_stats`. * @@ -1018,6 +1120,11 @@ export class WebRTCClient extends Emitter { this.emit("closed", undefined as unknown as never); } + /** Toggle sanitization for incoming data-channel strings at runtime. */ + setSanitizeDataChannelStrings(enabled: boolean): void { + this.sanitizeDcStrings = !!enabled; + } + private reconnectIfRequired(streamId: string, delayMs = 3000, forceReconnect = false): void { if (!this.autoReconnect) return; if (!this.activeStreams.has(streamId)) return; @@ -1028,6 +1135,12 @@ export class WebRTCClient extends Emitter { if (!forceReconnect && now - last < 1000) { delayMs = Math.max(delayMs, 1000); } + // notify reconnection attempt similar to v1 + const mode = this.activeStreams.get(streamId)?.mode; + if (mode === "publish") + this.emit("reconnection_attempt_for_publisher" as keyof EventMap, { streamId } as never); + else if (mode === "play") + this.emit("reconnection_attempt_for_player" as keyof EventMap, { streamId } as never); const timer = setTimeout(() => { this.reconnectTimers.delete(streamId); this.tryAgain(streamId, forceReconnect); @@ -1059,27 +1172,369 @@ export class WebRTCClient extends Emitter { }, 500); } + // ===== Parity signaling helpers (v1 compatibility) ===== + /** Instruct server to enable/disable a remote video track. */ + toggleVideo(streamId: string, trackId: string, enabled: boolean): void { + if (!this.ws) return; + this.ws.send(JSON.stringify({ command: "toggleVideo", streamId, trackId, enabled })); + } + + /** Instruct server to enable/disable a remote audio track. */ + toggleAudio(streamId: string, trackId: string, enabled: boolean): void { + if (!this.ws) return; + this.ws.send(JSON.stringify({ command: "toggleAudio", streamId, trackId, enabled })); + } + + /** Request stream info; listen on 'notification:streamInformation' or stream_information. */ + getStreamInfo(streamId: string): void { + if (!this.ws) return; + this.ws.send(JSON.stringify({ command: "getStreamInfo", streamId })); + } + + /** Request broadcast object; listen on 'notification:broadcastObject' or broadcast_object. */ + getBroadcastObject(streamId: string): void { + if (!this.ws) return; + this.ws.send(JSON.stringify({ command: "getBroadcastObject", streamId })); + } + + /** Request room info of roomId; optionally include streamId for context. */ + getRoomInfo(roomId: string, streamId = ""): void { + if (!this.ws) return; + this.ws.send(JSON.stringify({ command: "getRoomInfo", room: roomId, streamId })); + } + + /** Request track list under a main stream. */ + getTracks(streamId: string, token = ""): void { + if (!this.ws) return; + this.ws.send(JSON.stringify({ command: "getTrackList", streamId, token })); + } + + /** Request subtracks for a main stream with optional paging and role filter. */ + getSubtracks(streamId: string, role = "", offset = 0, size = 50): void { + if (!this.ws) return; + this.ws.send(JSON.stringify({ command: "getSubtracks", streamId, role, offset, size })); + } + + /** Request subtrack count for a main stream with optional role/status. */ + getSubtrackCount(streamId: string, role = "", status = ""): void { + if (!this.ws) return; + this.ws.send(JSON.stringify({ command: "getSubtracksCount", streamId, role, status })); + } + + /** Request current subscriber count; listen on subscriber_count. */ + getSubscriberCount(streamId: string): void { + if (!this.ws) return; + this.ws.send(JSON.stringify({ command: "getSubscriberCount", streamId })); + } + + /** Request current subscriber list; listen on subscriber_list. */ + getSubscriberList(streamId: string, offset = 0, size = 50): void { + if (!this.ws) return; + this.ws.send(JSON.stringify({ command: "getSubscribers", streamId, offset, size })); + } + + /** Peer-to-peer messaging helper. */ + peerMessage(streamId: string, definition: string, data: unknown): void { + if (!this.ws) return; + this.ws.send(JSON.stringify({ command: "peerMessageCommand", streamId, definition, data })); + } + + /** Register a push notification token with AMS. */ + registerPushNotificationToken( + subscriberId: string, + authToken: string, + pushToken: string, + tokenType: "fcm" | "apn" + ): void { + if (!this.ws) return; + this.ws.send( + JSON.stringify({ + command: "registerPushNotificationToken", + subscriberId, + token: authToken, + pnsRegistrationToken: pushToken, + pnsType: tokenType, + }) + ); + } + + /** Send a push notification to specific subscribers. */ + sendPushNotification( + subscriberId: string, + authToken: string, + pushNotificationContent: Record, + subscriberIdsToNotify: string[] + ): void { + if (!this.ws) return; + if (typeof pushNotificationContent !== "object") { + throw new Error("pushNotificationContent must be an object"); + } + if (!Array.isArray(subscriberIdsToNotify)) { + throw new Error("subscriberIdsToNotify must be an array"); + } + this.ws.send( + JSON.stringify({ + command: "sendPushNotification", + subscriberId, + token: authToken, + pushNotificationContent, + subscriberIdsToNotify, + }) + ); + } + + /** Send a push notification to a topic. */ + sendPushNotificationToTopic( + subscriberId: string, + authToken: string, + pushNotificationContent: Record, + topic: string + ): void { + if (!this.ws) return; + if (typeof pushNotificationContent !== "object") { + throw new Error("pushNotificationContent must be an object"); + } + this.ws.send( + JSON.stringify({ + command: "sendPushNotification", + subscriberId, + token: authToken, + pushNotificationContent, + topic, + }) + ); + } + + /** Request video track assignments list for a main stream. */ + requestVideoTrackAssignments(streamId: string): void { + if (!this.ws) return; + this.ws.send(JSON.stringify({ command: "getVideoTrackAssignmentsCommand", streamId })); + } + + /** + * Assign/unassign a specific video track under a main stream. + * + * Example: + * ```ts + * // Show only a specific participant's camera + * sdk.assignVideoTrack('mainStreamId', 'camera_user3', true); + * // Hide it again + * sdk.assignVideoTrack('mainStreamId', 'camera_user3', false); + * ``` + */ + assignVideoTrack(streamId: string, videoTrackId: string, enabled: boolean): void { + if (!this.ws) return; + this.ws.send( + JSON.stringify({ + command: "assignVideoTrackCommand", + streamId, + videoTrackId, + enabled, + }) + ); + } + + /** + * Update paginated video track assignments for UI pagination scenarios. + * + * Example: + * ```ts + * // Fetch next page of assignments (offset 20, size 10) + * sdk.updateVideoTrackAssignments({ streamId: 'main', offset: 20, size: 10 }); + * ``` + */ + updateVideoTrackAssignments(opts: import("./types.js").UpdateVideoTrackAssignmentsOptions): void { + if (!this.ws) return; + this.ws.send( + JSON.stringify({ + command: "updateVideoTrackAssignmentsCommand", + streamId: opts.streamId, + offset: opts.offset, + size: opts.size, + }) + ); + } + + /** + * Set the maximum number of video tracks for a main stream (conference pagination). + * + * Example: + * ```ts + * sdk.setMaxVideoTrackCount('mainStreamId', 9); + * ``` + */ + setMaxVideoTrackCount(streamId: string, maxTrackCount: number): void { + if (!this.ws) return; + this.ws.send( + JSON.stringify({ + command: "setMaxVideoTrackCountCommand", + streamId, + maxTrackCount, + }) + ); + } + + /** + * Change outbound video bandwidth (kbps) or 'unlimited' via RTCRtpSender.setParameters. + * + * Example: + * ```ts + * await sdk.changeBandwidth('s1', 600); // limit to 600 kbps + * await sdk.changeBandwidth('s1', 'unlimited'); + * ``` + */ + async changeBandwidth(streamId: string, bandwidth: number | "unlimited"): Promise { + const ctx = this.peers.get(streamId); + if (!ctx) return; + const sender = ctx.videoSender || ctx.pc.getSenders().find(s => s.track?.kind === "video"); + if (!sender) return; + const params = sender.getParameters(); + params.encodings = params.encodings || [{}]; + if (bandwidth === "unlimited") + delete (params.encodings[0] as Record).maxBitrate; + else (params.encodings[0] as Record).maxBitrate = bandwidth * 1000; + try { + await sender.setParameters(params); + } catch (e) { + this.log.warn("setParameters(maxBitrate) failed", e); + } + } + + /** + * Set degradationPreference for the video sender. + * + * Example: + * ```ts + * await sdk.setDegradationPreference('s1', 'maintain-framerate'); + * ``` + */ + async setDegradationPreference( + streamId: string, + preference: "maintain-framerate" | "maintain-resolution" | "balanced" + ): Promise { + const ctx = this.peers.get(streamId); + if (!ctx) return; + const sender = ctx.videoSender || ctx.pc.getSenders().find(s => s.track?.kind === "video"); + if (!sender) return; + const params = sender.getParameters(); + try { + (params as unknown as { degradationPreference?: string }).degradationPreference = preference; + await sender.setParameters(params); + this.log.info("Degradation Preference set to %s", preference); + } catch (e) { + this.log.warn("setParameters(degradationPreference) failed", e); + } + } + + /** Update stream metadata on the server side. */ + updateStreamMetaData(streamId: string, metaData: unknown): void { + if (!this.ws) return; + this.ws.send(JSON.stringify({ command: "updateStreamMetaData", streamId, metaData })); + } + private async applyLocalTracks(): Promise { const stream = this.media.getLocalStream(); if (!stream) return; for (const ctx of this.peers.values()) { const senders = ctx.pc.getSenders(); - for (const track of stream.getTracks()) { - const sender = senders.find(s => s.track && s.track.kind === track.kind); + // v1 parity: update video first, then audio + const videoTracks = stream.getVideoTracks(); + for (const track of videoTracks) { + let sender = + (track.kind === "video" ? ctx.videoSender : ctx.audioSender) || + senders.find(s => s.track && s.track.kind === track.kind); if (sender && sender.replaceTrack) { try { await sender.replaceTrack(track); + if (track.kind === "video") ctx.videoSender = sender; + if (track.kind === "audio") ctx.audioSender = sender; } catch (e) { this.log.warn("replaceTrack failed", e); } } else { try { - ctx.pc.addTrack(track, stream); + sender = ctx.pc.addTrack(track, stream); + if (track.kind === "video") ctx.videoSender = sender; + if (track.kind === "audio") ctx.audioSender = sender; } catch (e) { this.log.warn("addTrack failed", e); } } } + const audioTracks = stream.getAudioTracks(); + for (const track of audioTracks) { + let sender = ctx.audioSender || senders.find(s => s.track && s.track.kind === "audio"); + if (sender && sender.replaceTrack) { + try { + await sender.replaceTrack(track); + ctx.audioSender = sender; + } catch (e) { + this.log.warn("replaceTrack failed", e); + } + } else { + try { + sender = ctx.pc.addTrack(track, stream); + ctx.audioSender = sender; + } catch (e) { + this.log.warn("addTrack failed", e); + } + } + } + } + } + + // ===== Remote audio level metering (viewer side) ===== + /** Measure audio level for remote stream and invoke callback periodically. */ + async enableRemoteAudioLevel( + streamId: string, + callback: (level: number) => void, + periodMs = 200 + ): Promise { + const stream = + this.remoteStreams.get(streamId) || + (this.remoteVideo?.srcObject as MediaStream | null) || + null; + if (!stream) return; + if (!this.audioContext) this.audioContext = new AudioContext(); + const ctx = this.audioContext; + const source = ctx.createMediaStreamSource(stream); + const analyser = ctx.createAnalyser(); + analyser.fftSize = 256; + source.connect(analyser); + const data = new Uint8Array(analyser.frequencyBinCount); + if (this.remoteMeters.has(streamId)) this.disableRemoteAudioLevel(streamId); + const timer = setInterval(() => { + analyser.getByteTimeDomainData(data); + let sum = 0; + for (let i = 0; i < data.length; i++) { + const v = (data[i] - 128) / 128; + sum += v * v; + } + const rms = Math.sqrt(sum / data.length); + try { + callback(rms); + } catch (e) { + this.log.warn("remote audio level callback failed", e); + } + }, periodMs); + this.remoteMeters.set(streamId, { analyser, timer, data, source }); + } + + /** Stop remote audio level metering. */ + disableRemoteAudioLevel(streamId: string): void { + const meter = this.remoteMeters.get(streamId); + if (!meter) return; + clearInterval(meter.timer); + try { + meter.source.disconnect(); + } catch (e) { + this.log.warn("remote audio source disconnect failed", e); + } + try { + meter.analyser.disconnect(); + } catch (e) { + this.log.warn("remote audio analyser disconnect failed", e); } + this.remoteMeters.delete(streamId); } } diff --git a/packages/webrtc-sdk/test/datachannel-sanitize.test.ts b/packages/webrtc-sdk/test/datachannel-sanitize.test.ts new file mode 100644 index 00000000..5eb4e5c2 --- /dev/null +++ b/packages/webrtc-sdk/test/datachannel-sanitize.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { WebRTCClient } from '../src/core/webrtc-client.js'; + +describe('data channel sanitize option', () => { + beforeEach(() => { + // @ts-expect-error mock + global.RTCPeerConnection = vi.fn(() => ({ + addTrack: vi.fn(), + getSenders: vi.fn(() => []), + close: vi.fn(), + onicecandidate: null, + oniceconnectionstatechange: null, + ontrack: null, + createDataChannel: vi.fn(() => ({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + send: vi.fn(), + })), + })); + // @ts-expect-error mock + global.WebSocket = vi.fn(() => ({ send: vi.fn(), close: vi.fn(), readyState: 1 })); + }); + + it('escapes angle brackets in text messages when enabled', async () => { + const sdk = new WebRTCClient({ websocketURL: 'ws://x', sanitizeDataChannelStrings: true, mediaConstraints: { audio: true, video: true } }); + const dc: any = { addEventListener: vi.fn(), removeEventListener: vi.fn(), send: vi.fn(), onmessage: null }; + // create a peer and inject data channel handler + (sdk as any).peers.set('s1', { pc: new (global as any).RTCPeerConnection({}) }); + (sdk as any).setupDataChannel('s1', dc); + + let received: string | ArrayBuffer | null = null; + sdk.on('data_received', ({ data }) => { received = data as any; }); + + dc.onmessage({ data: 'tag' }); + expect(received).toBe('<b>tag</b>'); + }); +}); + + diff --git a/packages/webrtc-sdk/test/overlay-audio.test.ts b/packages/webrtc-sdk/test/overlay-audio.test.ts new file mode 100644 index 00000000..d8172b49 --- /dev/null +++ b/packages/webrtc-sdk/test/overlay-audio.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { WebRTCClient } from '../src/core/webrtc-client.js'; + +// Minimal DOM/media mocks +class MockTrack { + kind: 'audio' | 'video'; + enabled = true; + onended: (() => void) | null = null; + constructor(kind: 'audio' | 'video') { this.kind = kind; } + stop() { /* noop */ } +} +class MockStream { + private tracks: MediaStreamTrack[]; + constructor(tracks: MediaStreamTrack[]) { this.tracks = tracks; } + getTracks() { return this.tracks; } + getVideoTracks() { return this.tracks.filter(t => t.kind === 'video'); } + getAudioTracks() { return this.tracks.filter(t => t.kind === 'audio'); } + addTrack(t: MediaStreamTrack) { this.tracks.push(t); } + removeTrack(t: MediaStreamTrack) { this.tracks = this.tracks.filter(x => x !== t); } +} + +describe('overlay and audio utilities', () => { + beforeEach(() => { + // @ts-expect-error mock + global.RTCPeerConnection = vi.fn(() => ({ + addTrack: vi.fn(), + getSenders: vi.fn(() => []), + close: vi.fn(), + createDataChannel: vi.fn(() => ({ + send: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), + createOffer: vi.fn(async () => ({ type: 'offer', sdp: 'v=0' })), + setLocalDescription: vi.fn(async () => {}), + onicecandidate: null, + oniceconnectionstatechange: null, + ontrack: null, + })); + + const getUserMedia = vi.fn(async (constraints: MediaStreamConstraints) => { + const v = constraints.video ? [new MockTrack('video') as unknown as MediaStreamTrack] : []; + const a = constraints.audio ? [new MockTrack('audio') as unknown as MediaStreamTrack] : []; + return new MockStream([...v, ...a]) as unknown as MediaStream; + }); + const getDisplayMedia = vi.fn(async () => { + return new MockStream([new MockTrack('video') as unknown as MediaStreamTrack]) as unknown as MediaStream; + }); + // @ts-expect-error mock + global.navigator = Object.defineProperty(global, 'navigator', { + value: { + mediaDevices: { + getUserMedia, + getDisplayMedia, + enumerateDevices: vi.fn(async () => []), + }, + }, + configurable: true, + }); + + // @ts-expect-error mock + global.WebSocket = vi.fn(() => ({ + send: vi.fn(), + close: vi.fn(), + readyState: 1, + addEventListener: vi.fn(), + })); + }); + + it('starts and stops screen+camera overlay without throwing', async () => { + const local = { srcObject: null } as unknown as HTMLVideoElement; + const sdk = new WebRTCClient({ websocketURL: 'ws://x', localVideo: local, mediaConstraints: { audio: true, video: true } }); + // we don’t exercise ws init here + await sdk['media'].initLocalStream(); + await expect(sdk.startScreenWithCameraOverlay()).resolves.toBeUndefined(); + await expect(sdk.stopScreenWithCameraOverlay()).resolves.toBeUndefined(); + }); + + it('enables and disables audio level meter', async () => { + const local = { srcObject: null } as unknown as HTMLVideoElement; + const sdk = new WebRTCClient({ websocketURL: 'ws://x', localVideo: local, mediaConstraints: { audio: true, video: true } }); + await sdk['media'].initLocalStream(); + let last = 0; + await expect(sdk.enableAudioLevelForLocalStream(v => { last = v; }, 50)).resolves.toBeUndefined(); + // let a couple of intervals tick + await new Promise(r => setTimeout(r, 120)); + expect(typeof last).toBe('number'); + sdk.disableAudioLevelForLocalStream(); + }); +}); + + diff --git a/packages/webrtc-sdk/test/room-assignments.test.ts b/packages/webrtc-sdk/test/room-assignments.test.ts new file mode 100644 index 00000000..ba418288 --- /dev/null +++ b/packages/webrtc-sdk/test/room-assignments.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { WebRTCClient } from '../src/core/webrtc-client.js'; + +describe('room and assignments signaling', () => { + beforeEach(() => { + // @ts-expect-error mock + global.WebSocket = vi.fn(() => ({ send: vi.fn(), close: vi.fn(), readyState: 1 })); + }); + + it('joinRoom and leaveRoom send expected payloads', async () => { + const sdk = new WebRTCClient({ websocketURL: 'ws://x' }); + const sent: string[] = []; + // @ts-expect-error private + sdk.ws = { send: (m: string) => sent.push(m), close: vi.fn() } as any; + + await sdk.joinRoom({ roomId: 'r1', streamId: 's1', role: 'publisher', metaData: { a: 1 } }); + await sdk.leaveRoom('r1', 's1'); + + const cmds = sent.map(s => JSON.parse(s)); + expect(cmds[0].command).toBe('joinRoom'); + expect(cmds[0].room).toBe('r1'); + expect(cmds[1].command).toBe('leaveFromRoom'); + expect(cmds[1].room).toBe('r1'); + }); + + it('assignment signals are formatted correctly', () => { + const sdk = new WebRTCClient({ }); + const sent: string[] = []; + // @ts-expect-error private + sdk.ws = { send: (m: string) => sent.push(m) } as any; + sdk.requestVideoTrackAssignments('main'); + sdk.assignVideoTrack('main', 'trackA', true); + sdk.updateVideoTrackAssignments({ streamId: 'main', offset: 10, size: 5 }); + sdk.setMaxVideoTrackCount('main', 9); + const cmds = sent.map(s => JSON.parse(s).command); + expect(cmds).toEqual([ + 'getVideoTrackAssignmentsCommand', + 'assignVideoTrackCommand', + 'updateVideoTrackAssignmentsCommand', + 'setMaxVideoTrackCountCommand', + ]); + }); +}); + + diff --git a/packages/webrtc-sdk/test/signaling-parity.test.ts b/packages/webrtc-sdk/test/signaling-parity.test.ts new file mode 100644 index 00000000..c82a52ba --- /dev/null +++ b/packages/webrtc-sdk/test/signaling-parity.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { WebRTCClient } from '../src/core/webrtc-client.js'; + +describe('signaling parity sends correct commands', () => { + beforeEach(() => { + // minimal PC mock + // @ts-expect-error mock + global.RTCPeerConnection = vi.fn(() => ({ + addTrack: vi.fn(), + getSenders: vi.fn(() => []), + close: vi.fn(), + onicecandidate: null, + oniceconnectionstatechange: null, + ontrack: null, + createDataChannel: vi.fn(), + })); + }); + + it('toggleVideo/Audio and info APIs send expected payloads', async () => { + const sdk = new WebRTCClient({ mediaConstraints: { audio: true, video: true } }); + const sent: string[] = []; + // inject mock ws + (sdk as unknown as { ws: { send: (m: string) => void } }).ws = { send: (m: string) => sent.push(m) }; + + sdk.toggleVideo('s1', 't1', true); + sdk.toggleAudio('s1', 't1', false); + sdk.getStreamInfo('s1'); + sdk.getBroadcastObject('s1'); + sdk.getRoomInfo('roomA', 's1'); + sdk.getTracks('s1'); + sdk.getSubtracks('s1', 'role', 0, 10); + sdk.getSubtrackCount('s1', 'role', 'active'); + sdk.getSubscriberCount('s1'); + sdk.getSubscriberList('s1', 0, 5); + sdk.peerMessage('s1', 'PING', { x: 1 }); + sdk.requestVideoTrackAssignments('s1'); + sdk.assignVideoTrack('s1', 'vId', true); + sdk.updateVideoTrackAssignments({ streamId: 's1', offset: 0, size: 10 }); + sdk.setMaxVideoTrackCount('s1', 6); + + const cmds = sent.map(s => JSON.parse(s).command); + expect(cmds).toEqual([ + 'toggleVideo', + 'toggleAudio', + 'getStreamInfo', + 'getBroadcastObject', + 'getRoomInfo', + 'getTrackList', + 'getSubtracks', + 'getSubtracksCount', + 'getSubscriberCount', + 'getSubscribers', + 'peerMessageCommand', + 'getVideoTrackAssignmentsCommand', + 'assignVideoTrackCommand', + 'updateVideoTrackAssignmentsCommand', + 'setMaxVideoTrackCountCommand', + ]); + }); +}); + + From a5f71171be01c1620287c55da187a2817dad3d15 Mon Sep 17 00:00:00 2001 From: golgetahir Date: Mon, 25 Aug 2025 12:22:58 +0300 Subject: [PATCH 06/31] Extend examples and add docs --- packages/webrtc-sdk/README.md | 56 ++++++- packages/webrtc-sdk/documents/Cookbook.md | 75 ++++++++++ packages/webrtc-sdk/documents/EventMatrix.md | 37 +++++ .../webrtc-sdk/documents/ManualTestPlan.md | 62 ++++++++ packages/webrtc-sdk/documents/V2-vs-V1.md | 137 ++++++++++++++++++ packages/webrtc-sdk/examples/data-only.html | 55 +++++++ packages/webrtc-sdk/examples/play.html | 18 +++ packages/webrtc-sdk/examples/publish.html | 52 +++++++ packages/webrtc-sdk/src/core/events.ts | 4 + packages/webrtc-sdk/src/core/media-manager.ts | 71 +++++++++ packages/webrtc-sdk/src/core/types.ts | 7 + packages/webrtc-sdk/src/core/webrtc-client.ts | 114 ++++++++++++++- 12 files changed, 682 insertions(+), 6 deletions(-) create mode 100644 packages/webrtc-sdk/documents/Cookbook.md create mode 100644 packages/webrtc-sdk/documents/EventMatrix.md create mode 100644 packages/webrtc-sdk/documents/ManualTestPlan.md create mode 100644 packages/webrtc-sdk/documents/V2-vs-V1.md create mode 100644 packages/webrtc-sdk/examples/data-only.html diff --git a/packages/webrtc-sdk/README.md b/packages/webrtc-sdk/README.md index 13681ff9..f18536ab 100644 --- a/packages/webrtc-sdk/README.md +++ b/packages/webrtc-sdk/README.md @@ -16,17 +16,65 @@ npm run build ```ts import { WebRTCClient, getWebSocketURL } from 'webrtc-sdk'; -const adaptor = new WebRTCClient({ +// One-liner session helper (recommended for most apps) +const { client } = await WebRTCClient.createSession({ websocketURL: getWebSocketURL('wss://example.com:5443/LiveApp/websocket'), + role: 'publisher', + streamId: 'stream1', localVideo: document.getElementById('local') as HTMLVideoElement, remoteVideo: document.getElementById('remote') as HTMLVideoElement, mediaConstraints: { audio: true, video: true }, + autoPlay: true, // attempt autoplay on the remote element after join }); -await adaptor.ready(); -await adaptor.join({ role: 'publisher', streamId: 'stream1' }); +client.on('play_started', ({ streamId }) => console.log('playing', streamId)); +``` + +### Convenience helpers + +```ts +// One-liners +await client.publishAuto('s1'); +await client.playAuto({ streamId: 's1', token: 'OPTIONAL' }); + +// Send JSON over data-channel +await client.sendJSON('s1', { type: 'chat', text: 'Hello' }); +``` -adaptor.on('play_started', ({ streamId }) => console.log('playing', streamId)); +### Reconnect configuration + +```ts +client.configureReconnect({ backoff: "exp", baseMs: 500, maxMs: 10000, jitter: 0.3 }); +client.on("reconnected", ({ streamId }) => console.log("reconnected", streamId)); +``` + +### Device hot-swap and track controls + +```ts +client.on("device_hotswapped", e => console.log("hotswapped", e)); + +// Pause/resume tracks without renegotiation +client.pauseTrack("audio"); +client.resumeTrack("audio"); +``` + +### Remote audio level (viewer) + +```ts +await client.enableRemoteAudioLevel("s1", level => console.log(level), 200); +client.disableRemoteAudioLevel("s1"); +``` + +### Data-only publish + +```ts +const { client } = await WebRTCClient.createSession({ + websocketURL: getWebSocketURL('wss://example.com:5443/LiveApp/websocket'), + role: 'publisher', + streamId: 'data-only', + onlyDataChannel: true, +}); +await client.sendJSON('data-only', { hello: 'world' }); ``` ### Events quick reference diff --git a/packages/webrtc-sdk/documents/Cookbook.md b/packages/webrtc-sdk/documents/Cookbook.md new file mode 100644 index 00000000..64bd11ac --- /dev/null +++ b/packages/webrtc-sdk/documents/Cookbook.md @@ -0,0 +1,75 @@ +## WebRTC SDK v2 Cookbook + +### 1) Publish/Play with one-liner +```ts +const { client } = await WebRTCClient.createSession({ websocketURL, role: 'publisher', streamId: 's1', localVideo, remoteVideo, mediaConstraints: { audio: true, video: true }, autoPlay: true }); +``` + +### 2) Device switching +```ts +await client.selectVideoInput(cameraId); +await client.selectAudioInput(micId); +``` + +### 3) Screen share and PIP overlay +```ts +await client.startScreenShare(); +// later +await client.stopScreenShare(); + +await client.startScreenWithCameraOverlay(); +await client.stopScreenWithCameraOverlay(); +``` + +### 4) Data channel helpers +```ts +await client.sendData('s1', 'hello'); +await client.sendJSON('s1', { type: 'chat', text: 'hi' }); +``` + +### 5) Reconnect policy and events +```ts +client.configureReconnect({ backoff: 'exp', baseMs: 500, maxMs: 10000, jitter: 0.3 }); +client.on('reconnected', ({ streamId }) => console.log('reconnected', streamId)); +``` + +### 6) Room / multitrack helpers +```ts +await client.joinRoom({ roomId: 'room1', streamId: 'pub1' }); +await client.playSelective({ streamId: 'main', enableTracks: ['camera_u1'], disableTracksByDefault: true }); +client.enableTrack('main', 'camera_u1', true); +client.forceStreamQuality('main', 720); // or 'auto' +``` + +### 7) Bandwidth/quality +```ts +await client.changeBandwidth('s1', 600); +await client.changeBandwidth('s1', 'unlimited'); +await client.setDegradationPreference('s1', 'maintain-framerate'); +``` + +### 8) Stats +```ts +client.on('updated_stats', ps => console.log(ps)); +client.enableStats('s1', 2000); +``` + +### 9) Audio output (sinkId) and meters +```ts +await client.setAudioOutput(deviceId, remoteVideo); +await client.enableRemoteAudioLevel('s1', level => console.log(level)); +``` + +### 10) Track controls +```ts +client.pauseTrack('audio'); +client.resumeTrack('audio'); +``` + +### 11) Data-only publish +```ts +const { client } = await WebRTCClient.createSession({ websocketURL, role: 'publisher', streamId: 'data', onlyDataChannel: true }); +await client.sendJSON('data', { ping: true }); +``` + + diff --git a/packages/webrtc-sdk/documents/EventMatrix.md b/packages/webrtc-sdk/documents/EventMatrix.md new file mode 100644 index 00000000..586a5069 --- /dev/null +++ b/packages/webrtc-sdk/documents/EventMatrix.md @@ -0,0 +1,37 @@ +## Event Matrix + +### Core SDK events +- initialized: void +- closed: unknown +- server_will_stop: unknown +- publish_started: { streamId } +- publish_finished: { streamId } +- play_started: { streamId } +- play_finished: { streamId } +- ice_connection_state_changed: { state, streamId } +- reconnected: { streamId } +- updated_stats: PeerStats +- data_channel_opened: { streamId } +- data_channel_closed: { streamId } +- data_received: { streamId, data } +- newTrackAvailable: { stream, track, streamId } +- devices_updated: GroupedDevices +- device_hotswapped: { kind: "audioinput"|"videoinput", deviceId? } +- local_track_paused: { kind: "audio"|"video" } +- local_track_resumed: { kind: "audio"|"video" } +- error: { error, message? } + +### Common AMS notifications (forwarded) +- room_information → room_information +- broadcastObject → broadcast_object +- subscriberCount → subscriber_count +- subscriberList → subscriber_list +- videoTrackAssignmentList → video_track_assignments +- streamInformation → stream_information +- trackList → track_list +- subtrackList → subtrack_list +- subtrackCount → subtrack_count + +All notifications are also emitted under `notification:`. + + diff --git a/packages/webrtc-sdk/documents/ManualTestPlan.md b/packages/webrtc-sdk/documents/ManualTestPlan.md new file mode 100644 index 00000000..a8bcb3e0 --- /dev/null +++ b/packages/webrtc-sdk/documents/ManualTestPlan.md @@ -0,0 +1,62 @@ +## Manual Test Plan (WebRTC SDK v2) + +### 1. Publish/Play basics +- Start publisher with camera+mic using `createSession`. Expect initialized → publish_started → ICE connected. +- Start viewer and call `play`/`join`. Expect `play_started`, remote audio/video. +- Stop from publisher and viewer; expect `publish_finished`/`play_finished`. + +### 2. Device switching +- `selectVideoInput()` while publishing. Remote video switches without renegotiation. +- `selectAudioInput()` while publishing. Remote audio continues. +- `turnOffLocalCamera()` then `turnOnLocalCamera()`; remote shows black → restores. + +### 3. Screen share +- `startScreenShare()`; verify screen video and mixed system+mic audio at remote. +- Call `enableSecondStreamInMixedAudio(false)` via console on `media` to disable mic (if using mix); re-enable to restore. +- `stopScreenShare()` restores camera. + +### 4. Screen share with camera overlay +- `startScreenWithCameraOverlay()`; verify PIP camera. Stop; camera restores. + +### 5. Data channel +- After publish, `sendData('s1','hello')` and 128KB binary; viewer receives correct data. +- Toggle `setSanitizeDataChannelStrings(true)`; sending `x` arrives escaped. +- `sendJSON('s1',{ type:'chat', text:'hi' })` arrives as string payload on viewer. + +### 6. Stats +- `getStats('s1')` returns values; `enableStats('s1',2000)` emits updates; totals increase during activity. + +### 7. Reconnect +- Disable and re-enable network. Expect `reconnection_attempt_for_*`, then `reconnected` when session resumes. +- Test `configureReconnect({ backoff:'exp', baseMs:500, maxMs:10000, jitter:0.3 })` and observe delays. + +### 8. Rooms / Multitrack +- `joinRoom({ roomId, streamId })`; server responds with `room_information`. +- `playSelective({ streamId:'main', enableTracks:['camera_u1'], disableTracksByDefault:true })`. +- `enableTrack('main','camera_u1',true)` toggles visibility. +- `forceStreamQuality('main', 720)` then `'auto'` and observe quality changes. +- `requestVideoTrackAssignments`, `updateVideoTrackAssignments`, `setMaxVideoTrackCount`; verify notifications/UI pagination. + +### 9. Bandwidth/quality controls +- `changeBandwidth('s1',600)` then `'unlimited'`; verify bitrate changes in stats. +- `setDegradationPreference('s1','maintain-framerate')`; observe under load. + +### 10. Audio utilities +- `setVolumeLevel(0)` → remote silence; restore >0. +- `enableAudioLevelForLocalStream(cb)`; levels update; disable stops. +- `enableRemoteAudioLevel('s1', cb)` on viewer; levels update; disable stops. + +### 11. Audio output selection +- `setAudioOutput(deviceId, remoteVideo)`; verify audio routes to selected output device (if supported). Expect `error:set_sink_id_unsupported` otherwise. + +### 12. Track controls +- `pauseTrack('audio')` then `resumeTrack('audio')`; viewer hears silence then audio resumes. Listen for `local_track_paused`/`local_track_resumed`. + +### 13. Data-only publish +- `createSession({ onlyDataChannel:true, role:'publisher', streamId:'data' })`. +- Verify no camera/mic prompt, DC opens, and `sendData`/`sendJSON` works. + +### 14. Close +- `close()`; peers close, websocket closes, `closed` event fires. + + diff --git a/packages/webrtc-sdk/documents/V2-vs-V1.md b/packages/webrtc-sdk/documents/V2-vs-V1.md new file mode 100644 index 00000000..fc6c4935 --- /dev/null +++ b/packages/webrtc-sdk/documents/V2-vs-V1.md @@ -0,0 +1,137 @@ +## WebRTC SDK v2 vs v1 + +### TL;DR +- v2 provides a modern, type-safe, promise-based API with clear, high-level methods and typed events. It reduces boilerplate and "footguns" while keeping parity with core AMS features. +- v1 is powerful but callback-heavy, loosely typed, and exposes a very broad low-level surface that is harder to use correctly. + +### Why v2 is better +- **TypeScript-first**: strict types, better IDE help, fewer runtime mistakes. +- **Promise-based flow**: `await ready()` / `await join()` vs ad-hoc callbacks. +- **Typed events**: ergonomic `on('event', payload => { ... })` rather than stringly-typed callback fanout. +- **Single primary API**: `WebRTCClient` composes media and signaling with safe defaults. +- **Safer media operations**: device switching via `replaceTrack`, black dummy track when camera is turned off (no renegotiation hiccups). +- **Data channel**: chunking with backpressure, optional input sanitization. +- **QoS controls**: bandwidth, degradation preferences, stats helpers. +- **Rooms/Multitrack helpers**: `playSelective`, `enableTrack`, track assignment APIs. +- **New v2 features**: plugin API, data-only publish, audio output selection (sinkId), remote audio level metering, stream metadata update. + +### Quick usage comparison + +#### Initialize and publish +```js +// v1 (JS) +const adaptor = new WebRTCAdaptor({ + websocketURL, + mediaConstraints: { audio: true, video: true }, + callback: (info, obj) => { /* handle events */ }, + callbackError: (err, msg) => { /* handle errors */ }, +}); +// wait for 'initialized' callback +adaptor.publish('s1', 'OPTIONAL_TOKEN'); +``` + +```ts +// v2 (TS) +import { WebRTCClient } from './src'; +const sdk = new WebRTCClient({ websocketURL, mediaConstraints: { audio: true, video: true }, localVideo }); +await sdk.ready(); +await sdk.join({ role: 'publisher', streamId: 's1', token: 'OPTIONAL_TOKEN' }); +``` + +#### Play +```js +// v1 +adaptor.play('s1'); +``` + +```ts +// v2 +const viewer = new WebRTCClient({ websocketURL, isPlayMode: true, remoteVideo }); +await viewer.ready(); +await viewer.join({ role: 'viewer', streamId: 's1' }); +``` + +#### Device selection +```js +// v1 +adaptor.switchVideoCameraCapture('s1', deviceId); +adaptor.switchAudioInputSource('s1', micId); +``` + +```ts +// v2 +await sdk.selectVideoInput(deviceId); +await sdk.selectAudioInput(micId); +``` + +#### Screen share (+ overlay) +```js +// v1 +adaptor.switchDesktopCapture('s1'); +``` + +```ts +// v2 +await sdk.startScreenShare(); +await sdk.startScreenWithCameraOverlay(); +``` + +#### Data channel +```js +// v1 +adaptor.sendData('s1', 'hello'); +``` + +```ts +// v2 +await sdk.sendData('s1', 'hello'); +``` + +### New v2 capabilities (not in v1 by default) +- **Plugin API**: `WebRTCClient.register(sdk => { /* augment */ })` to extend behavior cleanly. +- **Data-only publish**: `new WebRTCClient({ websocketURL, onlyDataChannel: true })` to publish with DC only (no A/V capture). +- **Audio output selection (sinkId)**: `await sdk.setAudioOutput(deviceId, mediaElement?)` for routing playback to chosen output. +- **Remote audio levels (viewer)**: `enableRemoteAudioLevel('s1', cb)` to meter incoming audio. +- **Stream metadata update**: `updateStreamMetaData('s1', obj)` to push metadata to AMS. +- **One-liner sessions**: `WebRTCClient.createSession({ role, streamId, autoPlay })` simplifies startup. +- **Convenience helpers**: `publishAuto`, `playAuto`, `sendJSON` for common cases. +- **Reconnect backoff config**: `configureReconnect({ backoff, baseMs, maxMs, jitter })` with `reconnected` event. +- **Device hot-swap**: automatic default device re-acquisition on `devicechange`, emits `device_hotswapped`. +- **Track controls**: `pauseTrack('audio'|'video')` and `resumeTrack(...)`, emitting `local_track_paused/resumed`. + +### Rooms / multitrack +```js +// v1 selective play +adaptor.play({ streamId: 'main', enableTracks: ['camera_u1'], disableTracksByDefault: true }); +adaptor.enableTrack('main', 'camera_u1', true); +``` + +```ts +// v2 selective play +await viewer.playSelective({ streamId: 'main', enableTracks: ['camera_u1'], disableTracksByDefault: true }); +viewer.enableTrack('main', 'camera_u1', true); +``` + +### What still differs from v1 +- **Peer-to-peer multi-peer mode**: v1 supports `join(streamId)`/`isMultiPeer`; v2 focuses on publish/play/rooms and does not expose multi-peer. +- **Low-level media helpers**: v1 exposes `applyConstraints`, `openStream`, `updateVideoTrack`, etc.; v2 favors higher-level APIs. +- **DataChannel enable flag**: v1 `dataChannelEnabled`; v2 enables DC by default (no flag). +- **Play parameters**: v1 includes `subscriberName` and sets `userPublishId`; v2 does not send those fields. +- **ICE protocol list**: v1 exposes mutable `candidateTypes`; v2 keeps an internal list. +- **Stats detail**: v1 exposes extra fields (`fractionLost`, jitter averages, full inbound lists); v2 focuses on the common metrics. + +### Migration tips +- Publishing: `adaptor.publish(...)` → `await sdk.publish(streamId)` or `await sdk.join({ role: 'publisher', ... })`. +- Play: `adaptor.play(...)` → `await sdk.play(streamId)` or `await sdk.join({ role: 'viewer', ... })`. +- Device switching: `switchVideoCameraCapture` → `selectVideoInput`; `switchAudioInputSource` → `selectAudioInput`. +- Track toggling: `toggleVideo/toggleAudio` → `enableTrack(main, trackId, enabled)`. +- ABR: `forceStreamQuality` is available in both. +- Stats: `getStats`/`enableStats` available in both (types differ in v2). +- Data-only: v1 `onlyDataChannel` → v2 `{ onlyDataChannel: true }` in constructor. +- Metadata: v2 `updateStreamMetaData(streamId, obj)`. +- Audio output: new in v2 `setAudioOutput(deviceId, element?)`. + +### Bottom line +v2 streamlines common workflows, improves safety and DX, and adds modern capabilities, while keeping AMS feature parity for publish/play/rooms. Prefer v2 for new work; adopt it incrementally by mapping v1 calls to v2’s higher-level methods. + + diff --git a/packages/webrtc-sdk/examples/data-only.html b/packages/webrtc-sdk/examples/data-only.html new file mode 100644 index 00000000..f1dcd212 --- /dev/null +++ b/packages/webrtc-sdk/examples/data-only.html @@ -0,0 +1,55 @@ + + + + + + webrtc-sdk data-only sample + + + +

Data-Only Publish (TS v2)

+
+ + + +
+
+ Data Channel + + + +
+

+
+    
+  
+  
+
diff --git a/packages/webrtc-sdk/examples/play.html b/packages/webrtc-sdk/examples/play.html
index d835b8cb..3e107d34 100644
--- a/packages/webrtc-sdk/examples/play.html
+++ b/packages/webrtc-sdk/examples/play.html
@@ -44,6 +44,14 @@ 

Play Sample (TS v2)

+
+
+ Remote Audio Level + + + level: 0.00 +
+

 
     
+	
+	
+    
+    
+    
+  
+  
+    
+
+
+

WebRTC Multitrack Conference (SDK v2)

+ +
+
+ +
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+ + +
+ + + +
+ +
+
+
+ + + + +
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+
+ +
+
+ +
+
+
+
+
+
+ + +
+ + + + + diff --git a/packages/webrtc-sdk/examples/data-only.html b/packages/webrtc-sdk/examples/data-only.html index f1dcd212..3a92081f 100644 --- a/packages/webrtc-sdk/examples/data-only.html +++ b/packages/webrtc-sdk/examples/data-only.html @@ -22,7 +22,7 @@

Data-Only Publish (TS v2)


 
     
-  
-  
-
-
diff --git a/packages/webrtc-sdk/package.json b/packages/webrtc-sdk/package.json
index 48ef2b56..2f9e38fb 100644
--- a/packages/webrtc-sdk/package.json
+++ b/packages/webrtc-sdk/package.json
@@ -3,8 +3,8 @@
   "version": "2.0.0-beta.1",
   "private": true,
   "type": "module",
-  "main": "dist/index.cjs",
-  "module": "dist/index.mjs",
+  "main": "dist/index.js",
+  "module": "dist/index.js",
   "types": "dist/index.d.ts",
   "scripts": {
     "build": "tsc -p tsconfig.json",
diff --git a/packages/webrtc-sdk/src/client/base-client.ts b/packages/webrtc-sdk/src/client/base-client.ts
new file mode 100644
index 00000000..24624307
--- /dev/null
+++ b/packages/webrtc-sdk/src/client/base-client.ts
@@ -0,0 +1,930 @@
+import { Logger } from "../utils/logger.js";
+
+import { Emitter } from "../core/emitter.js";
+import type { EventMap } from "../core/events.js";
+import { MediaManager } from "../core/media-manager.js";
+import type { BaseClientOptions, GroupedDevices } from "../core/types.js";
+import { WebSocketAdaptor } from "../core/websocket-adaptor.js";
+
+export interface PeerContext {
+  pc: RTCPeerConnection;
+  dc?: RTCDataChannel;
+  videoSender?: RTCRtpSender;
+  audioSender?: RTCRtpSender;
+  mode?: StreamMode;
+}
+
+export type StreamMode = "publish" | "play";
+
+export interface ActiveStreamInfo {
+  mode: StreamMode;
+  token?: string;
+  roomId?: string;
+  streamName?: string;
+  metaData?: unknown;
+  role?: string;
+  subscriberId?: string;
+  subscriberCode?: string;
+  userPublishId?: string;
+  enableTracks?: string[];
+  disableTracksByDefault?: boolean;
+}
+
+interface ChunkState {
+  expected: number;
+  received: number;
+  buffers: Uint8Array[];
+}
+
+interface RemoteAudioMeter {
+  analyser: AnalyserNode;
+  timer: ReturnType;
+  data: Uint8Array;
+  source: MediaStreamAudioSourceNode;
+}
+
+interface ReconnectConfig {
+  backoff: "fixed" | "exp";
+  baseMs: number;
+  maxMs: number;
+  jitter: number;
+}
+
+export abstract class BaseClient extends Emitter {
+  static pluginInitMethods: Array<(sdk: BaseClient) => void> = [];
+
+  static register(init: (sdk: BaseClient) => void): void {
+    BaseClient.pluginInitMethods.push(init);
+  }
+
+  protected ws?: WebSocketAdaptor;
+  protected media: MediaManager;
+  protected get mediaManager(): MediaManager {
+    return this.media;
+  }
+  protected log: Logger;
+  protected isReady = false;
+  protected isPlayMode: boolean;
+  protected onlyDataChannel: boolean;
+  protected sanitizeDcStrings: boolean;
+  protected autoReconnect: boolean;
+  protected reconnectConfig: ReconnectConfig = {
+    backoff: "exp",
+    baseMs: 500,
+    maxMs: 8000,
+    jitter: 0.2,
+  };
+  protected peers: Map = new Map();
+  protected peerConfig: RTCConfiguration = {
+    iceServers: [{ urls: "stun:stun1.l.google.com:19302" }],
+  };
+  protected remoteDescriptionSet: Map = new Map();
+  protected candidateQueue: Map = new Map();
+  protected remoteVideo: HTMLVideoElement | null;
+  protected candidateTypes: Array<"udp" | "tcp"> = ["udp", "tcp"];
+  protected rxChunks: Map = new Map();
+  protected activeStreams: Map = new Map();
+  protected reconnectTimers: Map> = new Map();
+  protected lastReconnectAt: Map = new Map();
+  protected remoteStreams: Map = new Map();
+  protected audioContext: AudioContext | null = null;
+  protected remoteMeters: Map = new Map();
+  protected idMapping: Record> = Object.create(null);
+
+  constructor(opts: BaseClientOptions) {
+    super();
+    this.isPlayMode = !!opts.isPlayMode;
+    this.onlyDataChannel = !!opts.onlyDataChannel;
+    this.sanitizeDcStrings = !!opts.sanitizeDataChannelStrings;
+    this.autoReconnect = opts.autoReconnect ?? true;
+    this.remoteVideo = opts.remoteVideo ?? null;
+    this.log = new Logger(opts.debug ? "debug" : "info");
+
+    if (opts.reconnectConfig) {
+      this.reconnectConfig = {
+        backoff: opts.reconnectConfig.backoff ?? this.reconnectConfig.backoff,
+        baseMs: opts.reconnectConfig.baseMs ?? this.reconnectConfig.baseMs,
+        maxMs: opts.reconnectConfig.maxMs ?? this.reconnectConfig.maxMs,
+        jitter: opts.reconnectConfig.jitter ?? this.reconnectConfig.jitter,
+      };
+    }
+
+    this.media = opts.mediaManager ??
+      new MediaManager({ mediaConstraints: opts.mediaConstraints, localVideo: opts.localVideo });
+
+    this.media.on("devices_updated", g => this.emit("devices_updated", g));
+    this.media.on("local_tracks_changed", () => {
+      void this.applyLocalTracks();
+    });
+
+    if (!this.isPlayMode && !this.onlyDataChannel) {
+      this.media.initLocalStream().catch(() => {
+        this.emit("error", { error: "getUserMediaIsNotAllowed" });
+      });
+    }
+
+    if (opts.websocketURL || opts.httpEndpointUrl) {
+      this.ws = new WebSocketAdaptor({
+        websocketURL: opts.websocketURL,
+        httpEndpointUrl: opts.httpEndpointUrl,
+        webrtcadaptor: {
+          notifyEventListeners: (info: string, obj?: unknown) => this.handleTransportEvent(info, obj),
+        },
+        debug: opts.debug,
+      });
+    }
+  }
+
+  /**
+   * Resolves when underlying signaling is initialized and ready.
+   */
+  async ready(): Promise {
+    if (this.isReady) return;
+    await new Promise(resolve => {
+      this.once("initialized", () => resolve());
+    });
+  }
+
+  /**
+   * Stop an active stream (publish or play) and close its peer connection.
+   */
+  stop(streamId: string): void {
+    const ctx = this.peers.get(streamId);
+    const active = this.activeStreams.get(streamId);
+    const mode = (ctx && ctx.mode) || (active && active.mode);
+    if (ctx) {
+      try {
+        ctx.pc.close();
+      } catch (e) {
+        this.log.warn("pc.close failed", e);
+      }
+      this.peers.delete(streamId);
+    }
+    this.clearActiveStream(streamId);
+
+    if (this.ws) {
+      this.sendCommand({ command: "stop", streamId });
+    }
+    if (mode === "publish") {
+      this.emit("publish_finished", { streamId });
+    } else if (mode === "play") {
+      this.emit("play_finished", { streamId });
+    } else {
+      // fallback: emit both to preserve backward behavior when mode is unknown
+      this.emit("publish_finished", { streamId });
+      this.emit("play_finished", { streamId });
+    }
+  }
+
+  /** Configure reconnect backoff at runtime. */
+  configureReconnect(cfg: Partial): void {
+    this.reconnectConfig = { ...this.reconnectConfig, ...cfg } as ReconnectConfig;
+  }
+
+  /** Enumerate and group available media devices. */
+  async listDevices(): Promise {
+    return this.media.listDevices();
+  }
+
+  async setAudioOutput(deviceId: string, element?: HTMLMediaElement | null): Promise {
+    await this.media.setAudioOutput(deviceId, element);
+  }
+
+  async selectVideoInput(source: string | { facingMode: "user" | "environment" }): Promise {
+    await this.media.selectVideoInput(source);
+    await this.applyLocalTracks();
+  }
+
+  async selectAudioInput(deviceId: string): Promise {
+    await this.media.selectAudioInput(deviceId);
+    await this.applyLocalTracks();
+  }
+
+  pauseTrack(kind: "audio" | "video"): void {
+    this.media.pauseLocalTrack(kind);
+  }
+
+  resumeTrack(kind: "audio" | "video"): void {
+    this.media.resumeLocalTrack(kind);
+  }
+
+  async startScreenShare(): Promise {
+    await this.media.startScreenShare();
+    await this.applyLocalTracks();
+  }
+
+  async stopScreenShare(): Promise {
+    await this.media.stopScreenShare();
+    await this.applyLocalTracks();
+  }
+
+  async startScreenWithCameraOverlay(): Promise {
+    await this.media.startScreenWithCameraOverlay();
+    await this.applyLocalTracks();
+  }
+
+  async stopScreenWithCameraOverlay(): Promise {
+    await this.media.stopScreenWithCameraOverlay();
+    await this.applyLocalTracks();
+  }
+
+  async turnOffLocalCamera(): Promise {
+    this.media.turnOffLocalCamera();
+    for (const ctx of this.peers.values()) {
+      const primary = ctx.videoSender;
+      let sender = primary ?? ctx.pc.getSenders().find(s => s.track?.kind === "video");
+      if (!sender) continue;
+      try {
+        const stream = this.media.getLocalStream();
+        const blackTrack = stream?.getVideoTracks()[0] ?? null;
+        await sender.replaceTrack(blackTrack);
+      } catch (e) {
+        this.log.warn("replaceTrack(black) failed", e);
+      }
+    }
+  }
+
+  async turnOnLocalCamera(): Promise {
+    await this.media.turnOnLocalCamera();
+    await this.applyLocalTracks();
+  }
+
+  muteLocalMic(): void {
+    this.media.muteLocalMic();
+  }
+
+  unmuteLocalMic(): void {
+    this.media.unmuteLocalMic();
+  }
+
+  setVolumeLevel(level: number): void {
+    this.media.setVolumeLevel(level);
+  }
+
+  async enableAudioLevelForLocalStream(callback: (level: number) => void, periodMs = 200): Promise {
+    await this.media.enableAudioLevelForLocalStream(callback, periodMs);
+  }
+
+  disableAudioLevelForLocalStream(): void {
+    this.media.disableAudioLevelForLocalStream();
+  }
+
+  async enableAudioLevelWhenMuted(callback: (speaking: boolean) => void, threshold = 0.1): Promise {
+    await this.media.enableAudioLevelWhenMuted(callback, threshold);
+  }
+
+  disableAudioLevelWhenMuted(): void {
+    this.media.disableAudioLevelWhenMuted();
+  }
+
+  async getStats(streamId: string): Promise {
+    const ctx = this.peers.get(streamId);
+    if (!ctx) return false;
+    try {
+      const stats = await ctx.pc.getStats();
+      const ps = new (await import("../core/peer-stats.js")).PeerStats(streamId);
+      let bytesSent = 0;
+      let bytesRecv = 0;
+      let now = 0;
+      stats.forEach(r => {
+        if (r.type === "outbound-rtp") {
+          bytesSent += (r as any).bytesSent || 0;
+          if ((r as any).packetsSent) {
+            if ((r as any).kind === "audio") ps.audioPacketsSent = (r as any).packetsSent;
+            if ((r as any).kind === "video") {
+              ps.videoPacketsSent = (r as any).packetsSent;
+              ps.frameWidth = (r as any).frameWidth ?? ps.frameWidth;
+              ps.frameHeight = (r as any).frameHeight ?? ps.frameHeight;
+              if ((r as any).framesEncoded != null) ps.framesEncoded = (r as any).framesEncoded;
+            }
+          }
+          now = (r as any).timestamp || now;
+        } else if (r.type === "inbound-rtp") {
+          bytesRecv += (r as any).bytesReceived || 0;
+          if ((r as any).packetsReceived) {
+            if ((r as any).kind === "audio") ps.audioPacketsReceived = (r as any).packetsReceived;
+            if ((r as any).kind === "video") ps.videoPacketsReceived = (r as any).packetsReceived;
+          }
+          now = (r as any).timestamp || now;
+        } else if (r.type === "remote-inbound-rtp") {
+          if ((r as any).kind === "audio") {
+            if ((r as any).packetsLost != null) ps.audioPacketsLost = (r as any).packetsLost;
+            if ((r as any).roundTripTime != null) ps.audioRoundTripTime = (r as any).roundTripTime;
+            if ((r as any).jitter != null) ps.audioJitter = (r as any).jitter;
+          } else if ((r as any).kind === "video") {
+            if ((r as any).packetsLost != null) ps.videoPacketsLost = (r as any).packetsLost;
+            if ((r as any).roundTripTime != null) ps.videoRoundTripTime = (r as any).roundTripTime;
+            if ((r as any).jitter != null) ps.videoJitter = (r as any).jitter;
+          }
+        } else if (r.type === "track") {
+          if ((r as any).kind === "video") {
+            if ((r as any).frameWidth != null) ps.frameWidth = (r as any).frameWidth;
+            if ((r as any).frameHeight != null) ps.frameHeight = (r as any).frameHeight;
+            if ((r as any).framesDecoded != null) ps.framesDecoded = (r as any).framesDecoded;
+            if ((r as any).framesDropped != null) ps.framesDropped = (r as any).framesDropped;
+            if ((r as any).framesReceived != null) ps.framesReceived = (r as any).framesReceived;
+          }
+        } else if (r.type === "candidate-pair" && (r as any).state === "succeeded") {
+          if ((r as any).availableOutgoingBitrate != null) {
+            ps.availableOutgoingBitrateKbps = ((r as any).availableOutgoingBitrate as number) / 1000;
+          }
+          if ((r as any).currentRoundTripTime != null) {
+            ps.currentRoundTripTime = (r as any).currentRoundTripTime as number;
+          }
+        }
+      });
+      ps.totalBytesSent = bytesSent;
+      ps.totalBytesReceived = bytesRecv;
+      ps.currentTimestamp = now;
+      this.emit("updated_stats", ps);
+      return ps;
+    } catch {
+      return false;
+    }
+  }
+
+  enableStats(streamId: string, periodMs = 5000): void {
+    const key = `__stats_${streamId}`;
+    if ((this as any)[key]) return;
+    (this as any)[key] = setInterval(() => {
+      void this.getStats(streamId);
+    }, periodMs);
+  }
+
+  disableStats(streamId: string): void {
+    const key = `__stats_${streamId}`;
+    const timer = (this as unknown as Record)[key] as ReturnType | undefined;
+    if (timer) {
+      clearInterval(timer);
+      delete (this as unknown as Record)[key];
+    }
+  }
+
+  async sendData(streamId: string, data: string | ArrayBuffer): Promise {
+    const ctx = this.peers.get(streamId);
+    if (!ctx || !ctx.dc) {
+      this.log.warn("sendData: data channel not available for %s", streamId);
+      throw new Error("data_channel_not_available");
+    }
+    const dc = ctx.dc;
+    if (typeof data === "string") {
+      dc.send(data);
+      return;
+    }
+    const CHUNK_SIZE = 16000;
+    const binary = data as ArrayBuffer;
+    const length = binary.byteLength;
+    const token = Math.floor(Math.random() * 999999) | 0;
+    const header = new Int32Array(2);
+    header[0] = token;
+    header[1] = length;
+    dc.send(header);
+
+    let sent = 0;
+    dc.bufferedAmountLowThreshold = 1 << 20;
+    while (sent < length) {
+      const size = Math.min(length - sent, CHUNK_SIZE);
+      const buffer = new Uint8Array(size + 4);
+      const tokenArray = new Int32Array(1);
+      tokenArray[0] = token;
+      buffer.set(new Uint8Array(tokenArray.buffer, 0, 4), 0);
+      const chunk = new Uint8Array(binary, sent, size);
+      buffer.set(chunk, 4);
+      if (dc.bufferedAmount > dc.bufferedAmountLowThreshold) {
+        await new Promise(resolve => {
+          const onlow = () => {
+            (dc as any).removeEventListener("bufferedamountlow", onlow);
+            resolve();
+          };
+          (dc as any).addEventListener("bufferedamountlow", onlow, { once: true });
+        });
+      }
+      dc.send(buffer);
+      sent += size;
+    }
+  }
+
+  async sendJSON(streamId: string, obj: unknown): Promise {
+    const text = JSON.stringify(obj);
+    await this.sendData(streamId, text);
+  }
+
+  close(): void {
+    for (const streamId of Array.from(this.peers.keys())) {
+      this.stop(streamId);
+    }
+    try {
+      this.ws?.close();
+    } catch (e) {
+      this.log.warn("ws close failed", e);
+    }
+    this.emit("closed", undefined as never);
+  }
+
+  setSanitizeDataChannelStrings(enabled: boolean): void {
+    this.sanitizeDcStrings = !!enabled;
+  }
+
+  async enableRemoteAudioLevel(streamId: string, callback: (level: number) => void, periodMs = 200): Promise {
+    const stream = this.remoteStreams.get(streamId) ?? (this.remoteVideo?.srcObject as MediaStream | null) ?? null;
+    if (!stream) return;
+    if (!this.audioContext) this.audioContext = new AudioContext();
+    const ctx = this.audioContext;
+    const source = ctx.createMediaStreamSource(stream);
+    const analyser = ctx.createAnalyser();
+    analyser.fftSize = 256;
+    source.connect(analyser);
+    const data = new Uint8Array(analyser.frequencyBinCount);
+    if (this.remoteMeters.has(streamId)) this.disableRemoteAudioLevel(streamId);
+    const timer = setInterval(() => {
+      analyser.getByteTimeDomainData(data);
+      let sum = 0;
+      for (let i = 0; i < data.length; i++) {
+        const v = (data[i] - 128) / 128;
+        sum += v * v;
+      }
+      const rms = Math.sqrt(sum / data.length);
+      try {
+        callback(rms);
+      } catch (e) {
+        this.log.warn("remote audio level callback failed", e);
+      }
+    }, periodMs);
+    this.remoteMeters.set(streamId, { analyser, timer, data, source });
+  }
+
+  disableRemoteAudioLevel(streamId: string): void {
+    const meter = this.remoteMeters.get(streamId);
+    if (!meter) return;
+    clearInterval(meter.timer);
+    try {
+      meter.source.disconnect();
+    } catch (e) {
+      this.log.warn("remote audio source disconnect failed", e);
+    }
+    try {
+      meter.analyser.disconnect();
+    } catch (e) {
+      this.log.warn("remote audio analyser disconnect failed", e);
+    }
+    this.remoteMeters.delete(streamId);
+  }
+
+  protected abstract restartStream(streamId: string, info: ActiveStreamInfo): void;
+
+  protected trackActiveStream(streamId: string, info: ActiveStreamInfo): void {
+    this.activeStreams.set(streamId, info);
+  }
+
+  protected clearActiveStream(streamId: string): void {
+    this.activeStreams.delete(streamId);
+  }
+
+  protected getActiveStream(streamId: string): ActiveStreamInfo | undefined {
+    return this.activeStreams.get(streamId);
+  }
+
+  protected sendCommand(payload: Record): void {
+    if (!this.ws) return;
+    this.ws.send(JSON.stringify(payload));
+  }
+
+  protected onInitialized(): void {
+    // subclasses can extend; default no-op
+  }
+
+  protected onTransportEvent(_info: string, _obj?: unknown): void {
+    // subclasses override as needed
+  }
+
+  protected onStartCommand(streamId: string): void {
+    void this.startPublishing(streamId);
+  }
+
+  protected onRemoteOfferAnswered(streamId: string): void {
+    this.emit("play_started", { streamId });
+  }
+
+  protected onRemoteAnswerApplied(_streamId: string): void {
+    // subclasses may override
+  }
+
+  protected onNotification(_payload: Record): void {
+    // subclasses may override
+  }
+
+  protected createPeer(streamId: string, mode: StreamMode = "publish"): RTCPeerConnection {
+    const existingCtx = this.peers.get(streamId);
+    if (existingCtx && existingCtx.pc) {
+      existingCtx.mode = mode;
+      this.peers.set(streamId, existingCtx);
+      return existingCtx.pc;
+    }
+    const pc = new RTCPeerConnection(this.peerConfig);
+    pc.onicecandidate = ev => {
+      if (ev.candidate && this.ws) {
+        const cand = ev.candidate.candidate ?? "";
+        const protocolSupported = this.candidateTypes.some(p => cand.toLowerCase().includes(p));
+        if (!protocolSupported && cand !== "") {
+          this.log.debug("Skipping candidate due to protocol filter: %s", cand);
+          return;
+        }
+        const msg = {
+          command: "takeCandidate",
+          streamId,
+          label: ev.candidate.sdpMLineIndex ?? 0,
+          id: ev.candidate.sdpMid,
+          candidate: ev.candidate.candidate,
+        };
+        this.sendCommand(msg);
+      }
+    };
+    pc.oniceconnectionstatechange = () => {
+      this.log.info("ice state %s %s", streamId, pc.iceConnectionState);
+      this.emit("ice_connection_state_changed", { state: pc.iceConnectionState, streamId });
+      if (!this.autoReconnect) return;
+      if (!this.activeStreams.has(streamId)) return;
+      const state = pc.iceConnectionState;
+      if (state === "failed" || state === "closed") {
+        this.reconnectIfRequired(streamId, 0, false);
+      } else if (state === "disconnected") {
+        this.reconnectIfRequired(streamId, 3000, false);
+      }
+    };
+
+    pc.ontrack = (event: RTCTrackEvent) => {
+      this.log.debug("ontrack %s", streamId);
+      const stream = event.streams[0];
+      if (this.remoteVideo && this.remoteVideo.srcObject !== stream) {
+        this.remoteVideo.srcObject = stream;
+      }
+      if (stream) this.remoteStreams.set(streamId, stream);
+
+      const mid = event.transceiver && event.transceiver.mid;
+      const mapping = this.idMapping[streamId] || {};
+      const mappedId = typeof mid === "string" ? mapping[mid] : undefined;
+      const trackId = mappedId || event.track.id;
+      const payload = { stream, track: event.track, streamId, trackId };
+      this.emit("newTrackAvailable", payload as never);
+      this.emit("newStreamAvailable" as keyof EventMap, payload as never);
+    };
+
+    const existing = this.peers.get(streamId) ?? { pc };
+    existing.pc = pc;
+    existing.mode = mode;
+    this.peers.set(streamId, existing as PeerContext);
+    return pc;
+  }
+
+  protected setupDataChannel(streamId: string, dc: RTCDataChannel): void {
+    const ctx = this.peers.get(streamId);
+    if (ctx) ctx.dc = dc;
+    try {
+      (dc as any).binaryType = "arraybuffer";
+    } catch (e) {
+      this.log.warn("setting binaryType failed", e);
+    }
+    dc.onerror = error => {
+      this.log.warn("data channel error", error);
+      if (dc.readyState !== "closed") {
+        this.emit("error", { error: "data_channel_error", message: error });
+      }
+    };
+    dc.onopen = () => {
+      this.log.debug("data channel opened %s", streamId);
+      this.emit("data_channel_opened", { streamId });
+    };
+    dc.onclose = () => {
+      this.log.debug("data channel closed %s", streamId);
+      this.emit("data_channel_closed", { streamId });
+    };
+    dc.onmessage = event => {
+      const raw = event.data;
+      const processBuffer = (u8: Uint8Array) => {
+        if (u8.byteLength === 8) {
+          const view = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
+          const token = view.getInt32(0, true);
+          const total = view.getInt32(4, true);
+          this.rxChunks.set(token, { expected: total, received: 0, buffers: [] });
+          return;
+        }
+        if (u8.byteLength >= 4) {
+          const view = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
+          const token = view.getInt32(0, true);
+          const dataPart = u8.subarray(4);
+          const st = this.rxChunks.get(token);
+          if (!st) {
+            this.emit("data_received", {
+              streamId,
+              data: u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength),
+            });
+            return;
+          }
+          st.buffers.push(dataPart);
+          st.received += dataPart.byteLength;
+          if (st.received >= st.expected) {
+            const full = new Uint8Array(st.expected);
+            let offset = 0;
+            for (const b of st.buffers) {
+              full.set(b, offset);
+              offset += b.byteLength;
+            }
+            this.rxChunks.delete(token);
+            this.emit("data_received", { streamId, data: full.buffer });
+          }
+          return;
+        }
+        this.emit("data_received", {
+          streamId,
+          data: u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength),
+        });
+      };
+
+      if (typeof raw === "string") {
+        const text = this.sanitizeDcStrings ? raw.replace(//g, ">") : raw;
+        this.emit("data_received", { streamId, data: text });
+        return;
+      }
+      if (typeof Blob !== "undefined" && raw instanceof Blob) {
+        raw.arrayBuffer()
+          .then(ab => processBuffer(new Uint8Array(ab)))
+          .catch(() => {
+            this.emit("error", { error: "data_channel_blob_parse_failed", message: raw });
+          });
+        return;
+      }
+      if (raw instanceof ArrayBuffer) {
+        processBuffer(new Uint8Array(raw));
+        return;
+      }
+      if (ArrayBuffer.isView(raw)) {
+        const view = raw as ArrayBufferView;
+        processBuffer(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
+        return;
+      }
+      this.emit("data_received", { streamId, data: raw });
+    };
+  }
+
+  protected async startPublishing(streamId: string): Promise {
+    const pc = this.peers.get(streamId)?.pc ?? this.createPeer(streamId);
+    const stream = this.media.getLocalStream();
+    if (!stream && !this.onlyDataChannel) throw new Error("no_local_stream");
+
+    if (!this.onlyDataChannel && pc.getSenders().length === 0 && stream) {
+      for (const track of stream.getTracks()) {
+        const sender = pc.addTrack(track, stream);
+        if (track.kind === "video") this.peers.get(streamId)!.videoSender = sender;
+        if (track.kind === "audio") this.peers.get(streamId)!.audioSender = sender;
+      }
+    } else {
+      const ctx = this.peers.get(streamId);
+      if (ctx) {
+        const senders = pc.getSenders();
+        ctx.videoSender = ctx.videoSender || senders.find(s => s.track?.kind === "video");
+        ctx.audioSender = ctx.audioSender || senders.find(s => s.track?.kind === "audio");
+      }
+    }
+
+    try {
+      const dc = pc.createDataChannel ? pc.createDataChannel(streamId, { ordered: true }) : undefined;
+      if (dc) this.setupDataChannel(streamId, dc);
+    } catch (e) {
+      this.log.warn("createDataChannel not supported", e);
+    }
+    const offer = await pc.createOffer({ offerToReceiveAudio: false, offerToReceiveVideo: false });
+    await pc.setLocalDescription(offer);
+    this.sendTakeConfiguration(streamId, offer.type, offer.sdp ?? "");
+  }
+
+  protected sendTakeConfiguration(streamId: string, type: RTCSdpType, sdp: string): void {
+    const msg = { command: "takeConfiguration", streamId, type, sdp };
+    this.sendCommand(msg);
+    if (type === "offer") {
+      this.emit("publish_started", { streamId });
+    }
+  }
+
+  protected async applyLocalTracks(): Promise {
+    const stream = this.media.getLocalStream();
+    if (!stream) return;
+    for (const ctx of this.peers.values()) {
+      const senders = ctx.pc.getSenders();
+      const videoTracks = stream.getVideoTracks();
+      for (const track of videoTracks) {
+        let sender = (track.kind === "video" ? ctx.videoSender : ctx.audioSender) || senders.find(s => s.track && s.track.kind === track.kind);
+        if (sender && sender.replaceTrack) {
+          try {
+            await sender.replaceTrack(track);
+            if (track.kind === "video") ctx.videoSender = sender;
+            if (track.kind === "audio") ctx.audioSender = sender;
+          } catch (e) {
+            this.log.warn("replaceTrack failed", e);
+          }
+        } else {
+          try {
+            sender = ctx.pc.addTrack(track, stream);
+            if (track.kind === "video") ctx.videoSender = sender;
+            if (track.kind === "audio") ctx.audioSender = sender;
+          } catch (e) {
+            this.log.warn("addTrack failed", e);
+          }
+        }
+      }
+      const audioTracks = stream.getAudioTracks();
+      for (const track of audioTracks) {
+        let sender = ctx.audioSender || senders.find(s => s.track && s.track.kind === "audio");
+        if (sender && sender.replaceTrack) {
+          try {
+            await sender.replaceTrack(track);
+            ctx.audioSender = sender;
+          } catch (e) {
+            this.log.warn("replaceTrack failed", e);
+          }
+        } else {
+          try {
+            sender = ctx.pc.addTrack(track, stream);
+            ctx.audioSender = sender;
+          } catch (e) {
+            this.log.warn("addTrack failed", e);
+          }
+        }
+      }
+    }
+  }
+
+  private handleTransportEvent(info: string, obj?: unknown): void {
+    if (info === "initialized") {
+      this.isReady = true;
+      this.log.info("adaptor initialized");
+      for (const init of BaseClient.pluginInitMethods) {
+        try {
+          init(this);
+        } catch (e) {
+          this.log.warn("plugin init failed", e);
+        }
+      }
+      this.onInitialized();
+      return;
+    } else if (info === "start") {
+      const { streamId } = obj as { streamId: string };
+      this.log.debug("start received for %s", streamId);
+      this.onStartCommand(streamId);
+    } else if (info === "takeConfiguration") {
+      const payload = obj as { streamId: string; sdp: string; type: RTCSdpType; idMapping?: Record; streamTrackIds?: Record };
+      const { streamId, sdp, type } = payload;
+      this.log.debug("takeConfiguration %s %s", streamId, type);
+      const mapping = payload.idMapping || payload.streamTrackIds;
+      if (mapping) {
+        this.idMapping[streamId] = mapping;
+      }
+      if (type === "answer") {
+        const ctx = this.peers.get(streamId);
+        if (ctx) {
+          ctx.pc.setRemoteDescription(new RTCSessionDescription({ type, sdp })).then(() => {
+            this.remoteDescriptionSet.set(streamId, true);
+            const queued = this.candidateQueue.get(streamId) ?? [];
+            queued.forEach(c => ctx.pc.addIceCandidate(new RTCIceCandidate(c)));
+            this.candidateQueue.set(streamId, []);
+            this.onRemoteAnswerApplied(streamId);
+          });
+        }
+      } else if (type === "offer") {
+        const pc = this.createPeer(streamId, "play");
+        // Set up data channel for play mode like the original WebRTCAdaptor
+        pc.ondatachannel = ev => {
+          this.setupDataChannel(streamId, ev.channel);
+        };
+        pc.setRemoteDescription(new RTCSessionDescription({ type, sdp }))
+          .then(async () => {
+            const answer = await pc.createAnswer();
+            await pc.setLocalDescription(answer);
+            this.sendTakeConfiguration(streamId, answer.type, answer.sdp ?? "");
+            this.remoteDescriptionSet.set(streamId, true);
+            const queued = this.candidateQueue.get(streamId) ?? [];
+            queued.forEach(c => pc.addIceCandidate(new RTCIceCandidate(c)));
+            this.candidateQueue.set(streamId, []);
+            this.onRemoteOfferAnswered(streamId);
+          })
+          .catch(e => this.log.warn("setRemoteDescription failed", e));
+      }
+    } else if (info === "takeCandidate") {
+      const { streamId, label, id, candidate } = obj as { streamId: string; label: number | null; id?: string; candidate: string };
+      this.log.debug("takeCandidate %s", streamId);
+      const ice: RTCIceCandidateInit = { sdpMLineIndex: label ?? undefined, sdpMid: id, candidate };
+      const ctx = this.peers.get(streamId);
+      if (ctx) {
+        if (this.remoteDescriptionSet.get(streamId)) {
+          ctx.pc.addIceCandidate(new RTCIceCandidate(ice)).catch(e => this.log.warn("addIceCandidate failed", e));
+        } else {
+          const q = this.candidateQueue.get(streamId) ?? [];
+          q.push(ice);
+          this.candidateQueue.set(streamId, q);
+        }
+      }
+    } else if (info === "iceServerConfig") {
+      const cfg = obj as { stunServerUri?: string; turnServerUsername?: string; turnServerCredential?: string };
+      if (cfg.stunServerUri) {
+        if (cfg.stunServerUri.startsWith("turn:")) {
+          this.peerConfig.iceServers = [
+            { urls: "stun:stun1.l.google.com:19302" },
+            {
+              urls: cfg.stunServerUri,
+              username: cfg.turnServerUsername ?? "",
+              credential: cfg.turnServerCredential ?? "",
+            },
+          ];
+        } else if (cfg.stunServerUri.startsWith("stun:")) {
+          this.peerConfig.iceServers = [{ urls: cfg.stunServerUri }];
+        }
+        this.log.info("updated ice servers");
+      }
+    } else if (info === "stop") {
+      const { streamId } = obj as { streamId: string };
+      this.log.info("stop received for %s", streamId);
+      this.stop(streamId);
+    } else if (info === "notification") {
+      const payload = obj as Record;
+      const def = (payload.definition as string) || "";
+      const streamId = (payload.streamId as string) || "";
+      if (def === "publish_started") this.emit("publish_started", { streamId });
+      if (def === "publish_finished") this.emit("publish_finished", { streamId });
+      if (def === "play_started") this.emit("play_started", { streamId });
+      if (def === "play_finished") this.emit("play_finished", { streamId });
+      if (def === "subscriberCount") this.emit("subscriber_count" as keyof EventMap, obj as never);
+      if (def === "subscriberList") this.emit("subscriber_list" as keyof EventMap, obj as never);
+      if (def === "roomInformation") this.emit("room_information" as keyof EventMap, obj as never);
+      if (def === "broadcastObject") this.emit("broadcast_object" as keyof EventMap, obj as never);
+      if (def === "videoTrackAssignmentList") this.emit("video_track_assignments" as keyof EventMap, obj as never);
+      if (def === "streamInformation") this.emit("stream_information" as keyof EventMap, obj as never);
+      if (def === "trackList") this.emit("track_list" as keyof EventMap, obj as never);
+      if (def === "subtrackList") this.emit("subtrack_list" as keyof EventMap, obj as never);
+      if (def === "subtrackCount") this.emit("subtrack_count" as keyof EventMap, obj as never);
+      if (def === "joinedTheRoom") this.emit("room_joined" as keyof EventMap, obj as never);
+      if (def === "leavedTheRoom") this.emit("room_left" as keyof EventMap, obj as never);
+      if (def) this.emit(`notification:${def}` as keyof EventMap, obj as never);
+      this.onNotification(payload);
+    } else if (info === "closed") {
+      this.emit("closed", obj as never);
+      return;
+    } else if (info === "server_will_stop") {
+      this.emit("server_will_stop", obj as never);
+      return;
+    }
+
+    this.onTransportEvent(info, obj);
+    this.emit(info as keyof EventMap, obj as never);
+  }
+
+  private reconnectIfRequired(streamId: string, delayMs = 3000, forceReconnect = false): void {
+    if (!this.autoReconnect) return;
+    if (!this.activeStreams.has(streamId)) return;
+    if (delayMs <= 0) delayMs = this.reconnectConfig.baseMs;
+    if (this.reconnectTimers.has(streamId)) return;
+    const now = Date.now();
+    const last = this.lastReconnectAt.get(streamId) ?? 0;
+    if (!forceReconnect && now - last < 1000) {
+      delayMs = Math.max(delayMs, 1000);
+    }
+    const mode = this.activeStreams.get(streamId)?.mode;
+    if (mode === "publish") {
+      this.emit("reconnection_attempt_for_publisher" as keyof EventMap, { streamId } as never);
+    } else if (mode === "play") {
+      this.emit("reconnection_attempt_for_player" as keyof EventMap, { streamId } as never);
+    }
+    const nextDelay = this.computeNextDelay(delayMs);
+    const timer = setTimeout(() => {
+      this.reconnectTimers.delete(streamId);
+      this.tryAgain(streamId, forceReconnect);
+    }, nextDelay);
+    this.reconnectTimers.set(streamId, timer);
+  }
+
+  private computeNextDelay(lastDelay: number): number {
+    const { backoff, baseMs, maxMs, jitter } = this.reconnectConfig;
+    let next = backoff === "exp" ? Math.min(maxMs, Math.max(baseMs, lastDelay * 2)) : Math.min(maxMs, baseMs);
+    if (jitter > 0) {
+      const rand = 1 + (Math.random() * 2 - 1) * jitter;
+      next = Math.max(0, Math.floor(next * rand));
+    }
+    return next;
+  }
+
+  private tryAgain(streamId: string, forceReconnect: boolean): void {
+    const active = this.activeStreams.get(streamId);
+    if (!active) return;
+    this.lastReconnectAt.set(streamId, Date.now());
+    if (forceReconnect) {
+      this.log.info("Force reconnect requested for %s", streamId);
+    }
+    try {
+      this.stop(streamId);
+    } catch (e) {
+      this.log.warn("stop during reconnect failed", e);
+    }
+    setTimeout(() => {
+      this.restartStream(streamId, active);
+    }, 500);
+  }
+}
+
diff --git a/packages/webrtc-sdk/src/client/conference-client.ts b/packages/webrtc-sdk/src/client/conference-client.ts
new file mode 100644
index 00000000..496ae098
--- /dev/null
+++ b/packages/webrtc-sdk/src/client/conference-client.ts
@@ -0,0 +1,388 @@
+import type { EventMap } from "../core/events.js";
+import type {
+  ConferenceClientOptions,
+  ConferencePlayOptions,
+  ConferencePublishOptions,
+  JoinOptions,
+  JoinResult,
+  PlaySelectiveOptions,
+  RoomJoinOptions,
+  UpdateVideoTrackAssignmentsOptions,
+} from "../core/types.js";
+
+import { BaseClient, type ActiveStreamInfo } from "./base-client.js";
+
+export class ConferenceClient extends BaseClient {
+  private currentRoom?: string;
+  private currentPublishId?: string;
+
+  static register(initMethod: (sdk: ConferenceClient) => void): void {
+    BaseClient.register(initMethod as (sdk: BaseClient) => void);
+  }
+
+  constructor(opts: ConferenceClientOptions) {
+    super(opts);
+  }
+
+  protected override onInitialized(): void {
+    this.sendCommand({ command: "getIceServerConfig" });
+  }
+
+  async publish(opts: ConferencePublishOptions): Promise {
+    await this.ready();
+    this.trackActiveStream(opts.streamId, {
+      mode: "publish",
+      token: opts.token,
+      roomId: opts.roomId,
+      streamName: opts.streamName,
+      metaData: opts.metaData,
+      role: opts.role,
+      subscriberId: opts.subscriberId,
+      subscriberCode: opts.subscriberCode,
+    });
+    this.currentPublishId = opts.streamId;
+    this.currentRoom = opts.roomId;
+    const stream = this.media.getLocalStream();
+    const hasVideo = this.onlyDataChannel ? false : !!stream && stream.getVideoTracks().length > 0;
+    const hasAudio = this.onlyDataChannel ? false : !!stream && stream.getAudioTracks().length > 0;
+    this.sendCommand({
+      command: "publish",
+      streamId: opts.streamId,
+      token: opts.token ?? "",
+      mainTrack: opts.roomId,
+      streamName: opts.streamName ?? "",
+      metaData: opts.metaData ?? "",
+      role: opts.role ?? "",
+      subscriberId: opts.subscriberId ?? "",
+      subscriberCode: opts.subscriberCode ?? "",
+      video: hasVideo,
+      audio: hasAudio,
+    });
+  }
+
+  async play(opts: ConferencePlayOptions): Promise {
+    await this.ready();
+    this.trackActiveStream(opts.streamId, {
+      mode: "play",
+      token: opts.token,
+      roomId: opts.roomId,
+      enableTracks: opts.enableTracks,
+      userPublishId: opts.userPublishId,
+      role: opts.role,
+      subscriberId: opts.subscriberId,
+      subscriberCode: opts.subscriberCode,
+      disableTracksByDefault: opts.disableTracksByDefault,
+    });
+    if (opts.roomId) this.currentRoom = opts.roomId;
+    const pc = this.createPeer(opts.streamId, "play");
+    pc.ondatachannel = ev => this.setupDataChannel(opts.streamId, ev.channel);
+    this.sendCommand({
+      command: "play",
+      streamId: opts.streamId,
+      token: opts.token ?? "",
+      room: opts.roomId ?? "",
+      trackList: opts.enableTracks ?? [],
+      subscriberId: opts.subscriberId ?? "",
+      subscriberCode: opts.subscriberCode ?? "",
+      viewerInfo: opts.metaData ?? "",
+      role: opts.role ?? "",
+      subscriberName: opts.subscriberName ?? "",
+      userPublishId: opts.userPublishId ?? "",
+      disableTracksByDefault: opts.disableTracksByDefault ?? false,
+    });
+  }
+
+  async playSelective(opts: PlaySelectiveOptions): Promise {
+    await this.ready();
+    this.trackActiveStream(opts.streamId, {
+      mode: "play",
+      token: opts.token,
+      roomId: opts.roomId ?? opts.streamId,
+    });
+    this.createPeer(opts.streamId, "play");
+    this.sendCommand({
+      command: "play",
+      streamId: opts.streamId,
+      token: opts.token ?? "",
+      room: opts.roomId ?? "",
+      trackList: opts.enableTracks ?? [],
+      subscriberId: opts.subscriberId ?? "",
+      subscriberCode: opts.subscriberCode ?? "",
+      viewerInfo: opts.metaData ?? "",
+      role: opts.role ?? "",
+      userPublishId: "",
+      disableTracksByDefault: opts.disableTracksByDefault ?? false,
+    });
+  }
+
+  async join(options: JoinOptions): Promise {
+    await this.ready();
+    const timeout = options.timeoutMs ?? 20000;
+    return await new Promise((resolve, reject) => {
+      const to = setTimeout(() => reject(new Error("join_timeout")), timeout);
+      const cleanup = () => {
+        clearTimeout(to);
+        this.off("ice_connection_state_changed", onIce);
+        this.off("play_started", onPlayStarted);
+        this.off("publish_started", onPublishStarted);
+        this.off("error", onError);
+      };
+      const onIce = (payload: EventMap["ice_connection_state_changed"]) => {
+        if (
+          payload.streamId === options.streamId &&
+          (payload.state === "connected" || payload.state === "completed")
+        ) {
+          cleanup();
+          resolve({
+            streamId: options.streamId,
+            state: payload.state as "connected" | "completed",
+          });
+        }
+      };
+      const onPlayStarted = (payload: EventMap["play_started"]) => {
+        cleanup();
+        resolve({ streamId: payload.streamId, state: "track_added" });
+      };
+      const onPublishStarted = (payload: EventMap["publish_started"]) => {
+        cleanup();
+        resolve({ streamId: payload.streamId, state: "track_added" });
+      };
+      const onError = () => {
+        cleanup();
+        reject(new Error("join_failed"));
+      };
+      this.on("ice_connection_state_changed", onIce);
+      this.on("play_started", onPlayStarted);
+      this.on("publish_started", onPublishStarted);
+      this.on("error", onError);
+      if (options.role === "publisher") {
+        void this.publish({
+          streamId: options.streamId,
+          roomId: options.roomId ?? "",
+          token: options.token,
+        }).catch(onError);
+      } else {
+        void this.play({
+          streamId: options.streamId,
+          roomId: options.roomId,
+          token: options.token,
+        }).catch(onError);
+      }
+    });
+  }
+
+  async joinRoom(opts: RoomJoinOptions): Promise {
+    await this.ready();
+    const payload = {
+      command: "joinRoom",
+      room: opts.roomId,
+      mainTrack: opts.roomId,
+      streamId: opts.streamId ?? "",
+      mode: opts.mode ?? "multitrack",
+      streamName: opts.streamName ?? "",
+      role: opts.role ?? "",
+      metadata: opts.metaData ?? "",
+    } as Record;
+    this.sendCommand(payload);
+  }
+
+  async leaveRoom(roomId: string, streamId?: string): Promise {
+    await this.ready();
+    this.sendCommand({
+      command: "leaveFromRoom",
+      room: roomId,
+      mainTrack: roomId,
+      streamId: streamId ?? "",
+    });
+  }
+
+  async enableTrack(mainTrackId: string, trackId: string, enabled: boolean): Promise {
+    await this.ready();
+    this.sendCommand({ command: "enableTrack", streamId: mainTrackId, trackId, enabled });
+  }
+
+  async getTracks(streamId: string, token = ""): Promise {
+    await this.ready();
+    this.sendCommand({ command: "getTrackList", streamId, token });
+  }
+
+  async getSubtracks(streamId: string, role = "", offset = 0, size = 50): Promise {
+    await this.ready();
+    this.sendCommand({ command: "getSubtracks", streamId, role, offset, size });
+  }
+
+  async getSubtrackCount(streamId: string, role = "", status = ""): Promise {
+    await this.ready();
+    this.sendCommand({ command: "getSubtracksCount", streamId, role, status });
+  }
+
+  async requestVideoTrackAssignments(streamId: string): Promise {
+    await this.ready();
+    this.sendCommand({ command: "getVideoTrackAssignmentsCommand", streamId });
+  }
+
+  async updateVideoTrackAssignments(opts: UpdateVideoTrackAssignmentsOptions): Promise {
+    await this.ready();
+    this.sendCommand({
+      command: "updateVideoTrackAssignmentsCommand",
+      streamId: opts.streamId,
+      offset: opts.offset,
+      size: opts.size,
+    });
+  }
+
+  async setMaxVideoTrackCount(streamId: string, maxTrackCount: number): Promise {
+    await this.ready();
+    this.sendCommand({ command: "setMaxVideoTrackCountCommand", streamId, maxTrackCount });
+  }
+
+  async forceStreamQuality(streamId: string, height: number | "auto"): Promise {
+    await this.ready();
+    this.sendCommand({
+      command: "forceStreamQuality",
+      streamId,
+      streamHeight: height === "auto" ? "auto" : height,
+    });
+  }
+
+  toggleVideo(streamId: string, trackId: string, enabled: boolean): void {
+    this.sendCommand({ command: "toggleVideo", streamId, trackId, enabled });
+  }
+
+  toggleAudio(streamId: string, trackId: string, enabled: boolean): void {
+    this.sendCommand({ command: "toggleAudio", streamId, trackId, enabled });
+  }
+
+  async getRoomInfo(roomId: string, streamId = ""): Promise {
+    await this.ready();
+    this.sendCommand({ command: "getRoomInfo", room: roomId, streamId });
+  }
+
+  async getStreamInfo(streamId: string): Promise {
+    await this.ready();
+    this.sendCommand({ command: "getStreamInfo", streamId });
+  }
+
+  async getBroadcastObject(streamId: string): Promise {
+    await this.ready();
+    this.sendCommand({ command: "getBroadcastObject", streamId });
+  }
+
+  async getSubscriberCount(streamId: string): Promise {
+    await this.ready();
+    this.sendCommand({ command: "getSubscriberCount", streamId });
+  }
+
+  async getSubscriberList(streamId: string, offset = 0, size = 50): Promise {
+    await this.ready();
+    this.sendCommand({ command: "getSubscribers", streamId, offset, size });
+  }
+
+  peerMessage(streamId: string, definition: string, data: unknown): void {
+    this.sendCommand({ command: "peerMessageCommand", streamId, definition, data });
+  }
+
+  registerPushNotificationToken(
+    subscriberId: string,
+    authToken: string,
+    pushToken: string,
+    tokenType: "fcm" | "apn"
+  ): void {
+    this.sendCommand({
+      command: "registerPushNotificationToken",
+      subscriberId,
+      token: authToken,
+      pnsRegistrationToken: pushToken,
+      pnsType: tokenType,
+    });
+  }
+
+  sendPushNotification(
+    subscriberId: string,
+    authToken: string,
+    pushNotificationContent: Record,
+    subscriberIdsToNotify: string[]
+  ): void {
+    if (typeof pushNotificationContent !== "object") {
+      throw new Error("pushNotificationContent must be an object");
+    }
+    if (!Array.isArray(subscriberIdsToNotify)) {
+      throw new Error("subscriberIdsToNotify must be an array");
+    }
+    this.sendCommand({
+      command: "sendPushNotification",
+      subscriberId,
+      token: authToken,
+      pushNotificationContent,
+      subscriberIdsToNotify,
+    });
+  }
+
+  sendPushNotificationToTopic(
+    subscriberId: string,
+    authToken: string,
+    pushNotificationContent: Record,
+    topic: string
+  ): void {
+    if (typeof pushNotificationContent !== "object") {
+      throw new Error("pushNotificationContent must be an object");
+    }
+    this.sendCommand({
+      command: "sendPushNotification",
+      subscriberId,
+      token: authToken,
+      pushNotificationContent,
+      topic,
+    });
+  }
+
+  updateStreamMetaData(streamId: string, metaData: unknown): void {
+    this.sendCommand({ command: "updateStreamMetaData", streamId, metaData });
+  }
+
+  protected override onTransportEvent(info: string, obj?: unknown): void {
+    if (info === "notification") {
+      const payload = obj as Record | undefined;
+      const def = (payload?.definition as string) || "";
+      if (def === "roomInformation") this.emit("room_information", obj as never);
+      if (def === "joinedTheRoom") this.emit("room_joined", obj as never);
+      if (def === "leavedTheRoom") this.emit("room_left", obj as never);
+      if (def === "videoTrackAssignmentList") this.emit("video_track_assignments", obj as never);
+      if (def === "subscriberList") this.emit("subscriber_list", obj as never);
+      if (def === "subscriberCount") this.emit("subscriber_count", obj as never);
+      if (def === "trackList") this.emit("track_list", obj as never);
+      if (def === "subtrackList") this.emit("subtrack_list", obj as never);
+      if (def === "subtrackCount") this.emit("subtrack_count", obj as never);
+    }
+    super.onTransportEvent(info, obj);
+  }
+
+  protected override restartStream(streamId: string, info: ActiveStreamInfo): void {
+    if (info.mode === "publish") {
+      this.log.info("Re-publish attempt for %s", streamId);
+      void this.publish({
+        streamId,
+        roomId: info.roomId ?? this.currentRoom ?? "",
+        token: info.token,
+        streamName: info.streamName,
+        metaData: info.metaData,
+        role: info.role,
+        subscriberId: info.subscriberId,
+        subscriberCode: info.subscriberCode,
+      }).catch(e => this.log.warn("republish failed", e));
+    } else {
+      this.log.info("Re-play attempt for %s", streamId);
+      void this.play({
+        streamId,
+        roomId: info.roomId ?? this.currentRoom,
+        token: info.token,
+        enableTracks: info.enableTracks,
+        subscriberId: info.subscriberId,
+        subscriberCode: info.subscriberCode,
+        role: info.role,
+        disableTracksByDefault: info.disableTracksByDefault,
+        userPublishId: info.userPublishId,
+      }).catch(e => this.log.warn("replay failed", e));
+    }
+  }
+}
diff --git a/packages/webrtc-sdk/src/client/streaming-client.ts b/packages/webrtc-sdk/src/client/streaming-client.ts
new file mode 100644
index 00000000..f185412b
--- /dev/null
+++ b/packages/webrtc-sdk/src/client/streaming-client.ts
@@ -0,0 +1,314 @@
+import { BaseClient, type ActiveStreamInfo } from "./base-client.js";
+
+import type { EventMap } from "../core/events.js";
+import type {
+  JoinOptions,
+  JoinResult,
+  PlaySelectiveOptions,
+  StreamingClientOptions,
+} from "../core/types.js";
+
+export class StreamingClient extends BaseClient {
+  static register(initMethod: (sdk: StreamingClient) => void): void {
+    BaseClient.register(initMethod as (sdk: BaseClient) => void);
+  }
+
+  protected override onInitialized(): void {
+    this.sendCommand({ command: "getIceServerConfig" });
+  }
+
+  static async createSession(
+    opts: StreamingClientOptions &
+      Pick & {
+        autoPlay?: boolean;
+      }
+  ): Promise<{ client: StreamingClient; result: JoinResult }> {
+    const client = new StreamingClient(opts);
+    await client.ready();
+    const result = await client.join({
+      role: opts.role,
+      streamId: opts.streamId,
+      token: opts.token,
+      timeoutMs: opts.timeoutMs,
+    });
+    if (opts.autoPlay && opts.remoteVideo) {
+      try {
+        await opts.remoteVideo.play();
+      } catch {
+        // ignore autoplay errors (gesture required)
+      }
+    }
+    return { client, result };
+  }
+
+  constructor(opts: StreamingClientOptions) {
+    super(opts);
+  }
+
+  async publish(streamId: string, token?: string): Promise {
+    await this.ready();
+    this.log.info("publish %s", streamId);
+    this.trackActiveStream(streamId, { mode: "publish", token });
+
+    const stream = this.media.getLocalStream();
+    const hasVideo = this.onlyDataChannel ? false : !!stream && stream.getVideoTracks().length > 0;
+    const hasAudio = this.onlyDataChannel ? false : !!stream && stream.getAudioTracks().length > 0;
+
+    this.sendCommand({
+      command: "publish",
+      streamId,
+      token: token ?? "",
+      video: hasVideo,
+      audio: hasAudio,
+    });
+  }
+
+  async play(streamId: string, token?: string): Promise {
+    await this.ready();
+    this.log.info("play %s", streamId);
+    this.trackActiveStream(streamId, { mode: "play", token });
+
+    const pc = this.createPeer(streamId, "play");
+    pc.ondatachannel = ev => this.setupDataChannel(streamId, ev.channel);
+
+    this.sendCommand({
+      command: "play",
+      streamId,
+      token: token ?? "",
+      room: "",
+      trackList: [],
+      subscriberId: "",
+      subscriberCode: "",
+      viewerInfo: "",
+      role: "",
+      userPublishId: "",
+    });
+  }
+
+  async playSelective(opts: PlaySelectiveOptions): Promise {
+    await this.ready();
+    this.log.info("playSelective %s", opts.streamId);
+    this.trackActiveStream(opts.streamId, { mode: "play", token: opts.token });
+
+    const pc = this.createPeer(opts.streamId);
+    pc.ondatachannel = ev => this.setupDataChannel(opts.streamId, ev.channel);
+
+    this.sendCommand({
+      command: "play",
+      streamId: opts.streamId,
+      token: opts.token ?? "",
+      room: opts.roomId ?? "",
+      trackList: opts.enableTracks ?? [],
+      subscriberId: opts.subscriberId ?? "",
+      subscriberCode: opts.subscriberCode ?? "",
+      viewerInfo: opts.metaData ?? "",
+      role: opts.role ?? "",
+      userPublishId: "",
+      disableTracksByDefault: opts.disableTracksByDefault ?? false,
+    });
+  }
+
+  override stop(streamId: string): void {
+    super.stop(streamId);
+  }
+
+  async join(options: JoinOptions): Promise {
+    await this.ready();
+    const timeout = options.timeoutMs ?? 15000;
+
+    return await new Promise((resolve, reject) => {
+      const to = setTimeout(() => reject(new Error("join_timeout")), timeout);
+
+      const cleanup = () => {
+        clearTimeout(to);
+        this.off("ice_connection_state_changed", onIce);
+        this.off("play_started", onPlayStarted);
+        this.off("publish_started", onPublishStarted);
+        this.off("error", onErr);
+      };
+
+      const onIce = (payload: EventMap["ice_connection_state_changed"]) => {
+        if (
+          payload.streamId === options.streamId &&
+          (payload.state === "connected" || payload.state === "completed")
+        ) {
+          cleanup();
+          resolve({
+            streamId: options.streamId,
+            state: payload.state as "connected" | "completed",
+          });
+        }
+      };
+      const onPlayStarted = (payload: EventMap["play_started"]) => {
+        cleanup();
+        resolve({ streamId: payload.streamId, state: "track_added" });
+      };
+      const onPublishStarted = (payload: EventMap["publish_started"]) => {
+        cleanup();
+        resolve({ streamId: payload.streamId, state: "track_added" });
+      };
+      const onErr = (_payload: EventMap["error"]) => {
+        cleanup();
+        reject(new Error("join_failed"));
+      };
+
+      this.on("ice_connection_state_changed", onIce);
+      this.on("play_started", onPlayStarted);
+      this.on("publish_started", onPublishStarted);
+      this.on("error", onErr);
+
+      if (options.role === "publisher") {
+        void this.publish(options.streamId, options.token).catch(onErr);
+      } else {
+        void this.play(options.streamId, options.token).catch(onErr);
+      }
+    });
+  }
+
+  async forceStreamQuality(streamId: string, height: number | "auto"): Promise {
+    await this.ready();
+    this.sendCommand({
+      command: "forceStreamQuality",
+      streamId,
+      streamHeight: height === "auto" ? "auto" : height,
+    });
+  }
+
+  async changeBandwidth(streamId: string, bandwidth: number | "unlimited"): Promise {
+    const ctx = this.peers.get(streamId);
+    if (!ctx) return;
+    const sender = ctx.videoSender || ctx.pc.getSenders().find(s => s.track?.kind === "video");
+    if (!sender) return;
+    const params = sender.getParameters();
+    params.encodings = params.encodings || [{}];
+    if (bandwidth === "unlimited") {
+      delete (params.encodings[0] as Record).maxBitrate;
+    } else {
+      (params.encodings[0] as Record).maxBitrate = bandwidth * 1000;
+    }
+    try {
+      await sender.setParameters(params);
+    } catch (e) {
+      this.log.warn("setParameters(maxBitrate) failed", e);
+    }
+  }
+
+  async setDegradationPreference(
+    streamId: string,
+    preference: "maintain-framerate" | "maintain-resolution" | "balanced"
+  ): Promise {
+    const ctx = this.peers.get(streamId);
+    if (!ctx) return;
+    const sender = ctx.videoSender || ctx.pc.getSenders().find(s => s.track?.kind === "video");
+    if (!sender) return;
+    const params = sender.getParameters();
+    try {
+      (params as unknown as { degradationPreference?: string }).degradationPreference = preference;
+      await sender.setParameters(params);
+      this.log.info("Degradation Preference set to %s", preference);
+    } catch (e) {
+      this.log.warn("setParameters(degradationPreference) failed", e);
+    }
+  }
+
+  toggleVideo(streamId: string, trackId: string, enabled: boolean): void {
+    this.sendCommand({ command: "toggleVideo", streamId, trackId, enabled });
+  }
+
+  toggleAudio(streamId: string, trackId: string, enabled: boolean): void {
+    this.sendCommand({ command: "toggleAudio", streamId, trackId, enabled });
+  }
+
+  async getStreamInfo(streamId: string): Promise {
+    await this.ready();
+    this.sendCommand({ command: "getStreamInfo", streamId });
+  }
+
+  async getBroadcastObject(streamId: string): Promise {
+    await this.ready();
+    this.sendCommand({ command: "getBroadcastObject", streamId });
+  }
+
+  async getSubscriberCount(streamId: string): Promise {
+    await this.ready();
+    this.sendCommand({ command: "getSubscriberCount", streamId });
+  }
+
+  async getSubscriberList(streamId: string, offset = 0, size = 50): Promise {
+    await this.ready();
+    this.sendCommand({ command: "getSubscribers", streamId, offset, size });
+  }
+
+  peerMessage(streamId: string, definition: string, data: unknown): void {
+    this.sendCommand({ command: "peerMessageCommand", streamId, definition, data });
+  }
+
+  registerPushNotificationToken(
+    subscriberId: string,
+    authToken: string,
+    pushToken: string,
+    tokenType: "fcm" | "apn"
+  ): void {
+    this.sendCommand({
+      command: "registerPushNotificationToken",
+      subscriberId,
+      token: authToken,
+      pnsRegistrationToken: pushToken,
+      pnsType: tokenType,
+    });
+  }
+
+  sendPushNotification(
+    subscriberId: string,
+    authToken: string,
+    pushNotificationContent: Record,
+    subscriberIdsToNotify: string[]
+  ): void {
+    if (typeof pushNotificationContent !== "object") {
+      throw new Error("pushNotificationContent must be an object");
+    }
+    if (!Array.isArray(subscriberIdsToNotify)) {
+      throw new Error("subscriberIdsToNotify must be an array");
+    }
+    this.sendCommand({
+      command: "sendPushNotification",
+      subscriberId,
+      token: authToken,
+      pushNotificationContent,
+      subscriberIdsToNotify,
+    });
+  }
+
+  sendPushNotificationToTopic(
+    subscriberId: string,
+    authToken: string,
+    pushNotificationContent: Record,
+    topic: string
+  ): void {
+    if (typeof pushNotificationContent !== "object") {
+      throw new Error("pushNotificationContent must be an object");
+    }
+    this.sendCommand({
+      command: "sendPushNotification",
+      subscriberId,
+      token: authToken,
+      pushNotificationContent,
+      topic,
+    });
+  }
+
+  updateStreamMetaData(streamId: string, metaData: unknown): void {
+    this.sendCommand({ command: "updateStreamMetaData", streamId, metaData });
+  }
+
+  protected override restartStream(streamId: string, info: ActiveStreamInfo): void {
+    if (info.mode === "publish") {
+      this.log.info("Re-publish attempt for %s", streamId);
+      void this.publish(streamId, info.token).catch(e => this.log.warn("republish failed", e));
+    } else {
+      this.log.info("Re-play attempt for %s", streamId);
+      void this.play(streamId, info.token).catch(e => this.log.warn("replay failed", e));
+    }
+  }
+}
+
diff --git a/packages/webrtc-sdk/src/core/types.ts b/packages/webrtc-sdk/src/core/types.ts
index e48bad8f..a1b4815b 100644
--- a/packages/webrtc-sdk/src/core/types.ts
+++ b/packages/webrtc-sdk/src/core/types.ts
@@ -6,9 +6,9 @@
 export type Role = "publisher" | "viewer";
 
 /**
- * Options to configure {@link WebRTCClient}.
+ * Common configuration shared across client implementations.
  */
-export interface WebRTCClientOptions {
+export interface BaseClientOptions {
   /** WebSocket signaling URL (e.g. wss://host:5443/App/websocket) */
   websocketURL?: string;
   /** HTTP REST endpoint of Ant Media (used as fallback by signaling layer) */
@@ -23,6 +23,8 @@ export interface WebRTCClientOptions {
   localVideo?: HTMLVideoElement | null;
   /** Remote element to render incoming media (viewer side) */
   remoteVideo?: HTMLVideoElement | null;
+  /** Optional preconfigured MediaManager instance for advanced integrations */
+  mediaManager?: import("./media-manager.js").MediaManager;
   /** Enable verbose logging */
   debug?: boolean;
   /** Enable automatic reconnection on ICE failure/disconnect (default: true) */
@@ -38,6 +40,40 @@ export interface WebRTCClientOptions {
   sanitizeDataChannelStrings?: boolean;
 }
 
+/**
+ * Options to configure {@link WebRTCClient}.
+ */
+export interface WebRTCClientOptions extends BaseClientOptions {}
+
+export interface StreamingClientOptions extends BaseClientOptions {}
+
+export interface ConferenceClientOptions extends BaseClientOptions {}
+
+export interface ConferencePublishOptions {
+  streamId: string;
+  roomId: string;
+  token?: string;
+  subscriberId?: string;
+  subscriberCode?: string;
+  streamName?: string;
+  metaData?: unknown;
+  role?: string;
+}
+
+export interface ConferencePlayOptions {
+  streamId: string;
+  roomId?: string;
+  token?: string;
+  enableTracks?: string[];
+  subscriberId?: string;
+  subscriberCode?: string;
+  subscriberName?: string;
+  metaData?: unknown;
+  role?: string;
+  userPublishId?: string;
+  disableTracksByDefault?: boolean;
+}
+
 /**
  * Options for the one-liner {@link WebRTCClient.join} flow.
  */
@@ -95,6 +131,9 @@ export interface RoomJoinOptions {
   streamId?: string;
   role?: string;
   metaData?: unknown;
+  streamName?: string;
+  mode?: "mcu" | "amcu" | "multitrack";
+  timeoutMs?: number;
 }
 
 export interface UpdateVideoTrackAssignmentsOptions {
diff --git a/packages/webrtc-sdk/src/index.ts b/packages/webrtc-sdk/src/index.ts
index 334febf6..266afc86 100644
--- a/packages/webrtc-sdk/src/index.ts
+++ b/packages/webrtc-sdk/src/index.ts
@@ -7,3 +7,6 @@ export * from "./core/websocket-adaptor.js";
 export * from "./core/media-manager.js";
 export * from "./core/webrtc-client.js";
 export * from "./utils/utility.js";
+export * from "./client/base-client.js";
+export * from "./client/streaming-client.js";
+export * from "./client/conference-client.js";
diff --git a/src/main/webapp/conference.html b/src/main/webapp/conference.html
index e4fb9967..ad1dfbfe 100644
--- a/src/main/webapp/conference.html
+++ b/src/main/webapp/conference.html
@@ -474,6 +474,7 @@ 

WebRTC Multitrack Conference

} function handleNotificationEvent(data) { + console.log("=== CONFERENCE.HTML ALL NOTIFICATION EVENTS ===", data); var notificationEvent = JSON.parse(data.data); if (notificationEvent != null && typeof (notificationEvent) == "object") { var eventStreamId = notificationEvent.streamId; @@ -502,14 +503,21 @@

WebRTC Multitrack Conference

} else if (eventTyp == "UPDATE_SOUND_LEVEL") { //console.log("Received data : ", data.data); } else if (eventTyp == "VIDEO_TRACK_ASSIGNMENT_LIST") { + console.log("=== CONFERENCE.HTML TRACK ASSIGNMENT ==="); console.log("VIDEO_TRACK_ASSIGNMENT_LIST: " + data.data); const jsonObject = JSON.parse(data.data); + console.log("=== PARSED ASSIGNMENT PAYLOAD ===", jsonObject.payload); jsonObject.payload.forEach(item => { + console.log("=== ASSIGNMENT ITEM ===", item); + console.log("videoLabel:", item.videoLabel, "trackId:", item.trackId); var overlay = document.getElementById("overlay" + item.videoLabel); if (overlay != null) { + console.log("UPDATING OVERLAY:", item.videoLabel + "->" + item.trackId); overlay.innerHTML = item.videoLabel + "->" + item.trackId; + } else { + console.log("OVERLAY NOT FOUND for:", item.videoLabel); } }); } @@ -565,13 +573,22 @@

WebRTC Multitrack Conference

//In multitrack conferencing the stream is same, tracks are being and remove from the stream var roomId = roomNameBox.value; - console.log("new track available with id: " + console.log("=== CONFERENCE.HTML NEW TRACK AVAILABLE ==="); + console.log("trackId:", obj.trackId); + console.log("track.id:", obj.track.id); + console.log("track.kind:", obj.track.kind); + console.log("stream:", obj.stream); + console.log("--------------------------------------------------new track available with id: " + obj.trackId + " and kind: " + obj.track.kind + " on the room:" + roomId); //trackId is ARDAMSv+STREAM_ID or ARDAMSa+STREAM_ID var incomingTrackId = obj.trackId.substring("ARDAMSx".length); + console.log("**1*0312*3012*30 incomingTrackId", incomingTrackId); + console.log("roomId:", roomId, "publishStreamId:", publishStreamId); + if (incomingTrackId == roomId || incomingTrackId == publishStreamId) { + console.log("SKIPPING TRACK - matches room or publish stream"); return; } @@ -584,6 +601,37 @@

WebRTC Multitrack Conference

} video.srcObject.addTrack(obj.track) + + console.log("CONFERENCE.HTML: ADDED TRACK TO VIDEO", incomingTrackId); + console.log("CONFERENCE.HTML: VIDEO ELEMENT STATE AFTER ADDING TRACK:", { + readyState: video.readyState, + networkState: video.networkState, + paused: video.paused, + autoplay: video.autoplay, + tracks: video.srcObject ? video.srcObject.getTracks().length : 0 + }); + + // Try explicit play like conference-v2 + try { + console.log("CONFERENCE.HTML: TRYING TO PLAY VIDEO", incomingTrackId); + video.play().then(() => { + console.log("CONFERENCE.HTML: VIDEO PLAY SUCCESS", incomingTrackId); + }).catch(e => { + console.log("CONFERENCE.HTML: VIDEO PLAY FAILED", incomingTrackId, e); + }); + } catch (e) { + console.log("CONFERENCE.HTML: VIDEO PLAY ERROR", incomingTrackId, e); + } + + console.log("CONFERENCE.HTML: CHECKING IF WE REQUEST TRACK ASSIGNMENTS..."); + + // Let's see if the original requests track assignments anywhere + if (typeof webRTCAdaptor.requestVideoTrackAssignments === 'function') { + console.log("CONFERENCE.HTML: REQUESTING VIDEO TRACK ASSIGNMENTS"); + webRTCAdaptor.requestVideoTrackAssignments(roomId); + } else { + console.log("CONFERENCE.HTML: requestVideoTrackAssignments not available"); + } obj.track.onended = event => { console.log("track is ended with id: " + event.target.id) @@ -609,6 +657,7 @@

WebRTC Multitrack Conference

}; function createRemoteVideo(trackLabel, kind) { + console.log("=== CONFERENCE.HTML CREATING VIDEO ===", trackLabel, kind); var player = document.createElement("div"); player.className = "col-sm-3"; player.id = "player" + trackLabel; @@ -621,6 +670,7 @@

WebRTC Multitrack Conference

+'
' + trackLabel + '
'; document.getElementById("players").appendChild(player); + console.log("=== CONFERENCE.HTML VIDEO CREATED ===", trackLabel); } function removeRemoteVideo(trackLabel) { @@ -811,14 +861,17 @@

WebRTC Multitrack Conference

isPlaying = false; } else if (info == "data_channel_opened") { + console.log("=== CONFERENCE.HTML DATA CHANNEL OPENED ===", obj); console.log("Data Channel open for stream id", obj); isDataChannelOpen = true; } else if (info == "data_channel_closed") { + console.log("=== CONFERENCE.HTML DATA CHANNEL CLOSED ===", obj); console.log("Data Channel closed for stream id", obj); isDataChannelOpen = false; } else if (info == "data_received") { + console.log("=== CONFERENCE.HTML DATA RECEIVED ===", obj); handleNotificationEvent(obj); } else if (info == "session_restored") { From f159948e02a115c2c8fea92364d21591f9781375 Mon Sep 17 00:00:00 2001 From: golgetahir Date: Mon, 29 Sep 2025 16:43:01 +0300 Subject: [PATCH 19/31] Add typedocs --- .../webrtc-sdk/examples/conference-v2.html | 4 - packages/webrtc-sdk/src/client/base-client.ts | 15 +++ .../src/client/conference-client.ts | 92 +++++++++++++++++++ .../webrtc-sdk/src/client/streaming-client.ts | 72 +++++++++++++++ 4 files changed, 179 insertions(+), 4 deletions(-) diff --git a/packages/webrtc-sdk/examples/conference-v2.html b/packages/webrtc-sdk/examples/conference-v2.html index 51713820..43f98cce 100644 --- a/packages/webrtc-sdk/examples/conference-v2.html +++ b/packages/webrtc-sdk/examples/conference-v2.html @@ -469,15 +469,11 @@

WebRTC Multitrack Conference (SDK v2)

} function handleBroadcastObject(obj) { - console.log('=== BROADCAST OBJECT RECEIVED ===', obj); const broadcastObject = typeof obj.broadcast === 'string' ? JSON.parse(obj.broadcast) : obj.broadcast; if (!broadcastObject) return; - console.log('=== PARSED BROADCAST OBJECT ===', broadcastObject); if (obj.streamId === roomNameEl.value.trim()) { - console.log('=== ROOM BROADCAST OBJECT ===', broadcastObject); handleRoomBroadcastObject(broadcastObject); } else { - console.log('=== PARTICIPANT BROADCAST OBJECT ===', broadcastObject); handleParticipantBroadcastObject(broadcastObject); } } diff --git a/packages/webrtc-sdk/src/client/base-client.ts b/packages/webrtc-sdk/src/client/base-client.ts index 24624307..c08b39eb 100644 --- a/packages/webrtc-sdk/src/client/base-client.ts +++ b/packages/webrtc-sdk/src/client/base-client.ts @@ -50,6 +50,21 @@ interface ReconnectConfig { jitter: number; } +/** + * BaseClient + * + * Low-level WebRTC signaling and media management foundation used by higher-level clients + * (e.g., {@link ConferenceClient}, {@link StreamingClient}). + * + * Responsibilities: + * - Manages WebSocket signaling to Ant Media Server + * - Creates/maintains per-stream RTCPeerConnections and DataChannels + * - Applies local media tracks and exposes helpers to control devices and screen share + * - Emits typed events described by {@link EventMap} + * - Provides reconnection with backoff and per-stream tracking + * + * Consumers typically use concrete subclasses rather than instantiating this class directly. + */ export abstract class BaseClient extends Emitter { static pluginInitMethods: Array<(sdk: BaseClient) => void> = []; diff --git a/packages/webrtc-sdk/src/client/conference-client.ts b/packages/webrtc-sdk/src/client/conference-client.ts index 496ae098..d1d1dd31 100644 --- a/packages/webrtc-sdk/src/client/conference-client.ts +++ b/packages/webrtc-sdk/src/client/conference-client.ts @@ -12,6 +12,19 @@ import type { import { BaseClient, type ActiveStreamInfo } from "./base-client.js"; +/** + * ConferenceClient + * + * High-level SDK for Ant Media multitrack conferences. It publishes a participant stream as a + * subtrack of a room (main track) and plays the room with all (or selected) subtracks over a + * single RTCPeerConnection. It also exposes helpers for track assignment/pagination and room + * information queries. + * + * Typical usage: + * 1. Optionally publish your local stream as a subtrack of a room using {@link publish}. + * 2. Play the room using {@link play} to receive other participants' subtracks. + * 3. Listen to `newTrackAvailable` events to attach incoming tracks to media elements. + */ export class ConferenceClient extends BaseClient { private currentRoom?: string; private currentPublishId?: string; @@ -20,6 +33,10 @@ export class ConferenceClient extends BaseClient { BaseClient.register(initMethod as (sdk: BaseClient) => void); } + /** + * Create a ConferenceClient. + * @param opts Client options including signaling endpoints and media constraints. + */ constructor(opts: ConferenceClientOptions) { super(opts); } @@ -28,6 +45,11 @@ export class ConferenceClient extends BaseClient { this.sendCommand({ command: "getIceServerConfig" }); } + /** + * Publish a participant stream as a subtrack of the given room. + * @param opts Options including `streamId` (your publish id) and `roomId` (main track id). + * `metaData` may include user state (e.g., camera/mic status) as JSON. + */ async publish(opts: ConferencePublishOptions): Promise { await this.ready(); this.trackActiveStream(opts.streamId, { @@ -60,6 +82,11 @@ export class ConferenceClient extends BaseClient { }); } + /** + * Play a room (main track). Server responds with an SDP offer containing the enabled subtracks. + * The client answers and emits `newTrackAvailable` for each incoming media track. + * @param opts Options including `streamId` (room id to use on the PC) and `roomId` (room). + */ async play(opts: ConferencePlayOptions): Promise { await this.ready(); this.trackActiveStream(opts.streamId, { @@ -92,6 +119,10 @@ export class ConferenceClient extends BaseClient { }); } + /** + * Start a selective play session with optional `enableTracks` and `disableTracksByDefault`. + * This is useful for rendering a subset of participants. + */ async playSelective(opts: PlaySelectiveOptions): Promise { await this.ready(); this.trackActiveStream(opts.streamId, { @@ -115,6 +146,10 @@ export class ConferenceClient extends BaseClient { }); } + /** + * Join helper that resolves when ICE is `connected/completed` or the first media track arrives. + * Publishes or plays depending on `options.role`. + */ async join(options: JoinOptions): Promise { await this.ready(); const timeout = options.timeoutMs ?? 20000; @@ -171,6 +206,9 @@ export class ConferenceClient extends BaseClient { }); } + /** + * Legacy room-join command. For multitrack conferencing, prefer {@link publish} + {@link play}. + */ async joinRoom(opts: RoomJoinOptions): Promise { await this.ready(); const payload = { @@ -186,6 +224,9 @@ export class ConferenceClient extends BaseClient { this.sendCommand(payload); } + /** + * Leave the room (server side). This will also close all peer connections for this client. + */ async leaveRoom(roomId: string, streamId?: string): Promise { await this.ready(); this.sendCommand({ @@ -196,31 +237,49 @@ export class ConferenceClient extends BaseClient { }); } + /** + * Enable/disable a specific subtrack under a room (server forwards media when enabled). + */ async enableTrack(mainTrackId: string, trackId: string, enabled: boolean): Promise { await this.ready(); this.sendCommand({ command: "enableTrack", streamId: mainTrackId, trackId, enabled }); } + /** + * Request list of track ids under a main track. + */ async getTracks(streamId: string, token = ""): Promise { await this.ready(); this.sendCommand({ command: "getTrackList", streamId, token }); } + /** + * Request paginated subtracks for a given main track, optionally filtered by `role`. + */ async getSubtracks(streamId: string, role = "", offset = 0, size = 50): Promise { await this.ready(); this.sendCommand({ command: "getSubtracks", streamId, role, offset, size }); } + /** + * Request subtrack count for a main track, filterable by role and status. + */ async getSubtrackCount(streamId: string, role = "", status = ""): Promise { await this.ready(); this.sendCommand({ command: "getSubtracksCount", streamId, role, status }); } + /** + * Request current video track assignment list (useful for pagination/slot assignments). + */ async requestVideoTrackAssignments(streamId: string): Promise { await this.ready(); this.sendCommand({ command: "getVideoTrackAssignmentsCommand", streamId }); } + /** + * Update pagination window for video track assignments. + */ async updateVideoTrackAssignments(opts: UpdateVideoTrackAssignmentsOptions): Promise { await this.ready(); this.sendCommand({ @@ -231,11 +290,17 @@ export class ConferenceClient extends BaseClient { }); } + /** + * Set the maximum number of video tracks the server should forward for the room. + */ async setMaxVideoTrackCount(streamId: string, maxTrackCount: number): Promise { await this.ready(); this.sendCommand({ command: "setMaxVideoTrackCountCommand", streamId, maxTrackCount }); } + /** + * Force a specific ABR height for the current viewer session (or `auto`). + */ async forceStreamQuality(streamId: string, height: number | "auto"): Promise { await this.ready(); this.sendCommand({ @@ -245,39 +310,63 @@ export class ConferenceClient extends BaseClient { }); } + /** + * Toggle a video track server-side. + */ toggleVideo(streamId: string, trackId: string, enabled: boolean): void { this.sendCommand({ command: "toggleVideo", streamId, trackId, enabled }); } + /** + * Toggle an audio track server-side. + */ toggleAudio(streamId: string, trackId: string, enabled: boolean): void { this.sendCommand({ command: "toggleAudio", streamId, trackId, enabled }); } + /** + * Request legacy room information (ids and names of streams in the room). + */ async getRoomInfo(roomId: string, streamId = ""): Promise { await this.ready(); this.sendCommand({ command: "getRoomInfo", room: roomId, streamId }); } + /** + * Request stream information for a specific stream. + */ async getStreamInfo(streamId: string): Promise { await this.ready(); this.sendCommand({ command: "getStreamInfo", streamId }); } + /** + * Request broadcast object for a main track or subtrack (includes metadata and relations). + */ async getBroadcastObject(streamId: string): Promise { await this.ready(); this.sendCommand({ command: "getBroadcastObject", streamId }); } + /** + * Request subscriber count for a stream. + */ async getSubscriberCount(streamId: string): Promise { await this.ready(); this.sendCommand({ command: "getSubscriberCount", streamId }); } + /** + * Request paginated subscriber list for a stream. + */ async getSubscriberList(streamId: string, offset = 0, size = 50): Promise { await this.ready(); this.sendCommand({ command: "getSubscribers", streamId, offset, size }); } + /** + * Send a peer message to another participant in a peer-to-peer session. + */ peerMessage(streamId: string, definition: string, data: unknown): void { this.sendCommand({ command: "peerMessageCommand", streamId, definition, data }); } @@ -336,6 +425,9 @@ export class ConferenceClient extends BaseClient { }); } + /** + * Update the metadata (free-form JSON/text) for a specific stream. + */ updateStreamMetaData(streamId: string, metaData: unknown): void { this.sendCommand({ command: "updateStreamMetaData", streamId, metaData }); } diff --git a/packages/webrtc-sdk/src/client/streaming-client.ts b/packages/webrtc-sdk/src/client/streaming-client.ts index f185412b..7a98b31e 100644 --- a/packages/webrtc-sdk/src/client/streaming-client.ts +++ b/packages/webrtc-sdk/src/client/streaming-client.ts @@ -8,7 +8,17 @@ import type { StreamingClientOptions, } from "../core/types.js"; +/** + * StreamingClient + * + * High-level SDK for simple publish/play scenarios (single-track). It wraps the base signaling + * and media operations, providing convenience helpers like {@link createSession} and bandwidth + * controls without the multitrack room semantics of {@link ConferenceClient}. + */ export class StreamingClient extends BaseClient { + /** + * Register a plugin initializer to be invoked once the client is initialized. + */ static register(initMethod: (sdk: StreamingClient) => void): void { BaseClient.register(initMethod as (sdk: BaseClient) => void); } @@ -17,6 +27,10 @@ export class StreamingClient extends BaseClient { this.sendCommand({ command: "getIceServerConfig" }); } + /** + * Create a client, wait for readiness, and immediately join as publisher/player. + * Optionally attempts to autoplay the provided `remoteVideo` element. + */ static async createSession( opts: StreamingClientOptions & Pick & { @@ -45,6 +59,9 @@ export class StreamingClient extends BaseClient { super(opts); } + /** + * Publish a simple single-track stream. + */ async publish(streamId: string, token?: string): Promise { await this.ready(); this.log.info("publish %s", streamId); @@ -63,6 +80,9 @@ export class StreamingClient extends BaseClient { }); } + /** + * Play a simple single-track stream. + */ async play(streamId: string, token?: string): Promise { await this.ready(); this.log.info("play %s", streamId); @@ -85,6 +105,9 @@ export class StreamingClient extends BaseClient { }); } + /** + * Play selectively with optional track filtering (roomId-enabled servers only). + */ async playSelective(opts: PlaySelectiveOptions): Promise { await this.ready(); this.log.info("playSelective %s", opts.streamId); @@ -108,10 +131,16 @@ export class StreamingClient extends BaseClient { }); } + /** + * Stop the active stream and close its peer connection. + */ override stop(streamId: string): void { super.stop(streamId); } + /** + * Join helper that resolves on ICE connectivity or first track received. + */ async join(options: JoinOptions): Promise { await this.ready(); const timeout = options.timeoutMs ?? 15000; @@ -165,6 +194,9 @@ export class StreamingClient extends BaseClient { }); } + /** + * Force a specific ABR height (or `auto`) for the viewer. + */ async forceStreamQuality(streamId: string, height: number | "auto"): Promise { await this.ready(); this.sendCommand({ @@ -174,6 +206,9 @@ export class StreamingClient extends BaseClient { }); } + /** + * Change sender max bitrate (kbps). Use `"unlimited"` to remove cap. + */ async changeBandwidth(streamId: string, bandwidth: number | "unlimited"): Promise { const ctx = this.peers.get(streamId); if (!ctx) return; @@ -193,6 +228,9 @@ export class StreamingClient extends BaseClient { } } + /** + * Set WebRTC `degradationPreference` for the video sender. + */ async setDegradationPreference( streamId: string, preference: "maintain-framerate" | "maintain-resolution" | "balanced" @@ -211,38 +249,62 @@ export class StreamingClient extends BaseClient { } } + /** + * Toggle video server-side. + */ toggleVideo(streamId: string, trackId: string, enabled: boolean): void { this.sendCommand({ command: "toggleVideo", streamId, trackId, enabled }); } + /** + * Toggle audio server-side. + */ toggleAudio(streamId: string, trackId: string, enabled: boolean): void { this.sendCommand({ command: "toggleAudio", streamId, trackId, enabled }); } + /** + * Request stream info for a specific stream. + */ async getStreamInfo(streamId: string): Promise { await this.ready(); this.sendCommand({ command: "getStreamInfo", streamId }); } + /** + * Request broadcast object for a stream. + */ async getBroadcastObject(streamId: string): Promise { await this.ready(); this.sendCommand({ command: "getBroadcastObject", streamId }); } + /** + * Request subscriber count for a stream. + */ async getSubscriberCount(streamId: string): Promise { await this.ready(); this.sendCommand({ command: "getSubscriberCount", streamId }); } + /** + * Request paginated subscriber list for a stream. + */ async getSubscriberList(streamId: string, offset = 0, size = 50): Promise { await this.ready(); this.sendCommand({ command: "getSubscribers", streamId, offset, size }); } + /** + * Send a peer message in peer-to-peer mode. + */ peerMessage(streamId: string, definition: string, data: unknown): void { this.sendCommand({ command: "peerMessageCommand", streamId, definition, data }); } + /** + * Register a push notification token to Ant Media Server. + */ registerPushNotificationToken( subscriberId: string, authToken: string, @@ -258,6 +320,9 @@ export class StreamingClient extends BaseClient { }); } + /** + * Send push notification to users. + */ sendPushNotification( subscriberId: string, authToken: string, @@ -279,6 +344,9 @@ export class StreamingClient extends BaseClient { }); } + /** + * Send push notification to a topic. + */ sendPushNotificationToTopic( subscriberId: string, authToken: string, @@ -297,10 +365,14 @@ export class StreamingClient extends BaseClient { }); } + /** + * Update metadata for a stream. + */ updateStreamMetaData(streamId: string, metaData: unknown): void { this.sendCommand({ command: "updateStreamMetaData", streamId, metaData }); } + /** @internal */ protected override restartStream(streamId: string, info: ActiveStreamInfo): void { if (info.mode === "publish") { this.log.info("Re-publish attempt for %s", streamId); From 3584b1d05a5fa668b3ed2a1376bf1e4b80cba91c Mon Sep 17 00:00:00 2001 From: golgetahir Date: Thu, 6 Nov 2025 11:40:27 +0300 Subject: [PATCH 20/31] Revert changes on the src folder --- src/main/js/webrtc_adaptor.js | 159 ---- src/main/webapp/conference.html | 55 +- src/main/webapp/index-new.html | 164 ---- .../webapp/samples/publish_webrtc_new.html | 796 ------------------ 4 files changed, 1 insertion(+), 1173 deletions(-) delete mode 100644 src/main/webapp/index-new.html delete mode 100644 src/main/webapp/samples/publish_webrtc_new.html diff --git a/src/main/js/webrtc_adaptor.js b/src/main/js/webrtc_adaptor.js index afa1cc07..99465196 100644 --- a/src/main/js/webrtc_adaptor.js +++ b/src/main/js/webrtc_adaptor.js @@ -398,11 +398,6 @@ export class WebRTCAdaptor { }, }); - // New additive fields for promise-based readiness and event helpers - this._readyPromise = null; - this._readyResolve = null; - this._eventHandlers = new Map(); - //Initialize the local stream (if needed) and web socket connection if (this.initializeComponents) { this.initialize(); @@ -434,28 +429,6 @@ export class WebRTCAdaptor { this.errorEventListeners.push(errorListener); } - // Additive ergonomic event helpers (non-breaking) - on(eventName, handler) { - if (!this._eventHandlers.has(eventName)) { - this._eventHandlers.set(eventName, new Set()); - } - this._eventHandlers.get(eventName).add(handler); - } - - off(eventName, handler) { - if (this._eventHandlers.has(eventName)) { - this._eventHandlers.get(eventName).delete(handler); - } - } - - once(eventName, handler) { - const wrapper = (payload) => { - this.off(eventName, wrapper); - try { handler(payload); } catch(e) { Logger.warn(e); } - }; - this.on(eventName, wrapper); - } - /** * Notify event listeners and callback method * @param {*} info @@ -468,13 +441,6 @@ export class WebRTCAdaptor { if (this.callback != null) { this.callback(info, obj); } - // Dispatch to additive event handlers specific to the event name - const handlers = this._eventHandlers && typeof this._eventHandlers.get === "function" ? this._eventHandlers.get(info) : null; - if (handlers) { - handlers.forEach((fn) => { - try { fn(obj); } catch(e) { Logger.warn(e); } - }); - } } /** @@ -489,19 +455,6 @@ export class WebRTCAdaptor { if (this.callbackError != null) { this.callbackError(error, message); } - // Also emit a generic 'error' event and a specific error-code event - const genericHandlers = this._eventHandlers && typeof this._eventHandlers.get === "function" ? this._eventHandlers.get("error") : null; - if (genericHandlers) { - genericHandlers.forEach((fn) => { - try { fn({ error: error, message: message }); } catch(e) { Logger.warn(e); } - }); - } - const specificHandlers = this._eventHandlers && typeof this._eventHandlers.get === "function" ? this._eventHandlers.get(error) : null; - if (specificHandlers) { - specificHandlers.forEach((fn) => { - try { fn(message); } catch(e) { Logger.warn(e); } - }); - } } @@ -532,20 +485,6 @@ export class WebRTCAdaptor { }); } - // Additive readiness promise (non-breaking) - ready() { - if (this.webSocketAdaptor && this.webSocketAdaptor.isConnected()) { - return Promise.resolve(); - } - if (this._readyPromise) { - return this._readyPromise; - } - this._readyPromise = new Promise((resolve) => { - this._readyResolve = resolve; - }); - return this._readyPromise; - } - /** * Called to get the ICE server configuration from the server * if user hasn't provided any ICE servers in initialization @@ -1957,16 +1896,6 @@ export class WebRTCAdaptor { this.reconnectIfRequired(0, true); } - // Resolve readiness on initialized - if (info == "initialized") { - if (this._readyResolve) { - const resolveFn = this._readyResolve; - this._readyResolve = null; - this._readyPromise = null; - try { resolveFn(); } catch(e) { Logger.warn(e); } - } - } - this.notifyEventListeners(info, obj); } @@ -2529,94 +2458,6 @@ export class WebRTCAdaptor { closeStream() { return this.mediaManager.closeStream(); }; - - // High-level join helper (non-breaking, optional to use) - join(options) { - const params = options || {}; - const desiredRole = (params.role || "viewer").toLowerCase(); - const streamId = params.streamId; - const timeoutMs = typeof params.timeoutMs !== 'undefined' && params.timeoutMs != null ? params.timeoutMs : 15000; - if (!streamId) { - return Promise.reject(new Error("join_requires_streamId")); - } - - return this.ready().then(() => { - return new Promise((resolve, reject) => { - let timeoutId = -1; - - const complete = (state) => { - cleanup(); - resolve({ streamId: streamId, state: state }); - }; - const fail = (err, message) => { - cleanup(); - reject(typeof err === 'string' ? new Error(err) : err || new Error("join_failed")); - }; - - const onIce = (obj) => { - if (obj && obj.streamId === streamId && (obj.state === "connected" || obj.state === "completed")) { - complete(obj.state); - } - }; - const onTrack = (obj) => { - if (obj && obj.streamId === streamId) { - complete("track_added"); - } - }; - const onErr = (payload) => { - if (!payload) { - return; - } - const errStreamId = payload.streamId || (payload.message && payload.message.streamId) || null; - if (errStreamId == null || errStreamId === streamId) { - fail(payload.error || "error", payload.message); - } - }; - - this.on("ice_connection_state_changed", onIce); - this.on("newTrackAvailable", onTrack); - this.on("newStreamAvailable", onTrack); - this.on("error", onErr); - - const cleanup = () => { - clearTimeout(timeoutId); - this.off("ice_connection_state_changed", onIce); - this.off("newTrackAvailable", onTrack); - this.off("newStreamAvailable", onTrack); - this.off("error", onErr); - }; - - timeoutId = setTimeout(() => { - fail("join_timeout"); - }, timeoutMs); - - if (desiredRole === "publisher" || desiredRole === "publish") { - this.publish(streamId, - params.token, - params.subscriberId, - params.subscriberCode, - params.streamName, - params.mainTrack, - params.metaData, - params.roleHint); - } else { - const playParams = { - streamId: streamId, - token: params.token, - roomId: params.roomId, - enableTracks: params.enableTracks, - subscriberId: params.subscriberId, - subscriberName: params.subscriberName, - subscriberCode: params.subscriberCode, - metaData: params.metaData, - role: params.roleHint, - disableTracksByDefault: params.disableTracksByDefault - }; - this.playStream(playParams); - } - }); - }); - } } diff --git a/src/main/webapp/conference.html b/src/main/webapp/conference.html index ad1dfbfe..e4fb9967 100644 --- a/src/main/webapp/conference.html +++ b/src/main/webapp/conference.html @@ -474,7 +474,6 @@

WebRTC Multitrack Conference

} function handleNotificationEvent(data) { - console.log("=== CONFERENCE.HTML ALL NOTIFICATION EVENTS ===", data); var notificationEvent = JSON.parse(data.data); if (notificationEvent != null && typeof (notificationEvent) == "object") { var eventStreamId = notificationEvent.streamId; @@ -503,21 +502,14 @@

WebRTC Multitrack Conference

} else if (eventTyp == "UPDATE_SOUND_LEVEL") { //console.log("Received data : ", data.data); } else if (eventTyp == "VIDEO_TRACK_ASSIGNMENT_LIST") { - console.log("=== CONFERENCE.HTML TRACK ASSIGNMENT ==="); console.log("VIDEO_TRACK_ASSIGNMENT_LIST: " + data.data); const jsonObject = JSON.parse(data.data); - console.log("=== PARSED ASSIGNMENT PAYLOAD ===", jsonObject.payload); jsonObject.payload.forEach(item => { - console.log("=== ASSIGNMENT ITEM ===", item); - console.log("videoLabel:", item.videoLabel, "trackId:", item.trackId); var overlay = document.getElementById("overlay" + item.videoLabel); if (overlay != null) { - console.log("UPDATING OVERLAY:", item.videoLabel + "->" + item.trackId); overlay.innerHTML = item.videoLabel + "->" + item.trackId; - } else { - console.log("OVERLAY NOT FOUND for:", item.videoLabel); } }); } @@ -573,22 +565,13 @@

WebRTC Multitrack Conference

//In multitrack conferencing the stream is same, tracks are being and remove from the stream var roomId = roomNameBox.value; - console.log("=== CONFERENCE.HTML NEW TRACK AVAILABLE ==="); - console.log("trackId:", obj.trackId); - console.log("track.id:", obj.track.id); - console.log("track.kind:", obj.track.kind); - console.log("stream:", obj.stream); - console.log("--------------------------------------------------new track available with id: " + console.log("new track available with id: " + obj.trackId + " and kind: " + obj.track.kind + " on the room:" + roomId); //trackId is ARDAMSv+STREAM_ID or ARDAMSa+STREAM_ID var incomingTrackId = obj.trackId.substring("ARDAMSx".length); - console.log("**1*0312*3012*30 incomingTrackId", incomingTrackId); - console.log("roomId:", roomId, "publishStreamId:", publishStreamId); - if (incomingTrackId == roomId || incomingTrackId == publishStreamId) { - console.log("SKIPPING TRACK - matches room or publish stream"); return; } @@ -601,37 +584,6 @@

WebRTC Multitrack Conference

} video.srcObject.addTrack(obj.track) - - console.log("CONFERENCE.HTML: ADDED TRACK TO VIDEO", incomingTrackId); - console.log("CONFERENCE.HTML: VIDEO ELEMENT STATE AFTER ADDING TRACK:", { - readyState: video.readyState, - networkState: video.networkState, - paused: video.paused, - autoplay: video.autoplay, - tracks: video.srcObject ? video.srcObject.getTracks().length : 0 - }); - - // Try explicit play like conference-v2 - try { - console.log("CONFERENCE.HTML: TRYING TO PLAY VIDEO", incomingTrackId); - video.play().then(() => { - console.log("CONFERENCE.HTML: VIDEO PLAY SUCCESS", incomingTrackId); - }).catch(e => { - console.log("CONFERENCE.HTML: VIDEO PLAY FAILED", incomingTrackId, e); - }); - } catch (e) { - console.log("CONFERENCE.HTML: VIDEO PLAY ERROR", incomingTrackId, e); - } - - console.log("CONFERENCE.HTML: CHECKING IF WE REQUEST TRACK ASSIGNMENTS..."); - - // Let's see if the original requests track assignments anywhere - if (typeof webRTCAdaptor.requestVideoTrackAssignments === 'function') { - console.log("CONFERENCE.HTML: REQUESTING VIDEO TRACK ASSIGNMENTS"); - webRTCAdaptor.requestVideoTrackAssignments(roomId); - } else { - console.log("CONFERENCE.HTML: requestVideoTrackAssignments not available"); - } obj.track.onended = event => { console.log("track is ended with id: " + event.target.id) @@ -657,7 +609,6 @@

WebRTC Multitrack Conference

}; function createRemoteVideo(trackLabel, kind) { - console.log("=== CONFERENCE.HTML CREATING VIDEO ===", trackLabel, kind); var player = document.createElement("div"); player.className = "col-sm-3"; player.id = "player" + trackLabel; @@ -670,7 +621,6 @@

WebRTC Multitrack Conference

+'
' + trackLabel + '
'; document.getElementById("players").appendChild(player); - console.log("=== CONFERENCE.HTML VIDEO CREATED ===", trackLabel); } function removeRemoteVideo(trackLabel) { @@ -861,17 +811,14 @@

WebRTC Multitrack Conference

isPlaying = false; } else if (info == "data_channel_opened") { - console.log("=== CONFERENCE.HTML DATA CHANNEL OPENED ===", obj); console.log("Data Channel open for stream id", obj); isDataChannelOpen = true; } else if (info == "data_channel_closed") { - console.log("=== CONFERENCE.HTML DATA CHANNEL CLOSED ===", obj); console.log("Data Channel closed for stream id", obj); isDataChannelOpen = false; } else if (info == "data_received") { - console.log("=== CONFERENCE.HTML DATA RECEIVED ===", obj); handleNotificationEvent(obj); } else if (info == "session_restored") { diff --git a/src/main/webapp/index-new.html b/src/main/webapp/index-new.html deleted file mode 100644 index 17300b7f..00000000 --- a/src/main/webapp/index-new.html +++ /dev/null @@ -1,164 +0,0 @@ - - - - WebRTC Samples > New Publish/Play (join/ready) - - - - - - -
-
-
-

WebRTC Samples > New SDK Demo

-
-
- -
-
-
- - -
-
- - -
-
- -
-
- -
-
- -
-
- - -
-
- -
-
- Status: Idle -
-
-
-
- - - - \ No newline at end of file diff --git a/src/main/webapp/samples/publish_webrtc_new.html b/src/main/webapp/samples/publish_webrtc_new.html deleted file mode 100644 index 3b610f76..00000000 --- a/src/main/webapp/samples/publish_webrtc_new.html +++ /dev/null @@ -1,796 +0,0 @@ - - - - - WebRTC Samples > Publish (New API) - - - - - - - - - -
-
-
- -
- -
- - -
-
- -
-
- - -
- - - -
- - Video Source - Your - browser doesn't support screen share. You can see supported browsers in this link - - Audio Source - - - Microphone Gain -
- Audio Quality -
-
- - -
-
-
-
- - -
-
- -
-
- -
-
-
- -
-
- -
-
- - -
-
-
- -
-
-   -
-
- Status: Offline -
-
- - -
- - -
-
-
-
- -
Average Bitrate(Kbps):
-
Latest Bitrate(Kbps): -
-
PacketsLost:
-
Jitter(Secs):
-
Audio Level:
-
-
-
- -
Round Trip Time(Secs):
-
Source WidthxHeight: x - -
-
On-going WidthxHeight: x
-
On-going FPS:
- -
-
-
-
-
- - - -
- - - - - - - - \ No newline at end of file From 39ebb063627fb6e8cd3dad07a81d7c56f379ce5b Mon Sep 17 00:00:00 2001 From: golgetahir Date: Thu, 6 Nov 2025 14:24:46 +0300 Subject: [PATCH 21/31] Add v2 into WAR file and refactor for readability --- package.json | 8 + .../{data-only.html => data-only-v2.html} | 0 packages/webrtc-sdk/examples/play-v2.html | 760 ++++++++++++++++++ packages/webrtc-sdk/examples/play.html | 162 ---- .../{publish.html => publish-v2.html} | 0 packages/webrtc-sdk/src/client/base-client.ts | 272 ++++--- redeploy.sh | 10 + src/main/webapp/.gitignore | 1 + src/main/webapp/js/.gitignore | 4 + 9 files changed, 958 insertions(+), 259 deletions(-) rename packages/webrtc-sdk/examples/{data-only.html => data-only-v2.html} (100%) create mode 100644 packages/webrtc-sdk/examples/play-v2.html delete mode 100644 packages/webrtc-sdk/examples/play.html rename packages/webrtc-sdk/examples/{publish.html => publish-v2.html} (100%) create mode 100644 src/main/webapp/.gitignore diff --git a/package.json b/package.json index c61a92e5..3baeff59 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,14 @@ { "from": "./dist/es/*", "to": "./src/main/webapp/js/" + }, + { + "from": "./packages/webrtc-sdk/dist/**/*", + "to": "./src/main/webapp/js/" + }, + { + "from": "./packages/webrtc-sdk/examples/*", + "to": "./src/main/webapp/" } ], "copyFilesSettings": { diff --git a/packages/webrtc-sdk/examples/data-only.html b/packages/webrtc-sdk/examples/data-only-v2.html similarity index 100% rename from packages/webrtc-sdk/examples/data-only.html rename to packages/webrtc-sdk/examples/data-only-v2.html diff --git a/packages/webrtc-sdk/examples/play-v2.html b/packages/webrtc-sdk/examples/play-v2.html new file mode 100644 index 00000000..26c587db --- /dev/null +++ b/packages/webrtc-sdk/examples/play-v2.html @@ -0,0 +1,760 @@ + + + +WebRTC SDK Samples > Play + + + + + + + + + + +
+
+
+

+ WebRTC SDK Samples > Play +

+
+
+ + +
+ +
+
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ + + +
+
+
+ +
+
+ +
+
+ + +
+
+ +
+ + +
+ + +
+ + + + +
+ +
+ + + + + + +
+
+ Audio Output + + +
+
+
+
+ Remote Audio Level + + + level: 0.00 +
+
+ + + + + diff --git a/packages/webrtc-sdk/examples/play.html b/packages/webrtc-sdk/examples/play.html deleted file mode 100644 index 99529c8d..00000000 --- a/packages/webrtc-sdk/examples/play.html +++ /dev/null @@ -1,162 +0,0 @@ - - - - - - webrtc-sdk play sample - - - -

Play Sample (TS v2)

-
- - -
-
- - - - -
-
-
- Audio Output - - -
-
-
-
- Data Channel - - - - -
-
-
-
- Stats - -
-
-
-
-
- Remote Audio Level - - - level: 0.00 -
-
-

-
-    
-  
-
diff --git a/packages/webrtc-sdk/examples/publish.html b/packages/webrtc-sdk/examples/publish-v2.html
similarity index 100%
rename from packages/webrtc-sdk/examples/publish.html
rename to packages/webrtc-sdk/examples/publish-v2.html
diff --git a/packages/webrtc-sdk/src/client/base-client.ts b/packages/webrtc-sdk/src/client/base-client.ts
index c08b39eb..e1591bf3 100644
--- a/packages/webrtc-sdk/src/client/base-client.ts
+++ b/packages/webrtc-sdk/src/client/base-client.ts
@@ -1,11 +1,9 @@
 import { Logger } from "../utils/logger.js";
-
 import { Emitter } from "../core/emitter.js";
 import type { EventMap } from "../core/events.js";
 import { MediaManager } from "../core/media-manager.js";
 import type { BaseClientOptions, GroupedDevices } from "../core/types.js";
 import { WebSocketAdaptor } from "../core/websocket-adaptor.js";
-
 export interface PeerContext {
   pc: RTCPeerConnection;
   dc?: RTCDataChannel;
@@ -124,7 +122,8 @@ export abstract class BaseClient extends Emitter {
       };
     }
 
-    this.media = opts.mediaManager ??
+    this.media =
+      opts.mediaManager ??
       new MediaManager({ mediaConstraints: opts.mediaConstraints, localVideo: opts.localVideo });
 
     this.media.on("devices_updated", g => this.emit("devices_updated", g));
@@ -143,7 +142,8 @@ export abstract class BaseClient extends Emitter {
         websocketURL: opts.websocketURL,
         httpEndpointUrl: opts.httpEndpointUrl,
         webrtcadaptor: {
-          notifyEventListeners: (info: string, obj?: unknown) => this.handleTransportEvent(info, obj),
+          notifyEventListeners: (info: string, obj?: unknown) =>
+            this.handleTransportEvent(info, obj),
         },
         debug: opts.debug,
       });
@@ -247,7 +247,7 @@ export abstract class BaseClient extends Emitter {
     this.media.turnOffLocalCamera();
     for (const ctx of this.peers.values()) {
       const primary = ctx.videoSender;
-      let sender = primary ?? ctx.pc.getSenders().find(s => s.track?.kind === "video");
+      const sender = primary ?? ctx.pc.getSenders().find(s => s.track?.kind === "video");
       if (!sender) continue;
       try {
         const stream = this.media.getLocalStream();
@@ -276,7 +276,10 @@ export abstract class BaseClient extends Emitter {
     this.media.setVolumeLevel(level);
   }
 
-  async enableAudioLevelForLocalStream(callback: (level: number) => void, periodMs = 200): Promise {
+  async enableAudioLevelForLocalStream(
+    callback: (level: number) => void,
+    periodMs = 200
+  ): Promise {
     await this.media.enableAudioLevelForLocalStream(callback, periodMs);
   }
 
@@ -284,7 +287,10 @@ export abstract class BaseClient extends Emitter {
     this.media.disableAudioLevelForLocalStream();
   }
 
-  async enableAudioLevelWhenMuted(callback: (speaking: boolean) => void, threshold = 0.1): Promise {
+  async enableAudioLevelWhenMuted(
+    callback: (speaking: boolean) => void,
+    threshold = 0.1
+  ): Promise {
     await this.media.enableAudioLevelWhenMuted(callback, threshold);
   }
 
@@ -303,36 +309,42 @@ export abstract class BaseClient extends Emitter {
       let now = 0;
       stats.forEach(r => {
         if (r.type === "outbound-rtp") {
-          bytesSent += (r as any).bytesSent || 0;
-          if ((r as any).packetsSent) {
-            if ((r as any).kind === "audio") ps.audioPacketsSent = (r as any).packetsSent;
-            if ((r as any).kind === "video") {
-              ps.videoPacketsSent = (r as any).packetsSent;
-              ps.frameWidth = (r as any).frameWidth ?? ps.frameWidth;
-              ps.frameHeight = (r as any).frameHeight ?? ps.frameHeight;
-              if ((r as any).framesEncoded != null) ps.framesEncoded = (r as any).framesEncoded;
+          bytesSent += (r as RTCOutboundRtpStreamStats).bytesSent || 0;
+          if ((r as RTCOutboundRtpStreamStats).packetsSent) {
+            if ((r as RTCOutboundRtpStreamStats).kind === "audio")
+              ps.audioPacketsSent = (r as RTCOutboundRtpStreamStats).packetsSent;
+            if ((r as RTCOutboundRtpStreamStats).kind === "video") {
+              ps.videoPacketsSent = (r as RTCOutboundRtpStreamStats).packetsSent;
+              ps.frameWidth = (r as RTCOutboundRtpStreamStats).frameWidth ?? ps.frameWidth;
+              ps.frameHeight = (r as RTCOutboundRtpStreamStats).frameHeight ?? ps.frameHeight;
+              if ((r as RTCOutboundRtpStreamStats).framesEncoded != null)
+                ps.framesEncoded = (r as RTCOutboundRtpStreamStats).framesEncoded;
             }
           }
-          now = (r as any).timestamp || now;
+          now = (r as RTCOutboundRtpStreamStats).timestamp || now;
         } else if (r.type === "inbound-rtp") {
-          bytesRecv += (r as any).bytesReceived || 0;
-          if ((r as any).packetsReceived) {
-            if ((r as any).kind === "audio") ps.audioPacketsReceived = (r as any).packetsReceived;
-            if ((r as any).kind === "video") ps.videoPacketsReceived = (r as any).packetsReceived;
+          bytesRecv += (r as RTCInboundRtpStreamStats).bytesReceived || 0;
+          if ((r as RTCInboundRtpStreamStats).packetsReceived) {
+            if ((r as RTCInboundRtpStreamStats).kind === "audio")
+              ps.audioPacketsReceived = (r as RTCInboundRtpStreamStats).packetsReceived;
+            if ((r as RTCInboundRtpStreamStats).kind === "video")
+              ps.videoPacketsReceived = (r as RTCInboundRtpStreamStats).packetsReceived;
           }
-          now = (r as any).timestamp || now;
+          now = (r as RTCInboundRtpStreamStats).timestamp || now;
         } else if (r.type === "remote-inbound-rtp") {
-          if ((r as any).kind === "audio") {
-            if ((r as any).packetsLost != null) ps.audioPacketsLost = (r as any).packetsLost;
+          if ((r as RTCInboundRtpStreamStats).kind === "audio") {
+            if ((r as RTCInboundRtpStreamStats).packetsLost != null)
+              ps.audioPacketsLost = (r as RTCInboundRtpStreamStats).packetsLost;
             if ((r as any).roundTripTime != null) ps.audioRoundTripTime = (r as any).roundTripTime;
-            if ((r as any).jitter != null) ps.audioJitter = (r as any).jitter;
-          } else if ((r as any).kind === "video") {
+            if ((r as RTCInboundRtpStreamStats).jitter != null)
+              ps.audioJitter = (r as RTCInboundRtpStreamStats).jitter;
+          } else if ((r as RTCRtpStreamStats).kind === "video") {
             if ((r as any).packetsLost != null) ps.videoPacketsLost = (r as any).packetsLost;
             if ((r as any).roundTripTime != null) ps.videoRoundTripTime = (r as any).roundTripTime;
             if ((r as any).jitter != null) ps.videoJitter = (r as any).jitter;
           }
         } else if (r.type === "track") {
-          if ((r as any).kind === "video") {
+          if ((r as RTCRtpStreamStats).kind === "video") {
             if ((r as any).frameWidth != null) ps.frameWidth = (r as any).frameWidth;
             if ((r as any).frameHeight != null) ps.frameHeight = (r as any).frameHeight;
             if ((r as any).framesDecoded != null) ps.framesDecoded = (r as any).framesDecoded;
@@ -340,11 +352,13 @@ export abstract class BaseClient extends Emitter {
             if ((r as any).framesReceived != null) ps.framesReceived = (r as any).framesReceived;
           }
         } else if (r.type === "candidate-pair" && (r as any).state === "succeeded") {
-          if ((r as any).availableOutgoingBitrate != null) {
-            ps.availableOutgoingBitrateKbps = ((r as any).availableOutgoingBitrate as number) / 1000;
+          if ((r as RTCIceCandidatePairStats).availableOutgoingBitrate != null) {
+            ps.availableOutgoingBitrateKbps =
+              ((r as RTCIceCandidatePairStats).availableOutgoingBitrate as number) / 1000;
           }
-          if ((r as any).currentRoundTripTime != null) {
-            ps.currentRoundTripTime = (r as any).currentRoundTripTime as number;
+          if ((r as RTCIceCandidatePairStats).currentRoundTripTime != null) {
+            ps.currentRoundTripTime = (r as RTCIceCandidatePairStats)
+              .currentRoundTripTime as number;
           }
         }
       });
@@ -360,15 +374,17 @@ export abstract class BaseClient extends Emitter {
 
   enableStats(streamId: string, periodMs = 5000): void {
     const key = `__stats_${streamId}`;
-    if ((this as any)[key]) return;
-    (this as any)[key] = setInterval(() => {
+    if ((this as unknown as Record)[key]) return;
+    (this as unknown as Record)[key] = setInterval(() => {
       void this.getStats(streamId);
     }, periodMs);
   }
 
   disableStats(streamId: string): void {
     const key = `__stats_${streamId}`;
-    const timer = (this as unknown as Record)[key] as ReturnType | undefined;
+    const timer = (this as unknown as Record)[key] as
+      | ReturnType
+      | undefined;
     if (timer) {
       clearInterval(timer);
       delete (this as unknown as Record)[key];
@@ -408,10 +424,10 @@ export abstract class BaseClient extends Emitter {
       if (dc.bufferedAmount > dc.bufferedAmountLowThreshold) {
         await new Promise(resolve => {
           const onlow = () => {
-            (dc as any).removeEventListener("bufferedamountlow", onlow);
+            (dc as RTCDataChannel).removeEventListener("bufferedamountlow", onlow);
             resolve();
           };
-          (dc as any).addEventListener("bufferedamountlow", onlow, { once: true });
+          (dc as RTCDataChannel).addEventListener("bufferedamountlow", onlow, { once: true });
         });
       }
       dc.send(buffer);
@@ -440,8 +456,15 @@ export abstract class BaseClient extends Emitter {
     this.sanitizeDcStrings = !!enabled;
   }
 
-  async enableRemoteAudioLevel(streamId: string, callback: (level: number) => void, periodMs = 200): Promise {
-    const stream = this.remoteStreams.get(streamId) ?? (this.remoteVideo?.srcObject as MediaStream | null) ?? null;
+  async enableRemoteAudioLevel(
+    streamId: string,
+    callback: (level: number) => void,
+    periodMs = 200
+  ): Promise {
+    const stream =
+      this.remoteStreams.get(streamId) ??
+      (this.remoteVideo?.srcObject as MediaStream | null) ??
+      null;
     if (!stream) return;
     if (!this.audioContext) this.audioContext = new AudioContext();
     const ctx = this.audioContext;
@@ -485,6 +508,16 @@ export abstract class BaseClient extends Emitter {
     this.remoteMeters.delete(streamId);
   }
 
+  /**
+   * Called to get the signalling state for a stream.
+   * This information can be used for error handling.
+   * Check: https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/connectionState
+   * @param {string} streamId : unique id for the stream
+   */
+  signallingState(streamId: string): RTCSignalingState | null {
+    return this.peers.get(streamId)?.pc.signalingState ?? null;
+  }
+
   protected abstract restartStream(streamId: string, info: ActiveStreamInfo): void;
 
   protected trackActiveStream(streamId: string, info: ActiveStreamInfo): void {
@@ -631,7 +664,7 @@ export abstract class BaseClient extends Emitter {
           if (!st) {
             this.emit("data_received", {
               streamId,
-              data: u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength),
+              data: u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer,
             });
             return;
           }
@@ -651,7 +684,7 @@ export abstract class BaseClient extends Emitter {
         }
         this.emit("data_received", {
           streamId,
-          data: u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength),
+          data: u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer,
         });
       };
 
@@ -661,7 +694,8 @@ export abstract class BaseClient extends Emitter {
         return;
       }
       if (typeof Blob !== "undefined" && raw instanceof Blob) {
-        raw.arrayBuffer()
+        raw
+          .arrayBuffer()
           .then(ab => processBuffer(new Uint8Array(ab)))
           .catch(() => {
             this.emit("error", { error: "data_channel_blob_parse_failed", message: raw });
@@ -702,7 +736,9 @@ export abstract class BaseClient extends Emitter {
     }
 
     try {
-      const dc = pc.createDataChannel ? pc.createDataChannel(streamId, { ordered: true }) : undefined;
+      const dc = pc.createDataChannel
+        ? pc.createDataChannel(streamId, { ordered: true })
+        : undefined;
       if (dc) this.setupDataChannel(streamId, dc);
     } catch (e) {
       this.log.warn("createDataChannel not supported", e);
@@ -727,7 +763,9 @@ export abstract class BaseClient extends Emitter {
       const senders = ctx.pc.getSenders();
       const videoTracks = stream.getVideoTracks();
       for (const track of videoTracks) {
-        let sender = (track.kind === "video" ? ctx.videoSender : ctx.audioSender) || senders.find(s => s.track && s.track.kind === track.kind);
+        let sender =
+          (track.kind === "video" ? ctx.videoSender : ctx.audioSender) ||
+          senders.find(s => s.track && s.track.kind === track.kind);
         if (sender && sender.replaceTrack) {
           try {
             await sender.replaceTrack(track);
@@ -768,10 +806,87 @@ export abstract class BaseClient extends Emitter {
     }
   }
 
+  protected takeConfiguration(obj: Record): void {
+    const payload = obj as {
+      streamId: string;
+      sdp: string;
+      type: RTCSdpType;
+      idMapping?: Record;
+      streamTrackIds?: Record;
+    };
+    const { streamId, sdp, type } = payload;
+
+    this.log.debug("takeConfiguration %s %s", streamId, type);
+
+    const mapping = payload.idMapping || payload.streamTrackIds;
+
+    if (mapping) {
+      this.idMapping[streamId] = mapping;
+    }
+
+    if (type === "answer") {
+      const ctx = this.peers.get(streamId);
+      if (ctx) {
+        ctx.pc.setRemoteDescription(new RTCSessionDescription({ type, sdp })).then(() => {
+          this.remoteDescriptionSet.set(streamId, true);
+          const queued = this.candidateQueue.get(streamId) ?? [];
+          queued.forEach(c => ctx.pc.addIceCandidate(new RTCIceCandidate(c)));
+          this.candidateQueue.set(streamId, []);
+          this.onRemoteAnswerApplied(streamId);
+        });
+      }
+    } else if (type === "offer") {
+      const pc = this.createPeer(streamId, "play");
+      // Set up data channel for play mode like the original WebRTCAdaptor
+      pc.ondatachannel = ev => {
+        this.setupDataChannel(streamId, ev.channel);
+      };
+      pc.setRemoteDescription(new RTCSessionDescription({ type, sdp }))
+        .then(async () => {
+          const answer = await pc.createAnswer();
+          await pc.setLocalDescription(answer);
+          this.sendTakeConfiguration(streamId, answer.type, answer.sdp ?? "");
+          this.remoteDescriptionSet.set(streamId, true);
+
+          const queued = this.candidateQueue.get(streamId) ?? [];
+          queued.forEach(c => pc.addIceCandidate(new RTCIceCandidate(c)));
+          this.candidateQueue.set(streamId, []);
+          this.onRemoteOfferAnswered(streamId);
+        })
+        .catch(e => this.log.warn("setRemoteDescription failed", e));
+    }
+  }
+
+  protected takeCandidate(obj: Record): void {
+    const { streamId, label, id, candidate } = obj as {
+      streamId: string;
+      label: number | null;
+      id?: string;
+      candidate: string;
+    };
+    this.log.debug("takeCandidate %s", streamId);
+
+    const ice: RTCIceCandidateInit = { sdpMLineIndex: label ?? undefined, sdpMid: id, candidate };
+    const ctx = this.peers.get(streamId);
+
+    if (ctx) {
+      if (this.remoteDescriptionSet.get(streamId)) {
+        ctx.pc
+          .addIceCandidate(new RTCIceCandidate(ice))
+          .catch(e => this.log.warn("addIceCandidate failed", e));
+      } else {
+        const q = this.candidateQueue.get(streamId) ?? [];
+        q.push(ice);
+        this.candidateQueue.set(streamId, q);
+      }
+    }
+  }
+
   private handleTransportEvent(info: string, obj?: unknown): void {
     if (info === "initialized") {
       this.isReady = true;
       this.log.info("adaptor initialized");
+
       for (const init of BaseClient.pluginInitMethods) {
         try {
           init(this);
@@ -779,6 +894,7 @@ export abstract class BaseClient extends Emitter {
           this.log.warn("plugin init failed", e);
         }
       }
+
       this.onInitialized();
       return;
     } else if (info === "start") {
@@ -786,59 +902,15 @@ export abstract class BaseClient extends Emitter {
       this.log.debug("start received for %s", streamId);
       this.onStartCommand(streamId);
     } else if (info === "takeConfiguration") {
-      const payload = obj as { streamId: string; sdp: string; type: RTCSdpType; idMapping?: Record; streamTrackIds?: Record };
-      const { streamId, sdp, type } = payload;
-      this.log.debug("takeConfiguration %s %s", streamId, type);
-      const mapping = payload.idMapping || payload.streamTrackIds;
-      if (mapping) {
-        this.idMapping[streamId] = mapping;
-      }
-      if (type === "answer") {
-        const ctx = this.peers.get(streamId);
-        if (ctx) {
-          ctx.pc.setRemoteDescription(new RTCSessionDescription({ type, sdp })).then(() => {
-            this.remoteDescriptionSet.set(streamId, true);
-            const queued = this.candidateQueue.get(streamId) ?? [];
-            queued.forEach(c => ctx.pc.addIceCandidate(new RTCIceCandidate(c)));
-            this.candidateQueue.set(streamId, []);
-            this.onRemoteAnswerApplied(streamId);
-          });
-        }
-      } else if (type === "offer") {
-        const pc = this.createPeer(streamId, "play");
-        // Set up data channel for play mode like the original WebRTCAdaptor
-        pc.ondatachannel = ev => {
-          this.setupDataChannel(streamId, ev.channel);
-        };
-        pc.setRemoteDescription(new RTCSessionDescription({ type, sdp }))
-          .then(async () => {
-            const answer = await pc.createAnswer();
-            await pc.setLocalDescription(answer);
-            this.sendTakeConfiguration(streamId, answer.type, answer.sdp ?? "");
-            this.remoteDescriptionSet.set(streamId, true);
-            const queued = this.candidateQueue.get(streamId) ?? [];
-            queued.forEach(c => pc.addIceCandidate(new RTCIceCandidate(c)));
-            this.candidateQueue.set(streamId, []);
-            this.onRemoteOfferAnswered(streamId);
-          })
-          .catch(e => this.log.warn("setRemoteDescription failed", e));
-      }
+      this.takeConfiguration(obj as Record);
     } else if (info === "takeCandidate") {
-      const { streamId, label, id, candidate } = obj as { streamId: string; label: number | null; id?: string; candidate: string };
-      this.log.debug("takeCandidate %s", streamId);
-      const ice: RTCIceCandidateInit = { sdpMLineIndex: label ?? undefined, sdpMid: id, candidate };
-      const ctx = this.peers.get(streamId);
-      if (ctx) {
-        if (this.remoteDescriptionSet.get(streamId)) {
-          ctx.pc.addIceCandidate(new RTCIceCandidate(ice)).catch(e => this.log.warn("addIceCandidate failed", e));
-        } else {
-          const q = this.candidateQueue.get(streamId) ?? [];
-          q.push(ice);
-          this.candidateQueue.set(streamId, q);
-        }
-      }
+      this.takeCandidate(obj as Record);
     } else if (info === "iceServerConfig") {
-      const cfg = obj as { stunServerUri?: string; turnServerUsername?: string; turnServerCredential?: string };
+      const cfg = obj as {
+        stunServerUri?: string;
+        turnServerUsername?: string;
+        turnServerCredential?: string;
+      };
       if (cfg.stunServerUri) {
         if (cfg.stunServerUri.startsWith("turn:")) {
           this.peerConfig.iceServers = [
@@ -862,6 +934,7 @@ export abstract class BaseClient extends Emitter {
       const payload = obj as Record;
       const def = (payload.definition as string) || "";
       const streamId = (payload.streamId as string) || "";
+
       if (def === "publish_started") this.emit("publish_started", { streamId });
       if (def === "publish_finished") this.emit("publish_finished", { streamId });
       if (def === "play_started") this.emit("play_started", { streamId });
@@ -870,14 +943,17 @@ export abstract class BaseClient extends Emitter {
       if (def === "subscriberList") this.emit("subscriber_list" as keyof EventMap, obj as never);
       if (def === "roomInformation") this.emit("room_information" as keyof EventMap, obj as never);
       if (def === "broadcastObject") this.emit("broadcast_object" as keyof EventMap, obj as never);
-      if (def === "videoTrackAssignmentList") this.emit("video_track_assignments" as keyof EventMap, obj as never);
-      if (def === "streamInformation") this.emit("stream_information" as keyof EventMap, obj as never);
+      if (def === "videoTrackAssignmentList")
+        this.emit("video_track_assignments" as keyof EventMap, obj as never);
+      if (def === "streamInformation")
+        this.emit("stream_information" as keyof EventMap, obj as never);
       if (def === "trackList") this.emit("track_list" as keyof EventMap, obj as never);
       if (def === "subtrackList") this.emit("subtrack_list" as keyof EventMap, obj as never);
       if (def === "subtrackCount") this.emit("subtrack_count" as keyof EventMap, obj as never);
       if (def === "joinedTheRoom") this.emit("room_joined" as keyof EventMap, obj as never);
       if (def === "leavedTheRoom") this.emit("room_left" as keyof EventMap, obj as never);
       if (def) this.emit(`notification:${def}` as keyof EventMap, obj as never);
+
       this.onNotification(payload);
     } else if (info === "closed") {
       this.emit("closed", obj as never);
@@ -917,7 +993,10 @@ export abstract class BaseClient extends Emitter {
 
   private computeNextDelay(lastDelay: number): number {
     const { backoff, baseMs, maxMs, jitter } = this.reconnectConfig;
-    let next = backoff === "exp" ? Math.min(maxMs, Math.max(baseMs, lastDelay * 2)) : Math.min(maxMs, baseMs);
+    let next =
+      backoff === "exp"
+        ? Math.min(maxMs, Math.max(baseMs, lastDelay * 2))
+        : Math.min(maxMs, baseMs);
     if (jitter > 0) {
       const rand = 1 + (Math.random() * 2 - 1) * jitter;
       next = Math.max(0, Math.floor(next * rand));
@@ -942,4 +1021,3 @@ export abstract class BaseClient extends Emitter {
     }, 500);
   }
 }
-
diff --git a/redeploy.sh b/redeploy.sh
index 2d3b8e49..0bcda680 100755
--- a/redeploy.sh
+++ b/redeploy.sh
@@ -2,6 +2,16 @@
 AMS_DIR=~/softwares/ant-media-server
  
 
+# Build WebRTC SDK so its artifacts can be included in the WAR
+cd packages/webrtc-sdk
+rm -rf dist
+npm run build
+OUT=$?
+if [ $OUT -ne 0 ]; then
+    exit $OUT
+fi
+cd ../..
+
 #Latest sdk is to be deployed to src/main/webapp 
 rm -rf dist
 npm run compile
diff --git a/src/main/webapp/.gitignore b/src/main/webapp/.gitignore
new file mode 100644
index 00000000..fa24451e
--- /dev/null
+++ b/src/main/webapp/.gitignore
@@ -0,0 +1 @@
+*-v2.html
\ No newline at end of file
diff --git a/src/main/webapp/js/.gitignore b/src/main/webapp/js/.gitignore
index bc943b40..854b98cd 100644
--- a/src/main/webapp/js/.gitignore
+++ b/src/main/webapp/js/.gitignore
@@ -1,2 +1,6 @@
 /*.js
 /*.ts
+/client/*
+/core/*
+/utils/*
+index.d.ts.map
\ No newline at end of file

From c55d6474d2ce72f24ea5c9ad422e8c801a06fe9f Mon Sep 17 00:00:00 2001
From: golgetahir 
Date: Fri, 19 Dec 2025 16:34:24 +0300
Subject: [PATCH 22/31] Fix publish-v2 cosmetics

---
 packages/webrtc-sdk/examples/publish-v2.html | 726 ++++++++++++++-----
 1 file changed, 542 insertions(+), 184 deletions(-)

diff --git a/packages/webrtc-sdk/examples/publish-v2.html b/packages/webrtc-sdk/examples/publish-v2.html
index cce234a0..aaedbc81 100644
--- a/packages/webrtc-sdk/examples/publish-v2.html
+++ b/packages/webrtc-sdk/examples/publish-v2.html
@@ -1,190 +1,505 @@
 
 
   
-    
+    WebRTC Samples > Publish (TS v2)
     
-    webrtc-sdk publish sample
+    
+    
     
   
+
   
-    

Publish Sample (TS v2)

-
-
- - +
+
+
+

WebRTC Samples > Publish (TS v2)

+
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+ +
+ + +
+ + + +
+ + + +
+ + +
+ + + Video Source + + + Audio Source + + + + + Microphone Gain & Level +
+ + level: 0.00 +
+
+ + +
+ + + Media Controls +
+ + +
+
+ + +
+
+ + +
+ + + Screen Share +
+ + +
+
+ + +
+ + + +
+
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
+ +
+
+ Status: Offline +
+
+ + +
+ + +
+ +
+
+
+ +
+
+
+
+
+
+ +

+
+      
     
-
- - - - -
-
- - - - - -
-
-
- Controls - - - - - - -
-
-
-
- Network / QoS - - - - - - - -
-
-
-
- Data Channel - - - - -
-
-
-
- Screen Share - - - - -
-
-
-
- Audio - - - - - level: 0.00 -
-
-
-
- Stats - -
-
-
-

+
+    
+    
 
     
   
 

From 05bb92a9f746dd5c32063da08fd34ecd9caeeb63 Mon Sep 17 00:00:00 2001
From: golgetahir 
Date: Fri, 19 Dec 2025 17:25:42 +0300
Subject: [PATCH 23/31] Refactor data-only-v2 to look good

---
 .../webrtc-sdk/examples/data-only-v2.html     | 315 +++++++++++++++---
 packages/webrtc-sdk/examples/play-v2.html     |  12 +-
 2 files changed, 281 insertions(+), 46 deletions(-)

diff --git a/packages/webrtc-sdk/examples/data-only-v2.html b/packages/webrtc-sdk/examples/data-only-v2.html
index 3a92081f..31fcf41d 100644
--- a/packages/webrtc-sdk/examples/data-only-v2.html
+++ b/packages/webrtc-sdk/examples/data-only-v2.html
@@ -1,55 +1,290 @@
 
 
   
-    
+    WebRTC Samples > Data Channel Only (TS v2)
     
-    webrtc-sdk data-only sample
-    
+    
+    
+    
   
+
   
-    

Data-Only Publish (TS v2)

-
- - - +
+
+
+

+ WebRTC Samples > Data Channel Only (TS v2) +

+
+
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+
+ +
+
+
+ +
+
+ Status: Offline +
+
+ + +
+ + +
+
+ +

+
+      
     
-
- Data Channel - - - -
-

+
+    
+    
 
     
   
-  
-
+
diff --git a/packages/webrtc-sdk/examples/play-v2.html b/packages/webrtc-sdk/examples/play-v2.html
index 26c587db..3c10cdd2 100644
--- a/packages/webrtc-sdk/examples/play-v2.html
+++ b/packages/webrtc-sdk/examples/play-v2.html
@@ -496,8 +496,8 @@ 

adaptor.on('ice_connection_state_changed', ({ state }) => log('ice: ' + state)); adaptor.on('reconnected', ({ streamId }) => log('reconnected ' + streamId)); adaptor.on('error', (e) => log('error: ' + e.error)); - adaptor.on('data_channel_opened', () => { log('data channel opened'); sendTextBtn.disabled = false; sendFileBtn.disabled = false; }); - adaptor.on('data_channel_closed', () => { log('data channel closed'); sendTextBtn.disabled = true; sendFileBtn.disabled = true; }); + adaptor.on('data_channel_opened', () => { log('data channel opened'); sendTextBtn.disabled = false; sendFileBtn.disabled = false; }); + adaptor.on('data_channel_closed', () => { log('data channel closed'); sendTextBtn.disabled = true; sendFileBtn.disabled = true; }); adaptor.on('data_received', ({ data }) => { if (typeof data === 'string') { @@ -734,10 +734,10 @@

}; stopBtn.onclick = () => { if (currentId) { adaptor.stop(currentId); stopBtn.disabled = true; } }; - optionsBtn.addEventListener("click", toggleOptions, false); - sendTextBtn.onclick = sendData; - sendFileBtn.onclick = () => sendFileInput.click(); - sendFileInput.addEventListener("change", send_image); + optionsBtn.addEventListener("click", toggleOptions, false); + sendTextBtn.onclick = sendData; + sendFileBtn.onclick = () => sendFileInput.click(); + sendFileInput.addEventListener("change", send_image); levelOn.onclick = async () => { if (!currentId) return; From 58bcd64a3650039e195234587dac80799ce670b8 Mon Sep 17 00:00:00 2001 From: burak Date: Fri, 26 Dec 2025 11:04:54 +0300 Subject: [PATCH 24/31] handle all new packages in v2 --- package.json | 28 +++++++++++++++++++++-- packages/webrtc-sdk/examples/play-v2.html | 15 ++---------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 3baeff59..e3898f57 100644 --- a/package.json +++ b/package.json @@ -31,11 +31,35 @@ }, { "from": "./packages/webrtc-sdk/dist/**/*", - "to": "./src/main/webapp/js/" + "to": "./src/main/webapp/v2/js/" }, { "from": "./packages/webrtc-sdk/examples/*", - "to": "./src/main/webapp/" + "to": "./src/main/webapp/v2/" + }, + { + "from": "./src/main/webapp/js/external/**/*", + "to": "./src/main/webapp/v2/js/external/" + }, + { + "from": "./src/main/webapp/js/external/**/*", + "to": "./src/main/webapp/v2/js/external/" + }, + { + "from": "./src/main/webapp/js/fetch.stream.js", + "to": "./src/main/webapp/v2/js/" + }, + { + "from": "./src/main/webapp/js/utility.js", + "to": "./src/main/webapp/v2/js/" + }, + { + "from": "./src/main/webapp/js/external/**/*", + "to": "./src/main/webapp/v2/js/external/" + }, + { + "from": "./src/main/webapp/css/**/*", + "to": "./src/main/webapp/v2/css/" } ], "copyFilesSettings": { diff --git a/packages/webrtc-sdk/examples/play-v2.html b/packages/webrtc-sdk/examples/play-v2.html index 3c10cdd2..2a0dd1fe 100644 --- a/packages/webrtc-sdk/examples/play-v2.html +++ b/packages/webrtc-sdk/examples/play-v2.html @@ -231,20 +231,9 @@

function log(msg) { logEl.textContent += msg + '\n'; } - var appName = location.pathname.substring(0, location.pathname.lastIndexOf("/")+1); - var path = location.hostname + ":" + location.port + appName + "websocket"; - var websocketURL = "ws://" + path; + const websocketURL = getWebSocketURL(window.location); - if (location.protocol.startsWith("https")) { - websocketURL = "wss://" + path; - } - - if (signaling) { - //add signaling suffix to the websocket URL - websocketURL += "/signaling"; - } - - const adaptor = new WebRTCClient({ + const adaptor = new WebRTCClient({ websocketURL, remoteVideo: remote, isPlayMode: true, From d32cbec0c2ecce25590e1458898d20ba10bbebf4 Mon Sep 17 00:00:00 2001 From: burak Date: Fri, 26 Dec 2025 11:37:24 +0300 Subject: [PATCH 25/31] edit rollup rules for a warning --- rollup.config.browser.cjs | 40 +++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/rollup.config.browser.cjs b/rollup.config.browser.cjs index 106b8f6f..96a3f552 100644 --- a/rollup.config.browser.cjs +++ b/rollup.config.browser.cjs @@ -1,16 +1,36 @@ - const babel = require('@rollup/plugin-babel').default; const builds = { - input: [ 'src/main/js/index.js'], - output: [{ - name: 'webrtc_adaptor', - file: 'dist/browser/webrtc_adaptor.js', - format: 'umd' - }, - ], - plugins: [babel({ babelHelpers: 'bundled' })] + input: ['src/main/js/index.js'], + + // ✅ MediaPipe bundle edilmesin + external: [ + '@mediapipe/selfie_segmentation' + ], + + // ✅ loglevel "this is undefined" uyarısını yut + onwarn(warning, warn) { + if ( + warning.code === 'THIS_IS_UNDEFINED' && + warning.id && + warning.id.includes('loglevel.min.js') + ) { + return; + } + warn(warning); + }, + + output: [ + { + name: 'webrtc_adaptor', + file: 'dist/browser/webrtc_adaptor.js', + format: 'umd' + } + ], + plugins: [ + babel({ babelHelpers: 'bundled' }) + ] }; -module.exports = builds \ No newline at end of file +module.exports = builds; From 1bd4bc5301d7e4c14e46c177361db46460ff45ab Mon Sep 17 00:00:00 2001 From: burak Date: Fri, 26 Dec 2025 11:59:39 +0300 Subject: [PATCH 26/31] fix ci issues --- package.json | 2 +- rollup.config.browser.cjs | 2 +- rollup.config.module.cjs | 13 ++++++++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index e3898f57..e1e5ed84 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "@rollup/plugin-node-resolve": "^15.2.3", "chai": "^4.3.7", "codecov": "^3.8.3", - "copy-files-from-to": "^3.9.0", + "copy-files-from-to": "3.8.1", "eslint": "^8.9.0", "jsdoc": "^3.6.10", "karma": "^6.4.2", diff --git a/rollup.config.browser.cjs b/rollup.config.browser.cjs index 96a3f552..61e60d68 100644 --- a/rollup.config.browser.cjs +++ b/rollup.config.browser.cjs @@ -8,7 +8,7 @@ const builds = { '@mediapipe/selfie_segmentation' ], - // ✅ loglevel "this is undefined" uyarısını yut + // ✅ loglevel "this is undefined" warning onwarn(warning, warn) { if ( warning.code === 'THIS_IS_UNDEFINED' && diff --git a/rollup.config.module.cjs b/rollup.config.module.cjs index 96110b84..51773847 100644 --- a/rollup.config.module.cjs +++ b/rollup.config.module.cjs @@ -32,7 +32,18 @@ const builds = { nodeResolve(), commonjs(), css() - ] + ], + // ✅ loglevel "this is undefined" warning + onwarn(warning, warn) { + if ( + warning.code === 'THIS_IS_UNDEFINED' && + warning.id && + warning.id.includes('loglevel.min.js') + ) { + return; + } + warn(warning); + }, }; From f0a1c307f096a1f17f9cba70b89b0a24290901fc Mon Sep 17 00:00:00 2001 From: burak Date: Fri, 26 Dec 2025 12:13:45 +0300 Subject: [PATCH 27/31] change dependency version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e1e5ed84..ed33ef41 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "@rollup/plugin-node-resolve": "^15.2.3", "chai": "^4.3.7", "codecov": "^3.8.3", - "copy-files-from-to": "3.8.1", + "copy-files-from-to": "3.8.0", "eslint": "^8.9.0", "jsdoc": "^3.6.10", "karma": "^6.4.2", From 7ce4aae37a4ec42bed81fe2e906ffaad727cd18b Mon Sep 17 00:00:00 2001 From: burak Date: Fri, 26 Dec 2025 12:31:07 +0300 Subject: [PATCH 28/31] revert the changes for ci --- package.json | 2 +- rollup.config.browser.cjs | 40 ++++++++++---------------------------- rollup.config.module.cjs | 13 +------------ src/main/webapp/.gitignore | 3 ++- 4 files changed, 14 insertions(+), 44 deletions(-) diff --git a/package.json b/package.json index ed33ef41..e3898f57 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "@rollup/plugin-node-resolve": "^15.2.3", "chai": "^4.3.7", "codecov": "^3.8.3", - "copy-files-from-to": "3.8.0", + "copy-files-from-to": "^3.9.0", "eslint": "^8.9.0", "jsdoc": "^3.6.10", "karma": "^6.4.2", diff --git a/rollup.config.browser.cjs b/rollup.config.browser.cjs index 61e60d68..106b8f6f 100644 --- a/rollup.config.browser.cjs +++ b/rollup.config.browser.cjs @@ -1,36 +1,16 @@ + const babel = require('@rollup/plugin-babel').default; const builds = { - input: ['src/main/js/index.js'], - - // ✅ MediaPipe bundle edilmesin - external: [ - '@mediapipe/selfie_segmentation' - ], - - // ✅ loglevel "this is undefined" warning - onwarn(warning, warn) { - if ( - warning.code === 'THIS_IS_UNDEFINED' && - warning.id && - warning.id.includes('loglevel.min.js') - ) { - return; - } - warn(warning); - }, - - output: [ - { - name: 'webrtc_adaptor', - file: 'dist/browser/webrtc_adaptor.js', - format: 'umd' - } - ], + input: [ 'src/main/js/index.js'], + output: [{ + name: 'webrtc_adaptor', + file: 'dist/browser/webrtc_adaptor.js', + format: 'umd' + }, + ], + plugins: [babel({ babelHelpers: 'bundled' })] - plugins: [ - babel({ babelHelpers: 'bundled' }) - ] }; -module.exports = builds; +module.exports = builds \ No newline at end of file diff --git a/rollup.config.module.cjs b/rollup.config.module.cjs index 51773847..96110b84 100644 --- a/rollup.config.module.cjs +++ b/rollup.config.module.cjs @@ -32,18 +32,7 @@ const builds = { nodeResolve(), commonjs(), css() - ], - // ✅ loglevel "this is undefined" warning - onwarn(warning, warn) { - if ( - warning.code === 'THIS_IS_UNDEFINED' && - warning.id && - warning.id.includes('loglevel.min.js') - ) { - return; - } - warn(warning); - }, + ] }; diff --git a/src/main/webapp/.gitignore b/src/main/webapp/.gitignore index fa24451e..bcd8413e 100644 --- a/src/main/webapp/.gitignore +++ b/src/main/webapp/.gitignore @@ -1 +1,2 @@ -*-v2.html \ No newline at end of file +*-v2.html +v2/ \ No newline at end of file From 018226da3e3ac6eaa83f41e10987f3e1bebd8832 Mon Sep 17 00:00:00 2001 From: golgetahir Date: Mon, 29 Dec 2025 10:23:49 +0300 Subject: [PATCH 29/31] Fix rollup config for mediapipe --- rollup.config.browser.cjs | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/rollup.config.browser.cjs b/rollup.config.browser.cjs index 106b8f6f..259d6011 100644 --- a/rollup.config.browser.cjs +++ b/rollup.config.browser.cjs @@ -1,16 +1,17 @@ - const babel = require('@rollup/plugin-babel').default; const builds = { - input: [ 'src/main/js/index.js'], - output: [{ - name: 'webrtc_adaptor', - file: 'dist/browser/webrtc_adaptor.js', - format: 'umd' - }, - ], - plugins: [babel({ babelHelpers: 'bundled' })] - + input: ['src/main/js/index.js'], + output: [{ + name: 'webrtc_adaptor', + file: 'dist/browser/webrtc_adaptor.js', + format: 'umd', + globals: { + '@mediapipe/selfie_segmentation': 'SelfieSegmentation' + } + }], + external: ['@mediapipe/selfie_segmentation'], + plugins: [babel({ babelHelpers: 'bundled' })] }; -module.exports = builds \ No newline at end of file +module.exports = builds; \ No newline at end of file From 0821d78c0255020f889bf5484e87e75406edb79a Mon Sep 17 00:00:00 2001 From: golgetahir Date: Mon, 29 Dec 2025 10:28:31 +0300 Subject: [PATCH 30/31] Fix rollup warning errors --- rollup.config.browser.cjs | 9 ++++-- rollup.config.module.cjs | 63 ++++++++++++++++++++------------------- 2 files changed, 40 insertions(+), 32 deletions(-) diff --git a/rollup.config.browser.cjs b/rollup.config.browser.cjs index 259d6011..4f6dcd12 100644 --- a/rollup.config.browser.cjs +++ b/rollup.config.browser.cjs @@ -11,7 +11,12 @@ const builds = { } }], external: ['@mediapipe/selfie_segmentation'], - plugins: [babel({ babelHelpers: 'bundled' })] + plugins: [babel({ babelHelpers: 'bundled' })], + + onwarn(warning, warn) { + if (warning.code === 'THIS_IS_UNDEFINED') return; + warn(warning); + } }; -module.exports = builds; \ No newline at end of file +module.exports = builds; diff --git a/rollup.config.module.cjs b/rollup.config.module.cjs index 96110b84..33f3e4e4 100644 --- a/rollup.config.module.cjs +++ b/rollup.config.module.cjs @@ -1,39 +1,42 @@ - const babel = require('@rollup/plugin-babel').default; const nodeResolve = require('@rollup/plugin-node-resolve').default; const commonjs = require('@rollup/plugin-commonjs').default; const css = require("rollup-plugin-import-css"); - - const builds = { - input: [ 'src/main/js/index.js', - 'src/main/js/webrtc_adaptor.js', - 'src/main/js/fetch.stream.js', - 'src/main/js/video-effect.js', - 'src/main/js/soundmeter.js', - 'src/main/js/volume-meter-processor.js', - 'src/main/js/external/loglevel.min.js', - 'src/main/js/utility.js', - 'src/main/js/media_manager.js', - 'src/main/js/stream_merger.js', - ], - output: [{ - dir: 'dist', - format: 'cjs' - }, - { - dir: 'dist/es', - format: 'es' - } - ], - plugins: [ - babel({ babelHelpers: 'bundled' }), - nodeResolve(), - commonjs(), - css() - ] + input: [ + 'src/main/js/index.js', + 'src/main/js/webrtc_adaptor.js', + 'src/main/js/fetch.stream.js', + 'src/main/js/video-effect.js', + 'src/main/js/soundmeter.js', + 'src/main/js/volume-meter-processor.js', + 'src/main/js/external/loglevel.min.js', + 'src/main/js/utility.js', + 'src/main/js/media_manager.js', + 'src/main/js/stream_merger.js', + ], + output: [ + { + dir: 'dist', + format: 'cjs' + }, + { + dir: 'dist/es', + format: 'es' + } + ], + plugins: [ + babel({ babelHelpers: 'bundled' }), + nodeResolve(), + commonjs(), + css() + ], + onwarn(warning, warn) { + if (warning.code === 'THIS_IS_UNDEFINED') return; + warn(warning); + } }; -module.exports = builds +module.exports = builds; From d33f5f1db64d1909a34ecafe5aba3a67225fdf6d Mon Sep 17 00:00:00 2001 From: golgetahir Date: Mon, 29 Dec 2025 10:35:16 +0300 Subject: [PATCH 31/31] Fix npm run compile for rollup --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index e3898f57..986945f2 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,10 @@ "doc": "doc" }, "scripts": { - "compile": "npm run cleanup:tsbuild && npm run compile:js && npm run compile:ts && copy-files-from-to && npm run cleanup:tsbuild", + "compile": "npm run cleanup:tsbuild && npm run compile:js && npm run compile:ts && npm run compile:webrtc-sdk && copy-files-from-to && npm run cleanup:tsbuild", "compile:js": "rollup -c rollup.config.module.cjs && rollup -c rollup.config.browser.cjs ", "compile:ts": "tsc -p ./tsconfig.json && api-extractor run", + "compile:webrtc-sdk": "npm install --prefix packages/webrtc-sdk && npm run build --prefix packages/webrtc-sdk", "cleanup:tsbuild": "rimraf ./tsbuild", "test": "karma start karma.conf.cjs", "codecov": "codecov"