diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ffe8f25..7e2e4ab 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -196,6 +196,58 @@ jobs: # TEST JOBS - Test against pre-built artifacts # ============================================================================ + # C++ unit tests — mock SDK replaces the binary; real headers downloaded via check-deps + test-cpp-linux: + name: Test C++ Core (Linux) + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js (for check-deps.js SDK download) + uses: actions/setup-node@v4 + with: + node-version: '24.x' + + - name: Install CMake + run: sudo apt-get install -y cmake + + - name: Download SDK headers + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/check-deps.js + + - name: Build and run C++ tests + run: | + cmake -B build/tests -DRTMS_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Debug + cmake --build build/tests --target rtms_tests -j$(nproc) + cd build/tests && ctest --output-on-failure + + test-cpp-macos: + name: Test C++ Core (macOS) + runs-on: macos-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js (for check-deps.js SDK download) + uses: actions/setup-node@v4 + with: + node-version: '24.x' + + - name: Download SDK headers + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/check-deps.js + + - name: Build and run C++ tests + run: | + cmake -B build/tests -DRTMS_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Debug + cmake --build build/tests --target rtms_tests -j$(sysctl -n hw.logicalcpu) + cd build/tests && ctest --output-on-failure + # Test Node.js SDK on Linux using pre-built prebuilds test-nodejs-linux: name: Test Node.js SDK on Linux (Node ${{ matrix.node-version }}) @@ -203,7 +255,7 @@ jobs: needs: [build-nodejs-linux] strategy: matrix: - node-version: ['20.3.0', '24.x'] # Test minimum (N-API v9) and latest LTS + node-version: ['22.x', '24.x'] # Test current LTS and latest LTS fail-fast: false # Continue testing other versions if one fails steps: @@ -224,8 +276,16 @@ jobs: - name: Install dependencies run: npm install + - name: Extract native prebuild + # install.js skips extraction when .git exists (dev mode detection). + # --force bypasses that check so the full install flow runs in CI. + run: node scripts/install.js --force + + - name: Compile TypeScript + run: npx tsc + - name: Run Node.js tests - run: npx jest tests/rtms.test.ts + run: npx jest tests/ts/rtms.test.ts tests/ts/rtms.wrapper.test.ts # Test Node.js SDK on macOS using pre-built prebuilds test-nodejs-macos: @@ -251,8 +311,16 @@ jobs: - name: Install dependencies run: npm install + - name: Extract native prebuild + # Use install.js so macOS framework archives (.framework.tar.gz) are + # extracted after prebuild-install runs. --force bypasses dev-mode check. + run: node scripts/install.js --force + + - name: Compile TypeScript + run: npx tsc + - name: Run Node.js tests - run: npx jest tests/rtms.test.ts + run: npx jest tests/ts/rtms.test.ts tests/ts/rtms.wrapper.test.ts # Integration test: npm pack → install → load (validates end-user install flow) test-nodejs-integration-macos: @@ -279,7 +347,7 @@ jobs: run: npm install - name: Run integration test (npm pack → install → load) - run: npx jest tests/pack-install.test.js --testTimeout=120000 + run: npx jest tests/ts/pack-install.test.js --testTimeout=120000 # Test Python SDK on Linux using pre-built wheels test-python-linux: @@ -323,7 +391,7 @@ jobs: run: pip install pytest python-dotenv - name: Run Python tests - run: pytest tests/test_rtms.py -v + run: pytest tests/py/test_rtms.py -v # Test Python SDK on macOS using pre-built wheels test-python-macos: @@ -367,7 +435,7 @@ jobs: run: pip install pytest python-dotenv - name: Run Python tests - run: pytest tests/test_rtms.py -v + run: pytest tests/py/test_rtms.py -v # ============================================================================ # VERSION CHECK & PUBLISH TRIGGERS @@ -377,7 +445,7 @@ jobs: check-version-change: name: Check for Version Changes runs-on: ubuntu-latest - needs: [test-nodejs-linux, test-nodejs-macos, test-nodejs-integration-macos, test-python-linux, test-python-macos] + needs: [test-cpp-linux, test-cpp-macos, test-nodejs-linux, test-nodejs-macos, test-nodejs-integration-macos, test-python-linux, test-python-macos] if: | success() && github.event_name == 'push' && @@ -467,7 +535,7 @@ jobs: build-docs: name: Build Documentation runs-on: ubuntu-latest - needs: [test-nodejs-linux, test-nodejs-macos, test-python-linux, test-python-macos] + needs: [test-cpp-linux, test-cpp-macos, test-nodejs-linux, test-nodejs-macos, test-python-linux, test-python-macos] # Only run on main/master branches and if tests pass, or via workflow_dispatch if: | success() && diff --git a/.gitignore b/.gitignore index e5b0646..8e2209b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ __pycache__/ ### Dev Only docs/* !docs/*.md +spec.md ### Build dist diff --git a/CHANGELOG.md b/CHANGELOG.md index 68c78ab..c982b81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,75 @@ All notable changes to the Zoom RTMS SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] - 2026-04-15 + +### Added + +#### ZCC (Zoom Contact Center) Support +- **`engagement_id` in `join()`**: ZCC voice engagements identify sessions with an `engagement_id` instead of `meeting_uuid`; `join()` now accepts `engagement_id` as an alternative identifier (all bindings) +- **ZCC event constants**: `EVENT_CONSUMER_ANSWERED`, `EVENT_CONSUMER_END`, `EVENT_USER_ANSWERED`, `EVENT_USER_END`, `EVENT_USER_HOLD`, `EVENT_USER_UNHOLD` exported as flat module-level constants in both Node.js and Python + +#### Transcript Configuration +- **`TranscriptParams`**: New parameter class for configuring transcript streams (language hint, content type, language detection) +- **`TranscriptLanguage` constants**: Full set of language constants (`ENGLISH`, `SPANISH`, `JAPANESE`, `CHINESE_SIMPLIFIED`, and many more) for skipping auto-detection (~30 s delay) by hinting the source language +- **`setTranscriptParams()`/`set_transcript_params()`**: New method on Client to apply transcript configuration before joining (Node.js and Python) + +#### HTTP Proxy Support +- **`setProxy()`/`set_proxy()`**: New method on Client for configuring HTTP and HTTPS proxies for RTMS connections (Node.js and Python) + +#### Individual Video Streams +- **`subscribeVideo()`/`subscribe_video()`**: Subscribe or unsubscribe per-participant video when using `VIDEO_SINGLE_INDIVIDUAL_STREAM` mode +- **`onParticipantVideo()`/`on_participant_video()`**: Callback fired when a participant's video turns on or off +- **`onVideoSubscribed()`/`on_video_subscribed()`**: Callback fired with the result of each `subscribeVideo()` call +- **`EVENT_PARTICIPANT_VIDEO_ON` / `EVENT_PARTICIPANT_VIDEO_OFF`**: Exported as flat module-level constants in both Node.js and Python + +#### Python — Concurrency & Scaling +- **`EventLoop` / `EventLoopPool`**: New primitives for fine-grained control over polling threads; `EventLoop` wraps a dedicated background thread, `EventLoopPool` balances clients across a pool of threads +- **`run_async()`**: Drop-in async replacement for `run()` using `asyncio.sleep()` between polls; composes naturally with aiohttp, FastAPI, asyncpg +- **Executor dispatch**: `Client(executor=...)` and `run(executor=...)` / `run_async(executor=...)` dispatch data callbacks to a `concurrent.futures.Executor`, keeping the poll loop fast for CPU-bound or I/O-heavy callbacks +- **Async callback detection**: Coroutine callbacks are detected automatically and scheduled on the running event loop via `asyncio.run_coroutine_threadsafe` +- **GIL release in `poll()`**: The C++ poll call now releases the GIL, allowing other Python threads (e.g. the webhook HTTP server) to run freely during polling +- **Context manager support**: `with rtms.Client() as client:` guarantees `leave()` is called on exit +- **Lazy `alloc()` / thread affinity**: `Client()` construction is safe from any thread; the C SDK handle is allocated lazily on the EventLoop's own thread to satisfy the C SDK's thread-affinity constraint +- **Snake_case aliases**: All `camelCase` methods have `snake_case` aliases (`on_audio_data`, `set_audio_params`, `subscribe_event`, etc.) + +#### Metadata & AI Interpreter Fields +- **Full metadata fields**: `Metadata` now exposes `userId`, `userName`, `startTs`, `endTs` in both bindings +- **`AiInterpreter` fields**: `Metadata` now exposes `aiInterpreter` with language pair targets from the AI language interpreter SDK feature + +#### Constants & Enums +- **`IntEnum` for Python codec/rate/option types**: `AudioCodec`, `VideoCodec`, `AudioSampleRate`, `AudioChannel`, `DataOption` are now proper Python `IntEnum` values (comparisons with integers still work) +- **`DataOption`**: Unified stream delivery mode enum covering both audio (`AUDIO_MIXED_STREAM`, `AUDIO_MULTI_STREAMS`) and video (`VIDEO_SINGLE_ACTIVE_STREAM`, `VIDEO_SINGLE_INDIVIDUAL_STREAM`, `VIDEO_MIXED_GALLERY_VIEW`) options — mirrors the C++ `MEDIA_DATA_OPTION` enum directly; `AudioDataOption` and `VideoDataOption` kept as backward-compat aliases +- **Bidirectional param aliases**: All four param structs (`AudioParams`, `VideoParams`, `DeskshareParams`, `TranscriptParams`) now accept both `snake_case` and `camelCase` field names so existing code continues to work regardless of convention + +### Fixed + +- **`setOnParticipantVideo` missing auto-subscription**: Registering the participant video callback now automatically subscribes to `EVENT_PARTICIPANT_VIDEO_ON` and `EVENT_PARTICIPANT_VIDEO_OFF` events, matching the pattern used by `setOnUserUpdate` ([#108](https://github.com/zoom/rtms/issues/108)) +- **`config()` called before `open()`**: Video/audio/transcript params are now applied after the SDK session is open; calling `setVideoParams()` before `join()` no longer fails silently +- **`AiInterpreter.target_size` out-of-bounds**: Guard against uninitialized or negative `target_size` values from the C SDK to prevent array over-reads +- **`_run_executor` undefined**: Python's `_wrap_callback` referenced a module-level `_run_executor` that was never initialised; added the initialisation and wired the `executor` kwarg through `run()` / `run_async()` +- **Redundant `RTMS_` prefix on C++ enum names**: All enum classes inside the `rtms` namespace drop the redundant prefix (`RTMS_EVENT_TYPE` → `EVENT_TYPE`, `RTMS_SESSION_STATE` → `SESSION_STATE`, etc.) — internal refactor, no public API change +- **Spurious configure warnings on leave**: `updateMediaConfiguration` was called during callback teardown after the session was already closed, printing 4 `"Failed to update media configuration"` warnings on every clean leave; suppressed by tracking `sdk_opened_` state and calling `markClosed()` before stopping callbacks +- **SIGSEGV on meeting end (Python)**: Race condition between the EventLoop thread (inside `poll()`) and the webhook thread (calling `release()`) caused a use-after-free crash with exit status 139; fixed with `poll_mutex_` — `poll()` releases the GIL before acquiring the mutex so neither thread can deadlock the other + +### Changed + +- **Protocol enums moved to `rtms.h`**: `EVENT_TYPE`, `ZCC_VOICE_EVENT_TYPE`, `SESSION_STATE`, `STREAM_STATE`, `MESSAGE_TYPE`, `STOP_REASON`, and `TRANSCRIPT_LANGUAGE` are now defined in the shared C++ header, making them automatically available to both Node.js and Python bindings + +### Documentation + +- **Node.js API reference**: New "Media Configuration" section covering `setVideoParams`, `setAudioParams`, `setDeskshareParams`, `setTranscriptParams` with codec/resolution/option constants; updated "Individual Video Streams" section +- **Python API reference**: New "Media Callbacks", "Media Configuration", and "Individual Video Streams" sections; asyncio integration and executor dispatch examples; scaling strategy guide (4-layer progression from single-process to multi-process) +- **ZCC product guide**: Added Zoom Contact Center as a supported product with webhook event names and `engagement_id` usage + +### Testing + +- **C++ unit tests** (70 tests): Full mock-SDK test suite covering Client lifecycle, session/user events, media callbacks, proxy, transcript params, individual video subscriptions, metadata construction, and `AiInterpreter` bounds guarding +- **Python tests** (122 tests): Coverage for asyncio, executor dispatch, context manager, GIL release, ZCC `engagement_id` routing, individual video methods, transcript params, and all new constants +- **Node.js wrapper tests** (98 tests): Integration tests against the real built ESM module covering all Client methods, callbacks, event subscriptions, constants, and utility functions +- **Test reorganisation**: Tests moved from `tests/` root into `tests/ts/`, `tests/py/`, `tests/cpp/` subdirectories for clarity +- **CI test path fixes**: CI/CD workflows updated to reference new test locations; C++ unit tests added as dedicated `test-cpp-linux` and `test-cpp-macos` jobs using the mock SDK (no real Zoom SDK binary required in CI) + ## [1.0.3] - 2026-02-17 ### Fixed diff --git a/CMakeLists.txt b/CMakeLists.txt index e4e6988..9193dce 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.25.1) -project(rtms VERSION 0.0.1 LANGUAGES CXX) +project(rtms VERSION 1.1.0 LANGUAGES CXX) # ===== Build settings ===== set(CMAKE_CXX_STANDARD 20) @@ -57,7 +57,7 @@ set(RTMS_SEARCH_PATHS # ===== Locate library and headers ===== find_path(RTMS_HEADER_DIR - NAMES rtms_csdk.h rtms_common.h + NAMES rtms_sdk.h rtms_common.h PATHS ${RTMS_INCLUDE_DIR} ${RTMS_SEARCH_PATHS} DOC "RTMS SDK headers" ) @@ -68,8 +68,13 @@ find_library(RTMS_LIBRARY DOC "RTMS SDK library" ) -# Error if not found -if(NOT RTMS_LIBRARY) +# ===== Test-only build option ===== +# When RTMS_BUILD_TESTS=ON the SDK binary is replaced by tests/mock_sdk.cpp, +# so the real library is not required. +option(RTMS_BUILD_TESTS "Build C++ unit tests using the mock SDK (no real SDK binary needed)" OFF) + +# Error if not found (skip library check for pure test builds) +if(NOT RTMS_LIBRARY AND NOT RTMS_BUILD_TESTS) message(FATAL_ERROR "RTMS SDK library not found. Please install it or specify its location.") endif() @@ -297,3 +302,41 @@ elseif(DEFINED SKBUILD_PROJECT_NAME) elseif(GO) message(STATUS "Go bindings not yet implemented") endif() + +# ============================================================================ +# C++ Unit Tests +# Uses mock_sdk.cpp in place of the real Zoom SDK binary. +# Configure with: cmake -B build/tests -DRTMS_BUILD_TESTS=ON +# ============================================================================ +if(RTMS_BUILD_TESTS) + message(STATUS "Configuring C++ unit tests (mock SDK)") + + include(FetchContent) + FetchContent_Declare( + Catch2 + GIT_REPOSITORY https://github.com/catchorg/Catch2.git + GIT_TAG v3.5.3 + GIT_SHALLOW TRUE + ) + FetchContent_MakeAvailable(Catch2) + + add_executable(rtms_tests + "${RTMS_SOURCE_DIR}/rtms.cpp" + "${CMAKE_SOURCE_DIR}/tests/cpp/mock_sdk.cpp" + "${CMAKE_SOURCE_DIR}/tests/cpp/test_cpp_wrapper.cpp" + ) + + target_include_directories(rtms_tests PRIVATE + ${RTMS_HEADER_DIR} + ${RTMS_SOURCE_DIR} + "${CMAKE_SOURCE_DIR}/tests/cpp" + ) + + target_compile_features(rtms_tests PRIVATE cxx_std_20) + target_link_libraries(rtms_tests PRIVATE Catch2::Catch2WithMain) + + include(CTest) + list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras) + include(Catch) + catch_discover_tests(rtms_tests) +endif() diff --git a/Dockerfile b/Dockerfile index 7c50fec..41dce1e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,31 +31,40 @@ RUN apt update && apt install -y \ # Install nvm (Node Version Manager) and Node.js ENV NVM_DIR="/root/.nvm" ENV NODE_VERSION="24" -RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash \ - && . "$NVM_DIR/nvm.sh" \ - && nvm install $NODE_VERSION \ - && nvm alias default $NODE_VERSION \ - && nvm use default \ - && npm config set update-notifier false \ - && ln -sf "$NVM_DIR/versions/node/$(nvm version default)" /usr/local/node - -# Add Node.js to PATH ENV PATH="/usr/local/node/bin:$PATH" +RUN if [ "$TARGET" = "js" ] || [ "$TARGET" = "all" ]; then \ + echo "Installing NVM..." \ + && curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash \ + && . "$NVM_DIR/nvm.sh" \ + && nvm install $NODE_VERSION \ + && nvm alias default $NODE_VERSION \ + && nvm use default \ + && npm config set update-notifier false \ + && ln -sf "$NVM_DIR/versions/node/$(nvm version default)" /usr/local/node; \ + fi + + # Install pyenv (Python Version Manager) ENV PYENV_ROOT="/root/.pyenv" ENV PYTHON_VERSION="3.13" -RUN git clone https://github.com/pyenv/pyenv.git $PYENV_ROOT \ - && cd $PYENV_ROOT && src/configure && make -C src \ - && echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc \ - && echo 'export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc \ - && echo 'eval "$(pyenv init -)"' >> ~/.bashrc +ENV PATH="$PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH" +RUN if [ "$TARGET" = "py" ] || [ "$TARGET" = "all" ]; then \ + echo "Installing pyenv version..." \ + && git clone https://github.com/pyenv/pyenv.git $PYENV_ROOT \ + && cd $PYENV_ROOT && src/configure && make -C src \ + && echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc \ + && echo 'export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc \ + && echo 'eval "$(pyenv init -)"' >> ~/.bashrc; \ + fi # Add pyenv to PATH and install Python -ENV PATH="$PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH" -RUN pyenv install $PYTHON_VERSION \ - && pyenv global $PYTHON_VERSION \ - && python --version +RUN if [ "$TARGET" = "py" ] || [ "$TARGET" = "all" ]; then \ + echo "Installing pyenv version..." \ + && pyenv install $PYTHON_VERSION \ + && pyenv global $PYTHON_VERSION \ + && python --version; \ + fi # Install Task (go-task) RUN sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b /usr/local/bin @@ -66,10 +75,11 @@ RUN if [ "$TARGET" = "js" ] || [ "$TARGET" = "all" ]; then \ npm install -g prebuild; \ fi +COPY requirements-dev.txt . RUN if [ "$TARGET" = "py" ] || [ "$TARGET" = "all" ]; then \ echo "Installing Python build tools..." && \ pip install --no-cache-dir --upgrade pip && \ - pip install --no-cache-dir build "pybind11[global]" python-dotenv pdoc3 auditwheel twine; \ + pip install --no-cache-dir -r requirements-dev.txt; \ fi RUN if [ "$TARGET" = "go" ] || [ "$TARGET" = "all" ]; then \ diff --git a/README.md b/README.md index 7a5c9fc..dff5d57 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ The RTMS SDK works with multiple Zoom products: - **[Zoom Meetings](examples/meetings.md)** - Real-time streams from Zoom Meetings - **[Zoom Webinars](examples/webinars.md)** - Broadcast-quality streams from Zoom Webinars - **[Zoom Video SDK](examples/videosdk.md)** - Custom video experiences with RTMS access +- **[Zoom Contact Center](examples/zcc.md)** - Real-time streams from Zoom Contact Center voice engagements See [examples/](examples/) for complete guides and code samples. @@ -40,9 +41,9 @@ The RTMS SDK allows developers to: ### Node.js -**⚠️ Requirements: Node.js >= 20.3.0 (Node.js 24 LTS recommended)** +**⚠️ Requirements: Node.js >= 22.0.0 (Node.js 24 LTS recommended)** -The RTMS SDK uses N-API versions 9 and 10, which require Node.js 20.3.0 or higher. +The RTMS SDK uses N-API versions 9 and 10, which require Node.js 22.0.0 or higher. ```bash # Check your Node.js version @@ -52,24 +53,21 @@ node --version npm install @zoom/rtms ``` +#### Using NVM **If you're using an older version of Node.js:** ```bash # Using nvm (recommended) nvm install 24 # Install Node.js 24 LTS (recommended) nvm use 24 -# Or install Node.js 20 LTS (minimum) -nvm install 20 -nvm use 20 +# Or install Node.js 22 LTS (minimum supported) +nvm install 22 +nvm use 22 # Reinstall the package npm install @zoom/rtms ``` -**Download Node.js:** https://nodejs.org/ - -The Node.js package provides both class-based and singleton APIs for connecting to RTMS streams. - ### Python **⚠️ Requirements: Python >= 3.10 (Python 3.10, 3.11, 3.12, or 3.13)** @@ -95,59 +93,37 @@ pyenv local 3.12 # macOS: brew install python@3.12 ``` -**Download Python:** https://www.python.org/downloads/ +## Usage -The Python package provides a Pythonic decorator-based API with full feature parity to Node.js. +Regardless of the language that you use the RTMS SDK follows the same basic steps: -## For Contributors + - Receive and validate the webhook event + - Create an RTMS SDK Client + - Assign data callbacks to that client + - Join the meeting with the event payload -**This project uses [Task](https://taskfile.dev) (go-task) for development builds and testing.** +### Configure -If you're an **end user** installing via npm or pip, you don't need Task - the installation will work automatically using prebuilt binaries. +All SDK languages read from the environment or a `.env` file: -If you're a **contributor** building from source, you'll need to install Task: - -**macOS:** ```bash -brew install go-task -``` - -**Linux:** -```bash -sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b ~/.local/bin -``` - -**Quick Start for Contributors:** -```bash -# Verify your environment meets requirements -task doctor - -# Setup the project (fetch SDK, install dependencies) -task setup - -# Build for your platform -task build:js # Build Node.js bindings -task build:py # Build Python bindings +# Required - Your Zoom OAuth credentials +ZM_RTMS_CLIENT=your_client_id +ZM_RTMS_SECRET=your_client_secret -# Run tests -task test:js # Test Node.js -task test:py # Test Python +# Optional - Webhook server configuration +ZM_RTMS_PORT=8080 +ZM_RTMS_PATH=/webhook -# See all available commands -task --list +# Optional - Logging configuration +ZM_RTMS_LOG_LEVEL=debug # error, warn, info, debug, trace +ZM_RTMS_LOG_FORMAT=progressive # progressive or json +ZM_RTMS_LOG_ENABLED=true # true or false ``` -For detailed contribution guidelines, build instructions, and troubleshooting, see [CONTRIBUTING.md](CONTRIBUTING.md). - -## Usage - -> **Speaker Identification with Mixed Audio** -> -> When using `AUDIO_MIXED_STREAM` (the default), all participants are mixed into a single audio stream and the audio callback's metadata will **not** identify the current speaker. To identify who is speaking, register an `onActiveSpeakerEvent` callback — it fires whenever the active speaker changes. See [Troubleshooting #7](#7-identifying-speakers-with-mixed-audio-streams) and [#80](https://github.com/zoom/rtms/issues/80) for details. - -### Node.js - Webhook Integration +### Node.js -Easily respond to Zoom webhooks and connect to RTMS streams: +For more details, see our [Quickstart App](https://github.com/zoom/rtms-quickstart-js) ```javascript import rtms from "@zoom/rtms"; @@ -168,163 +144,32 @@ rtms.onWebhookEvent(({event, payload}) => { }); ``` -### Node.js - Advanced Webhook Handling - -For advanced use cases requiring custom webhook validation or response handling (e.g., Zoom's webhook validation challenge), you can use the enhanced callback with raw HTTP access: - -```javascript -import rtms from "@zoom/rtms"; - -rtms.onWebhookEvent((payload, req, res) => { - // Access request headers for webhook validation - const signature = req.headers['x-zoom-signature']; - - // Handle Zoom's webhook validation challenge - if (req.headers['x-zoom-webhook-validator']) { - const validationToken = req.headers['x-zoom-webhook-validator']; - - // Echo back the validation token - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ plainToken: validationToken })); - return; - } - - // Custom validation logic - if (!validateWebhookSignature(payload, signature)) { - res.writeHead(401, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid signature' })); - return; - } - - // Process the webhook payload - if (payload.event === "meeting.rtms_started") { - const client = new rtms.Client(); - - client.onAudioData((data, timestamp, metadata) => { - console.log(`Received audio from ${metadata.userName}`); - }); - - client.join(payload.payload); - } - - // Send custom response - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'ok' })); -}); -``` - -### Node.js - Sharing Ports with Existing Servers - -If you need to integrate webhook handling with your existing Express, Fastify, or other HTTP server (useful for Cloud Run, Kubernetes, or any deployment requiring a single port), use `createWebhookHandler`: - -```javascript -import express from 'express'; -import rtms from '@zoom/rtms'; - -const app = express(); -app.use(express.json()); +> **Production note:** The example above does not validate the incoming webhook signature. Zoom cryptographically signs every webhook — production apps must verify signatures before processing. See [Webhook Validation](examples/node.md#webhook-validation) -// Your existing application routes -app.get('/health', (req, res) => { - res.json({ status: 'healthy' }); -}); +### Python -app.get('/admin', (req, res) => { - res.json({ admin: 'panel' }); -}); +For more details, see our [Quickstart App](https://github.com/zoom/rtms-quickstart-py) -// Create a webhook handler that can be mounted on your existing server -const webhookHandler = rtms.createWebhookHandler( - (payload) => { - console.log(`Received webhook: ${payload.event}`); - - if (payload.event === "meeting.rtms_started") { - const client = new rtms.Client(); - client.onAudioData((data, timestamp, metadata) => { - console.log(`Audio from ${metadata.userName}`); - }); - client.join(payload.payload); - } - }, - '/zoom/webhook' // Path to handle -); - -// Mount the webhook handler on your Express app -app.post('/zoom/webhook', webhookHandler); - -// Single port for all routes -const PORT = process.env.PORT || 8080; -app.listen(PORT, () => { - console.log(`Server running on port ${PORT}`); - console.log(`Webhook endpoint: http://localhost:${PORT}/zoom/webhook`); - console.log(`Health check: http://localhost:${PORT}/health`); -}); -``` +#### Environment Setup -You can also use `RawWebhookCallback` with `createWebhookHandler` for custom validation: - -```javascript -const webhookHandler = rtms.createWebhookHandler( - (payload, req, res) => { - // Custom validation with raw HTTP access - const signature = req.headers['x-zoom-signature']; - - if (!validateSignature(payload, signature)) { - res.writeHead(401); - res.end('Unauthorized'); - return; - } - - // Process webhook... - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'ok' })); - }, - '/zoom/webhook' -); - -app.post('/zoom/webhook', webhookHandler); -``` - -### Node.js - Class-Based Approach - -For greater control or connecting to multiple streams simultaneously: +Create a virtual environment and install dependencies: -```javascript -import rtms from "@zoom/rtms"; +```bash +# Create virtual environment +python3 -m venv .venv -const client = new rtms.Client(); +# Activate virtual environment +source .venv/bin/activate # On Windows: .venv\Scripts\activate -client.onAudioData((data, timestamp, metadata) => { - console.log(`Received audio: ${data.length} bytes`); -}); +# Install dependencies +pip install python-dotenv -client.join({ - meeting_uuid: "your_meeting_uuid", - rtms_stream_id: "your_stream_id", - server_urls: "wss://example.zoom.us", -}); +# Install RTMS SDK +pip install rtms ``` -### Node.js - Global Singleton +#### Quickstart -When you only need to connect to a single RTMS stream: - -```javascript -import rtms from "@zoom/rtms"; - -rtms.onAudioData((data, timestamp, metadata) => { - console.log(`Received audio from ${metadata.userName}`); -}); - -rtms.join({ - meeting_uuid: "your_meeting_uuid", - rtms_stream_id: "your_stream_id", - server_urls: "wss://rtms.zoom.us" -}); -``` - -### Python - Basic Usage ```python #!/usr/bin/env python3 @@ -382,77 +227,63 @@ if __name__ == '__main__': time.sleep(0.01) ``` -### Python - Advanced Webhook Validation +> **Production note:** The example above does not validate the incoming webhook signature. Zoom cryptographically signs every webhook — production apps must verify signatures before processing. See [Webhook Validation](examples/python.md#webhook-validation) -For production use cases requiring custom webhook validation: +## Troubleshooting -```python -import rtms -import hmac -import hashlib +If you encounter issues some of these steps may help. -client = rtms.Client() +### 1. Segmentation Fault / Crash on Startup -@client.on_webhook_event() -def handle_webhook(payload, request, response): - # Access request headers for validation - signature = request.headers.get('x-zoom-signature') - - # Handle Zoom's webhook validation challenge - if request.headers.get('x-zoom-webhook-validator'): - validator = request.headers['x-zoom-webhook-validator'] - response.set_status(200) - response.send({'plainToken': validator}) - return - - # Custom signature validation - if not validate_signature(payload, signature): - response.set_status(401) - response.send({'error': 'Invalid signature'}) - return - - # Process valid webhook - if payload.get('event') == 'meeting.rtms_started': - client.join(payload.get('payload')) +**Symptoms:** +- Immediate crash when requiring/importing the module +- Error message: `Segmentation fault (core dumped)` +- Stack trace shows `napi_module_register_by_symbol` - response.send({'status': 'ok'}) -``` +**Root Cause:** Using Node.js version < 22.0.0 -### Python - Environment Setup +**Solution:** See [Using NVM](#using-nvm) -Create a virtual environment and install dependencies: -```bash -# Create virtual environment -python3 -m venv .venv +**Prevention:** +- Always use Node.js 22.0.0 or higher +- Use recommended version with `.nvmrc`: `nvm use` (Node.js 24 LTS) +- Check version before installing: `node --version` -# Activate virtual environment -source .venv/bin/activate # On Windows: .venv\Scripts\activate +### 2. Platform Support +Verify you're using a supported platform (darwin-arm64 or linux-x64) -# Install dependencies -pip install python-dotenv +### 3. SDK Files +Ensure RTMS C++ SDK files are correctly placed in the appropriate lib directory -# Install RTMS SDK -pip install rtms -``` +### 4. Build Mode +Try both debug and release modes (`npm run debug` or `npm run release`) -Create a `.env` file: +### 5. Dependencies +Verify all prerequisites are installed -```bash -# Required - Your Zoom OAuth credentials -ZM_RTMS_CLIENT=your_client_id -ZM_RTMS_SECRET=your_client_secret +### 6. Audio Defaults Mismatch +This SDK uses different default audio parameters than the raw RTMS WebSocket protocol for better out-of-the-box quality. If you need to match the WebSocket protocol defaults, see [#92](https://github.com/zoom/rtms/issues/92) for details. -# Optional - Webhook server configuration -ZM_RTMS_PORT=8080 -ZM_RTMS_PATH=/webhook +### 7. Identifying Speakers with Mixed Audio Streams +When using `AUDIO_MIXED_STREAM`, the audio callback's metadata does not identify the current speaker since all participants are mixed into a single stream. To identify who is speaking, use the `onActiveSpeakerEvent` callback: -# Optional - Logging configuration -ZM_RTMS_LOG_LEVEL=debug # error, warn, info, debug, trace -ZM_RTMS_LOG_FORMAT=progressive # progressive or json -ZM_RTMS_LOG_ENABLED=true # true or false +**Node.js:** +```javascript +client.onActiveSpeakerEvent((timestamp, userId, userName) => { + console.log(`Active speaker: ${userName} (${userId})`); +}); ``` +**Python:** +```python +@client.onActiveSpeakerEvent +def on_active_speaker(timestamp, user_id, user_name): + print(f"Active speaker: {user_name} ({user_id})") +``` + +This callback notifies your application whenever the active speaker changes in the meeting. You can also use the lower-level `onEventEx` function with the active speaker event type directly. See [#80](https://github.com/zoom/rtms/issues/80) for more details. + ## Building from Source The RTMS SDK can be built from source using either Docker (recommended) or local build tools. @@ -461,7 +292,7 @@ The RTMS SDK can be built from source using either Docker (recommended) or local #### Prerequisites - Docker and Docker Compose -- Zoom RTMS C SDK files (contact Zoom for access) +- Zoom RTMS C++ SDK files (contact Zoom for access) - Task installed (or use Docker's Task installation) #### Steps @@ -494,12 +325,12 @@ Docker Compose creates **distributable packages** for linux-x64 (prebuilds for N ### Building Locally #### Prerequisites -- Node.js (>= 20.3.0, LTS recommended) +- Node.js (>= 22.0.0, LTS recommended) - Python 3.10+ with pip (for Python build) - CMake 3.25+ - C/C++ build tools - Task (go-task) - https://taskfile.dev/installation/ -- Zoom RTMS C SDK files (contact Zoom for access) +- Zoom RTMS C++ SDK files (contact Zoom for access) #### Steps ```bash @@ -553,95 +384,17 @@ task setup # Fetch SDK and install dependencies BUILD_TYPE=Debug task build:js # Build in debug mode BUILD_TYPE=Release task build:js # Build in release mode (default) -# Debug logging for C SDK callbacks +# Debug logging for C++ SDK callbacks RTMS_DEBUG=ON task build:js # Enable verbose callback logging ``` -## Troubleshooting - -If you encounter issues: - -### 1. Segmentation Fault / Crash on Startup - -**Symptoms:** -- Immediate crash when requiring/importing the module -- Error message: `Segmentation fault (core dumped)` -- Stack trace shows `napi_module_register_by_symbol` - -**Root Cause:** Using Node.js version < 20.3.0 - -**Solution:** -```bash -# 1. Check your Node.js version -node --version - -# 2. If < 20.3.0, upgrade to a supported version - -# Using nvm (recommended): -nvm install 24 # Install Node.js 24 LTS (recommended) -nvm use 24 - -# Or install minimum version: -nvm install 20 -nvm use 20 - -# Or download from: https://nodejs.org/ - -# 3. Clear npm cache and reinstall -npm cache clean --force -rm -rf node_modules package-lock.json -npm install -``` - -**Prevention:** -- Always use Node.js 20.3.0 or higher -- Use recommended version with `.nvmrc`: `nvm use` (Node.js 24 LTS) -- Check version before installing: `node --version` - -### 2. Platform Support -Verify you're using a supported platform (darwin-arm64 or linux-x64) - -### 3. SDK Files -Ensure RTMS C SDK files are correctly placed in the appropriate lib directory - -### 4. Build Mode -Try both debug and release modes (`npm run debug` or `npm run release`) - -### 5. Dependencies -Verify all prerequisites are installed - -### 6. Audio Defaults Mismatch -This SDK uses different default audio parameters than the raw RTMS WebSocket protocol for better out-of-the-box quality. If you need to match the WebSocket protocol defaults, see [#92](https://github.com/zoom/rtms/issues/92) for details. - -### 7. Identifying Speakers with Mixed Audio Streams -When using `AUDIO_MIXED_STREAM`, the audio callback's metadata does not identify the current speaker since all participants are mixed into a single stream. To identify who is speaking, use the `onActiveSpeakerEvent` callback: - -**Node.js:** -```javascript -client.onActiveSpeakerEvent((timestamp, userId, userName) => { - console.log(`Active speaker: ${userName} (${userId})`); -}); -``` - -**Python:** -```python -@client.onActiveSpeakerEvent -def on_active_speaker(timestamp, user_id, user_name): - print(f"Active speaker: {user_name} ({user_id})") -``` +## For Contributors -This callback notifies your application whenever the active speaker changes in the meeting. You can also use the lower-level `onEventEx` function with the active speaker event type directly. See [#80](https://github.com/zoom/rtms/issues/80) for more details. +For detailed contribution guidelines, build instructions, and troubleshooting, see [CONTRIBUTING.md](CONTRIBUTING.md). ## For Maintainers -If you're a maintainer looking to build, test, or publish new releases of the RTMS SDK, please refer to [PUBLISHING.md](PUBLISHING.md) for comprehensive documentation on: - -- Building platform-specific wheels and prebuilds -- Publishing to npm and PyPI -- GitHub Actions CI/CD workflow -- Testing procedures -- Troubleshooting common issues -- Release workflows for Node.js and Python +If you're a maintainer looking to build, test, or publish new releases of the RTMS SDK, please refer to [PUBLISHING.md](PUBLISHING.md) ## License diff --git a/Taskfile.yml b/Taskfile.yml index f0174e8..9fb6945 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -107,7 +107,7 @@ tasks: for wheel in dist/py/*linux_x86_64*.whl; do if [ -f "$wheel" ]; then echo "Repairing: $wheel" - auditwheel repair "$wheel" --exclude librtmsdk.so.0 -w dist/py + auditwheel repair "$wheel" --plat manylinux_2_34_x86_64 --exclude librtmsdk.so.0 -w dist/py rm "$wheel" echo "Wheel repaired successfully" fi @@ -216,13 +216,13 @@ tasks: desc: "Run Node.js tests (local)" deps: [build:js] cmds: - - npx jest tests/rtms.test.ts + - npx jest tests/ts/rtms.test.ts tests/ts/rtms.wrapper.test.ts test:js:integration: desc: "Run npm pack → install → load integration test" deps: [build:js] cmds: - - npx jest tests/pack-install.test.js --testTimeout=120000 + - npx jest tests/ts/pack-install.test.js --testTimeout=120000 test:js:linux: desc: "Run Node.js tests in Linux Docker" @@ -230,17 +230,24 @@ tasks: - docker compose run --rm test-js test:py: - desc: "Run Python tests (local)" + desc: "Run Python tests (local darwin)" deps: [build:py] cmds: - - python -m pip install --find-links=dist/py rtms - - pytest tests/test_rtms.py -v + - .venv/bin/pip install --force-reinstall --find-links=dist/py rtms --quiet + - .venv/bin/pytest tests/py/test_rtms.py -v test:py:linux: desc: "Run Python tests in Linux Docker" cmds: - docker compose run --rm test-py + test:cpp: + desc: "Build and run C++ unit tests (no SDK binary required)" + cmds: + - cmake -B build/tests -DRTMS_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Debug + - cmake --build build/tests --target rtms_tests -j$(nproc 2>/dev/null || sysctl -n hw.logicalcpu) + - cd build/tests && ctest --output-on-failure + test:local: desc: "Run all tests locally" cmds: diff --git a/compose.yaml b/compose.yaml index 76ba139..031d99b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -52,7 +52,7 @@ services: TARGET: js environment: - GITHUB_TOKEN - command: sh -c "task setup && task build:js && npx jest tests/" + command: sh -c "task setup && task build:js && npx jest tests/ts/" test-py: <<: *base @@ -60,7 +60,7 @@ services: context: . args: TARGET: py - command: sh -c "task setup && task build:py && python -m pip install --find-links=dist/py rtms && pytest tests/test_rtms.py -v" + command: sh -c "task build:py && pip install --find-links=dist/py rtms --quiet && pytest tests/py/test_rtms.py -v" # ============================================================================= # Docs Service - Documentation generation diff --git a/examples/node.md b/examples/node.md new file mode 100644 index 0000000..a86f8c7 --- /dev/null +++ b/examples/node.md @@ -0,0 +1,334 @@ +# Node.js API Reference + +Complete API reference and examples for the `@zoom/rtms` Node.js package. + +**Requirements:** Node.js >= 22.0.0 (Node.js 24 LTS recommended) + +For product-specific webhook events and payload fields, see the [product guides](../README.md#supported-products). + +## Installation + +```bash +npm install @zoom/rtms +``` + +## Webhook Integration + +`onWebhookEvent` sets up an HTTP server that receives Zoom webhook deliveries and passes the parsed payload to your callback. The SDK starts polling for RTMS events automatically after `join()` succeeds. + +```javascript +import rtms from "@zoom/rtms"; + +const clients = new Map(); + +rtms.onWebhookEvent(({ event, payload }) => { + const streamId = payload?.rtms_stream_id; + + if (event.includes("rtms_stopped")) { + clients.get(streamId)?.leave(); + clients.delete(streamId); + return; + } + + if (!event.includes("rtms_started")) return; + + const client = new rtms.Client(); + clients.set(streamId, client); + + client.onTranscriptData((data, size, timestamp, metadata) => { + console.log(`[${timestamp}] ${metadata.userName}: ${data}`); + }); + + client.join(payload); +}); +``` + +For the specific event names for your product, see the [product guides](../README.md#supported-products). + +## Webhook Validation + +> **⚠️ Required for production.** The example above processes all incoming requests without verification. In production, Zoom cryptographically signs every webhook — you must validate the signature to reject forged requests. + +Use the raw callback form `(payload, req, res)` to access headers directly: + +```javascript +import rtms from "@zoom/rtms"; +import { createHmac } from "crypto"; + +function verifySignature(body, timestamp, signature) { + const message = `v0:${timestamp}:${body}`; + const expected = "v0=" + createHmac("sha256", process.env.ZM_RTMS_WEBHOOK_SECRET) + .update(message) + .digest("hex"); + return expected === signature; +} + +rtms.onWebhookEvent((payload, req, res) => { + const signature = req.headers["x-zm-signature"]; + const timestamp = req.headers["x-zm-request-timestamp"]; + + // Zoom endpoint validation challenge — respond before processing events + if (req.headers["x-zm-webhook-validator"]) { + const token = req.headers["x-zm-webhook-validator"]; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ plainToken: token })); + return; + } + + if (!signature || !timestamp || !verifySignature(JSON.stringify(payload), timestamp, signature)) { + res.writeHead(401); + res.end(JSON.stringify({ error: "Unauthorized" })); + return; + } + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "ok" })); + + if (payload.event?.includes("rtms_started")) { + const client = new rtms.Client(); + client.onTranscriptData((data, size, timestamp, metadata) => { + console.log(`[${timestamp}] ${metadata.userName}: ${data}`); + }); + client.join(payload.payload); + } +}); +``` + +## Sharing a Port with an Existing Server + +If your app already runs an HTTP server (Express, Fastify, Cloud Run, etc.), mount the webhook handler on your existing port with `createWebhookHandler`: + +```javascript +import express from "express"; +import rtms from "@zoom/rtms"; + +const app = express(); +app.use(express.json()); + +app.get("/health", (req, res) => res.json({ status: "ok" })); + +const webhookHandler = rtms.createWebhookHandler( + ({ event, payload }) => { + if (!event.includes("rtms_started")) return; + const client = new rtms.Client(); + client.onAudioData((data, size, timestamp, metadata) => { + console.log(`Audio from ${metadata.userName}: ${data.length}B`); + }); + client.join(payload); + }, + "/zoom/webhook" +); + +app.post("/zoom/webhook", webhookHandler); +app.listen(8080); +``` + +`createWebhookHandler` also accepts the raw `(payload, req, res)` form for custom validation. + +## Class-Based API + +Use `new rtms.Client()` directly for more control or to connect to multiple streams simultaneously: + +```javascript +import rtms from "@zoom/rtms"; + +const client = new rtms.Client(); + +client.onAudioData((data, size, timestamp, metadata) => { + console.log(`Received ${data.length} bytes from ${metadata.userName}`); +}); + +client.join({ + meeting_uuid: "your_meeting_uuid", + rtms_stream_id: "your_stream_id", + server_urls: "wss://example.zoom.us", +}); +``` + +## Media Callbacks + +All callbacks receive a `metadata` object with `userId` and `userName`: + +```javascript +// Transcript — text data with speaker info +client.onTranscriptData((data, size, timestamp, metadata) => { + console.log(`[${timestamp}] ${metadata.userName}: ${data}`); +}); + +// Audio — raw PCM / Opus frames +client.onAudioData((data, size, timestamp, metadata) => { + console.log(`Audio: ${data.length}B from ${metadata.userName}`); +}); + +// Video — H.264 / raw frames +client.onVideoData((data, size, timestamp, metadata) => { + console.log(`Video: ${size}B from ${metadata.userName}`); +}); + +// Desktop share +client.onDeskshareData((data, size, timestamp, metadata) => { + console.log(`Deskshare: ${size}B from ${metadata.userName}`); +}); +``` + +> **Speaker identification with mixed audio:** When using the default `AUDIO_MIXED_STREAM`, audio metadata does not identify the current speaker. Use `onActiveSpeakerEvent` to track who is speaking: +> +> ```javascript +> client.onActiveSpeakerEvent((timestamp, userId, userName) => { +> console.log(`Active speaker: ${userName} (${userId})`); +> }); +> ``` + +## Transcript Language Configuration + +By default the SDK auto-detects the spoken language before enabling transcription (~30 seconds). Providing a language hint with `setTranscriptParams` lets transcription begin immediately: + +```javascript +// Hint the source language — skips auto-detect, transcription starts immediately +client.setTranscriptParams({ srcLanguage: rtms.TranscriptLanguage.ENGLISH }); +``` + +`TranscriptLanguage` constants: `ENGLISH`, `SPANISH`, `JAPANESE`, `CHINESE_SIMPLIFIED`, and many more. To use auto-detection, omit `setTranscriptParams` or pass `srcLanguage: rtms.TranscriptLanguage.NONE`. + +## Media Configuration + +By default each stream type uses sensible settings (OPUS audio at 48 kHz, H.264 video at HD/30 fps). Call the relevant `set*Params` method before `join()` to override any field — unspecified fields keep their defaults. + +### Video + +```javascript +// Switch from the default composite active-speaker stream to per-participant streams +client.setVideoParams({ + dataOpt: rtms.VideoDataOption.VIDEO_SINGLE_INDIVIDUAL_STREAM, +}); + +// Full control — all fields optional +client.setVideoParams({ + codec: rtms.VideoCodec.H264, + resolution: rtms.VideoResolution.HD, + fps: 30, + dataOpt: rtms.VideoDataOption.VIDEO_SINGLE_ACTIVE_STREAM, +}); +``` + +`VideoCodec` constants: `H264`, `JPG`, `PNG`. `VideoResolution` constants: `SD`, `HD`, `FHD`, `QHD`. `VideoDataOption` constants: `VIDEO_SINGLE_ACTIVE_STREAM` (default composite), `VIDEO_SINGLE_INDIVIDUAL_STREAM` (per-participant), `VIDEO_MIXED_GALLERY_VIEW`. + +### Audio + +```javascript +// Receive a single mixed stream instead of the default per-participant streams +client.setAudioParams({ + dataOpt: rtms.AudioDataOption.AUDIO_MIXED_STREAM, +}); +``` + +`AudioSampleRate` constants: `SR_8K`, `SR_16K`, `SR_32K`, `SR_48K` (default). `AudioChannel` constants: `MONO`, `STEREO` (default). `AudioDataOption` constants: `AUDIO_MULTI_STREAMS` (default, per-participant), `AUDIO_MIXED_STREAM`. + +### Desktop Share + +```javascript +client.setDeskshareParams({ + codec: rtms.VideoCodec.H264, + resolution: rtms.VideoResolution.FHD, + fps: 5, +}); +``` + +Uses the same `codec`, `resolution`, `fps`, and `dataOpt` fields as video. + +## HTTP Proxy + +Route RTMS WebSocket traffic through an HTTP proxy. Call `setProxy` before `join()` — it returns `true` on success: + +```javascript +import rtms from "@zoom/rtms"; + +const clients = new Map(); + +rtms.onWebhookEvent(({ event, payload }) => { + const streamId = payload?.rtms_stream_id; + + if (event.includes("rtms_stopped")) { + clients.get(streamId)?.leave(); + clients.delete(streamId); + return; + } + + if (!event.includes("rtms_started")) return; + + const client = new rtms.Client(); + clients.set(streamId, client); + + // Route WebSocket traffic through an HTTP proxy. + // Must be called before join(). Returns true on success. + const ok = client.setProxy("http", process.env.RTMS_PROXY_URL); + if (!ok) console.warn("setProxy failed — joining without proxy"); + + client.onTranscriptData((data, size, timestamp, metadata) => { + console.log(`[${timestamp}] ${metadata.userName}: ${data}`); + }); + + client.join(payload); +}); +``` + +The first argument is the proxy type (`"http"`). The second argument is the full proxy URL including host and port. + +## Individual Video Streams + +By default you receive a single composite stream of the active speaker. To receive per-participant video, first configure `VIDEO_SINGLE_INDIVIDUAL_STREAM`, then subscribe per participant as they join: + +```javascript +// Must be called before join() — switches from composite to per-participant streams +client.setVideoParams({ + dataOpt: rtms.VideoDataOption.VIDEO_SINGLE_INDIVIDUAL_STREAM, +}); + +// Subscribe when a participant joins, unsubscribe when they leave +client.onUserUpdate((op, participant) => { + if (op === rtms.USER_JOIN && participant?.id) { + client.subscribeVideo(participant.id, true); + } + if (op === rtms.USER_LEAVE && participant?.id) { + client.subscribeVideo(participant.id, false); + } +}); + +// Fires when a participant's video turns on or off +client.onParticipantVideo((userIds, isOn) => { + console.log(`Video ${isOn ? "on" : "off"} for users: ${userIds}`); +}); + +// Fires with the subscription result for each subscribeVideo() call +client.onVideoSubscribed((userId, status, error) => { + console.log(`subscribeVideo(${userId}): status=${status}${error ? " error=" + error : ""}`); +}); +``` + +## Participant Events + +```javascript +client.onParticipantEvent((event, timestamp, participants) => { + for (const p of participants) { + console.log(`${event} ts=${timestamp} userId=${p.userId} name="${p.userName}"`); + } +}); +``` + +## Environment Variables + +| Variable | Required | Default | Description | +|---|---|---|---| +| `ZM_RTMS_CLIENT` | Yes | — | Your Zoom OAuth Client ID | +| `ZM_RTMS_SECRET` | Yes | — | Your Zoom OAuth Client Secret | +| `ZM_RTMS_PORT` | No | `8080` | Webhook server port | +| `ZM_RTMS_PATH` | No | `/` | Webhook endpoint path | +| `ZM_RTMS_CA` | No | system CA | Path to CA certificate file | +| `ZM_RTMS_LOG_LEVEL` | No | `info` | Log level: `error`, `warn`, `info`, `debug`, `trace` | +| `ZM_RTMS_LOG_FORMAT` | No | `progressive` | Log format: `progressive` or `json` | +| `ZM_RTMS_LOG_ENABLED` | No | `true` | Enable/disable SDK logging | + +## Related + +- [Python API Reference](python.md) +- [Full API Reference](https://zoom.github.io/rtms/js/) diff --git a/examples/python.md b/examples/python.md new file mode 100644 index 0000000..80c1fd2 --- /dev/null +++ b/examples/python.md @@ -0,0 +1,445 @@ +# Python API Reference + +Complete API reference and examples for the `rtms` Python package. + +**Requirements:** Python >= 3.10 + +For product-specific webhook events and payload fields, see the [product guides](../README.md#supported-products). + +## Installation + +```bash +pip install rtms +``` + +## Webhook Integration + +The `@rtms.on_webhook_event` decorator sets up an HTTP server that receives Zoom webhook deliveries. The SDK starts polling for RTMS events automatically after `join()` succeeds. + +```python +import rtms + +@rtms.on_webhook_event +def handle_webhook(payload): + if 'rtms_started' not in payload.get('event', ''): + return + + client = rtms.Client() + + @client.on_transcript_data + def on_transcript(data, size, timestamp, metadata): + print(f'[{timestamp}] {metadata.userName}: {data.decode()}') + + client.join(payload['payload']) + +rtms.run() # blocks; Ctrl-C to stop +``` + +For the specific event names for your product, see the [product guides](../README.md#supported-products). + +## Webhook Validation + +> **⚠️ Required for production.** The example above processes all incoming requests without verification. In production, Zoom cryptographically signs every webhook — you must validate the signature to reject forged requests. + +```python +import rtms +import hmac +import hashlib +import os + +def verify_signature(body: str, timestamp: str, signature: str) -> bool: + message = f"v0:{timestamp}:{body}" + expected = "v0=" + hmac.new( + os.environ["ZM_RTMS_WEBHOOK_SECRET"].encode(), + message.encode(), + hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(expected, signature) + +@rtms.on_webhook_event +def handle_webhook(payload, request, response): + signature = request.headers.get('x-zm-signature', '') + timestamp = request.headers.get('x-zm-request-timestamp', '') + + # Zoom endpoint validation challenge + validator = request.headers.get('x-zm-webhook-validator') + if validator: + response.set_status(200) + response.send({'plainToken': validator}) + return + + if not verify_signature(str(payload), timestamp, signature): + response.set_status(401) + response.send({'error': 'Unauthorized'}) + return + + response.send({'status': 'ok'}) + + if 'rtms_started' in payload.get('event', ''): + client = rtms.Client() + client.on_transcript_data( + lambda data, size, ts, meta: print(f'{meta.userName}: {data.decode()}') + ) + client.join(payload['payload']) + +rtms.run() +``` + +## Context Manager + +Use `with rtms.Client() as client:` to ensure `leave()` is always called — even if an exception occurs: + +```python +import rtms + +with rtms.Client() as client: + client.on_audio_data(lambda data, size, ts, meta: process(data)) + client.join( + meeting_uuid=..., + rtms_stream_id=..., + server_urls=... + ) + rtms.run(stop_on_empty=True) +# client.leave() called automatically on exit +``` + +## Media Callbacks + +All callbacks receive a `metadata` object with `userId` and `userName`: + +```python +# Transcript — text data with speaker info +@client.on_transcript_data +def on_transcript(data, size, timestamp, metadata): + print(f'[{timestamp}] {metadata.userName}: {data.decode()}') + +# Audio — raw PCM / Opus frames +@client.on_audio_data +def on_audio(data, size, timestamp, metadata): + print(f'Audio: {len(data)}B from {metadata.userName}') + +# Video — H.264 / raw frames +@client.on_video_data +def on_video(data, size, timestamp, metadata): + print(f'Video: {size}B from {metadata.userName}') + +# Desktop share +@client.on_deskshare_data +def on_deskshare(data, size, timestamp, metadata): + print(f'Deskshare: {size}B from {metadata.userName}') +``` + +> **Speaker identification with mixed audio:** When using the default `AUDIO_MIXED_STREAM`, audio metadata does not identify the current speaker. Use `on_active_speaker_event` to track who is speaking: +> +> ```python +> @client.on_active_speaker_event +> def on_speaker(timestamp, user_id, user_name): +> print(f'Active speaker: {user_name} ({user_id})') +> ``` + +## Media Configuration + +By default each stream type uses sensible settings (OPUS audio at 48 kHz, H.264 video at HD/30 fps). Call the relevant `set_*_params` method before `join()` to override any field — unspecified fields keep their defaults. + +### Video + +```python +# Switch from the default composite active-speaker stream to per-participant streams +params = rtms.VideoParams() +params.data_opt = rtms.DataOption.VIDEO_SINGLE_INDIVIDUAL_STREAM +client.set_video_params(params) + +# Full control — set only the fields you want to change +params = rtms.VideoParams() +params.codec = rtms.VideoCodec.H264 +params.resolution = rtms.VideoResolution.HD +params.fps = 30 +params.data_opt = rtms.DataOption.VIDEO_SINGLE_ACTIVE_STREAM +client.set_video_params(params) +``` + +`VideoCodec` constants: `H264`, `JPG`, `PNG`. `VideoResolution` constants: `SD`, `HD`, `FHD`, `QHD`. `DataOption` video constants: `VIDEO_SINGLE_ACTIVE_STREAM` (default composite), `VIDEO_SINGLE_INDIVIDUAL_STREAM` (per-participant), `VIDEO_MIXED_GALLERY_VIEW`. + +### Audio + +```python +# Receive a single mixed stream instead of the default per-participant streams +params = rtms.AudioParams() +params.data_opt = rtms.DataOption.AUDIO_MIXED_STREAM +client.set_audio_params(params) +``` + +`AudioSampleRate` constants: `SR_8K`, `SR_16K`, `SR_32K`, `SR_48K` (default). `AudioChannel` constants: `MONO`, `STEREO` (default). `DataOption` audio constants: `AUDIO_MULTI_STREAMS` (default, per-participant), `AUDIO_MIXED_STREAM`. + +### Desktop Share + +```python +params = rtms.DeskshareParams() +params.codec = rtms.VideoCodec.H264 +params.resolution = rtms.VideoResolution.FHD +params.fps = 5 +client.set_deskshare_params(params) +``` + +Uses the same `codec`, `resolution`, `fps`, and `data_opt` fields as video. + +### Transcript Language + +By default the SDK auto-detects the spoken language before enabling transcription (~30 seconds). Providing a language hint lets transcription begin immediately: + +```python +# Hint the source language — skips auto-detect, transcription starts immediately +params = rtms.TranscriptParams() +params.src_language = rtms.TranscriptLanguage.ENGLISH +client.set_transcript_params(params) +``` + +`TranscriptLanguage` constants: `ENGLISH`, `SPANISH`, `JAPANESE`, `CHINESE_SIMPLIFIED`, and many more. To use auto-detection, omit `set_transcript_params` or set `src_language = rtms.TranscriptLanguage.NONE`. + +## Individual Video Streams + +By default you receive a single composite stream of the active speaker. To receive per-participant video, first configure `VIDEO_SINGLE_INDIVIDUAL_STREAM`, then subscribe per participant as they join: + +```python +# Must be called before join() — switches from composite to per-participant streams +params = rtms.VideoParams() +params.data_opt = rtms.DataOption.VIDEO_SINGLE_INDIVIDUAL_STREAM +client.set_video_params(params) + +# Subscribe when a participant joins, unsubscribe when they leave +@client.on_user_update +def on_user(op, participant): + if op == rtms.USER_JOIN and participant.id: + client.subscribe_video(participant.id, True) + if op == rtms.USER_LEAVE and participant.id: + client.subscribe_video(participant.id, False) + +# Fires when a participant's video turns on or off +@client.on_participant_video +def on_participant_video(user_ids, is_on): + print(f'Video {"on" if is_on else "off"} for users: {user_ids}') + +# Fires with the subscription result for each subscribe_video() call +@client.on_video_subscribed +def on_video_subscribed(user_id, status, error): + print(f'subscribe_video({user_id}): status={status}' + (f' error={error}' if error else '')) + +@client.on_video_data +def on_video(data, size, timestamp, metadata): + print(f'Video: {size}B from {metadata.userName}') +``` + +## asyncio Integration + +`run_async()` is a drop-in replacement for `run()` that uses `asyncio.sleep()` between polls, so it composes naturally with aiohttp, FastAPI, asyncpg, and any other async framework on a shared event loop: + +```python +import asyncio +import rtms +from aiohttp import web + +routes = web.RouteTableDef() + +@routes.post('/webhook') +async def webhook(request): + payload = await request.json() + if 'rtms_started' in payload.get('event', ''): + client = rtms.Client() + client.on_transcript_data( + lambda d, s, t, m: print(m.userName, d.decode()) + ) + client.join(payload['payload']) + return web.Response(text='ok') + +async def main(): + app = web.Application() + app.add_routes(routes) + runner = web.AppRunner(app) + await runner.setup() + await web.TCPSite(runner, port=8080).start() + await rtms.run_async() # yields control between polls — never blocks + +asyncio.run(main()) +``` + +Async callbacks are detected automatically and scheduled on the running event loop: + +```python +client = rtms.Client() + +async def save_audio(data, size, timestamp, metadata): + await db.insert('audio', data) # fully non-blocking + +client.on_audio_data(save_audio) # coroutine detected, dispatched via loop +``` + +## Executor-Based Callback Dispatch + +For CPU-bound or I/O-heavy callbacks that should not block the poll loop, pass a `concurrent.futures.Executor`: + +```python +from concurrent.futures import ThreadPoolExecutor +import rtms + +# Per-client executor +client = rtms.Client(executor=ThreadPoolExecutor(max_workers=8)) +client.on_audio_data(run_asr_model) # dispatched to thread pool +client.on_transcript_data(write_to_database) # dispatched to thread pool + +# Global default for all clients in the event loop +rtms.run(executor=ThreadPoolExecutor(max_workers=32)) +``` + +Callbacks without an executor continue to run inline — identical to v1.0 behavior. + +## Scaling Strategy + +The Python SDK provides a layered set of primitives for scaling from a single meeting to hundreds of concurrent streams. + +### Layer 1 — Single process, inline callbacks (default, zero config) + +Appropriate for < ~20 concurrent meetings with lightweight callbacks (logging, forwarding, simple storage). + +```python +import rtms + +@rtms.on_webhook_event +def handle(payload): + if payload.get('event') != 'meeting.rtms_started': + return + client = rtms.Client() + client.on_transcript_data(lambda d, s, t, m: print(m.userName, d.decode())) + client.join(payload['payload']) + +rtms.run() +``` + +- **Event loop**: `rtms.run()` — synchronous, single-threaded +- **Callbacks**: inline, no dispatch overhead +- **GIL**: released during `poll()`, so other Python threads (e.g. the webhook HTTP server) stay responsive + +### Layer 2 — Single process, executor dispatch + +Appropriate for CPU-bound callbacks (ML inference, audio processing) or I/O callbacks (database writes, S3 uploads) that would otherwise block the poll loop. + +```python +from concurrent.futures import ThreadPoolExecutor +import rtms + +executor = ThreadPoolExecutor(max_workers=32) + +@rtms.on_webhook_event +def handle(payload): + if payload.get('event') != 'meeting.rtms_started': + return + client = rtms.Client(executor=executor) + client.on_audio_data(run_asr_model) # dispatched to thread pool + client.on_transcript_data(write_to_database) # dispatched to thread pool + client.join(payload['payload']) + +rtms.run() +``` + +- **Event loop**: `rtms.run()` — poll loop stays fast; callbacks run in the pool +- **Thread pool size**: tune `max_workers` to match your callback latency profile +- **Back-pressure**: executor queue depth limits unbounded growth + +### Layer 3 — asyncio native + +Appropriate when the rest of your stack is already async (aiohttp, FastAPI, asyncpg). Callbacks declared as `async def` are auto-scheduled on the running event loop. + +```python +import asyncio +import rtms + +async def on_transcript(data, size, timestamp, metadata): + await db.execute('INSERT INTO transcripts VALUES ($1, $2)', metadata.userId, data) + +@rtms.on_webhook_event +def handle(payload): + if payload.get('event') != 'meeting.rtms_started': + return + client = rtms.Client() + client.on_transcript_data(on_transcript) # coroutine auto-detected + client.join(payload['payload']) + +async def main(): + await asyncio.gather( + rtms.run_async(), + start_web_server(), + ) + +asyncio.run(main()) +``` + +- **Event loop**: `rtms.run_async()` — `await asyncio.sleep()` between polls, never blocks +- **Coroutine dispatch**: `asyncio.run_coroutine_threadsafe` bridges SDK callbacks to the loop +- **Composable**: runs alongside any other async service on the same loop + +### Layer 4 — Multi-process + +Appropriate for 50+ concurrent meetings. Each process runs its own `rtms.run()` loop; a load balancer routes webhook events to available workers. + +``` +Zoom → Load Balancer (nginx/HAProxy) + ├── Worker 0 (rtms.run(), meetings 0..N) + ├── Worker 1 (rtms.run(), meetings N..2N) + └── Worker 2 (rtms.run(), meetings 2N..3N) +``` + +Use a message queue (Redis pub/sub, RabbitMQ, Kafka) to distribute join requests: + +```python +# worker.py — one process per worker +import rtms, redis, json + +r = redis.Redis() +sub = r.pubsub() +sub.subscribe('rtms:join') + +@rtms.on_webhook_event +def handle(payload): + # Publish to queue; a free worker picks it up + r.publish('rtms:join', json.dumps(payload)) + +rtms.run() +``` + +### Choosing the Right Layer + +| Workload | Recommended Layer | +|---|---| +| Prototyping / light callbacks | Layer 1 — inline | +| CPU-bound (ASR, video) | Layer 2 — executor | +| Async framework (FastAPI, aiohttp) | Layer 3 — run_async | +| 50+ concurrent meetings | Layer 4 — multi-process | + +All layers use the same `Client` API. You can mix layers — e.g. Layer 3 for the web server plus Layer 2 executors for heavy callbacks — without any API changes. + +## Environment Setup + +```bash +python3 -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install python-dotenv rtms +``` + +Create a `.env` file: + +```bash +# Required +ZM_RTMS_CLIENT=your_client_id +ZM_RTMS_SECRET=your_client_secret + +# Optional +ZM_RTMS_PORT=8080 +ZM_RTMS_PATH=/webhook +ZM_RTMS_LOG_LEVEL=info # error, warn, info, debug, trace +ZM_RTMS_LOG_FORMAT=progressive # progressive or json +ZM_RTMS_LOG_ENABLED=true +``` + +## Related + +- [Node.js API Reference](node.md) +- [Full API Reference](https://zoom.github.io/rtms/py/) diff --git a/examples/zcc.md b/examples/zcc.md new file mode 100644 index 0000000..2086494 --- /dev/null +++ b/examples/zcc.md @@ -0,0 +1,105 @@ +# Zoom Contact Center (ZCC) + +Real-time media streaming from Zoom Contact Center voice engagements. + +## Overview + +The API is identical to Meetings. See the [Meetings Quick Start](meetings.md) for full examples. + +Key differences: +- Webhook event: `contact_center.voice_rtms_started` instead of `meeting.rtms_started` +- Stop event: `contact_center.voice_rtms_stopped` instead of `meeting.rtms_stopped` + +> **Audio Configuration:** This SDK uses different default audio parameters than the raw RTMS WebSocket protocol. See [Audio Configuration](meetings.md#audio-configuration) in the Meetings documentation for details. + +## Quick Start + +### Node.js + +The API is identical to Meetings. See the [Meetings Example](meetings.md) for full examples. + +```javascript +import rtms from "@zoom/rtms"; + +const clients = new Map(); + +rtms.onWebhookEvent(({ event, payload }) => { + const streamId = payload?.rtms_stream_id; + + if (event === "contact_center.voice_rtms_stopped") { + clients.get(streamId)?.leave(); + clients.delete(streamId); + return; + } + + if (event !== "contact_center.voice_rtms_started") return; + + const client = new rtms.Client(); + clients.set(streamId, client); + + client.onAudioData((data, size, timestamp, metadata) => { + console.log(`Contact center audio from ${metadata.userName}`); + }); + + client.onTranscriptData((data, size, timestamp, metadata) => { + console.log(`[${timestamp}] ${metadata.userName}: ${data}`); + }); + + client.join(payload); +}); +``` + +### Python + +The API is identical to Meetings. See the [Meetings Python Quick Start](meetings.md) for full examples. + +```python +import rtms + +clients = {} + +@rtms.on_webhook_event +def handle_webhook(payload): + event = payload.get('event', '') + rtms_payload = payload.get('payload', {}) + stream_id = rtms_payload.get('rtms_stream_id') + + if event == 'contact_center.voice_rtms_stopped': + client = clients.pop(stream_id, None) + if client: + client.leave() + return + + if event != 'contact_center.voice_rtms_started': + return + + client = rtms.Client() + clients[stream_id] = client + + @client.on_audio_data + def on_audio(data, size, timestamp, metadata): + print(f'Contact center audio from {metadata.userName}: {size} bytes') + + @client.on_transcript_data + def on_transcript(data, size, timestamp, metadata): + print(f'[{timestamp}] {metadata.userName}: {data.decode()}') + + client.join(rtms_payload) + +rtms.run() +``` + +## Use Cases + +- **Compliance recording** — Archive voice engagement audio and transcripts for regulatory requirements +- **Real-time transcription** — Provide live captions or searchable transcripts for agents and supervisors +- **Sentiment analysis** — Detect customer sentiment during calls to surface coaching opportunities +- **Agent assist** — Feed transcripts to an LLM to suggest responses or retrieve knowledge base articles in real-time +- **Quality assurance** — Automatically score call quality against defined criteria + +## Related Documentation + +- [Meetings Examples](meetings.md) — Full API examples (same API) +- [Node.js API Reference](node.md) — Webhook validation and advanced patterns +- [Python API Reference](python.md) — asyncio, executor dispatch, and more +- [Main README](../README.md) diff --git a/index.ts b/index.ts index 1634554..f829719 100644 --- a/index.ts +++ b/index.ts @@ -5,9 +5,9 @@ import { createHmac } from 'crypto'; import { createRequire } from 'module'; import { IncomingMessage, Server, ServerResponse } from 'http'; -import type { +import type { JoinParams, SignatureParams, WebhookCallback, RawWebhookCallback, - VideoParams, AudioParams, DeskshareParams + VideoParams, AudioParams, DeskshareParams, TranscriptParams } from "./rtms.d.ts"; const require = createRequire(import.meta.url); @@ -975,6 +975,7 @@ class Client extends nativeRtms.Client { meeting_uuid, webinar_uuid, session_id, + engagement_id, rtms_stream_id, server_urls, signature: providedSignature, @@ -984,12 +985,14 @@ class Client extends nativeRtms.Client { pollInterval = 0 } = options; - // Use meeting_uuid for Meeting SDK, webinar_uuid for Webinar, session_id for Video SDK - const instance_id = meeting_uuid || webinar_uuid || session_id; + // Use meeting_uuid for Meeting SDK, webinar_uuid for Webinar, + // session_id for Video SDK, engagement_id for ZCC + const instance_id = meeting_uuid || webinar_uuid || session_id || engagement_id; this.pollRate = pollInterval; - Logger.info('client', `Joining ${meeting_uuid ? 'meeting' : webinar_uuid ? 'webinar' : 'session'}: ${instance_id}`, { + const sessionType = meeting_uuid ? 'meeting' : webinar_uuid ? 'webinar' : engagement_id ? 'engagement' : 'session'; + Logger.info('client', `Joining ${sessionType}: ${instance_id}`, { streamId: rtms_stream_id, serverUrls: server_urls, timeout: providedTimeout, @@ -997,7 +1000,7 @@ class Client extends nativeRtms.Client { }); if (!instance_id) { - throw new Error('Either meeting_uuid, webinar_uuid, or session_id must be provided'); + throw new Error('Either meeting_uuid, webinar_uuid, session_id, or engagement_id must be provided'); } const finalSignature = providedSignature || generateSignature({ @@ -1052,17 +1055,45 @@ class Client extends nativeRtms.Client { ); } - /** - * Sets deskshare parameters for the client - */ - setDeskshareParams(params: DeskshareParams): boolean { - return setParameters( - 'client', - 'deskshare', - params, - (p) => super.setDeskshareParams(p) - ); - } + /** + * Sets deskshare parameters for the client + */ + setDeskshareParams(params: DeskshareParams): boolean { + return setParameters( + 'client', + 'deskshare', + params, + (p) => super.setDeskshareParams(p) + ); + } + + /** + * Sets transcript parameters for the client + */ + setTranscriptParams(params: TranscriptParams): boolean { + return setParameters( + 'client', + 'transcript', + params, + (p) => super.setTranscriptParams(p) + ); + } + + setProxy(proxy_type: string, proxy_url: string): boolean { + return super.setProxy(proxy_type, proxy_url); + } + + subscribeVideo(userId: number, subscribe: boolean): boolean { + return super.subscribeVideo(userId, subscribe); + } + + onParticipantVideo(callback: (users: number[], isOn: boolean) => void): boolean { + return super.onParticipantVideo(callback); + } + + onVideoSubscribed(callback: (userId: number, status: number, error: string) => void): boolean { + return super.onVideoSubscribed(callback); + } /** * Register a callback for participant join/leave events diff --git a/jest.config.cjs b/jest.config.cjs new file mode 100644 index 0000000..973db0b --- /dev/null +++ b/jest.config.cjs @@ -0,0 +1,8 @@ +/** @type {import('jest').Config} */ +module.exports = { + transform: { + '^.+\\.tsx?$': ['ts-jest', { tsconfig: { module: 'commonjs' } }], + '^.+\\.jsx?$': 'babel-jest', + }, + testEnvironment: 'node', +}; diff --git a/package-lock.json b/package-lock.json index 72fdfc8..bc229cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@zoom/rtms", - "version": "1.0.2", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@zoom/rtms", - "version": "1.0.2", + "version": "1.1.0", "cpu": [ "x64", "arm64" @@ -26,6 +26,7 @@ "devDependencies": { "@types/jest": "^30.0.0", "cmake-js": "^8.0.0", + "ts-jest": "^29.4.6", "typedoc": "^0.28.14", "typedoc-plugin-missing-exports": "^4.1.2", "typescript": "^5.9.3" @@ -35,9 +36,9 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", "dependencies": { @@ -49,885 +50,3507 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, - "node_modules/@gerrit0/mini-shiki": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.21.0.tgz", - "integrity": "sha512-9PrsT5DjZA+w3lur/aOIx3FlDeHdyCEFlv9U+fmsVyjPZh61G5SYURQ/1ebe2U63KbDmI2V8IhIUegWb8hjOyg==", + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@shikijs/engine-oniguruma": "^3.21.0", - "@shikijs/langs": "^3.21.0", - "@shikijs/themes": "^3.21.0", - "@shikijs/types": "^3.21.0", - "@shikijs/vscode-textmate": "^10.0.2" + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" } }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "node_modules/@babel/core/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", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" + "bin": { + "semver": "bin/semver.js" } }, - "node_modules/@jest/diff-sequences": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", - "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=6.9.0" } }, - "node_modules/@jest/expect-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", - "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0" + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=6.9.0" } }, - "node_modules/@jest/get-type": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", - "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "node_modules/@babel/helper-compilation-targets/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/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", "dev": true, "license": "MIT", "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=6.9.0" } }, - "node_modules/@jest/pattern": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", - "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*", - "jest-regex-util": "30.0.1" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=6.9.0" } }, - "node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", "dependencies": { - "@sinclair/typebox": "^0.34.0" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "dev": true, "license": "MIT", - "dependencies": { - "@jest/pattern": "30.0.1", - "@jest/schemas": "30.0.5", - "@types/istanbul-lib-coverage": "^2.0.6", - "@types/istanbul-reports": "^3.0.4", - "@types/node": "*", - "@types/yargs": "^17.0.33", - "chalk": "^4.1.2" - }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=6.9.0" } }, - "node_modules/@shikijs/engine-oniguruma": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.21.0.tgz", - "integrity": "sha512-OYknTCct6qiwpQDqDdf3iedRdzj6hFlOPv5hMvI+hkWfCKs5mlJ4TXziBG9nyabLwGulrUjHiCq3xCspSzErYQ==", + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "license": "MIT", - "dependencies": { - "@shikijs/types": "3.21.0", - "@shikijs/vscode-textmate": "^10.0.2" + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@shikijs/langs": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.21.0.tgz", - "integrity": "sha512-g6mn5m+Y6GBJ4wxmBYqalK9Sp0CFkUqfNzUy2pJglUginz6ZpWbaWjDB4fbQ/8SHzFjYbtU6Ddlp1pc+PPNDVA==", + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", - "dependencies": { - "@shikijs/types": "3.21.0" + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@shikijs/themes": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.21.0.tgz", - "integrity": "sha512-BAE4cr9EDiZyYzwIHEk7JTBJ9CzlPuM4PchfcA5ao1dWXb25nv6hYsoDiBq2aZK9E3dlt3WB78uI96UESD+8Mw==", + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", - "dependencies": { - "@shikijs/types": "3.21.0" + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@shikijs/types": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.21.0.tgz", - "integrity": "sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA==", + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "dev": true, "license": "MIT", "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@shikijs/vscode-textmate": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sinclair/typebox": { - "version": "0.34.48", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", - "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", "dev": true, "license": "MIT", "dependencies": { - "@types/unist": "*" + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", "dev": true, "license": "MIT", "dependencies": { - "@types/istanbul-lib-coverage": "*" + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", "dev": true, "license": "MIT", "dependencies": { - "@types/istanbul-lib-report": "*" + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@types/jest": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", - "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", "dev": true, "license": "MIT", "dependencies": { - "expect": "^30.0.0", - "pretty-format": "^30.0.0" + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@types/node": { - "version": "25.0.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", - "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/@types/yargs": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", - "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", "dev": true, "license": "MIT", "dependencies": { - "@types/yargs-parser": "*" + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "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==", + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "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==", + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@gerrit0/mini-shiki": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.21.0.tgz", + "integrity": "sha512-9PrsT5DjZA+w3lur/aOIx3FlDeHdyCEFlv9U+fmsVyjPZh61G5SYURQ/1ebe2U63KbDmI2V8IhIUegWb8hjOyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-oniguruma": "^3.21.0", + "@shikijs/langs": "^3.21.0", + "@shikijs/themes": "^3.21.0", + "@shikijs/types": "^3.21.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "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/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/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/@isaacs/cliui/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/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/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/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.3.0.tgz", + "integrity": "sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.3.0.tgz", + "integrity": "sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.3.0", + "@jest/pattern": "30.0.1", + "@jest/reporters": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.3.0", + "jest-config": "30.3.0", + "jest-haste-map": "30.3.0", + "jest-message-util": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.3.0", + "jest-resolve-dependencies": "30.3.0", + "jest-runner": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "jest-watcher": "30.3.0", + "pretty-format": "30.3.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", + "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.3.0.tgz", + "integrity": "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "jest-mock": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "30.3.0", + "jest-snapshot": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.3.0.tgz", + "integrity": "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.3.0.tgz", + "integrity": "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@sinonjs/fake-timers": "^15.0.0", + "@types/node": "*", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.3.0.tgz", + "integrity": "sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/expect": "30.3.0", + "@jest/types": "30.3.0", + "jest-mock": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.3.0.tgz", + "integrity": "sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.3.0.tgz", + "integrity": "sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.3.0.tgz", + "integrity": "sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.3.0", + "@jest/types": "30.3.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.3.0.tgz", + "integrity": "sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.3.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.3.0.tgz", + "integrity": "sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.3.0", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-util": "30.3.0", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "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/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "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/@shikijs/engine-oniguruma": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.21.0.tgz", + "integrity": "sha512-OYknTCct6qiwpQDqDdf3iedRdzj6hFlOPv5hMvI+hkWfCKs5mlJ4TXziBG9nyabLwGulrUjHiCq3xCspSzErYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.21.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.21.0.tgz", + "integrity": "sha512-g6mn5m+Y6GBJ4wxmBYqalK9Sp0CFkUqfNzUy2pJglUginz6ZpWbaWjDB4fbQ/8SHzFjYbtU6Ddlp1pc+PPNDVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.21.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.21.0.tgz", + "integrity": "sha512-BAE4cr9EDiZyYzwIHEk7JTBJ9CzlPuM4PchfcA5ao1dWXb25nv6hYsoDiBq2aZK9E3dlt3WB78uI96UESD+8Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.21.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.21.0.tgz", + "integrity": "sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.2.0.tgz", + "integrity": "sha512-+SM3gQi95RWZLlD+Npy/UC5mHftlXwnVJMRpMyiqjrF4yNnbvi/Ubh3x9sLw6gxWSuibOn00uiLu1CKozehWlQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/node": { + "version": "25.0.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", + "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/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/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/babel-jest": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", + "integrity": "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.3.0", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.3.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.3.0.tgz", + "integrity": "sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.3.0.tgz", + "integrity": "sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.3.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, + "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/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.3.tgz", + "integrity": "sha512-9+kwVx8QYvt3hPWnmb19tPnh38c6Nihz8Lx3t0g9+4GoIf3/fTgYwM4Z6NxgI+B9elLQA7mLE9PpqcWtOMRDiQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", + "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "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/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001782", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", + "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "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/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cmake-js": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-8.0.0.tgz", + "integrity": "sha512-YbUP88RDwCvoQkZhRtGURYm9RIpWdtvZuhT87fKNoLjk8kIFIFeARpKfuZQGdwfH99GZpUmqSfcDrK62X7lTgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "fs-extra": "^11.3.3", + "node-api-headers": "^1.8.0", + "rc": "1.2.8", + "semver": "^7.7.3", + "tar": "^7.5.6", + "url-join": "^4.0.1", + "which": "^6.0.0", + "yargs": "^17.7.2" + }, + "bin": { + "cmake-js": "bin/cmake-js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "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/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "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/cross-spawn/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/cross-spawn/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/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/electron-to-chromium": { + "version": "1.5.329", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.329.tgz", + "integrity": "sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "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/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" }, "engines": { - "node": ">=8" + "node": ">=4" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sindresorhus/execa?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==", + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true, - "license": "Python-2.0" + "license": "ISC" }, - "node_modules/b4a": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", - "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.3.0", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "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==", + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "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/bare-events": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", - "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } + "engines": { + "node": ">=8" } }, - "node_modules/bare-fs": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.3.tgz", - "integrity": "sha512-9+kwVx8QYvt3hPWnmb19tPnh38c6Nihz8Lx3t0g9+4GoIf3/fTgYwM4Z6NxgI+B9elLQA7mLE9PpqcWtOMRDiQ==", - "license": "Apache-2.0", - "optional": true, + "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": { - "bare-events": "^2.5.4", - "bare-path": "^3.0.0", - "bare-stream": "^2.6.4", - "bare-url": "^2.2.2", - "fast-fifo": "^1.3.2" + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" }, "engines": { - "bare": ">=1.16.0" + "node": ">=14" }, - "peerDependencies": { - "bare-buffer": "*" + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs-extra": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", + "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - } + "engines": { + "node": ">=14.14" } }, - "node_modules/bare-os": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", - "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", - "license": "Apache-2.0", + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "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": { - "bare": ">=1.14.0" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", - "license": "Apache-2.0", - "optional": true, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", "dependencies": { - "bare-os": "^3.0.1" + "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/bare-stream": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", - "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", - "license": "Apache-2.0", - "optional": true, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", "dependencies": { - "streamx": "^2.21.0" + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" }, - "peerDependencies": { - "bare-buffer": "*", - "bare-events": "*" + "bin": { + "handlebars": "bin/handlebars" }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - }, - "bare-events": { - "optional": true - } + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" } }, - "node_modules/bare-url": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", - "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "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/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-path": "^3.0.0" + "engines": { + "node": ">=10.17.0" } }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, "license": "MIT", "dependencies": { - "file-uri-to-path": "1.0.0" + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "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==", + "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/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", "dependencies": { - "balanced-match": "^1.0.0" + "once": "^1.3.0", + "wrappy": "1" } }, - "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==", + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "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", - "dependencies": { - "fill-range": "^7.1.1" - }, "engines": { "node": ">=8" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", "dev": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, "engines": { - "node": ">=10" + "node": ">=6" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "license": "BlueOak-1.0.0", + "node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "dev": true, + "license": "ISC", "engines": { - "node": ">=18" + "node": ">=16" } }, - "node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", + "license": "BSD-3-Clause", "engines": { "node": ">=8" } }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", "dev": true, - "license": "ISC", + "license": "BSD-3-Clause", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" }, "engines": { - "node": ">=12" + "node": ">=10" } }, - "node_modules/cmake-js": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-8.0.0.tgz", - "integrity": "sha512-YbUP88RDwCvoQkZhRtGURYm9RIpWdtvZuhT87fKNoLjk8kIFIFeARpKfuZQGdwfH99GZpUmqSfcDrK62X7lTgg==", + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "debug": "^4.4.3", - "fs-extra": "^11.3.3", - "node-api-headers": "^1.8.0", - "rc": "1.2.8", - "semver": "^7.7.3", - "tar": "^7.5.6", - "url-join": "^4.0.1", - "which": "^6.0.0", - "yargs": "^17.7.2" + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" }, - "bin": { - "cmake-js": "bin/cmake-js" + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=10" } }, - "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==", + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "color-name": "~1.1.4" + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" }, "engines": { - "node": ">=7.0.0" + "node": ">=8" } }, - "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==", + "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": "MIT" + "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/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/jest": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.3.0.tgz", + "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "ms": "^2.1.3" + "@jest/core": "30.3.0", + "@jest/types": "30.3.0", + "import-local": "^3.2.0", + "jest-cli": "30.3.0" + }, + "bin": { + "jest": "bin/jest.js" }, "engines": { - "node": ">=6.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "peerDependenciesMeta": { - "supports-color": { + "node-notifier": { "optional": true } } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "node_modules/jest-changed-files": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.3.0.tgz", + "integrity": "sha512-B/7Cny6cV5At6M25EWDgf9S617lHivamL8vl6KEpJqkStauzcG4e+WPfDgMMF+H4FVH4A2PLRyvgDJan4441QA==", + "dev": true, "license": "MIT", "dependencies": { - "mimic-response": "^3.1.0" + "execa": "^5.1.1", + "jest-util": "30.3.0", + "p-limit": "^3.1.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "node_modules/jest-circus": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.3.0.tgz", + "integrity": "sha512-PyXq5szeSfR/4f1lYqCmmQjh0vqDkURUYi9N6whnHjlRz4IUQfMcXkGLeEoiJtxtyPqgUaUUfyQlApXWBSN1RA==", + "dev": true, "license": "MIT", + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/expect": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.3.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "p-limit": "^3.1.0", + "pretty-format": "30.3.0", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, "engines": { - "node": ">=4.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", + "node_modules/jest-cli": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.3.0.tgz", + "integrity": "sha512-l6Tqx+j1fDXJEW5bqYykDQQ7mQg+9mhWXtnj+tQZrTWYHyHoi6Be8HPumDSA+UiX2/2buEgjA58iJzdj146uCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "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==", + "node_modules/jest-config": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.3.0.tgz", + "integrity": "sha512-WPMAkMAtNDY9P/oKObtsRG/6KTrhtgPJoBTmk20uDn4Uy6/3EJnnaZJre/FMT1KVRx8cve1r7/FlMIOfRVWL4w==", "dev": true, - "license": "MIT" - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", "dependencies": { - "once": "^1.4.0" + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.0.1", + "@jest/test-sequencer": "30.3.0", + "@jest/types": "30.3.0", + "babel-jest": "30.3.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-circus": "30.3.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.3.0", + "jest-runner": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "parse-json": "^5.2.0", + "pretty-format": "30.3.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } } }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "node_modules/jest-config/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": "BSD-2-Clause", + "license": "MIT", "engines": { - "node": ">=0.12" + "node": ">=8" }, "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-diff": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", + "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.3.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/jest-docblock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", + "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", "dev": true, "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, "engines": { - "node": ">=6" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "node_modules/jest-each": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.3.0.tgz", + "integrity": "sha512-V8eMndg/aZ+3LnCJgSm13IxS5XSBM22QSZc9BtPK8Dek6pm+hfUNfwBdvsB3d342bo1q7wnSkC38zjX259qZNA==", "dev": true, "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.3.0", + "chalk": "^4.1.2", + "jest-util": "30.3.0", + "pretty-format": "30.3.0" + }, "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "license": "Apache-2.0", + "node_modules/jest-environment-node": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.3.0.tgz", + "integrity": "sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ==", + "dev": true, + "license": "MIT", "dependencies": { - "bare-events": "^2.7.0" + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "jest-mock": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", + "node_modules/jest-haste-map": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.3.0.tgz", + "integrity": "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", + "picomatch": "^4.0.3", + "walker": "^1.0.8" + }, "engines": { - "node": ">=6" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" } }, - "node_modules/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", + "node_modules/jest-leak-detector": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.3.0.tgz", + "integrity": "sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.2.0", "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "license": "MIT" - }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, - "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==", + "node_modules/jest-matcher-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz", + "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==", "dev": true, "license": "MIT", "dependencies": { - "to-regex-range": "^5.0.1" + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.3.0", + "pretty-format": "30.3.0" }, "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/fs-extra": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", - "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", + "node_modules/jest-message-util": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", + "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", "dev": true, "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.3.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3", + "pretty-format": "30.3.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" }, "engines": { - "node": ">=14.14" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "node_modules/jest-mock": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz", + "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==", "dev": true, - "license": "ISC", + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "jest-util": "30.3.0" + }, "engines": { - "node": "6.* || 8.* || >= 10.*" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", "dev": true, - "license": "ISC" + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } }, - "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==", + "node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, - "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==", + "node_modules/jest-resolve": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.3.0.tgz", + "integrity": "sha512-NRtTAHQlpd15F9rUR36jqwelbrDV/dY4vzNte3S2kxCKUJRYNd5/6nTSbYiak1VX5g8IoFF23Uj5TURkUW8O5g==", "dev": true, "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "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==", + "node_modules/jest-resolve-dependencies": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.3.0.tgz", + "integrity": "sha512-9ev8s3YN6Hsyz9LV75XUwkCVFlwPbaFn6Wp75qnI0wzAINYWY8Fb3+6y59Rwd3QaS3kKXffHXsZMziMavfz/nw==", "dev": true, "license": "MIT", + "dependencies": { + "jest-regex-util": "30.0.1", + "jest-snapshot": "30.3.0" + }, "engines": { - "node": ">=0.12.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "node_modules/jest-runner": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.3.0.tgz", + "integrity": "sha512-gDv6C9LGKWDPLia9TSzZwf4h3kMQCqyTpq+95PODnTRDO0g9os48XIYYkS6D236vjpBir2fF63YmJFtqkS5Duw==", "dev": true, - "license": "ISC", + "license": "MIT", + "dependencies": { + "@jest/console": "30.3.0", + "@jest/environment": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.3.0", + "jest-haste-map": "30.3.0", + "jest-leak-detector": "30.3.0", + "jest-message-util": "30.3.0", + "jest-resolve": "30.3.0", + "jest-runtime": "30.3.0", + "jest-util": "30.3.0", + "jest-watcher": "30.3.0", + "jest-worker": "30.3.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, "engines": { - "node": ">=16" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-diff": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", - "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "node_modules/jest-runtime": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.3.0.tgz", + "integrity": "sha512-CgC+hIBJbuh78HEffkhNKcbXAytQViplcl8xupqeIWyKQF50kCQA8J7GeJCkjisC6hpnC9Muf8jV5RdtdFbGng==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.0.1", - "@jest/get-type": "30.1.0", + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/globals": "30.3.0", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", "chalk": "^4.1.2", - "pretty-format": "30.2.0" + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-matcher-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", - "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", + "node_modules/jest-snapshot": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.3.0.tgz", + "integrity": "sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==", "dev": true, "license": "MIT", "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.3.0", "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", - "jest-diff": "30.2.0", - "pretty-format": "30.2.0" + "expect": "30.3.0", + "graceful-fs": "^4.2.11", + "jest-diff": "30.3.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "pretty-format": "30.3.0", + "semver": "^7.7.2", + "synckit": "^0.11.8" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "node_modules/jest-util": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", - "@types/stack-utils": "^2.0.3", + "@jest/types": "30.3.0", + "@types/node": "*", "chalk": "^4.1.2", + "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-mock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", - "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "node_modules/jest-validate": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.3.0.tgz", + "integrity": "sha512-I/xzC8h5G+SHCb2P2gWkJYrNiTbeL47KvKeW5EzplkyxzBRBw1ssSHlI/jXec0ukH2q7x2zAWQm7015iusg62Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "jest-util": "30.2.0" + "@jest/get-type": "30.1.0", + "@jest/types": "30.3.0", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-regex-util": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", - "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "node_modules/jest-watcher": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.3.0.tgz", + "integrity": "sha512-PJ1d9ThtTR8aMiBWUdcownq9mDdLXsQzJayTk4kmaBRHKvwNQn+ANveuhEBUyNI2hR1TVhvQ8D5kHubbzBHR/w==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", + "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "emittery": "^0.13.1", + "jest-util": "30.3.0", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.3.0.tgz", + "integrity": "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.3.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -935,6 +3558,63 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/js-yaml/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", @@ -948,6 +3628,23 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, "node_modules/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", @@ -958,6 +3655,43 @@ "uc.micro": "^2.0.0" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lru-cache/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/lunr": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", @@ -965,10 +3699,43 @@ "dev": true, "license": "MIT" }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, "node_modules/markdown-it": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", - "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", "dev": true, "license": "MIT", "dependencies": { @@ -990,31 +3757,21 @@ "dev": true, "license": "MIT" }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } + "license": "MIT" }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">=6" } }, "node_modules/mimic-response": { @@ -1030,13 +3787,13 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -1094,6 +3851,36 @@ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", "license": "MIT" }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "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/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-abi": { "version": "3.87.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", @@ -1122,6 +3909,43 @@ "dev": true, "license": "MIT" }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1131,6 +3955,157 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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-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/path-scurry/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/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1139,9 +4114,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -1151,6 +4126,29 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -1178,9 +4176,9 @@ } }, "node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1225,6 +4223,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -1257,6 +4272,29 @@ "node": ">=0.10.0" } }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1277,16 +4315,52 @@ ], "license": "MIT" }, - "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/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", - "bin": { - "semver": "bin/semver.js" - }, "engines": { - "node": ">=10" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/simple-concat": { @@ -1344,6 +4418,34 @@ "node": ">=8" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -1368,6 +4470,20 @@ "text-decoder": "^1.1.0" } }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -1383,6 +4499,22 @@ "node": ">=8" } }, + "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/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -1396,6 +4528,40 @@ "node": ">=8" } }, + "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-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -1418,10 +4584,26 @@ "node": ">=8" } }, + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "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/tar": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", - "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -1459,6 +4641,67 @@ "streamx": "^2.15.0" } }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/text-decoder": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", @@ -1468,19 +4711,87 @@ "b4a": "^1.6.4" } }, - "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==", + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/ts-jest": { + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", "dev": true, "license": "MIT", "dependencies": { - "is-number": "^7.0.0" + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.3", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <6" }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=8.0" + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -1493,6 +4804,29 @@ "node": "*" } }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typedoc": { "version": "0.28.16", "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.16.tgz", @@ -1550,6 +4884,20 @@ "dev": true, "license": "MIT" }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -1567,6 +4915,72 @@ "node": ">= 10.0.0" } }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/url-join": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", @@ -1574,6 +4988,31 @@ "dev": true, "license": "MIT" }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, "node_modules/which": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/which/-/which-6.0.0.tgz", @@ -1590,6 +5029,13 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -1608,12 +5054,45 @@ "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/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -1634,9 +5113,9 @@ } }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "dev": true, "license": "ISC", "bin": { @@ -1677,6 +5156,19 @@ "engines": { "node": ">=12" } + }, + "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/package.json b/package.json index b0d9248..a8a8d92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@zoom/rtms", - "version": "1.0.3", + "version": "1.1.0", "description": "Node.js Wrapper for the Zoom RTMS C SDK", "main": "./build/Release/index.js", "types": "rtms.d.ts", @@ -17,6 +17,7 @@ "devDependencies": { "@types/jest": "^30.0.0", "cmake-js": "^8.0.0", + "ts-jest": "^29.4.6", "typedoc": "^0.28.14", "typedoc-plugin-missing-exports": "^4.1.2", "typescript": "^5.9.3" @@ -80,6 +81,6 @@ ], "license": "MIT", "engines": { - "node": ">=20.3.0" + "node": ">=22.0.0" } } diff --git a/pyproject.toml b/pyproject.toml index a20ffa2..8154433 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "scikit_build_core.build" [project] name = "rtms" -version = "1.0.3" +version = "1.1.0" description = "Python bindings for the Zoom RTMS C SDK - Real-Time Media Streaming" readme = "README.md" requires-python = ">=3.10" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..91379c0 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,29 @@ +# Dev/build dependencies pinned to non-vulnerable versions. +# Audit: pip-audit found CVE-2026-4539 in pygments<2.20.0 + +# Build +build==1.3.0 +scikit_build_core==0.11.6 +pybind11==3.0.1 +pybind11-global==3.0.1 +wheel==0.46.3 + +# Testing +pytest==9.0.2 +python-dotenv==1.2.1 + +# Publishing +twine==6.2.0 + +# Multi-version wheel building +cibuildwheel==3.3.1 + +# Linux wheel repair (Linux only) +auditwheel==6.3.0 + +# Documentation +pdoc3==0.11.6 +Pygments>=2.20.0 # CVE-2026-4539 fixed in 2.20.0 + +# Auditing +pip-audit==2.10.0 diff --git a/rtms.d.ts b/rtms.d.ts index 1cda6e1..2bb8e3f 100644 --- a/rtms.d.ts +++ b/rtms.d.ts @@ -183,9 +183,43 @@ export interface MediaTypes { transcript?: boolean; } +/** + * A single target language entry from an AI interpreter stream + * + * @category Data Interfaces + */ +export interface AiTargetLanguage { + /** Language ID of the target language */ + lid: number; + /** Tone ID (dialect/variant) of the target language */ + toneId: number; + /** Voice model identifier used to render this target language */ + voiceId: string; + /** AI engine name (e.g. "zoom_ai") */ + engine: string; +} + +/** + * AI interpreter metadata attached to an audio stream + * + * @category Data Interfaces + */ +export interface AiInterpreter { + /** Language ID of the source language being interpreted */ + lid: number; + /** Timestamp of the interpretation event */ + timestamp: number; + /** Number of audio channels in the interpreter stream */ + channelNum: number; + /** Sample rate of the interpreter stream in Hz */ + sampleRate: number; + /** Active target language entries (up to 100) */ + targets: AiTargetLanguage[]; +} + /** * Metadata information about a participant in a Zoom meeting - * + * * @category Data Interfaces */ export interface Metadata { @@ -193,6 +227,12 @@ export interface Metadata { userName: string; /** The user ID of the Zoom participant */ userId: number; + /** Stream start timestamp in milliseconds */ + startTs: number; + /** Stream end timestamp in milliseconds */ + endTs: number; + /** AI interpreter metadata (populated when Zoom's AI interpreter is active) */ + aiInterpreter: AiInterpreter; } /** @@ -296,6 +336,70 @@ export interface DeskshareParams { fps?: number; } +/** + * Configuration parameters for transcript streams + * + * @category Media Configuration + */ +export interface TranscriptParams { + /** Source language ID. Use TranscriptLanguage constants. Default: NONE (auto-detect after 30s) */ + srcLanguage?: number; + /** Enable Language Identification. Default: true */ + enableLid?: boolean; +} + +/** + * Language ID constants for use with TranscriptParams.srcLanguage + * + * @example + * ```typescript + * const params = new rtms.TranscriptParams(); + * params.setSrcLanguage(rtms.TranscriptLanguage.ENGLISH); + * ``` + * + * @category Constants + */ +export const TranscriptLanguage: { + readonly NONE: number; + readonly ARABIC: number; + readonly BENGALI: number; + readonly CANTONESE: number; + readonly CATALAN: number; + readonly CHINESE_SIMPLIFIED: number; + readonly CHINESE_TRADITIONAL: number; + readonly CZECH: number; + readonly DANISH: number; + readonly DUTCH: number; + readonly ENGLISH: number; + readonly ESTONIAN: number; + readonly FINNISH: number; + readonly FRENCH_CANADA: number; + readonly FRENCH_FRANCE: number; + readonly GERMAN: number; + readonly HEBREW: number; + readonly HINDI: number; + readonly HUNGARIAN: number; + readonly INDONESIAN: number; + readonly ITALIAN: number; + readonly JAPANESE: number; + readonly KOREAN: number; + readonly MALAY: number; + readonly PERSIAN: number; + readonly POLISH: number; + readonly PORTUGUESE: number; + readonly ROMANIAN: number; + readonly RUSSIAN: number; + readonly SPANISH: number; + readonly SWEDISH: number; + readonly TAGALOG: number; + readonly TAMIL: number; + readonly TELUGU: number; + readonly THAI: number; + readonly TURKISH: number; + readonly UKRAINIAN: number; + readonly VIETNAMESE: number; +}; + //----------------------------------------------------------------------------------- // Parameter interfaces @@ -318,6 +422,8 @@ export interface JoinParams { webinar_uuid?: string; /** The session ID (for Video SDK events) - used when meeting_uuid is not provided */ session_id?: string; + /** The engagement ID (for ZCC events) - used when meeting_uuid is not provided */ + engagement_id?: string; /** The RTMS stream ID for this connection */ rtms_stream_id: string; /** The server URL(s) to connect to */ @@ -761,7 +867,41 @@ export class Client { * @returns true if the operation succeeds */ setDeskshareParams(params: DeskshareParams): boolean; - + + /** + * Configures a proxy for SDK connections. + * + * @param proxy_type Proxy protocol type (e.g. `'http'`, `'https'`) + * @param proxy_url Full proxy URL including host and port + * @returns true if the operation succeeds + */ + setProxy(proxy_type: string, proxy_url: string): boolean; + + /** + * Subscribe or unsubscribe from an individual participant's video stream. + * + * @param userId The participant's user ID + * @param subscribe true to subscribe, false to unsubscribe + * @returns true if the operation succeeds + */ + subscribeVideo(userId: number, subscribe: boolean): boolean; + + /** + * Sets a callback fired when participants' video state changes. + * + * @param callback Invoked with an array of user IDs and a boolean indicating video on/off + * @returns true if registration succeeds + */ + onParticipantVideo(callback: (users: number[], isOn: boolean) => void): boolean; + + /** + * Sets a callback fired in response to a `subscribeVideo` call. + * + * @param callback Invoked with userId, status code, and an error string (empty on success) + * @returns true if registration succeeds + */ + onVideoSubscribed(callback: (userId: number, status: number, error: string) => void): boolean; + /** * Sets a callback for join confirmation events * @@ -976,16 +1116,8 @@ export class Client { * * @example * ```typescript - * client.onVideoData((buffer, size, timestamp, trackId, metadata) => { + * client.onVideoData((buffer, size, timestamp, metadata) => { * console.log(`Received ${size} bytes of video from ${metadata.userName}`); - * console.log(`Track ID: ${trackId}`); - * - * // Process the video data - * // buffer - Raw video data (Buffer) - * // size - Size of the video data in bytes - * // timestamp - Timestamp of the video data - * // trackId - ID of the video track - * // metadata - Information about the sender * }); * ``` */ diff --git a/scripts/install.js b/scripts/install.js index 1130af3..3cebbb5 100644 --- a/scripts/install.js +++ b/scripts/install.js @@ -87,8 +87,13 @@ function installPrebuild() { try { log('Attempting to download prebuilt binary...'); - // Use prebuild-install to download from GitHub releases - execSync('prebuild-install -r napi', { + // Use prebuild-install to download from GitHub releases. + // During `npm install`, npm augments PATH with node_modules/.bin/ so + // the bare command works. When called directly (e.g. CI with --force), + // PATH isn't augmented, so resolve from local node_modules/.bin/ if present. + const localBin = join(__dirname, '..', 'node_modules', '.bin', 'prebuild-install'); + const prebuildCmd = existsSync(localBin) ? localBin : 'prebuild-install'; + execSync(`${prebuildCmd} -r napi`, { stdio: 'inherit', env: process.env }); @@ -109,10 +114,12 @@ function installPrebuild() { * Main install function */ function main() { - // Detect development mode (git repo exists) + // Detect development mode (git repo exists). + // Pass --force to bypass this check (e.g. CI extracting a downloaded prebuild). const isDevelopment = existsSync(join(__dirname, '..', '.git')); + const isForced = process.argv.includes('--force'); - if (isDevelopment) { + if (isDevelopment && !isForced) { // In dev mode, skip prebuild - developers use `task build` process.exit(0); } diff --git a/src/node.cpp b/src/node.cpp index a4a4b97..dbc061e 100644 --- a/src/node.cpp +++ b/src/node.cpp @@ -11,6 +11,35 @@ using namespace Napi; using namespace std; +static Napi::Object buildMetadataObj(Napi::Env env, const rtms::Metadata& metadata) { + Napi::Object obj = Napi::Object::New(env); + obj.Set("userName", Napi::String::New(env, metadata.userName())); + obj.Set("userId", Napi::Number::New(env, metadata.userId())); + obj.Set("startTs", Napi::Number::New(env, static_cast(metadata.startTs()))); + obj.Set("endTs", Napi::Number::New(env, static_cast(metadata.endTs()))); + + const auto& aii = metadata.aiInterpreter(); + Napi::Object aiObj = Napi::Object::New(env); + aiObj.Set("lid", Napi::Number::New(env, aii.lid())); + aiObj.Set("timestamp", Napi::Number::New(env, static_cast(aii.timestamp()))); + aiObj.Set("channelNum", Napi::Number::New(env, aii.channelNum())); + aiObj.Set("sampleRate", Napi::Number::New(env, aii.sampleRate())); + + Napi::Array targets = Napi::Array::New(env, aii.targets().size()); + for (size_t i = 0; i < aii.targets().size(); ++i) { + const auto& t = aii.targets()[i]; + Napi::Object tObj = Napi::Object::New(env); + tObj.Set("lid", Napi::Number::New(env, t.lid())); + tObj.Set("toneId", Napi::Number::New(env, t.toneId())); + tObj.Set("voiceId", Napi::String::New(env, t.voiceId())); + tObj.Set("engine", Napi::String::New(env, t.engine())); + targets.Set(i, tObj); + } + aiObj.Set("targets", targets); + obj.Set("aiInterpreter", aiObj); + return obj; +} + class NodeClient : public Napi::ObjectWrap { public: static Napi::Object init(Napi::Env env, Napi::Object exports); @@ -34,6 +63,8 @@ class NodeClient : public Napi::ObjectWrap { Napi::Value setDeskshareParams(const Napi::CallbackInfo& info); Napi::Value setAudioParams(const Napi::CallbackInfo& info); Napi::Value setVideoParams(const Napi::CallbackInfo& info); + Napi::Value setTranscriptParams(const Napi::CallbackInfo& info); + Napi::Value setProxy(const Napi::CallbackInfo& info); Napi::Value setOnJoinConfirm(const Napi::CallbackInfo& info); Napi::Value setOnSessionUpdate(const Napi::CallbackInfo& info); @@ -48,6 +79,10 @@ class NodeClient : public Napi::ObjectWrap { Napi::Value subscribeEvent(const Napi::CallbackInfo& info); Napi::Value unsubscribeEvent(const Napi::CallbackInfo& info); + Napi::Value subscribeVideo(const Napi::CallbackInfo& info); + Napi::Value setOnParticipantVideo(const Napi::CallbackInfo& info); + Napi::Value setOnVideoSubscribed(const Napi::CallbackInfo& info); + unique_ptr client_; Napi::ThreadSafeFunction tsfn_join_confirm_; Napi::ThreadSafeFunction tsfn_session_update_; @@ -58,6 +93,8 @@ class NodeClient : public Napi::ObjectWrap { Napi::ThreadSafeFunction tsfn_transcript_data_; Napi::ThreadSafeFunction tsfn_leave_; Napi::ThreadSafeFunction tsfn_event_ex_; + Napi::ThreadSafeFunction tsfn_participant_video_; + Napi::ThreadSafeFunction tsfn_video_subscribed_; }; Napi::Value NodeClient::poll(const Napi::CallbackInfo& info) { @@ -263,6 +300,64 @@ Napi::Value NodeClient::setVideoParams(const Napi::CallbackInfo& info) { return Napi::Boolean::New(env, true); } +rtms::TranscriptParams readTranscriptParams(const Napi::Object& params) { + rtms::TranscriptParams transcript_params; + + if (params.Has("srcLanguage") && params.Get("srcLanguage").IsNumber()) { + transcript_params.setSrcLanguage(params.Get("srcLanguage").As().Int32Value()); + } + + if (params.Has("enableLid") && params.Get("enableLid").IsBoolean()) { + transcript_params.setEnableLid(params.Get("enableLid").As().Value()); + } + + return transcript_params; +} + +Napi::Value NodeClient::setTranscriptParams(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + Napi::HandleScope scope(env); + + if (info.Length() < 1 || !info[0].IsObject()) { + Napi::TypeError::New(env, "Object argument expected").ThrowAsJavaScriptException(); + return env.Null(); + } + + Napi::Object params = info[0].As(); + auto transcript_params = readTranscriptParams(params); + + try { + client_->setTranscriptParams(transcript_params); + } catch (const std::invalid_argument& e) { + Napi::Error::New(env, e.what()).ThrowAsJavaScriptException(); + return env.Null(); + } + + return Napi::Boolean::New(env, true); +} + +Napi::Value NodeClient::setProxy(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + Napi::HandleScope scope(env); + + if (info.Length() < 2 || !info[0].IsString() || !info[1].IsString()) { + Napi::TypeError::New(env, "Two string arguments expected: proxy_type, proxy_url").ThrowAsJavaScriptException(); + return env.Null(); + } + + std::string proxy_type = info[0].As().Utf8Value(); + std::string proxy_url = info[1].As().Utf8Value(); + + try { + client_->setProxy(proxy_type, proxy_url); + } catch (const rtms::Exception& e) { + Napi::Error::New(env, e.what()).ThrowAsJavaScriptException(); + return env.Null(); + } + + return Napi::Boolean::New(env, true); +} + Napi::Value NodeClient::setOnJoinConfirm(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); @@ -386,15 +481,10 @@ Napi::Value NodeClient::setOnDeskshareData(const Napi::CallbackInfo& info) { ); client_->setOnDeskshareData([this](const vector& data, uint64_t timestamp, const rtms::Metadata& metadata) { - auto callback = [data, timestamp, userName = metadata.userName(), userId = metadata.userId()] + auto callback = [data, timestamp, metadata] (Napi::Env env, Napi::Function jsCallback) { Napi::Buffer buffer = Napi::Buffer::Copy(env, data.data(), data.size()); - - Napi::Object metadataObj = Napi::Object::New(env); - metadataObj.Set("userName", Napi::String::New(env, userName)); - metadataObj.Set("userId", Napi::Number::New(env, userId)); - - jsCallback.Call({buffer, Napi::Number::New(env, data.size()), Napi::Number::New(env, timestamp), metadataObj}); + jsCallback.Call({buffer, Napi::Number::New(env, data.size()), Napi::Number::New(env, timestamp), buildMetadataObj(env, metadata)}); }; tsfn_ds_data_.BlockingCall(callback); }); @@ -422,15 +512,10 @@ Napi::Value NodeClient::setOnAudioData(const Napi::CallbackInfo& info) { ); client_->setOnAudioData([this](const vector& data, uint64_t timestamp, const rtms::Metadata& metadata) { - auto callback = [data, timestamp, userName = metadata.userName(), userId = metadata.userId()] + auto callback = [data, timestamp, metadata] (Napi::Env env, Napi::Function jsCallback) { Napi::Buffer buffer = Napi::Buffer::Copy(env, data.data(), data.size()); - - Napi::Object metadataObj = Napi::Object::New(env); - metadataObj.Set("userName", Napi::String::New(env, userName)); - metadataObj.Set("userId", Napi::Number::New(env, userId)); - - jsCallback.Call({buffer, Napi::Number::New(env, data.size()), Napi::Number::New(env, timestamp), metadataObj}); + jsCallback.Call({buffer, Napi::Number::New(env, data.size()), Napi::Number::New(env, timestamp), buildMetadataObj(env, metadata)}); }; tsfn_audio_data_.BlockingCall(callback); }); @@ -458,15 +543,10 @@ Napi::Value NodeClient::setOnVideoData(const Napi::CallbackInfo& info) { ); client_->setOnVideoData([this](const vector& data, uint64_t timestamp, const rtms::Metadata& metadata) { - auto callback = [data, timestamp, userName = metadata.userName(), userId = metadata.userId()] + auto callback = [data, timestamp, metadata] (Napi::Env env, Napi::Function jsCallback) { Napi::Buffer buffer = Napi::Buffer::Copy(env, data.data(), data.size()); - - Napi::Object metadataObj = Napi::Object::New(env); - metadataObj.Set("userName", Napi::String::New(env, userName)); - metadataObj.Set("userId", Napi::Number::New(env, userId)); - - jsCallback.Call({buffer, Napi::Number::New(env, data.size()), Napi::Number::New(env, timestamp), metadataObj}); + jsCallback.Call({buffer, Napi::Number::New(env, data.size()), Napi::Number::New(env, timestamp), buildMetadataObj(env, metadata)}); }; tsfn_video_data_.BlockingCall(callback); }); @@ -494,15 +574,10 @@ Napi::Value NodeClient::setOnTranscriptData(const Napi::CallbackInfo& info) { ); client_->setOnTranscriptData([this](const vector& data, uint64_t timestamp, const rtms::Metadata& metadata) { - auto callback = [data, timestamp, userName = metadata.userName(), userId = metadata.userId()] + auto callback = [data, timestamp, metadata] (Napi::Env env, Napi::Function jsCallback) { Napi::Buffer buffer = Napi::Buffer::Copy(env, data.data(), data.size()); - - Napi::Object metadataObj = Napi::Object::New(env); - metadataObj.Set("userName", Napi::String::New(env, userName)); - metadataObj.Set("userId", Napi::Number::New(env, userId)); - - jsCallback.Call({buffer, Napi::Number::New(env, data.size()), Napi::Number::New(env, timestamp), metadataObj}); + jsCallback.Call({buffer, Napi::Number::New(env, data.size()), Napi::Number::New(env, timestamp), buildMetadataObj(env, metadata)}); }; tsfn_transcript_data_.BlockingCall(callback); }); @@ -616,6 +691,93 @@ Napi::Value NodeClient::unsubscribeEvent(const Napi::CallbackInfo& info) { } } +Napi::Value NodeClient::subscribeVideo(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + Napi::HandleScope scope(env); + + if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsBoolean()) { + Napi::TypeError::New(env, "Two arguments expected: userId (number), subscribe (boolean)").ThrowAsJavaScriptException(); + return env.Null(); + } + + int user_id = info[0].As().Int32Value(); + bool subscribe = info[1].As().Value(); + + try { + client_->subscribeVideo(user_id, subscribe); + return Napi::Boolean::New(env, true); + } catch (const rtms::Exception& e) { + Napi::Error::New(env, e.what()).ThrowAsJavaScriptException(); + return env.Null(); + } +} + +Napi::Value NodeClient::setOnParticipantVideo(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + Napi::HandleScope scope(env); + + if (info.Length() < 1 || !info[0].IsFunction()) { + Napi::TypeError::New(env, "Function argument expected").ThrowAsJavaScriptException(); + return env.Null(); + } + + Napi::Function callback = info[0].As(); + + if (tsfn_participant_video_) { + tsfn_participant_video_.Release(); + } + + tsfn_participant_video_ = Napi::ThreadSafeFunction::New( + env, callback, "ParticipantVideoCallback", 0, 1 + ); + + client_->setOnParticipantVideo([this](const std::vector& users, bool is_on) { + auto callback = [users, is_on](Napi::Env env, Napi::Function jsCallback) { + Napi::Array arr = Napi::Array::New(env, users.size()); + for (size_t i = 0; i < users.size(); i++) { + arr.Set(static_cast(i), Napi::Number::New(env, users[i])); + } + jsCallback.Call({arr, Napi::Boolean::New(env, is_on)}); + }; + tsfn_participant_video_.BlockingCall(callback); + }); + + return Napi::Boolean::New(env, true); +} + +Napi::Value NodeClient::setOnVideoSubscribed(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + Napi::HandleScope scope(env); + + if (info.Length() < 1 || !info[0].IsFunction()) { + Napi::TypeError::New(env, "Function argument expected").ThrowAsJavaScriptException(); + return env.Null(); + } + + Napi::Function callback = info[0].As(); + + if (tsfn_video_subscribed_) { + tsfn_video_subscribed_.Release(); + } + + tsfn_video_subscribed_ = Napi::ThreadSafeFunction::New( + env, callback, "VideoSubscribedCallback", 0, 1 + ); + + client_->setOnVideoSubscribed([this](int user_id, int status, const std::string& error) { + auto callback = [user_id, status, error](Napi::Env env, Napi::Function jsCallback) { + jsCallback.Call({ + Napi::Number::New(env, user_id), + Napi::Number::New(env, status), + Napi::String::New(env, error) + }); + }; + tsfn_video_subscribed_.BlockingCall(callback); + }); + + return Napi::Boolean::New(env, true); +} + Napi::Value NodeClient::enableVideo(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); Napi::HandleScope scope(env); @@ -661,7 +823,7 @@ Napi::Value NodeClient::enableTranscript(const Napi::CallbackInfo& info) { return Napi::Boolean::New(env, true); } -NodeClient::NodeClient(const Napi::CallbackInfo& info) +NodeClient::NodeClient(const Napi::CallbackInfo& info) : Napi::ObjectWrap(info) { Napi::Env env = info.Env(); Napi::HandleScope scope(env); @@ -774,6 +936,8 @@ Napi::Object NodeClient::init(Napi::Env env, Napi::Object exports) { InstanceMethod("setDeskshareParams", &NodeClient::setDeskshareParams), InstanceMethod("setAudioParams", &NodeClient::setAudioParams), InstanceMethod("setVideoParams", &NodeClient::setVideoParams), + InstanceMethod("setTranscriptParams", &NodeClient::setTranscriptParams), + InstanceMethod("setProxy", &NodeClient::setProxy), InstanceMethod("onJoinConfirm", &NodeClient::setOnJoinConfirm), InstanceMethod("onSessionUpdate", &NodeClient::setOnSessionUpdate), InstanceMethod("onUserUpdate", &NodeClient::setOnUserUpdate), @@ -785,6 +949,9 @@ Napi::Object NodeClient::init(Napi::Env env, Napi::Object exports) { InstanceMethod("onEventEx", &NodeClient::setOnEventEx), InstanceMethod("subscribeEvent", &NodeClient::subscribeEvent), InstanceMethod("unsubscribeEvent", &NodeClient::unsubscribeEvent), + InstanceMethod("subscribeVideo", &NodeClient::subscribeVideo), + InstanceMethod("onParticipantVideo", &NodeClient::setOnParticipantVideo), + InstanceMethod("onVideoSubscribed", &NodeClient::setOnVideoSubscribed), }); Napi::FunctionReference* constructor = new Napi::FunctionReference(); @@ -810,21 +977,23 @@ Napi::Object NodeClient::init(Napi::Env env, Napi::Object exports) { exports.Set(Napi::String::New(env, "USER_LEAVE"), Napi::Number::New(env, USER_LEAVE)); // Event types for subscribeEvent/unsubscribeEvent (used with onEventEx callback) - // These match RTMS_EVENT_TYPE from Zoom's C SDK - exports.Set(Napi::String::New(env, "EVENT_UNDEFINED"), Napi::Number::New(env, rtms::Client::EVENT_UNDEFINED)); - exports.Set(Napi::String::New(env, "EVENT_FIRST_PACKET_TIMESTAMP"), Napi::Number::New(env, rtms::Client::EVENT_FIRST_PACKET_TIMESTAMP)); - exports.Set(Napi::String::New(env, "EVENT_ACTIVE_SPEAKER_CHANGE"), Napi::Number::New(env, rtms::Client::EVENT_ACTIVE_SPEAKER_CHANGE)); - exports.Set(Napi::String::New(env, "EVENT_PARTICIPANT_JOIN"), Napi::Number::New(env, rtms::Client::EVENT_PARTICIPANT_JOIN)); - exports.Set(Napi::String::New(env, "EVENT_PARTICIPANT_LEAVE"), Napi::Number::New(env, rtms::Client::EVENT_PARTICIPANT_LEAVE)); - exports.Set(Napi::String::New(env, "EVENT_SHARING_START"), Napi::Number::New(env, rtms::Client::EVENT_SHARING_START)); - exports.Set(Napi::String::New(env, "EVENT_SHARING_STOP"), Napi::Number::New(env, rtms::Client::EVENT_SHARING_STOP)); - exports.Set(Napi::String::New(env, "EVENT_MEDIA_CONNECTION_INTERRUPTED"), Napi::Number::New(env, rtms::Client::EVENT_MEDIA_CONNECTION_INTERRUPTED)); - exports.Set(Napi::String::New(env, "EVENT_CONSUMER_ANSWERED"), Napi::Number::New(env, rtms::Client::EVENT_CONSUMER_ANSWERED)); - exports.Set(Napi::String::New(env, "EVENT_CONSUMER_END"), Napi::Number::New(env, rtms::Client::EVENT_CONSUMER_END)); - exports.Set(Napi::String::New(env, "EVENT_USER_ANSWERED"), Napi::Number::New(env, rtms::Client::EVENT_USER_ANSWERED)); - exports.Set(Napi::String::New(env, "EVENT_USER_END"), Napi::Number::New(env, rtms::Client::EVENT_USER_END)); - exports.Set(Napi::String::New(env, "EVENT_USER_HOLD"), Napi::Number::New(env, rtms::Client::EVENT_USER_HOLD)); - exports.Set(Napi::String::New(env, "EVENT_USER_UNHOLD"), Napi::Number::New(env, rtms::Client::EVENT_USER_UNHOLD)); + // These match EVENT_TYPE from Zoom's C SDK + exports.Set(Napi::String::New(env, "EVENT_UNDEFINED"), Napi::Number::New(env, (int)rtms::EVENT_TYPE::UNDEFINED)); + exports.Set(Napi::String::New(env, "EVENT_FIRST_PACKET_TIMESTAMP"), Napi::Number::New(env, (int)rtms::EVENT_TYPE::FIRST_PACKET_TIMESTAMP)); + exports.Set(Napi::String::New(env, "EVENT_ACTIVE_SPEAKER_CHANGE"), Napi::Number::New(env, (int)rtms::EVENT_TYPE::ACTIVE_SPEAKER_CHANGE)); + exports.Set(Napi::String::New(env, "EVENT_PARTICIPANT_JOIN"), Napi::Number::New(env, (int)rtms::EVENT_TYPE::PARTICIPANT_JOIN)); + exports.Set(Napi::String::New(env, "EVENT_PARTICIPANT_LEAVE"), Napi::Number::New(env, (int)rtms::EVENT_TYPE::PARTICIPANT_LEAVE)); + exports.Set(Napi::String::New(env, "EVENT_SHARING_START"), Napi::Number::New(env, (int)rtms::EVENT_TYPE::SHARING_START)); + exports.Set(Napi::String::New(env, "EVENT_SHARING_STOP"), Napi::Number::New(env, (int)rtms::EVENT_TYPE::SHARING_STOP)); + exports.Set(Napi::String::New(env, "EVENT_MEDIA_CONNECTION_INTERRUPTED"), Napi::Number::New(env, (int)rtms::EVENT_TYPE::MEDIA_CONNECTION_INTERRUPTED)); + exports.Set(Napi::String::New(env, "EVENT_PARTICIPANT_VIDEO_ON"), Napi::Number::New(env, (int)rtms::EVENT_TYPE::PARTICIPANT_VIDEO_ON)); + exports.Set(Napi::String::New(env, "EVENT_PARTICIPANT_VIDEO_OFF"), Napi::Number::New(env, (int)rtms::EVENT_TYPE::PARTICIPANT_VIDEO_OFF)); + exports.Set(Napi::String::New(env, "EVENT_CONSUMER_ANSWERED"), Napi::Number::New(env, (int)rtms::ZCC_VOICE_EVENT_TYPE::CONSUMER_ANSWERED)); + exports.Set(Napi::String::New(env, "EVENT_CONSUMER_END"), Napi::Number::New(env, (int)rtms::ZCC_VOICE_EVENT_TYPE::CONSUMER_END)); + exports.Set(Napi::String::New(env, "EVENT_USER_ANSWERED"), Napi::Number::New(env, (int)rtms::ZCC_VOICE_EVENT_TYPE::USER_ANSWERED)); + exports.Set(Napi::String::New(env, "EVENT_USER_END"), Napi::Number::New(env, (int)rtms::ZCC_VOICE_EVENT_TYPE::USER_END)); + exports.Set(Napi::String::New(env, "EVENT_USER_HOLD"), Napi::Number::New(env, (int)rtms::ZCC_VOICE_EVENT_TYPE::USER_HOLD)); + exports.Set(Napi::String::New(env, "EVENT_USER_UNHOLD"), Napi::Number::New(env, (int)rtms::ZCC_VOICE_EVENT_TYPE::USER_UNHOLD)); exports.Set(Napi::String::New(env, "RTMS_SDK_FAILURE"), Napi::Number::New(env, RTMS_SDK_FAILURE)); exports.Set(Napi::String::New(env, "RTMS_SDK_OK"), Napi::Number::New(env, RTMS_SDK_OK)); @@ -839,178 +1008,244 @@ Napi::Object NodeClient::init(Napi::Env env, Napi::Object exports) { // ===== Audio Parameters ===== - // Audio Content Type + // Audio Content Type (MEDIA_CONTENT_TYPE) Napi::Object audioContentType = Napi::Object::New(env); - audioContentType.Set("UNDEFINED", Napi::Number::New(env, 0)); - audioContentType.Set("RTP", Napi::Number::New(env, 1)); - audioContentType.Set("RAW_AUDIO", Napi::Number::New(env, 2)); - audioContentType.Set("FILE_STREAM", Napi::Number::New(env, 4)); - audioContentType.Set("TEXT", Napi::Number::New(env, 5)); + audioContentType.Set("UNDEFINED", Napi::Number::New(env, (int)rtms::MEDIA_CONTENT_TYPE::UNDEFINED)); + audioContentType.Set("RTP", Napi::Number::New(env, (int)rtms::MEDIA_CONTENT_TYPE::RTP)); + audioContentType.Set("RAW_AUDIO", Napi::Number::New(env, (int)rtms::MEDIA_CONTENT_TYPE::RAW_AUDIO)); + audioContentType.Set("FILE_STREAM", Napi::Number::New(env, (int)rtms::MEDIA_CONTENT_TYPE::FILE_STREAM)); + audioContentType.Set("TEXT", Napi::Number::New(env, (int)rtms::MEDIA_CONTENT_TYPE::TEXT)); exports.Set("AudioContentType", audioContentType); - // Audio Codec + // Audio Codec (MEDIA_PAYLOAD_TYPE) Napi::Object audioCodec = Napi::Object::New(env); - audioCodec.Set("UNDEFINED", Napi::Number::New(env, 0)); - audioCodec.Set("L16", Napi::Number::New(env, 1)); - audioCodec.Set("G711", Napi::Number::New(env, 2)); - audioCodec.Set("G722", Napi::Number::New(env, 3)); - audioCodec.Set("OPUS", Napi::Number::New(env, 4)); + audioCodec.Set("UNDEFINED", Napi::Number::New(env, (int)rtms::MEDIA_PAYLOAD_TYPE::UNDEFINED)); + audioCodec.Set("L16", Napi::Number::New(env, (int)rtms::MEDIA_PAYLOAD_TYPE::L16)); + audioCodec.Set("G711", Napi::Number::New(env, (int)rtms::MEDIA_PAYLOAD_TYPE::G711)); + audioCodec.Set("G722", Napi::Number::New(env, (int)rtms::MEDIA_PAYLOAD_TYPE::G722)); + audioCodec.Set("OPUS", Napi::Number::New(env, (int)rtms::MEDIA_PAYLOAD_TYPE::OPUS)); exports.Set("AudioCodec", audioCodec); - // Audio Sample Rate + // Audio Sample Rate (AUDIO_SAMPLE_RATE) Napi::Object audioSampleRate = Napi::Object::New(env); - audioSampleRate.Set("SR_8K", Napi::Number::New(env, 0)); - audioSampleRate.Set("SR_16K", Napi::Number::New(env, 1)); - audioSampleRate.Set("SR_32K", Napi::Number::New(env, 2)); - audioSampleRate.Set("SR_48K", Napi::Number::New(env, 3)); + audioSampleRate.Set("SR_8K", Napi::Number::New(env, (int)rtms::AUDIO_SAMPLE_RATE::SR_8K)); + audioSampleRate.Set("SR_16K", Napi::Number::New(env, (int)rtms::AUDIO_SAMPLE_RATE::SR_16K)); + audioSampleRate.Set("SR_32K", Napi::Number::New(env, (int)rtms::AUDIO_SAMPLE_RATE::SR_32K)); + audioSampleRate.Set("SR_48K", Napi::Number::New(env, (int)rtms::AUDIO_SAMPLE_RATE::SR_48K)); exports.Set("AudioSampleRate", audioSampleRate); - // Audio Channel + // Audio Channel (AUDIO_CHANNEL) Napi::Object audioChannel = Napi::Object::New(env); - audioChannel.Set("MONO", Napi::Number::New(env, 1)); - audioChannel.Set("STEREO", Napi::Number::New(env, 2)); + audioChannel.Set("MONO", Napi::Number::New(env, (int)rtms::AUDIO_CHANNEL::MONO)); + audioChannel.Set("STEREO", Napi::Number::New(env, (int)rtms::AUDIO_CHANNEL::STEREO)); exports.Set("AudioChannel", audioChannel); - // Audio Data Option + // Audio Data Option (MEDIA_DATA_OPTION) Napi::Object audioDataOption = Napi::Object::New(env); - audioDataOption.Set("UNDEFINED", Napi::Number::New(env, 0)); - audioDataOption.Set("AUDIO_MIXED_STREAM", Napi::Number::New(env, 1)); - audioDataOption.Set("AUDIO_MULTI_STREAMS", Napi::Number::New(env, 2)); + audioDataOption.Set("UNDEFINED", Napi::Number::New(env, (int)rtms::MEDIA_DATA_OPTION::UNDEFINED)); + audioDataOption.Set("AUDIO_MIXED_STREAM", Napi::Number::New(env, (int)rtms::MEDIA_DATA_OPTION::AUDIO_MIXED_STREAM)); + audioDataOption.Set("AUDIO_MULTI_STREAMS", Napi::Number::New(env, (int)rtms::MEDIA_DATA_OPTION::AUDIO_MULTI_STREAMS)); exports.Set("AudioDataOption", audioDataOption); // ===== Video Parameters ===== - // Video Content Type + // Video Content Type (MEDIA_CONTENT_TYPE) Napi::Object videoContentType = Napi::Object::New(env); - videoContentType.Set("UNDEFINED", Napi::Number::New(env, 0)); - videoContentType.Set("RTP", Napi::Number::New(env, 1)); - videoContentType.Set("RAW_VIDEO", Napi::Number::New(env, 3)); - videoContentType.Set("FILE_STREAM", Napi::Number::New(env, 4)); - videoContentType.Set("TEXT", Napi::Number::New(env, 5)); + videoContentType.Set("UNDEFINED", Napi::Number::New(env, (int)rtms::MEDIA_CONTENT_TYPE::UNDEFINED)); + videoContentType.Set("RTP", Napi::Number::New(env, (int)rtms::MEDIA_CONTENT_TYPE::RTP)); + videoContentType.Set("RAW_VIDEO", Napi::Number::New(env, (int)rtms::MEDIA_CONTENT_TYPE::RAW_VIDEO)); + videoContentType.Set("FILE_STREAM", Napi::Number::New(env, (int)rtms::MEDIA_CONTENT_TYPE::FILE_STREAM)); + videoContentType.Set("TEXT", Napi::Number::New(env, (int)rtms::MEDIA_CONTENT_TYPE::TEXT)); exports.Set("VideoContentType", videoContentType); - // Video Codec + // Video Codec (MEDIA_PAYLOAD_TYPE) Napi::Object videoCodec = Napi::Object::New(env); - videoCodec.Set("UNDEFINED", Napi::Number::New(env, 0)); - videoCodec.Set("JPG", Napi::Number::New(env, 5)); - videoCodec.Set("PNG", Napi::Number::New(env, 6)); - videoCodec.Set("H264", Napi::Number::New(env, 7)); + videoCodec.Set("UNDEFINED", Napi::Number::New(env, (int)rtms::MEDIA_PAYLOAD_TYPE::UNDEFINED)); + videoCodec.Set("JPG", Napi::Number::New(env, (int)rtms::MEDIA_PAYLOAD_TYPE::JPG)); + videoCodec.Set("PNG", Napi::Number::New(env, (int)rtms::MEDIA_PAYLOAD_TYPE::PNG)); + videoCodec.Set("H264", Napi::Number::New(env, (int)rtms::MEDIA_PAYLOAD_TYPE::H264)); exports.Set("VideoCodec", videoCodec); - // Video Resolution + // Video Resolution (MEDIA_RESOLUTION) Napi::Object videoResolution = Napi::Object::New(env); - videoResolution.Set("SD", Napi::Number::New(env, 1)); - videoResolution.Set("HD", Napi::Number::New(env, 2)); - videoResolution.Set("FHD", Napi::Number::New(env, 3)); - videoResolution.Set("QHD", Napi::Number::New(env, 4)); + videoResolution.Set("SD", Napi::Number::New(env, (int)rtms::MEDIA_RESOLUTION::SD)); + videoResolution.Set("HD", Napi::Number::New(env, (int)rtms::MEDIA_RESOLUTION::HD)); + videoResolution.Set("FHD", Napi::Number::New(env, (int)rtms::MEDIA_RESOLUTION::FHD)); + videoResolution.Set("QHD", Napi::Number::New(env, (int)rtms::MEDIA_RESOLUTION::QHD)); exports.Set("VideoResolution", videoResolution); - // Video Data Option + // Video Data Option (MEDIA_DATA_OPTION) Napi::Object videoDataOption = Napi::Object::New(env); - videoDataOption.Set("UNDEFINED", Napi::Number::New(env, 0)); - videoDataOption.Set("VIDEO_SINGLE_ACTIVE_STREAM", Napi::Number::New(env, 3)); - videoDataOption.Set("VIDEO_MIXED_SPEAKER_VIEW", Napi::Number::New(env, 4)); - videoDataOption.Set("VIDEO_MIXED_GALLERY_VIEW", Napi::Number::New(env, 5)); + videoDataOption.Set("UNDEFINED", Napi::Number::New(env, (int)rtms::MEDIA_DATA_OPTION::UNDEFINED)); + videoDataOption.Set("VIDEO_SINGLE_ACTIVE_STREAM", Napi::Number::New(env, (int)rtms::MEDIA_DATA_OPTION::VIDEO_SINGLE_ACTIVE_STREAM)); + videoDataOption.Set("VIDEO_MIXED_SPEAKER_VIEW", Napi::Number::New(env, (int)rtms::MEDIA_DATA_OPTION::VIDEO_SINGLE_INDIVIDUAL_STREAM)); + videoDataOption.Set("VIDEO_SINGLE_INDIVIDUAL_STREAM", Napi::Number::New(env, (int)rtms::MEDIA_DATA_OPTION::VIDEO_SINGLE_INDIVIDUAL_STREAM)); + videoDataOption.Set("VIDEO_MIXED_GALLERY_VIEW", Napi::Number::New(env, (int)rtms::MEDIA_DATA_OPTION::VIDEO_MIXED_GALLERY_VIEW)); exports.Set("VideoDataOption", videoDataOption); // ===== Media Types ===== - // Media Data Type + // Media Data Type (MEDIA_DATA_TYPE) Napi::Object mediaDataType = Napi::Object::New(env); - mediaDataType.Set("UNDEFINED", Napi::Number::New(env, 0)); - mediaDataType.Set("AUDIO", Napi::Number::New(env, 1)); - mediaDataType.Set("VIDEO", Napi::Number::New(env, 2)); - mediaDataType.Set("DESKSHARE", Napi::Number::New(env, 4)); - mediaDataType.Set("TRANSCRIPT", Napi::Number::New(env, 8)); - mediaDataType.Set("CHAT", Napi::Number::New(env, 16)); - mediaDataType.Set("ALL", Napi::Number::New(env, 32)); + mediaDataType.Set("UNDEFINED", Napi::Number::New(env, (int)rtms::MEDIA_DATA_TYPE::UNDEFINED)); + mediaDataType.Set("AUDIO", Napi::Number::New(env, (int)rtms::MEDIA_DATA_TYPE::AUDIO)); + mediaDataType.Set("VIDEO", Napi::Number::New(env, (int)rtms::MEDIA_DATA_TYPE::VIDEO)); + mediaDataType.Set("DESKSHARE", Napi::Number::New(env, (int)rtms::MEDIA_DATA_TYPE::DESKSHARE)); + mediaDataType.Set("TRANSCRIPT", Napi::Number::New(env, (int)rtms::MEDIA_DATA_TYPE::TRANSCRIPT)); + mediaDataType.Set("CHAT", Napi::Number::New(env, (int)rtms::MEDIA_DATA_TYPE::CHAT)); + mediaDataType.Set("ALL", Napi::Number::New(env, (int)rtms::MEDIA_DATA_TYPE::ALL)); exports.Set("MediaDataType", mediaDataType); // ===== Session States ===== - // Session State + // Session State (SESSION_STATE) Napi::Object sessionState = Napi::Object::New(env); - sessionState.Set("INACTIVE", Napi::Number::New(env, 0)); - sessionState.Set("INITIALIZE", Napi::Number::New(env, 1)); - sessionState.Set("STARTED", Napi::Number::New(env, 2)); - sessionState.Set("PAUSED", Napi::Number::New(env, 3)); - sessionState.Set("RESUMED", Napi::Number::New(env, 4)); - sessionState.Set("STOPPED", Napi::Number::New(env, 5)); + sessionState.Set("INACTIVE", Napi::Number::New(env, (int)rtms::SESSION_STATE::INACTIVE)); + sessionState.Set("INITIALIZE", Napi::Number::New(env, (int)rtms::SESSION_STATE::INITIALIZE)); + sessionState.Set("STARTED", Napi::Number::New(env, (int)rtms::SESSION_STATE::STARTED)); + sessionState.Set("PAUSED", Napi::Number::New(env, (int)rtms::SESSION_STATE::PAUSED)); + sessionState.Set("RESUMED", Napi::Number::New(env, (int)rtms::SESSION_STATE::RESUMED)); + sessionState.Set("STOPPED", Napi::Number::New(env, (int)rtms::SESSION_STATE::STOPPED)); exports.Set("SessionState", sessionState); - // Stream State + // Stream State (STREAM_STATE) Napi::Object streamState = Napi::Object::New(env); - streamState.Set("INACTIVE", Napi::Number::New(env, 0)); - streamState.Set("ACTIVE", Napi::Number::New(env, 1)); - streamState.Set("INTERRUPTED", Napi::Number::New(env, 2)); - streamState.Set("TERMINATING", Napi::Number::New(env, 3)); - streamState.Set("TERMINATED", Napi::Number::New(env, 4)); + streamState.Set("INACTIVE", Napi::Number::New(env, (int)rtms::STREAM_STATE::INACTIVE)); + streamState.Set("ACTIVE", Napi::Number::New(env, (int)rtms::STREAM_STATE::ACTIVE)); + streamState.Set("INTERRUPTED", Napi::Number::New(env, (int)rtms::STREAM_STATE::INTERRUPTED)); + streamState.Set("TERMINATING", Napi::Number::New(env, (int)rtms::STREAM_STATE::TERMINATING)); + streamState.Set("TERMINATED", Napi::Number::New(env, (int)rtms::STREAM_STATE::TERMINATED)); + streamState.Set("PAUSED", Napi::Number::New(env, (int)rtms::STREAM_STATE::PAUSED)); + streamState.Set("RESUMED", Napi::Number::New(env, (int)rtms::STREAM_STATE::RESUMED)); exports.Set("StreamState", streamState); - // Event Type (matches RTMS_EVENT_TYPE from Zoom's C SDK) + // Event Type (EVENT_TYPE + ZCC_VOICE_EVENT_TYPE for backward compat) Napi::Object eventType = Napi::Object::New(env); - eventType.Set("UNDEFINED", Napi::Number::New(env, 0)); - eventType.Set("FIRST_PACKET_TIMESTAMP", Napi::Number::New(env, 1)); - eventType.Set("ACTIVE_SPEAKER_CHANGE", Napi::Number::New(env, 2)); - eventType.Set("PARTICIPANT_JOIN", Napi::Number::New(env, 3)); - eventType.Set("PARTICIPANT_LEAVE", Napi::Number::New(env, 4)); - eventType.Set("SHARING_START", Napi::Number::New(env, 5)); - eventType.Set("SHARING_STOP", Napi::Number::New(env, 6)); - eventType.Set("MEDIA_CONNECTION_INTERRUPTED", Napi::Number::New(env, 7)); - eventType.Set("CONSUMER_ANSWERED", Napi::Number::New(env, 8)); - eventType.Set("CONSUMER_END", Napi::Number::New(env, 9)); - eventType.Set("USER_ANSWERED", Napi::Number::New(env, 10)); - eventType.Set("USER_END", Napi::Number::New(env, 11)); - eventType.Set("USER_HOLD", Napi::Number::New(env, 12)); - eventType.Set("USER_UNHOLD", Napi::Number::New(env, 13)); + eventType.Set("UNDEFINED", Napi::Number::New(env, (int)rtms::EVENT_TYPE::UNDEFINED)); + eventType.Set("FIRST_PACKET_TIMESTAMP", Napi::Number::New(env, (int)rtms::EVENT_TYPE::FIRST_PACKET_TIMESTAMP)); + eventType.Set("ACTIVE_SPEAKER_CHANGE", Napi::Number::New(env, (int)rtms::EVENT_TYPE::ACTIVE_SPEAKER_CHANGE)); + eventType.Set("PARTICIPANT_JOIN", Napi::Number::New(env, (int)rtms::EVENT_TYPE::PARTICIPANT_JOIN)); + eventType.Set("PARTICIPANT_LEAVE", Napi::Number::New(env, (int)rtms::EVENT_TYPE::PARTICIPANT_LEAVE)); + eventType.Set("SHARING_START", Napi::Number::New(env, (int)rtms::EVENT_TYPE::SHARING_START)); + eventType.Set("SHARING_STOP", Napi::Number::New(env, (int)rtms::EVENT_TYPE::SHARING_STOP)); + eventType.Set("MEDIA_CONNECTION_INTERRUPTED", Napi::Number::New(env, (int)rtms::EVENT_TYPE::MEDIA_CONNECTION_INTERRUPTED)); + eventType.Set("PARTICIPANT_VIDEO_ON", Napi::Number::New(env, (int)rtms::EVENT_TYPE::PARTICIPANT_VIDEO_ON)); + eventType.Set("PARTICIPANT_VIDEO_OFF", Napi::Number::New(env, (int)rtms::EVENT_TYPE::PARTICIPANT_VIDEO_OFF)); + eventType.Set("CONSUMER_ANSWERED", Napi::Number::New(env, (int)rtms::ZCC_VOICE_EVENT_TYPE::CONSUMER_ANSWERED)); + eventType.Set("CONSUMER_END", Napi::Number::New(env, (int)rtms::ZCC_VOICE_EVENT_TYPE::CONSUMER_END)); + eventType.Set("USER_ANSWERED", Napi::Number::New(env, (int)rtms::ZCC_VOICE_EVENT_TYPE::USER_ANSWERED)); + eventType.Set("USER_END", Napi::Number::New(env, (int)rtms::ZCC_VOICE_EVENT_TYPE::USER_END)); + eventType.Set("USER_HOLD", Napi::Number::New(env, (int)rtms::ZCC_VOICE_EVENT_TYPE::USER_HOLD)); + eventType.Set("USER_UNHOLD", Napi::Number::New(env, (int)rtms::ZCC_VOICE_EVENT_TYPE::USER_UNHOLD)); exports.Set("EventType", eventType); - // Message Type + // Message Type (MESSAGE_TYPE) — complete set of 30 values Napi::Object messageType = Napi::Object::New(env); - messageType.Set("UNDEFINED", Napi::Number::New(env, 0)); - messageType.Set("SIGNALING_HAND_SHAKE_REQ", Napi::Number::New(env, 1)); - messageType.Set("SIGNALING_HAND_SHAKE_RESP", Napi::Number::New(env, 2)); - messageType.Set("DATA_HAND_SHAKE_REQ", Napi::Number::New(env, 3)); - messageType.Set("DATA_HAND_SHAKE_RESP", Napi::Number::New(env, 4)); - messageType.Set("EVENT_SUBSCRIPTION", Napi::Number::New(env, 5)); - messageType.Set("EVENT_UPDATE", Napi::Number::New(env, 6)); - messageType.Set("CLIENT_READY_ACK", Napi::Number::New(env, 7)); - messageType.Set("STREAM_STATE_UPDATE", Napi::Number::New(env, 8)); - messageType.Set("SESSION_STATE_UPDATE", Napi::Number::New(env, 9)); - messageType.Set("SESSION_STATE_REQ", Napi::Number::New(env, 10)); - messageType.Set("SESSION_STATE_RESP", Napi::Number::New(env, 11)); - messageType.Set("KEEP_ALIVE_REQ", Napi::Number::New(env, 12)); - messageType.Set("KEEP_ALIVE_RESP", Napi::Number::New(env, 13)); - messageType.Set("MEDIA_DATA_AUDIO", Napi::Number::New(env, 14)); - messageType.Set("MEDIA_DATA_VIDEO", Napi::Number::New(env, 15)); - messageType.Set("MEDIA_DATA_SHARE", Napi::Number::New(env, 16)); - messageType.Set("MEDIA_DATA_TRANSCRIPT", Napi::Number::New(env, 17)); - messageType.Set("MEDIA_DATA_CHAT", Napi::Number::New(env, 18)); + messageType.Set("UNDEFINED", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::UNDEFINED)); + messageType.Set("SIGNALING_HAND_SHAKE_REQ", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::SIGNALING_HAND_SHAKE_REQ)); + messageType.Set("SIGNALING_HAND_SHAKE_RESP",Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::SIGNALING_HAND_SHAKE_RESP)); + messageType.Set("DATA_HAND_SHAKE_REQ", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::DATA_HAND_SHAKE_REQ)); + messageType.Set("DATA_HAND_SHAKE_RESP", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::DATA_HAND_SHAKE_RESP)); + messageType.Set("EVENT_SUBSCRIPTION", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::EVENT_SUBSCRIPTION)); + messageType.Set("EVENT_UPDATE", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::EVENT_UPDATE)); + messageType.Set("CLIENT_READY_ACK", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::CLIENT_READY_ACK)); + messageType.Set("STREAM_STATE_UPDATE", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::STREAM_STATE_UPDATE)); + messageType.Set("SESSION_STATE_UPDATE", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::SESSION_STATE_UPDATE)); + messageType.Set("SESSION_STATE_REQ", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::SESSION_STATE_REQ)); + messageType.Set("SESSION_STATE_RESP", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::SESSION_STATE_RESP)); + messageType.Set("KEEP_ALIVE_REQ", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::KEEP_ALIVE_REQ)); + messageType.Set("KEEP_ALIVE_RESP", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::KEEP_ALIVE_RESP)); + messageType.Set("MEDIA_DATA_AUDIO", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::MEDIA_DATA_AUDIO)); + messageType.Set("MEDIA_DATA_VIDEO", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::MEDIA_DATA_VIDEO)); + messageType.Set("MEDIA_DATA_SHARE", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::MEDIA_DATA_SHARE)); + messageType.Set("MEDIA_DATA_TRANSCRIPT", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::MEDIA_DATA_TRANSCRIPT)); + messageType.Set("MEDIA_DATA_CHAT", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::MEDIA_DATA_CHAT)); + messageType.Set("STREAM_STATE_REQ", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::STREAM_STATE_REQ)); + messageType.Set("STREAM_STATE_RESP", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::STREAM_STATE_RESP)); + messageType.Set("STREAM_CLOSE_REQ", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::STREAM_CLOSE_REQ)); + messageType.Set("STREAM_CLOSE_RESP", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::STREAM_CLOSE_RESP)); + messageType.Set("META_DATA_AUDIO", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::META_DATA_AUDIO)); + messageType.Set("META_DATA_VIDEO", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::META_DATA_VIDEO)); + messageType.Set("META_DATA_SHARE", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::META_DATA_SHARE)); + messageType.Set("META_DATA_TRANSCRIPT", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::META_DATA_TRANSCRIPT)); + messageType.Set("META_DATA_CHAT", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::META_DATA_CHAT)); + messageType.Set("VIDEO_SUBSCRIPTION_REQ", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::VIDEO_SUBSCRIPTION_REQ)); + messageType.Set("VIDEO_SUBSCRIPTION_RESP", Napi::Number::New(env, (int)rtms::MESSAGE_TYPE::VIDEO_SUBSCRIPTION_RESP)); exports.Set("MessageType", messageType); - // Stop Reason + // Stop Reason (STOP_REASON) — complete set of 27 values Napi::Object stopReason = Napi::Object::New(env); - stopReason.Set("UNDEFINED", Napi::Number::New(env, 0)); - stopReason.Set("STOP_BC_HOST_TRIGGERED", Napi::Number::New(env, 1)); - stopReason.Set("STOP_BC_USER_TRIGGERED", Napi::Number::New(env, 2)); - stopReason.Set("STOP_BC_USER_LEFT", Napi::Number::New(env, 3)); - stopReason.Set("STOP_BC_USER_EJECTED", Napi::Number::New(env, 4)); - stopReason.Set("STOP_BC_APP_DISABLED_BY_HOST", Napi::Number::New(env, 5)); - stopReason.Set("STOP_BC_MEETING_ENDED", Napi::Number::New(env, 6)); - stopReason.Set("STOP_BC_STREAM_CANCELED", Napi::Number::New(env, 7)); - stopReason.Set("STOP_BC_STREAM_REVOKED", Napi::Number::New(env, 8)); - stopReason.Set("STOP_BC_ALL_APPS_DISABLED", Napi::Number::New(env, 9)); - stopReason.Set("STOP_BC_INTERNAL_EXCEPTION", Napi::Number::New(env, 10)); - stopReason.Set("STOP_BC_CONNECTION_TIMEOUT", Napi::Number::New(env, 11)); - stopReason.Set("STOP_BC_MEETING_CONNECTION_INTERRUPTED", Napi::Number::New(env, 12)); - stopReason.Set("STOP_BC_SIGNAL_CONNECTION_INTERRUPTED", Napi::Number::New(env, 13)); - stopReason.Set("STOP_BC_DATA_CONNECTION_INTERRUPTED", Napi::Number::New(env, 14)); - stopReason.Set("STOP_BC_SIGNAL_CONNECTION_CLOSED_ABNORMALLY", Napi::Number::New(env, 15)); - stopReason.Set("STOP_BC_DATA_CONNECTION_CLOSED_ABNORMALLY", Napi::Number::New(env, 16)); - stopReason.Set("STOP_BC_EXIT_SIGNAL", Napi::Number::New(env, 17)); - stopReason.Set("STOP_BC_AUTHENTICATION_FAILURE", Napi::Number::New(env, 18)); + stopReason.Set("UNDEFINED", Napi::Number::New(env, (int)rtms::STOP_REASON::UNDEFINED)); + stopReason.Set("HOST_TRIGGERED", Napi::Number::New(env, (int)rtms::STOP_REASON::HOST_TRIGGERED)); + stopReason.Set("USER_TRIGGERED", Napi::Number::New(env, (int)rtms::STOP_REASON::USER_TRIGGERED)); + stopReason.Set("USER_LEFT", Napi::Number::New(env, (int)rtms::STOP_REASON::USER_LEFT)); + stopReason.Set("USER_EJECTED", Napi::Number::New(env, (int)rtms::STOP_REASON::USER_EJECTED)); + stopReason.Set("HOST_DISABLED_APP", Napi::Number::New(env, (int)rtms::STOP_REASON::HOST_DISABLED_APP)); + stopReason.Set("MEETING_ENDED", Napi::Number::New(env, (int)rtms::STOP_REASON::MEETING_ENDED)); + stopReason.Set("STREAM_CANCELED", Napi::Number::New(env, (int)rtms::STOP_REASON::STREAM_CANCELED)); + stopReason.Set("STREAM_REVOKED", Napi::Number::New(env, (int)rtms::STOP_REASON::STREAM_REVOKED)); + stopReason.Set("ALL_APPS_DISABLED", Napi::Number::New(env, (int)rtms::STOP_REASON::ALL_APPS_DISABLED)); + stopReason.Set("INTERNAL_EXCEPTION", Napi::Number::New(env, (int)rtms::STOP_REASON::INTERNAL_EXCEPTION)); + stopReason.Set("CONNECTION_TIMEOUT", Napi::Number::New(env, (int)rtms::STOP_REASON::CONNECTION_TIMEOUT)); + stopReason.Set("INSTANCE_CONNECTION_INTERRUPTED", Napi::Number::New(env, (int)rtms::STOP_REASON::INSTANCE_CONNECTION_INTERRUPTED)); + stopReason.Set("SIGNAL_CONNECTION_INTERRUPTED", Napi::Number::New(env, (int)rtms::STOP_REASON::SIGNAL_CONNECTION_INTERRUPTED)); + stopReason.Set("DATA_CONNECTION_INTERRUPTED", Napi::Number::New(env, (int)rtms::STOP_REASON::DATA_CONNECTION_INTERRUPTED)); + stopReason.Set("SIGNAL_CONNECTION_CLOSED_ABNORMALLY", Napi::Number::New(env, (int)rtms::STOP_REASON::SIGNAL_CONNECTION_CLOSED_ABNORMALLY)); + stopReason.Set("DATA_CONNECTION_CLOSED_ABNORMALLY", Napi::Number::New(env, (int)rtms::STOP_REASON::DATA_CONNECTION_CLOSED_ABNORMALLY)); + stopReason.Set("EXIT_SIGNAL", Napi::Number::New(env, (int)rtms::STOP_REASON::EXIT_SIGNAL)); + stopReason.Set("AUTHENTICATION_FAILURE", Napi::Number::New(env, (int)rtms::STOP_REASON::AUTHENTICATION_FAILURE)); + stopReason.Set("AWAIT_RECONNECTION_TIMEOUT", Napi::Number::New(env, (int)rtms::STOP_REASON::AWAIT_RECONNECTION_TIMEOUT)); + stopReason.Set("RECEIVER_REQUEST_CLOSE", Napi::Number::New(env, (int)rtms::STOP_REASON::RECEIVER_REQUEST_CLOSE)); + stopReason.Set("CUSTOMER_DISCONNECTED", Napi::Number::New(env, (int)rtms::STOP_REASON::CUSTOMER_DISCONNECTED)); + stopReason.Set("AGENT_DISCONNECTED", Napi::Number::New(env, (int)rtms::STOP_REASON::AGENT_DISCONNECTED)); + stopReason.Set("ADMIN_DISABLED_APP", Napi::Number::New(env, (int)rtms::STOP_REASON::ADMIN_DISABLED_APP)); + stopReason.Set("KEEP_ALIVE_TIMEOUT", Napi::Number::New(env, (int)rtms::STOP_REASON::KEEP_ALIVE_TIMEOUT)); + stopReason.Set("MANUAL_API_TRIGGERED", Napi::Number::New(env, (int)rtms::STOP_REASON::MANUAL_API_TRIGGERED)); + stopReason.Set("STREAMING_NOT_SUPPORTED", Napi::Number::New(env, (int)rtms::STOP_REASON::STREAMING_NOT_SUPPORTED)); exports.Set("StopReason", stopReason); + // Transcript language constants (mirrors AudioCodec/VideoCodec pattern) + Napi::Object transcriptLanguage = Napi::Object::New(env); + transcriptLanguage.Set("NONE", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::NONE)); + transcriptLanguage.Set("ARABIC", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::ARABIC)); + transcriptLanguage.Set("BENGALI", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::BENGALI)); + transcriptLanguage.Set("CANTONESE", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::CANTONESE)); + transcriptLanguage.Set("CATALAN", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::CATALAN)); + transcriptLanguage.Set("CHINESE_SIMPLIFIED", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::CHINESE_SIMPLIFIED)); + transcriptLanguage.Set("CHINESE_TRADITIONAL", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::CHINESE_TRADITIONAL)); + transcriptLanguage.Set("CZECH", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::CZECH)); + transcriptLanguage.Set("DANISH", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::DANISH)); + transcriptLanguage.Set("DUTCH", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::DUTCH)); + transcriptLanguage.Set("ENGLISH", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::ENGLISH)); + transcriptLanguage.Set("ESTONIAN", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::ESTONIAN)); + transcriptLanguage.Set("FINNISH", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::FINNISH)); + transcriptLanguage.Set("FRENCH_CANADA", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::FRENCH_CANADA)); + transcriptLanguage.Set("FRENCH_FRANCE", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::FRENCH_FRANCE)); + transcriptLanguage.Set("GERMAN", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::GERMAN)); + transcriptLanguage.Set("HEBREW", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::HEBREW)); + transcriptLanguage.Set("HINDI", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::HINDI)); + transcriptLanguage.Set("HUNGARIAN", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::HUNGARIAN)); + transcriptLanguage.Set("INDONESIAN", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::INDONESIAN)); + transcriptLanguage.Set("ITALIAN", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::ITALIAN)); + transcriptLanguage.Set("JAPANESE", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::JAPANESE)); + transcriptLanguage.Set("KOREAN", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::KOREAN)); + transcriptLanguage.Set("MALAY", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::MALAY)); + transcriptLanguage.Set("PERSIAN", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::PERSIAN)); + transcriptLanguage.Set("POLISH", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::POLISH)); + transcriptLanguage.Set("PORTUGUESE", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::PORTUGUESE)); + transcriptLanguage.Set("ROMANIAN", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::ROMANIAN)); + transcriptLanguage.Set("RUSSIAN", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::RUSSIAN)); + transcriptLanguage.Set("SPANISH", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::SPANISH)); + transcriptLanguage.Set("SWEDISH", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::SWEDISH)); + transcriptLanguage.Set("TAGALOG", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::TAGALOG)); + transcriptLanguage.Set("TAMIL", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::TAMIL)); + transcriptLanguage.Set("TELUGU", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::TELUGU)); + transcriptLanguage.Set("THAI", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::THAI)); + transcriptLanguage.Set("TURKISH", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::TURKISH)); + transcriptLanguage.Set("UKRAINIAN", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::UKRAINIAN)); + transcriptLanguage.Set("VIETNAMESE", Napi::Number::New(env, (int)rtms::TRANSCRIPT_LANGUAGE::VIETNAMESE)); + exports.Set("TranscriptLanguage", transcriptLanguage); + return exports; } diff --git a/src/python.cpp b/src/python.cpp index 70c731f..cd7a93c 100644 --- a/src/python.cpp +++ b/src/python.cpp @@ -22,15 +22,46 @@ using namespace rtms; */ class PyClient { public: - PyClient() : client_(std::make_unique()) { - // Client created successfully - } + // Phase 1: safe to call from any thread — no C SDK interaction. + PyClient() : client_(nullptr) {} ~PyClient() { - // Clear all callbacks before destruction clearCallbacks(); } + // Phase 2: allocates the C SDK handle. Must be called from the thread that + // will own this client for its entire lifetime (join/poll/release must all + // run on the same OS thread as alloc). + // + // Replays all callbacks and params that were buffered before alloc() so + // that the caller can set up the client fully before calling alloc()+join(). + void alloc() { + if (client_) return; // idempotent + client_ = std::make_unique(); + + // Replay buffered callbacks + if (!join_confirm_callback_.is_none()) _registerJoinConfirm(); + if (!session_update_callback_.is_none()) _registerSessionUpdate(); + if (!user_update_callback_.is_none()) _registerUserUpdate(); + if (!audio_data_callback_.is_none()) _registerAudioData(); + if (!video_data_callback_.is_none()) _registerVideoData(); + if (!deskshare_data_callback_.is_none()) _registerDeskshareData(); + if (!transcript_data_callback_.is_none()) _registerTranscriptData(); + if (!leave_callback_.is_none()) _registerLeave(); + if (!event_ex_callback_.is_none()) _registerEventEx(); + if (!participant_video_callback_.is_none()) _registerParticipantVideo(); + if (!video_subscribed_callback_.is_none()) _registerVideoSubscribed(); + + // Replay buffered params + if (pending_audio_params_) client_->setAudioParams(*pending_audio_params_); + if (pending_video_params_) client_->setVideoParams(*pending_video_params_); + if (pending_deskshare_params_) client_->setDeskshareParams(*pending_deskshare_params_); + if (pending_transcript_params_) client_->setTranscriptParams(*pending_transcript_params_); + if (!pending_proxy_type_.empty()) client_->setProxy(pending_proxy_type_, pending_proxy_url_); + } + + bool isAllocated() const { return client_ != nullptr; } + // ======================================================================== // Core Methods // ======================================================================== @@ -38,24 +69,40 @@ class PyClient { void join(const std::string& uuid, const std::string& stream_id, const std::string& signature, const std::string& server_urls, int timeout = -1) { + if (!client_) throw std::runtime_error("alloc() must be called before join()"); client_->join(uuid, stream_id, signature, server_urls, timeout); } void poll() { - client_->poll(); + // Release the GIL *before* acquiring poll_mutex_ to prevent deadlock: + // release() holds poll_mutex_ while the webhook thread holds the GIL. + // If poll() tried to acquire poll_mutex_ while holding the GIL, the two + // threads would deadlock. Releasing the GIL first breaks the cycle. + py::gil_scoped_release release; + std::lock_guard lk(poll_mutex_); + if (client_) client_->poll(); } void release() { + if (!client_) return; + // Hold poll_mutex_ for the entire release sequence so that any in-flight + // poll() completes before we tear down the C SDK handle. + std::lock_guard lk(poll_mutex_); + // markClosed() sets sdk_opened_=false so that stopCallbacks() calls + // setOnAudioData/Video/etc. with empty lambdas without triggering + // configure() on an already-dead session (avoids 4 spurious warnings). + client_->markClosed(); stopCallbacks(); client_->release(); + client_.reset(); // prevent subsequent poll() from calling into released SDK } std::string uuid() const { - return client_->uuid(); + return client_ ? client_->uuid() : ""; } std::string streamId() const { - return client_->streamId(); + return client_ ? client_->streamId() : ""; } // ======================================================================== @@ -63,197 +110,286 @@ class PyClient { // ======================================================================== void enableAudio(bool enable) { - client_->enableAudio(enable); + if (client_) client_->enableAudio(enable); } void enableVideo(bool enable) { - client_->enableVideo(enable); + if (client_) client_->enableVideo(enable); } void enableTranscript(bool enable) { - client_->enableTranscript(enable); + if (client_) client_->enableTranscript(enable); } void enableDeskshare(bool enable) { - client_->enableDeskshare(enable); + if (client_) client_->enableDeskshare(enable); } // ======================================================================== // Parameter Setting Methods + // Buffer params pre-alloc; apply immediately post-alloc. // ======================================================================== void setAudioParams(const AudioParams& params) { - client_->setAudioParams(params); + pending_audio_params_ = std::make_unique(params); + if (client_) client_->setAudioParams(params); } void setVideoParams(const VideoParams& params) { - client_->setVideoParams(params); + pending_video_params_ = std::make_unique(params); + if (client_) client_->setVideoParams(params); } void setDeskshareParams(const DeskshareParams& params) { - client_->setDeskshareParams(params); + pending_deskshare_params_ = std::make_unique(params); + if (client_) client_->setDeskshareParams(params); + } + + void setTranscriptParams(const TranscriptParams& params) { + pending_transcript_params_ = std::make_unique(params); + if (client_) client_->setTranscriptParams(params); + } + + void setProxy(const std::string& proxy_type, const std::string& proxy_url) { + pending_proxy_type_ = proxy_type; + pending_proxy_url_ = proxy_url; + if (client_) client_->setProxy(proxy_type, proxy_url); } // ======================================================================== // Callback Registration Methods + // Buffer callback pre-alloc; register with C SDK immediately post-alloc. // ======================================================================== void onJoinConfirm(py::function callback) { join_confirm_callback_ = callback; + if (client_) _registerJoinConfirm(); + } + + void onSessionUpdate(py::function callback) { + session_update_callback_ = callback; + if (client_) _registerSessionUpdate(); + } + + void onUserUpdate(py::function callback) { + user_update_callback_ = callback; + if (client_) _registerUserUpdate(); + } + + void onAudioData(py::function callback) { + audio_data_callback_ = callback; + if (client_) _registerAudioData(); + } + + void onVideoData(py::function callback) { + video_data_callback_ = callback; + if (client_) _registerVideoData(); + } + + void onDeskshareData(py::function callback) { + deskshare_data_callback_ = callback; + if (client_) _registerDeskshareData(); + } + + void onTranscriptData(py::function callback) { + transcript_data_callback_ = callback; + if (client_) _registerTranscriptData(); + } + + void onLeave(py::function callback) { + leave_callback_ = callback; + if (client_) _registerLeave(); + } + + void onEventEx(py::function callback) { + event_ex_callback_ = callback; + if (client_) _registerEventEx(); + } + + // ======================================================================== + // Individual Video Subscription + // ======================================================================== + + void subscribeVideo(int user_id, bool subscribe) { + if (!client_) throw std::runtime_error("alloc() must be called before subscribeVideo()"); + client_->subscribeVideo(user_id, subscribe); + } + + void onParticipantVideo(py::function callback) { + participant_video_callback_ = callback; + if (client_) _registerParticipantVideo(); + } + + void onVideoSubscribed(py::function callback) { + video_subscribed_callback_ = callback; + if (client_) _registerVideoSubscribed(); + } + + // ======================================================================== + // Event Subscription Methods + // ======================================================================== + + void subscribeEvent(const std::vector& events) { + if (!client_) { + // Buffer for replay after alloc + pending_subscriptions_.insert(pending_subscriptions_.end(), events.begin(), events.end()); + return; + } + client_->subscribeEvent(events); + } + + void unsubscribeEvent(const std::vector& events) { + if (client_) client_->unsubscribeEvent(events); + } + +private: + std::unique_ptr client_; + std::mutex poll_mutex_; // guards poll() vs release() race + + // Python callback storage (buffered pre-alloc, registered post-alloc) + py::object join_confirm_callback_ = py::none(); + py::object session_update_callback_ = py::none(); + py::object user_update_callback_ = py::none(); + py::object audio_data_callback_ = py::none(); + py::object video_data_callback_ = py::none(); + py::object deskshare_data_callback_ = py::none(); + py::object transcript_data_callback_ = py::none(); + py::object leave_callback_ = py::none(); + py::object event_ex_callback_ = py::none(); + py::object participant_video_callback_ = py::none(); + py::object video_subscribed_callback_ = py::none(); + + // Param buffers (applied on alloc) + std::unique_ptr pending_audio_params_; + std::unique_ptr pending_video_params_; + std::unique_ptr pending_deskshare_params_; + std::unique_ptr pending_transcript_params_; + std::string pending_proxy_type_; + std::string pending_proxy_url_; + std::vector pending_subscriptions_; + + // ── Private registration helpers ──────────────────────────────────────── + // Each helper wires one stored py::object into the C++ Client. + // Called from alloc() (replay) and from the public setter (live update). + + void _registerJoinConfirm() { client_->setOnJoinConfirm([this](int reason) { if (!join_confirm_callback_.is_none()) { py::gil_scoped_acquire acquire; - try { - join_confirm_callback_(reason); - } catch (const py::error_already_set& e) { - py::print("Error in join confirm callback:", e.what()); - } + try { join_confirm_callback_(reason); } + catch (const py::error_already_set& e) { py::print("Error in join_confirm callback:", e.what()); } } }); } - void onSessionUpdate(py::function callback) { - session_update_callback_ = callback; + void _registerSessionUpdate() { client_->setOnSessionUpdate([this](int op, const Session& session) { if (!session_update_callback_.is_none()) { py::gil_scoped_acquire acquire; - try { - session_update_callback_(op, session); - } catch (const py::error_already_set& e) { - py::print("Error in session update callback:", e.what()); - } + try { session_update_callback_(op, session); } + catch (const py::error_already_set& e) { py::print("Error in session_update callback:", e.what()); } } }); } - void onUserUpdate(py::function callback) { - user_update_callback_ = callback; + void _registerUserUpdate() { client_->setOnUserUpdate([this](int op, const Participant& participant) { if (!user_update_callback_.is_none()) { py::gil_scoped_acquire acquire; - try { - user_update_callback_(op, participant); - } catch (const py::error_already_set& e) { - py::print("Error in user update callback:", e.what()); - } + try { user_update_callback_(op, participant); } + catch (const py::error_already_set& e) { py::print("Error in user_update callback:", e.what()); } } }); } - void onAudioData(py::function callback) { - audio_data_callback_ = callback; + void _registerAudioData() { client_->setOnAudioData([this](const std::vector& data, uint64_t timestamp, const Metadata& metadata) { if (!audio_data_callback_.is_none()) { py::gil_scoped_acquire acquire; try { py::bytes py_data(reinterpret_cast(data.data()), data.size()); audio_data_callback_(py_data, data.size(), timestamp, metadata); - } catch (const py::error_already_set& e) { - py::print("Error in audio data callback:", e.what()); - } + } catch (const py::error_already_set& e) { py::print("Error in audio_data callback:", e.what()); } } }); } - void onVideoData(py::function callback) { - video_data_callback_ = callback; + void _registerVideoData() { client_->setOnVideoData([this](const std::vector& data, uint64_t timestamp, const Metadata& metadata) { if (!video_data_callback_.is_none()) { py::gil_scoped_acquire acquire; try { py::bytes py_data(reinterpret_cast(data.data()), data.size()); video_data_callback_(py_data, data.size(), timestamp, metadata); - } catch (const py::error_already_set& e) { - py::print("Error in video data callback:", e.what()); - } + } catch (const py::error_already_set& e) { py::print("Error in video_data callback:", e.what()); } } }); } - void onDeskshareData(py::function callback) { - deskshare_data_callback_ = callback; + void _registerDeskshareData() { client_->setOnDeskshareData([this](const std::vector& data, uint64_t timestamp, const Metadata& metadata) { if (!deskshare_data_callback_.is_none()) { py::gil_scoped_acquire acquire; try { py::bytes py_data(reinterpret_cast(data.data()), data.size()); deskshare_data_callback_(py_data, data.size(), timestamp, metadata); - } catch (const py::error_already_set& e) { - py::print("Error in deskshare data callback:", e.what()); - } + } catch (const py::error_already_set& e) { py::print("Error in deskshare_data callback:", e.what()); } } }); } - void onTranscriptData(py::function callback) { - transcript_data_callback_ = callback; + void _registerTranscriptData() { client_->setOnTranscriptData([this](const std::vector& data, uint64_t timestamp, const Metadata& metadata) { if (!transcript_data_callback_.is_none()) { py::gil_scoped_acquire acquire; try { py::bytes py_data(reinterpret_cast(data.data()), data.size()); transcript_data_callback_(py_data, data.size(), timestamp, metadata); - } catch (const py::error_already_set& e) { - py::print("Error in transcript data callback:", e.what()); - } + } catch (const py::error_already_set& e) { py::print("Error in transcript_data callback:", e.what()); } } }); } - void onLeave(py::function callback) { - leave_callback_ = callback; + void _registerLeave() { client_->setOnLeave([this](int reason) { if (!leave_callback_.is_none()) { py::gil_scoped_acquire acquire; - try { - leave_callback_(reason); - } catch (const py::error_already_set& e) { - py::print("Error in leave callback:", e.what()); - } + try { leave_callback_(reason); } + catch (const py::error_already_set& e) { py::print("Error in leave callback:", e.what()); } } }); } - void onEventEx(py::function callback) { - event_ex_callback_ = callback; + void _registerEventEx() { client_->setOnEventEx([this](const std::string& event_data) { if (!event_ex_callback_.is_none()) { py::gil_scoped_acquire acquire; - try { - event_ex_callback_(event_data); - } catch (const py::error_already_set& e) { - py::print("Error in event ex callback:", e.what()); - } + try { event_ex_callback_(event_data); } + catch (const py::error_already_set& e) { py::print("Error in event_ex callback:", e.what()); } } }); } - // ======================================================================== - // Event Subscription Methods - // ======================================================================== - - void subscribeEvent(const std::vector& events) { - client_->subscribeEvent(events); + void _registerParticipantVideo() { + client_->setOnParticipantVideo([this](const std::vector& users, bool is_on) { + if (!participant_video_callback_.is_none()) { + py::gil_scoped_acquire acquire; + try { participant_video_callback_(users, is_on); } + catch (const py::error_already_set& e) { py::print("Error in participant_video callback:", e.what()); } + } + }); } - void unsubscribeEvent(const std::vector& events) { - client_->unsubscribeEvent(events); + void _registerVideoSubscribed() { + client_->setOnVideoSubscribed([this](int user_id, int status, const std::string& error) { + if (!video_subscribed_callback_.is_none()) { + py::gil_scoped_acquire acquire; + try { video_subscribed_callback_(user_id, status, error); } + catch (const py::error_already_set& e) { py::print("Error in video_subscribed callback:", e.what()); } + } + }); } -private: - std::unique_ptr client_; - - // Python callback storage - py::object join_confirm_callback_ = py::none(); - py::object session_update_callback_ = py::none(); - py::object user_update_callback_ = py::none(); - py::object audio_data_callback_ = py::none(); - py::object video_data_callback_ = py::none(); - py::object deskshare_data_callback_ = py::none(); - py::object transcript_data_callback_ = py::none(); - py::object leave_callback_ = py::none(); - py::object event_ex_callback_ = py::none(); - void clearCallbacks() { join_confirm_callback_ = py::none(); session_update_callback_ = py::none(); @@ -264,10 +400,11 @@ class PyClient { transcript_data_callback_ = py::none(); leave_callback_ = py::none(); event_ex_callback_ = py::none(); + participant_video_callback_ = py::none(); + video_subscribed_callback_ = py::none(); } void stopCallbacks() { - // Replace callbacks with no-ops to prevent calling Python during shutdown if (client_) { client_->setOnJoinConfirm([](int) {}); client_->setOnSessionUpdate([](int, const Session&) {}); @@ -278,6 +415,8 @@ class PyClient { client_->setOnTranscriptData([](const std::vector&, uint64_t, const Metadata&) {}); client_->setOnLeave([](int) {}); client_->setOnEventEx([](const std::string&) {}); + client_->setOnParticipantVideo([](const std::vector&, bool) {}); + client_->setOnVideoSubscribed([](int, int, const std::string&) {}); } } }; @@ -312,9 +451,25 @@ PYBIND11_MODULE(_rtms, m) { .def_property_readonly("id", &Participant::id) .def_property_readonly("name", &Participant::name); + py::class_(m, "AiTargetLanguage") + .def_property_readonly("lid", &AiTargetLanguage::lid) + .def_property_readonly("toneId", &AiTargetLanguage::toneId) + .def_property_readonly("voiceId", &AiTargetLanguage::voiceId) + .def_property_readonly("engine", &AiTargetLanguage::engine); + + py::class_(m, "AiInterpreter") + .def_property_readonly("lid", &AiInterpreter::lid) + .def_property_readonly("timestamp", &AiInterpreter::timestamp) + .def_property_readonly("channelNum", &AiInterpreter::channelNum) + .def_property_readonly("sampleRate", &AiInterpreter::sampleRate) + .def_property_readonly("targets", &AiInterpreter::targets); + py::class_(m, "Metadata") .def_property_readonly("userName", &Metadata::userName) - .def_property_readonly("userId", &Metadata::userId); + .def_property_readonly("userId", &Metadata::userId) + .def_property_readonly("startTs", &Metadata::startTs) + .def_property_readonly("endTs", &Metadata::endTs) + .def_property_readonly("aiInterpreter", &Metadata::aiInterpreter); // ======================================================================== // Parameter Classes @@ -326,34 +481,102 @@ PYBIND11_MODULE(_rtms, m) { py::arg("content_type"), py::arg("codec"), py::arg("sample_rate"), py::arg("channel"), py::arg("data_opt"), py::arg("duration"), py::arg("frame_size")) + // camelCase (primary — kept for backwards compat) .def_property("contentType", &AudioParams::contentType, &AudioParams::setContentType) - .def_property("codec", &AudioParams::codec, &AudioParams::setCodec) - .def_property("sampleRate", &AudioParams::sampleRate, &AudioParams::setSampleRate) - .def_property("channel", &AudioParams::channel, &AudioParams::setChannel) - .def_property("dataOpt", &AudioParams::dataOpt, &AudioParams::setDataOpt) - .def_property("duration", &AudioParams::duration, &AudioParams::setDuration) - .def_property("frameSize", &AudioParams::frameSize, &AudioParams::setFrameSize); + .def_property("codec", &AudioParams::codec, &AudioParams::setCodec) + .def_property("sampleRate", &AudioParams::sampleRate, &AudioParams::setSampleRate) + .def_property("channel", &AudioParams::channel, &AudioParams::setChannel) + .def_property("dataOpt", &AudioParams::dataOpt, &AudioParams::setDataOpt) + .def_property("duration", &AudioParams::duration, &AudioParams::setDuration) + .def_property("frameSize", &AudioParams::frameSize, &AudioParams::setFrameSize) + // snake_case aliases + .def_property("content_type", &AudioParams::contentType, &AudioParams::setContentType) + .def_property("sample_rate", &AudioParams::sampleRate, &AudioParams::setSampleRate) + .def_property("data_opt", &AudioParams::dataOpt, &AudioParams::setDataOpt) + .def_property("frame_size", &AudioParams::frameSize, &AudioParams::setFrameSize); py::class_(m, "VideoParams") .def(py::init<>()) .def(py::init(), py::arg("content_type"), py::arg("codec"), py::arg("resolution"), py::arg("data_opt"), py::arg("fps")) + // camelCase (primary — kept for backwards compat) .def_property("contentType", &VideoParams::contentType, &VideoParams::setContentType) - .def_property("codec", &VideoParams::codec, &VideoParams::setCodec) - .def_property("resolution", &VideoParams::resolution, &VideoParams::setResolution) - .def_property("dataOpt", &VideoParams::dataOpt, &VideoParams::setDataOpt) - .def_property("fps", &VideoParams::fps, &VideoParams::setFps); + .def_property("codec", &VideoParams::codec, &VideoParams::setCodec) + .def_property("resolution", &VideoParams::resolution, &VideoParams::setResolution) + .def_property("dataOpt", &VideoParams::dataOpt, &VideoParams::setDataOpt) + .def_property("fps", &VideoParams::fps, &VideoParams::setFps) + // snake_case aliases + .def_property("content_type", &VideoParams::contentType, &VideoParams::setContentType) + .def_property("data_opt", &VideoParams::dataOpt, &VideoParams::setDataOpt); py::class_(m, "DeskshareParams") .def(py::init<>()) .def(py::init(), py::arg("content_type"), py::arg("codec"), py::arg("resolution"), py::arg("fps")) + // camelCase (primary — kept for backwards compat) .def_property("contentType", &DeskshareParams::contentType, &DeskshareParams::setContentType) - .def_property("codec", &DeskshareParams::codec, &DeskshareParams::setCodec) - .def_property("resolution", &DeskshareParams::resolution, &DeskshareParams::setResolution) - .def_property("fps", &DeskshareParams::fps, &DeskshareParams::setFps); + .def_property("codec", &DeskshareParams::codec, &DeskshareParams::setCodec) + .def_property("resolution", &DeskshareParams::resolution, &DeskshareParams::setResolution) + .def_property("fps", &DeskshareParams::fps, &DeskshareParams::setFps) + // snake_case aliases + .def_property("content_type", &DeskshareParams::contentType, &DeskshareParams::setContentType) + .def_property("data_opt", &DeskshareParams::dataOpt, &DeskshareParams::setDataOpt); + + py::class_(m, "TranscriptParams") + .def(py::init<>()) + // snake_case (primary) + .def_property("content_type", &TranscriptParams::contentType, &TranscriptParams::setContentType) + .def_property("src_language", &TranscriptParams::srcLanguage, &TranscriptParams::setSrcLanguage) + .def_property("enable_lid", &TranscriptParams::enableLid, &TranscriptParams::setEnableLid) + // camelCase aliases + .def_property("contentType", &TranscriptParams::contentType, &TranscriptParams::setContentType) + .def_property("srcLanguage", &TranscriptParams::srcLanguage, &TranscriptParams::setSrcLanguage) + .def_property("enableLid", &TranscriptParams::enableLid, &TranscriptParams::setEnableLid); + + // TranscriptLanguage constants dict (matches pattern of AudioCodec, VideoCodec, etc.) + // Values sourced from the TRANSCRIPT_LANGUAGE enum in rtms.h + py::dict transcriptLanguage; + transcriptLanguage["NONE"] = (int)TRANSCRIPT_LANGUAGE::NONE; + transcriptLanguage["ARABIC"] = (int)TRANSCRIPT_LANGUAGE::ARABIC; + transcriptLanguage["BENGALI"] = (int)TRANSCRIPT_LANGUAGE::BENGALI; + transcriptLanguage["CANTONESE"] = (int)TRANSCRIPT_LANGUAGE::CANTONESE; + transcriptLanguage["CATALAN"] = (int)TRANSCRIPT_LANGUAGE::CATALAN; + transcriptLanguage["CHINESE_SIMPLIFIED"] = (int)TRANSCRIPT_LANGUAGE::CHINESE_SIMPLIFIED; + transcriptLanguage["CHINESE_TRADITIONAL"] = (int)TRANSCRIPT_LANGUAGE::CHINESE_TRADITIONAL; + transcriptLanguage["CZECH"] = (int)TRANSCRIPT_LANGUAGE::CZECH; + transcriptLanguage["DANISH"] = (int)TRANSCRIPT_LANGUAGE::DANISH; + transcriptLanguage["DUTCH"] = (int)TRANSCRIPT_LANGUAGE::DUTCH; + transcriptLanguage["ENGLISH"] = (int)TRANSCRIPT_LANGUAGE::ENGLISH; + transcriptLanguage["ESTONIAN"] = (int)TRANSCRIPT_LANGUAGE::ESTONIAN; + transcriptLanguage["FINNISH"] = (int)TRANSCRIPT_LANGUAGE::FINNISH; + transcriptLanguage["FRENCH_CANADA"] = (int)TRANSCRIPT_LANGUAGE::FRENCH_CANADA; + transcriptLanguage["FRENCH_FRANCE"] = (int)TRANSCRIPT_LANGUAGE::FRENCH_FRANCE; + transcriptLanguage["GERMAN"] = (int)TRANSCRIPT_LANGUAGE::GERMAN; + transcriptLanguage["HEBREW"] = (int)TRANSCRIPT_LANGUAGE::HEBREW; + transcriptLanguage["HINDI"] = (int)TRANSCRIPT_LANGUAGE::HINDI; + transcriptLanguage["HUNGARIAN"] = (int)TRANSCRIPT_LANGUAGE::HUNGARIAN; + transcriptLanguage["INDONESIAN"] = (int)TRANSCRIPT_LANGUAGE::INDONESIAN; + transcriptLanguage["ITALIAN"] = (int)TRANSCRIPT_LANGUAGE::ITALIAN; + transcriptLanguage["JAPANESE"] = (int)TRANSCRIPT_LANGUAGE::JAPANESE; + transcriptLanguage["KOREAN"] = (int)TRANSCRIPT_LANGUAGE::KOREAN; + transcriptLanguage["MALAY"] = (int)TRANSCRIPT_LANGUAGE::MALAY; + transcriptLanguage["PERSIAN"] = (int)TRANSCRIPT_LANGUAGE::PERSIAN; + transcriptLanguage["POLISH"] = (int)TRANSCRIPT_LANGUAGE::POLISH; + transcriptLanguage["PORTUGUESE"] = (int)TRANSCRIPT_LANGUAGE::PORTUGUESE; + transcriptLanguage["ROMANIAN"] = (int)TRANSCRIPT_LANGUAGE::ROMANIAN; + transcriptLanguage["RUSSIAN"] = (int)TRANSCRIPT_LANGUAGE::RUSSIAN; + transcriptLanguage["SPANISH"] = (int)TRANSCRIPT_LANGUAGE::SPANISH; + transcriptLanguage["SWEDISH"] = (int)TRANSCRIPT_LANGUAGE::SWEDISH; + transcriptLanguage["TAGALOG"] = (int)TRANSCRIPT_LANGUAGE::TAGALOG; + transcriptLanguage["TAMIL"] = (int)TRANSCRIPT_LANGUAGE::TAMIL; + transcriptLanguage["TELUGU"] = (int)TRANSCRIPT_LANGUAGE::TELUGU; + transcriptLanguage["THAI"] = (int)TRANSCRIPT_LANGUAGE::THAI; + transcriptLanguage["TURKISH"] = (int)TRANSCRIPT_LANGUAGE::TURKISH; + transcriptLanguage["UKRAINIAN"] = (int)TRANSCRIPT_LANGUAGE::UKRAINIAN; + transcriptLanguage["VIETNAMESE"] = (int)TRANSCRIPT_LANGUAGE::VIETNAMESE; + m.attr("TranscriptLanguage") = transcriptLanguage; // ======================================================================== // Client Class @@ -368,6 +591,12 @@ PYBIND11_MODULE(_rtms, m) { py::arg("ca_path"), py::arg("is_verify_cert") = 1, py::arg("agent") = nullptr) .def_static("uninitialize", &Client::uninitialize, "Uninitialize the RTMS SDK") + .def("alloc", &PyClient::alloc, + "Allocate the C SDK handle. Must be called from the thread that will own this " + "client (same thread must call join/poll/release). Replays any callbacks and " + "params registered before alloc().") + .def("is_allocated", &PyClient::isAllocated, + "Return True if alloc() has been called for this client") .def("join", &PyClient::join, "Join an RTMS session", py::arg("uuid"), py::arg("stream_id"), py::arg("signature"), @@ -378,43 +607,107 @@ PYBIND11_MODULE(_rtms, m) { "Release client resources") .def("uuid", &PyClient::uuid, "Get meeting UUID") + .def("stream_id", &PyClient::streamId, + "Get stream ID") .def("streamId", &PyClient::streamId, "Get stream ID") + .def("enable_audio", &PyClient::enableAudio, + "Enable/disable audio streaming") .def("enableAudio", &PyClient::enableAudio, "Enable/disable audio streaming") + .def("enable_video", &PyClient::enableVideo, + "Enable/disable video streaming") .def("enableVideo", &PyClient::enableVideo, "Enable/disable video streaming") + .def("enable_transcript", &PyClient::enableTranscript, + "Enable/disable transcript streaming") .def("enableTranscript", &PyClient::enableTranscript, "Enable/disable transcript streaming") + .def("enable_deskshare", &PyClient::enableDeskshare, + "Enable/disable deskshare streaming") .def("enableDeskshare", &PyClient::enableDeskshare, "Enable/disable deskshare streaming") + .def("set_audio_params", &PyClient::setAudioParams, + "Set audio parameters") .def("setAudioParams", &PyClient::setAudioParams, "Set audio parameters") + .def("set_video_params", &PyClient::setVideoParams, + "Set video parameters") .def("setVideoParams", &PyClient::setVideoParams, "Set video parameters") + .def("set_deskshare_params", &PyClient::setDeskshareParams, + "Set deskshare parameters") .def("setDeskshareParams", &PyClient::setDeskshareParams, "Set deskshare parameters") + .def("set_transcript_params", &PyClient::setTranscriptParams, + "Set transcript parameters") + .def("setTranscriptParams", &PyClient::setTranscriptParams, + "Set transcript parameters") + .def("set_proxy", &PyClient::setProxy, + "Set proxy for SDK connections", + py::arg("proxy_type"), py::arg("proxy_url")) + .def("setProxy", &PyClient::setProxy, + "Set proxy for SDK connections", + py::arg("proxy_type"), py::arg("proxy_url")) + .def("on_join_confirm", &PyClient::onJoinConfirm, + "Register join confirm callback") .def("onJoinConfirm", &PyClient::onJoinConfirm, "Register join confirm callback") + .def("on_session_update", &PyClient::onSessionUpdate, + "Register session update callback") .def("onSessionUpdate", &PyClient::onSessionUpdate, "Register session update callback") + .def("on_user_update", &PyClient::onUserUpdate, + "Register user update callback") .def("onUserUpdate", &PyClient::onUserUpdate, "Register user update callback") + .def("on_audio_data", &PyClient::onAudioData, + "Register audio data callback") .def("onAudioData", &PyClient::onAudioData, "Register audio data callback") + .def("on_video_data", &PyClient::onVideoData, + "Register video data callback") .def("onVideoData", &PyClient::onVideoData, "Register video data callback") + .def("on_deskshare_data", &PyClient::onDeskshareData, + "Register deskshare data callback") .def("onDeskshareData", &PyClient::onDeskshareData, "Register deskshare data callback") + .def("on_transcript_data", &PyClient::onTranscriptData, + "Register transcript data callback") .def("onTranscriptData", &PyClient::onTranscriptData, "Register transcript data callback") + .def("on_leave", &PyClient::onLeave, + "Register leave callback") .def("onLeave", &PyClient::onLeave, "Register leave callback") + .def("on_event_ex", &PyClient::onEventEx, + "Register extended event callback") .def("onEventEx", &PyClient::onEventEx, "Register extended event callback") + .def("subscribe_video", &PyClient::subscribeVideo, + "Subscribe or unsubscribe from an individual participant's video stream", + py::arg("user_id"), py::arg("subscribe")) + .def("subscribeVideo", &PyClient::subscribeVideo, + "Subscribe or unsubscribe from an individual participant's video stream", + py::arg("user_id"), py::arg("subscribe")) + .def("on_participant_video", &PyClient::onParticipantVideo, + "Register callback for participant video state changes") + .def("onParticipantVideo", &PyClient::onParticipantVideo, + "Register callback for participant video state changes") + .def("on_video_subscribed", &PyClient::onVideoSubscribed, + "Register callback for video subscription responses") + .def("onVideoSubscribed", &PyClient::onVideoSubscribed, + "Register callback for video subscription responses") + .def("subscribe_event", &PyClient::subscribeEvent, + "Subscribe to receive specific event types", + py::arg("events")) .def("subscribeEvent", &PyClient::subscribeEvent, "Subscribe to receive specific event types", py::arg("events")) + .def("unsubscribe_event", &PyClient::unsubscribeEvent, + "Unsubscribe from specific event types", + py::arg("events")) .def("unsubscribeEvent", &PyClient::unsubscribeEvent, "Unsubscribe from specific event types", py::arg("events")); @@ -450,23 +743,25 @@ PYBIND11_MODULE(_rtms, m) { // Constants - Event Types // ======================================================================== // Event Types (for subscribeEvent/unsubscribeEvent - used with onEventEx callback) - // These match RTMS_EVENT_TYPE from Zoom's C SDK - // ======================================================================== - - m.attr("EVENT_UNDEFINED") = py::int_(static_cast(Client::EVENT_UNDEFINED)); - m.attr("EVENT_FIRST_PACKET_TIMESTAMP") = py::int_(static_cast(Client::EVENT_FIRST_PACKET_TIMESTAMP)); - m.attr("EVENT_ACTIVE_SPEAKER_CHANGE") = py::int_(static_cast(Client::EVENT_ACTIVE_SPEAKER_CHANGE)); - m.attr("EVENT_PARTICIPANT_JOIN") = py::int_(static_cast(Client::EVENT_PARTICIPANT_JOIN)); - m.attr("EVENT_PARTICIPANT_LEAVE") = py::int_(static_cast(Client::EVENT_PARTICIPANT_LEAVE)); - m.attr("EVENT_SHARING_START") = py::int_(static_cast(Client::EVENT_SHARING_START)); - m.attr("EVENT_SHARING_STOP") = py::int_(static_cast(Client::EVENT_SHARING_STOP)); - m.attr("EVENT_MEDIA_CONNECTION_INTERRUPTED") = py::int_(static_cast(Client::EVENT_MEDIA_CONNECTION_INTERRUPTED)); - m.attr("EVENT_CONSUMER_ANSWERED") = py::int_(static_cast(Client::EVENT_CONSUMER_ANSWERED)); - m.attr("EVENT_CONSUMER_END") = py::int_(static_cast(Client::EVENT_CONSUMER_END)); - m.attr("EVENT_USER_ANSWERED") = py::int_(static_cast(Client::EVENT_USER_ANSWERED)); - m.attr("EVENT_USER_END") = py::int_(static_cast(Client::EVENT_USER_END)); - m.attr("EVENT_USER_HOLD") = py::int_(static_cast(Client::EVENT_USER_HOLD)); - m.attr("EVENT_USER_UNHOLD") = py::int_(static_cast(Client::EVENT_USER_UNHOLD)); + // These match EVENT_TYPE from Zoom's C SDK + // ======================================================================== + + m.attr("EVENT_UNDEFINED") = py::int_(static_cast(EVENT_TYPE::UNDEFINED)); + m.attr("EVENT_FIRST_PACKET_TIMESTAMP") = py::int_(static_cast(EVENT_TYPE::FIRST_PACKET_TIMESTAMP)); + m.attr("EVENT_ACTIVE_SPEAKER_CHANGE") = py::int_(static_cast(EVENT_TYPE::ACTIVE_SPEAKER_CHANGE)); + m.attr("EVENT_PARTICIPANT_JOIN") = py::int_(static_cast(EVENT_TYPE::PARTICIPANT_JOIN)); + m.attr("EVENT_PARTICIPANT_LEAVE") = py::int_(static_cast(EVENT_TYPE::PARTICIPANT_LEAVE)); + m.attr("EVENT_SHARING_START") = py::int_(static_cast(EVENT_TYPE::SHARING_START)); + m.attr("EVENT_SHARING_STOP") = py::int_(static_cast(EVENT_TYPE::SHARING_STOP)); + m.attr("EVENT_MEDIA_CONNECTION_INTERRUPTED") = py::int_(static_cast(EVENT_TYPE::MEDIA_CONNECTION_INTERRUPTED)); + m.attr("EVENT_PARTICIPANT_VIDEO_ON") = py::int_(static_cast(EVENT_TYPE::PARTICIPANT_VIDEO_ON)); + m.attr("EVENT_PARTICIPANT_VIDEO_OFF") = py::int_(static_cast(EVENT_TYPE::PARTICIPANT_VIDEO_OFF)); + m.attr("EVENT_CONSUMER_ANSWERED") = py::int_(static_cast(ZCC_VOICE_EVENT_TYPE::CONSUMER_ANSWERED)); + m.attr("EVENT_CONSUMER_END") = py::int_(static_cast(ZCC_VOICE_EVENT_TYPE::CONSUMER_END)); + m.attr("EVENT_USER_ANSWERED") = py::int_(static_cast(ZCC_VOICE_EVENT_TYPE::USER_ANSWERED)); + m.attr("EVENT_USER_END") = py::int_(static_cast(ZCC_VOICE_EVENT_TYPE::USER_END)); + m.attr("EVENT_USER_HOLD") = py::int_(static_cast(ZCC_VOICE_EVENT_TYPE::USER_HOLD)); + m.attr("EVENT_USER_UNHOLD") = py::int_(static_cast(ZCC_VOICE_EVENT_TYPE::USER_UNHOLD)); // ======================================================================== // Constants - Error Codes @@ -492,84 +787,87 @@ PYBIND11_MODULE(_rtms, m) { // ======================================================================== py::dict audioContentType; - audioContentType["UNDEFINED"] = 0; - audioContentType["RTP"] = 1; - audioContentType["RAW_AUDIO"] = 2; - audioContentType["FILE_STREAM"] = 4; - audioContentType["TEXT"] = 5; + audioContentType["UNDEFINED"] = (int)MEDIA_CONTENT_TYPE::UNDEFINED; + audioContentType["RTP"] = (int)MEDIA_CONTENT_TYPE::RTP; + audioContentType["RAW_AUDIO"] = (int)MEDIA_CONTENT_TYPE::RAW_AUDIO; + audioContentType["FILE_STREAM"] = (int)MEDIA_CONTENT_TYPE::FILE_STREAM; + audioContentType["TEXT"] = (int)MEDIA_CONTENT_TYPE::TEXT; m.attr("AudioContentType") = audioContentType; py::dict audioCodec; - audioCodec["UNDEFINED"] = 0; - audioCodec["L16"] = 1; - audioCodec["G711"] = 2; - audioCodec["G722"] = 3; - audioCodec["OPUS"] = 4; + audioCodec["UNDEFINED"] = (int)MEDIA_PAYLOAD_TYPE::UNDEFINED; + audioCodec["L16"] = (int)MEDIA_PAYLOAD_TYPE::L16; + audioCodec["G711"] = (int)MEDIA_PAYLOAD_TYPE::G711; + audioCodec["G722"] = (int)MEDIA_PAYLOAD_TYPE::G722; + audioCodec["OPUS"] = (int)MEDIA_PAYLOAD_TYPE::OPUS; m.attr("AudioCodec") = audioCodec; py::dict audioSampleRate; - audioSampleRate["SR_8K"] = 0; - audioSampleRate["SR_16K"] = 1; - audioSampleRate["SR_32K"] = 2; - audioSampleRate["SR_48K"] = 3; + audioSampleRate["SR_8K"] = (int)AUDIO_SAMPLE_RATE::SR_8K; + audioSampleRate["SR_16K"] = (int)AUDIO_SAMPLE_RATE::SR_16K; + audioSampleRate["SR_32K"] = (int)AUDIO_SAMPLE_RATE::SR_32K; + audioSampleRate["SR_48K"] = (int)AUDIO_SAMPLE_RATE::SR_48K; m.attr("AudioSampleRate") = audioSampleRate; py::dict audioChannel; - audioChannel["MONO"] = 1; - audioChannel["STEREO"] = 2; + audioChannel["MONO"] = (int)AUDIO_CHANNEL::MONO; + audioChannel["STEREO"] = (int)AUDIO_CHANNEL::STEREO; m.attr("AudioChannel") = audioChannel; - py::dict audioDataOption; - audioDataOption["UNDEFINED"] = 0; - audioDataOption["AUDIO_MIXED_STREAM"] = 1; - audioDataOption["AUDIO_MULTI_STREAMS"] = 2; - m.attr("AudioDataOption") = audioDataOption; + // DataOption — unified dict for all audio and video stream delivery modes. + // Mirrors the C++ MEDIA_DATA_OPTION enum directly. + // AudioDataOption and VideoDataOption are kept as backward-compat aliases. + py::dict dataOption; + dataOption["UNDEFINED"] = (int)MEDIA_DATA_OPTION::UNDEFINED; + dataOption["AUDIO_MIXED_STREAM"] = (int)MEDIA_DATA_OPTION::AUDIO_MIXED_STREAM; + dataOption["AUDIO_MULTI_STREAMS"] = (int)MEDIA_DATA_OPTION::AUDIO_MULTI_STREAMS; + dataOption["VIDEO_SINGLE_ACTIVE_STREAM"] = (int)MEDIA_DATA_OPTION::VIDEO_SINGLE_ACTIVE_STREAM; + dataOption["VIDEO_SINGLE_INDIVIDUAL_STREAM"] = (int)MEDIA_DATA_OPTION::VIDEO_SINGLE_INDIVIDUAL_STREAM; + dataOption["VIDEO_MIXED_SPEAKER_VIEW"] = (int)MEDIA_DATA_OPTION::VIDEO_SINGLE_INDIVIDUAL_STREAM; // legacy alias + dataOption["VIDEO_MIXED_GALLERY_VIEW"] = (int)MEDIA_DATA_OPTION::VIDEO_MIXED_GALLERY_VIEW; + m.attr("DataOption") = dataOption; + m.attr("AudioDataOption") = dataOption; // legacy alias + m.attr("VideoDataOption") = dataOption; // legacy alias // ======================================================================== // Constants - Video Parameters // ======================================================================== py::dict videoContentType; - videoContentType["UNDEFINED"] = 0; - videoContentType["RTP"] = 1; - videoContentType["RAW_VIDEO"] = 3; - videoContentType["FILE_STREAM"] = 4; - videoContentType["TEXT"] = 5; + videoContentType["UNDEFINED"] = (int)MEDIA_CONTENT_TYPE::UNDEFINED; + videoContentType["RTP"] = (int)MEDIA_CONTENT_TYPE::RTP; + videoContentType["RAW_VIDEO"] = (int)MEDIA_CONTENT_TYPE::RAW_VIDEO; + videoContentType["FILE_STREAM"] = (int)MEDIA_CONTENT_TYPE::FILE_STREAM; + videoContentType["TEXT"] = (int)MEDIA_CONTENT_TYPE::TEXT; m.attr("VideoContentType") = videoContentType; py::dict videoCodec; - videoCodec["UNDEFINED"] = 0; - videoCodec["JPG"] = 5; - videoCodec["PNG"] = 6; - videoCodec["H264"] = 7; + videoCodec["UNDEFINED"] = (int)MEDIA_PAYLOAD_TYPE::UNDEFINED; + videoCodec["JPG"] = (int)MEDIA_PAYLOAD_TYPE::JPG; + videoCodec["PNG"] = (int)MEDIA_PAYLOAD_TYPE::PNG; + videoCodec["H264"] = (int)MEDIA_PAYLOAD_TYPE::H264; m.attr("VideoCodec") = videoCodec; py::dict videoResolution; - videoResolution["SD"] = 1; - videoResolution["HD"] = 2; - videoResolution["FHD"] = 3; - videoResolution["QHD"] = 4; + videoResolution["SD"] = (int)MEDIA_RESOLUTION::SD; + videoResolution["HD"] = (int)MEDIA_RESOLUTION::HD; + videoResolution["FHD"] = (int)MEDIA_RESOLUTION::FHD; + videoResolution["QHD"] = (int)MEDIA_RESOLUTION::QHD; m.attr("VideoResolution") = videoResolution; - py::dict videoDataOption; - videoDataOption["UNDEFINED"] = 0; - videoDataOption["VIDEO_SINGLE_ACTIVE_STREAM"] = 3; - videoDataOption["VIDEO_MIXED_SPEAKER_VIEW"] = 4; - videoDataOption["VIDEO_MIXED_GALLERY_VIEW"] = 5; - m.attr("VideoDataOption") = videoDataOption; // ======================================================================== // Constants - Media Data Types // ======================================================================== py::dict mediaDataType; - mediaDataType["UNDEFINED"] = 0; - mediaDataType["AUDIO"] = 1; - mediaDataType["VIDEO"] = 2; - mediaDataType["DESKSHARE"] = 4; - mediaDataType["TRANSCRIPT"] = 8; - mediaDataType["CHAT"] = 16; - mediaDataType["ALL"] = 31; + mediaDataType["UNDEFINED"] = (int)MEDIA_DATA_TYPE::UNDEFINED; + mediaDataType["AUDIO"] = (int)MEDIA_DATA_TYPE::AUDIO; + mediaDataType["VIDEO"] = (int)MEDIA_DATA_TYPE::VIDEO; + mediaDataType["DESKSHARE"] = (int)MEDIA_DATA_TYPE::DESKSHARE; + mediaDataType["TRANSCRIPT"] = (int)MEDIA_DATA_TYPE::TRANSCRIPT; + mediaDataType["CHAT"] = (int)MEDIA_DATA_TYPE::CHAT; + mediaDataType["ALL"] = (int)MEDIA_DATA_TYPE::ALL; m.attr("MediaDataType") = mediaDataType; // ======================================================================== @@ -577,12 +875,12 @@ PYBIND11_MODULE(_rtms, m) { // ======================================================================== py::dict sessionState; - sessionState["INACTIVE"] = 0; - sessionState["INITIALIZE"] = 1; - sessionState["STARTED"] = 2; - sessionState["PAUSED"] = 3; - sessionState["RESUMED"] = 4; - sessionState["STOPPED"] = 5; + sessionState["INACTIVE"] = (int)SESSION_STATE::INACTIVE; + sessionState["INITIALIZE"] = (int)SESSION_STATE::INITIALIZE; + sessionState["STARTED"] = (int)SESSION_STATE::STARTED; + sessionState["PAUSED"] = (int)SESSION_STATE::PAUSED; + sessionState["RESUMED"] = (int)SESSION_STATE::RESUMED; + sessionState["STOPPED"] = (int)SESSION_STATE::STOPPED; m.attr("SessionState") = sessionState; // ======================================================================== @@ -590,32 +888,37 @@ PYBIND11_MODULE(_rtms, m) { // ======================================================================== py::dict streamState; - streamState["INACTIVE"] = 0; - streamState["ACTIVE"] = 1; - streamState["INTERRUPTED"] = 2; - streamState["TERMINATING"] = 3; - streamState["TERMINATED"] = 4; + streamState["INACTIVE"] = (int)STREAM_STATE::INACTIVE; + streamState["ACTIVE"] = (int)STREAM_STATE::ACTIVE; + streamState["INTERRUPTED"] = (int)STREAM_STATE::INTERRUPTED; + streamState["TERMINATING"] = (int)STREAM_STATE::TERMINATING; + streamState["TERMINATED"] = (int)STREAM_STATE::TERMINATED; + streamState["PAUSED"] = (int)STREAM_STATE::PAUSED; + streamState["RESUMED"] = (int)STREAM_STATE::RESUMED; m.attr("StreamState") = streamState; // ======================================================================== - // Constants - Event Type (matches RTMS_EVENT_TYPE from Zoom's C SDK) + // Constants - Event Type (matches EVENT_TYPE from Zoom's C SDK) // ======================================================================== py::dict eventType; - eventType["UNDEFINED"] = 0; - eventType["FIRST_PACKET_TIMESTAMP"] = 1; - eventType["ACTIVE_SPEAKER_CHANGE"] = 2; - eventType["PARTICIPANT_JOIN"] = 3; - eventType["PARTICIPANT_LEAVE"] = 4; - eventType["SHARING_START"] = 5; - eventType["SHARING_STOP"] = 6; - eventType["MEDIA_CONNECTION_INTERRUPTED"] = 7; - eventType["CONSUMER_ANSWERED"] = 8; - eventType["CONSUMER_END"] = 9; - eventType["USER_ANSWERED"] = 10; - eventType["USER_END"] = 11; - eventType["USER_HOLD"] = 12; - eventType["USER_UNHOLD"] = 13; + eventType["UNDEFINED"] = (int)EVENT_TYPE::UNDEFINED; + eventType["FIRST_PACKET_TIMESTAMP"] = (int)EVENT_TYPE::FIRST_PACKET_TIMESTAMP; + eventType["ACTIVE_SPEAKER_CHANGE"] = (int)EVENT_TYPE::ACTIVE_SPEAKER_CHANGE; + eventType["PARTICIPANT_JOIN"] = (int)EVENT_TYPE::PARTICIPANT_JOIN; + eventType["PARTICIPANT_LEAVE"] = (int)EVENT_TYPE::PARTICIPANT_LEAVE; + eventType["SHARING_START"] = (int)EVENT_TYPE::SHARING_START; + eventType["SHARING_STOP"] = (int)EVENT_TYPE::SHARING_STOP; + eventType["MEDIA_CONNECTION_INTERRUPTED"] = (int)EVENT_TYPE::MEDIA_CONNECTION_INTERRUPTED; + eventType["PARTICIPANT_VIDEO_ON"] = (int)EVENT_TYPE::PARTICIPANT_VIDEO_ON; + eventType["PARTICIPANT_VIDEO_OFF"] = (int)EVENT_TYPE::PARTICIPANT_VIDEO_OFF; + // ZCC voice events + eventType["CONSUMER_ANSWERED"] = (int)ZCC_VOICE_EVENT_TYPE::CONSUMER_ANSWERED; + eventType["CONSUMER_END"] = (int)ZCC_VOICE_EVENT_TYPE::CONSUMER_END; + eventType["USER_ANSWERED"] = (int)ZCC_VOICE_EVENT_TYPE::USER_ANSWERED; + eventType["USER_END"] = (int)ZCC_VOICE_EVENT_TYPE::USER_END; + eventType["USER_HOLD"] = (int)ZCC_VOICE_EVENT_TYPE::USER_HOLD; + eventType["USER_UNHOLD"] = (int)ZCC_VOICE_EVENT_TYPE::USER_UNHOLD; m.attr("EventType") = eventType; // ======================================================================== @@ -623,25 +926,36 @@ PYBIND11_MODULE(_rtms, m) { // ======================================================================== py::dict messageType; - messageType["UNDEFINED"] = 0; - messageType["SIGNALING_HAND_SHAKE_REQ"] = 1; - messageType["SIGNALING_HAND_SHAKE_RESP"] = 2; - messageType["DATA_HAND_SHAKE_REQ"] = 3; - messageType["DATA_HAND_SHAKE_RESP"] = 4; - messageType["EVENT_SUBSCRIPTION"] = 5; - messageType["EVENT_UPDATE"] = 6; - messageType["CLIENT_READY_ACK"] = 7; - messageType["STREAM_STATE_UPDATE"] = 8; - messageType["SESSION_STATE_UPDATE"] = 9; - messageType["SESSION_STATE_REQ"] = 10; - messageType["SESSION_STATE_RESP"] = 11; - messageType["KEEP_ALIVE_REQ"] = 12; - messageType["KEEP_ALIVE_RESP"] = 13; - messageType["MEDIA_DATA_AUDIO"] = 14; - messageType["MEDIA_DATA_VIDEO"] = 15; - messageType["MEDIA_DATA_SHARE"] = 16; - messageType["MEDIA_DATA_TRANSCRIPT"] = 17; - messageType["MEDIA_DATA_CHAT"] = 18; + messageType["UNDEFINED"] = (int)MESSAGE_TYPE::UNDEFINED; + messageType["SIGNALING_HAND_SHAKE_REQ"] = (int)MESSAGE_TYPE::SIGNALING_HAND_SHAKE_REQ; + messageType["SIGNALING_HAND_SHAKE_RESP"] = (int)MESSAGE_TYPE::SIGNALING_HAND_SHAKE_RESP; + messageType["DATA_HAND_SHAKE_REQ"] = (int)MESSAGE_TYPE::DATA_HAND_SHAKE_REQ; + messageType["DATA_HAND_SHAKE_RESP"] = (int)MESSAGE_TYPE::DATA_HAND_SHAKE_RESP; + messageType["EVENT_SUBSCRIPTION"] = (int)MESSAGE_TYPE::EVENT_SUBSCRIPTION; + messageType["EVENT_UPDATE"] = (int)MESSAGE_TYPE::EVENT_UPDATE; + messageType["CLIENT_READY_ACK"] = (int)MESSAGE_TYPE::CLIENT_READY_ACK; + messageType["STREAM_STATE_UPDATE"] = (int)MESSAGE_TYPE::STREAM_STATE_UPDATE; + messageType["SESSION_STATE_UPDATE"] = (int)MESSAGE_TYPE::SESSION_STATE_UPDATE; + messageType["SESSION_STATE_REQ"] = (int)MESSAGE_TYPE::SESSION_STATE_REQ; + messageType["SESSION_STATE_RESP"] = (int)MESSAGE_TYPE::SESSION_STATE_RESP; + messageType["KEEP_ALIVE_REQ"] = (int)MESSAGE_TYPE::KEEP_ALIVE_REQ; + messageType["KEEP_ALIVE_RESP"] = (int)MESSAGE_TYPE::KEEP_ALIVE_RESP; + messageType["MEDIA_DATA_AUDIO"] = (int)MESSAGE_TYPE::MEDIA_DATA_AUDIO; + messageType["MEDIA_DATA_VIDEO"] = (int)MESSAGE_TYPE::MEDIA_DATA_VIDEO; + messageType["MEDIA_DATA_SHARE"] = (int)MESSAGE_TYPE::MEDIA_DATA_SHARE; + messageType["MEDIA_DATA_TRANSCRIPT"] = (int)MESSAGE_TYPE::MEDIA_DATA_TRANSCRIPT; + messageType["MEDIA_DATA_CHAT"] = (int)MESSAGE_TYPE::MEDIA_DATA_CHAT; + messageType["STREAM_STATE_REQ"] = (int)MESSAGE_TYPE::STREAM_STATE_REQ; + messageType["STREAM_STATE_RESP"] = (int)MESSAGE_TYPE::STREAM_STATE_RESP; + messageType["STREAM_CLOSE_REQ"] = (int)MESSAGE_TYPE::STREAM_CLOSE_REQ; + messageType["STREAM_CLOSE_RESP"] = (int)MESSAGE_TYPE::STREAM_CLOSE_RESP; + messageType["META_DATA_AUDIO"] = (int)MESSAGE_TYPE::META_DATA_AUDIO; + messageType["META_DATA_VIDEO"] = (int)MESSAGE_TYPE::META_DATA_VIDEO; + messageType["META_DATA_SHARE"] = (int)MESSAGE_TYPE::META_DATA_SHARE; + messageType["META_DATA_TRANSCRIPT"] = (int)MESSAGE_TYPE::META_DATA_TRANSCRIPT; + messageType["META_DATA_CHAT"] = (int)MESSAGE_TYPE::META_DATA_CHAT; + messageType["VIDEO_SUBSCRIPTION_REQ"] = (int)MESSAGE_TYPE::VIDEO_SUBSCRIPTION_REQ; + messageType["VIDEO_SUBSCRIPTION_RESP"] = (int)MESSAGE_TYPE::VIDEO_SUBSCRIPTION_RESP; m.attr("MessageType") = messageType; // ======================================================================== @@ -649,24 +963,32 @@ PYBIND11_MODULE(_rtms, m) { // ======================================================================== py::dict stopReason; - stopReason["UNDEFINED"] = 0; - stopReason["STOP_BC_HOST_TRIGGERED"] = 1; - stopReason["STOP_BC_USER_TRIGGERED"] = 2; - stopReason["STOP_BC_USER_LEFT"] = 3; - stopReason["STOP_BC_USER_EJECTED"] = 4; - stopReason["STOP_BC_APP_DISABLED_BY_HOST"] = 5; - stopReason["STOP_BC_MEETING_ENDED"] = 6; - stopReason["STOP_BC_STREAM_CANCELED"] = 7; - stopReason["STOP_BC_STREAM_REVOKED"] = 8; - stopReason["STOP_BC_ALL_APPS_DISABLED"] = 9; - stopReason["STOP_BC_INTERNAL_EXCEPTION"] = 10; - stopReason["STOP_BC_CONNECTION_TIMEOUT"] = 11; - stopReason["STOP_BC_MEETING_CONNECTION_INTERRUPTED"] = 12; - stopReason["STOP_BC_SIGNAL_CONNECTION_INTERRUPTED"] = 13; - stopReason["STOP_BC_DATA_CONNECTION_INTERRUPTED"] = 14; - stopReason["STOP_BC_SIGNAL_CONNECTION_CLOSED_ABNORMALLY"] = 15; - stopReason["STOP_BC_DATA_CONNECTION_CLOSED_ABNORMALLY"] = 16; - stopReason["STOP_BC_EXIT_SIGNAL"] = 17; - stopReason["STOP_BC_AUTHENTICATION_FAILURE"] = 18; + stopReason["UNDEFINED"] = (int)STOP_REASON::UNDEFINED; + stopReason["HOST_TRIGGERED"] = (int)STOP_REASON::HOST_TRIGGERED; + stopReason["USER_TRIGGERED"] = (int)STOP_REASON::USER_TRIGGERED; + stopReason["USER_LEFT"] = (int)STOP_REASON::USER_LEFT; + stopReason["USER_EJECTED"] = (int)STOP_REASON::USER_EJECTED; + stopReason["HOST_DISABLED_APP"] = (int)STOP_REASON::HOST_DISABLED_APP; + stopReason["MEETING_ENDED"] = (int)STOP_REASON::MEETING_ENDED; + stopReason["STREAM_CANCELED"] = (int)STOP_REASON::STREAM_CANCELED; + stopReason["STREAM_REVOKED"] = (int)STOP_REASON::STREAM_REVOKED; + stopReason["ALL_APPS_DISABLED"] = (int)STOP_REASON::ALL_APPS_DISABLED; + stopReason["INTERNAL_EXCEPTION"] = (int)STOP_REASON::INTERNAL_EXCEPTION; + stopReason["CONNECTION_TIMEOUT"] = (int)STOP_REASON::CONNECTION_TIMEOUT; + stopReason["INSTANCE_CONNECTION_INTERRUPTED"] = (int)STOP_REASON::INSTANCE_CONNECTION_INTERRUPTED; + stopReason["SIGNAL_CONNECTION_INTERRUPTED"] = (int)STOP_REASON::SIGNAL_CONNECTION_INTERRUPTED; + stopReason["DATA_CONNECTION_INTERRUPTED"] = (int)STOP_REASON::DATA_CONNECTION_INTERRUPTED; + stopReason["SIGNAL_CONNECTION_CLOSED_ABNORMALLY"] = (int)STOP_REASON::SIGNAL_CONNECTION_CLOSED_ABNORMALLY; + stopReason["DATA_CONNECTION_CLOSED_ABNORMALLY"] = (int)STOP_REASON::DATA_CONNECTION_CLOSED_ABNORMALLY; + stopReason["EXIT_SIGNAL"] = (int)STOP_REASON::EXIT_SIGNAL; + stopReason["AUTHENTICATION_FAILURE"] = (int)STOP_REASON::AUTHENTICATION_FAILURE; + stopReason["AWAIT_RECONNECTION_TIMEOUT"] = (int)STOP_REASON::AWAIT_RECONNECTION_TIMEOUT; + stopReason["RECEIVER_REQUEST_CLOSE"] = (int)STOP_REASON::RECEIVER_REQUEST_CLOSE; + stopReason["CUSTOMER_DISCONNECTED"] = (int)STOP_REASON::CUSTOMER_DISCONNECTED; + stopReason["AGENT_DISCONNECTED"] = (int)STOP_REASON::AGENT_DISCONNECTED; + stopReason["ADMIN_DISABLED_APP"] = (int)STOP_REASON::ADMIN_DISABLED_APP; + stopReason["KEEP_ALIVE_TIMEOUT"] = (int)STOP_REASON::KEEP_ALIVE_TIMEOUT; + stopReason["MANUAL_API_TRIGGERED"] = (int)STOP_REASON::MANUAL_API_TRIGGERED; + stopReason["STREAMING_NOT_SUPPORTED"] = (int)STOP_REASON::STREAMING_NOT_SUPPORTED; m.attr("StopReason") = stopReason; } diff --git a/src/rtms.cpp b/src/rtms.cpp index 04f1910..d642e25 100644 --- a/src/rtms.cpp +++ b/src/rtms.cpp @@ -5,9 +5,6 @@ namespace rtms { -unordered_map Client::sdk_registry_; -mutex Client::registry_mutex_; - Exception::Exception(int error_code, const string& message) : runtime_error(message), error_code_(error_code) {} @@ -15,18 +12,47 @@ int Exception::code() const noexcept { return error_code_; } -Metadata::Metadata(const rtms_metadata& metadata) - : user_name_(metadata.user_name ? metadata.user_name : ""), - user_id_(metadata.user_id) {} +AiTargetLanguage::AiTargetLanguage(const ai_target_lan& atl) + : lid_(atl.lid), + tone_id_(atl.toneid), + voice_id_(atl.voice_id), + engine_(atl.engine) {} -string Metadata::userName() const { - return user_name_; -} +int AiTargetLanguage::lid() const { return lid_; } +int AiTargetLanguage::toneId() const { return tone_id_; } +string AiTargetLanguage::voiceId() const { return voice_id_; } +string AiTargetLanguage::engine() const { return engine_; } -int Metadata::userId() const { - return user_id_; +AiInterpreter::AiInterpreter(const ai_interpreter& aii) + : lid_(aii.lid), + timestamp_(aii.timestamp), + channel_num_(aii.channel_num), + sample_rate_(aii.sample_rate) { + int count = (aii.target_size > 0 && aii.target_size < 100) ? aii.target_size : 0; + targets_.reserve(count); + for (int i = 0; i < count; ++i) + targets_.emplace_back(aii.atl[i]); } +int AiInterpreter::lid() const { return lid_; } +uint64_t AiInterpreter::timestamp() const { return timestamp_; } +int AiInterpreter::channelNum() const { return channel_num_; } +int AiInterpreter::sampleRate() const { return sample_rate_; } +const vector& AiInterpreter::targets() const { return targets_; } + +Metadata::Metadata(const rtms_metadata& metadata) + : user_name_(metadata.user_name ? metadata.user_name : ""), + user_id_(metadata.user_id), + start_ts_(metadata.start_ts), + end_ts_(metadata.end_ts), + ai_interpreter_(metadata.aii) {} + +string Metadata::userName() const { return user_name_; } +int Metadata::userId() const { return user_id_; } +uint64_t Metadata::startTs() const { return start_ts_; } +uint64_t Metadata::endTs() const { return end_ts_; } +const AiInterpreter& Metadata::aiInterpreter() const { return ai_interpreter_; } + Session::Session(const session_info& info) : stat_time_(info.stat_time), status_(info.status) { @@ -126,7 +152,7 @@ AudioParams::AudioParams() setDataOpt(2); // AUDIO_MULTI_STREAMS - enables per-participant audio with userId } -AudioParams::AudioParams(int content_type, int codec, int sample_rate, +AudioParams::AudioParams(int content_type, int codec, int sample_rate, int channel, int data_opt, int duration, int frame_size) : BaseMediaParams(), sample_rate_(sample_rate), channel_(channel), duration_(duration), frame_size_(frame_size) { @@ -225,7 +251,11 @@ void AudioParams::validate() const { } VideoParams::VideoParams() - : BaseMediaParams(), resolution_(0), fps_(0) {} + : BaseMediaParams(), resolution_((int)MEDIA_RESOLUTION::HD), fps_(30) { + setContentType((int)MEDIA_CONTENT_TYPE::RAW_VIDEO); + setCodec((int)MEDIA_PAYLOAD_TYPE::H264); + setDataOpt((int)MEDIA_DATA_OPTION::VIDEO_SINGLE_ACTIVE_STREAM); +} VideoParams::VideoParams(int content_type, int codec, int resolution, int data_opt, int fps) : BaseMediaParams(), resolution_(resolution), fps_(fps) { @@ -251,8 +281,41 @@ int VideoParams::fps() const { return fps_; } -DeskshareParams::DeskshareParams() - : BaseMediaParams(), resolution_(0), fps_(0) {} +TranscriptParams::TranscriptParams() + : BaseMediaParams(), src_language_(-1), enable_lid_(true) { + setContentType(5); // TEXT +} + +void TranscriptParams::setSrcLanguage(int src_language) { + src_language_ = src_language; +} + +void TranscriptParams::setEnableLid(bool enable_lid) { + enable_lid_ = enable_lid; +} + +int TranscriptParams::srcLanguage() const { + return src_language_; +} + +bool TranscriptParams::enableLid() const { + return enable_lid_; +} + +transcript_parameters TranscriptParams::toNative() const { + transcript_parameters p{}; + p.content_type = contentType(); + p.src_language = src_language_; + p.enable_lid = enable_lid_; + return p; +} + +DeskshareParams::DeskshareParams() + : BaseMediaParams(), resolution_((int)MEDIA_RESOLUTION::HD), fps_(30) { + setContentType((int)MEDIA_CONTENT_TYPE::RAW_VIDEO); + setCodec((int)MEDIA_PAYLOAD_TYPE::H264); + setDataOpt((int)MEDIA_DATA_OPTION::VIDEO_SINGLE_ACTIVE_STREAM); +} DeskshareParams::DeskshareParams(int content_type, int codec, int resolution, int fps) : BaseMediaParams(), resolution_(resolution), fps_(fps) { @@ -391,18 +454,35 @@ bool MediaParams::hasVideoParams() const { return video_params_ != nullptr; } +void MediaParams::setTranscriptParams(const TranscriptParams& transcript_params) { + transcript_params_ = make_unique(transcript_params); +} + +const TranscriptParams& MediaParams::transcriptParams() const { + if (!transcript_params_) { + throw Exception(RTMS_SDK_NOT_EXIST, "Transcript parameters not set"); + } + return *transcript_params_; +} + +bool MediaParams::hasTranscriptParams() const { + return transcript_params_ != nullptr; +} + media_parameters MediaParams::toNative() const { media_parameters params; memset(¶ms, 0, sizeof(params)); params.audio_param = nullptr; params.video_param = nullptr; - + params.ds_param = nullptr; + params.tr_param = nullptr; + if (audio_params_) { audio_parameters* audio_params = new audio_parameters(); *audio_params = audio_params_->toNative(); params.audio_param = audio_params; } - + if (video_params_) { video_parameters* video_params = new video_parameters(); *video_params = video_params_->toNative(); @@ -414,7 +494,13 @@ media_parameters MediaParams::toNative() const { *ds_params = ds_params_->toNative(); params.ds_param = ds_params; } - + + if (transcript_params_) { + transcript_parameters* tr_params = new transcript_parameters(); + *tr_params = transcript_params_->toNative(); + params.tr_param = tr_params; + } + return params; } @@ -422,25 +508,18 @@ Client::Client() : sdk_(nullptr), enabled_media_types_(0), media_params_updated_(false), + sdk_opened_(false), join_confirmed_(false) { - sdk_ = rtms_alloc(); + sdk_ = rtms_sdk_provider::instance()->create_sdk(); if (!sdk_) { throw Exception(RTMS_SDK_FAILURE, "Failed to allocate RTMS SDK instance"); } - - lock_guard lock(registry_mutex_); - sdk_registry_[sdk_] = this; } Client::~Client() { try { if (sdk_) { - { - lock_guard lock(registry_mutex_); - sdk_registry_.erase(sdk_); - } - - rtms_release(sdk_); + rtms_sdk_provider::instance()->release_sdk(sdk_); sdk_ = nullptr; } } catch (const exception& e) { @@ -449,46 +528,50 @@ Client::~Client() { } void Client::initialize(const string& ca_path, int is_verify_cert, const char* agent) { - // SDK will crash if agent is nullptr, so pass empty string instead - const char* agent_str = (agent != nullptr) ? agent : ""; - int result = rtms_init(ca_path.empty() ? nullptr : ca_path.c_str(), is_verify_cert, agent_str); + if (agent && agent[0] != '\0') { + g_agent = agent; + } + int result = rtms_sdk_provider::instance()->init( + ca_path.empty() ? nullptr : ca_path.c_str(), + is_verify_cert != 0 + ); if (result != RTMS_SDK_OK) { throw Exception(result, "Failed to initialize RTMS SDK"); } } void Client::uninitialize() { - rtms_uninit(); + rtms_sdk_provider::instance()->uninit(); } void Client::configure(const MediaParams& params, int media_types, bool enable_application_layer_encryption, bool apply_defaults) { - // Store the media parameters for future use + // Always store the configuration so join() can apply it later media_params_ = params; enabled_media_types_ = media_types; media_params_updated_ = true; - // If media types are enabled but no params were set, use sensible defaults - // This ensures proper metadata attribution (e.g., AUDIO_MULTI_STREAMS for audio) - // Skip defaults when called from setAudioParams/setVideoParams/setDeskshareParams - // to avoid prematurely filling in default params for other media types + // Don't call sdk_->config() until sdk_->open() has been called in join(). + // Calling config() before open() (e.g. from setOnVideoData) hangs for VIDEO. + if (!sdk_opened_) return; + + // If a media type is enabled but no params were provided, fill in defaults. + // Skip when called from setAudioParams/setVideoParams/setDeskshareParams (apply_defaults=false). if (apply_defaults) { if ((media_types & MediaType::AUDIO) && !media_params_.hasAudioParams()) { #ifdef RTMS_DEBUG - cerr << "[DEBUG CONFIG] Audio enabled but no params set, using defaults (AUDIO_MULTI_STREAMS)" << endl; + cerr << "[DEBUG CONFIG] Audio enabled but no params set, using defaults (OPUS/AUDIO_MULTI_STREAMS)" << endl; #endif media_params_.setAudioParams(AudioParams()); } - if ((media_types & MediaType::VIDEO) && !media_params_.hasVideoParams()) { #ifdef RTMS_DEBUG - cerr << "[DEBUG CONFIG] Video enabled but no params set, using defaults" << endl; + cerr << "[DEBUG CONFIG] Video enabled but no params set, using defaults (H264/HD/30fps)" << endl; #endif media_params_.setVideoParams(VideoParams()); } - if ((media_types & MediaType::DESKSHARE) && !media_params_.hasDeskshareParams()) { #ifdef RTMS_DEBUG - cerr << "[DEBUG CONFIG] Deskshare enabled but no params set, using defaults" << endl; + cerr << "[DEBUG CONFIG] Deskshare enabled but no params set, using defaults (H264/HD/30fps)" << endl; #endif media_params_.setDeskshareParams(DeskshareParams()); } @@ -496,17 +579,17 @@ void Client::configure(const MediaParams& params, int media_types, bool enable_a if (!media_params_.hasAudioParams() && !media_params_.hasVideoParams() && !media_params_.hasDeskshareParams()) { #ifdef RTMS_DEBUG - cerr << "[DEBUG CONFIG] Calling rtms_config with NULL params, media_types=" << media_types << endl; + cerr << "[DEBUG CONFIG] Calling config with NULL params, media_types=" << media_types << endl; #endif - int result = rtms_config(sdk_, NULL, media_types, enable_application_layer_encryption ? 1 : 0); + int result = sdk_->config(nullptr, media_types, enable_application_layer_encryption ? 1 : 0); throwIfError(result, "configure with null params"); return; } - + media_parameters native_params = media_params_.toNative(); #ifdef RTMS_DEBUG - cerr << "[DEBUG CONFIG] Calling rtms_config with params, media_types=" << media_types << endl; + cerr << "[DEBUG CONFIG] Calling config with params, media_types=" << media_types << endl; if (native_params.audio_param) { cerr << "[DEBUG CONFIG] audio_param: data_opt=" << native_params.audio_param->data_opt << " content_type=" << native_params.audio_param->content_type @@ -516,15 +599,18 @@ void Client::configure(const MediaParams& params, int media_types, bool enable_a } #endif - int result = rtms_config(sdk_, &native_params, media_types, enable_application_layer_encryption ? 1 : 0); - + int result = sdk_->config(&native_params, media_types, enable_application_layer_encryption ? 1 : 0); + if (native_params.audio_param) { delete native_params.audio_param; } if (native_params.video_param) { delete native_params.video_param; } - + if (native_params.ds_param) { + delete native_params.ds_param; + } + throwIfError(result, "configure"); } @@ -546,13 +632,13 @@ void Client::enableDeskshare(bool enable) { void Client::updateMediaConfiguration(int mediaType, bool enable) { - if (enable) { + if (enable) { enabled_media_types_ |= mediaType; } else { enabled_media_types_ &= mediaType; } - - if (sdk_) { + + if (sdk_ && sdk_opened_) { try { configure(media_params_, enabled_media_types_, false); } catch (const Exception& e) { @@ -576,7 +662,11 @@ void Client::setOnUserUpdate(UserUpdateFn callback) { lock_guard lock(mutex_); user_update_callback_ = std::move(callback); } - subscribeEvent({EVENT_ACTIVE_SPEAKER_CHANGE, EVENT_PARTICIPANT_JOIN, EVENT_PARTICIPANT_LEAVE}); + subscribeEvent({ + (int)EVENT_TYPE::ACTIVE_SPEAKER_CHANGE, + (int)EVENT_TYPE::PARTICIPANT_JOIN, + (int)EVENT_TYPE::PARTICIPANT_LEAVE, + }); } void Client::setOnDeskshareData(DsDataFn callback){ @@ -589,21 +679,21 @@ void Client::setOnDeskshareData(DsDataFn callback){ void Client::setOnAudioData(AudioDataFn callback) { lock_guard lock(mutex_); audio_data_callback_ = std::move(callback); - + updateMediaConfiguration(MediaType::AUDIO); } void Client::setOnVideoData(VideoDataFn callback) { lock_guard lock(mutex_); video_data_callback_ = std::move(callback); - + updateMediaConfiguration(MediaType::VIDEO); } void Client::setOnTranscriptData(TranscriptDataFn callback) { lock_guard lock(mutex_); transcript_data_callback_ = std::move(callback); - + updateMediaConfiguration(MediaType::TRANSCRIPT); } @@ -639,7 +729,7 @@ void Client::subscribeEvent(const std::vector& events) { // Already joined - subscribe immediately (release lock first to avoid holding during SDK call) mutex_.unlock(); - int result = rtms_subscribe_event(sdk_, const_cast(events.data()), static_cast(events.size())); + int result = sdk_->subscribe_event(const_cast(events.data()), static_cast(events.size())); mutex_.lock(); if (result != RTMS_SDK_OK) { @@ -660,7 +750,7 @@ void Client::unsubscribeEvent(const std::vector& events) { throw Exception(RTMS_SDK_INVALID_STATUS, "SDK not initialized"); } - int result = rtms_unsubscribe_event(sdk_, const_cast(events.data()), static_cast(events.size())); + int result = sdk_->unsubscribe_event(const_cast(events.data()), static_cast(events.size())); throwIfError(result, "unsubscribe_event"); // Remove from tracked events @@ -727,33 +817,73 @@ void Client::setAudioParams(const AudioParams& audio_params) } } -void Client::join(const string& meeting_uuid, const string& rtms_stream_id, +void Client::setTranscriptParams(const TranscriptParams& transcript_params) +{ + media_params_.setTranscriptParams(transcript_params); + + // If transcript is already enabled, reconfigure with the new params + if (enabled_media_types_ & MediaType::TRANSCRIPT) { + try { + configure(media_params_, enabled_media_types_, false, false); + } catch (const Exception& e) { + cerr << "Warning: Failed to reconfigure transcript params: " << e.what() << endl; + } + } +} + +void Client::setProxy(const string& proxy_type, const string& proxy_url) +{ + int result = sdk_->set_proxy(proxy_type.c_str(), proxy_url.c_str()); + if (result != 0) { + throw Exception(result, "setProxy failed: Operation failed"); + } +} + +void Client::subscribeVideo(int user_id, bool subscribe) +{ + int result = sdk_->send_subscript_video(user_id, subscribe); + throwIfError(result, "subscribeVideo"); +} + +void Client::setOnParticipantVideo(ParticipantVideoFn callback) +{ + { + lock_guard lock(mutex_); + participant_video_callback_ = std::move(callback); + } + subscribeEvent({ + (int)EVENT_TYPE::PARTICIPANT_VIDEO_ON, + (int)EVENT_TYPE::PARTICIPANT_VIDEO_OFF, + }); +} + +void Client::setOnVideoSubscribed(VideoSubscribedFn callback) +{ + lock_guard lock(mutex_); + video_subscribed_callback_ = std::move(callback); +} + +void Client::join(const string& meeting_uuid, const string& rtms_stream_id, const string& signature, const string& server_url, int timeout) { - struct rtms_csdk_ops ops; - memset(&ops, 0, sizeof(ops)); - ops.on_join_confirm = &Client::handleJoinConfirm; - ops.on_session_update = &Client::handleSessionUpdate; - ops.on_user_update = &Client::handleUserUpdate; - ops.on_ds_data = &Client::handleDsData; - ops.on_audio_data = &Client::handleAudioData; - ops.on_video_data = &Client::handleVideoData; - ops.on_transcript_data = &Client::handleTranscriptData; - ops.on_leave = &Client::handleLeave; - ops.on_event_ex = &Client::handleEventEx; - - int result = rtms_set_callbacks(sdk_, &ops); - throwIfError(result, "set_callbacks"); - - if (enabled_media_types_ > 0 && !media_params_updated_) { + // Register this client as the sink — replaces the old static callback registry + int result = sdk_->open(this); + throwIfError(result, "open"); + sdk_opened_ = true; + + // Apply any media configuration that was registered before join() via + // setOnAudioData / setOnVideoData / setVideoParams etc. Those calls stored + // the state in media_params_ / enabled_media_types_ but deferred the actual + // sdk_->config() call until now (after open). + if (enabled_media_types_ > 0) { try { configure(media_params_, enabled_media_types_, false); } catch (const Exception& e) { cerr << "Warning: Failed to configure media types before join: " << e.what() << endl; } } - - result = rtms_join(sdk_, meeting_uuid.c_str(), rtms_stream_id.c_str(), - signature.c_str(), server_url.c_str(), timeout); + + result = sdk_->join(meeting_uuid.c_str(), rtms_stream_id.c_str(), + signature.c_str(), server_url.c_str(), timeout); throwIfError(result, "join"); lock_guard lock(mutex_); @@ -762,33 +892,28 @@ void Client::join(const string& meeting_uuid, const string& rtms_stream_id, } void Client::poll() { - int result = rtms_poll(sdk_); + int result = sdk_->poll(); throwIfError(result, "poll"); } -void Client::release() { - int result = rtms_release(sdk_); +void Client::markClosed() { + lock_guard lock(mutex_); + sdk_opened_ = false; +} - // Always clean up, even if release returned an error - { - lock_guard lock(registry_mutex_); - sdk_registry_.erase(sdk_); - } +void Client::release() { + sdk_->leave(0); - // Reset join state { lock_guard lock(mutex_); + sdk_opened_ = false; join_confirmed_ = false; pending_event_subscriptions_.clear(); subscribed_events_.clear(); } + rtms_sdk_provider::instance()->release_sdk(sdk_); sdk_ = nullptr; - - // RTMS_SDK_NOT_EXIST means the resource was already released — that's fine - if (result != RTMS_SDK_OK && result != RTMS_SDK_NOT_EXIST) { - throwIfError(result, "release"); - } } string Client::uuid() const { @@ -831,23 +956,15 @@ void Client::throwIfError(int result, const string& operation) const { } } -Client* Client::getClient(struct rtms_csdk* sdk) { - lock_guard lock(registry_mutex_); - auto it = sdk_registry_.find(sdk); - if (it == sdk_registry_.end()) { - return nullptr; - } - return it->second; -} - void Client::processPendingSubscriptions() { // Called with mutex_ already held if (pending_event_subscriptions_.empty()) return; // Process all pending subscriptions now that we're joined - int result = rtms_subscribe_event(sdk_, - const_cast(pending_event_subscriptions_.data()), - static_cast(pending_event_subscriptions_.size())); + int result = sdk_->subscribe_event( + const_cast(pending_event_subscriptions_.data()), + static_cast(pending_event_subscriptions_.size()) + ); if (result == RTMS_SDK_OK) { // Move pending to subscribed @@ -861,121 +978,125 @@ void Client::processPendingSubscriptions() { pending_event_subscriptions_.clear(); } -void Client::handleJoinConfirm(struct rtms_csdk* sdk, int reason) { - Client* client = getClient(sdk); - if (client) { - lock_guard lock(client->mutex_); +// ============================================================================ +// rtms_sdk_sink virtual overrides +// ============================================================================ - // Mark as joined FIRST - client->join_confirmed_ = true; +void Client::on_join_confirm(int reason) { + lock_guard lock(mutex_); - // Process any pending event subscriptions before user callback - client->processPendingSubscriptions(); + // Mark as joined FIRST + join_confirmed_ = true; - // Then invoke user callback - if (client->join_confirm_callback_) { - client->join_confirm_callback_(reason); - } + // Process any pending event subscriptions before user callback + processPendingSubscriptions(); + + // Then invoke user callback + if (join_confirm_callback_) { + join_confirm_callback_(reason); } } -void Client::handleSessionUpdate(struct rtms_csdk* sdk, int op, struct session_info* sess) { - Client* client = getClient(sdk); - if (client && sess) { - lock_guard lock(client->mutex_); - if (client->session_update_callback_) { +void Client::on_session_update(int op, struct session_info* sess) { + if (sess) { + lock_guard lock(mutex_); + if (session_update_callback_) { Session session(*sess); - client->session_update_callback_(op, session); + session_update_callback_(op, session); } } } -void Client::handleUserUpdate(struct rtms_csdk* sdk, int op, struct participant_info* pi) { - Client* client = getClient(sdk); - if (client && pi) { - lock_guard lock(client->mutex_); - if (client->user_update_callback_) { +void Client::on_user_update(int op, struct participant_info* pi) { + if (pi) { + lock_guard lock(mutex_); + if (user_update_callback_) { Participant participant(*pi); - client->user_update_callback_(op, participant); + user_update_callback_(op, participant); } } } -void Client::handleDsData(rtms_csdk* sdk, unsigned char* buf, int size, uint64_t timestamp, rtms_metadata* md) { - Client* client = getClient(sdk); - if (client && buf && size > 0 && md) { - lock_guard lock(client->mutex_); - if (client->ds_data_callback_) { - vector data(buf, buf + size); +void Client::on_ds_data(unsigned char* data_buf, int size, uint64_t timestamp, struct rtms_metadata* md) { + if (data_buf && size > 0 && md) { + lock_guard lock(mutex_); + if (ds_data_callback_) { + vector data(data_buf, data_buf + size); Metadata metadata(*md); - client->ds_data_callback_(data, timestamp, metadata); + ds_data_callback_(data, timestamp, metadata); } } } -void Client::handleAudioData(struct rtms_csdk* sdk, unsigned char* buf, int size, uint64_t timestamp, struct rtms_metadata* md) { - Client* client = getClient(sdk); - if (client && buf && size > 0 && md) { +void Client::on_audio_data(unsigned char* data_buf, int size, uint64_t timestamp, struct rtms_metadata* md) { + if (data_buf && size > 0 && md) { #ifdef RTMS_DEBUG cerr << "[DEBUG AUDIO] md->user_id=" << md->user_id << " md->user_name=" << (md->user_name ? md->user_name : "(null)") << endl; #endif - lock_guard lock(client->mutex_); - if (client->audio_data_callback_) { - vector data(buf, buf + size); + lock_guard lock(mutex_); + if (audio_data_callback_) { + vector data(data_buf, data_buf + size); Metadata metadata(*md); - client->audio_data_callback_(data, timestamp, metadata); + audio_data_callback_(data, timestamp, metadata); } } } -void Client::handleVideoData(struct rtms_csdk* sdk, unsigned char* buf, int size, uint64_t timestamp, struct rtms_metadata* md) { - Client* client = getClient(sdk); - if (client && buf && size > 0 && md) { - lock_guard lock(client->mutex_); - if (client->video_data_callback_) { - vector data(buf, buf + size); +void Client::on_video_data(unsigned char* data_buf, int size, uint64_t timestamp, struct rtms_metadata* md) { + if (data_buf && size > 0 && md) { + lock_guard lock(mutex_); + if (video_data_callback_) { + vector data(data_buf, data_buf + size); Metadata metadata(*md); - client->video_data_callback_(data, timestamp, metadata); + video_data_callback_(data, timestamp, metadata); } } } -void Client::handleTranscriptData(struct rtms_csdk* sdk, unsigned char* buf, int size, uint64_t timestamp, struct rtms_metadata* md) { - Client* client = getClient(sdk); - if (client && buf && size > 0 && md) { +void Client::on_transcript_data(unsigned char* data_buf, int size, uint64_t timestamp, struct rtms_metadata* md) { + if (data_buf && size > 0 && md) { #ifdef RTMS_DEBUG cerr << "[DEBUG TRANSCRIPT] md->user_id=" << md->user_id << " md->user_name=" << (md->user_name ? md->user_name : "(null)") << endl; #endif - lock_guard lock(client->mutex_); - if (client->transcript_data_callback_) { - vector data(buf, buf + size); + lock_guard lock(mutex_); + if (transcript_data_callback_) { + vector data(data_buf, data_buf + size); Metadata metadata(*md); - client->transcript_data_callback_(data, timestamp, metadata); + transcript_data_callback_(data, timestamp, metadata); } } } -void Client::handleLeave(struct rtms_csdk* sdk, int reason) { - Client* client = getClient(sdk); - if (client) { - lock_guard lock(client->mutex_); - if (client->leave_callback_) { - client->leave_callback_(reason); - } +void Client::on_leave(int reason) { + lock_guard lock(mutex_); + if (leave_callback_) { + leave_callback_(reason); } } -void Client::handleEventEx(struct rtms_csdk* sdk, const char* buf, int size) { - Client* client = getClient(sdk); - if (client && buf && size > 0) { - lock_guard lock(client->mutex_); - if (client->event_ex_callback_) { - string event_data(buf, size); - client->event_ex_callback_(event_data); +void Client::on_event_ex(const std::string& compact_str) { + if (!compact_str.empty()) { + lock_guard lock(mutex_); + if (event_ex_callback_) { + event_ex_callback_(compact_str); } } } -} // namespace rtms \ No newline at end of file +void Client::on_participant_video(std::vector users, bool is_on) { + lock_guard lock(mutex_); + if (participant_video_callback_) { + participant_video_callback_(users, is_on); + } +} + +void Client::on_video_subscript_resp(int user_id, int status, std::string error) { + lock_guard lock(mutex_); + if (video_subscribed_callback_) { + video_subscribed_callback_(user_id, status, error); + } +} + +} // namespace rtms diff --git a/src/rtms.h b/src/rtms.h index f5e6ee1..10b326a 100644 --- a/src/rtms.h +++ b/src/rtms.h @@ -1,12 +1,11 @@ #ifndef RTMS_H #define RTMS_H -#include "rtms_csdk.h" +#include "rtms_sdk.h" #include #include #include #include -#include #include using namespace std; @@ -54,16 +53,56 @@ class Participant { string name_; }; +class AiTargetLanguage { +public: + explicit AiTargetLanguage(const ai_target_lan& atl); + + int lid() const; + int toneId() const; + string voiceId() const; + string engine() const; + +private: + int lid_; + int tone_id_; + string voice_id_; + string engine_; +}; + +class AiInterpreter { +public: + explicit AiInterpreter(const ai_interpreter& aii); + + int lid() const; + uint64_t timestamp() const; + int channelNum() const; + int sampleRate() const; + const vector& targets() const; + +private: + int lid_; + uint64_t timestamp_; + int channel_num_; + int sample_rate_; + vector targets_; +}; + class Metadata { public: explicit Metadata(const rtms_metadata& metadata); string userName() const; int userId() const; + uint64_t startTs() const; + uint64_t endTs() const; + const AiInterpreter& aiInterpreter() const; private: string user_name_; int user_id_; + uint64_t start_ts_; + uint64_t end_ts_; + AiInterpreter ai_interpreter_; }; class BaseMediaParams { @@ -84,6 +123,272 @@ class BaseMediaParams { int data_opt_; }; +// ============================================================================ +// Protocol-level enums — single source of truth for all bindings. +// Values sourced from https://developers.zoom.us/docs/rtms/data-types/ +// and verified against rtms_common.h where applicable. +// ============================================================================ + +// MEDIA_CONTENT_TYPE — shared by audio, video, deskshare, transcript +enum class MEDIA_CONTENT_TYPE { + UNDEFINED = 0, + RTP = 1, + RAW_AUDIO = 2, + RAW_VIDEO = 3, + FILE_STREAM = 4, + TEXT = 5, +}; + +// MEDIA_PAYLOAD_TYPE — codec identifiers (shared by audio and video) +enum class MEDIA_PAYLOAD_TYPE { + UNDEFINED = 0, + L16 = 1, + G711 = 2, + G722 = 3, + OPUS = 4, + JPG = 5, + PNG = 6, + H264 = 7, +}; + +// AUDIO_SAMPLE_RATE +enum class AUDIO_SAMPLE_RATE { + SR_8K = 0, + SR_16K = 1, + SR_32K = 2, + SR_48K = 3, +}; + +// AUDIO_CHANNEL +enum class AUDIO_CHANNEL { + MONO = 1, + STEREO = 2, +}; + +// MEDIA_RESOLUTION — for video and deskshare +enum class MEDIA_RESOLUTION { + SD = 1, + HD = 2, + FHD = 3, + QHD = 4, +}; + +// MEDIA_DATA_OPTION — audio and video stream delivery modes +// Note: VIDEO_SINGLE_INDIVIDUAL_STREAM is also known as VIDEO_MIXED_SPEAKER_VIEW in older SDK docs. +enum class MEDIA_DATA_OPTION { + UNDEFINED = 0, + AUDIO_MIXED_STREAM = 1, + AUDIO_MULTI_STREAMS = 2, + VIDEO_SINGLE_ACTIVE_STREAM = 3, + VIDEO_SINGLE_INDIVIDUAL_STREAM = 4, + VIDEO_MIXED_GALLERY_VIEW = 5, +}; + +// MEDIA_DATA_TYPE — bitmask of media stream types +// SDK_ALL = 0x1<<5 = 32 (matches rtms_common.h sdk_type::SDK_ALL) +enum class MEDIA_DATA_TYPE { + UNDEFINED = 0, + AUDIO = 1, + VIDEO = 2, + DESKSHARE = 4, + TRANSCRIPT = 8, + CHAT = 16, + ALL = 32, +}; + +// SESSION_STATE +enum class SESSION_STATE { + INACTIVE = 0, + INITIALIZE = 1, + STARTED = 2, + PAUSED = 3, + RESUMED = 4, + STOPPED = 5, +}; + +// STREAM_STATE +enum class STREAM_STATE { + INACTIVE = 0, + ACTIVE = 1, + INTERRUPTED = 2, + TERMINATING = 3, + TERMINATED = 4, + PAUSED = 5, + RESUMED = 6, +}; + +// EVENT_TYPE — standard meeting events for subscribeEvent +enum class EVENT_TYPE { + UNDEFINED = 0, + FIRST_PACKET_TIMESTAMP = 1, + ACTIVE_SPEAKER_CHANGE = 2, + PARTICIPANT_JOIN = 3, + PARTICIPANT_LEAVE = 4, + SHARING_START = 5, + SHARING_STOP = 6, + MEDIA_CONNECTION_INTERRUPTED = 7, + PARTICIPANT_VIDEO_ON = 8, + PARTICIPANT_VIDEO_OFF = 9, + // ZCC voice events (kept here for backward compatibility) + CONSUMER_ANSWERED = 8, + CONSUMER_END = 9, + USER_ANSWERED = 10, + USER_END = 11, + USER_HOLD = 12, + USER_UNHOLD = 13, +}; + + +// ZCC_VOICE_EVENT_TYPE — Zoom Contact Center voice events +enum class ZCC_VOICE_EVENT_TYPE { + UNDEFINED = 0, + CONSUMER_ANSWERED = 8, + CONSUMER_END = 9, + USER_ANSWERED = 10, + USER_END = 11, + USER_HOLD = 12, + USER_UNHOLD = 13, + MONITOR_STARTED = 14, + MONITOR_TRANSITIONED = 15, + MONITOR_ENDED = 16, + TAKEOVER_STARTED = 17, + TRANSFER_INITIATED = 18, + TRANSFER_CANCELED = 19, + TRANSFER_ACCEPTED = 20, + TRANSFER_COMPLETED = 21, + TRANSFER_REJECTED = 22, + TRANSFER_TIMEOUT = 23, + CONFERENCE_CANCELED = 24, + CONFERENCE_PARTICIPANT_CANCELED = 25, + CONFERENCE_PARTICIPANT_INVITED = 26, + CONFERENCE_PARTICIPANT_REJECTED = 27, + CONFERENCE_PARTICIPANT_TIMEOUT = 28, +}; + +// MESSAGE_TYPE — WebSocket protocol message types +enum class MESSAGE_TYPE { + UNDEFINED = 0, + SIGNALING_HAND_SHAKE_REQ = 1, + SIGNALING_HAND_SHAKE_RESP = 2, + DATA_HAND_SHAKE_REQ = 3, + DATA_HAND_SHAKE_RESP = 4, + EVENT_SUBSCRIPTION = 5, + EVENT_UPDATE = 6, + CLIENT_READY_ACK = 7, + STREAM_STATE_UPDATE = 8, + SESSION_STATE_UPDATE = 9, + SESSION_STATE_REQ = 10, + SESSION_STATE_RESP = 11, + KEEP_ALIVE_REQ = 12, + KEEP_ALIVE_RESP = 13, + MEDIA_DATA_AUDIO = 14, + MEDIA_DATA_VIDEO = 15, + MEDIA_DATA_SHARE = 16, + MEDIA_DATA_TRANSCRIPT = 17, + MEDIA_DATA_CHAT = 18, + STREAM_STATE_REQ = 19, + STREAM_STATE_RESP = 20, + STREAM_CLOSE_REQ = 21, + STREAM_CLOSE_RESP = 22, + META_DATA_AUDIO = 23, + META_DATA_VIDEO = 24, + META_DATA_SHARE = 25, + META_DATA_TRANSCRIPT = 26, + META_DATA_CHAT = 27, + VIDEO_SUBSCRIPTION_REQ = 28, + VIDEO_SUBSCRIPTION_RESP = 29, +}; + +// STOP_REASON — reasons for stream termination +enum class STOP_REASON { + UNDEFINED = 0, + HOST_TRIGGERED = 1, + USER_TRIGGERED = 2, + USER_LEFT = 3, + USER_EJECTED = 4, + HOST_DISABLED_APP = 5, + MEETING_ENDED = 6, + STREAM_CANCELED = 7, + STREAM_REVOKED = 8, + ALL_APPS_DISABLED = 9, + INTERNAL_EXCEPTION = 10, + CONNECTION_TIMEOUT = 11, + INSTANCE_CONNECTION_INTERRUPTED = 12, + SIGNAL_CONNECTION_INTERRUPTED = 13, + DATA_CONNECTION_INTERRUPTED = 14, + SIGNAL_CONNECTION_CLOSED_ABNORMALLY = 15, + DATA_CONNECTION_CLOSED_ABNORMALLY = 16, + EXIT_SIGNAL = 17, + AUTHENTICATION_FAILURE = 18, + AWAIT_RECONNECTION_TIMEOUT = 19, + RECEIVER_REQUEST_CLOSE = 20, + CUSTOMER_DISCONNECTED = 21, + AGENT_DISCONNECTED = 22, + ADMIN_DISABLED_APP = 23, + KEEP_ALIVE_TIMEOUT = 24, + MANUAL_API_TRIGGERED = 25, + STREAMING_NOT_SUPPORTED = 26, +}; + +// TRANSCRIPT_LANGUAGE — source language IDs for transcript configuration. +// Use NONE (-1) to enable automatic language detection (LID). +enum class TRANSCRIPT_LANGUAGE { + NONE = -1, + ARABIC = 0, + BENGALI = 1, + CANTONESE = 2, + CATALAN = 3, + CHINESE_SIMPLIFIED = 4, + CHINESE_TRADITIONAL = 5, + CZECH = 6, + DANISH = 7, + DUTCH = 8, + ENGLISH = 9, + ESTONIAN = 10, + FINNISH = 11, + FRENCH_CANADA = 12, + FRENCH_FRANCE = 13, + GERMAN = 14, + HEBREW = 15, + HINDI = 16, + HUNGARIAN = 17, + INDONESIAN = 18, + ITALIAN = 19, + JAPANESE = 20, + KOREAN = 21, + MALAY = 22, + PERSIAN = 23, + POLISH = 24, + PORTUGUESE = 25, + ROMANIAN = 26, + RUSSIAN = 27, + SPANISH = 28, + SWEDISH = 29, + TAGALOG = 30, + TAMIL = 31, + TELUGU = 32, + THAI = 33, + TURKISH = 34, + UKRAINIAN = 35, + VIETNAMESE = 36, +}; + +class TranscriptParams : public BaseMediaParams { +public: + TranscriptParams(); + + void setSrcLanguage(int src_language); + void setEnableLid(bool enable_lid); + int srcLanguage() const; + bool enableLid() const; + + transcript_parameters toNative() const; + +private: + int src_language_; + bool enable_lid_; +}; + class DeskshareParams : public BaseMediaParams { public: DeskshareParams(); @@ -182,7 +487,7 @@ class MediaParams { } else { audio_params_.reset(); } - + if (other.hasVideoParams()) { video_params_ = std::make_unique(other.videoParams()); } else { @@ -194,31 +499,41 @@ class MediaParams { } else { ds_params_.reset(); } + + if (other.hasTranscriptParams()) { + transcript_params_ = std::make_unique(other.transcriptParams()); + } else { + transcript_params_.reset(); + } } return *this; } - + void setDeskshareParams(const DeskshareParams& ds_params); void setAudioParams(const AudioParams& audio_params); void setVideoParams(const VideoParams& video_params); - + void setTranscriptParams(const TranscriptParams& transcript_params); + const DeskshareParams& deskshareParams() const; const AudioParams& audioParams() const; const VideoParams& videoParams() const; - + const TranscriptParams& transcriptParams() const; + bool hasDeskshareParams() const; bool hasAudioParams() const; bool hasVideoParams() const; - + bool hasTranscriptParams() const; + media_parameters toNative() const; - + private: std::unique_ptr ds_params_; std::unique_ptr audio_params_; std::unique_ptr video_params_; + std::unique_ptr transcript_params_; }; -class Client { +class Client : public rtms_sdk_sink { public: using JoinConfirmFn = function; @@ -230,36 +545,21 @@ class Client { using TranscriptDataFn = function&, uint64_t, const Metadata&)>; using LeaveFn = function; using EventExFn = function; + using ParticipantVideoFn = function&, bool)>; + using VideoSubscribedFn = function; - // Media type constants + // Media type bitmask constants (matches SDK media_type enum in rtms_common.h) + // ALL = SDK_ALL = 0x1<<5 = 32 enum MediaType { - AUDIO = 1, - VIDEO = 2, - DESKSHARE = 4, + AUDIO = 1, + VIDEO = 2, + DESKSHARE = 4, TRANSCRIPT = 8, - CHAT = 16, - ALL = 31 // Sum of all types (1+2+4+8+16) + CHAT = 16, + ALL = 32, }; - // Event types for subscribeEvent/unsubscribeEvent - // These match the RTMS_EVENT_TYPE enum from Zoom's C SDK - // Used with on_event_ex callback (JSON events) - enum EventType { - EVENT_UNDEFINED = 0, - EVENT_FIRST_PACKET_TIMESTAMP = 1, - EVENT_ACTIVE_SPEAKER_CHANGE = 2, - EVENT_PARTICIPANT_JOIN = 3, - EVENT_PARTICIPANT_LEAVE = 4, - EVENT_SHARING_START = 5, - EVENT_SHARING_STOP = 6, - EVENT_MEDIA_CONNECTION_INTERRUPTED = 7, - EVENT_CONSUMER_ANSWERED = 8, - EVENT_CONSUMER_END = 9, - EVENT_USER_ANSWERED = 10, - EVENT_USER_END = 11, - EVENT_USER_HOLD = 12, - EVENT_USER_UNHOLD = 13 - }; + Client(); ~Client(); @@ -289,26 +589,47 @@ class Client { void setDeskshareParams(const DeskshareParams& ds_params); void setVideoParams(const VideoParams& video_params); void setAudioParams(const AudioParams& audio_params); + void setTranscriptParams(const TranscriptParams& transcript_params); + void setProxy(const string& proxy_type, const string& proxy_url); + void subscribeVideo(int user_id, bool subscribe); + + void setOnParticipantVideo(ParticipantVideoFn callback); + void setOnVideoSubscribed(VideoSubscribedFn callback); void join(const string& meeting_uuid, const string& rtms_stream_id, const string& signature, const string& server_url, int timeout = -1); void poll(); + void markClosed(); // mark sdk_opened_=false before teardown so configure() becomes a no-op void release(); - + string uuid() const; string streamId() const; + + // rtms_sdk_sink overrides — called by the SDK from within poll() + void on_join_confirm(int reason) override; + void on_session_update(int op, struct session_info* sess) override; + void on_user_update(int op, struct participant_info* pi) override; + void on_audio_data(unsigned char* data_buf, int size, uint64_t timestamp, struct rtms_metadata* md) override; + void on_video_data(unsigned char* data_buf, int size, uint64_t timestamp, struct rtms_metadata* md) override; + void on_ds_data(unsigned char* data_buf, int size, uint64_t timestamp, struct rtms_metadata* md) override; + void on_transcript_data(unsigned char* data_buf, int size, uint64_t timestamp, struct rtms_metadata* md) override; + void on_leave(int reason) override; + void on_event_ex(const std::string& compact_str) override; + void on_participant_video(std::vector users, bool is_on) override; + void on_video_subscript_resp(int user_id, int status, std::string error) override; + private: mutable mutex mutex_; - rtms_csdk* sdk_; + rtms_sdk* sdk_; string meeting_uuid_; string rtms_stream_id_; - + int enabled_media_types_; bool media_params_updated_; + bool sdk_opened_; MediaParams media_params_; - JoinConfirmFn join_confirm_callback_; SessionUpdateFn session_update_callback_; UserUpdateFn user_update_callback_; @@ -318,6 +639,8 @@ class Client { TranscriptDataFn transcript_data_callback_; LeaveFn leave_callback_; EventExFn event_ex_callback_; + ParticipantVideoFn participant_video_callback_; + VideoSubscribedFn video_subscribed_callback_; std::vector subscribed_events_; @@ -326,24 +649,9 @@ class Client { std::vector pending_event_subscriptions_; void processPendingSubscriptions(); - static void handleJoinConfirm(struct rtms_csdk* sdk, int reason); - static void handleSessionUpdate(struct rtms_csdk* sdk, int op, struct session_info* sess); - static void handleUserUpdate(struct rtms_csdk* sdk, int op, struct participant_info* pi); - static void handleDsData(struct rtms_csdk* sdk, unsigned char* buf, int size, uint64_t timestamp, struct rtms_metadata* md); - static void handleAudioData(struct rtms_csdk* sdk, unsigned char* buf, int size, uint64_t timestamp, struct rtms_metadata* md); - static void handleVideoData(struct rtms_csdk* sdk, unsigned char* buf, int size, uint64_t timestamp, struct rtms_metadata* md); - static void handleTranscriptData(struct rtms_csdk* sdk, unsigned char* buf, int size, uint64_t timestamp, struct rtms_metadata* md); - static void handleLeave(struct rtms_csdk* sdk, int reason); - static void handleEventEx(struct rtms_csdk* sdk, const char* buf, int size); - - static unordered_map sdk_registry_; - static mutex registry_mutex_; - void throwIfError(int result, const std::string& operation) const; - static Client* getClient(struct rtms_csdk* sdk); - void updateMediaConfiguration(int mediaType, bool enable = true); }; } -#endif // RTMS_H \ No newline at end of file +#endif // RTMS_H diff --git a/src/rtms/__init__.py b/src/rtms/__init__.py index 24f23e4..ab4a7fd 100644 --- a/src/rtms/__init__.py +++ b/src/rtms/__init__.py @@ -6,6 +6,8 @@ import time import sys import traceback +import asyncio +import inspect from http.server import BaseHTTPRequestHandler, HTTPServer from typing import Callable, Dict, Any, Optional, Union, List, Tuple from enum import IntEnum, Enum @@ -14,7 +16,8 @@ from ._rtms import ( # Classes Client as _ClientBase, Session, Participant, Metadata, - AudioParams, VideoParams, DeskshareParams, + AiTargetLanguage, AiInterpreter, + AudioParams, VideoParams, DeskshareParams, TranscriptParams, # Media type constants MEDIA_TYPE_AUDIO, MEDIA_TYPE_VIDEO, MEDIA_TYPE_DESKSHARE, @@ -27,11 +30,12 @@ USER_JOIN, USER_LEAVE, # Event types for subscribeEvent/unsubscribeEvent (used with onEventEx callback) - # These match RTMS_EVENT_TYPE from Zoom's C SDK + # These match EVENT_TYPE from Zoom's C SDK EVENT_UNDEFINED, EVENT_FIRST_PACKET_TIMESTAMP, EVENT_ACTIVE_SPEAKER_CHANGE, EVENT_PARTICIPANT_JOIN, EVENT_PARTICIPANT_LEAVE, EVENT_SHARING_START, EVENT_SHARING_STOP, EVENT_MEDIA_CONNECTION_INTERRUPTED, + EVENT_PARTICIPANT_VIDEO_ON, EVENT_PARTICIPANT_VIDEO_OFF, EVENT_CONSUMER_ANSWERED, EVENT_CONSUMER_END, EVENT_USER_ANSWERED, EVENT_USER_END, EVENT_USER_HOLD, EVENT_USER_UNHOLD, @@ -41,12 +45,43 @@ RTMS_SDK_INVALID_STATUS, RTMS_SDK_INVALID_ARGS, SESS_STATUS_ACTIVE, SESS_STATUS_PAUSED, - # Parameter dictionaries - import directly with their original names - AudioContentType, AudioCodec, AudioSampleRate, AudioChannel, AudioDataOption, - VideoContentType, VideoCodec, VideoResolution, VideoDataOption, - MediaDataType, SessionState, StreamState, EventType, MessageType, StopReason + # Parameter dictionaries - imported as private names, converted to IntEnum below + AudioContentType as _AudioContentType, + AudioCodec as _AudioCodec, + AudioSampleRate as _AudioSampleRate, + AudioChannel as _AudioChannel, + DataOption as _DataOption, + VideoContentType as _VideoContentType, + VideoCodec as _VideoCodec, + VideoResolution as _VideoResolution, + MediaDataType as _MediaDataType, + SessionState as _SessionState, + StreamState as _StreamState, + EventType as _EventType, + MessageType as _MessageType, + StopReason as _StopReason, + TranscriptLanguage as _TranscriptLanguage, ) +# Convert raw C++ dicts to IntEnum for Pythonic dot-notation access +AudioContentType = IntEnum("AudioContentType", _AudioContentType) +AudioCodec = IntEnum("AudioCodec", _AudioCodec) +AudioSampleRate = IntEnum("AudioSampleRate", _AudioSampleRate) +AudioChannel = IntEnum("AudioChannel", _AudioChannel) +DataOption = IntEnum("DataOption", _DataOption) +AudioDataOption = DataOption # legacy alias +VideoContentType = IntEnum("VideoContentType", _VideoContentType) +VideoCodec = IntEnum("VideoCodec", _VideoCodec) +VideoResolution = IntEnum("VideoResolution", _VideoResolution) +VideoDataOption = DataOption # legacy alias +MediaDataType = IntEnum("MediaDataType", _MediaDataType) +SessionState = IntEnum("SessionState", _SessionState) +StreamState = IntEnum("StreamState", _StreamState) +EventType = IntEnum("EventType", _EventType) +MessageType = IntEnum("MessageType", _MessageType) +StopReason = IntEnum("StopReason", _StopReason) +TranscriptLanguage = IntEnum("TranscriptLanguage", _TranscriptLanguage) + # Set up logging _log_level = os.getenv('ZM_RTMS_LOG_LEVEL', 'info').lower() @@ -70,14 +105,13 @@ class LogFormat(str, Enum): # Global webhook server _webhook_server = None -# Global client registry for run() event loop +# Global client registry (for status/monitoring — len(rtms._clients)) _clients: Dict[int, 'Client'] = {} _clients_lock = threading.Lock() -_pending_operations: List[Tuple[Callable, tuple, dict]] = [] -_pending_lock = threading.Lock() -_main_thread_id: Optional[int] = None +_sdk_init_lock = threading.Lock() _running = False _stop_event = threading.Event() +_run_executor: Optional['Executor'] = None def _log(level, component, message, details=None): """Log a message at the specified level""" @@ -445,11 +479,11 @@ def _validate_audio_params(params): # Check data option if hasattr(params, 'dataOpt') and params.dataOpt is not None: - valid_opts = [v for k, v in AudioDataOption.__dict__.items() if not k.startswith('_')] + valid_opts = [v for k, v in DataOption.__dict__.items() if not k.startswith('_')] if params.dataOpt not in valid_opts: errors.append( f"Invalid audio dataOpt: {params.dataOpt}. " - f"Use rtms.AudioDataOption constants (e.g., rtms.AudioDataOption.AUDIO_MULTI_STREAMS)" + f"Use rtms.DataOption constants (e.g., rtms.DataOption.AUDIO_MULTI_STREAMS)" ) # Validate codec-specific requirements @@ -521,11 +555,11 @@ def _validate_video_params(params): # Check data option if hasattr(params, 'dataOpt') and params.dataOpt is not None: - valid_opts = [v for k, v in VideoDataOption.__dict__.items() if not k.startswith('_')] + valid_opts = [v for k, v in DataOption.__dict__.items() if not k.startswith('_')] if params.dataOpt not in valid_opts: errors.append( f"Invalid video dataOpt: {params.dataOpt}. " - f"Use rtms.VideoDataOption constants (e.g., rtms.VideoDataOption.VIDEO_SINGLE_ACTIVE_STREAM)" + f"Use rtms.DataOption constants (e.g., rtms.DataOption.VIDEO_SINGLE_ACTIVE_STREAM)" ) if errors: @@ -538,6 +572,313 @@ def _validate_deskshare_params(params): _validate_video_params(params) +# ============================================================================ +# EventLoop +# ============================================================================ + +class EventLoop: + """ + An SDK I/O thread that owns one or more Client lifecycles. + + The Zoom C SDK requires that alloc(), join(), poll(), and release() all run + on the same OS thread. EventLoop is that thread. Clients assigned to a loop + via add() will have their entire lifecycle managed on the loop's thread. + + Usage:: + + loop = rtms.EventLoop() + + @rtms.on_webhook_event + def handle(payload): + client = rtms.Client(executor=EXECUTOR) + client.on_audio_data(on_audio) + loop.add(client) + client.join(payload['payload']) + + await loop.run_async() # or loop.run() to block + + Callbacks are dispatched according to the executor set on each Client: + - No executor: callback runs inline on the loop's thread (simple, low latency) + - executor=ThreadPoolExecutor(...): heavy work offloaded to worker pool + - async def callback: bridged to the asyncio event loop via run_coroutine_threadsafe + """ + + def __init__(self, poll_interval: float = 0.01, name: str = None): + """ + Args: + poll_interval: Seconds between poll cycles (default: 0.01 = 10ms) + name: Optional thread name for debugging + """ + self._poll_interval = poll_interval + self._name = name + self._clients: List['Client'] = [] + self._clients_lock = threading.Lock() + self._pending: List['Client'] = [] # clients waiting for alloc+join on this thread + self._pending_lock = threading.Lock() + self._running = False + self._stop_event = threading.Event() + self._thread: Optional[threading.Thread] = None + + @property + def client_count(self) -> int: + """Number of clients currently owned by this loop.""" + with self._clients_lock: + return len(self._clients) + + def add(self, client: 'Client') -> None: + """ + Assign a client to this loop's thread. + + Must be called before client.join(). The loop's thread will call + alloc() and join() on behalf of the client. + + Args: + client: A Client whose join() will be deferred to this loop's thread + """ + client._assigned_loop = self + with self._pending_lock: + self._pending.append(client) + + def _drain_pending(self) -> None: + """Called from the loop's thread — alloc and join all waiting clients.""" + with self._pending_lock: + pending = self._pending[:] + self._pending.clear() + + for client in pending: + try: + client._do_alloc_and_join() + with self._clients_lock: + self._clients.append(client) + except Exception as e: + log_error('eventloop', f'Failed to alloc/join client: {e}') + traceback.print_exc() + + def _poll_all(self) -> None: + """Poll all active clients. Removes clients that have left.""" + with self._clients_lock: + active = self._clients[:] + + to_remove = [] + for client in active: + if client._running: + try: + client.poll() + except Exception as e: + log_error('eventloop', f'Error polling client: {e}') + to_remove.append(client) + else: + to_remove.append(client) + + if to_remove: + with self._clients_lock: + for c in to_remove: + self._clients.discard(c) if hasattr(self._clients, 'discard') else None + with self._clients_lock: + self._clients = [c for c in self._clients if c not in to_remove] + + def run(self, stop_on_empty: bool = False) -> None: + """ + Run the event loop on the current thread (blocking). + + The current thread becomes the SDK I/O thread. Use this when you want + explicit control of which thread drives the loop. + + Args: + stop_on_empty: Stop automatically when all clients have left + """ + self._running = True + self._stop_event.clear() + log_info('eventloop', f'Starting event loop{" (" + self._name + ")" if self._name else ""} ' + f'(poll_interval={self._poll_interval}s)') + try: + while self._running and not self._stop_event.is_set(): + self._drain_pending() + self._poll_all() + if stop_on_empty: + with self._clients_lock: + if not self._clients and not self._pending: + break + time.sleep(self._poll_interval) + except KeyboardInterrupt: + pass + finally: + self._running = False + log_debug('eventloop', 'Event loop stopped') + + async def run_async(self, stop_on_empty: bool = False) -> None: + """ + Run the event loop as an asyncio coroutine. + + Yields to the asyncio event loop between poll cycles so other coroutines + (aiohttp, FastAPI, asyncpg, etc.) run freely. Async callbacks registered + on clients are automatically bridged to this event loop. + + Args: + stop_on_empty: Stop automatically when all clients have left + + Example:: + + async def main(): + loop = rtms.EventLoop() + await asyncio.gather(loop.run_async(), aiohttp_app.start()) + + asyncio.run(main()) + """ + self._running = True + self._stop_event.clear() + log_info('eventloop', f'Starting async event loop{" (" + self._name + ")" if self._name else ""} ' + f'(poll_interval={self._poll_interval}s)') + try: + while self._running and not self._stop_event.is_set(): + self._drain_pending() + self._poll_all() + if stop_on_empty: + with self._clients_lock: + if not self._clients and not self._pending: + break + await asyncio.sleep(self._poll_interval) + except asyncio.CancelledError: + log_info('eventloop', 'Async event loop cancelled') + finally: + self._running = False + log_debug('eventloop', 'Async event loop stopped') + + def start(self) -> 'EventLoop': + """ + Start the event loop in a background daemon thread. + + Returns self for chaining:: + + loop = rtms.EventLoop().start() + + The thread runs until stop() is called or the process exits. + """ + self._thread = threading.Thread( + target=self.run, + name=self._name or 'rtms-eventloop', + daemon=True, + ) + self._thread.start() + log_debug('eventloop', f'Background thread started: {self._thread.name}') + return self + + def stop(self) -> None: + """Signal the event loop to stop after the current poll cycle.""" + self._running = False + self._stop_event.set() + + def join(self, timeout: float = None) -> None: + """Wait for the background thread to finish (only valid after start()).""" + if self._thread: + self._thread.join(timeout=timeout) + + +# ============================================================================ +# EventLoopPool +# ============================================================================ + +class EventLoopPool: + """ + A pool of EventLoop threads that distributes clients across N SDK I/O threads. + + Use this for high-concurrency deployments where many clients share a fixed + number of threads. Each client is permanently assigned to one loop for its + entire lifetime. + + Usage:: + + pool = rtms.EventLoopPool(threads=4) + + @rtms.on_webhook_event + def handle(payload): + client = rtms.Client(executor=EXECUTOR) + client.on_audio_data(on_audio) + pool.add(client) # routed to least-loaded loop + client.join(payload['payload']) + + await pool.run_async() # or pool.run() + + Scaling guidance: + - 1 thread per ~25 clients is a reasonable starting point + - Use executor= on Client for CPU/IO-heavy callbacks + - Monitor loop.client_count to tune thread count + """ + + def __init__( + self, + threads: int = 4, + poll_interval: float = 0.01, + strategy: str = 'least_loaded', + ): + """ + Args: + threads: Number of SDK I/O threads (default: 4) + poll_interval: Seconds between poll cycles per loop (default: 0.01) + strategy: Client routing strategy — 'least_loaded' or 'round_robin' + """ + if threads < 1: + raise ValueError("threads must be >= 1") + if strategy not in ('least_loaded', 'round_robin'): + raise ValueError("strategy must be 'least_loaded' or 'round_robin'") + self._loops = [ + EventLoop(poll_interval=poll_interval, name=f'rtms-pool-{i}') + for i in range(threads) + ] + self._strategy = strategy + self._rr_index = 0 + self._rr_lock = threading.Lock() + + @property + def loops(self) -> List[EventLoop]: + """The underlying EventLoop list.""" + return self._loops + + @property + def client_count(self) -> int: + """Total clients across all loops.""" + return sum(l.client_count for l in self._loops) + + def add(self, client: 'Client') -> EventLoop: + """ + Assign a client to a loop according to the routing strategy. + + Returns the EventLoop the client was assigned to. + """ + if self._strategy == 'least_loaded': + loop = min(self._loops, key=lambda l: l.client_count) + else: # round_robin + with self._rr_lock: + loop = self._loops[self._rr_index % len(self._loops)] + self._rr_index += 1 + loop.add(client) + return loop + + def run(self, stop_on_empty: bool = False) -> None: + """ + Run all loops. Starts N-1 loops as background daemon threads and runs + the last one on the current thread (blocking). + """ + for loop in self._loops[:-1]: + loop.start() + self._loops[-1].run(stop_on_empty=stop_on_empty) + + async def run_async(self, stop_on_empty: bool = False) -> None: + """ + Run all loops as asyncio coroutines concurrently. + """ + await asyncio.gather(*[l.run_async(stop_on_empty=stop_on_empty) for l in self._loops]) + + def stop(self) -> None: + """Stop all loops.""" + for loop in self._loops: + loop.stop() + + +# Module-level default EventLoop used by rtms.run() / rtms.run_async() +_default_loop: Optional[EventLoop] = None + + class Client(_ClientBase): """ RTMS Client - provides real-time media streaming capabilities @@ -548,33 +889,34 @@ class Client(_ClientBase): _sdk_initialized = False - def __init__(self): - """Initialize a new RTMS client""" - # Ensure SDK is initialized before creating client instance - if not Client._sdk_initialized: - try: - ca_path = find_ca_certificate() - log_debug("client", f"Initializing SDK with CA: {ca_path}") - _ClientBase.initialize(ca_path, 1, "python-rtms") - Client._sdk_initialized = True - log_debug("client", "SDK initialized successfully") - except Exception as e: - log_error("client", f"SDK initialization failed: {e}") - # Try with empty path as fallback - try: - log_debug("client", "Trying SDK initialization with empty CA path") - _ClientBase.initialize("", 1, "python-rtms") - Client._sdk_initialized = True - log_debug("client", "SDK initialized with empty CA path") - except Exception as e2: - log_error("client", f"SDK initialization failed completely: {e2}") - raise RuntimeError(f"Failed to initialize RTMS SDK: {e2}") + def __init__(self, executor=None): + """Initialize a new RTMS client. + Args: + executor: Optional concurrent.futures.Executor for dispatching data callbacks + (audio, video, transcript, deskshare) to a thread pool. When set, callbacks + are submitted via executor.submit() instead of running inline. Pass + concurrent.futures.ThreadPoolExecutor(n) for CPU-bound or I/O-heavy callbacks. + """ + # super().__init__() is PyClient() — intentionally a no-op at construction + # time. The C SDK handle is allocated lazily in _do_alloc_and_join(), which + # runs on the owning EventLoop's thread. This satisfies the C SDK's thread + # affinity requirement: alloc/join/poll/release must share one OS thread. super().__init__() - self._polling_thread = None self._polling_interval = 10 # milliseconds self._running = False self._webhook_server = None + self._executor = executor # concurrent.futures.Executor or None + self._loop = None # asyncio event loop captured at callback-registration time + + # EventLoop that owns this client's lifecycle (set by loop.add() or auto-created) + self._assigned_loop: Optional['EventLoop'] = None + # Pending join params — stored until the loop's thread calls _do_alloc_and_join() + self._pending_join_params: Optional[dict] = None + + # Individual video subscription callbacks + self._participant_video_callback = None + self._video_subscribed_callback = None # Shared event dispatcher state (matches Node.js setupEventHandler pattern) self._event_handler_registered = False @@ -592,6 +934,7 @@ def join(self, meeting_uuid: str = None, webinar_uuid: str = None, session_id: str = None, + engagement_id: str = None, rtms_stream_id: str = None, server_urls: str = None, signature: str = None, @@ -610,6 +953,7 @@ def join(self, meeting_uuid (str): Meeting UUID (for Meeting SDK events) webinar_uuid (str): Webinar UUID (for Webinar events) session_id (str): Session ID (for Video SDK events) - used when meeting_uuid is not provided + engagement_id (str): Engagement ID (for ZCC events) - used when meeting_uuid is not provided rtms_stream_id (str): RTMS stream ID server_urls (str): Server URLs (comma-separated) signature (str, optional): Authentication signature. If not provided, will be generated @@ -624,123 +968,121 @@ def join(self, bool: True if joined successfully, False otherwise """ - try: - # Support for both dictionary-style and parameter-style calls - if len(kwargs) > 0: - # If additional kwargs are provided, merge them with the named parameters - params = { - 'meeting_uuid': meeting_uuid, - 'webinar_uuid': webinar_uuid, - 'session_id': session_id, - 'rtms_stream_id': rtms_stream_id, - 'server_urls': server_urls, - 'signature': signature, - 'timeout': timeout, - 'ca': ca, - 'client': client, - 'secret': secret, - 'poll_interval': poll_interval - } - # Update with any additional kwargs - params.update(kwargs) - return self._join_with_params(**params) - - # Check if uuid is actually a dictionary (first param) - if isinstance(meeting_uuid, dict): - return self._join_with_params(**meeting_uuid) - - # Otherwise, use the parameters directly - return self._join_with_params( - meeting_uuid=meeting_uuid, - webinar_uuid=webinar_uuid, - session_id=session_id, - rtms_stream_id=rtms_stream_id, - server_urls=server_urls, - signature=signature, - timeout=timeout, - ca=ca, - client=client, - secret=secret, - poll_interval=poll_interval - ) - except Exception as e: - log_error("client", f"Error in join: {e}") - traceback.print_exc() - return False + # Normalise all call forms into a single params dict + params = { + 'meeting_uuid': meeting_uuid, + 'webinar_uuid': webinar_uuid, + 'session_id': session_id, + 'engagement_id': engagement_id, + 'rtms_stream_id': rtms_stream_id, + 'server_urls': server_urls, + 'signature': signature, + 'timeout': timeout, + 'ca': ca, + 'client': client, + 'secret': secret, + 'poll_interval': poll_interval, + } + if kwargs: + params.update(kwargs) + if isinstance(meeting_uuid, dict): + params = dict(meeting_uuid) + + # Store params for the loop thread to consume + self._pending_join_params = params + + # If the client has been assigned to an explicit EventLoop, it will be + # picked up by that loop's _drain_pending() call — nothing else to do. + if self._assigned_loop is not None: + log_debug("client", "join() deferred to assigned EventLoop thread") + return True + + # If rtms.run() / rtms.run_async() is active, route to the default loop. + if _default_loop is not None: + log_debug("client", "join() routed to default EventLoop") + _default_loop.add(self) + return True - def _join_with_params(self, **params): + # Zero-config (Tier 0): no loop assigned and no run() active — + # create an implicit single-client EventLoop as a background daemon thread. + log_debug("client", "No EventLoop assigned — creating implicit single-client loop") + implicit_loop = EventLoop( + poll_interval=params.get('poll_interval', 10) / 1000.0, + name='rtms-implicit', + ) + implicit_loop.add(self) + implicit_loop.start() + return True + + def _do_alloc_and_join(self) -> None: """ - Internal method to join with parameter dictionary. + Called by EventLoop._drain_pending() on the loop's own thread. - IMPORTANT: Due to SDK threading constraints, the actual join() must be called - from the same thread that runs the event loop. If rtms.run() hasn't been called, - we proceed directly (backwards compatible). Otherwise, we queue for main thread. + Performs the two operations that must share an OS thread: + 1. alloc() — creates the C SDK handle (rtms_alloc) + 2. join() — registers callbacks and connects (rtms_set_callbacks + rtms_join) """ - # If rtms.run() hasn't been called yet, proceed directly (backwards compatible) - if _main_thread_id is None: - return self._do_join(**params) - - # If on main thread (where rtms.run() is executing), join directly - if threading.get_ident() == _main_thread_id: - return self._do_join(**params) - - # Queue the join for main thread execution - log_debug("client", "Join called from non-main thread, queuing request") - with _pending_lock: - _pending_operations.append((self._do_join, (), params)) - log_debug("client", "Join request queued, will be processed by rtms.run()") - return True # Return immediately; actual join happens later - - def _do_join(self, **params): - """Actually perform the join - always called on main thread or when no event loop""" + params = self._pending_join_params + if params is None: + raise RuntimeError("_do_alloc_and_join called with no pending join params") + try: - # Extract parameters with defaults - meeting_uuid = params.get('meeting_uuid') - webinar_uuid = params.get('webinar_uuid') - session_id = params.get('session_id') + meeting_uuid = params.get('meeting_uuid') + webinar_uuid = params.get('webinar_uuid') + session_id = params.get('session_id') + engagement_id = params.get('engagement_id') rtms_stream_id = params.get('rtms_stream_id') - server_urls = params.get('server_urls') - signature = params.get('signature') - timeout = params.get('timeout', -1) - ca = params.get('ca') - client = params.get('client', os.getenv('ZM_RTMS_CLIENT')) - secret = params.get('secret', os.getenv('ZM_RTMS_SECRET')) + server_urls = params.get('server_urls') + signature = params.get('signature') + timeout = params.get('timeout', -1) + client_id = params.get('client', os.getenv('ZM_RTMS_CLIENT')) + secret = params.get('secret', os.getenv('ZM_RTMS_SECRET')) poll_interval = params.get('poll_interval', 10) - # Use meeting_uuid for Meeting SDK, webinar_uuid for Webinar, session_id for Video SDK - instance_id = meeting_uuid or webinar_uuid or session_id - + instance_id = meeting_uuid or webinar_uuid or session_id or engagement_id if not instance_id: - raise ValueError("Either meeting_uuid, webinar_uuid, or session_id is required") + raise ValueError("meeting_uuid, webinar_uuid, session_id, or engagement_id is required") if not rtms_stream_id: - raise ValueError("RTMS Stream ID is required") + raise ValueError("rtms_stream_id is required") if not server_urls: - raise ValueError("Server URLs is required") + raise ValueError("server_urls is required") - # Generate signature if not provided if not signature: - try: - signature = generate_signature(client, secret, instance_id, rtms_stream_id) - except Exception as e: - log_error("client", f"Error generating signature: {e}") - raise + signature = generate_signature(client_id, secret, instance_id, rtms_stream_id) - # Store polling interval self._polling_interval = poll_interval - # Join the meeting/webinar/session - log_info("client", f"Joining {'meeting' if meeting_uuid else 'webinar' if webinar_uuid else 'session'}: {instance_id}") - super().join(instance_id, rtms_stream_id, signature, server_urls, timeout) + # Phase 1: ensure SDK is initialized on THIS thread (same thread as alloc/join). + # The lock ensures init() runs exactly once even if multiple EventLoops start + # simultaneously, but the actual C call only happens on the first client's thread. + with _sdk_init_lock: + if not Client._sdk_initialized: + ca_path = find_ca_certificate() + log_debug("client", f"Initializing SDK with CA: {ca_path}") + try: + _ClientBase.initialize(ca_path, 1, "python-rtms") + except Exception: + log_debug("client", "Trying SDK initialization with empty CA path") + _ClientBase.initialize("", 1, "python-rtms") + Client._sdk_initialized = True + log_debug("client", "SDK initialized successfully") - # Start polling thread - self._start_polling() + # Phase 2: allocate C SDK handle on this thread + super().alloc() + + session_type = 'meeting' if meeting_uuid else 'webinar' if webinar_uuid else 'engagement' if engagement_id else 'session' + log_info("client", f"Joining {session_type}: {instance_id}") + + # join() on the same thread as alloc() — C SDK constraint satisfied + super().join(instance_id, rtms_stream_id, signature, server_urls, timeout) + self._running = True log_info("client", "Successfully joined") - return True + except Exception as e: - log_error("client", f"Error joining: {e}") + log_error("client", f"Error in _do_alloc_and_join: {e}") traceback.print_exc() - return False + raise def _initialize_rtms(self, ca_path=None): """Initialize the RTMS SDK with the best available CA certificate""" @@ -764,29 +1106,91 @@ def _initialize_rtms(self, ca_path=None): log_error("client", f"Failed to initialize with empty CA path: {e2}") raise e # Raise the original error - def _poll_if_needed(self): - """ - Poll the RTMS client if needed. - - IMPORTANT: Due to SDK threading constraints, poll() must be called from the - main thread (same thread that initialized the SDK). This should be called - periodically from the main loop. - """ + def poll(self): + """Poll the C SDK for pending events. Called by the owning EventLoop's thread.""" if self._running: try: super().poll() except Exception as e: log_error("client", f"Error during polling: {e}") - def _start_polling(self): - """Mark that polling should begin (will be done from main thread)""" - self._running = True - log_debug("client", "Polling enabled - call _poll_if_needed() from main loop") + # ======================================================================== + # Callback Dispatch + # ======================================================================== - def _stop_polling(self): - """Stop polling""" - self._running = False - log_debug("client", "Polling stopped") + def _wrap_callback(self, callback): + """Wrap a callback for executor or asyncio dispatch. + + - sync + no executor → returned unchanged (v1.0 inline behavior) + - sync + executor → submitted to executor.submit() on each call + - async coroutine → scheduled on the captured asyncio event loop + """ + if callback is None: + return None + if inspect.iscoroutinefunction(callback): + # Capture the running loop now (at registration time) if one exists. + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + self._loop = loop + def async_wrapper(*args): + _loop = self._loop + if _loop and _loop.is_running(): + asyncio.run_coroutine_threadsafe(callback(*args), _loop) + else: + try: + asyncio.run(callback(*args)) + except RuntimeError: + pass + return async_wrapper + executor = self._executor or _run_executor + if executor is not None: + def executor_wrapper(*args): + executor.submit(callback, *args) + return executor_wrapper + return callback + + # ======================================================================== + # Data Callbacks (Python-level so _wrap_callback applies and aliases work) + # ======================================================================== + + def on_audio_data(self, callback) -> None: + """Register audio data callback. Supports executor and async coroutines.""" + super().on_audio_data(self._wrap_callback(callback)) + + onAudioData = on_audio_data + + def on_video_data(self, callback) -> None: + """Register video data callback. Supports executor and async coroutines.""" + super().on_video_data(self._wrap_callback(callback)) + + onVideoData = on_video_data + + def on_deskshare_data(self, callback) -> None: + """Register deskshare data callback. Supports executor and async coroutines.""" + super().on_deskshare_data(self._wrap_callback(callback)) + + onDeskshareData = on_deskshare_data + + def on_transcript_data(self, callback) -> None: + """Register transcript data callback. Supports executor and async coroutines.""" + super().on_transcript_data(self._wrap_callback(callback)) + + onTranscriptData = on_transcript_data + + # ======================================================================== + # Context Manager + # ======================================================================== + + def __enter__(self): + """Support `with rtms.Client() as client:` usage.""" + return self + + def __exit__(self, *_): + """Call leave() on context exit. Exceptions are not suppressed.""" + self.leave() + return False def stop(self): """ @@ -796,7 +1200,7 @@ def stop(self): """ return self.leave() - def setAudioParams(self, params): + def set_audio_params(self, params): """ Set audio parameters with validation. @@ -810,9 +1214,12 @@ def setAudioParams(self, params): ValueError: If parameters are invalid """ _validate_audio_params(params) - return super().setAudioParams(params) + return super().set_audio_params(params) - def setVideoParams(self, params): + # camelCase legacy alias + setAudioParams = set_audio_params + + def set_video_params(self, params): """ Set video parameters with validation. @@ -826,9 +1233,12 @@ def setVideoParams(self, params): ValueError: If parameters are invalid """ _validate_video_params(params) - return super().setVideoParams(params) + return super().set_video_params(params) + + # camelCase legacy alias + setVideoParams = set_video_params - def setDeskshareParams(self, params): + def set_deskshare_params(self, params): """ Set deskshare parameters with validation. @@ -842,13 +1252,74 @@ def setDeskshareParams(self, params): ValueError: If parameters are invalid """ _validate_deskshare_params(params) - return super().setDeskshareParams(params) + return super().set_deskshare_params(params) - def subscribeEvent(self, events): + # camelCase legacy alias + setDeskshareParams = set_deskshare_params + + def set_transcript_params(self, params): + """ + Set transcript parameters. + + Args: + params (TranscriptParams): Transcript parameters object + + Returns: + bool: True if parameters were set successfully + """ + return super().set_transcript_params(params) + + # camelCase legacy alias + setTranscriptParams = set_transcript_params + + def set_proxy(self, proxy_type: str, proxy_url: str) -> None: + """Configure a proxy for SDK connections. + + Args: + proxy_type (str): Proxy protocol type (e.g. 'http', 'https'). + proxy_url (str): Full proxy URL including host and port. + """ + return super().set_proxy(proxy_type, proxy_url) + + # camelCase legacy alias + setProxy = set_proxy + + def subscribe_video(self, user_id: int, subscribe: bool) -> None: + """Subscribe or unsubscribe from an individual participant's video stream. + + Args: + user_id (int): The participant's user ID. + subscribe (bool): True to subscribe, False to unsubscribe. + """ + return super().subscribe_video(user_id, subscribe) + + subscribeVideo = subscribe_video + + def on_participant_video(self, callback) -> None: + """Register a callback for participant video state changes. + + The callback receives (users: list[int], is_on: bool). + """ + self._participant_video_callback = callback + super().on_participant_video(callback) + + onParticipantVideo = on_participant_video + + def on_video_subscribed(self, callback) -> None: + """Register a callback for video subscription responses. + + The callback receives (user_id: int, status: int, error: str). + """ + self._video_subscribed_callback = callback + super().on_video_subscribed(callback) + + onVideoSubscribed = on_video_subscribed + + def subscribe_event(self, events): """ Subscribe to receive specific event types. - Note: Calling onParticipantEvent() automatically subscribes to + Note: Calling on_participant_event() automatically subscribes to EVENT_PARTICIPANT_JOIN and EVENT_PARTICIPANT_LEAVE events. Args: @@ -857,9 +1328,12 @@ def subscribeEvent(self, events): Returns: bool: True if subscription was successful """ - return super().subscribeEvent(events) + return super().subscribe_event(events) - def unsubscribeEvent(self, events): + # camelCase legacy alias + subscribeEvent = subscribe_event + + def unsubscribe_event(self, events): """ Unsubscribe from specific event types. @@ -869,7 +1343,10 @@ def unsubscribeEvent(self, events): Returns: bool: True if unsubscription was successful """ - return super().unsubscribeEvent(events) + return super().unsubscribe_event(events) + + # camelCase legacy alias + unsubscribeEvent = unsubscribe_event def _setup_event_handler(self): """ @@ -936,9 +1413,9 @@ def event_dispatcher(event_data: str): except Exception as e: log_error('client', f'Failed to parse event: {e}') - super().onEventEx(event_dispatcher) + super().on_event_ex(event_dispatcher) - def onParticipantEvent(self, callback: Callable[[str, int, list], None]) -> bool: + def on_participant_event(self, callback: Callable[[str, int, list], None]) -> bool: """ Register a callback for participant join/leave events. @@ -957,17 +1434,20 @@ def onParticipantEvent(self, callback: Callable[[str, int, list], None]) -> bool Example: >>> def on_participant(event, timestamp, participants): ... print(f"Participant {event}: {participants}") - >>> client.onParticipantEvent(on_participant) + >>> client.on_participant_event(on_participant) """ self._participant_event_callback = callback self._setup_event_handler() try: - self.subscribeEvent([EVENT_PARTICIPANT_JOIN, EVENT_PARTICIPANT_LEAVE]) + self.subscribe_event([EVENT_PARTICIPANT_JOIN, EVENT_PARTICIPANT_LEAVE]) except Exception as e: log_warn('client', f'Failed to auto-subscribe to participant events: {e}') return True - def onActiveSpeakerEvent(self, callback: Callable[[int, int, str], None]) -> bool: + # camelCase legacy alias + onParticipantEvent = on_participant_event + + def on_active_speaker_event(self, callback: Callable[[int, int, str], None]) -> bool: """ Register a callback for active speaker change events. @@ -982,17 +1462,20 @@ def onActiveSpeakerEvent(self, callback: Callable[[int, int, str], None]) -> boo Example: >>> def on_speaker(timestamp, user_id, user_name): ... print(f"Active speaker: {user_name} ({user_id})") - >>> client.onActiveSpeakerEvent(on_speaker) + >>> client.on_active_speaker_event(on_speaker) """ self._active_speaker_callback = callback self._setup_event_handler() try: - self.subscribeEvent([EVENT_ACTIVE_SPEAKER_CHANGE]) + self.subscribe_event([EVENT_ACTIVE_SPEAKER_CHANGE]) except Exception as e: log_warn('client', f'Failed to auto-subscribe to active speaker events: {e}') return True - def onSharingEvent(self, callback: Callable[[str, int, Optional[int], Optional[str]], None]) -> bool: + # camelCase legacy alias + onActiveSpeakerEvent = on_active_speaker_event + + def on_sharing_event(self, callback: Callable[[str, int, Optional[int], Optional[str]], None]) -> bool: """ Register a callback for sharing start/stop events. @@ -1012,17 +1495,20 @@ def onSharingEvent(self, callback: Callable[[str, int, Optional[int], Optional[s Example: >>> def on_sharing(event, timestamp, user_id, user_name): ... print(f"Sharing {event} by {user_name}") - >>> client.onSharingEvent(on_sharing) + >>> client.on_sharing_event(on_sharing) """ self._sharing_callback = callback self._setup_event_handler() try: - self.subscribeEvent([EVENT_SHARING_START, EVENT_SHARING_STOP]) + self.subscribe_event([EVENT_SHARING_START, EVENT_SHARING_STOP]) except Exception as e: log_warn('client', f'Failed to auto-subscribe to sharing events: {e}') return True - def onMediaConnectionInterrupted(self, callback: Callable[[int], None]) -> bool: + # camelCase legacy alias + onSharingEvent = on_sharing_event + + def on_media_connection_interrupted(self, callback: Callable[[int], None]) -> bool: """ Register a callback for media connection interrupted events. @@ -1037,17 +1523,20 @@ def onMediaConnectionInterrupted(self, callback: Callable[[int], None]) -> bool: Example: >>> def on_interrupted(timestamp): ... print(f"Media connection interrupted at {timestamp}") - >>> client.onMediaConnectionInterrupted(on_interrupted) + >>> client.on_media_connection_interrupted(on_interrupted) """ self._media_interrupted_callback = callback self._setup_event_handler() try: - self.subscribeEvent([EVENT_MEDIA_CONNECTION_INTERRUPTED]) + self.subscribe_event([EVENT_MEDIA_CONNECTION_INTERRUPTED]) except Exception as e: log_warn('client', f'Failed to auto-subscribe to media connection interrupted events: {e}') return True - def onEventEx(self, callback: Callable[[str], None]) -> bool: + # camelCase legacy alias + onMediaConnectionInterrupted = on_media_connection_interrupted + + def on_event_ex(self, callback: Callable[[str], None]) -> bool: """ Register a callback for raw event data. @@ -1065,17 +1554,23 @@ def onEventEx(self, callback: Callable[[str], None]) -> bool: self._setup_event_handler() return True + # camelCase legacy alias + onEventEx = on_event_ex + def leave(self): """ - Leave the RTMS session and stop all threads. + Leave the RTMS session. + + Signals the owning EventLoop to stop polling this client and releases + the C SDK handle. Safe to call from any thread. Returns: bool: True if left successfully """ log_info("client", "Leaving RTMS session") - # Stop polling thread - self._stop_polling() + # Signal the EventLoop to stop polling this client + self._running = False # Unregister from global client registry with _clients_lock: @@ -1234,120 +1729,103 @@ def decorator(func): on_webhook_event = onWebhookEvent -def _process_pending_operations(): - """Process operations queued from other threads""" - with _pending_lock: - if not _pending_operations: - return - operations = _pending_operations[:] - _pending_operations.clear() - - for func, args, kwargs in operations: - try: - func(*args, **kwargs) - except Exception as e: - log_error('rtms', f'Error processing pending operation: {e}') - traceback.print_exc() +def run(poll_interval: float = 0.01, stop_on_empty: bool = False, executor=None): + """ + Start the default RTMS event loop (blocking). + Clients that call join() without an explicit EventLoop are automatically + routed to this default loop. Blocks until interrupted or stop() is called. -def _cleanup_all_clients(): - """Clean up all clients on shutdown""" - with _clients_lock: - for client in list(_clients.values()): - try: - client.leave() - except Exception: - pass - _clients.clear() - + Args: + poll_interval: Seconds between poll cycles (default: 0.01 = 10ms) + stop_on_empty: Stop automatically when all clients have left + executor: Optional concurrent.futures.Executor for dispatching data + callbacks on all clients that don't have their own executor set. -def run(poll_interval: float = 0.01, stop_on_empty: bool = False): - """ - Start the RTMS event loop. + Example:: - This function blocks and handles: - - Polling all active clients - - Processing pending operations from other threads (like webhook handlers) - - Graceful shutdown on KeyboardInterrupt + @rtms.on_webhook_event + def handle(payload): + client = rtms.Client() + client.on_transcript_data(lambda d,s,t,m: print(m.userName, d)) + client.join(payload['payload']) - Args: - poll_interval: Time in seconds between poll cycles (default: 0.01 = 10ms) - stop_on_empty: If True, stop when no clients remain (default: False) - - Example: - >>> import rtms - >>> - >>> clients = {} - >>> - >>> @rtms.onWebhookEvent - >>> def handle(payload): - >>> client = rtms.Client() - >>> clients[payload['payload']['rtms_stream_id']] = client - >>> client.onTranscriptData(lambda d,s,t,m: print(m.userName, d)) - >>> client.join(payload['payload']) - >>> - >>> rtms.run() # Blocks until interrupted + rtms.run() # blocks until Ctrl-C """ - global _main_thread_id, _running - - _main_thread_id = threading.get_ident() + global _default_loop, _running, _run_executor + _run_executor = executor + _default_loop = EventLoop(poll_interval=poll_interval, name='rtms-default') _running = True _stop_event.clear() + try: + _default_loop.run(stop_on_empty=stop_on_empty) + finally: + _running = False + _default_loop = None + _run_executor = None - log_info('rtms', f'Starting RTMS event loop (poll_interval={poll_interval}s)') - try: - while _running and not _stop_event.is_set(): - # Process pending operations from other threads - _process_pending_operations() +async def run_async(poll_interval: float = 0.01, stop_on_empty: bool = False, executor=None): + """ + Start the default RTMS event loop as an asyncio coroutine. - # Poll all active clients - with _clients_lock: - clients_to_poll = list(_clients.values()) + Drop-in async replacement for rtms.run(). Yields to the asyncio event loop + between poll cycles so other coroutines (aiohttp, FastAPI, asyncpg) run freely. - for client in clients_to_poll: - if client._running: - try: - client.poll() - except Exception as e: - log_error('rtms', f'Error polling client: {e}') + Args: + poll_interval: Seconds between poll cycles (default: 0.01 = 10ms) + stop_on_empty: Stop automatically when all clients have left + executor: Optional concurrent.futures.Executor for dispatching data + callbacks on all clients that don't have their own executor set. - # Check stop_on_empty condition - if stop_on_empty and not clients_to_poll: - log_info('rtms', 'No active clients, stopping event loop') - break + Example:: - time.sleep(poll_interval) + async def main(): + await asyncio.gather(rtms.run_async(), aiohttp_app.start()) - except KeyboardInterrupt: - log_info('rtms', 'Received interrupt, shutting down...') + asyncio.run(main()) + """ + global _default_loop, _running, _run_executor + _run_executor = executor + _default_loop = EventLoop(poll_interval=poll_interval, name='rtms-default') + _running = True + _stop_event.clear() + try: + await _default_loop.run_async(stop_on_empty=stop_on_empty) finally: _running = False - _main_thread_id = None - _cleanup_all_clients() + _default_loop = None + _run_executor = None def stop(): """ - Signal the event loop to stop. + Stop the default event loop (rtms.run() or rtms.run_async()). - Call this from another thread to gracefully stop the rtms.run() loop. + For EventLoop or EventLoopPool instances, call their .stop() method directly. """ global _running _running = False _stop_event.set() + if _default_loop: + _default_loop.stop() log_info('rtms', 'Stop signal received') __all__ = [ # Classes "Client", + "EventLoop", + "EventLoopPool", "Session", "Participant", + "AiTargetLanguage", + "AiInterpreter", "Metadata", "AudioParams", "VideoParams", "DeskshareParams", + "TranscriptParams", "LogLevel", "LogFormat", @@ -1370,7 +1848,7 @@ def stop(): "USER_LEAVE", # Constants - Event Types (for subscribeEvent/onEventEx) - # These match RTMS_EVENT_TYPE from Zoom's C SDK + # These match EVENT_TYPE from Zoom's C SDK "EVENT_UNDEFINED", "EVENT_FIRST_PACKET_TIMESTAMP", "EVENT_ACTIVE_SPEAKER_CHANGE", @@ -1379,6 +1857,8 @@ def stop(): "EVENT_SHARING_START", "EVENT_SHARING_STOP", "EVENT_MEDIA_CONNECTION_INTERRUPTED", + "EVENT_PARTICIPANT_VIDEO_ON", + "EVENT_PARTICIPANT_VIDEO_OFF", "EVENT_CONSUMER_ANSWERED", "EVENT_CONSUMER_END", "EVENT_USER_ANSWERED", @@ -1397,16 +1877,20 @@ def stop(): "SESS_STATUS_ACTIVE", "SESS_STATUS_PAUSED", + # Transcript language constants dict (mirrors AudioCodec/VideoCodec pattern) + "TranscriptLanguage", + # Parameter dictionaries "AudioContentType", "AudioCodec", "AudioSampleRate", "AudioChannel", - "AudioDataOption", + "DataOption", + "AudioDataOption", # legacy alias for DataOption "VideoContentType", "VideoCodec", "VideoResolution", - "VideoDataOption", + "VideoDataOption", # legacy alias for DataOption "MediaDataType", "SessionState", "StreamState", @@ -1427,6 +1911,7 @@ def stop(): # Event loop functions "run", + "run_async", "stop", # Logging functions diff --git a/src/rtms/__init__.pyi b/src/rtms/__init__.pyi index 72d0a2c..f1912ea 100644 --- a/src/rtms/__init__.pyi +++ b/src/rtms/__init__.pyi @@ -4,6 +4,8 @@ Real-Time Media Streaming SDK for Python """ from typing import Callable, Dict, Any, Optional, List, Literal, TypedDict +from concurrent.futures import Executor +from typing import Awaitable, Coroutine # ============================================================================ # Data Classes @@ -33,12 +35,42 @@ class Participant: @property def name(self) -> str: ... +class AiTargetLanguage: + """A single target language entry from an AI interpreter stream""" + @property + def lid(self) -> int: ... + @property + def toneId(self) -> int: ... + @property + def voiceId(self) -> str: ... + @property + def engine(self) -> str: ... + +class AiInterpreter: + """AI interpreter metadata attached to an audio stream""" + @property + def lid(self) -> int: ... + @property + def timestamp(self) -> int: ... + @property + def channelNum(self) -> int: ... + @property + def sampleRate(self) -> int: ... + @property + def targets(self) -> list[AiTargetLanguage]: ... + class Metadata: """Metadata about a participant in a Zoom meeting""" @property def userName(self) -> str: ... @property def userId(self) -> int: ... + @property + def startTs(self) -> int: ... + @property + def endTs(self) -> int: ... + @property + def aiInterpreter(self) -> AiInterpreter: ... # ============================================================================ # Parameter Classes @@ -198,12 +230,103 @@ EventExCallback = Callable[[str], None] # ============================================================================ # Client Class +# ============================================================================ +# EventLoop and EventLoopPool +# ============================================================================ + +class EventLoop: + """ + An SDK I/O thread that owns one or more Client lifecycles. + + Manages alloc/join/poll/release on a single dedicated OS thread, satisfying + the C SDK's thread-affinity requirement. + """ + def __init__(self, poll_interval: float = 0.01, name: Optional[str] = None) -> None: ... + + @property + def client_count(self) -> int: ... + + def add(self, client: 'Client') -> None: + """Assign a client to this loop. Must be called before client.join().""" + ... + + def run(self, stop_on_empty: bool = False) -> None: + """Run the event loop on the current thread (blocking).""" + ... + + async def run_async(self, stop_on_empty: bool = False) -> None: + """Run the event loop as an asyncio coroutine.""" + ... + + def start(self) -> 'EventLoop': + """Start the event loop in a background daemon thread. Returns self.""" + ... + + def stop(self) -> None: + """Signal the loop to stop after the current poll cycle.""" + ... + + def join(self, timeout: Optional[float] = None) -> None: + """Wait for the background thread to finish (only valid after start()).""" + ... + + +class EventLoopPool: + """ + A pool of EventLoop threads for distributing clients across N SDK I/O threads. + + Example:: + + pool = rtms.EventLoopPool(threads=4) + + @rtms.on_webhook_event + def handle(payload): + client = rtms.Client(executor=EXECUTOR) + client.on_audio_data(on_audio) + pool.add(client) + client.join(payload['payload']) + + await pool.run_async() + """ + def __init__( + self, + threads: int = 4, + poll_interval: float = 0.01, + strategy: Literal['least_loaded', 'round_robin'] = 'least_loaded', + ) -> None: ... + + @property + def loops(self) -> List[EventLoop]: ... + + @property + def client_count(self) -> int: ... + + def add(self, client: 'Client') -> EventLoop: + """Route client to a loop per the strategy. Returns the assigned EventLoop.""" + ... + + def run(self, stop_on_empty: bool = False) -> None: + """Run all loops. Starts N-1 as background threads, runs last on current thread.""" + ... + + async def run_async(self, stop_on_empty: bool = False) -> None: + """Run all loops as concurrent asyncio coroutines.""" + ... + + def stop(self) -> None: + """Stop all loops.""" + ... + + # ============================================================================ class Client: """RTMS Client for connecting to Zoom real-time media streams""" - def __init__(self) -> None: ... + def __init__(self, executor: Optional[Executor] = None) -> None: ... + + def __enter__(self) -> "Client": ... + def __exit__(self, *_: Any) -> bool: ... @staticmethod def initialize(ca_path: str, is_verify_cert: int = 1, agent: Optional[str] = None) -> None: @@ -220,6 +343,7 @@ class Client: meeting_uuid: Optional[str] = None, webinar_uuid: Optional[str] = None, session_id: Optional[str] = None, + engagement_id: Optional[str] = None, rtms_stream_id: Optional[str] = None, server_urls: Optional[str] = None, signature: Optional[str] = None, @@ -235,6 +359,7 @@ class Client: For Meeting SDK events (meeting.rtms_started), use meeting_uuid. For Webinar events (webinar.rtms_started), use webinar_uuid. For Video SDK events (session.rtms_started), use session_id. + For ZCC events (engagement.rtms_started), use engagement_id. """ ... @@ -258,47 +383,129 @@ class Client: """Get meeting UUID""" ... - def streamId(self) -> str: + def stream_id(self) -> str: """Get stream ID""" ... + def streamId(self) -> str: + """Get stream ID (legacy camelCase alias)""" + ... - def enableAudio(self, enable: bool) -> None: + def enable_audio(self, enable: bool) -> None: """Enable/disable audio streaming""" ... + def enableAudio(self, enable: bool) -> None: + """Enable/disable audio streaming (legacy camelCase alias)""" + ... - def enableVideo(self, enable: bool) -> None: + def enable_video(self, enable: bool) -> None: """Enable/disable video streaming""" ... + def enableVideo(self, enable: bool) -> None: + """Enable/disable video streaming (legacy camelCase alias)""" + ... - def enableTranscript(self, enable: bool) -> None: + def enable_transcript(self, enable: bool) -> None: """Enable/disable transcript streaming""" ... + def enableTranscript(self, enable: bool) -> None: + """Enable/disable transcript streaming (legacy camelCase alias)""" + ... - def enableDeskshare(self, enable: bool) -> None: + def enable_deskshare(self, enable: bool) -> None: """Enable/disable deskshare streaming""" ... + def enableDeskshare(self, enable: bool) -> None: + """Enable/disable deskshare streaming (legacy camelCase alias)""" + ... - def setAudioParams(self, params: AudioParams) -> None: + def set_audio_params(self, params: AudioParams) -> None: """Set audio parameters""" ... + def setAudioParams(self, params: AudioParams) -> None: + """Set audio parameters (legacy camelCase alias)""" + ... - def setVideoParams(self, params: VideoParams) -> None: + def set_video_params(self, params: VideoParams) -> None: """Set video parameters""" ... + def setVideoParams(self, params: VideoParams) -> None: + """Set video parameters (legacy camelCase alias)""" + ... - def setDeskshareParams(self, params: DeskshareParams) -> None: + def set_deskshare_params(self, params: DeskshareParams) -> None: """Set deskshare parameters""" ... + def setDeskshareParams(self, params: DeskshareParams) -> None: + """Set deskshare parameters (legacy camelCase alias)""" + ... - def onJoinConfirm(self, callback: Callable[[int], None]) -> None: + def set_transcript_params(self, params: TranscriptParams) -> None: + """Set transcript parameters""" + ... + def setTranscriptParams(self, params: TranscriptParams) -> None: + """Set transcript parameters (legacy camelCase alias)""" + ... + + def set_proxy(self, proxy_type: str, proxy_url: str) -> None: + """Configure a proxy for SDK connections""" + ... + def setProxy(self, proxy_type: str, proxy_url: str) -> None: + """Configure a proxy for SDK connections (legacy camelCase alias)""" + ... + + def on_join_confirm(self, callback: Callable[[int], None]) -> None: """Register join confirm callback""" ... + def onJoinConfirm(self, callback: Callable[[int], None]) -> None: + """Register join confirm callback (legacy camelCase alias)""" + ... - def onSessionUpdate(self, callback: Callable[[int, Session], None]) -> None: + def on_session_update(self, callback: Callable[[int, Session], None]) -> None: """Register session update callback""" ... + def onSessionUpdate(self, callback: Callable[[int, Session], None]) -> None: + """Register session update callback (legacy camelCase alias)""" + ... - def onParticipantEvent(self, callback: ParticipantEventCallback) -> bool: + def on_user_update(self, callback: Callable[[int, Participant], None]) -> None: + """Register user update callback""" + ... + def onUserUpdate(self, callback: Callable[[int, Participant], None]) -> None: + """Register user update callback (legacy camelCase alias)""" + ... + + def _wrap_callback(self, callback: Callable) -> Callable: + """Wrap a callback for executor or asyncio dispatch.""" + ... + + def on_audio_data(self, callback: Callable[[bytes, int, int, Metadata], None]) -> None: + """Register audio data callback. Supports executor and async coroutines.""" + ... + onAudioData: Callable # camelCase alias + + def on_video_data(self, callback: Callable[[bytes, int, int, Metadata], None]) -> None: + """Register video data callback. Supports executor and async coroutines.""" + ... + onVideoData: Callable # camelCase alias + + def on_deskshare_data(self, callback: Callable[[bytes, int, int, Metadata], None]) -> None: + """Register deskshare data callback. Supports executor and async coroutines.""" + ... + onDeskshareData: Callable # camelCase alias + + def on_transcript_data(self, callback: Callable[[bytes, int, int, Metadata], None]) -> None: + """Register transcript data callback. Supports executor and async coroutines.""" + ... + onTranscriptData: Callable # camelCase alias + + def on_leave(self, callback: Callable[[int], None]) -> None: + """Register leave callback""" + ... + def onLeave(self, callback: Callable[[int], None]) -> None: + """Register leave callback (legacy camelCase alias)""" + ... + + def on_participant_event(self, callback: ParticipantEventCallback) -> bool: """ Register participant join/leave event callback. @@ -314,8 +521,11 @@ class Client: True if callback was set successfully """ ... + def onParticipantEvent(self, callback: ParticipantEventCallback) -> bool: + """Register participant join/leave event callback (legacy camelCase alias)""" + ... - def onActiveSpeakerEvent(self, callback: ActiveSpeakerEventCallback) -> bool: + def on_active_speaker_event(self, callback: ActiveSpeakerEventCallback) -> bool: """ Register active speaker change event callback. @@ -331,8 +541,11 @@ class Client: True if callback was set successfully """ ... + def onActiveSpeakerEvent(self, callback: ActiveSpeakerEventCallback) -> bool: + """Register active speaker change event callback (legacy camelCase alias)""" + ... - def onSharingEvent(self, callback: SharingEventCallback) -> bool: + def on_sharing_event(self, callback: SharingEventCallback) -> bool: """ Register sharing start/stop event callback. @@ -350,8 +563,11 @@ class Client: True if callback was set successfully """ ... + def onSharingEvent(self, callback: SharingEventCallback) -> bool: + """Register sharing start/stop event callback (legacy camelCase alias)""" + ... - def onMediaConnectionInterrupted(self, callback: Callable[[int], None]) -> bool: + def on_media_connection_interrupted(self, callback: Callable[[int], None]) -> bool: """ Register media connection interrupted event callback. @@ -364,8 +580,11 @@ class Client: True if callback was set successfully """ ... + def onMediaConnectionInterrupted(self, callback: Callable[[int], None]) -> bool: + """Register media connection interrupted event callback (legacy camelCase alias)""" + ... - def onEventEx(self, callback: EventExCallback) -> bool: + def on_event_ex(self, callback: EventExCallback) -> bool: """ Register raw JSON event callback. @@ -380,32 +599,15 @@ class Client: True if callback was set successfully """ ... - - def onAudioData(self, callback: Callable[[bytes, int, int, Metadata], None]) -> None: - """Register audio data callback""" - ... - - def onVideoData(self, callback: Callable[[bytes, int, int, Metadata], None]) -> None: - """Register video data callback""" - ... - - def onDeskshareData(self, callback: Callable[[bytes, int, int, Metadata], None]) -> None: - """Register deskshare data callback""" - ... - - def onTranscriptData(self, callback: Callable[[bytes, int, int, Metadata], None]) -> None: - """Register transcript data callback""" - ... - - def onLeave(self, callback: Callable[[int], None]) -> None: - """Register leave callback""" + def onEventEx(self, callback: EventExCallback) -> bool: + """Register raw JSON event callback (legacy camelCase alias)""" ... - def subscribeEvent(self, events: List[int]) -> bool: + def subscribe_event(self, events: List[int]) -> bool: """ Subscribe to receive specific event types. - Note: Typed event callbacks (onParticipantEvent, onActiveSpeakerEvent, etc.) + Note: Typed event callbacks (on_participant_event, on_active_speaker_event, etc.) automatically subscribe to their respective events. Args: @@ -415,8 +617,11 @@ class Client: True if subscription was successful """ ... + def subscribeEvent(self, events: List[int]) -> bool: + """Subscribe to event types (legacy camelCase alias)""" + ... - def unsubscribeEvent(self, events: List[int]) -> bool: + def unsubscribe_event(self, events: List[int]) -> bool: """ Unsubscribe from specific event types. @@ -427,6 +632,9 @@ class Client: True if unsubscription was successful """ ... + def unsubscribeEvent(self, events: List[int]) -> bool: + """Unsubscribe from event types (legacy camelCase alias)""" + ... def on_webhook_event( self, @@ -466,7 +674,7 @@ USER_LEAVE: int # ============================================================================ # Constants - Event Types (for subscribeEvent/unsubscribeEvent) -# These match RTMS_EVENT_TYPE from Zoom's C SDK +# These match EVENT_TYPE from Zoom's C SDK # ============================================================================ EVENT_UNDEFINED: int @@ -477,6 +685,8 @@ EVENT_PARTICIPANT_LEAVE: int EVENT_SHARING_START: int EVENT_SHARING_STOP: int EVENT_MEDIA_CONNECTION_INTERRUPTED: int +EVENT_PARTICIPANT_VIDEO_ON: int +EVENT_PARTICIPANT_VIDEO_OFF: int EVENT_CONSUMER_ANSWERED: int EVENT_CONSUMER_END: int EVENT_USER_ANSWERED: int @@ -500,26 +710,38 @@ SESS_STATUS_ACTIVE: int SESS_STATUS_PAUSED: int # ============================================================================ -# Parameter Dictionaries +# Parameter Enums # ============================================================================ -AudioContentType: Dict[str, int] -AudioCodec: Dict[str, int] -AudioSampleRate: Dict[str, int] -AudioChannel: Dict[str, int] -AudioDataOption: Dict[str, int] +from enum import IntEnum -VideoContentType: Dict[str, int] -VideoCodec: Dict[str, int] -VideoResolution: Dict[str, int] -VideoDataOption: Dict[str, int] +class AudioContentType(IntEnum): ... +class AudioCodec(IntEnum): ... +class AudioSampleRate(IntEnum): ... +class AudioChannel(IntEnum): ... -MediaDataType: Dict[str, int] -SessionState: Dict[str, int] -StreamState: Dict[str, int] -EventType: Dict[str, int] -MessageType: Dict[str, int] -StopReason: Dict[str, int] +class DataOption(IntEnum): + UNDEFINED: int + AUDIO_MIXED_STREAM: int + AUDIO_MULTI_STREAMS: int + VIDEO_SINGLE_ACTIVE_STREAM: int + VIDEO_SINGLE_INDIVIDUAL_STREAM: int + VIDEO_MIXED_GALLERY_VIEW: int + +AudioDataOption = DataOption # legacy alias +VideoDataOption = DataOption # legacy alias + +class VideoContentType(IntEnum): ... +class VideoCodec(IntEnum): ... +class VideoResolution(IntEnum): ... + +class MediaDataType(IntEnum): ... +class SessionState(IntEnum): ... +class StreamState(IntEnum): ... +class EventType(IntEnum): ... +class MessageType(IntEnum): ... +class StopReason(IntEnum): ... +class TranscriptLanguage(IntEnum): ... # ============================================================================ # SDK Initialization Functions @@ -605,35 +827,56 @@ on_webhook_event = onWebhookEvent # Event Loop Functions # ============================================================================ -def run(poll_interval: float = 0.01, stop_on_empty: bool = False) -> None: +def run( + poll_interval: float = 0.01, + stop_on_empty: bool = False, + executor: Optional[Executor] = None, +) -> None: """ - Start the RTMS event loop. + Start the RTMS event loop (blocking). - This function blocks and handles: - - Polling all active clients - - Processing pending operations from other threads (like webhook handlers) - - Graceful shutdown on KeyboardInterrupt - - With this function, you can create clients and call join() directly from - webhook handlers without manual queue management. + Polls all active clients in a loop, processing webhook-triggered joins and + dispatching callbacks. Blocks until KeyboardInterrupt or rtms.stop() is called. Args: poll_interval: Time in seconds between poll cycles (default: 0.01 = 10ms) stop_on_empty: If True, stop when no clients remain (default: False) + executor: Optional global executor for callback dispatch on all clients that + do not have their own executor set. Example: >>> import rtms - >>> - >>> clients = {} - >>> >>> @rtms.onWebhookEvent >>> def handle(payload): >>> client = rtms.Client() - >>> clients[payload['payload']['rtms_stream_id']] = client - >>> client.onTranscriptData(lambda d,s,t,m: print(m.userName, d)) + >>> client.on_transcript_data(lambda d,s,t,m: print(m.userName, d)) >>> client.join(payload['payload']) - >>> - >>> rtms.run() # Blocks until interrupted + >>> rtms.run() + """ + ... + +async def run_async( + poll_interval: float = 0.01, + stop_on_empty: bool = False, + executor: Optional[Executor] = None, +) -> None: + """ + Start the RTMS event loop as an asyncio coroutine. + + Drop-in async replacement for rtms.run(). Uses asyncio.sleep() instead of + time.sleep(), yielding control back to the event loop between poll cycles so + it composes naturally with aiohttp, FastAPI, asyncpg, and other async frameworks. + + Args: + poll_interval: Time in seconds between poll cycles (default: 0.01 = 10ms) + stop_on_empty: If True, stop when no clients remain (default: False) + executor: Optional global executor for callback dispatch. + + Example: + >>> import asyncio, rtms + >>> async def main(): + >>> await asyncio.gather(rtms.run_async(), my_server.start()) + >>> asyncio.run(main()) """ ... @@ -641,7 +884,8 @@ def stop() -> None: """ Signal the event loop to stop. - Call this from another thread to gracefully stop the rtms.run() loop. + Call this from another thread or coroutine to gracefully stop rtms.run() + or rtms.run_async(). """ ... diff --git a/tests/cpp/mock_sdk.cpp b/tests/cpp/mock_sdk.cpp new file mode 100644 index 0000000..3e6a3b7 --- /dev/null +++ b/tests/cpp/mock_sdk.cpp @@ -0,0 +1,184 @@ +/** + * Mock implementations of rtms_sdk, rtms_sdk_provider, and g_agent. + * Linked in place of the real Zoom RTMS shared library for unit tests. + */ + +#include "mock_sdk.h" +#include +#include + +// Required by rtms_sdk.h extern declaration +std::string g_agent; + +// Global mock state — reset in each test +MockSdkState g_mock_state; + +// ============================================================================ +// rtms_sdk stubs +// rtms_sdk_impl is only forward-declared; m_impl is never used in the mock. +// ============================================================================ + +rtms_sdk::rtms_sdk() : m_impl(nullptr) {} +rtms_sdk::~rtms_sdk() {} + +int rtms_sdk::open(rtms_sdk_sink* sink) { + ++g_mock_state.open_calls; + g_mock_state.last_sink = sink; + return g_mock_state.open_result; +} + +int rtms_sdk::config(struct media_parameters* params, int media_types, int ale, int feature_ids) { + ++g_mock_state.config_calls; + g_mock_state.last_media_types = media_types; + g_mock_state.last_ale = ale; + return g_mock_state.config_result; +} + +int rtms_sdk::set_proxy(std::string proxy_type, std::string proxy_url) { + ++g_mock_state.proxy_calls; + g_mock_state.last_proxy_type = std::move(proxy_type); + g_mock_state.last_proxy_url = std::move(proxy_url); + return g_mock_state.proxy_result; +} + +int rtms_sdk::join(const char* meeting_uuid, const char* rtms_stream_id, + const char* signature, const char* server_url, int timeout) { + ++g_mock_state.join_calls; + g_mock_state.last_meeting_uuid = meeting_uuid ? meeting_uuid : ""; + g_mock_state.last_stream_id = rtms_stream_id ? rtms_stream_id : ""; + g_mock_state.last_signature = signature ? signature : ""; + g_mock_state.last_server_url = server_url ? server_url : ""; + g_mock_state.last_timeout = timeout; + return g_mock_state.join_result; +} + +int rtms_sdk::send_data(int, unsigned char*, int, uint64_t, void*) { + return RTMS_SDK_OK; +} + +int rtms_sdk::leave(int) { + ++g_mock_state.leave_calls; + return g_mock_state.leave_result; +} + +int rtms_sdk::poll() { + ++g_mock_state.poll_calls; + return g_mock_state.poll_result; +} + +int rtms_sdk::subscribe_event(int events[], int len) { + ++g_mock_state.subscribe_calls; + g_mock_state.last_subscribed_events.assign(events, events + len); + return g_mock_state.subscribe_result; +} + +int rtms_sdk::unsubscribe_event(int events[], int len) { + ++g_mock_state.unsubscribe_calls; + g_mock_state.last_unsubscribed_events.assign(events, events + len); + return g_mock_state.unsubscribe_result; +} + +int rtms_sdk::send_subscript_video(int user_id, bool is_sub) { + ++g_mock_state.subscript_video_calls; + g_mock_state.last_subscript_user_id = user_id; + g_mock_state.last_subscript_is_sub = is_sub; + return g_mock_state.subscript_video_result; +} + +// ============================================================================ +// rtms_sdk_provider stubs +// ============================================================================ + +rtms_sdk_provider* rtms_sdk_provider::s_inst = nullptr; +std::mutex rtms_sdk_provider::mtx; + +rtms_sdk_provider::rtms_sdk_provider() {} +rtms_sdk_provider::~rtms_sdk_provider() {} + +rtms_sdk_provider* rtms_sdk_provider::instance() { + std::lock_guard lock(mtx); + if (!s_inst) s_inst = new rtms_sdk_provider(); + return s_inst; +} + +int rtms_sdk_provider::init(const char* ca_path, bool is_verify_cert) { + ++g_mock_state.init_calls; + g_mock_state.last_ca_path = ca_path ? ca_path : ""; + g_mock_state.last_verify_cert = is_verify_cert; + return g_mock_state.init_result; +} + +rtms_sdk* rtms_sdk_provider::create_sdk() { + ++g_mock_state.create_calls; + if (g_mock_state.create_result != 0) return nullptr; + return new rtms_sdk(); +} + +int rtms_sdk_provider::release_sdk(rtms_sdk* sdk) { + ++g_mock_state.release_calls; + delete sdk; + return RTMS_SDK_OK; +} + +void rtms_sdk_provider::uninit() { + ++g_mock_state.uninit_calls; +} + +// ============================================================================ +// Callback trigger helpers +// ============================================================================ + +void mock_trigger_join_confirm(int reason) { + if (g_mock_state.last_sink) + g_mock_state.last_sink->on_join_confirm(reason); +} + +void mock_trigger_session_update(int op, session_info* sess) { + if (g_mock_state.last_sink) + g_mock_state.last_sink->on_session_update(op, sess); +} + +void mock_trigger_user_update(int op, participant_info* pi) { + if (g_mock_state.last_sink) + g_mock_state.last_sink->on_user_update(op, pi); +} + +void mock_trigger_audio_data(unsigned char* buf, int size, uint64_t ts, rtms_metadata* md) { + if (g_mock_state.last_sink) + g_mock_state.last_sink->on_audio_data(buf, size, ts, md); +} + +void mock_trigger_video_data(unsigned char* buf, int size, uint64_t ts, rtms_metadata* md) { + if (g_mock_state.last_sink) + g_mock_state.last_sink->on_video_data(buf, size, ts, md); +} + +void mock_trigger_ds_data(unsigned char* buf, int size, uint64_t ts, rtms_metadata* md) { + if (g_mock_state.last_sink) + g_mock_state.last_sink->on_ds_data(buf, size, ts, md); +} + +void mock_trigger_transcript_data(unsigned char* buf, int size, uint64_t ts, rtms_metadata* md) { + if (g_mock_state.last_sink) + g_mock_state.last_sink->on_transcript_data(buf, size, ts, md); +} + +void mock_trigger_leave(int reason) { + if (g_mock_state.last_sink) + g_mock_state.last_sink->on_leave(reason); +} + +void mock_trigger_event_ex(const std::string& compact_str) { + if (g_mock_state.last_sink) + g_mock_state.last_sink->on_event_ex(compact_str); +} + +void mock_trigger_participant_video(std::vector users, bool is_on) { + if (g_mock_state.last_sink) + g_mock_state.last_sink->on_participant_video(std::move(users), is_on); +} + +void mock_trigger_video_subscript_resp(int user_id, int status, std::string error) { + if (g_mock_state.last_sink) + g_mock_state.last_sink->on_video_subscript_resp(user_id, status, std::move(error)); +} diff --git a/tests/cpp/mock_sdk.h b/tests/cpp/mock_sdk.h new file mode 100644 index 0000000..fafee76 --- /dev/null +++ b/tests/cpp/mock_sdk.h @@ -0,0 +1,99 @@ +/** + * Mock SDK state for C++ unit tests. + * + * Provides: + * - g_mock_state — per-test configurable return values and call records + * - mock_trigger_* — helpers to fire SDK callbacks on the registered sink + * + * Usage in tests: + * g_mock_state.reset(); // called in each test fixture + * g_mock_state.join_result = RTMS_SDK_FAILURE; // inject failure + * mock_trigger_join_confirm(0); // simulate SDK firing a callback + */ + +#pragma once + +#include "rtms_sdk.h" +#include +#include +#include + +struct MockSdkState { + // --- Configurable return values --- + int create_result = 0; // 0 = return real sdk, non-zero = return nullptr + int init_result = RTMS_SDK_OK; + int open_result = RTMS_SDK_OK; + int config_result = RTMS_SDK_OK; + int join_result = RTMS_SDK_OK; + int poll_result = RTMS_SDK_OK; + int leave_result = RTMS_SDK_OK; + int subscribe_result = RTMS_SDK_OK; + int unsubscribe_result = RTMS_SDK_OK; + int proxy_result = RTMS_SDK_OK; + int subscript_video_result = RTMS_SDK_OK; + + // --- Call counters --- + int init_calls = 0; + int uninit_calls = 0; + int create_calls = 0; + int release_calls = 0; + int open_calls = 0; + int config_calls = 0; + int join_calls = 0; + int poll_calls = 0; + int leave_calls = 0; + int subscribe_calls = 0; + int unsubscribe_calls = 0; + int proxy_calls = 0; + int subscript_video_calls = 0; + + // --- Recorded arguments --- + rtms_sdk_sink* last_sink = nullptr; + + // init() + std::string last_ca_path; + bool last_verify_cert = true; + + // join() + std::string last_meeting_uuid; + std::string last_stream_id; + std::string last_signature; + std::string last_server_url; + int last_timeout = 0; + + // config() + int last_media_types = 0; + int last_ale = 0; // application layer encryption flag + + // set_proxy() + std::string last_proxy_type; + std::string last_proxy_url; + + // subscribe_event() / unsubscribe_event() + std::vector last_subscribed_events; + std::vector last_unsubscribed_events; + + // send_subscript_video() + int last_subscript_user_id = 0; + bool last_subscript_is_sub = false; + + void reset() { *this = MockSdkState{}; } +}; + +extern MockSdkState g_mock_state; + +// ============================================================================ +// Callback trigger helpers — simulate the SDK firing events on the Client +// ============================================================================ + +void mock_trigger_join_confirm(int reason); +void mock_trigger_session_update(int op, session_info* sess); +void mock_trigger_user_update(int op, participant_info* pi); +void mock_trigger_audio_data(unsigned char* buf, int size, uint64_t ts, rtms_metadata* md); +void mock_trigger_video_data(unsigned char* buf, int size, uint64_t ts, rtms_metadata* md); +void mock_trigger_ds_data(unsigned char* buf, int size, uint64_t ts, rtms_metadata* md); +void mock_trigger_transcript_data(unsigned char* buf, int size, uint64_t ts, rtms_metadata* md); +void mock_trigger_leave(int reason); +void mock_trigger_event_ex(const std::string& compact_str); +void mock_trigger_participant_video(std::vector users, bool is_on); +void mock_trigger_video_subscript_resp(int user_id, int status, std::string error); diff --git a/tests/cpp/test_cpp_wrapper.cpp b/tests/cpp/test_cpp_wrapper.cpp new file mode 100644 index 0000000..83a8728 --- /dev/null +++ b/tests/cpp/test_cpp_wrapper.cpp @@ -0,0 +1,1127 @@ +/** + * C++ unit tests for the RTMS SDK wrapper (src/rtms.h / src/rtms.cpp). + * + * Linked against tests/mock_sdk.cpp instead of the real Zoom SDK binary, + * so these run in CI without credentials or the proprietary library. + * + * Test coverage: + * - AudioParams / VideoParams / DeskshareParams validation + * - Session / Participant / Metadata data classes + * - MediaParams composition and toNative() + * - Client lifecycle (create, initialize, join, poll, release) + * - Callback dispatch (via mock_trigger_* helpers) + * - Event subscription deferral / on-confirm flush + * - Media type auto-enable on callback registration + * - setProxy forwarding and error handling + */ + +#include +#include + +#include "rtms.h" +#include "mock_sdk.h" + +#include +#include +#include + +using namespace rtms; +using Catch::Matchers::ContainsSubstring; + +// Reset global mock state before every test case +struct R { R() { g_mock_state.reset(); } }; + +// ============================================================================ +// AudioParams +// ============================================================================ + +TEST_CASE("AudioParams default constructor", "[params][audio]") { + R _; + AudioParams p; + CHECK(p.contentType() == 2); // RAW_AUDIO + CHECK(p.codec() == 4); // OPUS + CHECK(p.sampleRate() == 3); // SR_48K + CHECK(p.channel() == 2); // STEREO + CHECK(p.dataOpt() == 2); // AUDIO_MULTI_STREAMS + CHECK(p.duration() == 20); + CHECK(p.frameSize() == 960); +} + +TEST_CASE("AudioParams validate() — valid configurations", "[params][audio]") { + R _; + + SECTION("default params pass validation") { + AudioParams p; + REQUIRE_NOTHROW(p.validate()); + } + + SECTION("PCM/L16 at 16kHz with correct frame size passes") { + // L16=1, SR_16K=1, MONO=1, AUDIO_MULTI_STREAMS=2, 20ms → 320 samples + AudioParams p(2, 1, 1, 1, 2, 20, 320); + REQUIRE_NOTHROW(p.validate()); + } + + SECTION("PCM/L16 at 8kHz with correct frame size passes") { + // L16=1, SR_8K=0, MONO=1, AUDIO_MULTI_STREAMS=2, 20ms → 160 samples + AudioParams p(2, 1, 0, 1, 2, 20, 160); + REQUIRE_NOTHROW(p.validate()); + } +} + +TEST_CASE("AudioParams validate() — invalid configurations throw", "[params][audio]") { + R _; + + SECTION("contentType 0 throws") { + AudioParams p; + p.setContentType(0); + REQUIRE_THROWS_AS(p.validate(), std::invalid_argument); + } + + SECTION("codec 0 throws") { + AudioParams p; + p.setCodec(0); + REQUIRE_THROWS_AS(p.validate(), std::invalid_argument); + } + + SECTION("channel 0 throws") { + AudioParams p; + p.setChannel(0); + REQUIRE_THROWS_AS(p.validate(), std::invalid_argument); + } + + SECTION("dataOpt 0 throws") { + AudioParams p; + p.setDataOpt(0); + REQUIRE_THROWS_AS(p.validate(), std::invalid_argument); + } + + SECTION("OPUS codec with 16kHz sample rate throws") { + AudioParams p; + p.setSampleRate(1); // SR_16K — OPUS requires SR_48K + REQUIRE_THROWS_AS(p.validate(), std::invalid_argument); + } + + SECTION("OPUS codec with 8kHz sample rate throws") { + AudioParams p; + p.setSampleRate(0); // SR_8K + REQUIRE_THROWS_AS(p.validate(), std::invalid_argument); + } + + SECTION("frameSize mismatch for 48kHz 20ms throws") { + AudioParams p; // default: 48kHz, 20ms → expects 960 + p.setFrameSize(480); // wrong + REQUIRE_THROWS_AS(p.validate(), std::invalid_argument); + } + + SECTION("frameSize mismatch error message names the values") { + AudioParams p; + p.setFrameSize(480); + REQUIRE_THROWS_WITH(p.validate(), ContainsSubstring("960")); + } + + SECTION("OPUS error message mentions 48kHz") { + AudioParams p; + p.setSampleRate(1); + REQUIRE_THROWS_WITH(p.validate(), ContainsSubstring("48kHz")); + } +} + +TEST_CASE("AudioParams toNative() maps fields correctly", "[params][audio]") { + R _; + AudioParams p(1, 2, 0, 1, 1, 20, 160); // RTP, G711, 8K, MONO, MIXED, 20ms, 160 + auto n = p.toNative(); + CHECK(n.content_type == 1); + CHECK(n.codec == 2); + CHECK(n.sample_rate == 0); + CHECK(n.channel == 1); + CHECK(n.data_opt == 1); + CHECK(n.duration == 20); + CHECK(n.frame_size == 160); +} + +// ============================================================================ +// VideoParams +// ============================================================================ + +TEST_CASE("VideoParams default constructor uses H264/HD/30fps defaults", "[params][video]") { + R _; + VideoParams p; + CHECK(p.contentType() == (int)MEDIA_CONTENT_TYPE::RAW_VIDEO); + CHECK(p.codec() == (int)MEDIA_PAYLOAD_TYPE::H264); + CHECK(p.resolution() == (int)MEDIA_RESOLUTION::HD); + CHECK(p.dataOpt() == (int)MEDIA_DATA_OPTION::VIDEO_SINGLE_ACTIVE_STREAM); + CHECK(p.fps() == 30); +} + +TEST_CASE("VideoParams validate() — invalid configurations throw", "[params][video]") { + R _; + + SECTION("resolution 0 throws") { + VideoParams p(3, 7, 0, 5, 30); // RAW_VIDEO, H264, no resolution + REQUIRE_THROWS_AS(p.validate(), std::invalid_argument); + } + + SECTION("JPG codec with fps > 5 throws") { + VideoParams p(3, 5, 2, 5, 10); // JPG=5, HD=2, fps=10 + REQUIRE_THROWS_AS(p.validate(), std::invalid_argument); + } + + SECTION("PNG codec with fps > 5 throws") { + VideoParams p(3, 6, 2, 5, 6); // PNG=6, fps=6 + REQUIRE_THROWS_AS(p.validate(), std::invalid_argument); + } + + SECTION("JPG codec with fps <= 5 passes") { + VideoParams p(3, 5, 2, 5, 5); // JPG=5, fps=5 + REQUIRE_NOTHROW(p.validate()); + } + + SECTION("H264 with fps > 30 throws") { + VideoParams p(3, 7, 2, 5, 31); // H264=7, fps=31 + REQUIRE_THROWS_AS(p.validate(), std::invalid_argument); + } + + SECTION("H264 with fps 0 throws") { + VideoParams p(3, 7, 2, 5, 0); + REQUIRE_THROWS_AS(p.validate(), std::invalid_argument); + } + + SECTION("H264 with fps 30 passes") { + VideoParams p(3, 7, 2, 5, 30); + REQUIRE_NOTHROW(p.validate()); + } +} + +// ============================================================================ +// DeskshareParams +// ============================================================================ + +TEST_CASE("DeskshareParams validate() — fps/codec rules match VideoParams", "[params][ds]") { + R _; + + SECTION("JPG with fps > 5 throws") { + DeskshareParams p(3, 5, 2, 10); // JPG=5, fps=10 + REQUIRE_THROWS_AS(p.validate(), std::invalid_argument); + } + + SECTION("H264 with fps 30 passes") { + DeskshareParams p(3, 7, 2, 30); + REQUIRE_NOTHROW(p.validate()); + } +} + +// ============================================================================ +// Session +// ============================================================================ + +TEST_CASE("Session normal construction", "[data][session]") { + R _; + session_info info; + char sid[] = "sess-abc"; + char strid[] = "stream-xyz"; + char mid[MAX_MEETING_ID_LEN] = "meeting-123"; + info.session_id = sid; + info.stream_id = strid; + memcpy(info.meeting_id, mid, sizeof(info.meeting_id)); + info.stat_time = 42; + info.status = SESS_STATUS_ACTIVE; + + Session s(info); + CHECK(s.sessionId() == "sess-abc"); + CHECK(s.streamId() == "stream-xyz"); + CHECK(s.meetingId() == "meeting-123"); + CHECK(s.statTime() == 42); + CHECK(s.isActive()); + CHECK_FALSE(s.isPaused()); +} + +TEST_CASE("Session null pointer safety", "[data][session]") { + R _; + + SECTION("null session_id becomes empty string") { + session_info info{}; + info.session_id = nullptr; + info.stream_id = nullptr; + Session s(info); + CHECK(s.sessionId().empty()); + CHECK(s.streamId().empty()); + } + + SECTION("low-address session_id treated as invalid") { + session_info info{}; + // Anything <= 0xFFFF is treated as an invalid pointer (not a real address) + info.session_id = reinterpret_cast(0x100); + info.stream_id = nullptr; + Session s(info); + CHECK(s.sessionId().empty()); + } + + SECTION("meeting_id buffer without null terminator is safe") { + session_info info{}; + info.session_id = nullptr; + info.stream_id = nullptr; + // Fill entire buffer with 'X' — no null terminator + memset(info.meeting_id, 'X', MAX_MEETING_ID_LEN); + Session s(info); + // Should read exactly MAX_MEETING_ID_LEN 'X' characters, not overflow + CHECK(s.meetingId().size() == MAX_MEETING_ID_LEN); + CHECK(s.meetingId() == std::string(MAX_MEETING_ID_LEN, 'X')); + } +} + +TEST_CASE("Session pause status", "[data][session]") { + R _; + session_info info{}; + info.status = SESS_STATUS_PAUSED; + Session s(info); + CHECK(s.isPaused()); + CHECK_FALSE(s.isActive()); +} + +// ============================================================================ +// Participant +// ============================================================================ + +TEST_CASE("Participant construction", "[data][participant]") { + R _; + + SECTION("normal construction copies id and name") { + participant_info pi; + char name[] = "Alice"; + pi.participant_id = 99; + pi.participant_name = name; + Participant p(pi); + CHECK(p.id() == 99); + CHECK(p.name() == "Alice"); + } + + SECTION("null participant_name becomes empty string") { + participant_info pi; + pi.participant_id = 1; + pi.participant_name = nullptr; + Participant p(pi); + CHECK(p.name().empty()); + } +} + +// ============================================================================ +// Metadata +// ============================================================================ + +TEST_CASE("Metadata construction", "[data][metadata]") { + R _; + + SECTION("normal construction copies user_name and user_id") { + rtms_metadata md{}; + char name[] = "Bob"; + md.user_name = name; + md.user_id = 42; + Metadata m(md); + CHECK(m.userName() == "Bob"); + CHECK(m.userId() == 42); + } + + SECTION("null user_name becomes empty string") { + rtms_metadata md{}; + md.user_name = nullptr; + md.user_id = 7; + Metadata m(md); + CHECK(m.userName().empty()); + CHECK(m.userId() == 7); + } +} + +// ============================================================================ +// MediaParams +// ============================================================================ + +TEST_CASE("MediaParams — empty by default", "[params][media]") { + R _; + MediaParams mp; + CHECK_FALSE(mp.hasAudioParams()); + CHECK_FALSE(mp.hasVideoParams()); + CHECK_FALSE(mp.hasDeskshareParams()); +} + +TEST_CASE("MediaParams — set/get round-trips", "[params][media]") { + R _; + MediaParams mp; + + AudioParams ap; + ap.setSampleRate(1); // SR_16K + mp.setAudioParams(ap); + REQUIRE(mp.hasAudioParams()); + CHECK(mp.audioParams().sampleRate() == 1); + + VideoParams vp; + vp.setResolution(2); // HD + mp.setVideoParams(vp); + REQUIRE(mp.hasVideoParams()); + CHECK(mp.videoParams().resolution() == 2); +} + +TEST_CASE("MediaParams toNative() — correct pointers", "[params][media]") { + R _; + + SECTION("empty MediaParams produces all-null native struct") { + MediaParams mp; + auto n = mp.toNative(); + CHECK(n.audio_param == nullptr); + CHECK(n.video_param == nullptr); + CHECK(n.ds_param == nullptr); + CHECK(n.tr_param == nullptr); + // No leak — nothing was allocated + } + + SECTION("set audio produces non-null audio_param, others null") { + MediaParams mp; + mp.setAudioParams(AudioParams()); + auto n = mp.toNative(); + REQUIRE(n.audio_param != nullptr); + CHECK(n.video_param == nullptr); + CHECK(n.ds_param == nullptr); + CHECK(n.tr_param == nullptr); + CHECK(n.audio_param->codec == 4); // OPUS default + delete n.audio_param; + } + + SECTION("set video produces non-null video_param") { + MediaParams mp; + VideoParams vp(3, 7, 2, 5, 30); // RAW_VIDEO, H264, HD, GALLERY, 30fps + mp.setVideoParams(vp); + auto n = mp.toNative(); + REQUIRE(n.video_param != nullptr); + CHECK(n.audio_param == nullptr); + CHECK(n.video_param->codec == 7); + delete n.video_param; + } +} + +TEST_CASE("MediaParams assignment copies all params", "[params][media]") { + R _; + MediaParams a; + a.setAudioParams(AudioParams()); + VideoParams vp; vp.setResolution(2); + a.setVideoParams(vp); + + MediaParams b; + b = a; + REQUIRE(b.hasAudioParams()); + REQUIRE(b.hasVideoParams()); + CHECK_FALSE(b.hasDeskshareParams()); + CHECK(b.videoParams().resolution() == 2); +} + +// ============================================================================ +// Client lifecycle +// ============================================================================ + +TEST_CASE("Client constructor calls create_sdk", "[client][lifecycle]") { + R _; + { Client c; } + CHECK(g_mock_state.create_calls == 1); + // Destructor should release + CHECK(g_mock_state.release_calls == 1); +} + +TEST_CASE("Client constructor throws when create_sdk returns null", "[client][lifecycle]") { + R _; + g_mock_state.create_result = 1; // make create_sdk return nullptr + REQUIRE_THROWS_AS(Client(), Exception); +} + +TEST_CASE("Client::initialize calls provider->init with correct args", "[client][lifecycle]") { + R _; + Client::initialize("/path/to/ca.pem", 1, nullptr); + CHECK(g_mock_state.init_calls == 1); + CHECK(g_mock_state.last_ca_path == "/path/to/ca.pem"); + CHECK(g_mock_state.last_verify_cert == true); +} + +TEST_CASE("Client::initialize with verify=0 passes false to provider", "[client][lifecycle]") { + R _; + Client::initialize("", 0); + CHECK(g_mock_state.last_verify_cert == false); +} + +TEST_CASE("Client::initialize sets g_agent when provided", "[client][lifecycle]") { + R _; + Client::initialize("", 1, "my-agent/1.0"); + CHECK(g_agent == "my-agent/1.0"); + g_agent.clear(); // cleanup +} + +TEST_CASE("Client::uninitialize calls provider->uninit", "[client][lifecycle]") { + R _; + Client::uninitialize(); + CHECK(g_mock_state.uninit_calls == 1); +} + +TEST_CASE("Client::join calls open() then join() in order", "[client][join]") { + R _; + Client c; + c.join("uuid-1", "stream-1", "sig-1", "wss://server", 5000); + + CHECK(g_mock_state.open_calls == 1); + CHECK(g_mock_state.join_calls == 1); + // open() must be called before join() — verified by sequential counts + CHECK(g_mock_state.last_meeting_uuid == "uuid-1"); + CHECK(g_mock_state.last_stream_id == "stream-1"); + CHECK(g_mock_state.last_signature == "sig-1"); + CHECK(g_mock_state.last_server_url == "wss://server"); + CHECK(g_mock_state.last_timeout == 5000); +} + +TEST_CASE("Client::join passes itself as the sink to open()", "[client][join]") { + R _; + Client c; + c.join("uuid", "stream", "sig", "url"); + CHECK(g_mock_state.last_sink == &c); +} + +TEST_CASE("Client::join throws when open() fails", "[client][join]") { + R _; + g_mock_state.open_result = RTMS_SDK_FAILURE; + Client c; + REQUIRE_THROWS_AS(c.join("uuid", "stream", "sig", "url"), Exception); + CHECK(g_mock_state.join_calls == 0); // join() should not be called after open() fails +} + +TEST_CASE("Client::join throws when join() fails", "[client][join]") { + R _; + g_mock_state.join_result = RTMS_SDK_FAILURE; + Client c; + REQUIRE_THROWS_AS(c.join("uuid", "stream", "sig", "url"), Exception); +} + +TEST_CASE("Client::poll calls sdk->poll()", "[client][poll]") { + R _; + Client c; + c.poll(); + CHECK(g_mock_state.poll_calls == 1); +} + +TEST_CASE("Client::poll throws on SDK error", "[client][poll]") { + R _; + g_mock_state.poll_result = RTMS_SDK_TIMEOUT; + Client c; + REQUIRE_THROWS_AS(c.poll(), Exception); +} + +TEST_CASE("Client::release calls leave() then release_sdk()", "[client][release]") { + R _; + { + Client c; + c.release(); + CHECK(g_mock_state.leave_calls == 1); + CHECK(g_mock_state.release_calls == 1); + } + // Destructor should NOT double-release (sdk_ is nullptr after release()) + CHECK(g_mock_state.release_calls == 1); +} + +TEST_CASE("Client::uuid and streamId return joined values", "[client][join]") { + R _; + Client c; + c.join("my-uuid", "my-stream", "sig", "url"); + CHECK(c.uuid() == "my-uuid"); + CHECK(c.streamId() == "my-stream"); +} + +// ============================================================================ +// Callback dispatch +// ============================================================================ + +TEST_CASE("on_join_confirm fires registered callback", "[client][callbacks]") { + R _; + Client c; + c.join("u", "s", "sig", "url"); + + int received_reason = -99; + c.setOnJoinConfirm([&](int r) { received_reason = r; }); + mock_trigger_join_confirm(0); + + CHECK(received_reason == 0); +} + +TEST_CASE("on_leave fires registered callback with reason", "[client][callbacks]") { + R _; + Client c; + c.join("u", "s", "sig", "url"); + + int received = -1; + c.setOnLeave([&](int r) { received = r; }); + mock_trigger_leave(3); + + CHECK(received == 3); +} + +TEST_CASE("on_event_ex fires registered callback with string", "[client][callbacks]") { + R _; + Client c; + c.join("u", "s", "sig", "url"); + + std::string received; + c.setOnEventEx([&](const std::string& s) { received = s; }); + mock_trigger_event_ex(R"({"event":"test"})"); + + CHECK(received == R"({"event":"test"})"); +} + +TEST_CASE("on_event_ex does not fire callback on empty string", "[client][callbacks]") { + R _; + Client c; + c.join("u", "s", "sig", "url"); + + bool called = false; + c.setOnEventEx([&](const std::string&) { called = true; }); + mock_trigger_event_ex(""); + + CHECK_FALSE(called); +} + +TEST_CASE("on_audio_data fires callback with correct data", "[client][callbacks]") { + R _; + Client c; + c.join("u", "s", "sig", "url"); + + std::vector received_data; + uint64_t received_ts = 0; + int received_uid = -1; + c.setOnAudioData([&](const std::vector& d, uint64_t ts, const Metadata& md) { + received_data = d; + received_ts = ts; + received_uid = md.userId(); + }); + + unsigned char buf[] = {0x01, 0x02, 0x03}; + rtms_metadata md{}; md.user_id = 42; md.user_name = nullptr; + mock_trigger_audio_data(buf, 3, 999ULL, &md); + + REQUIRE(received_data.size() == 3); + CHECK(received_data[0] == 0x01); + CHECK(received_data[2] == 0x03); + CHECK(received_ts == 999ULL); + CHECK(received_uid == 42); +} + +TEST_CASE("on_audio_data does not fire callback on null buf", "[client][callbacks]") { + R _; + Client c; + c.join("u", "s", "sig", "url"); + + bool called = false; + c.setOnAudioData([&](const std::vector&, uint64_t, const Metadata&) { called = true; }); + + rtms_metadata md{}; + mock_trigger_audio_data(nullptr, 3, 0, &md); + CHECK_FALSE(called); +} + +TEST_CASE("on_audio_data does not fire callback on zero size", "[client][callbacks]") { + R _; + Client c; + c.join("u", "s", "sig", "url"); + + bool called = false; + c.setOnAudioData([&](const std::vector&, uint64_t, const Metadata&) { called = true; }); + + unsigned char buf[] = {0x01}; + rtms_metadata md{}; + mock_trigger_audio_data(buf, 0, 0, &md); + CHECK_FALSE(called); +} + +TEST_CASE("on_audio_data does not fire callback on null metadata", "[client][callbacks]") { + R _; + Client c; + c.join("u", "s", "sig", "url"); + + bool called = false; + c.setOnAudioData([&](const std::vector&, uint64_t, const Metadata&) { called = true; }); + + unsigned char buf[] = {0x01}; + mock_trigger_audio_data(buf, 1, 0, nullptr); + CHECK_FALSE(called); +} + +TEST_CASE("on_session_update fires with correct Session object", "[client][callbacks]") { + R _; + Client c; + c.join("u", "s", "sig", "url"); + + int received_op = -1; + std::string received_sid; + c.setOnSessionUpdate([&](int op, const Session& sess) { + received_op = op; + received_sid = sess.sessionId(); + }); + + session_info info{}; + char sid[] = "my-session"; + info.session_id = sid; + info.stream_id = nullptr; + info.status = SESS_STATUS_ACTIVE; + mock_trigger_session_update(SESSION_ADD, &info); + + CHECK(received_op == SESSION_ADD); + CHECK(received_sid == "my-session"); +} + +TEST_CASE("on_user_update fires with correct Participant object", "[client][callbacks]") { + R _; + Client c; + c.join("u", "s", "sig", "url"); + + int received_op = -1; + int received_id = -1; + c.setOnUserUpdate([&](int op, const Participant& p) { + received_op = op; + received_id = p.id(); + }); + + participant_info pi; + pi.participant_id = 77; + pi.participant_name = nullptr; + mock_trigger_user_update(USER_JOIN, &pi); + + CHECK(received_op == USER_JOIN); + CHECK(received_id == 77); +} + +// ============================================================================ +// Event subscription +// ============================================================================ + +TEST_CASE("subscribeEvent before join queues events for later", "[client][events]") { + R _; + Client c; + // Not joined yet — subscribe should NOT call sdk immediately + c.subscribeEvent({1, 2, 3}); + CHECK(g_mock_state.subscribe_calls == 0); +} + +TEST_CASE("pending subscriptions flushed on join confirm", "[client][events]") { + R _; + Client c; + c.subscribeEvent({4, 5}); + CHECK(g_mock_state.subscribe_calls == 0); + + c.join("u", "s", "sig", "url"); + mock_trigger_join_confirm(0); + + CHECK(g_mock_state.subscribe_calls == 1); + // The pending events should have been sent + REQUIRE(g_mock_state.last_subscribed_events.size() == 2); + CHECK(g_mock_state.last_subscribed_events[0] == 4); + CHECK(g_mock_state.last_subscribed_events[1] == 5); +} + +TEST_CASE("subscribeEvent after join confirm fires sdk immediately", "[client][events]") { + R _; + Client c; + c.join("u", "s", "sig", "url"); + mock_trigger_join_confirm(0); + g_mock_state.subscribe_calls = 0; // reset counter (was called for pending) + + c.subscribeEvent({7}); + CHECK(g_mock_state.subscribe_calls == 1); + CHECK(g_mock_state.last_subscribed_events[0] == 7); +} + +TEST_CASE("setOnUserUpdate auto-subscribes to participant events", "[client][events]") { + R _; + Client c; + c.setOnUserUpdate([](int, const Participant&) {}); + // Before join: should be queued (not sent to sdk yet) + CHECK(g_mock_state.subscribe_calls == 0); + + c.join("u", "s", "sig", "url"); + mock_trigger_join_confirm(0); + + // After join confirm, pending subscriptions should include participant events + REQUIRE(g_mock_state.subscribe_calls >= 1); + auto& evts = g_mock_state.last_subscribed_events; + bool has_join = std::find(evts.begin(), evts.end(), (int)EVENT_TYPE::PARTICIPANT_JOIN) != evts.end(); + bool has_leave = std::find(evts.begin(), evts.end(), (int)EVENT_TYPE::PARTICIPANT_LEAVE) != evts.end(); + CHECK(has_join); + CHECK(has_leave); +} + +// ============================================================================ +// Media type auto-enable +// ============================================================================ + +TEST_CASE("setOnAudioData enables AUDIO media type via config on join", "[client][media]") { + R _; + Client c; + c.setOnAudioData([](const std::vector&, uint64_t, const Metadata&) {}); + // Config is deferred until join() calls open() — no sdk_->config() before open() + CHECK(g_mock_state.config_calls == 0); + c.join("u", "s", "sig", "url"); + REQUIRE(g_mock_state.config_calls >= 1); + CHECK(g_mock_state.last_media_types & Client::AUDIO); +} + +TEST_CASE("setOnVideoData enables VIDEO media type via config on join", "[client][media]") { + R _; + Client c; + c.setOnVideoData([](const std::vector&, uint64_t, const Metadata&) {}); + // Config is deferred until join() — calling sdk_->config() before open() + // hangs for VIDEO in the real C++ SDK + CHECK(g_mock_state.config_calls == 0); + c.join("u", "s", "sig", "url"); + REQUIRE(g_mock_state.config_calls >= 1); + CHECK(g_mock_state.last_media_types & Client::VIDEO); +} + +TEST_CASE("setOnTranscriptData enables TRANSCRIPT media type via config on join", "[client][media]") { + R _; + Client c; + c.setOnTranscriptData([](const std::vector&, uint64_t, const Metadata&) {}); + // Config is deferred until join() + CHECK(g_mock_state.config_calls == 0); + c.join("u", "s", "sig", "url"); + REQUIRE(g_mock_state.config_calls >= 1); + CHECK(g_mock_state.last_media_types & Client::TRANSCRIPT); +} + +// ============================================================================ +// TranscriptParams +// ============================================================================ + +TEST_CASE("TranscriptParams default constructor", "[params][transcript]") { + R _; + TranscriptParams p; + CHECK(p.contentType() == 5); // TEXT + CHECK(p.srcLanguage() == -1); // LANGUAGE_ID_NONE — auto-detect + CHECK(p.enableLid() == true); +} + +TEST_CASE("TranscriptParams setters round-trip", "[params][transcript]") { + R _; + TranscriptParams p; + p.setSrcLanguage(9); // LANGUAGE_ID_ENGLISH + p.setEnableLid(false); + CHECK(p.srcLanguage() == 9); + CHECK(p.enableLid() == false); +} + +TEST_CASE("TranscriptParams toNative() maps fields correctly", "[params][transcript]") { + R _; + TranscriptParams p; + p.setSrcLanguage(9); + p.setEnableLid(false); + auto n = p.toNative(); + CHECK(n.content_type == 5); + CHECK(n.src_language == 9); + CHECK(n.enable_lid == false); +} + +TEST_CASE("MediaParams setTranscriptParams / hasTranscriptParams", "[params][media][transcript]") { + R _; + MediaParams mp; + CHECK_FALSE(mp.hasTranscriptParams()); + + TranscriptParams tp; + mp.setTranscriptParams(tp); + REQUIRE(mp.hasTranscriptParams()); + CHECK(mp.transcriptParams().contentType() == 5); +} + +TEST_CASE("MediaParams toNative() produces non-null tr_param when set", "[params][media][transcript]") { + R _; + MediaParams mp; + mp.setTranscriptParams(TranscriptParams()); + auto n = mp.toNative(); + REQUIRE(n.tr_param != nullptr); + CHECK(n.tr_param->content_type == 5); + CHECK(n.tr_param->enable_lid == true); + delete n.tr_param; +} + +TEST_CASE("Client::setTranscriptParams calls config with updated params", "[client][transcript]") { + R _; + Client c; + // Enable transcript first so the reconfigure path runs + c.setOnTranscriptData([](const std::vector&, uint64_t, const Metadata&) {}); + // Must join first — configure() is deferred until after open() sets sdk_opened_=true + c.join("u", "s", "sig", "url"); + int calls_before = g_mock_state.config_calls; + + TranscriptParams tp; + tp.setSrcLanguage(14); // LANGUAGE_ID_GERMAN + c.setTranscriptParams(tp); + + // Should have called config() again with the updated params + CHECK(g_mock_state.config_calls > calls_before); +} + +// ============================================================================ +// Client::setProxy +// ============================================================================ + +TEST_CASE("Client::setProxy forwards proxy_type and proxy_url to SDK", "[client][proxy]") { + R _; + Client c; + c.setProxy("http", "http://proxy.example.com:8080"); + CHECK(g_mock_state.proxy_calls == 1); + CHECK(g_mock_state.last_proxy_type == "http"); + CHECK(g_mock_state.last_proxy_url == "http://proxy.example.com:8080"); +} + +TEST_CASE("Client::setProxy works for https proxy", "[client][proxy]") { + R _; + Client c; + c.setProxy("https", "https://proxy.example.com:8080"); + CHECK(g_mock_state.proxy_calls == 1); + CHECK(g_mock_state.last_proxy_type == "https"); + CHECK(g_mock_state.last_proxy_url == "https://proxy.example.com:8080"); +} + +TEST_CASE("Client::setProxy throws on SDK failure", "[client][proxy]") { + R _; + g_mock_state.proxy_result = RTMS_SDK_FAILURE; + Client c; + REQUIRE_THROWS_WITH( + c.setProxy("http", "http://proxy.example.com:8080"), + ContainsSubstring("setProxy failed") + ); +} + +// ============================================================================ +// Client::subscribeVideo +// ============================================================================ + +TEST_CASE("Client::subscribeVideo forwards user_id and true to send_subscript_video", "[client][video]") { + R _; + Client c; + c.subscribeVideo(12345, true); + CHECK(g_mock_state.subscript_video_calls == 1); + CHECK(g_mock_state.last_subscript_user_id == 12345); + CHECK(g_mock_state.last_subscript_is_sub == true); +} + +TEST_CASE("Client::subscribeVideo forwards user_id and false to send_subscript_video", "[client][video]") { + R _; + Client c; + c.subscribeVideo(99999, false); + CHECK(g_mock_state.subscript_video_calls == 1); + CHECK(g_mock_state.last_subscript_user_id == 99999); + CHECK(g_mock_state.last_subscript_is_sub == false); +} + +TEST_CASE("Client::subscribeVideo throws on SDK failure", "[client][video]") { + R _; + g_mock_state.subscript_video_result = RTMS_SDK_FAILURE; + Client c; + REQUIRE_THROWS_WITH( + c.subscribeVideo(12345, true), + ContainsSubstring("subscribeVideo failed") + ); +} + +// ============================================================================ +// on_participant_video callback +// ============================================================================ + +TEST_CASE("on_participant_video fires registered callback with users and flag", "[client][video][callbacks]") { + R _; + Client c; + c.join("u", "s", "sig", "url"); + + std::vector received_users; + bool received_is_on = false; + c.setOnParticipantVideo([&](const std::vector& users, bool is_on) { + received_users = users; + received_is_on = is_on; + }); + + mock_trigger_participant_video({11111, 22222}, true); + + REQUIRE(received_users.size() == 2); + CHECK(received_users[0] == 11111); + CHECK(received_users[1] == 22222); + CHECK(received_is_on == true); +} + +TEST_CASE("on_participant_video fires with is_on=false when video turns off", "[client][video][callbacks]") { + R _; + Client c; + c.join("u", "s", "sig", "url"); + + bool received_is_on = true; + c.setOnParticipantVideo([&](const std::vector&, bool is_on) { + received_is_on = is_on; + }); + + mock_trigger_participant_video({11111}, false); + CHECK(received_is_on == false); +} + +TEST_CASE("on_participant_video does not fire when no callback is registered", "[client][video][callbacks]") { + R _; + Client c; + c.join("u", "s", "sig", "url"); + // No callback registered — should not crash + REQUIRE_NOTHROW(mock_trigger_participant_video({11111}, true)); +} + +// ============================================================================ +// on_video_subscript_resp callback +// ============================================================================ + +TEST_CASE("on_video_subscript_resp fires registered callback with user_id, status, error", "[client][video][callbacks]") { + R _; + Client c; + c.join("u", "s", "sig", "url"); + + int received_user_id = -1; + int received_status = -1; + std::string received_error; + c.setOnVideoSubscribed([&](int user_id, int status, const std::string& error) { + received_user_id = user_id; + received_status = status; + received_error = error; + }); + + mock_trigger_video_subscript_resp(12345, 0, ""); + + CHECK(received_user_id == 12345); + CHECK(received_status == 0); + CHECK(received_error.empty()); +} + +TEST_CASE("on_video_subscript_resp relays error string on failure", "[client][video][callbacks]") { + R _; + Client c; + c.join("u", "s", "sig", "url"); + + int received_status = 0; + std::string received_error; + c.setOnVideoSubscribed([&](int, int status, const std::string& error) { + received_status = status; + received_error = error; + }); + + mock_trigger_video_subscript_resp(12345, RTMS_SDK_FAILURE, "subscription failed"); + + CHECK(received_status == RTMS_SDK_FAILURE); + CHECK(received_error == "subscription failed"); +} + +TEST_CASE("on_video_subscript_resp does not fire when no callback is registered", "[client][video][callbacks]") { + R _; + Client c; + c.join("u", "s", "sig", "url"); + REQUIRE_NOTHROW(mock_trigger_video_subscript_resp(12345, 0, "")); +} + +// ============================================================================ +// setOnParticipantVideo auto-subscribe +// ============================================================================ + +TEST_CASE("setOnParticipantVideo auto-subscribes to video on/off events", "[client][events]") { + R _; + Client c; + c.setOnParticipantVideo([](const std::vector&, bool) {}); + // Before join: subscriptions should be queued, not sent to sdk yet + CHECK(g_mock_state.subscribe_calls == 0); + + c.join("u", "s", "sig", "url"); + mock_trigger_join_confirm(0); + + // After join confirm, pending subscriptions must include both video events + REQUIRE(g_mock_state.subscribe_calls >= 1); + auto& evts = g_mock_state.last_subscribed_events; + bool has_on = std::find(evts.begin(), evts.end(), (int)EVENT_TYPE::PARTICIPANT_VIDEO_ON) != evts.end(); + bool has_off = std::find(evts.begin(), evts.end(), (int)EVENT_TYPE::PARTICIPANT_VIDEO_OFF) != evts.end(); + CHECK(has_on); + CHECK(has_off); +} + +// ============================================================================ +// Metadata — startTs / endTs / AiInterpreter +// ============================================================================ + +TEST_CASE("Metadata construction — start_ts and end_ts", "[data][metadata]") { + R _; + + SECTION("start_ts and end_ts are copied correctly") { + rtms_metadata md{}; + md.start_ts = 1000000ULL; + md.end_ts = 2000000ULL; + Metadata m(md); + CHECK(m.startTs() == 1000000ULL); + CHECK(m.endTs() == 2000000ULL); + } + + SECTION("zero timestamps are preserved") { + rtms_metadata md{}; + md.start_ts = 0; + md.end_ts = 0; + Metadata m(md); + CHECK(m.startTs() == 0ULL); + CHECK(m.endTs() == 0ULL); + } +} + +TEST_CASE("AiInterpreter construction — zero targets when target_size is zero", "[data][metadata]") { + R _; + rtms_metadata md{}; + md.aii.lid = 9; // ENGLISH + md.aii.timestamp = 12345ULL; + md.aii.channel_num = 1; + md.aii.sample_rate = 16000; + md.aii.target_size = 0; + + Metadata m(md); + const auto& ai = m.aiInterpreter(); + CHECK(ai.lid() == 9); + CHECK(ai.timestamp() == 12345ULL); + CHECK(ai.channelNum() == 1); + CHECK(ai.sampleRate() == 16000); + CHECK(ai.targets().empty()); +} + +TEST_CASE("AiInterpreter construction — target_size guarded against out-of-bounds values", "[data][metadata]") { + R _; + + SECTION("target_size larger than atl array is clamped") { + rtms_metadata md{}; + md.aii.target_size = 999; // atl has only 100 entries + // Must not read past bounds — just constructing should be safe + Metadata m(md); + CHECK(m.aiInterpreter().targets().size() <= 100); + } + + SECTION("negative target_size produces empty targets") { + rtms_metadata md{}; + md.aii.target_size = -1; + Metadata m(md); + CHECK(m.aiInterpreter().targets().empty()); + } +} + +TEST_CASE("AiInterpreter construction — one target populated correctly", "[data][metadata]") { + R _; + rtms_metadata md{}; + md.aii.lid = 9; + md.aii.timestamp = 100ULL; + md.aii.channel_num = 2; + md.aii.sample_rate = 48000; + md.aii.target_size = 1; + md.aii.atl[0].lid = 14; // GERMAN + md.aii.atl[0].toneid = 0; + strncpy(md.aii.atl[0].voice_id, "voice-de-1", MAX_VOICE_ID_LEN - 1); + strncpy(md.aii.atl[0].engine, "engine-A", MAX_ENGINE_LEN - 1); + + Metadata m(md); + const auto& ai = m.aiInterpreter(); + REQUIRE(ai.targets().size() == 1); + const auto& tgt = ai.targets()[0]; + CHECK(tgt.lid() == 14); + CHECK(tgt.toneId() == 0); + CHECK(tgt.voiceId() == "voice-de-1"); + CHECK(tgt.engine() == "engine-A"); +} diff --git a/tests/py/test_rtms.py b/tests/py/test_rtms.py new file mode 100644 index 0000000..d6419d4 --- /dev/null +++ b/tests/py/test_rtms.py @@ -0,0 +1,1037 @@ +#!/usr/bin/env python3 +""" +Comprehensive test suite for Python RTMS SDK + +Mirrors the Node.js test coverage in tests/rtms.test.ts +""" + +import pytest +import rtms +from unittest.mock import Mock, patch, MagicMock +import json +import asyncio +import inspect + + +@pytest.fixture(autouse=True) +def clean_rtms_state(): + """Clear global rtms state before each test. + + Prevents clients created in earlier tests from leaking into later tests + via the _clients registry, which would cause run_async(stop_on_empty=True) + to never exit and _cleanup_all_clients() to block on unjoined C++ clients. + """ + with rtms._clients_lock: + rtms._clients.clear() + rtms._running = False + rtms._stop_event.clear() + yield + + +class TestConstants: + """Test that all constants are properly defined""" + + def test_media_type_constants(self): + """Test media type constants match expected values""" + assert rtms.MEDIA_TYPE_AUDIO == 1 + assert rtms.MEDIA_TYPE_VIDEO == 2 + assert rtms.MEDIA_TYPE_DESKSHARE == 4 + assert rtms.MEDIA_TYPE_TRANSCRIPT == 8 + assert rtms.MEDIA_TYPE_CHAT == 16 + assert rtms.MEDIA_TYPE_ALL == 32 + + def test_session_event_constants(self): + """Test session event constants""" + assert rtms.SESSION_EVENT_ADD == 1 + assert rtms.SESSION_EVENT_STOP == 2 + assert rtms.SESSION_EVENT_PAUSE == 3 + assert rtms.SESSION_EVENT_RESUME == 4 + + def test_event_type_constants(self): + """Test event type constants (for subscribeEvent/unsubscribeEvent)""" + assert rtms.EVENT_UNDEFINED == 0 + assert rtms.EVENT_FIRST_PACKET_TIMESTAMP == 1 + assert rtms.EVENT_ACTIVE_SPEAKER_CHANGE == 2 + assert rtms.EVENT_PARTICIPANT_JOIN == 3 + assert rtms.EVENT_PARTICIPANT_LEAVE == 4 + assert rtms.EVENT_SHARING_START == 5 + assert rtms.EVENT_SHARING_STOP == 6 + assert rtms.EVENT_MEDIA_CONNECTION_INTERRUPTED == 7 + assert rtms.EVENT_PARTICIPANT_VIDEO_ON == 8 + assert rtms.EVENT_PARTICIPANT_VIDEO_OFF == 9 + assert rtms.EVENT_CONSUMER_ANSWERED == 8 + assert rtms.EVENT_CONSUMER_END == 9 + assert rtms.EVENT_USER_ANSWERED == 10 + assert rtms.EVENT_USER_END == 11 + assert rtms.EVENT_USER_HOLD == 12 + assert rtms.EVENT_USER_UNHOLD == 13 + + def test_status_constants(self): + """Test SDK status constants""" + assert rtms.RTMS_SDK_FAILURE == -1 + assert rtms.RTMS_SDK_OK == 0 + assert rtms.RTMS_SDK_TIMEOUT == 1 + assert rtms.RTMS_SDK_NOT_EXIST == 2 + + def test_audio_codec_constants(self): + """Test audio codec constants exist""" + assert 'OPUS' in rtms.AudioCodec.__members__ + assert 'L16' in rtms.AudioCodec.__members__ + assert 'G711' in rtms.AudioCodec.__members__ + + def test_video_codec_constants(self): + """Test video codec constants exist""" + assert 'H264' in rtms.VideoCodec.__members__ + assert 'JPG' in rtms.VideoCodec.__members__ + + def test_sample_rate_constants(self): + """Test audio sample rate constants""" + assert 'SR_48K' in rtms.AudioSampleRate.__members__ + assert 'SR_16K' in rtms.AudioSampleRate.__members__ + assert 'SR_8K' in rtms.AudioSampleRate.__members__ + + +class TestLogging: + """Test logging functionality""" + + def test_log_levels_enum(self): + """Test LogLevel enum values""" + assert rtms.LogLevel.ERROR == 0 + assert rtms.LogLevel.WARN == 1 + assert rtms.LogLevel.INFO == 2 + assert rtms.LogLevel.DEBUG == 3 + assert rtms.LogLevel.TRACE == 4 + + def test_log_format_enum(self): + """Test LogFormat enum values""" + assert rtms.LogFormat.PROGRESSIVE == 'progressive' + assert rtms.LogFormat.JSON == 'json' + + def test_configure_logger(self): + """Test logger configuration""" + # Should not raise errors + rtms.configure_logger({ + 'level': 'debug', + 'format': 'json', + 'enabled': True + }) + + rtms.configure_logger({ + 'level': 'info', + 'format': 'progressive', + 'enabled': False + }) + + def test_log_functions_exist(self): + """Test that all log functions are available""" + assert callable(rtms.log_debug) + assert callable(rtms.log_info) + assert callable(rtms.log_warn) + assert callable(rtms.log_error) + + +class TestClient: + """Test Client class functionality""" + + def test_client_instantiation(self): + """Test that Client can be instantiated""" + client = rtms.Client() + assert client is not None + assert isinstance(client, rtms.Client) + + def test_client_has_join_method(self): + """Test that Client has join method""" + client = rtms.Client() + assert hasattr(client, 'join') + assert callable(client.join) + + def test_client_has_leave_method(self): + """Test that Client has leave method""" + client = rtms.Client() + assert hasattr(client, 'leave') + assert callable(client.leave) + + def test_client_has_callback_methods(self): + """Test that Client has all callback registration methods""" + client = rtms.Client() + assert hasattr(client, 'onJoinConfirm') + assert hasattr(client, 'onSessionUpdate') + assert hasattr(client, 'onParticipantEvent') + assert hasattr(client, 'onActiveSpeakerEvent') + assert hasattr(client, 'onSharingEvent') + assert hasattr(client, 'onEventEx') + assert hasattr(client, 'onAudioData') + assert hasattr(client, 'onVideoData') + assert hasattr(client, 'onDeskshareData') + assert hasattr(client, 'onTranscriptData') + assert hasattr(client, 'onLeave') + + def test_client_has_param_methods(self): + """Test that Client has parameter configuration methods""" + client = rtms.Client() + assert hasattr(client, 'setAudioParams') + assert hasattr(client, 'setVideoParams') + assert hasattr(client, 'setDeskshareParams') + + def test_client_has_event_subscription_methods(self): + """Test that Client has event subscription methods""" + client = rtms.Client() + assert hasattr(client, 'subscribeEvent') + assert callable(client.subscribeEvent) + assert hasattr(client, 'unsubscribeEvent') + + def test_client_has_individual_video_methods(self): + """Test that Client has individual video stream methods""" + client = rtms.Client() + assert hasattr(client, 'subscribe_video') + assert callable(client.subscribe_video) + assert hasattr(client, 'subscribeVideo') # camelCase alias + assert hasattr(client, 'on_participant_video') + assert callable(client.on_participant_video) + assert hasattr(client, 'onParticipantVideo') + assert hasattr(client, 'on_video_subscribed') + assert callable(client.on_video_subscribed) + assert hasattr(client, 'onVideoSubscribed') + + def test_on_participant_video_accepts_callback(self): + """Test that on_participant_video registers a callback without error""" + client = rtms.Client() + client.on_participant_video(lambda user_ids, is_on: None) + + def test_on_video_subscribed_accepts_callback(self): + """Test that on_video_subscribed registers a callback without error""" + client = rtms.Client() + client.on_video_subscribed(lambda user_id, status, error: None) + assert callable(client.unsubscribeEvent) + + def test_client_callback_registration(self): + """Test callback registration doesn't raise errors""" + client = rtms.Client() + + @client.onJoinConfirm + def on_join(reason): + pass + + @client.onTranscriptData + def on_transcript(data, size, timestamp, metadata): + pass + + # Should complete without errors + assert True + + +class TestParameterValidation: + """Test parameter validation functionality""" + + def test_audio_params_exist(self): + """Test AudioParams class exists""" + assert hasattr(rtms, 'AudioParams') + + def test_video_params_exist(self): + """Test VideoParams class exists""" + assert hasattr(rtms, 'VideoParams') + + def test_deskshare_params_exist(self): + """Test DeskshareParams class exists""" + assert hasattr(rtms, 'DeskshareParams') + + +class TestUtilityFunctions: + """Test utility functions""" + + def test_generate_signature_exists(self): + """Test signature generation function exists""" + assert hasattr(rtms, 'generate_signature') + assert callable(rtms.generate_signature) + + def test_find_ca_certificate_exists(self): + """Test CA certificate finder exists""" + assert hasattr(rtms, 'find_ca_certificate') + assert callable(rtms.find_ca_certificate) + + def test_initialize_exists(self): + """Test initialize function exists""" + assert hasattr(rtms, 'initialize') + assert callable(rtms.initialize) + + def test_uninitialize_exists(self): + """Test uninitialize function exists""" + assert hasattr(rtms, 'uninitialize') + assert callable(rtms.uninitialize) + + +class TestWebhookFunctionality: + """Test webhook-related functionality""" + + def test_on_webhook_event_exists(self): + """Test webhook event decorator exists""" + assert hasattr(rtms, 'onWebhookEvent') + assert callable(rtms.onWebhookEvent) + # Also test the alias + assert hasattr(rtms, 'on_webhook_event') + assert callable(rtms.on_webhook_event) + + def test_webhook_response_class_exists(self): + """Test WebhookResponse class exists""" + # WebhookResponse is internal but should be importable + from rtms import WebhookResponse + assert WebhookResponse is not None + + def test_client_webhook_decorator(self): + """Test client webhook event decorator""" + client = rtms.Client() + assert hasattr(client, 'on_webhook_event') + assert callable(client.on_webhook_event) + + # Test decorator usage + @client.on_webhook_event() + def handle_webhook(payload): + pass + + assert True + + +class TestDataClasses: + """Test data classes and structures""" + + def test_session_class_exists(self): + """Test Session class exists""" + assert hasattr(rtms, 'Session') + + def test_participant_class_exists(self): + """Test Participant class exists""" + assert hasattr(rtms, 'Participant') + + def test_metadata_class_exists(self): + """Test Metadata class exists""" + assert hasattr(rtms, 'Metadata') + + +class TestModuleExports: + """Test that all expected exports are available""" + + def test_client_export(self): + """Test Client is exported""" + assert 'Client' in rtms.__all__ + + def test_constant_exports(self): + """Test constants are exported""" + exports_to_check = [ + 'MEDIA_TYPE_AUDIO', + 'MEDIA_TYPE_VIDEO', + 'MEDIA_TYPE_TRANSCRIPT', + 'SESSION_EVENT_ADD', + 'EVENT_PARTICIPANT_JOIN', + 'EVENT_PARTICIPANT_LEAVE', + 'EVENT_ACTIVE_SPEAKER_CHANGE', + 'EVENT_SHARING_START', + 'EVENT_SHARING_STOP', + 'RTMS_SDK_OK' + ] + for export in exports_to_check: + assert export in rtms.__all__ + + def test_function_exports(self): + """Test functions are exported""" + functions = [ + 'initialize', + 'uninitialize', + 'generate_signature', + 'configure_logger' + ] + for func in functions: + assert func in rtms.__all__ + + def test_enum_exports(self): + """Test enums are exported""" + enums = [ + 'AudioCodec', + 'VideoCodec', + 'AudioSampleRate', + 'VideoResolution', + 'LogLevel', + 'LogFormat' + ] + for enum in enums: + assert enum in rtms.__all__ + + +class TestThreadSafety: + """Test thread-safe features""" + + def test_module_has_run_function(self): + """Test module has run() function for event loop""" + assert hasattr(rtms, 'run') + assert callable(rtms.run) + + def test_module_has_stop_function(self): + """Test module has stop() function to signal shutdown""" + assert hasattr(rtms, 'stop') + assert callable(rtms.stop) + + def test_client_has_polling_control(self): + """Test client has EventLoop integration attributes""" + client = rtms.Client() + assert hasattr(client, '_do_alloc_and_join') + assert hasattr(client, '_pending_join_params') + + +class TestTranscriptParams: + """Test TranscriptParams class and setTranscriptParams/set_transcript_params. + + These tests fail before implementation because TranscriptParams does not exist + and Client has no set_transcript_params() method. + """ + + def test_transcript_params_class_exists(self): + """rtms.TranscriptParams should be importable.""" + assert hasattr(rtms, 'TranscriptParams') + + def test_transcript_params_default_content_type(self): + """Default content_type should be 5 (TEXT).""" + p = rtms.TranscriptParams() + assert p.content_type == 5 + + def test_transcript_params_default_src_language(self): + """Default src_language should be -1 (LANGUAGE_ID_NONE, auto-detect).""" + p = rtms.TranscriptParams() + assert p.src_language == -1 + + def test_transcript_params_default_enable_lid(self): + """Default enable_lid should be True.""" + p = rtms.TranscriptParams() + assert p.enable_lid is True + + def test_transcript_params_setters(self): + """src_language and enable_lid setters should round-trip.""" + p = rtms.TranscriptParams() + p.src_language = 9 # LANGUAGE_ID_ENGLISH + p.enable_lid = False + assert p.src_language == 9 + assert p.enable_lid is False + + def test_transcript_language_dict_exists(self): + """TranscriptLanguage constant dict should exist.""" + assert hasattr(rtms, 'TranscriptLanguage') + + def test_transcript_language_english(self): + """TranscriptLanguage['ENGLISH'] should be 9.""" + assert rtms.TranscriptLanguage['ENGLISH'] == 9 + + def test_transcript_language_none(self): + """TranscriptLanguage['NONE'] should be -1 (auto-detect).""" + assert rtms.TranscriptLanguage['NONE'] == -1 + + def test_client_has_set_transcript_params(self): + """Client should expose set_transcript_params() method.""" + client = rtms.Client() + assert hasattr(client, 'set_transcript_params') + assert callable(client.set_transcript_params) + + def test_set_transcript_params_does_not_raise(self): + """Calling set_transcript_params with a valid TranscriptParams should not raise.""" + client = rtms.Client() + p = rtms.TranscriptParams() + p.src_language = rtms.TranscriptLanguage['ENGLISH'] + client.set_transcript_params(p) + + def test_transcript_params_exported_in_all(self): + """TranscriptParams and TranscriptLanguage dict should appear in rtms.__all__.""" + assert 'TranscriptParams' in rtms.__all__ + assert 'TranscriptLanguage' in rtms.__all__ + + +class TestZccEngagementId: + """Test ZCC engagement_id support in join() + + ZCC (Zoom Contact Center) identifies sessions with an engagement_id instead + of a meeting_uuid. join() must accept engagement_id and route it as the + instance identifier to the native SDK. + + These tests fail before implementation because _do_join() raises: + ValueError("Either meeting_uuid, webinar_uuid, or session_id is required") + when only engagement_id is supplied. + """ + + def _make_client_with_mocked_native(self): + """Return a Client instance with native join() and polling mocked out.""" + client = rtms.Client() + # Patch the native C++ join on the base class so no real SDK call is made + NativeClient = rtms.Client.__bases__[0] + return client, NativeClient + + def test_join_with_engagement_id_returns_true(self): + """join() called with engagement_id should return True, not False.""" + client, NativeClient = self._make_client_with_mocked_native() + # join() stores params and returns True immediately (defers to EventLoop) + with patch.object(client, '_do_alloc_and_join'): + result = client.join( + engagement_id='engagement-abc-123', + rtms_stream_id='stream-xyz', + server_urls='wss://rtms.zoom.us', + signature='mock-sig', + ) + # Before implementation: False (ValueError caught, swallowed, returns False) + # After implementation: True + assert result is True + + def test_do_join_with_engagement_id_does_not_raise(self): + """_do_alloc_and_join() with engagement_id should not raise a missing-identifier error.""" + client, NativeClient = self._make_client_with_mocked_native() + client._pending_join_params = { + 'engagement_id': 'engagement-abc-123', + 'rtms_stream_id': 'stream-xyz', + 'server_urls': 'wss://rtms.zoom.us', + 'signature': 'mock-sig', + 'timeout': -1, + } + with patch.object(NativeClient, 'join', return_value=None), \ + patch.object(NativeClient, 'alloc', return_value=None): + # Before implementation: raises ValueError + # After implementation: should complete cleanly + client._do_alloc_and_join() + + def test_engagement_id_passed_as_first_arg_to_native_join(self): + """engagement_id should be forwarded as the meeting_uuid positional arg.""" + client, NativeClient = self._make_client_with_mocked_native() + client._pending_join_params = { + 'engagement_id': 'eng-abc-123', + 'rtms_stream_id': 'stream-xyz', + 'server_urls': 'wss://rtms.zoom.us', + 'signature': 'mock-sig', + 'timeout': -1, + } + with patch.object(NativeClient, 'join', return_value=None) as mock_native_join, \ + patch.object(NativeClient, 'alloc', return_value=None): + client._do_alloc_and_join() + mock_native_join.assert_called_once_with( + 'eng-abc-123', 'stream-xyz', 'mock-sig', 'wss://rtms.zoom.us', -1 + ) + + def test_engagement_id_lower_priority_than_meeting_uuid(self): + """When both meeting_uuid and engagement_id are supplied, meeting_uuid wins.""" + client, NativeClient = self._make_client_with_mocked_native() + client._pending_join_params = { + 'meeting_uuid': 'meeting-uuid-wins', + 'engagement_id': 'engagement-id-loses', + 'rtms_stream_id': 'stream-xyz', + 'server_urls': 'wss://rtms.zoom.us', + 'signature': 'mock-sig', + 'timeout': -1, + } + with patch.object(NativeClient, 'join', return_value=None) as mock_native_join, \ + patch.object(NativeClient, 'alloc', return_value=None): + client._do_alloc_and_join() + first_arg = mock_native_join.call_args[0][0] + assert first_arg == 'meeting-uuid-wins' + + +class TestProxySupport: + """Test set_proxy() support. + + Mirrors JS wrapper test coverage for set_proxy: + - exists as a callable + - does not raise for http proxy + - does not raise for https proxy + + Python-specific additions: + - set_proxy legacy camelCase alias + - native forwarding verified via mock + """ + + def test_set_proxy_is_callable(self): + """set_proxy should be callable on a Client instance.""" + client = rtms.Client() + assert callable(client.set_proxy) + + def test_set_proxy_legacy_alias_is_callable(self): + """set_proxy legacy alias should exist for camelCase parity with Node.js.""" + client = rtms.Client() + assert callable(client.setProxy) + + def test_set_proxy_does_not_raise_for_http(self): + """set_proxy should not raise for an http proxy.""" + client = rtms.Client() + NativeClient = rtms.Client.__bases__[0] + with patch.object(NativeClient, 'set_proxy', return_value=None): + client.set_proxy('http', 'http://proxy.example.com:8080') + + def test_set_proxy_does_not_raise_for_https(self): + """set_proxy should not raise for an https proxy.""" + client = rtms.Client() + NativeClient = rtms.Client.__bases__[0] + with patch.object(NativeClient, 'set_proxy', return_value=None): + client.set_proxy('https', 'https://proxy.example.com:8080') + + def test_set_proxy_exported_in_all(self): + """set_proxy does not need to be in __all__ but the Client method must be accessible.""" + client = rtms.Client() + assert callable(getattr(client, 'set_proxy', None)) + + def test_set_proxy_forwards_to_native(self): + """set_proxy should forward proxy_type and proxy_url to the native layer.""" + client = rtms.Client() + NativeClient = rtms.Client.__bases__[0] + with patch.object(NativeClient, 'set_proxy', return_value=None) as mock_native: + client.set_proxy('http', 'http://proxy.example.com:8080') + mock_native.assert_called_once_with('http', 'http://proxy.example.com:8080') + + +class TestIndividualVideoSubscription: + """Test subscribe_video() and on_participant_video / on_video_subscribed callbacks. + + These tests fail before implementation because: + - Client has no subscribe_video() / subscribeVideo() method + - Client has no on_participant_video() / on_video_subscribed() callback registration + + Per spec (branch 5): + - subscribe_video(user_id, True) → subscribe to individual participant stream + - subscribe_video(user_id, False) → unsubscribe + - on_participant_video(callback) → fires when participant video state changes + callback signature: (users: List[int], is_on: bool) + - on_video_subscribed(callback) → fires in response to subscribe_video() + callback signature: (user_id: int, status: int, error: str) + """ + + def _native(self): + return rtms.Client.__bases__[0] + + # ------------------------------------------------------------------ + # subscribe_video method + + def test_subscribe_video_is_callable(self): + """subscribe_video should be callable on a Client instance.""" + client = rtms.Client() + assert callable(getattr(client, 'subscribe_video', None)) + + def test_subscribe_video_camelcase_alias_is_callable(self): + """subscribeVideo camelCase alias must exist for parity with Node.js.""" + client = rtms.Client() + assert callable(getattr(client, 'subscribeVideo', None)) + + def test_subscribe_video_does_not_raise_for_subscribe(self): + """subscribe_video(user_id, True) should not raise.""" + client = rtms.Client() + with patch.object(self._native(), 'subscribe_video', return_value=None): + client.subscribe_video(12345, True) + + def test_subscribe_video_does_not_raise_for_unsubscribe(self): + """subscribe_video(user_id, False) should not raise.""" + client = rtms.Client() + with patch.object(self._native(), 'subscribe_video', return_value=None): + client.subscribe_video(12345, False) + + def test_subscribe_video_forwards_to_native(self): + """subscribe_video should forward user_id and subscribe flag to native layer.""" + client = rtms.Client() + with patch.object(self._native(), 'subscribe_video', return_value=None) as mock_native: + client.subscribe_video(99999, True) + mock_native.assert_called_once_with(99999, True) + + def test_subscribe_video_unsubscribe_forwards_to_native(self): + """subscribe_video(user_id, False) should forward False to native layer.""" + client = rtms.Client() + with patch.object(self._native(), 'subscribe_video', return_value=None) as mock_native: + client.subscribe_video(99999, False) + mock_native.assert_called_once_with(99999, False) + + # ------------------------------------------------------------------ + # on_participant_video callback + + def test_on_participant_video_is_callable(self): + """on_participant_video should be callable on a Client instance.""" + client = rtms.Client() + assert callable(getattr(client, 'on_participant_video', None)) + + def test_on_participant_video_camelcase_alias_is_callable(self): + """onParticipantVideo camelCase alias must exist for Node.js parity.""" + client = rtms.Client() + assert callable(getattr(client, 'onParticipantVideo', None)) + + def test_on_participant_video_does_not_raise(self): + """Registering an on_participant_video callback should not raise.""" + client = rtms.Client() + client.on_participant_video(lambda *_: None) + + def test_on_participant_video_callback_receives_users_and_flag(self): + """on_participant_video callback must receive a list of user IDs and a bool.""" + client = rtms.Client() + received = {} + + def cb(users, is_on): + received['users'] = users + received['is_on'] = is_on + + client.on_participant_video(cb) + # Simulate the callback being fired by the native layer + client._participant_video_callback([11111, 22222], True) + assert received['users'] == [11111, 22222] + assert received['is_on'] is True + + # ------------------------------------------------------------------ + # on_video_subscribed callback + + def test_on_video_subscribed_is_callable(self): + """on_video_subscribed should be callable on a Client instance.""" + client = rtms.Client() + assert callable(getattr(client, 'on_video_subscribed', None)) + + def test_on_video_subscribed_camelcase_alias_is_callable(self): + """onVideoSubscribed camelCase alias must exist for Node.js parity.""" + client = rtms.Client() + assert callable(getattr(client, 'onVideoSubscribed', None)) + + def test_on_video_subscribed_does_not_raise(self): + """Registering an on_video_subscribed callback should not raise.""" + client = rtms.Client() + client.on_video_subscribed(lambda *_: None) + + def test_on_video_subscribed_callback_receives_userid_status_error(self): + """on_video_subscribed callback must receive user_id (int), status (int), error (str).""" + client = rtms.Client() + received = {} + + def cb(user_id, status, error): + received['user_id'] = user_id + received['status'] = status + received['error'] = error + + client.on_video_subscribed(cb) + # Simulate the callback being fired by the native layer + client._video_subscribed_callback(12345, 0, '') + assert received['user_id'] == 12345 + assert received['status'] == 0 + assert received['error'] == '' + + def test_on_video_subscribed_callback_reports_error_string(self): + """on_video_subscribed callback should relay a non-empty error string.""" + client = rtms.Client() + received = {} + + def cb(user_id, status, error): + received['user_id'] = user_id + received['status'] = status + received['error'] = error + + client.on_video_subscribed(cb) + client._video_subscribed_callback(12345, -1, 'subscription failed') + assert received['status'] == -1 + assert received['error'] == 'subscription failed' + + +class TestGilRelease: + """Tests that poll() releases the GIL, allowing other Python threads to run concurrently.""" + + def test_poll_exists_on_client(self): + client = rtms.Client() + assert hasattr(client, 'poll') + + def test_poll_callable(self): + client = rtms.Client() + # Should not raise (SDK errors are OK; what matters is it doesn't hang) + try: + client.poll() + except Exception: + pass + + def test_poll_does_not_block_other_threads(self): + """Smoke test: poll() on an unjoined client returns without hanging. + + NOTE: On an unjoined client with no active meeting, poll() returns in + microseconds — too fast to observe true GIL-release concurrency. This + test verifies poll() is callable and doesn't hang; it does NOT prove + GIL release in a loaded scenario. + + True GIL-release verification requires either a live meeting (where + poll() blocks waiting for SDK events) or a mock SDK with a sleep(). + The structural guarantee lives in src/python.cpp: py::gil_scoped_release. + """ + import threading + results = [] + + def worker(): + results.append(threading.get_ident()) + + client = rtms.Client() + t = threading.Thread(target=worker) + t.start() + try: + client.poll() + except Exception: + pass + t.join(timeout=2.0) + assert len(results) == 1, "Worker thread should have completed" + + +class TestRunAsync: + """Tests the new run_async() asyncio-native event loop.""" + + def test_run_async_exists(self): + assert hasattr(rtms, 'run_async') + + def test_run_async_is_coroutine_function(self): + assert inspect.iscoroutinefunction(rtms.run_async) + + def test_run_async_accepts_poll_interval(self): + sig = inspect.signature(rtms.run_async) + assert 'poll_interval' in sig.parameters + + def test_run_async_accepts_stop_on_empty(self): + sig = inspect.signature(rtms.run_async) + assert 'stop_on_empty' in sig.parameters + + def test_run_async_stops_when_stop_called(self): + async def run_and_stop(): + loop = asyncio.get_event_loop() + loop.call_later(0.05, rtms.stop) + await rtms.run_async(poll_interval=0.01) + + asyncio.run(run_and_stop()) # Should complete, not hang + + def test_run_async_stop_on_empty_exits(self): + async def run_empty(): + await rtms.run_async(stop_on_empty=True, poll_interval=0.01) + + asyncio.run(run_empty()) # No clients registered → exits immediately + + def test_run_async_composes_with_gather(self): + """run_async() should work inside asyncio.gather without blocking.""" + results = [] + + async def side_task(): + results.append('side_task_ran') + + async def test(): + await asyncio.gather( + rtms.run_async(stop_on_empty=True, poll_interval=0.01), + side_task(), + ) + + asyncio.run(test()) + assert 'side_task_ran' in results + + +class TestExecutorSupport: + """Tests executor-based callback dispatch.""" + + def test_client_accepts_executor_kwarg(self): + from concurrent.futures import ThreadPoolExecutor + executor = ThreadPoolExecutor(max_workers=2) + client = rtms.Client(executor=executor) + assert client is not None + executor.shutdown(wait=False) + + def test_client_stores_executor(self): + from concurrent.futures import ThreadPoolExecutor + executor = ThreadPoolExecutor(max_workers=2) + client = rtms.Client(executor=executor) + assert client._executor is executor + executor.shutdown(wait=False) + + def test_client_no_executor_default(self): + client = rtms.Client() + assert client._executor is None + + def test_run_accepts_executor_kwarg(self): + sig = inspect.signature(rtms.run) + assert 'executor' in sig.parameters + + def test_run_executor_default_is_none(self): + sig = inspect.signature(rtms.run) + assert sig.parameters['executor'].default is None + + def test_wrap_callback_exists(self): + client = rtms.Client() + assert hasattr(client, '_wrap_callback') + + def test_sync_callback_no_executor_passthrough(self): + """Sync callback with no executor should be returned unchanged.""" + client = rtms.Client() + + def sync_cb(*_): + pass + + wrapped = client._wrap_callback(sync_cb) + assert wrapped is sync_cb, "No executor → callback passed through unchanged" + + def test_executor_wraps_sync_callback(self): + """When executor is set, sync callbacks are wrapped for thread pool dispatch.""" + from concurrent.futures import ThreadPoolExecutor + executor = ThreadPoolExecutor(max_workers=1) + client = rtms.Client(executor=executor) + + def audio_cb(*_): + pass + + wrapped = client._wrap_callback(audio_cb) + assert wrapped is not audio_cb, "Executor wrapping should produce a different callable" + executor.shutdown(wait=False) + + def test_async_callback_detected_and_wrapped(self): + """Async coroutine callbacks should be auto-wrapped for event loop dispatch.""" + client = rtms.Client() + + async def async_cb(*_): + pass + + wrapped = client._wrap_callback(async_cb) + assert wrapped is not async_cb, "Async callback should be wrapped" + + def test_executor_applied_to_video_data_callback(self): + from concurrent.futures import ThreadPoolExecutor + executor = ThreadPoolExecutor(max_workers=1) + client = rtms.Client(executor=executor) + + def video_cb(*_): + pass + + wrapped = client._wrap_callback(video_cb) + assert wrapped is not video_cb + executor.shutdown(wait=False) + + +class TestContextManager: + """Tests `with rtms.Client() as client:` protocol.""" + + def test_client_has_enter(self): + assert hasattr(rtms.Client, '__enter__') + + def test_client_has_exit(self): + assert hasattr(rtms.Client, '__exit__') + + def test_enter_returns_client(self): + client = rtms.Client() + result = client.__enter__() + assert result is client + + def test_exit_calls_leave(self): + client = rtms.Client() + with patch.object(client, 'leave') as mock_leave: + client.__exit__(None, None, None) + mock_leave.assert_called_once() + + def test_with_statement_basic(self): + """with rtms.Client() as client: should work without error.""" + with patch.object(rtms.Client, 'leave'): + with rtms.Client() as client: + assert client is not None + + def test_exit_called_on_exception(self): + """leave() should be called even when an exception is raised inside with block.""" + leave_called = [] + + class TrackingClient(rtms.Client): + def leave(self): + leave_called.append(True) + return True + + client = TrackingClient() + try: + with client: + raise ValueError("test exception") + except ValueError: + pass + + assert len(leave_called) == 1, "leave() should be called on exception" + + def test_exit_does_not_suppress_exceptions(self): + """__exit__ should return False/None so exceptions propagate.""" + client = rtms.Client() + with patch.object(client, 'leave'): + result = client.__exit__(ValueError, ValueError("test"), None) + assert not result # False or None — do not suppress + + def test_context_manager_integration(self): + """Full with-statement: enter returns client, leave called on exit.""" + leave_called = [] + + with patch.object(rtms.Client, 'leave', side_effect=lambda: leave_called.append(True) or True): + with rtms.Client() as client: + assert client is not None + + assert len(leave_called) == 1 + + +class TestSnakeCaseCallbacks: + """snake_case callback methods are canonical; camelCase are backward-compat aliases.""" + + def test_on_audio_data_canonical(self): + client = rtms.Client() + client.on_audio_data(lambda *_: None) + + def test_on_video_data_canonical(self): + client = rtms.Client() + client.on_video_data(lambda *_: None) + + def test_on_transcript_data_canonical(self): + client = rtms.Client() + client.on_transcript_data(lambda *_: None) + + def test_on_deskshare_data_canonical(self): + client = rtms.Client() + client.on_deskshare_data(lambda *_: None) + + def test_on_join_confirm_canonical(self): + client = rtms.Client() + client.on_join_confirm(lambda _: None) + + def test_on_session_update_canonical(self): + client = rtms.Client() + client.on_session_update(lambda *_: None) + + def test_on_user_update_canonical(self): + client = rtms.Client() + client.on_user_update(lambda *_: None) + + def test_on_leave_canonical(self): + client = rtms.Client() + client.on_leave(lambda _: None) + + def test_on_event_ex_canonical(self): + client = rtms.Client() + client.on_event_ex(lambda _: None) + + def test_on_participant_event_canonical(self): + client = rtms.Client() + with patch.object(client, 'subscribeEvent'): + client.on_participant_event(lambda *_: None) + + def test_on_active_speaker_event_canonical(self): + client = rtms.Client() + with patch.object(client, 'subscribeEvent'): + client.on_active_speaker_event(lambda *_: None) + + def test_on_sharing_event_canonical(self): + client = rtms.Client() + with patch.object(client, 'subscribeEvent'): + client.on_sharing_event(lambda *_: None) + + def test_on_media_connection_interrupted_canonical(self): + client = rtms.Client() + with patch.object(client, 'subscribeEvent'): + client.on_media_connection_interrupted(lambda *_: None) + + def test_onAudioData_alias_works(self): + client = rtms.Client() + client.onAudioData(lambda *_: None) + + def test_onVideoData_alias_works(self): + client = rtms.Client() + client.onVideoData(lambda *_: None) + + def test_onLeave_alias_works(self): + client = rtms.Client() + client.onLeave(lambda _: None) + + def test_camelCase_is_alias_for_snake_case(self): + """camelCase names should be Python-level aliases pointing to the same function as snake_case. + + After fix: on_audio_data is defined in __init__.py and onAudioData = on_audio_data. + At the class level, they should be identical objects. + """ + assert rtms.Client.onAudioData is rtms.Client.on_audio_data + + +# Run tests +if __name__ == '__main__': + pytest.main([__file__, '-v']) diff --git a/tests/test_rtms.py b/tests/test_rtms.py deleted file mode 100644 index a0612c2..0000000 --- a/tests/test_rtms.py +++ /dev/null @@ -1,339 +0,0 @@ -#!/usr/bin/env python3 -""" -Comprehensive test suite for Python RTMS SDK - -Mirrors the Node.js test coverage in tests/rtms.test.ts -""" - -import pytest -import rtms -from unittest.mock import Mock, patch, MagicMock -import json - - -class TestConstants: - """Test that all constants are properly defined""" - - def test_media_type_constants(self): - """Test media type constants match expected values""" - assert rtms.MEDIA_TYPE_AUDIO == 1 - assert rtms.MEDIA_TYPE_VIDEO == 2 - assert rtms.MEDIA_TYPE_DESKSHARE == 4 - assert rtms.MEDIA_TYPE_TRANSCRIPT == 8 - assert rtms.MEDIA_TYPE_CHAT == 16 - assert rtms.MEDIA_TYPE_ALL == 32 - - def test_session_event_constants(self): - """Test session event constants""" - assert rtms.SESSION_EVENT_ADD == 1 - assert rtms.SESSION_EVENT_STOP == 2 - assert rtms.SESSION_EVENT_PAUSE == 3 - assert rtms.SESSION_EVENT_RESUME == 4 - - def test_event_type_constants(self): - """Test event type constants (for subscribeEvent/unsubscribeEvent)""" - assert rtms.EVENT_UNDEFINED == 0 - assert rtms.EVENT_FIRST_PACKET_TIMESTAMP == 1 - assert rtms.EVENT_ACTIVE_SPEAKER_CHANGE == 2 - assert rtms.EVENT_PARTICIPANT_JOIN == 3 - assert rtms.EVENT_PARTICIPANT_LEAVE == 4 - assert rtms.EVENT_SHARING_START == 5 - assert rtms.EVENT_SHARING_STOP == 6 - assert rtms.EVENT_MEDIA_CONNECTION_INTERRUPTED == 7 - assert rtms.EVENT_CONSUMER_ANSWERED == 8 - assert rtms.EVENT_CONSUMER_END == 9 - assert rtms.EVENT_USER_ANSWERED == 10 - assert rtms.EVENT_USER_END == 11 - assert rtms.EVENT_USER_HOLD == 12 - assert rtms.EVENT_USER_UNHOLD == 13 - - def test_status_constants(self): - """Test SDK status constants""" - assert rtms.RTMS_SDK_FAILURE == -1 - assert rtms.RTMS_SDK_OK == 0 - assert rtms.RTMS_SDK_TIMEOUT == 1 - assert rtms.RTMS_SDK_NOT_EXIST == 2 - - def test_audio_codec_constants(self): - """Test audio codec constants exist""" - assert 'OPUS' in rtms.AudioCodec - assert 'L16' in rtms.AudioCodec - assert 'G711' in rtms.AudioCodec - - def test_video_codec_constants(self): - """Test video codec constants exist""" - assert 'H264' in rtms.VideoCodec - assert 'JPG' in rtms.VideoCodec - - def test_sample_rate_constants(self): - """Test audio sample rate constants""" - assert 'SR_48K' in rtms.AudioSampleRate - assert 'SR_16K' in rtms.AudioSampleRate - assert 'SR_8K' in rtms.AudioSampleRate - - -class TestLogging: - """Test logging functionality""" - - def test_log_levels_enum(self): - """Test LogLevel enum values""" - assert rtms.LogLevel.ERROR == 0 - assert rtms.LogLevel.WARN == 1 - assert rtms.LogLevel.INFO == 2 - assert rtms.LogLevel.DEBUG == 3 - assert rtms.LogLevel.TRACE == 4 - - def test_log_format_enum(self): - """Test LogFormat enum values""" - assert rtms.LogFormat.PROGRESSIVE == 'progressive' - assert rtms.LogFormat.JSON == 'json' - - def test_configure_logger(self): - """Test logger configuration""" - # Should not raise errors - rtms.configure_logger({ - 'level': 'debug', - 'format': 'json', - 'enabled': True - }) - - rtms.configure_logger({ - 'level': 'info', - 'format': 'progressive', - 'enabled': False - }) - - def test_log_functions_exist(self): - """Test that all log functions are available""" - assert callable(rtms.log_debug) - assert callable(rtms.log_info) - assert callable(rtms.log_warn) - assert callable(rtms.log_error) - - -class TestClient: - """Test Client class functionality""" - - def test_client_instantiation(self): - """Test that Client can be instantiated""" - client = rtms.Client() - assert client is not None - assert isinstance(client, rtms.Client) - - def test_client_has_join_method(self): - """Test that Client has join method""" - client = rtms.Client() - assert hasattr(client, 'join') - assert callable(client.join) - - def test_client_has_leave_method(self): - """Test that Client has leave method""" - client = rtms.Client() - assert hasattr(client, 'leave') - assert callable(client.leave) - - def test_client_has_callback_methods(self): - """Test that Client has all callback registration methods""" - client = rtms.Client() - assert hasattr(client, 'onJoinConfirm') - assert hasattr(client, 'onSessionUpdate') - assert hasattr(client, 'onParticipantEvent') - assert hasattr(client, 'onActiveSpeakerEvent') - assert hasattr(client, 'onSharingEvent') - assert hasattr(client, 'onEventEx') - assert hasattr(client, 'onAudioData') - assert hasattr(client, 'onVideoData') - assert hasattr(client, 'onDeskshareData') - assert hasattr(client, 'onTranscriptData') - assert hasattr(client, 'onLeave') - - def test_client_has_param_methods(self): - """Test that Client has parameter configuration methods""" - client = rtms.Client() - assert hasattr(client, 'setAudioParams') - assert hasattr(client, 'setVideoParams') - assert hasattr(client, 'setDeskshareParams') - - def test_client_has_event_subscription_methods(self): - """Test that Client has event subscription methods""" - client = rtms.Client() - assert hasattr(client, 'subscribeEvent') - assert callable(client.subscribeEvent) - assert hasattr(client, 'unsubscribeEvent') - assert callable(client.unsubscribeEvent) - - def test_client_callback_registration(self): - """Test callback registration doesn't raise errors""" - client = rtms.Client() - - @client.onJoinConfirm - def on_join(reason): - pass - - @client.onTranscriptData - def on_transcript(data, size, timestamp, metadata): - pass - - # Should complete without errors - assert True - - -class TestParameterValidation: - """Test parameter validation functionality""" - - def test_audio_params_exist(self): - """Test AudioParams class exists""" - assert hasattr(rtms, 'AudioParams') - - def test_video_params_exist(self): - """Test VideoParams class exists""" - assert hasattr(rtms, 'VideoParams') - - def test_deskshare_params_exist(self): - """Test DeskshareParams class exists""" - assert hasattr(rtms, 'DeskshareParams') - - -class TestUtilityFunctions: - """Test utility functions""" - - def test_generate_signature_exists(self): - """Test signature generation function exists""" - assert hasattr(rtms, 'generate_signature') - assert callable(rtms.generate_signature) - - def test_find_ca_certificate_exists(self): - """Test CA certificate finder exists""" - assert hasattr(rtms, 'find_ca_certificate') - assert callable(rtms.find_ca_certificate) - - def test_initialize_exists(self): - """Test initialize function exists""" - assert hasattr(rtms, 'initialize') - assert callable(rtms.initialize) - - def test_uninitialize_exists(self): - """Test uninitialize function exists""" - assert hasattr(rtms, 'uninitialize') - assert callable(rtms.uninitialize) - - -class TestWebhookFunctionality: - """Test webhook-related functionality""" - - def test_on_webhook_event_exists(self): - """Test webhook event decorator exists""" - assert hasattr(rtms, 'onWebhookEvent') - assert callable(rtms.onWebhookEvent) - # Also test the alias - assert hasattr(rtms, 'on_webhook_event') - assert callable(rtms.on_webhook_event) - - def test_webhook_response_class_exists(self): - """Test WebhookResponse class exists""" - # WebhookResponse is internal but should be importable - from rtms import WebhookResponse - assert WebhookResponse is not None - - def test_client_webhook_decorator(self): - """Test client webhook event decorator""" - client = rtms.Client() - assert hasattr(client, 'on_webhook_event') - assert callable(client.on_webhook_event) - - # Test decorator usage - @client.on_webhook_event() - def handle_webhook(payload): - pass - - assert True - - -class TestDataClasses: - """Test data classes and structures""" - - def test_session_class_exists(self): - """Test Session class exists""" - assert hasattr(rtms, 'Session') - - def test_participant_class_exists(self): - """Test Participant class exists""" - assert hasattr(rtms, 'Participant') - - def test_metadata_class_exists(self): - """Test Metadata class exists""" - assert hasattr(rtms, 'Metadata') - - -class TestModuleExports: - """Test that all expected exports are available""" - - def test_client_export(self): - """Test Client is exported""" - assert 'Client' in rtms.__all__ - - def test_constant_exports(self): - """Test constants are exported""" - exports_to_check = [ - 'MEDIA_TYPE_AUDIO', - 'MEDIA_TYPE_VIDEO', - 'MEDIA_TYPE_TRANSCRIPT', - 'SESSION_EVENT_ADD', - 'EVENT_PARTICIPANT_JOIN', - 'EVENT_PARTICIPANT_LEAVE', - 'EVENT_ACTIVE_SPEAKER_CHANGE', - 'EVENT_SHARING_START', - 'EVENT_SHARING_STOP', - 'RTMS_SDK_OK' - ] - for export in exports_to_check: - assert export in rtms.__all__ - - def test_function_exports(self): - """Test functions are exported""" - functions = [ - 'initialize', - 'uninitialize', - 'generate_signature', - 'configure_logger' - ] - for func in functions: - assert func in rtms.__all__ - - def test_enum_exports(self): - """Test enums are exported""" - enums = [ - 'AudioCodec', - 'VideoCodec', - 'AudioSampleRate', - 'VideoResolution', - 'LogLevel', - 'LogFormat' - ] - for enum in enums: - assert enum in rtms.__all__ - - -class TestThreadSafety: - """Test thread-safe features""" - - def test_module_has_run_function(self): - """Test module has run() function for event loop""" - assert hasattr(rtms, 'run') - assert callable(rtms.run) - - def test_module_has_stop_function(self): - """Test module has stop() function to signal shutdown""" - assert hasattr(rtms, 'stop') - assert callable(rtms.stop) - - def test_client_has_polling_control(self): - """Test client has polling control""" - client = rtms.Client() - assert hasattr(client, '_poll_if_needed') - assert hasattr(client, '_running') - - -# Run tests -if __name__ == '__main__': - pytest.main([__file__, '-v']) diff --git a/tests/pack-install.test.js b/tests/ts/pack-install.test.js similarity index 94% rename from tests/pack-install.test.js rename to tests/ts/pack-install.test.js index 3981820..4cc07f2 100644 --- a/tests/pack-install.test.js +++ b/tests/ts/pack-install.test.js @@ -168,8 +168,6 @@ describe('npm pack → install → load integration', () => { throw new Error(`Native module not found at ${buildDir}/rtms.node`); } - console.log(`Using build artifacts from: ${buildDir}`); - // Create tarball from current package const output = execSync('npm pack --json', { encoding: 'utf8' }); const packages = JSON.parse(output); @@ -277,4 +275,21 @@ describe('npm pack → install → load integration', () => { expect(result.trim()).toBe('OK'); }); + + test('Client.setProxy is a function', () => { + installWithLocalBuild(tempDir, tarballPath, buildDir); + + const testScript = ` + import rtms from '@zoom/rtms'; + const client = new rtms.Client(); + console.log(typeof client.setProxy === 'function' ? 'OK' : 'FAIL'); + `; + + const result = execSync(`node -e "${testScript}"`, { + cwd: tempDir, + encoding: 'utf8' + }); + + expect(result.trim()).toBe('OK'); + }); }); diff --git a/tests/rtms.test.ts b/tests/ts/rtms.test.ts similarity index 82% rename from tests/rtms.test.ts rename to tests/ts/rtms.test.ts index d3a997b..8c6c3d3 100644 --- a/tests/rtms.test.ts +++ b/tests/ts/rtms.test.ts @@ -1,7 +1,7 @@ -const rtms = require("../index.ts") +const rtms = require("../../index.ts") // Mock the native addon methods for both Client and global functions -jest.mock("../index.ts", () => { +jest.mock("../../index.ts", () => { // Create mock functions for all methods const mockFunctions = { // Client class methods @@ -26,6 +26,7 @@ jest.mock("../index.ts", () => { setAudioParams: jest.fn().mockReturnValue(true), setVideoParams: jest.fn().mockReturnValue(true), setDeskshareParams: jest.fn().mockReturnValue(true), + setTranscriptParams: jest.fn().mockReturnValue(true), // Callback methods onJoinConfirm: jest.fn().mockReturnValue(true), @@ -43,8 +44,33 @@ jest.mock("../index.ts", () => { // Event subscription methods subscribeEvent: jest.fn().mockReturnValue(true), unsubscribeEvent: jest.fn().mockReturnValue(true), + + // Individual video subscription methods + subscribeVideo: jest.fn().mockReturnValue(true), + onParticipantVideo: jest.fn().mockReturnValue(true), + onVideoSubscribed: jest.fn().mockReturnValue(true), + })), + + // TranscriptParams class + TranscriptParams: jest.fn().mockImplementation(() => ({ + contentType: 5, + srcLanguage: -1, + enableLid: true, + setSrcLanguage: jest.fn(), + setEnableLid: jest.fn(), })), + // Transcript language constants (mirrors AudioCodec pattern) + TranscriptLanguage: { + NONE: -1, + ARABIC: 0, + ENGLISH: 9, + FRENCH_FRANCE: 13, + GERMAN: 14, + JAPANESE: 20, + SPANISH: 28, + }, + // Utility functions initialize: jest.fn().mockReturnValue(true), uninitialize: jest.fn().mockReturnValue(true), @@ -197,7 +223,7 @@ describe('RTMS Node.JS Addon Comprehensive Test Suite', () => { // ---- Class-based approach tests ---- describe('Class-based Client Approach', () => { - let client; + let client: InstanceType; beforeEach(() => { client = new rtms.Client(); @@ -219,7 +245,24 @@ describe('RTMS Node.JS Addon Comprehensive Test Suite', () => { timeout: 5000, pollInterval: 100 }; - + + const result = client.join(joinParams); + expect(client.join).toHaveBeenCalledWith(joinParams); + expect(result).toBe(true); + }); + + // ZCC engagement_id tests + // NOTE: The test module is fully mocked so client.join always returns true + // regardless of params. These tests document the expected API contract. + // The real routing logic is validated in the Python tests and by TypeScript + // compilation (engagement_id must appear in the JoinParams interface). + test('client.join accepts engagement_id for ZCC sessions', () => { + const joinParams = { + engagement_id: "engagement-abc-123", + rtms_stream_id: "stream-xyz", + server_urls: "wss://rtms.zoom.us", + signature: "mock-sig", + }; const result = client.join(joinParams); expect(client.join).toHaveBeenCalledWith(joinParams); expect(result).toBe(true); @@ -319,6 +362,24 @@ describe('RTMS Node.JS Addon Comprehensive Test Suite', () => { expect(client.setDeskshareParams).toHaveBeenCalledWith(deskshareParams); expect(result).toBe(true); }); + + // TranscriptParams tests + // NOTE: The module is fully mocked; these document the expected API shape. + // The real default values and toNative() mapping are verified in the C++ tests. + test('client.setTranscriptParams accepts a TranscriptParams object', () => { + const params = new rtms.TranscriptParams(); + const result = client.setTranscriptParams(params); + expect(client.setTranscriptParams).toHaveBeenCalledWith(params); + expect(result).toBe(true); + }); + + test('TranscriptLanguage.ENGLISH equals 9', () => { + expect(rtms.TranscriptLanguage.ENGLISH).toBe(9); + }); + + test('TranscriptLanguage.NONE equals -1', () => { + expect(rtms.TranscriptLanguage.NONE).toBe(-1); + }); }); describe('Callback Methods', () => { @@ -400,6 +461,64 @@ describe('RTMS Node.JS Addon Comprehensive Test Suite', () => { }); }); + describe('Individual Video Subscription', () => { + test('client.subscribeVideo subscribes to an individual participant video stream', () => { + const result = client.subscribeVideo(12345, true); + expect(client.subscribeVideo).toHaveBeenCalledWith(12345, true); + expect(result).toBe(true); + }); + + test('client.subscribeVideo unsubscribes from an individual participant video stream', () => { + const result = client.subscribeVideo(12345, false); + expect(client.subscribeVideo).toHaveBeenCalledWith(12345, false); + expect(result).toBe(true); + }); + + test('client.onParticipantVideo sets the participant video state callback', () => { + const callback = jest.fn(); + const result = client.onParticipantVideo(callback); + expect(client.onParticipantVideo).toHaveBeenCalledWith(callback); + expect(result).toBe(true); + }); + + test('client.onVideoSubscribed sets the video subscription response callback', () => { + const callback = jest.fn(); + const result = client.onVideoSubscribed(callback); + expect(client.onVideoSubscribed).toHaveBeenCalledWith(callback); + expect(result).toBe(true); + }); + + test('onParticipantVideo callback receives users array and isOn flag', () => { + let capturedUsers: number[] = []; + let capturedIsOn: boolean | null = null; + const callback = jest.fn().mockImplementation((users: number[], isOn: boolean) => { + capturedUsers = users; + capturedIsOn = isOn; + }); + client.onParticipantVideo(callback); + // Simulate a callback invocation + callback([11111, 22222], true); + expect(capturedUsers).toEqual([11111, 22222]); + expect(capturedIsOn).toBe(true); + }); + + test('onVideoSubscribed callback receives userId, status, and error string', () => { + let capturedUserId: number | null = null; + let capturedStatus: number | null = null; + let capturedError: string | null = null; + const callback = jest.fn().mockImplementation((userId: number, status: number, error: string) => { + capturedUserId = userId; + capturedStatus = status; + capturedError = error; + }); + client.onVideoSubscribed(callback); + callback(12345, 0, ''); + expect(capturedUserId).toBe(12345); + expect(capturedStatus).toBe(0); + expect(capturedError).toBe(''); + }); + }); + describe('Event Subscription Methods', () => { test('client.subscribeEvent subscribes to events correctly', () => { const events = [rtms.EVENT_PARTICIPANT_JOIN, rtms.EVENT_PARTICIPANT_LEAVE]; diff --git a/tests/ts/rtms.wrapper.test.ts b/tests/ts/rtms.wrapper.test.ts new file mode 100644 index 0000000..4a190b2 --- /dev/null +++ b/tests/ts/rtms.wrapper.test.ts @@ -0,0 +1,206 @@ +/** + * Integration tests for the index.ts TypeScript wrapper layer. + * + * Unlike rtms.test.ts (which mocks the entire module), these tests run against + * the real built ESM output (build/Release/index.js) via child node processes. + * This provides a genuine red-green TDD cycle: methods missing from index.ts + * cause failures here even if the mock in rtms.test.ts is already wired. + * + * Requires build/Release/index.js to exist (task build:js). + */ + +import { execSync } from 'child_process'; +import { existsSync } from 'fs'; +import * as path from 'path'; + +const BUILD = path.resolve(__dirname, '../../build/Release/index.js'); + +// The built module uses a default export. Prefix every eval with this preamble. +const PREAMBLE = `import * as _mod from '${BUILD}'; const rtms = _mod.default;`; + +/** Run an expression with a Client instance; returns true if exit code 0. */ +function run(expr: string): boolean { + try { + execSync( + `node --input-type=module --eval "${PREAMBLE} const c = new rtms.Client(); process.exit((${expr}) ? 0 : 1);"`, + { stdio: 'pipe' } + ); + return true; + } catch { + return false; + } +} + +/** Run an expression at module level (no Client). */ +function runModule(expr: string): boolean { + try { + execSync( + `node --input-type=module --eval "${PREAMBLE} process.exit((${expr}) ? 0 : 1);"`, + { stdio: 'pipe' } + ); + return true; + } catch { + return false; + } +} + +describe('index.ts wrapper — real built module', () => { + beforeAll(() => { + if (!existsSync(BUILD)) { + throw new Error(`Build output not found: ${BUILD}\nRun 'task build:js' first.`); + } + }); + + // -------------------------------------------------------------------------- + describe('Client — connection methods', () => { + test('join is a function', () => { + expect(run("typeof c.join === 'function'")).toBe(true); + }); + + test('leave is a function', () => { + expect(run("typeof c.leave === 'function'")).toBe(true); + }); + }); + + // -------------------------------------------------------------------------- + describe('Client — parameter methods', () => { + test('setAudioParams is a function', () => { + expect(run("typeof c.setAudioParams === 'function'")).toBe(true); + }); + + test('setVideoParams is a function', () => { + expect(run("typeof c.setVideoParams === 'function'")).toBe(true); + }); + + test('setDeskshareParams is a function', () => { + expect(run("typeof c.setDeskshareParams === 'function'")).toBe(true); + }); + + test('setTranscriptParams is a function', () => { + expect(run("typeof c.setTranscriptParams === 'function'")).toBe(true); + }); + + test('setProxy is a function', () => { + expect(run("typeof c.setProxy === 'function'")).toBe(true); + }); + + test('setProxy does not throw for http', () => { + expect(run("(c.setProxy('http', 'http://proxy.example.com:8080'), true)")).toBe(true); + }); + + test('setProxy does not throw for https', () => { + expect(run("(c.setProxy('https', 'https://proxy.example.com:8080'), true)")).toBe(true); + }); + }); + + // -------------------------------------------------------------------------- + describe('Client — callback registration methods', () => { + const callbacks = [ + 'onJoinConfirm', 'onSessionUpdate', 'onUserUpdate', + 'onParticipantEvent', 'onActiveSpeakerEvent', 'onSharingEvent', 'onEventEx', + 'onAudioData', 'onVideoData', 'onDeskshareData', 'onTranscriptData', 'onLeave', + ]; + + for (const method of callbacks) { + test(`${method} is a function`, () => { + expect(run(`typeof c.${method} === 'function'`)).toBe(true); + }); + } + }); + + // -------------------------------------------------------------------------- + describe('Client — event subscription methods', () => { + test('subscribeEvent is a function', () => { + expect(run("typeof c.subscribeEvent === 'function'")).toBe(true); + }); + + test('unsubscribeEvent is a function', () => { + expect(run("typeof c.unsubscribeEvent === 'function'")).toBe(true); + }); + }); + + // -------------------------------------------------------------------------- + describe('Client — individual video subscription', () => { + test('subscribeVideo is a function', () => { + expect(run("typeof c.subscribeVideo === 'function'")).toBe(true); + }); + + test('onParticipantVideo is a function', () => { + expect(run("typeof c.onParticipantVideo === 'function'")).toBe(true); + }); + + test('onVideoSubscribed is a function', () => { + expect(run("typeof c.onVideoSubscribed === 'function'")).toBe(true); + }); + + test('onParticipantVideo accepts a callback without throwing', () => { + expect(run("(c.onParticipantVideo(() => {}), true)")).toBe(true); + }); + + test('onVideoSubscribed accepts a callback without throwing', () => { + expect(run("(c.onVideoSubscribed(() => {}), true)")).toBe(true); + }); + }); + + // -------------------------------------------------------------------------- + describe('Module — utility functions', () => { + test('generateSignature is a function', () => { + expect(runModule("typeof rtms.generateSignature === 'function'")).toBe(true); + }); + + test('configureLogger is a function', () => { + expect(runModule("typeof rtms.configureLogger === 'function'")).toBe(true); + }); + + test('onWebhookEvent is a function', () => { + expect(runModule("typeof rtms.onWebhookEvent === 'function'")).toBe(true); + }); + + test('Client.initialize is a static function', () => { + expect(runModule("typeof rtms.Client.initialize === 'function'")).toBe(true); + }); + + test('Client.uninitialize is a static function', () => { + expect(runModule("typeof rtms.Client.uninitialize === 'function'")).toBe(true); + }); + }); + + // -------------------------------------------------------------------------- + describe('Module — constants', () => { + test('MEDIA_TYPE_AUDIO === 1', () => { + expect(runModule('rtms.MEDIA_TYPE_AUDIO === 1')).toBe(true); + }); + + test('MEDIA_TYPE_VIDEO === 2', () => { + expect(runModule('rtms.MEDIA_TYPE_VIDEO === 2')).toBe(true); + }); + + test('MEDIA_TYPE_TRANSCRIPT === 8', () => { + expect(runModule('rtms.MEDIA_TYPE_TRANSCRIPT === 8')).toBe(true); + }); + + test('RTMS_SDK_OK === 0', () => { + expect(runModule('rtms.RTMS_SDK_OK === 0')).toBe(true); + }); + + test('SESSION_EVENT_ADD === 1', () => { + expect(runModule('rtms.SESSION_EVENT_ADD === 1')).toBe(true); + }); + + test('TranscriptLanguage.ENGLISH === 9', () => { + expect(runModule('rtms.TranscriptLanguage && rtms.TranscriptLanguage.ENGLISH === 9')).toBe(true); + }); + + test('TranscriptLanguage.NONE === -1', () => { + expect(runModule('rtms.TranscriptLanguage && rtms.TranscriptLanguage.NONE === -1')).toBe(true); + }); + + test('AudioCodec dict is exported', () => { + expect(runModule("typeof rtms.AudioCodec === 'object' && rtms.AudioCodec !== null")).toBe(true); + }); + + test('VideoCodec dict is exported', () => { + expect(runModule("typeof rtms.VideoCodec === 'object' && rtms.VideoCodec !== null")).toBe(true); + }); + }); +});