diff --git a/.prettierignore b/.prettierignore index 80026a2ed..9fa899f78 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,6 @@ dist build +.angular e2e-tests node_modules packages/protobufs diff --git a/EXTRACTION_MAP.md b/EXTRACTION_MAP.md new file mode 100644 index 000000000..3fbc9fb8b --- /dev/null +++ b/EXTRACTION_MAP.md @@ -0,0 +1,20 @@ +# spike/tsunami-full → extraction map (commit ↔ future PR ↔ Linear) + +Reference implementation built consumer-first; each commit is one future PR. Extract in order — later slices assume earlier ones. Spike commits are donors, not cherry-picks: re-derive each PR on top of review feedback, using the commit as the reference. + +| # | Spike commit (subject prefix) | Future PR / ticket | Notes for extraction | +|---|---|---|---| +| 0 | (cherry-picked) "FCE-3578: add StateStore" | PR #581 (open) | **Amend #581: switch to synchronous notifications** (microtask batching breaks the FCE-3030 sync-act contract — proven in slice 1). Needs user decision before touching the PR. | +| 1 | "slice 1: client wiring …" | New ticket: client wiring + ClientState (connection slices) + swap useConnection/usePeers | The missing bridge ticket identified in planning. Includes the sync-notify store change if not amended into #581. | +| 2 | "slice 2: device core …" | FCE-3582 + FCE-3586 + FCE-3588 + FCE-3583 merged scope (orchestrator + init + camera + microphone + hook swaps) | Camera/mic/init are inseparable (shared initial stream). Either one PR or split camera/mic after orchestrator lands — both slices compile independently. Device errors follow AGENTS.md (classification in WebDeviceManager). | +| 3–4 | "slices 3-4: ScreenShareController + CustomSourceController …" | FCE-3584 (+ FCE-3574 fix) and FCE-3585, each + hook swap | Contains THE one test-file change: screenShare.spec quirk assertion flipped (documented intent; FCE-3574 fixed). Get explicit sign-off. | +| 5 | "sweep: delete dead react-client utils …" | Folds into the tail of the slice-3/4 PR or a tiny cleanup PR | Also records: NO dataChannel/bandwidth/VAD store slices (no consumer); FCE-3579 finding → no current consumer, propose re-scope/defer. | +| 6 | "vanilla-ts demo …" | FCE-3593 (reduced scope: vanilla + Angular) | Workspace glob + demo. | +| 7 | "angular example …" (agent, pending) | FCE-3593 | First-class app + behavioral suite. | +| 8 | "demolition pass …" | Folds into whichever PR carries the touched files | Altitude + dead-code verdicts in SPIKE_NOTES.md. | + +## Linear restructure implied (do NOT apply without user) +- FCE-3589/3590/3591 (milestone 3) dissolve into the per-slice hook swaps above; keep a small final "delete leftovers + StrictMode interop specs" remnant if desired. +- FCE-3587 (middleware+reconnect): behavior is covered inside slices 2–4 (middleware persistence, reconnect republish) — verify remaining scope, likely reduces to addTracksOnReconnect docs. +- FCE-3579 (VAD channel): no consumer today — defer or re-scope. +- FCE-3580 follow-through: device errors landed per AGENTS.md in slice 2. diff --git a/SPIKE_NOTES.md b/SPIKE_NOTES.md new file mode 100644 index 000000000..b4397f6df --- /dev/null +++ b/SPIKE_NOTES.md @@ -0,0 +1,62 @@ +# spike/tsunami-full — build notes (not for extraction; delete before any PR) + +Working rules: react-client `src/tests/**` is READ-ONLY (69 tests = fixed contract; `git diff` must stay empty there). tsunami tests are ours. AGENTS.md error rules apply. One event → one store update. Map reports live in the session scratchpad `map/` dir (test-seams, provider-connection, device-layer, screenshare-middleware, stash-controllers, public-surface). + +## Slice log + +### Slice 1 — DONE (commit "slice 1: client wiring ...") +- StateStore: notifications now SYNCHRONOUS (microtask batching broke sync `act()` assertions in connection.spec). One notification per effective update(); each signalling event composes ONE update (joined = peerStatus+participants in one call). +- ClientState = connection slices only. FishjamClient: `signallingClient` injection (the FCE-3030 fake seam), getState/subscribe/subscribeToSlice, bindSessionStateEvents, dispose clears store. +- react-client: provider wraps injected ts-client in tsunami client; usePeerStatus/useReconnection/useFishjamClientState = useSyncExternalStore over the store; FishjamClientContext holds tsunami client (type ripple in ~8 files); useScreenshareManager awaits addTrack (FCE-3580 rejection contract; keep-local recovery preserved). +- RESOLVED 2026-08-04: PR #581 was closed by Miłosz — nothing to amend. The sync-notify StateStore on this branch is the version of record for FCE-3578. + +### Slice 2 — IN PROGRESS: device core (camera + microphone + initializeDevices together — init shares its stream with both device controllers, inseparable) +Design decisions: +1. **Device errors per AGENTS.md**: classes extending FishjamError with legacy name literals ("NotAllowedError" | "NotFoundError" | "OverconstrainedError" | "UNHANDLED_ERROR"), recoverability set, platform cause. Classification lives in the IDeviceManager IMPL (rule 4): WebDeviceManager's getUserMedia/getDisplayMedia reject with classified DeviceError; core never touches DOMException names except via `error.name` on already-classified DeviceError. +2. **react-client boundary conversion**: specs assert `toEqual({ name: "..." })` (plain object). FishjamError instances have extra enumerable props → the provider adapter converts instance→`{ name }` plain object wherever DeviceError reaches the public hook surface (deviceError, startCamera tuple). +3. Port from stash (donor, adjust to error model): devices/{constraints,mediaInitializer}.ts, tracks/trackUtils.ts, controllers/{TrackPublisher,TrackDeviceController,DeviceOrchestrator}.ts, deviceManager.ts platform-types delta, mediaTypes.ts, ClientState grows camera/microphone/availableCameras/availableMicrophones/cameraError/microphoneError/devicesInitialized slices. FishjamClient config grows deviceManager/constraints/bandwidthLimits/streamConfigs; device API methods; `devices` internal getter. +4. react-client provider: replace useMediaDevices/useDeviceManager/useTrackManager wiring for camera+mic with adapters over tsunami controllers, PRESERVING context value shapes (`DeviceManager`, `TrackManager` types) so useCamera/useMicrophone/useInitializeDevices hook bodies stay ~untouched. Provider passes `deviceManager: new WebDeviceManager({ persistence })` + constraints etc. into the tsunami client (stash FishjamProvider L149–171 is the donor for the adapter shapes). +5. ScreenShare/CustomSource stay on the OLD react-client path this slice (they don't use the device managers; they call navigator/fishjamClient directly). Orchestrator constructs camera+microphone only until their slices. +6. Store sync: DeviceOrchestrator.syncStore writes one update() with all device slices; controller snapshot() memoization keeps slice refs stable. + +Fixed-contract seams to respect (from test-seams map): fakes patch `navigator.mediaDevices` + global MediaStream (WebDeviceManager reads exactly that — keeps working); `persistLastDevice: false` default in renderWithProvider → persistence must be injectable/disable-able through provider props as today; fake tracks key off `getSettings().deviceId`. + +Behavioral invariants the port must preserve (device-layer map): init dedup (in-flight promise reuse; rejection resets; already_initialized short-circuit), initial combined stream shared by both controllers + invalidated when either side diverges, `deviceId: {ideal}` on init vs `{exact}` on explicit start/select, Safari label-matching correction, getAvailableMedia fallback ladder (audio-only→video-only, Overconstrained retry with stripped deviceId), enabled=false reapplied to fresh tracks while muted, selectDevice without live track only records selection, stopDevice performs NO signalling call, toggleMute requires published track (warn otherwise), pause=replaceTrack(null)+metadata paused:true, resume=replaceTrack(track)+paused:false, join auto-publish (catch TrackTypeError silently, log others), local-id→remote-id resolution awaiting in-flight addTrack, `correctDevicesOnSafari` only from initializeDevices. + +### Slices 3-4 — DONE (commit "slices 3-4: ...") +- ScreenShareController + CustomSourceController ported; provider contexts are store-backed memo adapters; old manager hooks deleted. +- FCE-3574 FIXED (middleware persisted + re-applied). THE one test-file diff: screenShare.spec.ts quirk assertion flipped per its own documented intent. SIGNED OFF by Miłosz 2026-08-04 — the new behavior is correct; FCE-3574 closes with the extraction PR. + +### Slice 5 — RESOLVED AS EMPTY (consumer-first verdict) +- useDataChannel/useVAD/useLocalVAD/useStatistics/useUpdatePeerMetadata/useSandbox/livestream hooks all pass unaltered on the tsunami client (events + delegation). NO dataChannel/bandwidthEstimation ClientState slices added — no consumer pull (would repeat the ClientState-before-consumer mistake). +- FCE-3579 (high-frequency VAD channel bypassing store): NO current consumer — the existing VAD hooks poll getLocalTrackAudioLevel + re-render off track events and their specs pass. Finding: defer/re-scope the ticket. +- Dead code swept: react-client utils/track.ts, utils/bandwidth.ts, devices/, provider logger. +### Then: vanilla demo + Angular example (first-class, zoneless+signals, behavioral suite mirroring FCE-3030 scenarios), demolition pass (see stash map §5 weak spots: orchestrator delegation altitude, TrackPublisher single-impl, unused constraints consts, dead trackUtils export, snapshot memoization duplication, setError/adoptInitialStream orchestrator-private-as-public), re-slice history, commit→ticket map, final validation (FCE-3030 zero-diff, Angular suite, root gates, fishjam-chat via Argent, live two-tab). + +## Verification loop per slice +tsunami: tsc + test + build. react-client: tsc + test (69/69) + `git diff --stat src/tests/` empty. Commit per slice. + +### Demolition pass — verdicts (all suites green after) +- APPLIED: orchestrator screen-share delegation wrappers removed (altitude now uniform: client -> requireDevices().. for all four controllers). +- APPLIED: dead code deleted — AUDIO_TRACK_CONSTRAINTS, SCREEN_SHARING_MEDIA_CONSTRAINTS, tsunami trackUtils.getRemoteOrLocalTrackContext (inline copy in createTrackPublisher is the single real impl), react-client utils/errors trimmed to MissingSandboxApiUrlError (parseUserMediaError + {name} consts gone). +- KEPT: TrackPublisher — earns its keep as the confined DOM/platform track cast boundary and keeps controllers signalling-agnostic; single impl acceptable. +- KEPT: per-controller snapshot() memoization duplication (2 sites, explicit beats indirection at this size). +- KEPT (documented): TrackDeviceController.setError/adoptInitialStream are orchestrator-private wiring exposed as public (TS has no friend classes); isSignallingActive vs getPeerStatus gating overlap left as-is (suite pins both behaviors; unifying risks semantic drift). + +### Live validation — fishjam-chat on iOS simulator (Argent) + vanilla web peer +- App boots on the tsunami-backed stack; initializeDevices triggers mic permission; sandbox token + join works; two-party call with the vanilla demo (Playwright, fake camera): web saw remotePeers=1 + the sim's video track rendered; peer-left propagated both ways; leave clean. +- FIX FOUND ON DEVICE: RN's polyfilled navigator.mediaDevices has no addEventListener — WebDeviceManager.onDeviceChange now feature-detects (react-client's provider hard-instantiates WebDeviceManager, which mobile-client inherits via re-export). +- FINDING for the mobile slice: the proper fix is a deviceManager injection point in FishjamProvider so mobile passes ReactNativeDeviceManager (it exists, still unused); the feature-detect makes the WebDeviceManager-over-RN-polyfill path work meanwhile. +- Vanilla render count over the whole join+track flow: 16 notifications (sane; sync-notify not spammy). + +### Mobile overhaul (2026-08-04) — native device layer + polyfill diet, 5 commits +Scope locked by user: keep only the 3 load-bearing engine globals, no ts-client/webrtc-client changes, no fork changes, breaking change for apps using removed globals accepted. +1. react-client `FishjamProvider` gains `deviceManager` + `clientType` props; an injected manager owns persistence (the `persistLastDevice` branch is skipped entirely, which is what makes mobile's localStorage polyfill removable). +2. `IDeviceManager.createMediaStream` — stream construction moved behind the platform boundary; `TrackDeviceController` exposes a render-ready stream scoped to the ACTIVE track (initializeDevices shares one stream across camera+mic, so a naive passthrough would leak the other kind into a preview); useCamera/useMicrophone/useLivestreamStreamer no longer touch the `MediaStream` global. +3. `ReactNativeDeviceManager` hardened BEFORE going live: real bug fixed — the fork rejects with `MediaStreamError` (NOT an Error) naming permission denial `SecurityError`, so denials classified as UNHANDLED_ERROR; now duck-typed → NotAllowedError. Permission warnings moved off the navigator monkey-patch onto the acquisition path. `displayMediaOptions` ctor option; `onDeviceChange` honest no-op (deletes the FCE-3689 cast here). +4. Mobile provider: own module, `clientType:'mobile'` + module-level native manager, no per-render ts-client allocation, NO deviceManager prop exposed to apps (user decision). +5. `webrtc-polyfill.ts` → `globals.ts`: no `registerGlobals()`; sets only RTCPeerConnection (the getConfiguration-caching subclass), RTCIceCandidate, MediaStream from direct fork imports. Deleted: local-storage polyfill, overrides/getUserMedia.ts, EventTarget global. README breaking note. + +VALIDATED ON SIMULATOR (iPhone 16 Pro, iOS 18.6, fishjam-chat, Metro 8081): app boots, permissions prompt → denial path shows the app's denied state, grant + relaunch → camera preview renders through RTCView (SimCam placeholder = live camera frames), join sandbox room reaches the Room screen with call controls. Hermes eval proved the diet exactly: navigator.mediaDevices / localStorage / EventTarget / MediaStreamTrack / RTCSessionDescription / RTCRtpSender / RTCRtpReceiver / RTCRtpTransceiver / RTCCertificate / RTCErrorEvent / MediaStreamTrackEvent all `undefined`; RTCPeerConnection + RTCIceCandidate + MediaStream present, constructible, `getConfiguration()` returns the config. +NOT covered this pass: web-peer bidirectional media (the vanilla demo's connect hung in headless Chrome — demo-side wiring, unrelated to the SDK change), screen-share picker, VAD indicator, livestream connect. Verify those before shipping. +Tests after: tsunami 66, react-client 71 (69 originals + 2 injection specs), mobile-client 16 (new: classification, permission warnings, displayMediaOptions, createMediaStream, provider shape, globals). diff --git a/examples/tsunami/angular/.gitignore b/examples/tsunami/angular/.gitignore new file mode 100644 index 000000000..eeebebe42 --- /dev/null +++ b/examples/tsunami/angular/.gitignore @@ -0,0 +1,3 @@ +.angular/ +dist/ +node_modules/ diff --git a/examples/tsunami/angular/.prettierrc b/examples/tsunami/angular/.prettierrc new file mode 100644 index 000000000..963354f23 --- /dev/null +++ b/examples/tsunami/angular/.prettierrc @@ -0,0 +1,3 @@ +{ + "printWidth": 120 +} diff --git a/examples/tsunami/angular/angular.json b/examples/tsunami/angular/angular.json new file mode 100644 index 000000000..c499eb5b1 --- /dev/null +++ b/examples/tsunami/angular/angular.json @@ -0,0 +1,59 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "projects": { + "tsunami-angular-example": { + "projectType": "application", + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "outputPath": "dist", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": [], + "tsConfig": "tsconfig.app.json", + "styles": ["src/styles.css"], + "allowedCommonJsDependencies": ["events"], + "budgets": [ + { "type": "initial", "maximumWarning": "2MB", "maximumError": "5MB" }, + { "type": "anyComponentStyle", "maximumWarning": "8kB", "maximumError": "16kB" } + ] + }, + "configurations": { + "production": { + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular/build:dev-server", + "options": { + "port": 4201 + }, + "configurations": { + "production": { + "buildTarget": "tsunami-angular-example:build:production" + }, + "development": { + "buildTarget": "tsunami-angular-example:build:development" + } + }, + "defaultConfiguration": "development" + } + } + } + }, + "cli": { + "analytics": false + } +} diff --git a/examples/tsunami/angular/package.json b/examples/tsunami/angular/package.json new file mode 100644 index 000000000..9c61945ec --- /dev/null +++ b/examples/tsunami/angular/package.json @@ -0,0 +1,32 @@ +{ + "name": "@fishjam-example/tsunami-angular-example", + "private": true, + "version": "0.0.0", + "license": "Apache-2.0", + "scripts": { + "dev": "ng serve", + "build": "ng build", + "test": "vitest run" + }, + "dependencies": { + "@angular/common": "^20.3.0", + "@angular/compiler": "^20.3.0", + "@angular/core": "^20.3.0", + "@angular/platform-browser": "^20.3.0", + "@fishjam-cloud/tsunami": "workspace:*", + "rxjs": "^7.8.2", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@angular/build": "^20.3.0", + "@angular/cli": "^20.3.0", + "@angular/compiler-cli": "^20.3.0", + "@types/events": "^3.0.3", + "events": "^3.3.0", + "fake-mediastreamtrack": "^2.0.0", + "jsdom": "^26.0.0", + "typescript": "~5.8.3", + "vitest": "^3.1.1" + }, + "packageManager": "yarn@4.16.0" +} diff --git a/examples/tsunami/angular/src/app/app.component.ts b/examples/tsunami/angular/src/app/app.component.ts new file mode 100644 index 000000000..edf445990 --- /dev/null +++ b/examples/tsunami/angular/src/app/app.component.ts @@ -0,0 +1,256 @@ +import { ChangeDetectionStrategy, Component, computed, inject, signal } from "@angular/core"; +import type { FishjamTrackContext } from "@fishjam-cloud/tsunami"; +import { getSandboxPeerToken, httpToWebsocketUrl, resolveFishjamUrl } from "@fishjam-cloud/tsunami"; + +import { FishjamService } from "./fishjam.service"; + +const MAX_ACTIVITY_LOG_LINES = 10; +const CONNECT_SETTINGS_KEY = "tsunami-angular-connect-settings"; + +type ConnectSettings = Partial>; + +const loadConnectSettings = (): ConnectSettings => { + try { + return JSON.parse(localStorage.getItem(CONNECT_SETTINGS_KEY) ?? "{}") as ConnectSettings; + } catch { + return {}; + } +}; + +const savedConnectSettings = loadConnectSettings(); + +@Component({ + selector: "app-root", + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+

Fishjam Tsunami — Angular

+ +

+ peer: {{ state().peerStatus }} · reconnection: + {{ state().reconnectionStatus }} · devices initialized: + {{ state().devicesInitialized }} · errors: {{ errorSummary() }} +

+ +
+

Connection

+ + + + + + +
+ +
+

Devices

+ + + + + + +
+ +
+

Local previews

+
+
+ +
camera{{ state().camera.isEnabled ? "" : " (muted)" }}
+
+
+ +
+ screen share + + +
+
+
+
+ +
+

Remote tracks ({{ remoteVideoTracks().length }})

+
+ @for (context of remoteVideoTracks(); track context.trackId) { + + } +
+
+ +
+

Activity

+
{{ activityLogText() }}
+
+
+ `, +}) +export class AppComponent { + protected readonly fishjam = inject(FishjamService); + protected readonly state = this.fishjam.state; + + protected readonly fishjamId = signal(savedConnectSettings.fishjamId ?? ""); + protected readonly sandboxUrl = signal(savedConnectSettings.sandboxUrl ?? ""); + protected readonly roomName = signal(savedConnectSettings.roomName ?? "tsunami-room"); + protected readonly peerName = signal(savedConnectSettings.peerName ?? "angular-peer"); + + protected readonly activityLog = signal([]); + protected readonly activityLogText = computed(() => this.activityLog().join("\n")); + + protected readonly errorSummary = computed(() => { + const { cameraError, microphoneError } = this.state(); + const parts = [ + cameraError && `camera: ${cameraError.name}`, + microphoneError && `microphone: ${microphoneError.name}`, + ] + .filter(Boolean) + .join(", "); + return parts || "none"; + }); + + protected readonly activeCameraId = computed(() => { + const { activeDevice, selectedDevice } = this.state().camera; + return activeDevice?.deviceId ?? selectedDevice?.deviceId; + }); + + protected readonly activeMicrophoneId = computed(() => { + const { activeDevice, selectedDevice } = this.state().microphone; + return activeDevice?.deviceId ?? selectedDevice?.deviceId; + }); + + // The state exposes platform-contract track types; this app runs on the web, + // so they narrow back to their DOM implementations for rendering. + private readonly cameraTrack = computed(() => this.state().camera.track as MediaStreamTrack | null); + + protected readonly cameraPreviewStream = computed(() => { + const track = this.cameraTrack(); + return track ? new MediaStream([track]) : null; + }); + + protected readonly screenSharePreviewStream = computed( + () => (this.state().screenShare.stream as MediaStream | null) ?? null, + ); + + protected readonly remoteVideoTracks = computed(() => { + this.state(); // remote tracks are read from the client, so re-read them on every state change + return Object.values(this.fishjam.client.getRemoteTracks()).filter( + (context) => context.track?.kind === "video" && context.stream, + ); + }); + + protected connect(): void { + const fishjamId = this.fishjamId().trim(); + const sandboxUrl = this.sandboxUrl().trim(); + const roomName = this.roomName().trim() || "tsunami-room"; + const peerName = this.peerName().trim() || "angular-peer"; + + if (!fishjamId || !sandboxUrl) { + this.log("connect ✗ provide your Fishjam ID and Sandbox API URL first"); + return; + } + this.saveConnectSettings(); + + this.run("connect", async () => { + const peerToken = await getSandboxPeerToken(sandboxUrl, roomName, peerName); + await this.fishjam.client.connect({ + url: httpToWebsocketUrl(resolveFishjamUrl(fishjamId)), + token: peerToken, + peerMetadata: { displayName: peerName }, + }); + }); + } + + private saveConnectSettings(): void { + localStorage.setItem( + CONNECT_SETTINGS_KEY, + JSON.stringify({ + fishjamId: this.fishjamId(), + sandboxUrl: this.sandboxUrl(), + roomName: this.roomName(), + peerName: this.peerName(), + }), + ); + } + + protected disconnect(): void { + this.fishjam.client.disconnect(); + this.log("disconnect ✓"); + } + + protected initializeDevices(): void { + this.run("initializeDevices", () => this.fishjam.client.initializeDevices()); + } + + protected toggleCamera(): void { + this.run("toggleCamera", () => this.fishjam.client.toggleCamera()); + } + + protected toggleMicrophone(): void { + this.run("toggleMicrophone", () => this.fishjam.client.toggleMicrophone()); + } + + protected toggleMicrophoneMute(): void { + this.run("toggleMicrophoneMute", () => this.fishjam.client.toggleMicrophoneMute()); + } + + protected startScreenShare(): void { + this.run("startScreenShare", () => this.fishjam.client.startScreenShare()); + } + + protected stopScreenShare(): void { + this.run("stopScreenShare", () => this.fishjam.client.stopScreenShare()); + } + + protected selectCamera(event: Event): void { + const deviceId = (event.target as HTMLSelectElement).value; + this.run("selectCamera", () => this.fishjam.client.selectCamera(deviceId)); + } + + protected selectMicrophone(event: Event): void { + const deviceId = (event.target as HTMLSelectElement).value; + this.run("selectMicrophone", () => this.fishjam.client.selectMicrophone(deviceId)); + } + + private run(label: string, operation: () => Promise): void { + operation().then( + () => this.log(`${label} ✓`), + (error: unknown) => this.log(`${label} ✗ ${error instanceof Error ? error.message : String(error)}`), + ); + } + + private log(message: string): void { + const timestamp = new Date().toLocaleTimeString(); + this.activityLog.update((lines) => [`${timestamp} ${message}`, ...lines].slice(0, MAX_ACTIVITY_LOG_LINES)); + } +} diff --git a/examples/tsunami/angular/src/app/fishjam.service.ts b/examples/tsunami/angular/src/app/fishjam.service.ts new file mode 100644 index 000000000..a31151d6e --- /dev/null +++ b/examples/tsunami/angular/src/app/fishjam.service.ts @@ -0,0 +1,44 @@ +import { Injectable, type OnDestroy, signal, type Signal } from "@angular/core"; +import { + type ClientState, + FishjamClient, + LocalStorageDevicePersistence, + WebDeviceManager, +} from "@fishjam-cloud/tsunami"; + +const createDefaultClient = (): FishjamClient => + new FishjamClient({ + deviceManager: new WebDeviceManager({ persistence: new LocalStorageDevicePersistence() }), + }); + +/** + * Owns the application's single `FishjamClient` and bridges its observable + * store into an Angular signal. + * + * The whole bridge is the three constructor lines: seed a signal with the + * current snapshot and re-set it on every store notification. The store + * notifies synchronously and its snapshots are immutable (a new object per + * change), so `signal.set` sees a fresh reference exactly once per update and + * Angular's zoneless scheduler takes it from there. + */ +@Injectable({ + providedIn: "root", + useFactory: () => new FishjamService(createDefaultClient()), +}) +export class FishjamService implements OnDestroy { + /** Live `ClientState` snapshot; a new value lands synchronously on every store change. */ + public readonly state: Signal; + + private readonly unsubscribeFromStore: () => void; + + public constructor(public readonly client: FishjamClient) { + const clientState = signal(client.getState()); + this.state = clientState.asReadonly(); + this.unsubscribeFromStore = client.subscribe(() => clientState.set(client.getState())); + } + + public ngOnDestroy(): void { + this.unsubscribeFromStore(); + this.client.dispose(); + } +} diff --git a/examples/tsunami/angular/src/index.html b/examples/tsunami/angular/src/index.html new file mode 100644 index 000000000..7d119923b --- /dev/null +++ b/examples/tsunami/angular/src/index.html @@ -0,0 +1,11 @@ + + + + + Fishjam Tsunami — Angular example + + + + + + diff --git a/examples/tsunami/angular/src/main.ts b/examples/tsunami/angular/src/main.ts new file mode 100644 index 000000000..d3093d637 --- /dev/null +++ b/examples/tsunami/angular/src/main.ts @@ -0,0 +1,8 @@ +import { provideZonelessChangeDetection } from "@angular/core"; +import { bootstrapApplication } from "@angular/platform-browser"; + +import { AppComponent } from "./app/app.component"; + +bootstrapApplication(AppComponent, { + providers: [provideZonelessChangeDetection()], +}).catch((error: unknown) => console.error(error)); diff --git a/examples/tsunami/angular/src/styles.css b/examples/tsunami/angular/src/styles.css new file mode 100644 index 000000000..4e29e0bfb --- /dev/null +++ b/examples/tsunami/angular/src/styles.css @@ -0,0 +1,65 @@ +:root { + color-scheme: light dark; + font-family: system-ui, sans-serif; +} + +body { + margin: 0 auto; + max-width: 64rem; + padding: 1rem; +} + +section { + border: 1px solid color-mix(in srgb, currentColor 25%, transparent); + border-radius: 0.5rem; + padding: 0.75rem 1rem; + margin-block: 0.75rem; +} + +h1 { + font-size: 1.4rem; +} + +h2 { + font-size: 1rem; + margin-top: 0; +} + +button, +input, +select { + margin-inline-end: 0.5rem; +} + +.status-line { + font-size: 0.9rem; +} + +.preview-row { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +figure { + margin: 0; +} + +video { + width: 18rem; + aspect-ratio: 16 / 9; + background: #1c1c1e; + border-radius: 0.375rem; +} + +.remote-grid { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +pre { + font-size: 0.8rem; + white-space: pre-wrap; + margin: 0; +} diff --git a/examples/tsunami/angular/src/tests/camera.spec.ts b/examples/tsunami/angular/src/tests/camera.spec.ts new file mode 100644 index 000000000..8c2fbbd74 --- /dev/null +++ b/examples/tsunami/angular/src/tests/camera.spec.ts @@ -0,0 +1,83 @@ +import { createFakeStream } from "./support/fakeMediaStream"; +import { connectAndJoin, describe, expect, it } from "./support/fixtures"; + +const videoStream = () => createFakeStream([{ kind: "video", deviceId: "camera-1" }]); + +describe("camera", () => { + it("is off before the device is started", ({ service }) => { + expect(service.state().camera.track).toBeNull(); + expect(service.state().camera.stream).toBeNull(); + }); + + it("startCamera exposes the track in state without publishing when not connected", async ({ + media, + service, + signalling, + }) => { + media.setUserMediaStream(videoStream()); + + await service.client.startCamera(); + + expect(media.devices.getUserMedia).toHaveBeenCalledTimes(1); + expect(service.state().camera.track).not.toBeNull(); + expect(service.state().camera.stream).not.toBeNull(); + expect(signalling.addTrack).not.toHaveBeenCalled(); + }); + + it("stopCamera tears the track down in state", async ({ media, service }) => { + media.setUserMediaStream(videoStream()); + + await service.client.startCamera(); + await service.client.stopCamera(); + + expect(service.state().camera.track).toBeNull(); + expect(service.state().camera.stream).toBeNull(); + }); + + it("toggleCamera while connected publishes one video track with camera metadata", async ({ + media, + service, + signalling, + }) => { + media.setUserMediaStream(videoStream()); + await connectAndJoin(service, signalling); + + await service.client.toggleCamera(); + + expect(service.state().camera.track).not.toBeNull(); + expect(signalling.addTrack).toHaveBeenCalledTimes(1); + const [publishedTrack, publishedMetadata] = signalling.addTrack.mock.calls[0]; + expect(publishedTrack.kind).toBe("video"); + expect(publishedMetadata).toMatchObject({ type: "camera", paused: false }); + }); + + it("toggleCamera off pauses the published track and does not remove it", async ({ media, service, signalling }) => { + media.setUserMediaStream(videoStream()); + await connectAndJoin(service, signalling); + + await service.client.toggleCamera(); // on + publish + const publishedTrackId = await signalling.addTrack.mock.results[0].value; + + await service.client.toggleCamera(); // off + + expect(service.state().camera.track).toBeNull(); + expect(signalling.removeTrack).not.toHaveBeenCalled(); + expect(signalling.replaceTrack).toHaveBeenCalledWith(publishedTrackId, null); + expect(signalling.updateTrackMetadata).toHaveBeenCalledWith( + publishedTrackId, + expect.objectContaining({ type: "camera", paused: true }), + ); + }); + + it("auto-publishes an already running camera when the room is joined", async ({ media, service, signalling }) => { + media.setUserMediaStream(videoStream()); + + await service.client.startCamera(); + expect(signalling.addTrack).not.toHaveBeenCalled(); + + await connectAndJoin(service, signalling); + + expect(signalling.addTrack).toHaveBeenCalledTimes(1); + expect(signalling.addTrack.mock.calls[0][1]).toMatchObject({ type: "camera" }); + }); +}); diff --git a/examples/tsunami/angular/src/tests/connection.spec.ts b/examples/tsunami/angular/src/tests/connection.spec.ts new file mode 100644 index 000000000..87f1a18aa --- /dev/null +++ b/examples/tsunami/angular/src/tests/connection.spec.ts @@ -0,0 +1,81 @@ +import { connectAndJoin, describe, expect, it } from "./support/fixtures"; + +describe("connection state", () => { + it("starts idle", ({ service }) => { + expect(service.state().peerStatus).toBe("idle"); + expect(service.state().reconnectionStatus).toBe("idle"); + }); + + it("passes url, token and peer metadata to the signalling client", async ({ service, signalling }) => { + const connecting = service.client.connect({ + url: "wss://fishjam.example/socket", + token: "token-123", + peerMetadata: { displayName: "alice" }, + }); + signalling.simulateJoined(); + await connecting; + + expect(signalling.connect).toHaveBeenCalledTimes(1); + expect(signalling.connect.mock.calls[0][0]).toMatchObject({ + url: "wss://fishjam.example/socket", + token: "token-123", + peerMetadata: { displayName: "alice" }, + }); + }); + + it("mirrors idle → connecting → connected → idle into the state signal", async ({ service, signalling }) => { + await connectAndJoin(service, signalling); + expect(service.state().peerStatus).toBe("connected"); + + service.client.disconnect(); + expect(signalling.disconnect).toHaveBeenCalledTimes(1); + expect(service.state().peerStatus).toBe("idle"); + }); + + it("rejects connect and reports error state when the join fails", async ({ service, signalling }) => { + const connecting = service.client.connect({ url: "wss://fishjam.example/socket", token: "bad-token", peerMetadata: {} }); + signalling.simulateJoinError(); + + await expect(connecting).rejects.toThrow(); + expect(service.state().peerStatus).toBe("error"); + }); + + it("sets peerStatus to error on auth, join and connection errors", ({ service, signalling }) => { + // Each error source is handled independently; disconnect resets to idle + // between them so every one is proven to flip peerStatus to "error". + signalling.simulateAuthError(); + expect(service.state().peerStatus).toBe("error"); + + signalling.simulateDisconnected(); + expect(service.state().peerStatus).toBe("idle"); + + signalling.simulateJoinError(); + expect(service.state().peerStatus).toBe("error"); + + signalling.simulateDisconnected(); + signalling.simulateConnectionError(); + expect(service.state().peerStatus).toBe("error"); + }); + + it("tracks reconnectionStatus across the reconnect lifecycle", ({ service, signalling }) => { + signalling.simulateReconnectionStarted(); + expect(service.state().reconnectionStatus).toBe("reconnecting"); + + signalling.simulateReconnected(); + expect(service.state().reconnectionStatus).toBe("idle"); + expect(service.state().peerStatus).toBe("connected"); + + signalling.simulateReconnectionStarted(); + signalling.simulateReconnectionRetriesLimitReached(); + expect(service.state().reconnectionStatus).toBe("error"); + }); + + it("promotes a join error to a reconnection error only while reconnecting", ({ service, signalling }) => { + signalling.simulateJoinError(); + expect(service.state().reconnectionStatus).toBe("idle"); + + signalling.simulateReconnectionStarted(); + signalling.simulateJoinError(); + expect(service.state().reconnectionStatus).toBe("error"); + }); +}); diff --git a/examples/tsunami/angular/src/tests/initializeDevices.spec.ts b/examples/tsunami/angular/src/tests/initializeDevices.spec.ts new file mode 100644 index 000000000..b5b62dc93 --- /dev/null +++ b/examples/tsunami/angular/src/tests/initializeDevices.spec.ts @@ -0,0 +1,75 @@ +import { DeviceOverconstrainedError, DevicePermissionDeniedError } from "@fishjam-cloud/tsunami"; + +import { createFakeStream } from "./support/fakeMediaStream"; +import { describe, expect, it } from "./support/fixtures"; + +const fullStream = () => + createFakeStream([ + { kind: "video", deviceId: "camera-1" }, + { kind: "audio", deviceId: "microphone-1" }, + ]); + +const installedDevices = [ + { deviceId: "camera-1", kind: "videoinput" as const, label: "Camera 1" }, + { deviceId: "microphone-1", kind: "audioinput" as const, label: "Microphone 1" }, +]; + +describe("initializeDevices", () => { + it("acquires media and mirrors devices into state", async ({ media, service }) => { + media.setUserMediaStream(fullStream()); + media.setEnumeratedDevices(installedDevices); + + const result = await service.client.initializeDevices(); + + expect(result.status).toBe("initialized"); + expect(result.stream).not.toBeNull(); + expect(media.devices.getUserMedia).toHaveBeenCalledTimes(1); + + const state = service.state(); + expect(state.devicesInitialized).toBe(true); + expect(state.availableCameras).toMatchObject([{ deviceId: "camera-1", label: "Camera 1" }]); + expect(state.availableMicrophones).toMatchObject([{ deviceId: "microphone-1", label: "Microphone 1" }]); + expect(state.camera.track).not.toBeNull(); + expect(state.microphone.track).not.toBeNull(); + expect(state.cameraError).toBeNull(); + expect(state.microphoneError).toBeNull(); + }); + + it("deduplicates: a second call reports already_initialized without touching the platform again", async ({ + media, + service, + }) => { + media.setUserMediaStream(fullStream()); + media.setEnumeratedDevices(installedDevices); + + await service.client.initializeDevices(); + const secondResult = await service.client.initializeDevices(); + + expect(secondResult.status).toBe("already_initialized"); + expect(media.devices.getUserMedia).toHaveBeenCalledTimes(1); + expect(service.state().devicesInitialized).toBe(true); + }); + + it("surfaces OverconstrainedError in cameraError when selecting a nonexistent camera", async ({ media, service }) => { + media.setUserMediaStream(fullStream()); + media.setEnumeratedDevices(installedDevices); + await service.client.initializeDevices(); + + await service.client.selectCamera("nonexistent-camera"); + + const { cameraError } = service.state(); + expect(cameraError).toBeInstanceOf(DeviceOverconstrainedError); + expect(cameraError?.name).toBe("OverconstrainedError"); + }); + + it("surfaces NotAllowedError in both device error slots when permission is denied", async ({ media, service }) => { + media.failUserMediaAlways("NotAllowedError"); + + const result = await service.client.initializeDevices(); + + expect(result.status).toBe("failed"); + expect(service.state().cameraError).toBeInstanceOf(DevicePermissionDeniedError); + expect(service.state().cameraError?.name).toBe("NotAllowedError"); + expect(service.state().microphoneError?.name).toBe("NotAllowedError"); + }); +}); diff --git a/examples/tsunami/angular/src/tests/microphone.spec.ts b/examples/tsunami/angular/src/tests/microphone.spec.ts new file mode 100644 index 000000000..6d28d04a8 --- /dev/null +++ b/examples/tsunami/angular/src/tests/microphone.spec.ts @@ -0,0 +1,70 @@ +import { createFakeStream } from "./support/fakeMediaStream"; +import { connectAndJoin, describe, expect, it } from "./support/fixtures"; + +const audioStream = () => createFakeStream([{ kind: "audio", deviceId: "microphone-1" }]); + +describe("microphone", () => { + it("starts off and unmuted", ({ service }) => { + expect(service.state().microphone.track).toBeNull(); + expect(service.state().microphone.isEnabled).toBe(true); + }); + + it("toggleMicrophone while connected publishes one audio track with microphone metadata", async ({ + media, + service, + signalling, + }) => { + media.setUserMediaStream(audioStream()); + await connectAndJoin(service, signalling); + + await service.client.toggleMicrophone(); + + expect(service.state().microphone.track).not.toBeNull(); + expect(signalling.addTrack).toHaveBeenCalledTimes(1); + const [publishedTrack, publishedMetadata] = signalling.addTrack.mock.calls[0]; + expect(publishedTrack.kind).toBe("audio"); + expect(publishedMetadata).toMatchObject({ type: "microphone", paused: false }); + }); + + it("toggleMicrophoneMute flips isEnabled and pauses the published track via replaceTrack(null)", async ({ + media, + service, + signalling, + }) => { + media.setUserMediaStream(audioStream()); + await connectAndJoin(service, signalling); + await service.client.toggleMicrophone(); // device on + publish + const publishedTrackId = await signalling.addTrack.mock.results[0].value; + + await service.client.toggleMicrophoneMute(); + + expect(service.state().microphone.isEnabled).toBe(false); + // Soft mute: the device track stays in state, only media flow stops. + expect(service.state().microphone.track).not.toBeNull(); + expect(signalling.replaceTrack).toHaveBeenCalledWith(publishedTrackId, null); + expect(signalling.updateTrackMetadata).toHaveBeenLastCalledWith( + publishedTrackId, + expect.objectContaining({ type: "microphone", paused: true }), + ); + expect(signalling.removeTrack).not.toHaveBeenCalled(); + }); + + it("unmuting flips isEnabled back and resumes the published track", async ({ media, service, signalling }) => { + media.setUserMediaStream(audioStream()); + await connectAndJoin(service, signalling); + await service.client.toggleMicrophone(); + const publishedTrackId = await signalling.addTrack.mock.results[0].value; + + await service.client.toggleMicrophoneMute(); // mute + await service.client.toggleMicrophoneMute(); // unmute + + expect(service.state().microphone.isEnabled).toBe(true); + const lastReplaceCall = signalling.replaceTrack.mock.calls.at(-1); + expect(lastReplaceCall?.[0]).toBe(publishedTrackId); + expect(lastReplaceCall?.[1]).not.toBeNull(); + expect(signalling.updateTrackMetadata).toHaveBeenLastCalledWith( + publishedTrackId, + expect.objectContaining({ type: "microphone", paused: false }), + ); + }); +}); diff --git a/examples/tsunami/angular/src/tests/peers.spec.ts b/examples/tsunami/angular/src/tests/peers.spec.ts new file mode 100644 index 000000000..cba78b036 --- /dev/null +++ b/examples/tsunami/angular/src/tests/peers.spec.ts @@ -0,0 +1,49 @@ +import { connectAndJoin, describe, expect, it } from "./support/fixtures"; + +describe("participants state", () => { + it("starts with no local peer and no remote peers", ({ service }) => { + expect(service.state().localPeer).toBeNull(); + expect(service.state().remotePeers).toEqual({}); + }); + + it("exposes the local peer after joining", async ({ service, signalling }) => { + await connectAndJoin(service, signalling); + expect(service.state().localPeer?.id).toBe("local-peer"); + }); + + it("surfaces remote peers on peerJoined and removes them on peerLeft", async ({ service, signalling }) => { + await connectAndJoin(service, signalling); + + signalling.addRemotePeer({ id: "peer-1" }); + expect(Object.keys(service.state().remotePeers)).toEqual(["peer-1"]); + + signalling.addRemotePeer({ id: "peer-2" }); + expect(Object.keys(service.state().remotePeers)).toEqual(["peer-1", "peer-2"]); + + signalling.removeRemotePeer("peer-1"); + expect(Object.keys(service.state().remotePeers)).toEqual(["peer-2"]); + }); + + it("carries remote peer track metadata into state", async ({ service, signalling }) => { + await connectAndJoin(service, signalling); + + signalling.addRemotePeer({ + id: "peer-1", + tracks: [{ trackId: "remote-video", metadata: { type: "camera", paused: false } }], + }); + + const remotePeer = service.state().remotePeers["peer-1"]; + const trackContext = remotePeer?.tracks.get("remote-video"); + expect(trackContext?.metadata).toMatchObject({ type: "camera", paused: false }); + }); + + it("clears participants on disconnect", async ({ service, signalling }) => { + await connectAndJoin(service, signalling); + signalling.addRemotePeer({ id: "peer-1" }); + + signalling.simulateDisconnected(); + + expect(service.state().localPeer).toBeNull(); + expect(service.state().remotePeers).toEqual({}); + }); +}); diff --git a/examples/tsunami/angular/src/tests/screenShare.spec.ts b/examples/tsunami/angular/src/tests/screenShare.spec.ts new file mode 100644 index 000000000..32b8fbd41 --- /dev/null +++ b/examples/tsunami/angular/src/tests/screenShare.spec.ts @@ -0,0 +1,88 @@ +import type { TracksMiddleware } from "@fishjam-cloud/tsunami"; + +import { createFakeStream, createFakeTrack } from "./support/fakeMediaStream"; +import { connectAndJoin, describe, expect, it } from "./support/fixtures"; + +const displayStream = () => + createFakeStream([ + { kind: "video", deviceId: "screen-1" }, + { kind: "audio", deviceId: "tab-audio-1" }, + ]); + +describe("screen share", () => { + it("has no stream initially", ({ service }) => { + const { stream, videoTrack, audioTrack } = service.state().screenShare; + expect(stream).toBeNull(); + expect(videoTrack).toBeNull(); + expect(audioTrack).toBeNull(); + }); + + it("startScreenShare while connected publishes screen share video and audio tracks", async ({ + media, + service, + signalling, + }) => { + media.setDisplayMediaStream(displayStream()); + await connectAndJoin(service, signalling); + + await service.client.startScreenShare(); + + expect(media.devices.getDisplayMedia).toHaveBeenCalledTimes(1); + const screenShareState = service.state().screenShare; + expect(screenShareState.stream).not.toBeNull(); + expect(screenShareState.videoTrack).not.toBeNull(); + expect(screenShareState.audioTrack).not.toBeNull(); + + const publishedTypes = signalling.addTrack.mock.calls.map((call) => (call[1] as { type: string }).type); + expect(publishedTypes).toContain("screenShareVideo"); + expect(publishedTypes).toContain("screenShareAudio"); + }); + + it("keeps the capture local when not connected", async ({ media, service, signalling }) => { + media.setDisplayMediaStream(displayStream()); + + await service.client.startScreenShare(); + + expect(service.state().screenShare.stream).not.toBeNull(); + expect(signalling.addTrack).not.toHaveBeenCalled(); + }); + + it("stopScreenShare removes the published tracks and clears the state", async ({ media, service, signalling }) => { + media.setDisplayMediaStream(displayStream()); + await connectAndJoin(service, signalling); + await service.client.startScreenShare(); + + await service.client.stopScreenShare(); + + expect(signalling.removeTrack).toHaveBeenCalledTimes(2); + const screenShareState = service.state().screenShare; + expect(screenShareState.stream).toBeNull(); + expect(screenShareState.videoTrack).toBeNull(); + expect(screenShareState.audioTrack).toBeNull(); + }); + + it("setScreenShareTracksMiddleware persists in state and replaces the published tracks", async ({ + media, + service, + signalling, + }) => { + media.setDisplayMediaStream(displayStream()); + await connectAndJoin(service, signalling); + await service.client.startScreenShare(); + + const processedVideoTrack = createFakeTrack({ kind: "video", deviceId: "processed-video" }); + const middleware: TracksMiddleware = (videoTrack, audioTrack) => ({ + videoTrack: processedVideoTrack, + audioTrack, + onClear: () => {}, + }); + await service.client.setScreenShareTracksMiddleware(middleware); + + expect(service.state().screenShare.middleware).toBe(middleware); + expect(signalling.replaceTrack).toHaveBeenCalled(); + + // The middleware survives stopping the capture, ready for the next start. + await service.client.stopScreenShare(); + expect(service.state().screenShare.middleware).toBe(middleware); + }); +}); diff --git a/examples/tsunami/angular/src/tests/signalsBridge.spec.ts b/examples/tsunami/angular/src/tests/signalsBridge.spec.ts new file mode 100644 index 000000000..5748219bc --- /dev/null +++ b/examples/tsunami/angular/src/tests/signalsBridge.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "./support/fixtures"; + +/** + * The Angular-specific value under test: the store→signal bridge in + * FishjamService. The store notifies synchronously, so the signal must hold + * the new snapshot the moment any other store subscriber runs. + */ +describe("store → signal bridge", () => { + it("updates the signal synchronously on every store notification", ({ service, signalling }) => { + const statusesFromSlice: string[] = []; + const statusesFromSignal: string[] = []; + // Reading the signal inside another store subscription proves the bridge + // ran first and synchronously for the same notification. The slice + // subscription fires exactly once per peerStatus change. + service.client.subscribeToSlice( + (state) => state.peerStatus, + (nextStatus) => { + statusesFromSlice.push(nextStatus); + statusesFromSignal.push(service.state().peerStatus); + }, + ); + + signalling.simulateConnectionStarted(); + signalling.simulateJoined(); + signalling.simulateDisconnected(); + + expect(statusesFromSlice).toEqual(["connecting", "connected", "idle"]); + expect(statusesFromSignal).toEqual(statusesFromSlice); + }); + + it("keeps the signal value referentially stable until the state changes", ({ service, signalling }) => { + const snapshotBefore = service.state(); + expect(service.state()).toBe(snapshotBefore); + + signalling.simulateConnectionStarted(); + + const snapshotAfter = service.state(); + expect(snapshotAfter).not.toBe(snapshotBefore); + // Untouched slices are structurally shared, so slice-level change + // detection (e.g. `computed`) can rely on reference equality. + expect(snapshotAfter.camera).toBe(snapshotBefore.camera); + expect(snapshotAfter.remotePeers).toBe(snapshotBefore.remotePeers); + }); + + it("stops updating after the service is destroyed", ({ service, signalling }) => { + service.ngOnDestroy(); + + signalling.simulateConnectionStarted(); + + expect(service.state().peerStatus).toBe("idle"); + expect(service.client.isDisposed).toBe(true); + }); +}); diff --git a/examples/tsunami/angular/src/tests/support/fakeMediaDevices.ts b/examples/tsunami/angular/src/tests/support/fakeMediaDevices.ts new file mode 100644 index 000000000..5a88e3393 --- /dev/null +++ b/examples/tsunami/angular/src/tests/support/fakeMediaDevices.ts @@ -0,0 +1,183 @@ +import { vi } from "vitest"; + +import { createFakeTrack, FakeMediaStream } from "./fakeMediaStream"; + +/** + * Controllable fake of the browser media layer, patched over + * `navigator.mediaDevices` and the global `MediaStream`. The real + * `WebDeviceManager` runs against it, so device acquisition, enumeration and + * error classification are exercised end to end. Modeled on the react-client + * test kit. + */ +export type FakeMediaDevices = { + getUserMedia: ReturnType; + getDisplayMedia: ReturnType; + enumerateDevices: ReturnType; + addEventListener: ReturnType; + removeEventListener: ReturnType; +}; + +export type MediaDevicesController = { + devices: FakeMediaDevices; + /** + * Declare the tracks the "installed" devices produce. Each getUserMedia call + * hands out FRESH live tracks cut down to the requested kinds (an audio-only + * request never receives video), so a stop→restart sequence gets live tracks + * again instead of the previous call's ended ones. + */ + setUserMediaStream: (stream: MediaStream) => void; + /** Make every getUserMedia reject with an error of the given name. */ + failUserMediaAlways: (errorName: string) => void; + setDisplayMediaStream: (stream: MediaStream) => void; + setEnumeratedDevices: (devices: Partial[]) => void; + restore: () => void; +}; + +const makeError = (name: string) => { + const error = new Error(name); + error.name = name; + return error; +}; + +// Real browsers report which constraint could not be satisfied. +const makeOverconstrainedError = () => { + const error = makeError("OverconstrainedError") as Error & { constraint: string }; + error.constraint = "deviceId"; + return error; +}; + +type TrackKind = "audio" | "video"; + +/** A device the fake has "installed", derived from the staged template tracks. */ +type InstalledDevice = { kind: TrackKind; deviceId: string; label: string }; + +/** `deviceId: { exact }` is mandatory; a plain string or `{ ideal }` is a preference. */ +const requestedDeviceId = (constraint: MediaTrackConstraints): { id: string; mandatory: boolean } | null => { + const deviceId = constraint.deviceId; + if (typeof deviceId === "string") return { id: deviceId, mandatory: false }; + if (deviceId && typeof deviceId === "object" && !Array.isArray(deviceId)) { + if (typeof deviceId.exact === "string") return { id: deviceId.exact, mandatory: true }; + if (typeof deviceId.ideal === "string") return { id: deviceId.ideal, mandatory: false }; + } + return null; +}; + +/** + * Resolve one kind's constraint against the installed devices, mirroring real + * getUserMedia semantics: unmatched `exact` deviceId → OverconstrainedError, + * no device of the kind at all → NotFoundError, otherwise a fresh live track. + */ +const resolveTrack = (installed: InstalledDevice[], kind: TrackKind, constraint: MediaTrackConstraints | boolean) => { + const candidates = installed.filter((device) => device.kind === kind); + const requested = typeof constraint === "object" ? requestedDeviceId(constraint) : null; + + let device: InstalledDevice | undefined; + if (requested?.mandatory) { + device = candidates.find((candidate) => candidate.deviceId === requested.id); + if (!device) throw makeOverconstrainedError(); + } else { + device = (requested && candidates.find((candidate) => candidate.deviceId === requested.id)) || candidates[0]; + if (!device) throw makeError("NotFoundError"); + } + + return createFakeTrack({ kind, deviceId: device.deviceId, label: device.label }); +}; + +export const installFakeMediaDevices = (): MediaDevicesController => { + let userMediaTemplate: MediaStream = new FakeMediaStream(); + let userMediaError: string | null = null; + let displayMediaStreamFactory: () => MediaStream = () => new FakeMediaStream(); + let enumerated: MediaDeviceInfo[] = []; + + // Installed devices = the staged template tracks, plus enumerated devices + // not already covered by them (so a test can select any enumerated device). + const installedDevices = (): InstalledDevice[] => { + const fromTemplate = userMediaTemplate.getTracks().map( + (track): InstalledDevice => ({ + kind: track.kind as TrackKind, + deviceId: track.getSettings().deviceId ?? "", + label: track.label, + }), + ); + const fromEnumerated = enumerated + .filter((device) => device.kind === "videoinput" || device.kind === "audioinput") + .map( + (device): InstalledDevice => ({ + kind: device.kind === "videoinput" ? "video" : "audio", + deviceId: device.deviceId, + label: device.label, + }), + ) + .filter( + (device) => + !fromTemplate.some((template) => template.kind === device.kind && template.deviceId === device.deviceId), + ); + return [...fromTemplate, ...fromEnumerated]; + }; + + const getUserMedia = vi.fn(async (constraints: MediaStreamConstraints = {}) => { + if (userMediaError) throw makeError(userMediaError); + + const installed = installedDevices(); + const tracks = (["audio", "video"] as const) + .filter((kind) => Boolean(constraints[kind])) + .map((kind) => resolveTrack(installed, kind, constraints[kind]!)); + return new FakeMediaStream(tracks); + }); + + const getDisplayMedia = vi.fn(async () => displayMediaStreamFactory()); + + const enumerateDevices = vi.fn(async () => enumerated); + + const devices: FakeMediaDevices = { + getUserMedia, + getDisplayMedia, + enumerateDevices, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + + // jsdom provides `navigator` but not `navigator.mediaDevices` / `MediaStream`. + const originalMediaDevices = Object.getOwnPropertyDescriptor(navigator, "mediaDevices"); + Object.defineProperty(navigator, "mediaDevices", { value: devices, configurable: true, writable: true }); + + const originalMediaStream = (globalThis as { MediaStream?: unknown }).MediaStream; + (globalThis as { MediaStream?: unknown }).MediaStream = FakeMediaStream; + + return { + devices, + setUserMediaStream: (stream) => { + userMediaError = null; + userMediaTemplate = stream; + }, + failUserMediaAlways: (errorName) => { + userMediaError = errorName; + }, + setDisplayMediaStream: (stream) => { + displayMediaStreamFactory = () => stream; + }, + setEnumeratedDevices: (list) => { + enumerated = list.map((device) => { + const info = { + deviceId: device.deviceId ?? "", + kind: device.kind ?? "videoinput", + label: device.label ?? "", + groupId: device.groupId ?? "", + }; + return { ...info, toJSON: () => info } as MediaDeviceInfo; + }); + }, + restore: () => { + // jsdom ships no `navigator.mediaDevices`, so `originalMediaDevices` is + // usually undefined — in that case the fake must be DELETED, not left in + // place, or the next install() would capture this test's fake as its + // "original" and re-install stale fakes on later restores. + if (originalMediaDevices) { + Object.defineProperty(navigator, "mediaDevices", originalMediaDevices); + } else { + delete (navigator as { mediaDevices?: unknown }).mediaDevices; + } + (globalThis as { MediaStream?: unknown }).MediaStream = originalMediaStream; + }, + }; +}; diff --git a/examples/tsunami/angular/src/tests/support/fakeMediaStream.ts b/examples/tsunami/angular/src/tests/support/fakeMediaStream.ts new file mode 100644 index 000000000..35eeeeea2 --- /dev/null +++ b/examples/tsunami/angular/src/tests/support/fakeMediaStream.ts @@ -0,0 +1,86 @@ +import { FakeMediaStreamTrack } from "fake-mediastreamtrack"; + +let streamCounter = 0; +let trackCounter = 0; + +/** + * Minimal `MediaStream` stand-in for jsdom (which ships none). Only the + * surface the SDK touches is implemented: id, the get*Tracks accessors and + * add/removeTrack. Modeled on the react-client test kit so both SDKs are + * exercised by equivalent doubles. + */ +export class FakeMediaStream implements MediaStream { + readonly id = `fake-stream-${streamCounter++}`; + active = true; + onaddtrack = null; + onremovetrack = null; + onactive = null; + oninactive = null; + + private tracks: MediaStreamTrack[]; + + constructor(tracks: MediaStreamTrack[] = []) { + this.tracks = [...tracks]; + } + + getTracks(): MediaStreamTrack[] { + return [...this.tracks]; + } + + getVideoTracks(): MediaStreamTrack[] { + return this.tracks.filter((track) => track.kind === "video"); + } + + getAudioTracks(): MediaStreamTrack[] { + return this.tracks.filter((track) => track.kind === "audio"); + } + + getTrackById(id: string): MediaStreamTrack | null { + return this.tracks.find((track) => track.id === id) ?? null; + } + + addTrack(track: MediaStreamTrack): void { + if (!this.tracks.includes(track)) this.tracks.push(track); + } + + removeTrack(track: MediaStreamTrack): void { + this.tracks = this.tracks.filter((candidate) => candidate !== track); + } + + clone(): MediaStream { + // Real MediaStream.clone() clones the tracks too (new ids). + return new FakeMediaStream(this.tracks.map((track) => track.clone())); + } + + // EventTarget surface — unused by the SDK, present for type-compatibility. + addEventListener(): void {} + removeEventListener(): void {} + dispatchEvent(): boolean { + return true; + } +} + +export type FakeTrackOptions = { + kind: "audio" | "video"; + deviceId?: string; + label?: string; +}; + +/** + * Create a fake track whose `getSettings().deviceId` reflects the device it + * was acquired from — the device-manager logic keys off exactly this. + */ +export const createFakeTrack = ({ + kind, + deviceId = `${kind}-device-default`, + label = `${kind} track`, +}: FakeTrackOptions): FakeMediaStreamTrack => + new FakeMediaStreamTrack({ + kind, + id: `${kind}-track-${trackCounter++}`, + label, + settings: { deviceId }, + }); + +export const createFakeStream = (tracks: FakeTrackOptions[]): FakeMediaStream => + new FakeMediaStream(tracks.map(createFakeTrack)); diff --git a/examples/tsunami/angular/src/tests/support/fakeSignallingClient.ts b/examples/tsunami/angular/src/tests/support/fakeSignallingClient.ts new file mode 100644 index 000000000..9daae5dd7 --- /dev/null +++ b/examples/tsunami/angular/src/tests/support/fakeSignallingClient.ts @@ -0,0 +1,250 @@ +import type { + ConnectConfig, + FishjamClientConfig, + GenericMetadata, + Peer, + SimulcastConfig, + TrackBandwidthLimit, + TrackMetadata, +} from "@fishjam-cloud/tsunami"; +import { EventEmitter } from "events"; +import { vi } from "vitest"; + +import { FakeMediaStream } from "./fakeMediaStream"; + +/** + * The signalling client type tsunami accepts through its injection seam. + * Deriving it from the config keeps this file importing only from tsunami. + */ +export type SignallingClient = NonNullable; + +/** + * The exact subset of the signalling client tsunami calls. Deriving it with + * `Pick` is the point: if any of these is renamed, removed or re-signed on the + * real client, the `asClient()` guard below stops compiling, flagging that the + * fake has drifted. `on`/`off`/`emit` are intentionally NOT included — the + * fake's EventEmitter base is deliberately permissive so tests can drive + * events. + */ +type SignallingClientContract = Pick< + SignallingClient, + | "status" + | "connect" + | "disconnect" + | "cleanup" + | "addTrack" + | "replaceTrack" + | "removeTrack" + | "updateTrackMetadata" + | "getLocalPeer" + | "getRemotePeers" + | "getRemoteComponents" + | "getRemoteTracks" + | "isReconnecting" +>; + +/** In-memory track context mirroring the slice of `FishjamTrackContext` the SDK reads. */ +export class FakeTrackContext extends EventEmitter { + public constructor( + public trackId: string, + public track: MediaStreamTrack | null, + public metadata: TrackMetadata | undefined, + public stream: MediaStream | null, + ) { + super(); + } +} + +export type FakePeerInit = { + id: string; + metadata?: Peer["metadata"]; + tracks?: { trackId: string; metadata: TrackMetadata; track?: MediaStreamTrack | null }[]; +}; + +const buildPeer = (init: FakePeerInit): Peer => { + const tracks = new Map(); + for (const trackInit of init.tracks ?? []) { + tracks.set( + trackInit.trackId, + new FakeTrackContext( + trackInit.trackId, + trackInit.track ?? null, + trackInit.metadata, + new FakeMediaStream(trackInit.track ? [trackInit.track] : []), + ), + ); + } + return { + id: init.id, + type: "webrtc", + metadata: init.metadata, + // FakeTrackContext implements only the read surface of FishjamTrackContext; + // this cast erases the unimplemented remainder. + tracks: tracks as unknown as Peer["tracks"], + } satisfies Peer; +}; + +/** + * Behavioral double for the ts-client signalling layer, injected through + * tsunami's `signallingClient` config seam. Implements only the surface + * tsunami uses, with spies on every mutating method and `simulate*` helpers to + * drive the event-based state machine deterministically. Modeled on the + * react-client `FakeFishjamClient`. + */ +export class FakeSignallingClient extends EventEmitter { + // Mirrors the real client: starts `"new"` and only becomes `"initialized"` + // inside connect(). Publishing paths gated on `status === "initialized"` + // (e.g. screen share) therefore behave as they do in production. + public status: "new" | "initialized" = "new"; + + public localPeer: Peer | null = null; + public remotePeers: Record = {}; + + private trackIdCounter = 0; + private reconnecting = false; + + // ---- spies (assert call args / counts) ------------------------------- + + // Faithful to the real connect(): emits `connectionStarted`, flips status to + // `initialized`, and only resolves once `joined` fires (rejects on + // join/auth/socket errors). A test that awaits connect() must drive + // `simulateJoined()` for the await to settle. + public connect = vi.fn((_config: ConnectConfig) => { + this.emit("connectionStarted"); + this.status = "initialized"; + return new Promise((resolve, reject) => { + const errorEvents = ["joinError", "authError", "socketError"] as const; + const onSuccess = () => { + cleanupListeners(); + resolve(); + }; + const errorHandlers = errorEvents.map((event) => { + const handler = () => { + cleanupListeners(); + reject(new Error(`FakeSignallingClient: "${event}" emitted while connect() was pending`)); + }; + return [event, handler] as const; + }); + const cleanupListeners = () => { + this.off("joined", onSuccess); + for (const [event, handler] of errorHandlers) this.off(event, handler); + }; + this.on("joined", onSuccess); + for (const [event, handler] of errorHandlers) this.on(event, handler); + }); + }); + + public disconnect = vi.fn(() => { + this.simulateDisconnected(); + }); + + public cleanup = vi.fn(); + + public addTrack = vi.fn( + ( + track: MediaStreamTrack, + metadata?: TrackMetadata, + _simulcastConfig?: SimulcastConfig, + _maxBandwidth?: TrackBandwidthLimit, + ): Promise => { + const remoteTrackId = `remote-${this.trackIdCounter++}`; + if (!this.localPeer) this.localPeer = buildPeer({ id: "local-peer" }); + (this.localPeer.tracks as unknown as Map).set( + remoteTrackId, + new FakeTrackContext(remoteTrackId, track, metadata, new FakeMediaStream([track])), + ); + this.emit("localTrackAdded"); + return Promise.resolve(remoteTrackId); + }, + ); + + public replaceTrack = vi.fn(async (trackId: string, newTrack: MediaStreamTrack | null) => { + const context = (this.localPeer?.tracks as unknown as Map | undefined)?.get(trackId); + if (context) context.track = newTrack; + this.emit("localTrackReplaced", { trackId, track: newTrack }); + }); + + public removeTrack = vi.fn(async (trackId: string) => { + (this.localPeer?.tracks as unknown as Map | undefined)?.delete(trackId); + this.emit("localTrackRemoved", { trackId }); + }); + + public updateTrackMetadata = vi.fn((trackId: string, metadata: TrackMetadata) => { + const context = (this.localPeer?.tracks as unknown as Map | undefined)?.get(trackId); + if (context) context.metadata = metadata; + this.emit("localTrackMetadataChanged", { trackId, metadata }); + }); + + // ---- read methods ---------------------------------------------------- + + public getLocalPeer = () => this.localPeer; + public getRemotePeers = () => this.remotePeers; + public getRemoteComponents = () => ({}); + public getRemoteTracks = () => ({}); + public isReconnecting = () => this.reconnecting; + + public asClient(): SignallingClient { + // Drift tripwire: `this` must satisfy the surface tsunami uses (see + // SignallingClientContract). The final `as unknown` only erases the unused + // remainder of the (large, partly-private) ts-client surface. + return this satisfies SignallingClientContract as unknown as SignallingClient; + } + + // ---- test controls --------------------------------------------------- + + public addRemotePeer(init: FakePeerInit): void { + this.remotePeers[init.id] = buildPeer(init); + this.emit("peerJoined", this.remotePeers[init.id]); + } + + public removeRemotePeer(peerId: string): void { + const peer = this.remotePeers[peerId]; + delete this.remotePeers[peerId]; + this.emit("peerLeft", peer); + } + + public simulateConnectionStarted(): void { + this.emit("connectionStarted"); + } + + public simulateJoined(): void { + // You cannot be joined without having connected, so status must already be + // `initialized` here (connect() sets it; this covers tests that jump + // straight to the joined state without awaiting connect()). + this.status = "initialized"; + if (!this.localPeer) this.localPeer = buildPeer({ id: "local-peer" }); + this.emit("joined"); + } + + public simulateReconnectionStarted(): void { + this.reconnecting = true; + this.emit("reconnectionStarted"); + } + + public simulateReconnected(): void { + this.reconnecting = false; + this.emit("reconnected"); + } + + public simulateReconnectionRetriesLimitReached(): void { + this.emit("reconnectionRetriesLimitReached"); + } + + public simulateAuthError(): void { + this.emit("authError"); + } + + public simulateJoinError(): void { + this.emit("joinError"); + } + + public simulateConnectionError(): void { + this.emit("connectionError"); + } + + public simulateDisconnected(): void { + this.localPeer = null; + this.remotePeers = {}; + this.emit("disconnected"); + } +} diff --git a/examples/tsunami/angular/src/tests/support/fixtures.ts b/examples/tsunami/angular/src/tests/support/fixtures.ts new file mode 100644 index 000000000..9eb9ad39d --- /dev/null +++ b/examples/tsunami/angular/src/tests/support/fixtures.ts @@ -0,0 +1,61 @@ +import { FishjamClient, WebDeviceManager } from "@fishjam-cloud/tsunami"; +import { test as base } from "vitest"; + +import { FishjamService } from "../../app/fishjam.service"; +import { installFakeMediaDevices, type MediaDevicesController } from "./fakeMediaDevices"; +import { FakeSignallingClient } from "./fakeSignallingClient"; + +interface Fixtures { + /** + * The fake browser media layer (navigator.mediaDevices + global + * MediaStream). Declared `auto` so it is installed and torn down for EVERY + * test using this `it`, whether or not the test destructures it. + */ + media: MediaDevicesController; + /** A fresh fake signalling client, shared with the `service` fixture. */ + signalling: FakeSignallingClient; + /** + * A `FishjamService` owning a real tsunami `FishjamClient` wired to the + * `signalling` fake and a real `WebDeviceManager` (which runs against the + * `media` fake). Torn down through `ngOnDestroy`, like Angular would. + */ + service: FishjamService; +} + +// `provide` is vitest's fixture-injection callback (positionally the 2nd arg). +export const it = base.extend({ + media: [ + // eslint-disable-next-line no-empty-pattern + async ({}, provide) => { + const controller = installFakeMediaDevices(); + await provide(controller); + controller.restore(); + }, + { auto: true }, + ], + // eslint-disable-next-line no-empty-pattern + signalling: async ({}, provide) => { + await provide(new FakeSignallingClient()); + }, + // Depends on `media` so the fake platform is installed before the client + // (and its device manager) come up. + service: async ({ media: _media, signalling }, provide) => { + const service = new FishjamService( + new FishjamClient({ + signallingClient: signalling.asClient(), + deviceManager: new WebDeviceManager(), + }), + ); + await provide(service); + service.ngOnDestroy(); + }, +}); + +/** Connects the client through the fake and settles on the joined state. */ +export const connectAndJoin = async (service: FishjamService, signalling: FakeSignallingClient): Promise => { + const connecting = service.client.connect({ url: "wss://fishjam.example/socket", token: "test-token", peerMetadata: {} }); + signalling.simulateJoined(); + await connecting; +}; + +export { describe, expect, vi } from "vitest"; diff --git a/examples/tsunami/angular/tsconfig.app.json b/examples/tsunami/angular/tsconfig.app.json new file mode 100644 index 000000000..672627daf --- /dev/null +++ b/examples/tsunami/angular/tsconfig.app.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/main.ts", "src/app/**/*.ts"] +} diff --git a/examples/tsunami/angular/tsconfig.json b/examples/tsunami/angular/tsconfig.json new file mode 100644 index 000000000..43062b8ec --- /dev/null +++ b/examples/tsunami/angular/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "noEmit": true, + // Building against tsunami SOURCES (see "paths") puts the SDK files into + // this program, so opt-in flags stricter than tsunami's own build + // (noImplicitOverride, noImplicitReturns) must stay off here. + "strict": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "importHelpers": true, + "module": "preserve", + "moduleResolution": "bundler", + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "paths": { + "@fishjam-cloud/tsunami": ["../../../packages/tsunami/src/index.ts"] + } + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "include": ["src/**/*.ts"] +} diff --git a/examples/tsunami/angular/vitest.config.ts b/examples/tsunami/angular/vitest.config.ts new file mode 100644 index 000000000..62f74d41e --- /dev/null +++ b/examples/tsunami/angular/vitest.config.ts @@ -0,0 +1,19 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +const configDirectory = dirname(fileURLToPath(import.meta.url)); + +// The behavioral suite runs against tsunami sources directly, matching the +// application build, so no package build step is needed while iterating. +export default defineConfig({ + resolve: { + alias: { + "@fishjam-cloud/tsunami": resolve(configDirectory, "../../../packages/tsunami/src/index.ts"), + }, + }, + test: { + environment: "jsdom", + include: ["src/tests/**/*.spec.ts"], + }, +}); diff --git a/examples/tsunami/vanilla-ts/index.html b/examples/tsunami/vanilla-ts/index.html new file mode 100644 index 000000000..ba86c1c36 --- /dev/null +++ b/examples/tsunami/vanilla-ts/index.html @@ -0,0 +1,265 @@ + + + + + + Tsunami — vanilla JS demo (no React) + + + +
+

Tsunami

+ framework-agnostic FishjamClient · getState() / subscribe() · zero React on this page +
+
+
+
+

Camera preview

+ +
+ + + + +
+
+ + +
+
+
+

Screen share preview

+ +
+ + +
+
+
+

Room (optional)

+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+

Remote peers

+
+
+
remote video tracks
+
0
+
+
+
+

Client state — client.getState()

+
+
peerStatus
+
+
devicesInitialized
+
+
camera
+
+
microphone
+
+
screen share
+
+
cameras
+
+
microphones
+
+
remote peers
+
+
errors
+
+
+
+
+

Store updates & structural sharing

+
+
store notifications
+
0
+
slices changed by last update
+
+
+
+
+

Event log

+

+        
+
+
+ + + diff --git a/examples/tsunami/vanilla-ts/package.json b/examples/tsunami/vanilla-ts/package.json new file mode 100644 index 000000000..5344c26ca --- /dev/null +++ b/examples/tsunami/vanilla-ts/package.json @@ -0,0 +1,19 @@ +{ + "name": "@fishjam-example/tsunami-vanilla-example", + "private": true, + "version": "0.0.0", + "type": "module", + "license": "Apache-2.0", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@fishjam-cloud/tsunami": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.8.3", + "vite": "^6.2.6" + } +} diff --git a/examples/tsunami/vanilla-ts/src/main.ts b/examples/tsunami/vanilla-ts/src/main.ts new file mode 100644 index 000000000..5cf6e517f --- /dev/null +++ b/examples/tsunami/vanilla-ts/src/main.ts @@ -0,0 +1,193 @@ +import { + type ClientState, + type DeviceItem, + FishjamClient, + LocalStorageDevicePersistence, + WebDeviceManager, +} from "@fishjam-cloud/tsunami"; + +const client = new FishjamClient({ + deviceManager: new WebDeviceManager({ persistence: new LocalStorageDevicePersistence() }), +}); + +const element = (id: string): T => { + const found = document.getElementById(id); + if (!found) throw new Error(`Missing element #${id}`); + return found as T; +}; + +const cameraPreview = element("camera-preview"); +const screensharePreview = element("screenshare-preview"); +const cameraSelect = element("camera-select"); +const microphoneSelect = element("mic-select"); +const eventLog = element("event-log"); + +const logLines: string[] = []; +const log = (message: string) => { + const timestamp = new Date().toLocaleTimeString(); + logLines.unshift(`${timestamp} ${message}`); + eventLog.textContent = logLines.slice(0, 12).join("\n"); +}; + +const run = (label: string, operation: () => Promise) => { + operation().then( + () => log(`${label} ✓`), + (error) => log(`${label} ✗ ${error instanceof Error ? error.message : String(error)}`), + ); +}; + +element("init-devices").onclick = () => run("initializeDevices", () => client.initializeDevices()); +element("toggle-camera").onclick = () => run("toggleCamera", () => client.toggleCamera()); +element("toggle-mic").onclick = () => run("toggleMicrophone", () => client.toggleMicrophone()); +element("mute-mic").onclick = () => run("toggleMicrophoneMute", () => client.toggleMicrophoneMute()); +element("start-screenshare").onclick = () => run("startScreenShare", () => client.startScreenShare()); +element("stop-screenshare").onclick = () => run("stopScreenShare", () => client.stopScreenShare()); + +cameraSelect.onchange = () => run(`selectCamera(${cameraSelect.value.slice(0, 8)}…)`, () => client.selectCamera(cameraSelect.value)); +microphoneSelect.onchange = () => + run(`selectMicrophone(${microphoneSelect.value.slice(0, 8)}…)`, () => client.selectMicrophone(microphoneSelect.value)); + +element("connect").onclick = () => { + const url = element("connect-url").value.trim(); + const token = element("connect-token").value.trim(); + if (!url || !token) { + log("connect ✗ paste a Fishjam URL and peer token first"); + return; + } + run("connect", () => client.connect({ url, token, peerMetadata: { displayName: "tsunami-vanilla" } })); +}; +element("disconnect").onclick = () => { + client.disconnect(); + log("disconnect ✓"); +}; + +const deviceLabel = (device: DeviceItem | null) => device?.label || device?.deviceId.slice(0, 12) || "—"; + +const renderDeviceOptions = (select: HTMLSelectElement, devices: DeviceItem[], active: DeviceItem | null) => { + select.replaceChildren( + ...devices.map((device) => { + const option = document.createElement("option"); + option.value = device.deviceId; + option.textContent = device.label || device.deviceId; + option.selected = device.deviceId === active?.deviceId; + return option; + }), + ); +}; + +type State = ClientState; + +let notificationCount = 0; +let previousState: State | null = null; + +const sliceNames = [ + "peerStatus", + "reconnectionStatus", + "localPeer", + "remotePeers", + "components", + "camera", + "microphone", + "screenShare", + "customSources", + "availableCameras", + "availableMicrophones", + "cameraError", + "microphoneError", + "devicesInitialized", +] as const; + +const changedSlices = (previous: State | null, next: State): string[] => + previous ? sliceNames.filter((name) => previous[name] !== next[name]) : [...sliceNames]; + +const render = () => { + const state = client.getState(); + + element("stat-peer-status").textContent = state.peerStatus; + element("stat-devices-initialized").textContent = String(state.devicesInitialized); + + const cameraPill = state.camera.track ? (state.camera.isEnabled ? "on" : "muted") : "off"; + element("stat-camera").innerHTML = + `${cameraPill} ${deviceLabel(state.camera.activeDevice ?? state.camera.selectedDevice)}`; + + const microphonePill = state.microphone.track ? (state.microphone.isEnabled ? "on" : "muted") : "off"; + element("stat-microphone").innerHTML = + `${microphonePill} ${deviceLabel(state.microphone.activeDevice ?? state.microphone.selectedDevice)}`; + + element("stat-screenshare").innerHTML = + `${state.screenShare.stream ? "sharing" : "off"}`; + + element("stat-available-cameras").textContent = String(state.availableCameras.length); + element("stat-available-microphones").textContent = String(state.availableMicrophones.length); + element("stat-remote-peers").textContent = String(Object.keys(state.remotePeers).length); + + const errors = [state.cameraError && `camera: ${state.cameraError.name}`, state.microphoneError && `mic: ${state.microphoneError.name}`] + .filter(Boolean) + .join(", "); + element("stat-errors").innerHTML = errors ? `${errors}` : "none"; + + renderDeviceOptions(cameraSelect, state.availableCameras, state.camera.activeDevice ?? state.camera.selectedDevice); + renderDeviceOptions( + microphoneSelect, + state.availableMicrophones, + state.microphone.activeDevice ?? state.microphone.selectedDevice, + ); + + // This demo runs on the web, so the platform-contract types narrow back to + // their DOM implementations. + const cameraTrack = (state.camera.track as MediaStreamTrack | null) ?? null; + const cameraStream = cameraTrack ? new MediaStream([cameraTrack]) : null; + const currentCameraTrack = (cameraPreview.srcObject as MediaStream | null)?.getVideoTracks()[0] ?? null; + if (currentCameraTrack !== cameraTrack) { + cameraPreview.srcObject = cameraStream; + } + + const screenShareStream = (state.screenShare.stream as MediaStream | null) ?? null; + if (screensharePreview.srcObject !== screenShareStream) { + screensharePreview.srcObject = screenShareStream; + } + + renderRemoteTracks(); + + element("stat-render-count").textContent = String(notificationCount); + element("changed-slices").textContent = changedSlices(previousState, state).join(", ") || "(no slice changed)"; + previousState = state; +}; + +const remoteGrid = element("remote-grid"); +const remoteVideos = new Map(); + +const renderRemoteTracks = () => { + const remoteTracks = Object.values(client.getRemoteTracks()).filter( + (context) => context.track?.kind === "video" && context.stream, + ); + + for (const [trackId, video] of remoteVideos) { + if (!remoteTracks.some((context) => context.trackId === trackId)) { + video.remove(); + remoteVideos.delete(trackId); + } + } + + for (const context of remoteTracks) { + let video = remoteVideos.get(context.trackId); + if (!video) { + video = document.createElement("video"); + video.autoplay = true; + video.playsInline = true; + remoteGrid.append(video); + remoteVideos.set(context.trackId, video); + } + if (video.srcObject !== context.stream) video.srcObject = context.stream; + } + + element("stat-remote-tracks").textContent = String(remoteTracks.length); +}; + +client.subscribe(() => { + notificationCount += 1; + render(); +}); + +render(); +log("client created — state is live before any connection"); diff --git a/examples/tsunami/vanilla-ts/tsconfig.json b/examples/tsunami/vanilla-ts/tsconfig.json new file mode 100644 index 000000000..a0f293a72 --- /dev/null +++ b/examples/tsunami/vanilla-ts/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true + }, + "include": ["src"] +} diff --git a/examples/tsunami/vanilla-ts/vite.config.ts b/examples/tsunami/vanilla-ts/vite.config.ts new file mode 100644 index 000000000..7cfbeae11 --- /dev/null +++ b/examples/tsunami/vanilla-ts/vite.config.ts @@ -0,0 +1,16 @@ +import { resolve } from "node:path"; +import { defineConfig } from "vite"; + +// The example runs against tsunami sources directly so no package build step +// is needed while iterating. +export default defineConfig({ + server: { + host: true, + port: 3010, + }, + resolve: { + alias: { + "@fishjam-cloud/tsunami": resolve(__dirname, "../../../packages/tsunami/src/index.ts"), + }, + }, +}); diff --git a/package.json b/package.json index dc061eceb..2999af788 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "packages/react-native-webrtc", "examples/react-client/*", "examples/mobile-client/*", + "examples/tsunami/*", "e2e-tests/webrtc-client", "e2e-tests/react-client", "e2e-tests/livestream-client" diff --git a/packages/mobile-client/README.md b/packages/mobile-client/README.md index 45254077e..cb46c9851 100644 --- a/packages/mobile-client/README.md +++ b/packages/mobile-client/README.md @@ -10,6 +10,14 @@ npm install @fishjam-cloud/react-native-client yarn add @fishjam-cloud/react-native-client ``` +## Browser globals (breaking change) + +The SDK no longer installs the browser-compatibility globals it used to register at import time. Only three WebRTC engine classes remain global: `RTCPeerConnection`, `RTCIceCandidate`, and `MediaStream`. + +Removed: `navigator.mediaDevices` (with `getUserMedia`/`getDisplayMedia`/`enumerateDevices`), `localStorage`, `EventTarget`, `MediaStreamTrack`, `MediaStreamTrackEvent`, `RTCSessionDescription`, `RTCCertificate`, `RTCErrorEvent`, `RTCRtpSender`, `RTCRtpReceiver`, `RTCRtpTransceiver`. + +If your app used any of these globals directly, use the SDK hooks instead, or import what you need from `@fishjam-cloud/react-native-webrtc` (e.g. `import { mediaDevices } from '@fishjam-cloud/react-native-webrtc'`). + ## Local Development with WebRTC Fork This package depends on `@fishjam-cloud/react-native-webrtc`, a fork of `react-native-webrtc`. The fork lives in [its own GitHub repo](https://github.com/fishjam-cloud/fishjam-react-native-webrtc) and is included in this monorepo as a git submodule at `packages/react-native-webrtc/`, wired up as a yarn workspace. No manual linking is required. diff --git a/packages/mobile-client/src/FishjamProvider.test.ts b/packages/mobile-client/src/FishjamProvider.test.ts new file mode 100644 index 000000000..da3af1d37 --- /dev/null +++ b/packages/mobile-client/src/FishjamProvider.test.ts @@ -0,0 +1,44 @@ +import { FishjamProvider as ReactClientFishjamProvider } from '@fishjam-cloud/react-client'; +import { describe, expect, it, vi } from 'vitest'; + +import { ReactNativeDeviceManager } from './devices/ReactNativeDeviceManager'; +import { FishjamProvider } from './FishjamProvider'; + +const nativeMediaDevices = vi.hoisted(() => ({ + enumerateDevices: vi.fn(), + getDisplayMedia: vi.fn(), + getUserMedia: vi.fn(), +})); + +vi.mock('@fishjam-cloud/react-native-webrtc', () => ({ + mediaDevices: nativeMediaDevices, + permissions: { query: vi.fn() }, + MediaStream: class {}, +})); + +type RenderedProviderProps = { + clientType?: string; + deviceManager?: unknown; + fishjamId?: string; +}; + +const renderedProps = (element: ReturnType): RenderedProviderProps => + element.props as RenderedProviderProps; + +describe('FishjamProvider (mobile)', () => { + it('always runs on the native device manager with the mobile client type', () => { + const element = FishjamProvider({ fishjamId: 'test-fishjam-id' }); + + expect(element.type).toBe(ReactClientFishjamProvider); + expect(renderedProps(element).clientType).toBe('mobile'); + expect(renderedProps(element).deviceManager).toBeInstanceOf(ReactNativeDeviceManager); + expect(renderedProps(element).fishjamId).toBe('test-fishjam-id'); + }); + + it('reuses one device manager across provider instances', () => { + const first = FishjamProvider({ fishjamId: 'a' }); + const second = FishjamProvider({ fishjamId: 'b' }); + + expect(renderedProps(first).deviceManager).toBe(renderedProps(second).deviceManager); + }); +}); diff --git a/packages/mobile-client/src/FishjamProvider.ts b/packages/mobile-client/src/FishjamProvider.ts new file mode 100644 index 000000000..556c0444e --- /dev/null +++ b/packages/mobile-client/src/FishjamProvider.ts @@ -0,0 +1,24 @@ +import { + FishjamProvider as ReactClientFishjamProvider, + type FishjamProviderProps as ReactClientFishjamProviderProps, +} from '@fishjam-cloud/react-client'; +import React from 'react'; + +import { ReactNativeDeviceManager } from './devices/ReactNativeDeviceManager'; + +// The native device manager owns persistence (in-memory for the app session), +// so persistLastDevice does not apply on mobile. +const deviceManager = new ReactNativeDeviceManager(); + +export type FishjamProviderProps = Omit< + ReactClientFishjamProviderProps, + 'persistLastDevice' | 'fishjamClient' | 'deviceManager' | 'clientType' +>; + +export function FishjamProvider(props: FishjamProviderProps) { + return React.createElement(ReactClientFishjamProvider, { + ...props, + clientType: 'mobile', + deviceManager, + }); +} diff --git a/packages/mobile-client/src/devices/ReactNativeDeviceManager.test.ts b/packages/mobile-client/src/devices/ReactNativeDeviceManager.test.ts index 353fe1ac4..8794b7c4c 100644 --- a/packages/mobile-client/src/devices/ReactNativeDeviceManager.test.ts +++ b/packages/mobile-client/src/devices/ReactNativeDeviceManager.test.ts @@ -1,23 +1,46 @@ import type { MediaStream as ReactNativeMediaStream } from '@fishjam-cloud/react-native-webrtc'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { InMemoryDevicePersistence } from './InMemoryDevicePersistence'; import { ReactNativeDeviceManager } from './ReactNativeDeviceManager'; const nativeMediaDevices = vi.hoisted(() => ({ - addEventListener: vi.fn(), enumerateDevices: vi.fn(), getDisplayMedia: vi.fn(), getUserMedia: vi.fn(), - removeEventListener: vi.fn(), })); -vi.mock('@fishjam-cloud/react-native-webrtc', () => ({ mediaDevices: nativeMediaDevices })); +const nativePermissions = vi.hoisted(() => ({ + query: vi.fn(), +})); + +const FakeNativeMediaStream = vi.hoisted( + () => + class FakeNativeMediaStream { + constructor(public readonly tracks: unknown[]) {} + getTracks() { + return this.tracks; + } + }, +); + +vi.mock('@fishjam-cloud/react-native-webrtc', () => ({ + mediaDevices: nativeMediaDevices, + permissions: nativePermissions, + MediaStream: FakeNativeMediaStream, +})); + +beforeEach(() => { + nativePermissions.query.mockResolvedValue('granted'); +}); afterEach(() => { vi.clearAllMocks(); }); +// react-native-webrtc's MediaStreamError shape: a plain object, NOT an Error. +const nativeError = (name: string) => ({ name, message: name }); + describe('ReactNativeDeviceManager', () => { it('has inert construction', () => { const persistence = { @@ -30,7 +53,6 @@ describe('ReactNativeDeviceManager', () => { expect(nativeMediaDevices.enumerateDevices).not.toHaveBeenCalled(); expect(nativeMediaDevices.getUserMedia).not.toHaveBeenCalled(); expect(nativeMediaDevices.getDisplayMedia).not.toHaveBeenCalled(); - expect(nativeMediaDevices.addEventListener).not.toHaveBeenCalled(); }); it('shares session-scoped persistence between manager instances by default', () => { @@ -66,29 +88,75 @@ describe('ReactNativeDeviceManager', () => { expect(nativeMediaDevices.getUserMedia).toHaveBeenCalledWith(userConstraints); }); - it('uses native display-media defaults instead of forwarding incompatible browser options', async () => { + it("classifies the native SecurityError (a non-Error object) as permission denial", async () => { + nativeMediaDevices.getUserMedia.mockRejectedValue(nativeError('SecurityError')); + const manager = new ReactNativeDeviceManager(); + + await expect(manager.getUserMedia({ video: true })).rejects.toMatchObject({ name: 'NotAllowedError' }); + }); + + it('classifies other native rejections through the shared name mapping', async () => { + nativeMediaDevices.getUserMedia.mockRejectedValue(nativeError('OverconstrainedError')); + const manager = new ReactNativeDeviceManager(); + + await expect(manager.getUserMedia({ video: true })).rejects.toMatchObject({ name: 'OverconstrainedError' }); + + nativeMediaDevices.getUserMedia.mockRejectedValue('total garbage'); + await expect(manager.getUserMedia({ video: true })).rejects.toMatchObject({ name: 'UNHANDLED_ERROR' }); + }); + + it('warns when acquiring media without granted permissions, but still acquires', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + nativePermissions.query.mockResolvedValue('denied'); + nativeMediaDevices.getUserMedia.mockResolvedValue({} as ReactNativeMediaStream); + const manager = new ReactNativeDeviceManager(); + + await manager.getUserMedia({ video: true, audio: true }); + + expect(warn).toHaveBeenCalledWith('Attempting to access camera with permission status: "denied".'); + expect(warn).toHaveBeenCalledWith('Attempting to access microphone with permission status: "denied".'); + expect(nativeMediaDevices.getUserMedia).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('queries permissions only for the requested kinds', async () => { + nativeMediaDevices.getUserMedia.mockResolvedValue({} as ReactNativeMediaStream); + const manager = new ReactNativeDeviceManager(); + + await manager.getUserMedia({ audio: true }); + + expect(nativePermissions.query).toHaveBeenCalledTimes(1); + expect(nativePermissions.query).toHaveBeenCalledWith({ name: 'microphone' }); + }); + + it('forwards constructor display-media options to the native call', async () => { const displayStream = {} as ReactNativeMediaStream; nativeMediaDevices.getDisplayMedia.mockResolvedValue(displayStream); - const manager = new ReactNativeDeviceManager(); + const displayMediaOptions = { android: { resolutionScale: 0.5 } }; + const manager = new ReactNativeDeviceManager({ displayMediaOptions }); await expect(manager.getDisplayMedia({ video: true })).resolves.toBe(displayStream); - expect(nativeMediaDevices.getDisplayMedia).toHaveBeenCalledWith(); + expect(nativeMediaDevices.getDisplayMedia).toHaveBeenCalledWith(displayMediaOptions); + }); + + it('wraps tracks in a native stream', () => { + const manager = new ReactNativeDeviceManager(); + const track = { id: 'track-1' }; + + const stream = manager.createMediaStream([track as never]); + + expect(stream).toBeInstanceOf(FakeNativeMediaStream); + expect(stream.getTracks()).toEqual([track]); }); - it('removes a device-change listener exactly once', () => { - const callback = vi.fn(); + it('returns an inert cleanup from onDeviceChange without touching the native module', () => { const manager = new ReactNativeDeviceManager(); - const cleanup = manager.onDeviceChange(callback); - const listener = nativeMediaDevices.addEventListener.mock.calls[0][1] as () => void; - listener(); + const cleanup = manager.onDeviceChange(vi.fn()); cleanup(); cleanup(); - expect(callback).toHaveBeenCalledOnce(); - expect(nativeMediaDevices.addEventListener).toHaveBeenCalledWith('devicechange', listener); - expect(nativeMediaDevices.removeEventListener).toHaveBeenCalledOnce(); - expect(nativeMediaDevices.removeEventListener).toHaveBeenCalledWith('devicechange', listener); + expect(nativeMediaDevices.getUserMedia).not.toHaveBeenCalled(); }); }); diff --git a/packages/mobile-client/src/devices/ReactNativeDeviceManager.ts b/packages/mobile-client/src/devices/ReactNativeDeviceManager.ts index 2b8c084a1..d405281bd 100644 --- a/packages/mobile-client/src/devices/ReactNativeDeviceManager.ts +++ b/packages/mobile-client/src/devices/ReactNativeDeviceManager.ts @@ -1,15 +1,33 @@ -import { mediaDevices, type MediaStream as ReactNativeMediaStream } from '@fishjam-cloud/react-native-webrtc'; -import type { DeviceItem, DeviceType, IDeviceManager, IDevicePersistence } from '@fishjam-cloud/tsunami'; +import { + mediaDevices, + MediaStream as ReactNativeMediaStream, + type MediaStreamTrack as ReactNativeMediaStreamTrack, + permissions, +} from '@fishjam-cloud/react-native-webrtc'; +import { + classifyDeviceError, + type DeviceError, + DevicePermissionDeniedError, + type DeviceItem, + type DeviceType, + type IDeviceManager, + type IDevicePersistence, + type PlatformMediaStreamTrack, +} from '@fishjam-cloud/tsunami'; import { InMemoryDevicePersistence } from './InMemoryDevicePersistence'; -export type ReactNativeDeviceManagerOptions = { - persistence?: IDevicePersistence; +export type ReactNativeDisplayMediaOptions = { + android?: { + createConfigForDefaultDisplay?: boolean; + resolutionScale?: number; + }; }; -type NativeMediaDevices = typeof mediaDevices & { - addEventListener(type: 'devicechange', listener: () => void): void; - removeEventListener(type: 'devicechange', listener: () => void): void; +export type ReactNativeDeviceManagerOptions = { + persistence?: IDevicePersistence; + /** Forwarded to react-native-webrtc's getDisplayMedia (Android screen-capture tuning). */ + displayMediaOptions?: ReactNativeDisplayMediaOptions; }; type NativeDeviceInfo = { @@ -25,20 +43,46 @@ const inputDeviceKinds: Partial> = { const defaultPersistence = new InMemoryDevicePersistence(); -// TODO: FCE-3689 Fix react-native-webrtc's bundled EventTarget declarations and remove this workaround. -// The runtime object extends EventTarget, but react-native-webrtc's bundled -// declaration does not currently expose the inherited listener methods. -const nativeMediaDevices = mediaDevices as NativeMediaDevices; +// react-native-webrtc rejects with MediaStreamError, which does not extend +// Error and reports permission denial as "SecurityError" — classify by the +// name field before deferring to the shared web-name mapping. +const classifyNativeDeviceError = (error: unknown): DeviceError => { + const name = typeof error === 'object' && error !== null && 'name' in error ? String(error.name) : ''; + if (name === 'SecurityError' || name === 'NotAllowedError') { + return new DevicePermissionDeniedError({ cause: error }); + } + return classifyDeviceError(error instanceof Error ? error : Object.assign(new Error(name), { name })); +}; + +const warnWhenPermissionMissing = async (constraints: MediaStreamConstraints): Promise => { + try { + const [cameraStatus, microphoneStatus] = await Promise.all([ + constraints.video ? permissions.query({ name: 'camera' }) : null, + constraints.audio ? permissions.query({ name: 'microphone' }) : null, + ]); + + if (cameraStatus && cameraStatus !== 'granted') { + console.warn(`Attempting to access camera with permission status: "${cameraStatus}".`); + } + if (microphoneStatus && microphoneStatus !== 'granted') { + console.warn(`Attempting to access microphone with permission status: "${microphoneStatus}".`); + } + } catch (error) { + console.warn('Failed to check permissions before getUserMedia', error); + } +}; export class ReactNativeDeviceManager implements IDeviceManager { public readonly persistence: IDevicePersistence; + private readonly displayMediaOptions?: ReactNativeDisplayMediaOptions; - public constructor({ persistence = defaultPersistence }: ReactNativeDeviceManagerOptions = {}) { + public constructor({ persistence = defaultPersistence, displayMediaOptions }: ReactNativeDeviceManagerOptions = {}) { this.persistence = persistence; + this.displayMediaOptions = displayMediaOptions; } public async enumerateDevices(): Promise { - const devices = (await nativeMediaDevices.enumerateDevices()) as NativeDeviceInfo[]; + const devices = (await mediaDevices.enumerateDevices()) as NativeDeviceInfo[]; return devices.flatMap((device) => { const kind = inputDeviceKinds[device.kind]; @@ -49,25 +93,33 @@ export class ReactNativeDeviceManager implements IDeviceManager { - const nativeConstraints = constraints as Parameters[0]; - return nativeMediaDevices.getUserMedia(nativeConstraints); + await warnWhenPermissionMissing(constraints); + + const nativeConstraints = constraints as Parameters[0]; + try { + return await mediaDevices.getUserMedia(nativeConstraints); + } catch (error) { + throw classifyNativeDeviceError(error); + } } + // The DOM options shape is disjoint from react-native-webrtc's constraint + // shape, so it is ignored; screen-capture tuning comes from the constructor. public async getDisplayMedia(_options?: DisplayMediaStreamOptions): Promise { - return nativeMediaDevices.getDisplayMedia(); + try { + return await mediaDevices.getDisplayMedia(this.displayMediaOptions); + } catch (error) { + throw classifyNativeDeviceError(error); + } } - // react-native-webrtc does not currently emit devicechange; this method exists to satisfy IDeviceManager. - public onDeviceChange(callback: () => void): () => void { - const listener = () => callback(); - let subscribed = true; - - nativeMediaDevices.addEventListener('devicechange', listener); + public createMediaStream(tracks: PlatformMediaStreamTrack[]): ReactNativeMediaStream { + return new ReactNativeMediaStream(tracks as ReactNativeMediaStreamTrack[]); + } - return () => { - if (!subscribed) return; - subscribed = false; - nativeMediaDevices.removeEventListener('devicechange', listener); - }; + // react-native-webrtc never emits devicechange, so there is nothing to + // subscribe to; device lists refresh on explicit operations instead. + public onDeviceChange(_callback: () => void): () => void { + return () => {}; } } diff --git a/packages/mobile-client/src/globals.test.ts b/packages/mobile-client/src/globals.test.ts new file mode 100644 index 000000000..e50292fa5 --- /dev/null +++ b/packages/mobile-client/src/globals.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const ForkRTCPeerConnection = vi.hoisted( + () => + class ForkRTCPeerConnection { + setConfiguration() {} + }, +); +const ForkRTCIceCandidate = vi.hoisted(() => class ForkRTCIceCandidate {}); +const ForkMediaStream = vi.hoisted(() => class ForkMediaStream {}); + +vi.mock('@fishjam-cloud/react-native-webrtc', () => ({ + RTCPeerConnection: ForkRTCPeerConnection, + RTCIceCandidate: ForkRTCIceCandidate, + MediaStream: ForkMediaStream, +})); +vi.mock('react-native', () => ({ NativeModules: { WebRTCModule: {} } })); +vi.mock('fast-text-encoding', () => ({})); +vi.mock('react-native-get-random-values', () => ({})); +vi.mock('react-native-url-polyfill/auto', () => ({})); + +const removedGlobals = [ + 'MediaStreamTrack', + 'RTCSessionDescription', + 'RTCCertificate', + 'RTCErrorEvent', + 'MediaStreamTrackEvent', + 'RTCRtpTransceiver', + 'RTCRtpSender', + 'RTCRtpReceiver', +] as const; + +const globalRecord = globalThis as Record; + +describe('globals', () => { + afterEach(() => { + for (const name of ['RTCPeerConnection', 'RTCIceCandidate', 'MediaStream']) { + delete globalRecord[name]; + } + vi.resetModules(); + }); + + it('installs exactly the three engine globals, nothing else', async () => { + await import('./globals'); + + // RTCPeerConnection is the SDK subclass (getConfiguration cache), built on the fork class. + const InstalledPeerConnection = globalRecord.RTCPeerConnection as new (config: object) => { + getConfiguration(): object; + }; + expect(Object.getPrototypeOf(InstalledPeerConnection)).toBe(ForkRTCPeerConnection); + const connection = new InstalledPeerConnection({ iceServers: [] }); + expect(connection.getConfiguration()).toEqual({ iceServers: [] }); + + expect(globalRecord.RTCIceCandidate).toBe(ForkRTCIceCandidate); + expect(globalRecord.MediaStream).toBe(ForkMediaStream); + + for (const name of removedGlobals) { + expect(globalRecord[name], `${name} should not be installed`).toBeUndefined(); + } + expect(globalRecord.localStorage, 'localStorage polyfill should be gone').toBeUndefined(); + expect(globalThis.navigator?.mediaDevices, 'navigator.mediaDevices fake should be gone').toBeUndefined(); + }); +}); diff --git a/packages/mobile-client/src/webrtc-polyfill.ts b/packages/mobile-client/src/globals.ts similarity index 66% rename from packages/mobile-client/src/webrtc-polyfill.ts rename to packages/mobile-client/src/globals.ts index 52ae62522..d605baace 100644 --- a/packages/mobile-client/src/webrtc-polyfill.ts +++ b/packages/mobile-client/src/globals.ts @@ -2,21 +2,10 @@ import 'fast-text-encoding'; import 'react-native-get-random-values'; import 'react-native-url-polyfill/auto'; -import { EventTarget, registerGlobals } from '@fishjam-cloud/react-native-webrtc'; +import { MediaStream, RTCIceCandidate } from '@fishjam-cloud/react-native-webrtc'; import { NativeModules } from 'react-native'; -import { patchGetUserMediaWithPermissionWarnings } from './overrides/getUserMedia'; import { RTCPeerConnection } from './overrides/RTCPeerConnection'; -import { LocalStoragePolyfill } from './polyfills/local-storage'; - -const registerGlobalsPolyfill = () => { - (global as unknown as { EventTarget: typeof EventTarget }).EventTarget = EventTarget; - (global as unknown as { localStorage: typeof localStorage }).localStorage = new LocalStoragePolyfill(); - registerGlobals(); - // Custom overrides - (globalThis.RTCPeerConnection as unknown as typeof RTCPeerConnection) = RTCPeerConnection; - patchGetUserMediaWithPermissionWarnings(); -}; const assertReactNativeWebRTCNativeModule = () => { if (NativeModules.WebRTCModule) return; @@ -48,8 +37,22 @@ const assertGetRandomValuesPolyfill = () => { } }; +/** + * The SDK's connection core resolves exactly three WebRTC classes from + * globals; everything else reaches react-native-webrtc through direct imports + * (device acquisition goes through ReactNativeDeviceManager). Consumers: + * - RTCPeerConnection: webrtc-client ConnectionManager, ts-client livestream + * - RTCIceCandidate: webrtc-client webRTCEndpoint + * - MediaStream: ts-client FishjamClient, webrtc-client webRTCEndpoint + */ +const installWebRtcGlobals = () => { + globalThis.RTCPeerConnection = RTCPeerConnection as unknown as typeof globalThis.RTCPeerConnection; + globalThis.RTCIceCandidate = RTCIceCandidate as unknown as typeof globalThis.RTCIceCandidate; + globalThis.MediaStream = MediaStream as unknown as typeof globalThis.MediaStream; +}; + if (__DEV__) { assertReactNativeWebRTCNativeModule(); assertGetRandomValuesPolyfill(); } -registerGlobalsPolyfill(); +installWebRtcGlobals(); diff --git a/packages/mobile-client/src/index.ts b/packages/mobile-client/src/index.ts index 45ac37aa1..9e5c02d7b 100644 --- a/packages/mobile-client/src/index.ts +++ b/packages/mobile-client/src/index.ts @@ -8,13 +8,7 @@ /* eslint-disable simple-import-sort/exports */ /* eslint-disable import/first */ // TODO: FCE-2464 Investigate order -import './webrtc-polyfill'; -import React from 'react'; -import { - FishjamProvider as ReactClientFishjamProvider, - type FishjamProviderProps as ReactClientFishjamProviderProps, -} from '@fishjam-cloud/react-client'; -import { FishjamClient } from '@fishjam-cloud/ts-client'; +import './globals'; export { RTCView, RTCPIPView, type RTCVideoViewProps, type RTCPIPViewProps } from './overrides/RTCView'; export { @@ -39,7 +33,13 @@ export type { } from '@fishjam-cloud/react-native-webrtc'; export { useForegroundService, type ForegroundServiceConfig } from './useForegroundService'; -export { ReactNativeDeviceManager, type ReactNativeDeviceManagerOptions } from './devices/ReactNativeDeviceManager'; +export { InMemoryDevicePersistence } from './devices/InMemoryDevicePersistence'; +export { + ReactNativeDeviceManager, + type ReactNativeDeviceManagerOptions, + type ReactNativeDisplayMediaOptions, +} from './devices/ReactNativeDeviceManager'; +export type { IDevicePersistence } from '@fishjam-cloud/tsunami'; export { useCameraPermissions, useMicrophonePermissions, type PermissionStatus } from './hooks/usePermissions'; export { useCustomAudioSource, @@ -123,13 +123,4 @@ export type { TrackBandwidthLimit, } from '@fishjam-cloud/react-client'; -// persistLastDevice is not supported on mobile -export type FishjamProviderProps = Omit; -export function FishjamProvider(props: FishjamProviderProps) { - const fishjamClient = new FishjamClient({ reconnect: props.reconnect, debug: props.debug, clientType: 'mobile' }); - return React.createElement(ReactClientFishjamProvider, { - ...props, - persistLastDevice: false, - fishjamClient, - }); -} +export { FishjamProvider, type FishjamProviderProps } from './FishjamProvider'; diff --git a/packages/mobile-client/src/overrides/getUserMedia.ts b/packages/mobile-client/src/overrides/getUserMedia.ts deleted file mode 100644 index 0662d4af2..000000000 --- a/packages/mobile-client/src/overrides/getUserMedia.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { permissions } from '@fishjam-cloud/react-native-webrtc'; - -export const patchGetUserMediaWithPermissionWarnings = () => { - const original = globalThis.navigator?.mediaDevices?.getUserMedia; - if (!original) return; - - const boundOriginal = original.bind(globalThis.navigator.mediaDevices); - - globalThis.navigator.mediaDevices.getUserMedia = async (constraints?: MediaStreamConstraints) => { - try { - const [cameraStatus, micStatus] = await Promise.all([ - constraints?.video ? permissions.query({ name: 'camera' }) : null, - constraints?.audio ? permissions.query({ name: 'microphone' }) : null, - ]); - - if (cameraStatus && cameraStatus !== 'granted') { - console.warn(`Attempting to access camera with permission status: "${cameraStatus}".`); - } - if (micStatus && micStatus !== 'granted') { - console.warn(`Attempting to access microphone with permission status: "${micStatus}".`); - } - } catch (error) { - console.warn('Failed to check permissions before getUserMedia', error); - } - - return boundOriginal(constraints); - }; -}; diff --git a/packages/mobile-client/src/polyfills/local-storage.ts b/packages/mobile-client/src/polyfills/local-storage.ts deleted file mode 100644 index 6324eff07..000000000 --- a/packages/mobile-client/src/polyfills/local-storage.ts +++ /dev/null @@ -1,30 +0,0 @@ -// LocalStorage polyfill for mobile. -// Device persistence is not supported on mobile, so an in-memory implementation is used instead of actual persistent storage. -export class LocalStoragePolyfill { - private storage: Map = new Map(); - - getItem(key: string): string | null { - return this.storage.get(key) ?? null; - } - - setItem(key: string, value: string): void { - this.storage.set(key, String(value)); - } - - removeItem(key: string): void { - this.storage.delete(key); - } - - clear(): void { - this.storage.clear(); - } - - get length(): number { - return this.storage.size; - } - - key(index: number): string | null { - const keys = Array.from(this.storage.keys()); - return keys[index] ?? null; - } -} diff --git a/packages/mobile-client/vitest.config.ts b/packages/mobile-client/vitest.config.ts new file mode 100644 index 000000000..a835474aa --- /dev/null +++ b/packages/mobile-client/vitest.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + // React Native defines __DEV__ at runtime; tests run the production branch. + define: { __DEV__: 'false' }, +}); diff --git a/packages/react-client/package.json b/packages/react-client/package.json index 54c336634..cae1e7180 100644 --- a/packages/react-client/package.json +++ b/packages/react-client/package.json @@ -66,6 +66,7 @@ }, "dependencies": { "@fishjam-cloud/ts-client": "workspace:*", + "@fishjam-cloud/tsunami": "workspace:*", "events": "3.3.0", "lodash.isequal": "4.5.0" }, diff --git a/packages/react-client/src/FishjamProvider.tsx b/packages/react-client/src/FishjamProvider.tsx index f5559afe9..0add2fdf0 100644 --- a/packages/react-client/src/FishjamProvider.tsx +++ b/packages/react-client/src/FishjamProvider.tsx @@ -1,5 +1,20 @@ -import { FishjamClient, getLogger, type ReconnectConfig } from "@fishjam-cloud/ts-client"; -import { type PropsWithChildren, useMemo, useRef } from "react"; +import type { ClientType, FishjamClient, ReconnectConfig } from "@fishjam-cloud/ts-client"; +import { + type DeviceError as CoreDeviceError, + type DeviceItem, + FishjamClient as TsunamiClient, + type IDeviceManager, + type IDevicePersistence, + type InitializeDevicesResult as CoreInitializeDevicesResult, + type LocalDeviceState, + type PlatformMediaStream, + type PlatformMediaStreamTrack, + type TrackDeviceController, + type TrackMiddleware as CoreTrackMiddleware, + type TracksMiddleware as CoreTracksMiddleware, + WebDeviceManager, +} from "@fishjam-cloud/tsunami"; +import { type PropsWithChildren, useCallback, useMemo, useRef, useSyncExternalStore } from "react"; import { CameraContext } from "./contexts/camera"; import { CustomSourceContext } from "./contexts/customSource"; @@ -10,15 +25,24 @@ import { InitDevicesContext } from "./contexts/initDevices"; import { MicrophoneContext } from "./contexts/microphone"; import { PeerStatusContext } from "./contexts/peerStatus"; import { ScreenshareContext } from "./contexts/screenshare"; -import { VIDEO_TRACK_CONSTRAINTS } from "./devices/constraints"; -import { useMediaDevices } from "./hooks/internal/devices/useMediaDevices"; -import { useCustomSourceManager } from "./hooks/internal/useCustomSourceManager"; import { useFishjamClientState } from "./hooks/internal/useFishjamClientState"; import { usePeerStatus } from "./hooks/internal/usePeerStatus"; -import { useScreenShareManager } from "./hooks/internal/useScreenshareManager"; -import { useTrackManager } from "./hooks/internal/useTrackManager"; -import type { BandwidthLimits, PersistLastDeviceHandlers, StreamConfig } from "./types/public"; -import { mergeWithDefaultBandwitdthLimits } from "./utils/bandwidth"; +import type { + CustomSourceManager, + CustomSourceState, + DeviceManager, + TrackManager, + UseScreenshareResult, +} from "./types/internal"; +import type { + BandwidthLimits, + DeviceError, + InitializeDevicesResult, + PersistLastDeviceHandlers, + StreamConfig, + TrackMiddleware, + TracksMiddleware, +} from "./types/public"; import { getLastDevice, saveLastDevice } from "./utils/localStorage"; /** @@ -65,83 +89,213 @@ export interface FishjamProviderProps extends PropsWithChildren { * Allows to provide your own FishjamClient instance from ts-client. */ fishjamClient?: FishjamClient; + /** + * Advanced: platform device layer used for media acquisition. Defaults to + * the browser device manager wired to `persistLastDevice`. When provided, + * the manager owns persistence and `persistLastDevice` is ignored. Read + * once on first render. + */ + deviceManager?: IDeviceManager; + /** + * Platform reported to Fishjam. Defaults to `"web"`. Read once on first + * render. + */ + clientType?: ClientType; } +const asLegacyDeviceError = (error: CoreDeviceError | null): DeviceError | null => + error === null ? null : { name: error.name }; + +const asDomTrack = (track: PlatformMediaStreamTrack | null): MediaStreamTrack | null => + track as MediaStreamTrack | null; + +const asDomStream = (stream: PlatformMediaStream | null): MediaStream | null => stream as MediaStream | null; + +const asStartDeviceResult = async ( + result: Promise<[PlatformMediaStreamTrack, null] | [null, CoreDeviceError]>, +): Promise<[MediaStreamTrack, null] | [null, DeviceError]> => { + const [track, error] = await result; + if (error) return [null, { name: error.name }]; + return [track as MediaStreamTrack, null]; +}; + +const asSelectDeviceResult = async (result: Promise): Promise => { + const error = await result; + return error && { name: error.name }; +}; + +const toDevicePersistence = (handlers: PersistLastDeviceHandlers): IDevicePersistence => ({ + getLastDevice: (deviceType) => { + const device = handlers.getLastDevice(deviceType); + return device && { deviceId: device.deviceId, label: device.label, kind: deviceType }; + }, + saveLastDevice: (deviceType, device) => + handlers.saveLastDevice({ deviceId: device.deviceId, label: device.label } as MediaDeviceInfo, deviceType), +}); + +const createWebDeviceManager = (persistLastDevice: FishjamProviderProps["persistLastDevice"]): WebDeviceManager => { + const persistHandlers = + persistLastDevice === false + ? undefined + : typeof persistLastDevice === "object" + ? persistLastDevice + : { getLastDevice, saveLastDevice }; + + return new WebDeviceManager({ persistence: persistHandlers && toDevicePersistence(persistHandlers) }); +}; + /** - * Provides the Fishjam Context + * Provides the Fishjam Context. + * + * Device, track, and session logic lives in `@fishjam-cloud/tsunami`; this + * provider adapts the core client's store snapshots into the context shapes + * the hooks render from. + * * @category Components */ export function FishjamProvider(props: FishjamProviderProps) { - const fishjamClientRef = useRef( - props.fishjamClient ?? new FishjamClient({ reconnect: props.reconnect, debug: props.debug }), - ); - - const persistHandlers = useMemo(() => { - if (props.persistLastDevice === false) return undefined; + const fishjamClientRef = useRef(null); + if (fishjamClientRef.current === null) { + fishjamClientRef.current = new TsunamiClient({ + reconnect: props.reconnect, + debug: props.debug, + clientType: props.clientType, + signallingClient: props.fishjamClient, + deviceManager: props.deviceManager ?? createWebDeviceManager(props.persistLastDevice), + videoConstraints: props.constraints?.video, + audioConstraints: props.constraints?.audio, + bandwidthLimits: props.bandwidthLimits, + videoStreamConfig: props.videoConfig, + audioStreamConfig: props.audioConfig, + }); + } + const client = fishjamClientRef.current; + const devices = client.devices; + if (!devices) throw Error("FishjamProvider always injects a device manager"); - if (typeof props.persistLastDevice === "object") return props.persistLastDevice; + const clientState = useSyncExternalStore(client.subscribe, client.getState); + const peerStatus = usePeerStatus(client); - return { getLastDevice, saveLastDevice }; - }, [props.persistLastDevice]); + const buildDeviceManager = useCallback( + ( + controller: TrackDeviceController, + deviceState: LocalDeviceState, + deviceList: DeviceItem[], + deviceError: CoreDeviceError | null, + ): DeviceManager => ({ + startDevice: (deviceId) => asStartDeviceResult(controller.startDevice(deviceId ?? undefined)), + stopDevice: () => controller.stopDevice(), + selectDevice: (deviceId) => asStartDeviceResult(controller.startDevice(deviceId)), + activeDevice: deviceState.activeDevice, + deviceTrack: asDomTrack(deviceState.track), + deviceStream: asDomStream(deviceState.stream), + deviceList, + deviceEnabled: deviceState.isEnabled, + enableDevice: () => controller.enableDevice(), + disableDevice: () => controller.disableDevice(), + currentMiddleware: deviceState.middleware as TrackMiddleware, + applyMiddleware: (middleware) => controller.applyMiddleware(middleware as CoreTrackMiddleware).then(asDomTrack), + deviceError: asLegacyDeviceError(deviceError), + selectedDevice: (deviceState.selectedDevice as unknown as MediaDeviceInfo) ?? null, + }), + [], + ); - const logger = useMemo(() => getLogger(props.debug ?? false), [props.debug]); + const buildTrackManager = useCallback( + (controller: TrackDeviceController, deviceState: LocalDeviceState): TrackManager => ({ + selectDevice: (deviceId) => asSelectDeviceResult(controller.selectDevice(deviceId)), + stopDevice: () => controller.stopDevice(), + startDevice: (deviceId) => asStartDeviceResult(controller.startDevice(deviceId ?? undefined)), + deviceTrack: asDomTrack(deviceState.track), + currentMiddleware: deviceState.middleware as TrackMiddleware, + setTrackMiddleware: (middleware) => controller.setTrackMiddleware(middleware as CoreTrackMiddleware), + toggleMute: () => controller.toggleMute(), + toggleDevice: () => asSelectDeviceResult(controller.toggleDevice()), + }), + [], + ); - const { cameraManager, microphoneManager, initializeDevices } = useMediaDevices({ - videoConstraints: props.constraints?.video ?? VIDEO_TRACK_CONSTRAINTS, - audioConstraints: props.constraints?.audio ?? true, - persistHandlers, - logger, - }); + const cameraContext = useMemo( + () => ({ + videoTrackManager: buildTrackManager(devices.camera, clientState.camera), + cameraManager: buildDeviceManager( + devices.camera, + clientState.camera, + clientState.availableCameras, + clientState.cameraError, + ), + }), + [ + buildTrackManager, + buildDeviceManager, + devices, + clientState.camera, + clientState.availableCameras, + clientState.cameraError, + ], + ); - const peerStatus = usePeerStatus(fishjamClientRef.current); + const microphoneContext = useMemo( + () => ({ + audioTrackManager: buildTrackManager(devices.microphone, clientState.microphone), + microphoneManager: buildDeviceManager( + devices.microphone, + clientState.microphone, + clientState.availableMicrophones, + clientState.microphoneError, + ), + }), + [ + buildTrackManager, + buildDeviceManager, + devices, + clientState.microphone, + clientState.availableMicrophones, + clientState.microphoneError, + ], + ); - const mergedBandwidthLimits = useMemo( - () => mergeWithDefaultBandwitdthLimits(props.bandwidthLimits), - [props.bandwidthLimits], + const initializeDevices = useCallback( + async (settings?: { enableVideo?: boolean; enableAudio?: boolean }): Promise => { + const result: CoreInitializeDevicesResult = await client.initializeDevices(settings); + return { + status: result.status, + stream: result.stream as MediaStream | null, + errors: result.errors && { + audio: asLegacyDeviceError(result.errors.audio), + video: asLegacyDeviceError(result.errors.video), + }, + }; + }, + [client], ); - const audioTrackManager = useTrackManager({ - tsClient: fishjamClientRef.current, - peerStatus, - deviceManager: microphoneManager, - bandwidthLimits: mergedBandwidthLimits, - streamConfig: props.audioConfig, - type: "microphone", - logger, - }); - - const videoTrackManager = useTrackManager({ - tsClient: fishjamClientRef.current, - peerStatus, - deviceManager: cameraManager, - bandwidthLimits: mergedBandwidthLimits, - streamConfig: props.videoConfig, - type: "camera", - logger, - }); - - const screenShareManager = useScreenShareManager({ - fishjamClient: fishjamClientRef.current, - peerStatus, - logger, - }); - - const cameraContext = useMemo(() => ({ videoTrackManager, cameraManager }), [videoTrackManager, cameraManager]); - const microphoneContext = useMemo( - () => ({ audioTrackManager, microphoneManager }), - [audioTrackManager, microphoneManager], + const screenShareManager: UseScreenshareResult = useMemo( + () => ({ + startStreaming: (constraints) => client.startScreenShare(constraints), + stopStreaming: () => client.stopScreenShare(), + stream: asDomStream(clientState.screenShare.stream), + videoTrack: asDomTrack(clientState.screenShare.videoTrack), + audioTrack: asDomTrack(clientState.screenShare.audioTrack), + currentTracksMiddleware: clientState.screenShare.middleware as TracksMiddleware | null, + setTracksMiddleware: (middleware) => + client.setScreenShareTracksMiddleware(middleware as CoreTracksMiddleware | null), + }), + [client, clientState.screenShare], ); - const customSourceManager = useCustomSourceManager({ - fishjamClient: fishjamClientRef.current, - peerStatus, - logger, - }); + const customSourceManager: CustomSourceManager = useMemo( + () => ({ + setStream: (sourceId, stream) => client.setCustomSource(sourceId, stream), + getSource: (sourceId) => clientState.customSources[sourceId] as CustomSourceState | undefined, + }), + [client, clientState.customSources], + ); - const fishjamClientState = useFishjamClientState(fishjamClientRef.current); + const fishjamClientState = useFishjamClientState(client); return ( - + }> diff --git a/packages/react-client/src/contexts/camera.ts b/packages/react-client/src/contexts/camera.ts index f44ee6796..16a54e947 100644 --- a/packages/react-client/src/contexts/camera.ts +++ b/packages/react-client/src/contexts/camera.ts @@ -1,7 +1,6 @@ import { createContext } from "react"; -import type { DeviceManager } from "../hooks/internal/devices/useDeviceManager"; -import type { TrackManager } from "../types/internal"; +import type { DeviceManager, TrackManager } from "../types/internal"; export type CameraContextType = { videoTrackManager: TrackManager; diff --git a/packages/react-client/src/contexts/customSource.ts b/packages/react-client/src/contexts/customSource.ts index 120f7f681..11e5e324d 100644 --- a/packages/react-client/src/contexts/customSource.ts +++ b/packages/react-client/src/contexts/customSource.ts @@ -1,5 +1,5 @@ import { createContext } from "react"; -import type { CustomSourceManager } from "../hooks/internal/useCustomSourceManager"; +import type { CustomSourceManager } from "../types/internal"; export const CustomSourceContext = createContext(null); diff --git a/packages/react-client/src/contexts/fishjamClient.ts b/packages/react-client/src/contexts/fishjamClient.ts index 054f005e0..e5d6fc7c6 100644 --- a/packages/react-client/src/contexts/fishjamClient.ts +++ b/packages/react-client/src/contexts/fishjamClient.ts @@ -1,4 +1,4 @@ -import type { FishjamClient } from "@fishjam-cloud/ts-client"; +import type { FishjamClient } from "@fishjam-cloud/tsunami"; import { createContext, type RefObject } from "react"; export const FishjamClientContext = createContext | null>(null); diff --git a/packages/react-client/src/contexts/initDevices.ts b/packages/react-client/src/contexts/initDevices.ts index c6e498e02..b34a5df93 100644 --- a/packages/react-client/src/contexts/initDevices.ts +++ b/packages/react-client/src/contexts/initDevices.ts @@ -1,7 +1,6 @@ import { createContext } from "react"; -import type { InitializeDevicesSettings } from "../hooks/internal/devices/useMediaDevices"; -import type { InitializeDevicesResult } from "../types/public"; +import type { InitializeDevicesResult, InitializeDevicesSettings } from "../types/public"; export const InitDevicesContext = createContext< ((settings?: InitializeDevicesSettings) => Promise) | null diff --git a/packages/react-client/src/contexts/microphone.ts b/packages/react-client/src/contexts/microphone.ts index dd0c905b8..78ad87910 100644 --- a/packages/react-client/src/contexts/microphone.ts +++ b/packages/react-client/src/contexts/microphone.ts @@ -1,7 +1,6 @@ import { createContext } from "react"; -import type { DeviceManager } from "../hooks/internal/devices/useDeviceManager"; -import type { TrackManager } from "../types/internal"; +import type { DeviceManager, TrackManager } from "../types/internal"; export type MicrophoneContextType = { audioTrackManager: TrackManager; diff --git a/packages/react-client/src/contexts/screenshare.ts b/packages/react-client/src/contexts/screenshare.ts index 1016800c7..8817d1b6c 100644 --- a/packages/react-client/src/contexts/screenshare.ts +++ b/packages/react-client/src/contexts/screenshare.ts @@ -1,5 +1,5 @@ import { createContext } from "react"; -import type { UseScreenshareResult } from "../hooks/internal/useScreenshareManager"; +import type { UseScreenshareResult } from "../types/internal"; export const ScreenshareContext = createContext(null); diff --git a/packages/react-client/src/devices/constraints.ts b/packages/react-client/src/devices/constraints.ts deleted file mode 100644 index bfedbe721..000000000 --- a/packages/react-client/src/devices/constraints.ts +++ /dev/null @@ -1,50 +0,0 @@ -export const VIDEO_TRACK_CONSTRAINTS: MediaTrackConstraints = { - width: { - max: 1280, - ideal: 1280, - min: 640, - }, - height: { - max: 720, - ideal: 720, - min: 320, - }, - frameRate: { - max: 30, - ideal: 24, - }, -}; - -export const SCREEN_SHARING_MEDIA_CONSTRAINTS: MediaStreamConstraints = { - video: { - frameRate: { ideal: 20, max: 25 }, - width: { max: 1920, ideal: 1920 }, - height: { max: 1080, ideal: 1080 }, - }, -}; - -export const prepareMediaTrackConstraints = ( - deviceId: string | undefined, - constraints: MediaTrackConstraints | undefined | boolean, -): MediaTrackConstraints | boolean => { - const trackConstraints = typeof constraints === "boolean" ? {} : constraints; - - if (!deviceId) return { ...trackConstraints }; - - return { ...trackConstraints, deviceId: { exact: deviceId } }; -}; - -export const prepareConstraints = ( - deviceIdToStart: string | undefined, - constraints: MediaTrackConstraints | undefined | boolean, -): MediaTrackConstraints | undefined | boolean => { - if (!deviceIdToStart) return constraints; - - // The resulting stream will not contain a track of this type, - // which means that the device will not be activated. - if (constraints === false) return false; - - const constraintsObj = constraints === true ? {} : constraints; - - return { ...constraintsObj, deviceId: { ideal: deviceIdToStart } }; -}; diff --git a/packages/react-client/src/devices/mediaInitializer.ts b/packages/react-client/src/devices/mediaInitializer.ts deleted file mode 100644 index 6d2c9f890..000000000 --- a/packages/react-client/src/devices/mediaInitializer.ts +++ /dev/null @@ -1,151 +0,0 @@ -import type { AudioVideo, CurrentDevices } from "../types/internal"; -import type { DeviceError } from "../types/public"; -import { NOT_FOUND_ERROR, OVERCONSTRAINED_ERROR, PERMISSION_DENIED, UNHANDLED_ERROR } from "../utils/errors"; -import { prepareConstraints } from "./constraints"; - -type MediaConstraints = AudioVideo; -type PreviousDevices = AudioVideo; - -type GetMediaResult = { stream: MediaStream | null; errors: AudioVideo }; - -const defaultErrors = { audio: null, video: null }; - -const errorMap: Record = { - NotFoundError: NOT_FOUND_ERROR, - OverconstrainedError: OVERCONSTRAINED_ERROR, - NotAllowedError: PERMISSION_DENIED, -}; - -const getSingleMedia = async ( - type: T, - constraints: MediaStreamConstraints[T], -): Promise<[MediaStream, null] | [null, DeviceError]> => { - const baseConstraints = { audio: undefined, video: undefined }; - - try { - const stream = await navigator.mediaDevices.getUserMedia({ ...baseConstraints, [type]: constraints }); - return [stream, null]; - } catch (err) { - const name = err instanceof Error ? err.name : ""; - return [null, errorMap[name] ?? UNHANDLED_ERROR]; - } -}; - -const tryToGetAudioOnlyThenVideoOnly = async ( - constraints: MediaStreamConstraints, - initialError: DeviceError, -): Promise => { - const [audioStream, audioErr] = await getSingleMedia("audio", constraints.audio); - if (audioStream) return { stream: audioStream, errors: { video: initialError, audio: null } }; - - const [videoStream, videoErr] = await getSingleMedia("video", constraints.video); - return { stream: videoStream, errors: { audio: audioErr, video: videoErr } }; -}; - -export const getAvailableMedia = async ( - constraints: MediaStreamConstraints, - errors: AudioVideo = defaultErrors, -): Promise => { - try { - return { stream: await navigator.mediaDevices.getUserMedia(constraints), errors }; - } catch (err: unknown) { - const name = err instanceof Error ? err.name : ""; - switch (name) { - case errors.audio?.name: - case errors.video?.name: - return { stream: null, errors }; - case "NotFoundError": - return tryToGetAudioOnlyThenVideoOnly(constraints, PERMISSION_DENIED); - case "OverconstrainedError": - return getAvailableMedia( - { audio: unspecifyDevice(constraints.audio), video: unspecifyDevice(constraints.video) }, - { audio: OVERCONSTRAINED_ERROR, video: OVERCONSTRAINED_ERROR }, - ); - case "NotAllowedError": - return tryToGetAudioOnlyThenVideoOnly(constraints, PERMISSION_DENIED); - default: - return { stream: null, errors: { audio: UNHANDLED_ERROR, video: UNHANDLED_ERROR } }; - } - } -}; - -// Safari changes deviceId between sessions, therefore we cannot rely on deviceId for identification purposes. -// We can switch a random device that comes from safari to one that has the same label as the one used in the previous session. -export const correctDevicesOnSafari = async ( - stream: MediaStream, - errors: AudioVideo, - devices: MediaDeviceInfo[], - constraints: MediaConstraints, - previousDevices: PreviousDevices, -): Promise => { - const shouldCorrectDevices = isAnyDeviceDifferentFromLastSession( - previousDevices.video, - previousDevices.audio, - getCurrentDevicesSettings(stream, devices), - ); - - if (!shouldCorrectDevices) return { stream, errors }; - - const videoIdToStart = devices.find((info) => info.label === previousDevices.video?.label)?.deviceId; - const audioIdToStart = devices.find((info) => info.label === previousDevices.audio?.label)?.deviceId; - - if (!videoIdToStart && !audioIdToStart) return { stream, errors }; - - stopTracks(stream); - - const exactConstraints: MediaStreamConstraints = { - video: !errors.video && prepareConstraints(videoIdToStart, constraints.video), - audio: !errors.audio && prepareConstraints(audioIdToStart, constraints.audio), - }; - - return await getAvailableMedia(exactConstraints, errors); -}; - -const getCurrentDevicesSettings = ( - requestedDevices: MediaStream, - mediaDeviceInfos: MediaDeviceInfo[], -): CurrentDevices => { - const currentDevices: CurrentDevices = { videoinput: null, audioinput: null }; - - for (const track of requestedDevices.getTracks()) { - const settings = track.getSettings(); - if (settings.deviceId) { - const currentDevice = mediaDeviceInfos.find((device) => device.deviceId == settings.deviceId); - const kind = currentDevice?.kind ?? null; - if ((currentDevice && kind === "videoinput") || kind === "audioinput") { - currentDevices[kind] = currentDevice ?? null; - } - } - } - return currentDevices; -}; - -const isDeviceDifferentFromLastSession = (lastDevice: MediaDeviceInfo | null, currentDevice: MediaDeviceInfo | null) => - lastDevice && (currentDevice?.deviceId !== lastDevice.deviceId || currentDevice?.label !== lastDevice?.label); - -const isAnyDeviceDifferentFromLastSession = ( - lastVideoDevice: MediaDeviceInfo | null, - lastAudioDevice: MediaDeviceInfo | null, - currentDevices: CurrentDevices | null, -): boolean => - !!( - (currentDevices?.videoinput && - isDeviceDifferentFromLastSession(lastVideoDevice, currentDevices?.videoinput || null)) || - (currentDevices?.audioinput && - isDeviceDifferentFromLastSession(lastAudioDevice, currentDevices?.audioinput || null)) - ); - -const stopTracks = (requestedDevices: MediaStream) => { - for (const track of requestedDevices.getTracks()) { - track.stop(); - } -}; - -const unspecifyDevice = ( - trackConstraints?: boolean | MediaTrackConstraints, -): boolean | MediaTrackConstraints | undefined => { - if (typeof trackConstraints === "object") { - return { ...trackConstraints, deviceId: undefined }; - } - return trackConstraints; -}; diff --git a/packages/react-client/src/hooks/devices/useCamera.ts b/packages/react-client/src/hooks/devices/useCamera.ts index c06c64fb5..a87701083 100644 --- a/packages/react-client/src/hooks/devices/useCamera.ts +++ b/packages/react-client/src/hooks/devices/useCamera.ts @@ -12,11 +12,7 @@ export function useCamera() { const { videoTrackManager, cameraManager } = cameraCtx; - const cameraStream = useMemo(() => { - const track = videoTrackManager.deviceTrack; - if (!track) return null; - return new MediaStream([track]); - }, [videoTrackManager.deviceTrack]); + const cameraStream = cameraManager.deviceStream; return { /** diff --git a/packages/react-client/src/hooks/devices/useMicrophone.ts b/packages/react-client/src/hooks/devices/useMicrophone.ts index a64bcfffc..8331d1055 100644 --- a/packages/react-client/src/hooks/devices/useMicrophone.ts +++ b/packages/react-client/src/hooks/devices/useMicrophone.ts @@ -12,11 +12,7 @@ export function useMicrophone() { const { audioTrackManager, microphoneManager } = microphoneCtx; - const microphoneStream = useMemo(() => { - const track = audioTrackManager.deviceTrack; - if (!track) return null; - return new MediaStream([track]); - }, [audioTrackManager.deviceTrack]); + const microphoneStream = microphoneManager.deviceStream; return { /** Toggles current microphone on/off */ diff --git a/packages/react-client/src/hooks/internal/devices/useDeviceManager.ts b/packages/react-client/src/hooks/internal/devices/useDeviceManager.ts deleted file mode 100644 index f3f342b33..000000000 --- a/packages/react-client/src/hooks/internal/devices/useDeviceManager.ts +++ /dev/null @@ -1,206 +0,0 @@ -import type { Logger } from "@fishjam-cloud/ts-client"; -import type { SetStateAction } from "react"; -import { useCallback, useMemo, useRef, useState } from "react"; - -import type { DeviceError, DeviceItem, TrackMiddleware } from "../../../types/public"; -import { parseUserMediaError } from "../../../utils/errors"; -import { getTrackFromStream, stopStream } from "../../../utils/track"; -import { useTrackMiddleware } from "../useTrackMiddleware"; -import { useHandleTrackEnd } from "./useHandleTrackEnd"; - -type DeviceManagerProps = { - mediaStream: MediaStream | null; - setMediaStream: (action: SetStateAction) => void; - deviceError: DeviceError | null; - setDeviceError: (action: SetStateAction) => void; - getInitialStream: () => Promise; - deviceType: "audio" | "video"; - allDevicesList: MediaDeviceInfo[]; - constraints?: MediaTrackConstraints | boolean; - setSelectedDevice: (device: MediaDeviceInfo) => void; - selectedDevice: MediaDeviceInfo | null; - logger: Logger; -}; - -export type DeviceManager = { - startDevice: (deviceId?: string | null) => Promise<[MediaStreamTrack, null] | [null, DeviceError]>; - stopDevice: () => void; - selectDevice: (deviceId: string) => Promise<[MediaStreamTrack, null] | [null, DeviceError]> | undefined; - activeDevice: DeviceItem | null; - deviceTrack: MediaStreamTrack | null; - deviceList: DeviceItem[]; - deviceEnabled: boolean; - enableDevice: () => void; - disableDevice: () => void; - currentMiddleware: TrackMiddleware; - applyMiddleware: (middleware: TrackMiddleware) => Promise; - deviceError: DeviceError | null; - selectedDevice: MediaDeviceInfo | null; -}; - -async function getDeviceStream( - type: "audio" | "video", - constraints: MediaTrackConstraints | boolean | undefined, - deviceId: string | null, -) { - constraints = typeof constraints === "object" ? constraints : {}; - if (deviceId) { - constraints.deviceId = { exact: deviceId }; - } - const stream = await navigator.mediaDevices.getUserMedia({ - [type]: constraints, - }); - return stream; -} - -export const useDeviceManager = ({ - mediaStream, - setMediaStream, - getInitialStream, - deviceType, - constraints, - allDevicesList, - setSelectedDevice, - deviceError, - setDeviceError, - selectedDevice, - logger, -}: DeviceManagerProps): DeviceManager => { - const mediaStreamRef = useRef(mediaStream); - mediaStreamRef.current = mediaStream; - - const rawTrack = useMemo(() => mediaStream && getTrackFromStream(mediaStream, deviceType), [mediaStream, deviceType]); - - const clearStream = useCallback(() => { - setMediaStream(null); - }, [setMediaStream]); - - useHandleTrackEnd(rawTrack, clearStream); - - const { processedTrack, applyMiddleware, currentMiddleware } = useTrackMiddleware(rawTrack); - - const currentTrack = processedTrack ?? rawTrack; - - const deviceList = useMemo( - () => allDevicesList.filter(({ kind }) => kind === `${deviceType}input`), - [allDevicesList, deviceType], - ); - - const activeDevice = useMemo(() => { - const currentDevice = - mediaStream && - deviceList.find( - (device) => device.deviceId === getTrackFromStream(mediaStream, deviceType)?.getSettings().deviceId, - ); - if (!currentDevice) return null; - return { label: currentDevice.label, deviceId: currentDevice.deviceId }; - }, [mediaStream, deviceList, deviceType]); - - const [deviceEnabled, setDeviceEnabled] = useState(true); - - const setSelectedDeviceId = useCallback( - (deviceId: string) => { - const device = deviceList.find((d) => d.deviceId === deviceId); - if (!device) return; - setSelectedDevice(device); - }, - [deviceList, setSelectedDevice], - ); - - const startDevice: DeviceManager["startDevice"] = useCallback( - async (deviceId = selectedDevice?.deviceId) => { - const initialStream = await getInitialStream(); - - const track = initialStream && getTrackFromStream(initialStream, deviceType); - const isUsingDesiredDevice = !deviceId || deviceId === track?.getSettings().deviceId; - - if (track?.enabled && isUsingDesiredDevice) { - return [track, null]; - } - - try { - const stream = await getDeviceStream(deviceType, constraints, deviceId ?? null); - - if (mediaStreamRef.current) { - stopStream(mediaStreamRef.current, deviceType); - } - setMediaStream(stream); - - const retrievedTrack = stream && getTrackFromStream(stream, deviceType); - - const retrievedTrackDeviceId = retrievedTrack.getSettings().deviceId; - - if (retrievedTrackDeviceId) { - setSelectedDeviceId(retrievedTrackDeviceId); - } - - if (retrievedTrack && !deviceEnabled) { - retrievedTrack.enabled = false; - } - - return [retrievedTrack, null]; - } catch (err) { - const parsedError = parseUserMediaError(err, logger); - setDeviceError(parsedError); - return [null, parsedError]; - } - }, - [ - selectedDevice?.deviceId, - getInitialStream, - deviceType, - constraints, - setMediaStream, - deviceEnabled, - setSelectedDeviceId, - logger, - setDeviceError, - ], - ); - - const selectDevice = useCallback( - (deviceId: string) => { - if (currentTrack) { - return startDevice(deviceId); - } else { - setSelectedDeviceId(deviceId); - } - }, - [currentTrack, setSelectedDeviceId, startDevice], - ); - - const stopDevice = useCallback(() => { - if (mediaStreamRef.current) { - stopStream(mediaStreamRef.current, deviceType); - } - setMediaStream(null); - }, [setMediaStream, deviceType]); - - const enableDevice = useCallback(() => { - if (!currentTrack) return; - currentTrack.enabled = true; - setDeviceEnabled(true); - }, [currentTrack]); - - const disableDevice = useCallback(() => { - if (!currentTrack) return; - currentTrack.enabled = false; - setDeviceEnabled(false); - }, [currentTrack]); - - return { - startDevice, - stopDevice, - selectDevice, - activeDevice, - deviceTrack: processedTrack ?? rawTrack, - deviceList, - enableDevice, - disableDevice, - deviceEnabled, - currentMiddleware, - applyMiddleware, - deviceError, - selectedDevice, - }; -}; diff --git a/packages/react-client/src/hooks/internal/devices/useMediaDevices.ts b/packages/react-client/src/hooks/internal/devices/useMediaDevices.ts deleted file mode 100644 index ccc71b56f..000000000 --- a/packages/react-client/src/hooks/internal/devices/useMediaDevices.ts +++ /dev/null @@ -1,174 +0,0 @@ -import type { Logger } from "@fishjam-cloud/ts-client"; -import { useCallback, useEffect, useRef, useState } from "react"; - -import { prepareConstraints } from "../../../devices/constraints"; -import { correctDevicesOnSafari, getAvailableMedia } from "../../../devices/mediaInitializer"; -import type { DeviceError, InitializeDevicesResult, PersistLastDeviceHandlers } from "../../../types/public"; -import { useDeviceManager } from "./useDeviceManager"; - -interface UseDevicesProps { - videoConstraints?: MediaTrackConstraints | boolean; - audioConstraints?: MediaTrackConstraints | boolean; - persistHandlers?: PersistLastDeviceHandlers; - logger: Logger; -} - -export type InitializeDevicesSettings = { enableVideo?: boolean; enableAudio?: boolean }; - -export const useMediaDevices = ({ videoConstraints, audioConstraints, persistHandlers, logger }: UseDevicesProps) => { - const [deviceList, setDeviceList] = useState([]); - - const [videoStream, setVideoStream] = useState(null); - const [audioStream, setAudioStream] = useState(null); - - const [videoError, setVideoError] = useState(null); - const [audioError, setAudioError] = useState(null); - - const [selectedCamera, setSelectedCamera] = useState( - persistHandlers?.getLastDevice("video") ?? null, - ); - const [selectedMic, setSelectedMic] = useState( - persistHandlers?.getLastDevice("audio") ?? null, - ); - - const isInitializedRef = useRef(false); - const initializationRef = useRef | null>(null); - - const selectCamera = useCallback( - (deviceInfo: MediaDeviceInfo) => { - setSelectedCamera(deviceInfo); - persistHandlers?.saveLastDevice(deviceInfo, "video"); - }, - [persistHandlers], - ); - - const selectMic = useCallback( - (deviceInfo: MediaDeviceInfo) => { - setSelectedMic(deviceInfo); - persistHandlers?.saveLastDevice(deviceInfo, "audio"); - }, - [persistHandlers], - ); - - const initializeDevices = useCallback( - async (settings?: InitializeDevicesSettings): Promise => { - if (isInitializedRef.current) { - return { stream: null, errors: null, status: "already_initialized" }; - } - - if (initializationRef.current) { - return initializationRef.current; - } - - const lastUsed = { - audio: persistHandlers?.getLastDevice("audio") ?? null, - video: persistHandlers?.getLastDevice("video") ?? null, - }; - - const constraints = { - video: settings?.enableVideo !== false && prepareConstraints(lastUsed.video?.deviceId, videoConstraints), - audio: settings?.enableAudio !== false && prepareConstraints(lastUsed.audio?.deviceId, audioConstraints), - }; - - const intitialize = async (): Promise => { - let media = await getAvailableMedia(constraints); - const fetchedDevices = await navigator.mediaDevices.enumerateDevices(); - setDeviceList(fetchedDevices); - - if (media.stream) { - media = await correctDevicesOnSafari(media.stream, media.errors, fetchedDevices, constraints, lastUsed); - } - - const { stream, errors } = media; - - const videoDeviceId = stream?.getVideoTracks()[0]?.getSettings().deviceId; - const audioDeviceId = stream?.getAudioTracks()[0]?.getSettings().deviceId; - - const videoDevice = fetchedDevices.find((device) => device.deviceId === videoDeviceId); - const audioDevice = fetchedDevices.find((device) => device.deviceId === audioDeviceId); - - if (videoDevice) { - selectCamera(videoDevice); - } - if (audioDevice) { - selectMic(audioDevice); - } - - setVideoStream(stream); - setAudioStream(stream); - setVideoError(errors.video); - setAudioError(errors.audio); - - if (!stream) { - return { status: "failed", errors, stream: null }; - } else if (errors.video || errors.audio) { - return { status: "initialized_with_errors", errors, stream: null }; - } else { - return { status: "initialized", errors: null, stream }; - } - }; - - const initializePromise = intitialize().then( - (result) => { - isInitializedRef.current = true; - return result; - }, - (error) => { - initializationRef.current = null; - throw error; - }, - ); - initializationRef.current = initializePromise; - - return await initializePromise; - }, - [videoConstraints, audioConstraints, selectCamera, selectMic, persistHandlers], - ); - - useEffect(() => { - const isInitialStreamIrrelevant = videoStream !== audioStream; - - if (isInitialStreamIrrelevant) { - initializationRef.current = null; - } - }, [videoStream, audioStream]); - - const getInitialStream = useCallback(async () => { - const result = await initializationRef.current; - return result?.stream ?? null; - }, []); - - const cameraManager = useDeviceManager({ - mediaStream: videoStream, - setMediaStream: setVideoStream, - deviceError: videoError, - setDeviceError: setVideoError, - getInitialStream, - deviceType: "video", - allDevicesList: deviceList, - constraints: videoConstraints, - setSelectedDevice: selectCamera, - selectedDevice: selectedCamera, - logger, - }); - - const microphoneManager = useDeviceManager({ - mediaStream: audioStream, - setMediaStream: setAudioStream, - deviceError: audioError, - setDeviceError: setAudioError, - getInitialStream, - deviceType: "audio", - allDevicesList: deviceList, - constraints: audioConstraints, - setSelectedDevice: selectMic, - selectedDevice: selectedMic, - logger, - }); - - return { - initializeDevices, - cameraManager, - microphoneManager, - }; -}; diff --git a/packages/react-client/src/hooks/internal/useCustomSourceManager.ts b/packages/react-client/src/hooks/internal/useCustomSourceManager.ts deleted file mode 100644 index c2ef7d437..000000000 --- a/packages/react-client/src/hooks/internal/useCustomSourceManager.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { type FishjamClient, type Logger, type TrackMetadata, TrackTypeError } from "@fishjam-cloud/ts-client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; - -import type { CustomSourceState, CustomSourceTracks } from "../../types/internal"; -import type { PeerStatus } from "../../types/public"; - -type CustomSourceManagerProps = { - fishjamClient: FishjamClient; - peerStatus: PeerStatus; - logger: Logger; -}; - -export type CustomSourceManager = { - setStream: (sourceId: string, stream: MediaStream | null) => Promise; - getSource: (sourceId: string) => CustomSourceState | undefined; -}; - -export function useCustomSourceManager({ - fishjamClient, - peerStatus, - logger, -}: CustomSourceManagerProps): CustomSourceManager { - const [sources, setSources] = useState>({}); - // setStream reads the current sources synchronously (to diff against the old stream and to - // remove stale tracks), but must stay referentially stable so consumers can safely depend on - // it in effects. Reading through a ref keeps it out of the useCallback deps. - const sourcesRef = useRef(sources); - sourcesRef.current = sources; - - // Replacing a source's stream issues two setStream calls in the same tick (unpublish the old, - // publish the new). Run concurrently they would read the same snapshot and race to remove the - // same track IDs — and when the publish call loses that race, the new stream is never - // published. This chain serializes the calls instead. - const setStreamQueue = useRef(Promise.resolve()); - - const pendingSources = useMemo( - () => Object.entries(sources).filter(([_, source]) => source.trackIds === undefined), - [sources], - ); - - const getDisplayName = useCallback(() => { - const name = fishjamClient.getLocalPeer()?.metadata?.peer?.displayName; - if (typeof name == "string") return name; - }, [fishjamClient]); - - const addTrackToFishjamClient = useCallback( - async (track: MediaStreamTrack, trackMetadata: TrackMetadata) => { - try { - return fishjamClient.addTrack(track, trackMetadata); - } catch (err) { - if (err instanceof TrackTypeError) { - logger.warn(err.message); - return undefined; - } - throw err; - } - }, - [fishjamClient, logger], - ); - - const startStreaming = useCallback( - async (source: CustomSourceState): Promise => { - const stream = source?.stream; - - const video = stream.getVideoTracks().at(0); - const audio = stream.getAudioTracks().at(0); - - const promises = []; - const displayName = getDisplayName(); - if (video) { - const videoMetadata = { type: "customVideo", displayName, paused: false } as const; - promises.push(addTrackToFishjamClient(video, videoMetadata)); - } - if (audio) { - const audioMetadata = { type: "customAudio", displayName, paused: false } as const; - promises.push(addTrackToFishjamClient(audio, audioMetadata)); - } - - if (promises.length === 0) { - logger.warn("Attempted to add empty MediaStream as custom source."); - return source; - } - const [videoId, audioId] = await Promise.all(promises); - return { ...source, trackIds: { videoId, audioId } }; - }, - [addTrackToFishjamClient, getDisplayName, logger], - ); - - const removeTracks = useCallback( - async ({ videoId, audioId }: CustomSourceTracks) => { - const promises = []; - if (videoId) promises.push(fishjamClient.removeTrack(videoId)); - if (audioId) promises.push(fishjamClient.removeTrack(audioId)); - await Promise.all(promises); - }, - [fishjamClient], - ); - - const getSource = useCallback((sourceId: string) => sources[sourceId], [sources]); - - // Updates both the state (what consumers render) and the ref (what queued setStream calls - // read synchronously, before React re-renders). - const updateSources = useCallback( - (update: (old: Record) => Record) => { - sourcesRef.current = update(sourcesRef.current); - setSources(update); - }, - [], - ); - - const applySetStream = useCallback( - async (sourceId: string, stream: MediaStream | null) => { - const oldSource = sourcesRef.current[sourceId]; - if (stream === oldSource?.stream) return; - - if (oldSource?.trackIds) await removeTracks(oldSource.trackIds); - - if (stream !== null) { - updateSources((old) => ({ ...old, [sourceId]: { stream } })); - } else if (oldSource) { - updateSources((old) => Object.fromEntries(Object.entries(old).filter(([id]) => id !== sourceId))); - } - }, - [removeTracks, updateSources], - ); - - const setStream = useCallback( - (sourceId: string, stream: MediaStream | null) => { - const run = setStreamQueue.current.then(() => applySetStream(sourceId, stream)); - // Chain a never-rejecting link so one failed call cannot poison the queue; the caller - // still observes failures through the returned promise. - setStreamQueue.current = run.catch(() => undefined); - return run; - }, - [applySetStream], - ); - - useEffect(() => { - const onConnected = async () => { - if (pendingSources.length === 0) return; - - const results = await Promise.all( - pendingSources.map(async ([id, source]) => [id, await startStreaming(source)] as const), - ); - - // While the tracks were being added, setStream may have unpublished the source or replaced - // its stream. Record track IDs only where the entry is still the one we started streaming - // (same stream, still no track IDs) — anything else must not be patched (it would resurrect - // an unpublished source or attach the IDs to a different stream), and the tracks we just - // added for it are orphans to unpublish again. - const isStillCurrent = ([id, started]: (typeof results)[number]) => { - const current = sourcesRef.current[id]; - return current !== undefined && current.stream === started.stream && current.trackIds === undefined; - }; - const patch = results.filter(isStillCurrent); - const orphans = results.filter((result) => !isStillCurrent(result)); - - if (patch.length > 0) { - updateSources((old) => ({ ...old, ...Object.fromEntries(patch) })); - } - for (const [, started] of orphans) { - if (started.trackIds) await removeTracks(started.trackIds); - } - }; - - const onDisconnected = () => { - updateSources((old) => - Object.fromEntries(Object.entries(old).map(([id, source]) => [id, { ...source, trackIds: undefined }])), - ); - }; - - // addTrack can reject for reasons other than TrackTypeError (e.g. a disconnect mid-add); - // surface it instead of leaving an unhandled rejection. - if (peerStatus === "connected") - onConnected().catch((error) => logger.error("Failed to publish custom sources", error)); - - fishjamClient.on("disconnected", onDisconnected); - return () => { - fishjamClient.off("disconnected", onDisconnected); - }; - }, [pendingSources, fishjamClient, peerStatus, startStreaming, removeTracks, updateSources, logger]); - - return { setStream, getSource }; -} diff --git a/packages/react-client/src/hooks/internal/useFishjamClientState.ts b/packages/react-client/src/hooks/internal/useFishjamClientState.ts index 0ee382286..73fbe0367 100644 --- a/packages/react-client/src/hooks/internal/useFishjamClientState.ts +++ b/packages/react-client/src/hooks/internal/useFishjamClientState.ts @@ -1,50 +1,10 @@ -import type { Component, FishjamClient, GenericMetadata, MessageEvents } from "@fishjam-cloud/ts-client"; -import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; +import type { Component, GenericMetadata } from "@fishjam-cloud/ts-client"; +import type { ClientState, FishjamClient } from "@fishjam-cloud/tsunami"; +import { useCallback, useRef, useSyncExternalStore } from "react"; import type { BrandedPeer } from "../../types/internal"; import type { PeerId } from "../../types/public"; -const eventNames = [ - "socketClose", - "socketError", - "socketOpen", - "authSuccess", - "authError", - "disconnected", - "reconnectionStarted", - "reconnected", - "reconnectionRetriesLimitReached", - "joined", - "joinError", - "trackReady", - "trackAdded", - "trackRemoved", - "trackUpdated", - "encodingChanged", - "peerJoined", - "peerLeft", - "peerUpdated", - "componentAdded", - "componentRemoved", - "componentUpdated", - "connectionError", - "tracksPriorityChanged", - "bandwidthEstimationChanged", - "targetTrackEncodingRequested", - "localTrackAdded", - "localTrackRemoved", - "localTrackReplaced", - "localTrackMuted", - "localTrackUnmuted", - "localTrackBandwidthSet", - "localTrackEncodingBandwidthSet", - "localTrackEncodingEnabled", - "localTrackEncodingDisabled", - "localPeerMetadataChanged", - "localTrackMetadataChanged", - "disconnectRequested", -] as const satisfies (keyof MessageEvents)[]; - export interface FishjamClientState

{ peers: Record>; components: Record; @@ -57,43 +17,24 @@ This is an internally used hook. It is not meant to be used by the end user. */ export function useFishjamClientState(fishjamClient: FishjamClient): FishjamClientState { - const client = useMemo(() => fishjamClient, [fishjamClient]); - const mutationRef = useRef(false); - - const subscribe = useCallback( - (subscribeCallback: () => void) => { - const callback = () => { - mutationRef.current = true; - subscribeCallback(); - }; - eventNames.forEach((eventName) => client.on(eventName, callback)); - return () => { - eventNames.forEach((eventName) => client.removeListener(eventName, callback)); - }; - }, - [client], - ); - - const lastSnapshotRef = useRef | null>(null); + const lastSnapshotRef = useRef<{ source: ClientState; value: FishjamClientState } | null>(null); const getSnapshot: () => FishjamClientState = useCallback(() => { - if (mutationRef.current || lastSnapshotRef.current === null) { - const peers = client.getRemotePeers(); - const components = client.getRemoteComponents(); - const localPeer = client.getLocalPeer(); - const isReconnecting = client.isReconnecting(); - + const source = fishjamClient.getState(); + if (lastSnapshotRef.current?.source !== source) { lastSnapshotRef.current = { - peers: peers as Record>, - components, - localPeer: localPeer as BrandedPeer, - isReconnecting, + source, + value: { + peers: source.remotePeers as Record>, + components: source.components, + localPeer: source.localPeer as BrandedPeer | null, + isReconnecting: source.reconnectionStatus === "reconnecting", + }, }; - mutationRef.current = false; } - return lastSnapshotRef.current; - }, [client]); + return lastSnapshotRef.current.value; + }, [fishjamClient]); - return useSyncExternalStore(subscribe, getSnapshot); + return useSyncExternalStore(fishjamClient.subscribe, getSnapshot); } diff --git a/packages/react-client/src/hooks/internal/usePeerStatus.ts b/packages/react-client/src/hooks/internal/usePeerStatus.ts index 5244d430b..5ea03d203 100644 --- a/packages/react-client/src/hooks/internal/usePeerStatus.ts +++ b/packages/react-client/src/hooks/internal/usePeerStatus.ts @@ -1,43 +1,10 @@ -import type { FishjamClient } from "@fishjam-cloud/ts-client"; -import { useEffect, useState } from "react"; +import type { FishjamClient } from "@fishjam-cloud/tsunami"; +import { useCallback, useSyncExternalStore } from "react"; import type { PeerStatus } from "../../types/public"; -export const usePeerStatus = (client: FishjamClient) => { - const [peerStatus, setPeerStatus] = useState("idle"); - - useEffect(() => { - const setConnecting = () => { - setPeerStatus("connecting"); - }; - const setError = () => { - setPeerStatus("error"); - }; - const setJoined = () => { - setPeerStatus("connected"); - }; - const setDisconnected = () => { - setPeerStatus("idle"); - }; - - client.on("connectionStarted", setConnecting); - client.on("reconnected", setJoined); - client.on("joined", setJoined); - client.on("authError", setError); - client.on("joinError", setError); - client.on("connectionError", setError); - client.on("disconnected", setDisconnected); - - return () => { - client.off("connectionStarted", setConnecting); - client.off("reconnected", setJoined); - client.off("joined", setJoined); - client.off("authError", setError); - client.off("joinError", setError); - client.off("connectionError", setError); - client.off("disconnected", setDisconnected); - }; - }, [client, setPeerStatus]); - - return peerStatus; -}; +export const usePeerStatus = (client: FishjamClient): PeerStatus => + useSyncExternalStore( + client.subscribe, + useCallback(() => client.getState().peerStatus, [client]), + ); diff --git a/packages/react-client/src/hooks/internal/useReconnection.ts b/packages/react-client/src/hooks/internal/useReconnection.ts index 4ffdb17ab..9904aff7b 100644 --- a/packages/react-client/src/hooks/internal/useReconnection.ts +++ b/packages/react-client/src/hooks/internal/useReconnection.ts @@ -1,5 +1,5 @@ import type { ReconnectionStatus } from "@fishjam-cloud/ts-client"; -import { useContext, useEffect, useState } from "react"; +import { useCallback, useContext, useSyncExternalStore } from "react"; import { FishjamClientContext } from "../../contexts/fishjamClient"; @@ -11,38 +11,10 @@ export const useReconnection = (): ReconnectionStatus => { const fishjamClientRef = useContext(FishjamClientContext); if (!fishjamClientRef) throw Error("useConnection must be used within FishjamProvider"); - const [reconnectionStatus, setReconnectionStatus] = useState("idle"); + const client = fishjamClientRef.current; - useEffect(() => { - const client = fishjamClientRef.current; - - const setReconnecting = () => { - setReconnectionStatus("reconnecting"); - }; - const setIdle = () => { - setReconnectionStatus("idle"); - }; - const setError = () => { - setReconnectionStatus("error"); - }; - const setErrorIfReconnecting = () => { - setReconnectionStatus((prev) => (prev === "reconnecting" ? "error" : prev)); - }; - - client.on("reconnectionStarted", setReconnecting); - client.on("reconnected", setIdle); - client.on("reconnectionRetriesLimitReached", setError); - client.on("authError", setErrorIfReconnecting); - client.on("joinError", setErrorIfReconnecting); - - return () => { - client.off("reconnectionStarted", setReconnecting); - client.off("reconnected", setIdle); - client.off("reconnectionRetriesLimitReached", setError); - client.off("authError", setErrorIfReconnecting); - client.off("joinError", setErrorIfReconnecting); - }; - }, [fishjamClientRef]); - - return reconnectionStatus; + return useSyncExternalStore( + client.subscribe, + useCallback(() => client.getState().reconnectionStatus, [client]), + ); }; diff --git a/packages/react-client/src/hooks/internal/useScreenshareManager.ts b/packages/react-client/src/hooks/internal/useScreenshareManager.ts deleted file mode 100644 index 143c48806..000000000 --- a/packages/react-client/src/hooks/internal/useScreenshareManager.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { type FishjamClient, type Logger, type TrackMetadata, TrackTypeError } from "@fishjam-cloud/ts-client"; -import { useCallback, useEffect, useRef, useState } from "react"; - -import type { ScreenShareState } from "../../types/internal"; -import type { PeerStatus, TracksMiddleware } from "../../types/public"; -import { useCurrentCallback } from "./useCurrentCallback"; - -export type UseScreenshareResult = { - /** - * Invokes the screen sharing prompt in the user's browser and starts streaming upon approval. - */ - startStreaming: (props?: { - audioConstraints?: boolean | MediaTrackConstraints; - videoConstraints?: boolean | MediaTrackConstraints; - }) => Promise; - /** - * Stops the stream and cancels browser screen sharing. - */ - stopStreaming: () => Promise; - /** - * The MediaStream object containing both tracks. - */ - stream: MediaStream | null; - /** - * The separate video MediaStreamTrack. - */ - videoTrack: MediaStreamTrack | null; - /** - * The separate audio MediaStreamTrack. - */ - audioTrack: MediaStreamTrack | null; - /** - * The middleware currently assigned to process the tracks. - * A screenshare may include both audio and video tracks, and this middleware is capable of processing - * each track type. - */ - currentTracksMiddleware: TracksMiddleware | null; - /** - * Sets the middleware responsible for processing the tracks. - * @param middleware The middleware to set, which can be a TracksMiddleware instance or null to remove the middleware. - * @returns A Promise that resolves once the middleware is successfully set. - */ - setTracksMiddleware: (middleware: TracksMiddleware | null) => Promise; -}; - -interface ScreenShareManagerProps { - fishjamClient: FishjamClient; - peerStatus: PeerStatus; - logger: Logger; -} - -export const useScreenShareManager = ({ - fishjamClient, - peerStatus, - logger, -}: ScreenShareManagerProps): UseScreenshareResult => { - const [state, setState] = useState({ stream: null, trackIds: null }); - - const cleanMiddlewareFnRef = useRef<(() => void) | null>(null); - - const stream = state.stream ?? null; - const tracksMiddleware = state.tracksMiddleware ?? null; - const [mediaVideoTrack, mediaAudioTrack] = stream ? getTracksFromStream(stream) : [null, null]; - - const getDisplayName = useCallback(() => { - const name = fishjamClient.getLocalPeer()?.metadata?.peer?.displayName; - if (typeof name === "string") return name; - }, [fishjamClient]); - - const addTrackToFishjamClient = useCallback( - async (track: MediaStreamTrack, trackMetadata: TrackMetadata) => { - try { - return fishjamClient.addTrack(track, trackMetadata); - } catch (err) { - if (err instanceof TrackTypeError) { - logger.warn(err.message); - return undefined; - } - throw err; - } - }, - [fishjamClient, logger], - ); - - const startStreaming: UseScreenshareResult["startStreaming"] = useCallback( - async (props) => { - const displayStream = await navigator.mediaDevices.getDisplayMedia({ - video: props?.videoConstraints ?? true, - audio: props?.audioConstraints ?? true, - }); - - const displayName = getDisplayName(); - - let [video, audio] = getTracksFromStream(displayStream); - - if (tracksMiddleware) { - const { videoTrack, audioTrack, onClear } = await tracksMiddleware(video, audio); - video = videoTrack; - audio = audioTrack; - cleanMiddlewareFnRef.current = onClear; - } - - // TODO: FCE-2659 Refactor this hook so this check is not required. - // This check is needed to support screensharing in livestreams which don't use the FishjamClient. - // trackIds are simply ignored because they are not used in this use case. - if (fishjamClient.status === "initialized") { - const addTrackPromises = [ - addTrackToFishjamClient(video, { displayName, type: "screenShareVideo", paused: false }), - ]; - if (audio) - addTrackPromises.push( - addTrackToFishjamClient(audio, { displayName, type: "screenShareAudio", paused: false }), - ); - - const [videoId, audioId] = await Promise.all(addTrackPromises); - setState({ stream: displayStream, trackIds: { videoId, audioId } }); - } else { - setState({ stream: displayStream, trackIds: {} }); - } - }, - [tracksMiddleware, getDisplayName, addTrackToFishjamClient, fishjamClient], - ); - - const replaceTracks = useCallback( - async (newVideoTrack: MediaStreamTrack, newAudioTrack: MediaStreamTrack | null) => { - if (!state?.stream) return; - - const addTrackPromises: Promise[] = []; - - if (newVideoTrack && state.trackIds.videoId) - addTrackPromises.push(fishjamClient.replaceTrack(state.trackIds.videoId, newVideoTrack)); - if (newAudioTrack && state.trackIds.audioId) - addTrackPromises.push(fishjamClient.replaceTrack(state.trackIds.audioId, newAudioTrack)); - - await Promise.all(addTrackPromises); - }, - [state.stream, state.trackIds?.videoId, state.trackIds?.audioId, fishjamClient], - ); - - const cleanMiddleware = useCallback(() => { - cleanMiddlewareFnRef.current?.(); - cleanMiddlewareFnRef.current = null; - }, []); - - const setTracksMiddleware = useCallback( - async (middleware: TracksMiddleware | null): Promise => { - if (!state?.stream) return; - - const [video, audio] = getTracksFromStream(state.stream); - - cleanMiddleware(); - - const { videoTrack, audioTrack, onClear } = (await middleware?.(video, audio)) ?? { - videoTrack: video, - audioTrack: audio, - onClear: null, - }; - cleanMiddlewareFnRef.current = onClear; - await replaceTracks(videoTrack, audioTrack); - }, - [state.stream, cleanMiddleware, replaceTracks], - ); - - // Stable identity with a live closure: peerStatus must be observed at call - // time so a captured reference doesn't skip the SFU removeTrack calls. - const stopStreaming: UseScreenshareResult["stopStreaming"] = useCurrentCallback(async () => { - if (!state.stream) { - logger.warn("No stream to stop"); - return; - } - const [video, audio] = getTracksFromStream(state.stream); - - video.stop(); - if (audio) audio.stop(); - - if (peerStatus === "connected") { - const removeTrackPromises: Promise[] = []; - if (state.trackIds.videoId) removeTrackPromises.push(fishjamClient.removeTrack(state.trackIds.videoId)); - if (state.trackIds.audioId) removeTrackPromises.push(fishjamClient.removeTrack(state.trackIds.audioId)); - - await Promise.all(removeTrackPromises); - } - - cleanMiddleware(); - setState((prev) => ({ stream: null, trackIds: null, tracksMiddleware: prev.tracksMiddleware })); - }); - - useEffect(() => { - if (!state.stream) return; - const [video, audio] = getTracksFromStream(state.stream); - - const trackEndedHandler = () => { - void stopStreaming().catch((err) => { - logger.error(err); - }); - }; - - video.addEventListener("ended", trackEndedHandler); - audio?.addEventListener("ended", trackEndedHandler); - - return () => { - video.removeEventListener("ended", trackEndedHandler); - audio?.removeEventListener("ended", trackEndedHandler); - }; - }, [state, stopStreaming, logger]); - - useEffect(() => { - const onDisconnected = () => { - if (!stream) return; - void stopStreaming().catch((err) => { - logger.error(err); - }); - }; - fishjamClient.on("disconnected", onDisconnected); - - return () => { - fishjamClient.removeListener("disconnected", onDisconnected); - }; - }, [stopStreaming, fishjamClient, stream, logger]); - - return { - startStreaming, - stopStreaming, - stream, - videoTrack: mediaVideoTrack, - audioTrack: mediaAudioTrack, - setTracksMiddleware, - currentTracksMiddleware: tracksMiddleware, - }; -}; - -const getTracksFromStream = (stream: MediaStream): [MediaStreamTrack, MediaStreamTrack | null] => { - const video = stream.getVideoTracks()[0]; - const audio = stream.getAudioTracks()[0] ?? null; - - return [video, audio]; -}; diff --git a/packages/react-client/src/hooks/internal/useTrackManager.ts b/packages/react-client/src/hooks/internal/useTrackManager.ts deleted file mode 100644 index 0f5440a9e..000000000 --- a/packages/react-client/src/hooks/internal/useTrackManager.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { type FishjamClient, type Logger, type TrackMetadata, TrackTypeError, Variant } from "@fishjam-cloud/ts-client"; -import { useEffect, useRef } from "react"; - -import type { TrackManager } from "../../types/internal"; -import type { BandwidthLimits, PeerStatus, StreamConfig, TrackMiddleware } from "../../types/public"; -import { getConfigAndBandwidthFromProps, getRemoteOrLocalTrack } from "../../utils/track"; -import type { DeviceManager } from "./devices/useDeviceManager"; -import { useCurrentCallback } from "./useCurrentCallback"; - -interface TrackManagerConfig { - deviceManager: DeviceManager; - tsClient: FishjamClient; - peerStatus: PeerStatus; - bandwidthLimits: BandwidthLimits; - streamConfig?: StreamConfig; - type: "camera" | "microphone"; - logger: Logger; -} - -export const useTrackManager = ({ - deviceManager, - tsClient, - peerStatus, - bandwidthLimits, - streamConfig, - type, - logger, -}: TrackManagerConfig): TrackManager => { - const currentTrackIdRef = useRef(null); - const connectionPromiseRef = useRef | null>(null); - - const { - startDevice, - stopDevice, - enableDevice, - disableDevice, - deviceTrack, - applyMiddleware, - currentMiddleware, - selectDevice: _selectDevice, - } = deviceManager; - - // Read live deviceTrack from the `joined` listener without re-subscribing - // every time it changes. - const getDeviceTrack = useCurrentCallback(() => deviceTrack); - - const getCurrentTrackId = async (): Promise => { - if (connectionPromiseRef.current) { - await connectionPromiseRef.current; - } - const refTrackId = currentTrackIdRef.current; - if (!refTrackId) return null; - const currentTrack = getRemoteOrLocalTrack(tsClient, refTrackId); - return currentTrack?.trackId ?? null; - }; - - const selectDevice = useCurrentCallback(async (deviceId: string) => { - const result = await _selectDevice(deviceId); - if (!result) return; - - const [newTrack, error] = result; - if (error) return error; - - const currentTrackId = await getCurrentTrackId(); - if (!currentTrackId) return; - - await tsClient.replaceTrack(currentTrackId, newTrack); - }); - - const setTrackMiddleware = useCurrentCallback(async (middleware: TrackMiddleware) => { - const processedTrack = await applyMiddleware(middleware); - - const currentTrackId = await getCurrentTrackId(); - if (!currentTrackId) return; - - await tsClient.replaceTrack(currentTrackId, processedTrack); - }); - - const startStreaming = useCurrentCallback( - async ( - track: MediaStreamTrack, - props: StreamConfig = { sentQualities: [Variant.VARIANT_LOW, Variant.VARIANT_MEDIUM, Variant.VARIANT_HIGH] }, - ) => { - // temporarily setting the local trackId until we have the remoteTrackId - currentTrackIdRef.current = track.id; - - const trackMetadata: TrackMetadata = { type, paused: false }; - - const displayName = tsClient.getLocalPeer()?.metadata?.peer?.displayName; - if (typeof displayName === "string") { - trackMetadata.displayName = displayName; - } - - const [maxBandwidth, simulcastConfig] = getConfigAndBandwidthFromProps(props.sentQualities, bandwidthLimits); - - try { - const addTrackJob = tsClient.addTrack(track, trackMetadata, simulcastConfig, maxBandwidth); - connectionPromiseRef.current = addTrackJob; - const remoteTrackId = await addTrackJob; - currentTrackIdRef.current = remoteTrackId; - } catch (err) { - if (err instanceof TrackTypeError) { - logger.warn(err.message); - currentTrackIdRef.current = null; - } - throw err; - } - }, - ); - - const pauseStreaming = useCurrentCallback(async (trackId: string) => { - if (peerStatus !== "connected") return; - await tsClient.replaceTrack(trackId, null); - return tsClient.updateTrackMetadata(trackId, { type, paused: true } satisfies TrackMetadata); - }); - - const resumeStreaming = useCurrentCallback(async (trackId: string, track: MediaStreamTrack) => { - if (peerStatus !== "connected") return; - await tsClient.replaceTrack(trackId, track); - return tsClient.updateTrackMetadata(trackId, { type, paused: false } satisfies TrackMetadata); - }); - - /** - * @see {@link TrackManager#toggleMute} for more details. - */ - const toggleMute = useCurrentCallback(async () => { - const currentTrackId = await getCurrentTrackId(); - const isTrackCurrentlyEnabled = Boolean(deviceTrack?.enabled); - if (!currentTrackId) { - logger.warn("Toggling mute is only possible while connected to a room."); - return; - } - - if (isTrackCurrentlyEnabled) { - disableDevice(); - await pauseStreaming(currentTrackId); - } else if (deviceTrack) { - enableDevice(); - await resumeStreaming(currentTrackId, deviceTrack); - } - }); - - /** - * @see {@link TrackManager#toggleDevice} for more details. - */ - const toggleDevice = useCurrentCallback(async () => { - const currentTrackId = await getCurrentTrackId(); - if (deviceTrack) { - stopDevice(); - if (currentTrackId) { - await pauseStreaming(currentTrackId); - } - } else { - const [newTrack, error] = await startDevice(); - if (error) return error; - - if (currentTrackId) { - await resumeStreaming(currentTrackId, newTrack); - } else if (peerStatus === "connected") { - await startStreaming(newTrack, streamConfig); - } - } - }); - - useEffect(() => { - const onJoinedRoom = () => { - const currentDeviceTrack = getDeviceTrack(); - if (!currentDeviceTrack) return; - // The handler is sync; observe rejections so non-TrackTypeError failures - // from addTrack don't surface as unhandledrejection. - void startStreaming(currentDeviceTrack, streamConfig).catch((err) => { - if (err instanceof TrackTypeError) return; - logger.error(err); - }); - }; - - const onLeftRoom = () => { - currentTrackIdRef.current = null; - }; - - tsClient.on("joined", onJoinedRoom); - tsClient.on("disconnected", onLeftRoom); - return () => { - tsClient.off("joined", onJoinedRoom); - tsClient.off("disconnected", onLeftRoom); - }; - }, [startStreaming, tsClient, streamConfig, getDeviceTrack, logger]); - - return { - deviceTrack, - currentMiddleware, - setTrackMiddleware, - selectDevice, - toggleMute, - toggleDevice, - stopDevice, - startDevice, - }; -}; diff --git a/packages/react-client/src/hooks/internal/useTrackMiddleware.ts b/packages/react-client/src/hooks/internal/useTrackMiddleware.ts deleted file mode 100644 index 34e591791..000000000 --- a/packages/react-client/src/hooks/internal/useTrackMiddleware.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; - -import type { TrackMiddleware } from "../../types/public"; - -export const useTrackMiddleware = (rawTrack: MediaStreamTrack | null) => { - const [currentMiddleware, setMiddleware] = useState(null); - const [processedTrack, setProcessedTrack] = useState(null); - const cleanupRef = useRef<(() => void) | undefined>(undefined); - - useEffect(() => { - if (!rawTrack && processedTrack) { - processedTrack.stop(); - cleanupRef.current?.(); - setProcessedTrack(null); - } - }, [rawTrack, processedTrack]); - - const applyMiddleware = useCallback( - async (newMiddleware: TrackMiddleware) => { - cleanupRef.current?.(); - setMiddleware(() => newMiddleware); - - if (newMiddleware && rawTrack) { - const { track, onClear } = await newMiddleware(rawTrack); - cleanupRef.current = onClear; - setProcessedTrack(track); - return track; - } - - setProcessedTrack(null); - return rawTrack; - }, - [rawTrack], - ); - - return { processedTrack, applyMiddleware, currentMiddleware }; -}; diff --git a/packages/react-client/src/hooks/useConnection.ts b/packages/react-client/src/hooks/useConnection.ts index 922aca4a8..e26685459 100644 --- a/packages/react-client/src/hooks/useConnection.ts +++ b/packages/react-client/src/hooks/useConnection.ts @@ -1,10 +1,10 @@ import type { GenericMetadata } from "@fishjam-cloud/ts-client"; +import { httpToWebsocketUrl, resolveFishjamUrl } from "@fishjam-cloud/tsunami"; import { useCallback, useContext } from "react"; import { FishjamClientContext } from "../contexts/fishjamClient"; import { useFishjamId } from "../contexts/fishjamId"; import { PeerStatusContext } from "../contexts/peerStatus"; -import { httpToWebsocketUrl, resolveFishjamUrl } from "../utils/fishjamUrl"; import { useReconnection } from "./internal/useReconnection"; export interface JoinRoomConfig { diff --git a/packages/react-client/src/hooks/useDataChannel.ts b/packages/react-client/src/hooks/useDataChannel.ts index 77de19f47..5adb3b33b 100644 --- a/packages/react-client/src/hooks/useDataChannel.ts +++ b/packages/react-client/src/hooks/useDataChannel.ts @@ -1,104 +1,53 @@ import type { DataCallback, DataChannelOptions } from "@fishjam-cloud/ts-client"; -import { useCallback, useContext, useEffect, useState } from "react"; +import { useCallback, useContext, useSyncExternalStore } from "react"; import { FishjamClientContext } from "../contexts/fishjamClient"; -import { PeerStatusContext } from "../contexts/peerStatus"; import type { UseDataChannelResult } from "../types/public"; -import { useCurrentCallback } from "./internal/useCurrentCallback"; /** - * Hook for data channel operations - publish and subscribe to data. + * Hook for managing data channels: initialization, publishing and subscribing to data. * * @category Connection * @group Hooks */ export function useDataChannel(): UseDataChannelResult { const fishjamClientRef = useContext(FishjamClientContext); - const peerStatus = useContext(PeerStatusContext); - if (!fishjamClientRef) throw Error("useDataPublisher must be used within FishjamProvider"); - const client = fishjamClientRef.current; - const [ready, setReady] = useState(false); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - const publisherReady = client.getDataChannelsReadiness(); - setReady(publisherReady); - const handleReady = () => { - setReady(true); - setError(null); - }; - const handleDisconnect = () => { - setReady(false); - }; - const handleError = (err: Error) => { - setReady(false); - setLoading(false); - setError(err); - }; - - client.on("dataChannelsReady", handleReady); - client.on("dataChannelsError", handleError); - client.on("disconnected", handleDisconnect); + const dataChannel = useSyncExternalStore( + client.subscribe, + useCallback(() => client.getState().dataChannel, [client]), + ); - return () => { - client.removeListener("dataChannelsReady", handleReady); - client.removeListener("disconnected", handleDisconnect); - }; + const initializeDataChannel = useCallback(() => { + // Failures surface through the state slice, matching the hook's + // historical non-throwing contract. + void client.createDataChannels().catch(() => undefined); }, [client]); - // Stable identity with a live closure: peerStatus / loading / ready must be - // observed at call time so a captured reference doesn't reject with a stale - // "Peer is not connected" right after the connect promise settles. - const initialize = useCurrentCallback(async () => { - if (loading || ready) return; - - if (peerStatus !== "connected") { - setError(new Error("Peer is not connected")); - return; - } - - try { - setLoading(true); - await client.createDataChannels(); - } catch (err) { - if (err instanceof Error) { - setError(err); - } - } finally { - setLoading(false); - } - }); - const publishData = useCallback( - (data: Uint8Array, options: DataChannelOptions) => { + (payload: Uint8Array, options: DataChannelOptions) => { try { - client.publishData(data, options); - } catch (err) { - if (err instanceof Error) { - setError(err); - } + client.publishData(payload, options); + } catch { + // Mirrored into the state slice by the client. } }, [client], ); const subscribeData = useCallback( - (callback: DataCallback, options: DataChannelOptions): (() => void) => { - return client.subscribeData(callback, options); - }, + (callback: DataCallback, options: DataChannelOptions) => client.subscribeData(callback, options), [client], ); return { + initializeDataChannel, publishData, subscribeData, - initializeDataChannel: initialize, - dataChannelReady: ready, - dataChannelLoading: loading, - dataChannelError: error, + dataChannelReady: dataChannel.status === "ready", + dataChannelLoading: dataChannel.status === "creating", + dataChannelError: dataChannel.error, }; } diff --git a/packages/react-client/src/hooks/useLivestreamStreamer.ts b/packages/react-client/src/hooks/useLivestreamStreamer.ts index edf2d5052..b4d20ea19 100644 --- a/packages/react-client/src/hooks/useLivestreamStreamer.ts +++ b/packages/react-client/src/hooks/useLivestreamStreamer.ts @@ -1,8 +1,9 @@ import { LivestreamError, publishLivestream, type PublishLivestreamResult } from "@fishjam-cloud/ts-client"; -import { useCallback, useRef, useState } from "react"; +import { buildLivestreamWhipUrl } from "@fishjam-cloud/tsunami"; +import { useCallback, useContext, useRef, useState } from "react"; +import { FishjamClientContext } from "../contexts/fishjamClient"; import { useFishjamId } from "../contexts/fishjamId"; -import { buildLivestreamWhipUrl } from "../utils/fishjamUrl"; /** @category Livestream */ export type StreamerInputs = @@ -53,6 +54,8 @@ export const useLivestreamStreamer = (): UseLivestreamStreamerResult => { const [error, setError] = useState(null); const [isConnected, setIsConnected] = useState(false); const fishjamId = useFishjamId(); + const fishjamClientRef = useContext(FishjamClientContext); + if (!fishjamClientRef) throw Error("useLivestreamStreamer must be used within FishjamProvider"); const resultRef = useRef(null); const disconnect = useCallback(() => { @@ -74,7 +77,8 @@ export const useLivestreamStreamer = (): UseLivestreamStreamerResult => { const videoTrack = video?.getVideoTracks().at(0); const audioTrack = audio?.getAudioTracks().at(0); - const stream = new MediaStream([videoTrack, audioTrack].filter((v) => v != null)); + const tracks = [videoTrack, audioTrack].filter((track) => track != null); + const stream = fishjamClientRef.current.createMediaStream(tracks) as MediaStream; try { const result = await publishLivestream(stream, urlOverride ?? buildLivestreamWhipUrl(fishjamId), token, { @@ -87,7 +91,7 @@ export const useLivestreamStreamer = (): UseLivestreamStreamerResult => { else console.error(e); } }, - [disconnect, onConnectionStateChange, fishjamId], + [disconnect, onConnectionStateChange, fishjamId, fishjamClientRef], ); return { connect, disconnect, error, isConnected }; diff --git a/packages/react-client/src/hooks/useLivestreamViewer.ts b/packages/react-client/src/hooks/useLivestreamViewer.ts index b88dab495..650143dff 100644 --- a/packages/react-client/src/hooks/useLivestreamViewer.ts +++ b/packages/react-client/src/hooks/useLivestreamViewer.ts @@ -1,8 +1,8 @@ import { LivestreamError, receiveLivestream, type ReceiveLivestreamResult } from "@fishjam-cloud/ts-client"; +import { buildLivestreamWhepUrl } from "@fishjam-cloud/tsunami"; import { useCallback, useRef, useState } from "react"; import { useFishjamId } from "../contexts/fishjamId"; -import { buildLivestreamWhepUrl } from "../utils/fishjamUrl"; export type ConnectViewerConfig = { token: string; streamId?: never } | { streamId: string; token?: never }; diff --git a/packages/react-client/src/hooks/useLocalVAD.ts b/packages/react-client/src/hooks/useLocalVAD.ts deleted file mode 100644 index 8c8578fd6..000000000 --- a/packages/react-client/src/hooks/useLocalVAD.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { useContext, useEffect, useState } from "react"; - -import { FishjamClientContext } from "../contexts/fishjamClient"; -import type { PeerId } from "../types/public"; -import { usePeers } from "./usePeers"; - -// This is a dBov-to-linear conversion. -32 dBov number is taken from backend VAD threshold -// formula for dBov to linear conversion: linear = 10 ^ (dBov / 20) -// So -32 dBov = 10^(-32/20) ≈ 0.025. This is the minimum audio level considered "speech". -const THRESHOLD = 10 ** (-32 / 20); - -// Number of consecutive "silence" ticks before we consider speech to have stopped. Helps with smoothing out brief pauses in speech. -const SILENCE_DEBOUNCE_TICKS = 2; - -/** - * Client-side voice activity detection for the local peer. - * - * Polls the local microphone's audio level every 100ms and derives a speech/silence - * state from it. A level above ~0.025 (approximately −32 dBov, scaled to [0, 1]) - * is treated as speech. Silence is debounced over 2 consecutive ticks (~200ms) - * to prevent rapid flapping. - * - * This is purely client-side — it does not signal other peers. Remote participants - * receive the local peer's VAD status via backend `vadNotification` messages. - * - * @internal Used by `useVAD` when the local peer's id is included in `peerIds`. - * @returns A record mapping the local peer's id to its current speaking state, - * or an empty object if `options.disabled` is true, the local peer is not available, or no microphone track is found. - */ -export const useLocalVAD = (options: { disabled: boolean }): Record => { - const fishjamClient = useContext(FishjamClientContext); - const [isSpeaking, setIsSpeaking] = useState(false); - const { localPeer } = usePeers(); - const localPeerId = localPeer?.id; - const microphoneTrackId = localPeer?.microphoneTrack?.trackId; - - useEffect(() => { - if (options.disabled || !localPeerId || !microphoneTrackId) return; - - let silenceTicks = 0; - let timeoutId: ReturnType | undefined; - const controller = new AbortController(); - const { signal } = controller; - - const poll = async () => { - if (signal.aborted) return; - - const trackAudio = await fishjamClient?.current?.getLocalTrackAudioLevel(microphoneTrackId); - if (signal.aborted) return; - - if (trackAudio != null && trackAudio.level > THRESHOLD) { - silenceTicks = 0; - setIsSpeaking(true); - } else { - silenceTicks += 1; - if (silenceTicks >= SILENCE_DEBOUNCE_TICKS) { - setIsSpeaking(false); - } - } - - if (signal.aborted) return; - timeoutId = setTimeout(poll, 100); - }; - - timeoutId = setTimeout(poll, 0); - - return () => { - controller.abort(); - clearTimeout(timeoutId); - setIsSpeaking(false); - }; - }, [options.disabled, fishjamClient, localPeerId, microphoneTrackId]); - - if (!localPeerId || options.disabled || !microphoneTrackId) return {}; - return { [localPeerId]: isSpeaking }; -}; diff --git a/packages/react-client/src/hooks/usePeers.ts b/packages/react-client/src/hooks/usePeers.ts index f5d1864e9..c58dd7e66 100644 --- a/packages/react-client/src/hooks/usePeers.ts +++ b/packages/react-client/src/hooks/usePeers.ts @@ -1,18 +1,10 @@ -import type { - EncodingReason, - FishjamClient, - Metadata, - Peer, - SimulcastConfig, - TrackMetadata, - Variant, -} from "@fishjam-cloud/ts-client"; +import type { Metadata, Variant } from "@fishjam-cloud/ts-client"; +import { localPeerWithTracks, remotePeerWithTracks } from "@fishjam-cloud/tsunami"; import { useCallback, useContext } from "react"; import { FishjamClientContext } from "../contexts/fishjamClient"; import { FishjamClientStateContext } from "../contexts/fishjamState"; -import type { BrandedPeer } from "../types/internal"; -import type { PeerId, RemoteTrack, Track, TrackId } from "../types/public"; +import type { PeerId, RemoteTrack, Track } from "../types/public"; /** * @@ -31,79 +23,6 @@ export type PeerWithTracks { - fishjamClient.setTargetTrackEncoding(track.trackId, encoding); - }, - }; -} - -function getLocalPeerWithTracks(peer: BrandedPeer): PeerWithTracks { - const tracks = [...peer.tracks.values()].map(trackContextToTrack); - - return { - id: peer.id, - metadata: peer.metadata as Peer["metadata"], - tracks, - cameraTrack: tracks.find(({ metadata }) => metadata?.type === "camera"), - microphoneTrack: tracks.find(({ metadata }) => metadata?.type === "microphone"), - screenShareVideoTrack: tracks.find(({ metadata }) => metadata?.type === "screenShareVideo"), - screenShareAudioTrack: tracks.find(({ metadata }) => metadata?.type === "screenShareAudio"), - customVideoTracks: tracks.filter(({ metadata }) => metadata?.type === "customVideo"), - customAudioTracks: tracks.filter(({ metadata }) => metadata?.type === "customAudio"), - }; -} - -function getRemotePeerWithTracks( - peer: BrandedPeer, - fishjamClient: FishjamClient, -): PeerWithTracks { - const tracks = [...peer.tracks.values()].map((track) => trackContextToRemoteTrack(track, fishjamClient)); - - return { - id: peer.id, - metadata: peer.metadata as Peer["metadata"], - tracks, - cameraTrack: tracks.find(({ metadata }) => metadata?.type === "camera"), - microphoneTrack: tracks.find(({ metadata }) => metadata?.type === "microphone"), - screenShareVideoTrack: tracks.find(({ metadata }) => metadata?.type === "screenShareVideo"), - screenShareAudioTrack: tracks.find(({ metadata }) => metadata?.type === "screenShareAudio"), - customVideoTracks: tracks.filter(({ metadata }) => metadata?.type === "customVideo"), - customAudioTracks: tracks.filter(({ metadata }) => metadata?.type === "customAudio"), - }; -} - /** * Hook allows to access id, tracks and metadata of the local and remote peers. * @@ -117,18 +36,19 @@ export function usePeers, ServerMetadata const fishjamClient = useContext(FishjamClientContext); if (!clientState || !fishjamClient) throw Error("usePeers must be used within FishjamProvider"); - const localPeer: PeerWithTracks | null = clientState.localPeer - ? getLocalPeerWithTracks( - clientState.localPeer as BrandedPeer, - ) + // The tsunami views are structurally identical to the public shapes; the + // casts reintroduce the branded ids and DOM media types of the public API. + const localPeer = clientState.localPeer + ? (localPeerWithTracks(clientState.localPeer) as unknown as PeerWithTracks) : null; - const remotePeers: PeerWithTracks[] = Object.values(clientState.peers).map( + const remotePeers = Object.values(clientState.peers).map( (peer) => - getRemotePeerWithTracks( - peer as BrandedPeer, - fishjamClient.current, - ), + remotePeerWithTracks(peer, fishjamClient.current) as unknown as PeerWithTracks< + PeerMetadata, + ServerMetadata, + RemoteTrack + >, ); const setReceivedTracksQuality = useCallback( diff --git a/packages/react-client/src/hooks/useSandbox.ts b/packages/react-client/src/hooks/useSandbox.ts index 57d54572d..0ae3f7cc4 100644 --- a/packages/react-client/src/hooks/useSandbox.ts +++ b/packages/react-client/src/hooks/useSandbox.ts @@ -1,124 +1,44 @@ +import { + getSandboxLivestream, + getSandboxMoqPublisherAccess, + getSandboxMoqSubscriberAccess, + getSandboxPeerToken, + getSandboxViewerToken, + type MoqAccess, + type RoomType, +} from "@fishjam-cloud/tsunami"; import { useCallback } from "react"; -import { MissingSandboxApiUrlError } from "../utils/errors"; - -type BasicInfo = { id: string; name: string }; -type RoomManagerResponse = { - peerToken: string; - url: string; - room: BasicInfo; - peer: BasicInfo; -}; - -type MoqAccessResponse = { - connection_url: string; - token: string; -}; - -export type MoqAccess = { - connectionUrl: string; - token: string; -}; +export type { MoqAccess, RoomType }; export type UseSandboxProps = { sandboxApiUrl: string; }; -export type RoomType = "conference" | "livestream" | "audio_only"; - export const useSandbox = (props: UseSandboxProps) => { const sandboxApiUrl = props?.sandboxApiUrl; - const getSandboxPeerToken = useCallback( - async (roomName: string, peerName: string, roomType: RoomType = "conference") => { - if (!sandboxApiUrl) throw new MissingSandboxApiUrlError(); - - const url = new URL(sandboxApiUrl); - url.searchParams.set("roomName", roomName); - url.searchParams.set("peerName", peerName); - url.searchParams.set("roomType", roomType); - - const res = await fetch(url); - - if (!res.ok) { - const message = `Failed to retrieve peer token for peer '${peerName}' in ${roomType} room '${roomName}'.`; - throw new Error(message); - } - - const data: RoomManagerResponse = await res.json(); - return data.peerToken; - }, - [sandboxApiUrl], - ); - - const getSandboxViewerToken = useCallback( - async (roomName: string) => { - if (!sandboxApiUrl) throw new MissingSandboxApiUrlError(); - - const url = new URL(`${sandboxApiUrl}/${roomName}/livestream-viewer-token`); - - const res = await fetch(url); - if (!res.ok) { - let message = `Failed to retrieve viewer token for '${roomName}' livestream room.`; - if (res.status === 404) { - message = `A livestream room of name '${roomName}' does not exist.`; - } - throw new Error(message); - } - const data: { token: string } = await res.json(); - - return data.token; - }, - [sandboxApiUrl], - ); - - const getSandboxLivestream = useCallback( - async (roomName: string, isPublic: boolean = false) => { - if (!sandboxApiUrl) throw new MissingSandboxApiUrlError(); - - const url = new URL(`${sandboxApiUrl}/livestream`); - url.searchParams.set("roomName", roomName); - url.searchParams.set("public", isPublic.toString()); - - const res = await fetch(url); - if (!res.ok) throw new Error(`Failed to retrieve streamer token for '${roomName}' livestream room.`); - - const data: { streamerToken: string; room: { id: string; name: string } } = await res.json(); - return data; - }, - [sandboxApiUrl], - ); - - const fetchMoqAccess = useCallback( - async (streamName: string, type: "subscriber" | "publisher"): Promise => { - if (!sandboxApiUrl) throw new MissingSandboxApiUrlError(); - - const urlEncodedStreamName = encodeURIComponent(streamName); - - const res = await fetch(`${sandboxApiUrl}/moq/${urlEncodedStreamName}/${type}`); - if (!res.ok) throw new Error(`Failed to retrieve MoQ ${type} connection for stream '${streamName}'.`); - - const data: MoqAccessResponse = await res.json(); - return { connectionUrl: data.connection_url, token: data.token }; - }, - [sandboxApiUrl], - ); - - const getSandboxMoqPublisherAccess = useCallback( - async (streamName: string) => fetchMoqAccess(streamName, "publisher"), - [fetchMoqAccess], - ); - - const getSandboxMoqSubscriberAccess = useCallback( - async (streamName: string) => fetchMoqAccess(streamName, "subscriber"), - [fetchMoqAccess], - ); - return { - getSandboxPeerToken, - getSandboxViewerToken, - getSandboxLivestream, - getSandboxMoqPublisherAccess, - getSandboxMoqSubscriberAccess, + getSandboxPeerToken: useCallback( + (roomName: string, peerName: string, roomType: RoomType = "conference") => + getSandboxPeerToken(sandboxApiUrl, roomName, peerName, roomType), + [sandboxApiUrl], + ), + getSandboxViewerToken: useCallback( + (roomName: string) => getSandboxViewerToken(sandboxApiUrl, roomName), + [sandboxApiUrl], + ), + getSandboxLivestream: useCallback( + (roomName: string, isPublic: boolean = false) => getSandboxLivestream(sandboxApiUrl, roomName, isPublic), + [sandboxApiUrl], + ), + getSandboxMoqPublisherAccess: useCallback( + (streamName: string) => getSandboxMoqPublisherAccess(sandboxApiUrl, streamName), + [sandboxApiUrl], + ), + getSandboxMoqSubscriberAccess: useCallback( + (streamName: string) => getSandboxMoqSubscriberAccess(sandboxApiUrl, streamName), + [sandboxApiUrl], + ), }; }; diff --git a/packages/react-client/src/hooks/useVAD.ts b/packages/react-client/src/hooks/useVAD.ts index 8c5068e6e..3ec863093 100644 --- a/packages/react-client/src/hooks/useVAD.ts +++ b/packages/react-client/src/hooks/useVAD.ts @@ -1,91 +1,29 @@ -import { useContext, useEffect, useMemo, useReducer } from "react"; +import { useContext, useMemo, useSyncExternalStore } from "react"; -import { FishjamClientStateContext } from "../contexts/fishjamState"; +import { FishjamClientContext } from "../contexts/fishjamClient"; import type { PeerId } from "../types/public"; -import { useLocalVAD } from "./useLocalVAD"; /** - * Voice activity detection. Use this hook to check if voice is detected in the audio track for given peer(s). + * Hook that reports which of the requested peers are currently speaking. * - * Remote peer VAD is driven by `vadNotification` messages from the backend. - * If the local peer's id is included in `peerIds`, local VAD is determined client-side - * by polling the microphone's audio level (see `useLocalVAD`). + * Remote peers are voice-activity-detected by the backend; the local peer is + * detected by sampling the microphone's audio level. * - * @param options - Options object. - * @param options.peerIds - List of peer ids to subscribe to for VAD notifications. - * Include the local peer's id to also track whether the local user is speaking. - * - * Example usage: - * ```tsx - * import { useVAD, type PeerId } from "@fishjam-cloud/react-client"; - * - * function WhoIsTalkingComponent({ peerIds }: { peerIds: PeerId[] }) { - * const peersInfo = useVAD({ peerIds }); - * const activePeers = (Object.keys(peersInfo) as PeerId[]).filter((peerId) => peersInfo[peerId]); - * - * return "Now talking: " + activePeers.join(", "); - * } - * ``` * @category Connection * @group Hooks - * @returns A record where each key is a peer id and the boolean value indicates - * whether voice activity is currently detected for that peer. */ -export const useVAD = (options: { peerIds: ReadonlyArray }): Record => { - const { peerIds } = options; - const clientState = useContext(FishjamClientStateContext); - if (!clientState) throw Error("useVAD must be used within FishjamProvider"); - const showLocalPeerVAD = useMemo( - () => (clientState.localPeer?.id ? peerIds.includes(clientState.localPeer?.id) : false), - [clientState.localPeer?.id, peerIds], - ); - - const micTracksWithSelectedPeerIds = useMemo( - () => - Object.values(clientState.peers) - .filter((peer) => peerIds.includes(peer.id)) - .map((peer) => ({ - peerId: peer.id, - microphoneTrack: Array.from(peer.tracks.values()).find(({ metadata }) => metadata?.type === "microphone"), - })), - [clientState.peers, peerIds], - ); - - // `voiceActivityChanged` mutates the track context in place and does not flow through - // `useFishjamClientState`, so we need an explicit re-render trigger to re-read the - // current `vadStatus` off each mic track. - const [version, bumpVersion] = useReducer((n: number) => n + 1, 0); - - useEffect(() => { - const unsubs = micTracksWithSelectedPeerIds.map(({ microphoneTrack }) => { - if (!microphoneTrack) return () => {}; - - microphoneTrack.on("voiceActivityChanged", bumpVersion); - - return () => { - microphoneTrack.off("voiceActivityChanged", bumpVersion); - }; - }); - - return () => unsubs.forEach((unsub) => unsub()); - }, [micTracksWithSelectedPeerIds]); - - const localVAD = useLocalVAD({ disabled: !showLocalPeerVAD }); - - const vadStatuses = useMemo(() => { - // Referencing `version` makes the memo recompute on every `voiceActivityChanged` - // event, so we re-read each current mic track's mutable `vadStatus`. - void version; - return { - ...Object.fromEntries( - micTracksWithSelectedPeerIds.map(({ peerId, microphoneTrack }) => [ - peerId, - microphoneTrack?.vadStatus === "speech", - ]), - ), - ...localVAD, - } satisfies Record; - }, [micTracksWithSelectedPeerIds, localVAD, version]); - - return vadStatuses; -}; +export function useVAD(options: { peerIds: ReadonlyArray }): Record { + const fishjamClientRef = useContext(FishjamClientContext); + if (!fishjamClientRef) throw Error("useVAD must be used within FishjamProvider"); + const client = fishjamClientRef.current; + + const voiceActivity = useSyncExternalStore(client.subscribeToVoiceActivity, client.getVoiceActivity); + + return useMemo(() => { + const requested: Record = {}; + for (const peerId of options.peerIds) { + if (peerId in voiceActivity) requested[peerId] = voiceActivity[peerId]; + } + return requested; + }, [voiceActivity, options.peerIds]); +} diff --git a/packages/react-client/src/index.ts b/packages/react-client/src/index.ts index 5f404ec44..2d0558eeb 100644 --- a/packages/react-client/src/index.ts +++ b/packages/react-client/src/index.ts @@ -7,7 +7,6 @@ export { FishjamProvider, type FishjamProviderProps } from "./FishjamProvider"; export { useCamera } from "./hooks/devices/useCamera"; export { useInitializeDevices, UseInitializeDevicesParams } from "./hooks/devices/useInitializeDevices"; export { useMicrophone } from "./hooks/devices/useMicrophone"; -export { InitializeDevicesSettings } from "./hooks/internal/devices/useMediaDevices"; export { type JoinRoomConfig, useConnection } from "./hooks/useConnection"; export { useCustomSource } from "./hooks/useCustomSource"; export { useDataChannel } from "./hooks/useDataChannel"; @@ -49,6 +48,7 @@ export type { TracksMiddlewareResult, UseDataChannelResult, } from "./types/public"; +export { InitializeDevicesSettings } from "./types/public"; export type { AuthErrorReason, DataCallback, @@ -62,3 +62,4 @@ export type { TrackBandwidthLimit, } from "@fishjam-cloud/ts-client"; export { Variant } from "@fishjam-cloud/ts-client"; +export type { IDeviceManager, PlatformMediaStream, PlatformMediaStreamTrack } from "@fishjam-cloud/tsunami"; diff --git a/packages/react-client/src/tests/deviceManagerInjection.spec.ts b/packages/react-client/src/tests/deviceManagerInjection.spec.ts new file mode 100644 index 000000000..27c3db1db --- /dev/null +++ b/packages/react-client/src/tests/deviceManagerInjection.spec.ts @@ -0,0 +1,68 @@ +import type { DeviceItem, IDeviceManager, PlatformMediaStream } from "@fishjam-cloud/tsunami"; +import { act } from "@testing-library/react"; + +import { useCamera } from "../hooks/devices/useCamera"; +import { useInitializeDevices } from "../hooks/devices/useInitializeDevices"; +import { createFakeStream, FakeMediaStream } from "./support/fakeMediaStream"; +import { describe, expect, it, vi } from "./support/fixtures"; + +const fakeDevices: DeviceItem[] = [ + { deviceId: "native-cam", label: "Native Camera", kind: "video" }, + { deviceId: "native-mic", label: "Native Microphone", kind: "audio" }, +]; + +const createFakeDeviceManager = () => { + const stream = () => + createFakeStream([ + { kind: "video", deviceId: "native-cam" }, + { kind: "audio", deviceId: "native-mic" }, + ]); + + return { + enumerateDevices: vi.fn(async () => fakeDevices), + getUserMedia: vi.fn(async () => stream() as PlatformMediaStream), + getDisplayMedia: vi.fn(async () => stream() as PlatformMediaStream), + onDeviceChange: vi.fn(() => () => {}), + createMediaStream: vi.fn((tracks) => new FakeMediaStream(tracks as MediaStreamTrack[]) as PlatformMediaStream), + } satisfies IDeviceManager; +}; + +describe("FishjamProvider deviceManager injection", () => { + it("routes device acquisition through the injected manager, not the browser globals", async ({ + media, + renderHook, + }) => { + const deviceManager = createFakeDeviceManager(); + const { result } = renderHook(() => ({ init: useInitializeDevices(), camera: useCamera() }), { + providerProps: { deviceManager }, + }); + + await act(async () => { + await result.current.init.initializeDevices(); + }); + + expect(deviceManager.getUserMedia).toHaveBeenCalled(); + expect(deviceManager.enumerateDevices).toHaveBeenCalled(); + expect(media.devices.getUserMedia).not.toHaveBeenCalled(); + expect(media.devices.enumerateDevices).not.toHaveBeenCalled(); + + expect(result.current.camera.isCameraOn).toBe(true); + expect(result.current.camera.cameraDevices).toEqual([fakeDevices[0]]); + }); + + it("ignores persistLastDevice handlers when a manager is injected", async ({ renderHook }) => { + const deviceManager = createFakeDeviceManager(); + const persistHandlers = { getLastDevice: vi.fn(() => null), saveLastDevice: vi.fn() }; + + const { result } = renderHook(() => useInitializeDevices(), { + providerProps: { deviceManager, persistLastDevice: persistHandlers }, + }); + + await act(async () => { + await result.current.initializeDevices(); + }); + + expect(persistHandlers.getLastDevice).not.toHaveBeenCalled(); + expect(persistHandlers.saveLastDevice).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-client/src/tests/screenShare.spec.ts b/packages/react-client/src/tests/screenShare.spec.ts index 7a6d9f9cd..c8c384194 100644 --- a/packages/react-client/src/tests/screenShare.spec.ts +++ b/packages/react-client/src/tests/screenShare.spec.ts @@ -111,11 +111,9 @@ describe("useScreenShare", () => { // The middleware output is pushed to the SFU via replaceTrack. expect(client.replaceTrack).toHaveBeenCalled(); - // KNOWN QUIRK (FCE-3574): setTracksMiddleware never writes the middleware - // back into state, so `currentTracksMiddleware` stays null and the middleware - // is NOT re-applied on a subsequent startStreaming. Captured here so the - // rewrite has to make a deliberate decision to fix it (the assertion will - // flip when it does). - expect(result.current.currentTracksMiddleware).toBeNull(); + // FCE-3574 (fixed by the tsunami rewrite): the middleware is persisted, so + // `currentTracksMiddleware` reflects it and it is re-applied on a + // subsequent startStreaming. + expect(result.current.currentTracksMiddleware).not.toBeNull(); }); }); diff --git a/packages/react-client/src/types/internal.ts b/packages/react-client/src/types/internal.ts index f7f3abdbd..1f2f0a1ce 100644 --- a/packages/react-client/src/types/internal.ts +++ b/packages/react-client/src/types/internal.ts @@ -1,6 +1,6 @@ import type { Peer } from "@fishjam-cloud/ts-client"; -import type { DeviceError, PeerId, TrackMiddleware, TracksMiddleware } from "./public"; +import type { DeviceError, DeviceItem, PeerId, TrackMiddleware, TracksMiddleware } from "./public"; export type AudioVideo = { audio: T; video: T }; @@ -51,3 +51,62 @@ export type CustomSourceState = { stream: MediaStream; trackIds?: CustomSourceTracks; }; + +export type DeviceManager = { + startDevice: (deviceId?: string | null) => Promise<[MediaStreamTrack, null] | [null, DeviceError]>; + stopDevice: () => void; + selectDevice: (deviceId: string) => Promise<[MediaStreamTrack, null] | [null, DeviceError]> | undefined; + activeDevice: DeviceItem | null; + deviceTrack: MediaStreamTrack | null; + /** Render-ready stream containing `deviceTrack` — built by the platform device manager. */ + deviceStream: MediaStream | null; + deviceList: DeviceItem[]; + deviceEnabled: boolean; + enableDevice: () => void; + disableDevice: () => void; + currentMiddleware: TrackMiddleware; + applyMiddleware: (middleware: TrackMiddleware) => Promise; + deviceError: DeviceError | null; + selectedDevice: MediaDeviceInfo | null; +}; + +export type UseScreenshareResult = { + /** + * Invokes the screen sharing prompt in the user's browser and starts streaming upon approval. + */ + startStreaming: (props?: { + audioConstraints?: boolean | MediaTrackConstraints; + videoConstraints?: boolean | MediaTrackConstraints; + }) => Promise; + /** + * Stops the stream and cancels browser screen sharing. + */ + stopStreaming: () => Promise; + /** + * The MediaStream object containing both tracks. + */ + stream: MediaStream | null; + /** + * The separate video MediaStreamTrack. + */ + videoTrack: MediaStreamTrack | null; + /** + * The separate audio MediaStreamTrack. + */ + audioTrack: MediaStreamTrack | null; + /** + * The middleware currently assigned to process the tracks. + * By default, the middleware function returns the original track. + */ + currentTracksMiddleware: TracksMiddleware | null; + /** + * Sets a new middleware function to process the tracks. + * @param middleware The middleware function to set, which can be a TracksMiddleware function or null to remove the middleware. + */ + setTracksMiddleware: (middleware: TracksMiddleware | null) => Promise; +}; + +export type CustomSourceManager = { + setStream: (sourceId: string, stream: MediaStream | null) => Promise; + getSource: (sourceId: string) => CustomSourceState | undefined; +}; diff --git a/packages/react-client/src/types/public.ts b/packages/react-client/src/types/public.ts index 576558475..b7fc599a5 100644 --- a/packages/react-client/src/types/public.ts +++ b/packages/react-client/src/types/public.ts @@ -124,3 +124,5 @@ export type UseDataChannelResult = { */ dataChannelError: Error | null; }; + +export type InitializeDevicesSettings = { enableVideo?: boolean; enableAudio?: boolean }; diff --git a/packages/react-client/src/utils/bandwidth.ts b/packages/react-client/src/utils/bandwidth.ts deleted file mode 100644 index 6877946e0..000000000 --- a/packages/react-client/src/utils/bandwidth.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Variant } from "@fishjam-cloud/ts-client"; - -import type { BandwidthLimits } from "../types/public"; - -export const ALL_VARIANTS_SIMULCAST = [Variant.VARIANT_LOW, Variant.VARIANT_MEDIUM, Variant.VARIANT_HIGH] as const; - -export const mergeWithDefaultBandwitdthLimits = (limits?: Partial): BandwidthLimits => ({ - singleStream: limits?.singleStream ?? 0, - simulcast: limits?.simulcast ?? { [Variant.VARIANT_LOW]: 0, [Variant.VARIANT_MEDIUM]: 0, [Variant.VARIANT_HIGH]: 0 }, -}); diff --git a/packages/react-client/src/utils/errors.ts b/packages/react-client/src/utils/errors.ts deleted file mode 100644 index 800415f83..000000000 --- a/packages/react-client/src/utils/errors.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { getLogger } from "@fishjam-cloud/ts-client"; - -import type { DeviceError } from "../types/public"; - -export const PERMISSION_DENIED: DeviceError = { name: "NotAllowedError" }; -export const OVERCONSTRAINED_ERROR: DeviceError = { name: "OverconstrainedError" }; -export const NOT_FOUND_ERROR: DeviceError = { name: "NotFoundError" }; -export const UNHANDLED_ERROR: DeviceError = { name: "UNHANDLED_ERROR" }; - -// https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#exceptions -// OverconstrainedError has higher priority than NotAllowedError -export const parseUserMediaError = (error: unknown, logger: ReturnType): DeviceError => { - const name = error instanceof Error ? error.name : ""; - switch (name) { - case "NotAllowedError": - return PERMISSION_DENIED; - case "OverconstrainedError": - return OVERCONSTRAINED_ERROR; - case "NotFoundError": - return NOT_FOUND_ERROR; - default: - logger.warn({ name: "Unhandled getUserMedia error", error }); - return UNHANDLED_ERROR; - } -}; - -export class MissingSandboxApiUrlError extends Error { - constructor() { - super("useSandbox requires a sandboxApiUrl, you can get it at: https://fishjam.io/app/sandbox"); - this.name = "MissingSandboxApiUrlError"; - } -} diff --git a/packages/react-client/src/utils/track.ts b/packages/react-client/src/utils/track.ts deleted file mode 100644 index aaac0485a..000000000 --- a/packages/react-client/src/utils/track.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { FishjamClient, SimulcastConfig, TrackContext, TrackMetadata } from "@fishjam-cloud/ts-client"; -import { Variant } from "@fishjam-cloud/ts-client"; - -import type { BandwidthLimits, Track, TrackId } from "../types/public"; - -// In most cases, the track is identified by its remote track ID. -// This ID comes from the ts-client `addTrack` method. -// However, we don't have that ID before the `addTrack` method returns it. -// -// The `addTrack` method emits the `localTrackAdded` event. -// This event will refresh the internal state of this object. -// However, in that event handler, we don't yet have the remote track ID. -// Therefore, for that brief moment, we will use the local track ID from the MediaStreamTrack object to identify the track. -const getRemoteOrLocalTrackContext = ( - tsClient: FishjamClient, - remoteOrLocalTrackId: string, -): TrackContext | null => { - const tracks = tsClient?.getLocalPeer()?.tracks; - if (!tracks) return null; - - const trackByRemoteId = tracks?.get(remoteOrLocalTrackId); - if (trackByRemoteId) return trackByRemoteId; - - const trackByLocalId = [...tracks.values()].find(({ track }) => track?.id === remoteOrLocalTrackId); - return trackByLocalId ?? null; -}; - -const getTrackFromContext = (context: TrackContext): Track => ({ - metadata: context.metadata as TrackMetadata, - trackId: context.trackId as TrackId, - stream: context.stream, - simulcastConfig: context.simulcastConfig || null, - track: context.track, -}); - -export const getRemoteOrLocalTrack = (tsClient: FishjamClient, remoteOrLocalTrackId: string) => { - const context = getRemoteOrLocalTrackContext(tsClient, remoteOrLocalTrackId); - if (!context) return null; - return getTrackFromContext(context); -}; - -export function setupOnEndedCallback( - track: MediaStreamTrack, - getCurrentTrackId: () => string | undefined, - callback: () => Promise, -) { - track.addEventListener("ended", async (event: Event) => { - const trackId = (event.target as MediaStreamTrack).id; - if (trackId === getCurrentTrackId()) { - await callback(); - } - }); -} - -const getDisabledEncodings = (activeEncodings: Variant[] = []) => { - const allEncodings: Variant[] = [Variant.VARIANT_LOW, Variant.VARIANT_MEDIUM, Variant.VARIANT_HIGH]; - return allEncodings.filter((encoding) => !activeEncodings.includes(encoding)); -}; - -export const getConfigAndBandwidthFromProps = ( - encodings: Variant[] | false | undefined, - bandwidthLimits: BandwidthLimits, -) => { - if (!encodings) return [bandwidthLimits.singleStream, undefined] as const; - - const config: SimulcastConfig = { - enabled: true, - enabledVariants: encodings, - disabledVariants: getDisabledEncodings(encodings), - }; - - const variantEntries = Object.entries(bandwidthLimits.simulcast).map( - ([key, value]) => [Number(key), value] as [Variant, number], - ); - - const bandwidth = new Map(variantEntries); - return [bandwidth, config] as const; -}; - -function getCertainTypeTracks(stream: MediaStream, type: "audio" | "video") { - if (type === "audio") return stream.getAudioTracks(); - return stream.getVideoTracks(); -} - -export function getTrackFromStream(stream: MediaStream, type: "audio" | "video") { - return getCertainTypeTracks(stream, type)[0] ?? null; -} - -export function stopStream(stream: MediaStream, type: "audio" | "video") { - getCertainTypeTracks(stream, type).forEach((track) => { - track.enabled = false; - track.stop(); - }); -} diff --git a/packages/tsunami/package.json b/packages/tsunami/package.json index 0e77ed051..884821d67 100644 --- a/packages/tsunami/package.json +++ b/packages/tsunami/package.json @@ -1,6 +1,6 @@ { "name": "@fishjam-cloud/tsunami", - "version": "0.0.0", + "version": "0.29.0", "description": "Framework-agnostic SDK core for Fishjam clients", "license": "Apache-2.0", "author": "Fishjam Team", diff --git a/packages/tsunami/src/FishjamClient.sessionState.test.ts b/packages/tsunami/src/FishjamClient.sessionState.test.ts new file mode 100644 index 000000000..6308e4704 --- /dev/null +++ b/packages/tsunami/src/FishjamClient.sessionState.test.ts @@ -0,0 +1,199 @@ +import type { Component, FishjamClient as TsClient, GenericMetadata, Peer } from "@fishjam-cloud/ts-client"; +import { EventEmitter } from "events"; +import { describe, expect, it, vi } from "vitest"; + +import { DataChannelsNotConnectedError } from "./errors/lifecycleErrors"; +import { FishjamClient } from "./FishjamClient"; + +class FakeSignallingClient extends EventEmitter { + public localPeer: Peer | null = null; + public remotePeers: Record> = {}; + public components: Record = {}; + + public getLocalPeer = () => this.localPeer; + public getRemotePeers = () => this.remotePeers; + public getRemoteComponents = () => this.components; + public connect = vi.fn(async () => {}); + public disconnect = vi.fn(); + public cleanup = vi.fn(); + public createDataChannels = vi.fn(async () => { + this.emit("dataChannelsReady"); + }); +} + +const createWiredClient = () => { + const signalling = new FakeSignallingClient(); + const client = new FishjamClient({ + signallingClient: signalling as unknown as TsClient, + }); + return { signalling, client }; +}; + +const buildPeer = (id: string): Peer => ({ + id, + type: "webrtc", + tracks: new Map(), +}); + +describe("FishjamClient session state", () => { + it("starts idle with no participants", () => { + const { client } = createWiredClient(); + + expect(client.getState()).toMatchObject({ + peerStatus: "idle", + reconnectionStatus: "idle", + localPeer: null, + remotePeers: {}, + components: {}, + }); + }); + + it("mirrors the connection lifecycle into peerStatus", () => { + const { signalling, client } = createWiredClient(); + + signalling.emit("connectionStarted"); + expect(client.getState().peerStatus).toBe("connecting"); + + signalling.emit("joined", "local-peer", [], []); + expect(client.getState().peerStatus).toBe("connected"); + + signalling.emit("disconnected"); + expect(client.getState().peerStatus).toBe("idle"); + }); + + it("sets peerStatus to error on auth, join and connection errors", () => { + const { signalling, client } = createWiredClient(); + + for (const errorEvent of ["authError", "joinError", "connectionError"] as const) { + signalling.emit("disconnected"); + signalling.emit(errorEvent); + expect(client.getState().peerStatus).toBe("error"); + } + }); + + it("tracks reconnectionStatus across the reconnect lifecycle", () => { + const { signalling, client } = createWiredClient(); + + signalling.emit("reconnectionStarted"); + expect(client.getState().reconnectionStatus).toBe("reconnecting"); + + signalling.emit("reconnected"); + expect(client.getState().reconnectionStatus).toBe("idle"); + + signalling.emit("reconnectionStarted"); + signalling.emit("reconnectionRetriesLimitReached"); + expect(client.getState().reconnectionStatus).toBe("error"); + }); + + it("promotes auth and join errors to reconnection errors only while reconnecting", () => { + const { signalling, client } = createWiredClient(); + + signalling.emit("joinError"); + expect(client.getState().reconnectionStatus).toBe("idle"); + + signalling.emit("reconnectionStarted"); + signalling.emit("authError"); + expect(client.getState().reconnectionStatus).toBe("error"); + }); + + it("re-reads participants from the signalling client on participant events", () => { + const { signalling, client } = createWiredClient(); + + signalling.localPeer = buildPeer("local"); + signalling.remotePeers = { remote: buildPeer("remote") }; + signalling.emit("peerJoined", signalling.remotePeers["remote"]); + + expect(client.getState().localPeer?.id).toBe("local"); + expect(Object.keys(client.getState().remotePeers)).toEqual(["remote"]); + }); + + it("produces fresh participant references even when the signalling client mutates in place", () => { + const { signalling, client } = createWiredClient(); + signalling.localPeer = buildPeer("local"); + signalling.emit("peerJoined", buildPeer("remote")); + const localPeerBefore = client.getState().localPeer; + + // The signalling client mutates the same peer object; the mirror must + // still expose the change as a new reference. + signalling.emit("localTrackAdded"); + + expect(client.getState().localPeer).not.toBe(localPeerBefore); + }); + + it("notifies subscribers synchronously on state changes", () => { + const { signalling, client } = createWiredClient(); + const observedStatuses: string[] = []; + client.subscribe(() => { + observedStatuses.push(client.getState().peerStatus); + }); + + signalling.emit("connectionStarted"); + signalling.emit("joined", "local-peer", [], []); + + expect(observedStatuses).toEqual(["connecting", "connected"]); + }); + + it("supports slice subscriptions on the client", () => { + const { signalling, client } = createWiredClient(); + const sliceListener = vi.fn(); + client.subscribeToSlice((state) => state.peerStatus, sliceListener); + + signalling.emit("connectionStarted"); + signalling.emit("reconnectionStarted"); + + expect(sliceListener).toHaveBeenCalledTimes(1); + expect(sliceListener).toHaveBeenCalledWith("connecting", "idle"); + }); + + it("stops mirroring after dispose", () => { + const { signalling, client } = createWiredClient(); + const listener = vi.fn(); + client.subscribe(listener); + + client.dispose(); + signalling.emit("connectionStarted"); + + expect(listener).not.toHaveBeenCalled(); + expect(client.getState().peerStatus).toBe("idle"); + }); +}); + +describe("FishjamClient data channel state", () => { + it("rejects with a typed error and mirrors it when creating channels while not connected", async () => { + const { client } = createWiredClient(); + + await expect(client.createDataChannels()).rejects.toBeInstanceOf(DataChannelsNotConnectedError); + expect(client.getState().dataChannel).toMatchObject({ status: "idle" }); + expect(client.getState().dataChannel.error).toBeInstanceOf(DataChannelsNotConnectedError); + }); + + it("moves to ready when channels are created while connected", async () => { + const { signalling, client } = createWiredClient(); + signalling.emit("joined", "local-peer", [], []); + + await client.createDataChannels(); + + expect(client.getState().dataChannel).toEqual({ status: "ready", error: null }); + }); + + it("mirrors data channel errors and resets readiness", () => { + const { signalling, client } = createWiredClient(); + const failure = new Error("boom"); + + signalling.emit("dataChannelsReady"); + signalling.emit("dataChannelsError", failure); + + expect(client.getState().dataChannel).toEqual({ status: "idle", error: failure }); + }); + + it("resets readiness but preserves the error on disconnect", () => { + const { signalling, client } = createWiredClient(); + const failure = new Error("boom"); + + signalling.emit("dataChannelsError", failure); + signalling.emit("dataChannelsReady"); + signalling.emit("disconnected"); + + expect(client.getState().dataChannel.status).toBe("idle"); + }); +}); diff --git a/packages/tsunami/src/FishjamClient.ts b/packages/tsunami/src/FishjamClient.ts index 7729c2e0a..62c13662a 100644 --- a/packages/tsunami/src/FishjamClient.ts +++ b/packages/tsunami/src/FishjamClient.ts @@ -8,23 +8,94 @@ import { FishjamClient as TsClient, type FishjamTrackContext, type GenericMetadata, + getLogger, type MessageEvents, type Peer, type SimulcastConfig, type TrackBandwidthLimit, type TrackMetadata, - type Variant, + Variant, } from "@fishjam-cloud/ts-client"; import { EventEmitter } from "events"; import type TypedEmitter from "typed-emitter"; import { ClientResourceScope } from "./ClientResourceScope"; +import { DeviceOrchestrator } from "./controllers/DeviceOrchestrator"; +import type { ScreenShareConstraints } from "./controllers/ScreenShareController"; +import type { TrackPublisher } from "./controllers/TrackPublisher"; +import { VIDEO_TRACK_CONSTRAINTS } from "./devices/constraints"; +import type { IDeviceManager, PlatformMediaStream, PlatformMediaStreamTrack } from "./devices/deviceManager"; +import { DataChannelsNotConnectedError, DeviceManagerMissingError } from "./errors/lifecycleErrors"; +import type { + BandwidthLimits, + InitializeDevicesResult, + InitializeDevicesSettings, + StreamConfig, + TrackMiddleware, + TracksMiddleware, +} from "./mediaTypes"; +import { type ClientState, createInitialClientState } from "./state/clientState"; +import { StateStore, type StoreListener } from "./state/StateStore"; +import { VoiceActivityMonitor } from "./vad/VoiceActivityMonitor"; type LegacyClientInternals = { reconnectManager?: { reset(metadata: PeerMetadata): void }; sendStatisticsInterval?: ReturnType; }; +export type FishjamClientConfig = CreateConfig & { + /** + * Platform boundary for local media acquisition. When omitted (or `null`) + * the client is signalling-only and all device-related state stays at its + * zero values. + */ + deviceManager?: IDeviceManager | null; + videoConstraints?: MediaTrackConstraints | boolean; + audioConstraints?: MediaTrackConstraints | boolean; + bandwidthLimits?: Partial; + videoStreamConfig?: StreamConfig; + audioStreamConfig?: StreamConfig; + /** + * Strangler-migration hook: use an externally created signalling client + * instead of constructing one. Also the seam tests inject fakes through. + * + * @internal + */ + signallingClient?: TsClient; +}; + +/** + * Events after which only the participant slices of {@link ClientState} are + * re-read. Events that also change connection status (`joined`, + * `disconnected`, `reconnected`) are handled separately so each event + * produces a single state update. + */ +const participantEventNames = [ + "authSuccess", + "trackReady", + "trackAdded", + "trackRemoved", + "trackUpdated", + "encodingChanged", + "peerJoined", + "peerLeft", + "peerUpdated", + "componentAdded", + "componentRemoved", + "componentUpdated", + "localTrackAdded", + "localTrackRemoved", + "localTrackReplaced", + "localTrackMuted", + "localTrackUnmuted", + "localTrackBandwidthSet", + "localTrackEncodingBandwidthSet", + "localTrackEncodingEnabled", + "localTrackEncodingDisabled", + "localPeerMetadataChanged", + "localTrackMetadataChanged", +] as const; + /** * Framework-agnostic Fishjam SDK client. * @@ -41,12 +112,222 @@ export class FishjamClient | null = null; + private readonly injectedTsClient: TsClient | null = null; + + private readonly store = new StateStore>( + createInitialClientState(), + ); - public constructor(config?: CreateConfig) { + private readonly deviceOrchestrator: DeviceOrchestrator | null = null; + + public constructor(config?: FishjamClientConfig) { super(); - this.config = config; + const { + deviceManager, + videoConstraints, + audioConstraints, + bandwidthLimits, + videoStreamConfig, + audioStreamConfig, + signallingClient, + ...createConfig + } = config ?? {}; + this.config = createConfig; + this.injectedTsClient = signallingClient ?? null; + + this.bindSessionStateEvents(); + + // An injected signalling client can emit events before any client method + // is called, so its event forwarding must be wired immediately. + if (signallingClient) this.getTsClient(); + + if (deviceManager) { + this.deviceOrchestrator = new DeviceOrchestrator({ + publisher: this.createTrackPublisher(), + deviceManager, + store: this.store, + logger: getLogger(config?.debug ?? false), + videoConstraints: videoConstraints ?? VIDEO_TRACK_CONSTRAINTS, + audioConstraints: audioConstraints ?? true, + videoStreamConfig, + audioStreamConfig, + bandwidthLimits: { + singleStream: bandwidthLimits?.singleStream ?? 0, + simulcast: bandwidthLimits?.simulcast ?? { + [Variant.VARIANT_LOW]: 0, + [Variant.VARIANT_MEDIUM]: 0, + [Variant.VARIANT_HIGH]: 0, + }, + }, + }); + } + } + + /** + * Device controllers backing the high-level device API. Exposed for the + * framework adapters during the strangler migration; application code + * should use the `startCamera`-style methods instead. + * + * @internal + */ + public get devices(): DeviceOrchestrator | null { + return this.deviceOrchestrator; + } + + // --- device API (available when a deviceManager was injected) --- + + public initializeDevices(settings?: InitializeDevicesSettings): Promise { + return this.requireDevices().initializeDevices(settings); + } + + public async startCamera(deviceId?: string): Promise { + await this.requireDevices().camera.start(deviceId); + } + + public async stopCamera(): Promise { + await this.requireDevices().camera.stop(); + } + + public async toggleCamera(): Promise { + await this.requireDevices().camera.toggleDevice(); + } + + public async selectCamera(deviceId: string): Promise { + await this.requireDevices().camera.selectDevice(deviceId); + } + + public async setCameraTrackMiddleware(middleware: TrackMiddleware): Promise { + await this.requireDevices().camera.setTrackMiddleware(middleware); + } + + public async startMicrophone(deviceId?: string): Promise { + await this.requireDevices().microphone.start(deviceId); + } + + public async stopMicrophone(): Promise { + await this.requireDevices().microphone.stop(); + } + + public async toggleMicrophone(): Promise { + await this.requireDevices().microphone.toggleDevice(); } + public async toggleMicrophoneMute(): Promise { + await this.requireDevices().microphone.toggleMute(); + } + + public async selectMicrophone(deviceId: string): Promise { + await this.requireDevices().microphone.selectDevice(deviceId); + } + + public async setMicrophoneTrackMiddleware(middleware: TrackMiddleware): Promise { + await this.requireDevices().microphone.setTrackMiddleware(middleware); + } + + public startScreenShare(constraints?: ScreenShareConstraints): Promise { + return this.requireDevices().screenShare.start(constraints); + } + + public stopScreenShare(): Promise { + return this.requireDevices().screenShare.stop(); + } + + public setScreenShareTracksMiddleware(middleware: TracksMiddleware | null): Promise { + return this.requireDevices().screenShare.setMiddleware(middleware); + } + + public setCustomSource(sourceId: string, stream: PlatformMediaStream | null): Promise { + return this.requireDevices().customSources.setSource(sourceId, stream); + } + + /** + * Wraps tracks in a platform stream via the injected device manager. + * Available only on device-capable clients. + */ + public createMediaStream(tracks: PlatformMediaStreamTrack[]): PlatformMediaStream { + return this.requireDevices().createMediaStream(tracks); + } + + private requireDevices(): DeviceOrchestrator { + this.resources.assertActive(); + if (!this.deviceOrchestrator) throw new DeviceManagerMissingError(); + return this.deviceOrchestrator; + } + + private createTrackPublisher(): TrackPublisher { + // The signalling layer is typed against DOM media types, while the device + // layer only knows the platform contract. On React Native the runtime + // objects reaching this boundary are react-native-webrtc tracks that the + // signalling stack already handles, so the widening cast is confined here. + const asSignallingTrack = (track: PlatformMediaStreamTrack | null) => track as MediaStreamTrack | null; + + return { + addTrack: (track, metadata, simulcastConfig, maxBandwidth) => + this.addTrack(asSignallingTrack(track) as MediaStreamTrack, metadata, simulcastConfig, maxBandwidth), + replaceTrack: (trackId, newTrack) => this.replaceTrack(trackId, asSignallingTrack(newTrack)), + removeTrack: (trackId) => this.removeTrack(trackId), + updateTrackMetadata: (trackId, metadata) => this.updateTrackMetadata(trackId, metadata), + getDisplayName: () => { + const peerMetadata = this.getLocalPeer()?.metadata?.peer as Record | undefined; + const displayName = peerMetadata?.displayName; + return typeof displayName === "string" ? displayName : undefined; + }, + resolveRemoteTrackId: (remoteOrLocalTrackId) => { + const tracks = this.getLocalPeer()?.tracks; + if (!tracks) return null; + if (tracks.get(remoteOrLocalTrackId)) return remoteOrLocalTrackId; + const trackByLocalId = [...tracks.values()].find(({ track }) => track?.id === remoteOrLocalTrackId); + return trackByLocalId?.trackId ?? null; + }, + isSignallingActive: () => this.status === "initialized", + onJoined: (listener) => { + this.on("joined", listener); + return () => this.off("joined", listener); + }, + onDisconnected: (listener) => { + this.on("disconnected", listener); + return () => this.off("disconnected", listener); + }, + }; + } + + /** Synchronously readable snapshot of the client's observable state. */ + public getState = (): ClientState => this.store.getState(); + + /** Notifies on every state change; returns an unsubscribe function. */ + public subscribe = (listener: StoreListener): (() => void) => this.store.subscribe(listener); + + private voiceActivityMonitor: VoiceActivityMonitor | null = null; + + /** + * Voice activity keyed by peer id, for every peer with a published + * microphone track. Stable reference until a value changes. High-frequency + * channel — deliberately separate from {@link getState}. + */ + public getVoiceActivity = (): Record => this.requireVoiceActivityMonitor().getSnapshot(); + + /** Notifies on any voice-activity change; returns an unsubscribe function. */ + public subscribeToVoiceActivity = (listener: () => void): (() => void) => + this.requireVoiceActivityMonitor().subscribe(listener); + + private requireVoiceActivityMonitor(): VoiceActivityMonitor { + if (!this.voiceActivityMonitor) { + this.voiceActivityMonitor = new VoiceActivityMonitor({ + getLocalPeer: () => this.getLocalPeer(), + getRemotePeers: () => this.getRemotePeers(), + getLocalTrackAudioLevel: (trackId) => this.getLocalTrackAudioLevel(trackId), + subscribeToPeerChanges: this.subscribe, + }); + } + return this.voiceActivityMonitor; + } + + /** Notifies only when the selected part of the state changes (`Object.is`). */ + public subscribeToSlice = ( + selector: (state: ClientState) => Slice, + listener: (slice: Slice, previousSlice: Slice) => void, + ): (() => void) => this.store.subscribeToSlice(selector, listener); + public get isDisposed(): boolean { return this.resources.isDisposed; } @@ -56,11 +337,11 @@ export class FishjamClient>( + public override emit>( event: Event, ...args: Parameters[Event]> ): boolean; - public emit(event: string | symbol, ...args: unknown[]): boolean { + public override emit(event: string | symbol, ...args: unknown[]): boolean { if (this.tsClient) return (this.tsClient as EventEmitter).emit(event, ...args); return EventEmitter.prototype.emit.call(this, event, ...args); } @@ -168,12 +449,41 @@ export class FishjamClient { - return this.resources.run(() => this.getTsClient().createDataChannels()); + return this.resources.run(async () => { + const { dataChannel, peerStatus } = this.store.getState(); + if (dataChannel.status !== "idle") return; + + if (peerStatus !== "connected") { + const error = new DataChannelsNotConnectedError(); + this.store.update({ dataChannel: { status: "idle", error } }); + throw error; + } + + this.store.update({ dataChannel: { status: "creating", error: null } }); + try { + await this.getTsClient().createDataChannels(); + } catch (error) { + this.store.update({ + dataChannel: { status: "idle", error: error instanceof Error ? error : new Error(String(error)) }, + }); + throw error; + } + }); } public publishData(data: Uint8Array, options: DataChannelOptions): void { this.resources.assertActive(); - this.getTsClient().publishData(data, options); + try { + this.getTsClient().publishData(data, options); + } catch (error) { + this.store.update({ + dataChannel: { + ...this.store.getState().dataChannel, + error: error instanceof Error ? error : new Error(String(error)), + }, + }); + throw error; + } } public subscribeData(callback: DataCallback, options: DataChannelOptions): () => void { @@ -206,6 +516,9 @@ export class FishjamClient>; + + // Fresh references on every refresh: the signalling client mutates peers + // in place, so re-read values must not be reference-equal to the previous + // slice or the store would treat them as unchanged. + const participants = (): StatePartial => { + const localPeer = this.getLocalPeer(); + return { + localPeer: localPeer === null ? null : { ...localPeer }, + remotePeers: { ...this.getRemotePeers() }, + components: { ...this.getRemoteComponents() }, + }; + }; + const reconnectionErrorIfReconnecting = (): StatePartial => + this.store.getState().reconnectionStatus === "reconnecting" ? { reconnectionStatus: "error" } : {}; + + // Each event composes everything it affects into ONE update, so + // subscribers observe exactly one notification per event. + this.on("connectionStarted", () => this.store.update({ peerStatus: "connecting" })); + this.on("joined", () => this.store.update({ peerStatus: "connected", ...participants() })); + this.on("reconnected", () => + this.store.update({ peerStatus: "connected", reconnectionStatus: "idle", ...participants() }), + ); + this.on("disconnected", () => + this.store.update({ + peerStatus: "idle", + dataChannel: { status: "idle", error: this.store.getState().dataChannel.error }, + ...participants(), + }), + ); + this.on("dataChannelsReady", () => this.store.update({ dataChannel: { status: "ready", error: null } })); + this.on("dataChannelsError", (error) => this.store.update({ dataChannel: { status: "idle", error } })); + this.on("authError", () => this.store.update({ peerStatus: "error", ...reconnectionErrorIfReconnecting() })); + this.on("joinError", () => this.store.update({ peerStatus: "error", ...reconnectionErrorIfReconnecting() })); + this.on("connectionError", () => this.store.update({ peerStatus: "error" })); + this.on("reconnectionStarted", () => this.store.update({ reconnectionStatus: "reconnecting" })); + this.on("reconnectionRetriesLimitReached", () => this.store.update({ reconnectionStatus: "error" })); + + const refreshParticipants = () => this.store.update(participants()); + for (const eventName of participantEventNames) { + this.on(eventName, refreshParticipants); + } + } + private getTsClient(): TsClient { this.resources.assertActive(); if (this.tsClient) return this.tsClient; - const tsClient = new TsClient(this.config); + const tsClient = this.injectedTsClient ?? new TsClient(this.config); const emitter = tsClient as EventEmitter; const emit = emitter.emit.bind(emitter); emitter.emit = (event: string | symbol, ...args: unknown[]) => { diff --git a/packages/tsunami/src/controllers/CustomSourceController.ts b/packages/tsunami/src/controllers/CustomSourceController.ts new file mode 100644 index 000000000..eddd8d845 --- /dev/null +++ b/packages/tsunami/src/controllers/CustomSourceController.ts @@ -0,0 +1,144 @@ +import { type Logger, type TrackMetadata, TrackTypeError } from "@fishjam-cloud/ts-client"; + +import type { PlatformMediaStream, PlatformMediaStreamTrack } from "../devices/deviceManager"; +import type { CustomSourceState, PeerStatus } from "../state/clientState"; +import type { TrackPublisher } from "./TrackPublisher"; + +export type CustomSourceControllerDeps = { + publisher: TrackPublisher; + logger: Logger; + getPeerStatus: () => PeerStatus; + onStateChanged: () => void; +}; + +/** + * Owns user-provided custom media sources. All mutations run through a promise + * queue: replacing a source's stream issues an unpublish and a publish in the + * same tick, and run concurrently they would race to remove the same track ids. + */ +export class CustomSourceController { + private sources: Record = {}; + private queue: Promise = Promise.resolve(); + private readonly sessionCleanups: (() => void)[]; + + public constructor(private readonly deps: CustomSourceControllerDeps) { + this.sessionCleanups = [ + deps.publisher.onJoined(() => { + void this.enqueue(() => this.publishPendingSources()).catch((error) => + this.deps.logger.error("Failed to publish custom sources", error), + ); + }), + deps.publisher.onDisconnected(() => { + const hasPublishedSources = Object.values(this.sources).some((source) => source.trackIds !== undefined); + if (!hasPublishedSources) return; + this.sources = Object.fromEntries( + Object.entries(this.sources).map(([id, source]) => [id, { stream: source.stream }]), + ); + this.notify(); + }), + ]; + } + + public snapshot(): Record { + return this.sources; + } + + public setSource(sourceId: string, stream: PlatformMediaStream | null): Promise { + return this.enqueue(() => this.applySetSource(sourceId, stream)); + } + + public dispose(): void { + for (const cleanup of this.sessionCleanups) cleanup(); + this.sources = {}; + } + + private enqueue(operation: () => Promise): Promise { + const run = this.queue.then(operation); + // Chain a never-rejecting link so one failed call cannot poison the queue; + // the caller still observes failures through the returned promise. + this.queue = run.catch(() => undefined); + return run; + } + + private async applySetSource(sourceId: string, stream: PlatformMediaStream | null): Promise { + const oldSource = this.sources[sourceId]; + if (stream === oldSource?.stream) return; + + if (oldSource?.trackIds) await this.removeTracks(oldSource.trackIds); + + if (stream !== null) { + this.sources = { ...this.sources, [sourceId]: { stream } }; + this.notify(); + if (this.deps.getPeerStatus() === "connected") await this.publishPendingSources(); + } else if (oldSource) { + this.sources = Object.fromEntries(Object.entries(this.sources).filter(([id]) => id !== sourceId)); + this.notify(); + } + } + + private async publishPendingSources(): Promise { + const pending = Object.entries(this.sources).filter(([, source]) => source.trackIds === undefined); + if (pending.length === 0) return; + + const published = await Promise.all( + pending.map(async ([id, source]) => [id, await this.publishSource(source)] as const), + ); + + // The queue serializes mutations, but publishing awaits addTrack — verify + // each entry is still the one we published before recording its track ids. + const isStillCurrent = ([id, started]: (typeof published)[number]) => { + const current = this.sources[id]; + return current !== undefined && current.stream === started.stream && current.trackIds === undefined; + }; + const toPatch = published.filter(isStillCurrent); + const orphans = published.filter((entry) => !isStillCurrent(entry)); + + if (toPatch.length > 0) { + this.sources = { ...this.sources, ...Object.fromEntries(toPatch) }; + this.notify(); + } + for (const [, started] of orphans) { + if (started.trackIds) await this.removeTracks(started.trackIds); + } + } + + private async publishSource(source: CustomSourceState): Promise { + const video = source.stream.getVideoTracks().at(0); + const audio = source.stream.getAudioTracks().at(0); + + const displayName = this.deps.publisher.getDisplayName(); + const promises = []; + if (video) promises.push(this.addTrack(video, { type: "customVideo", displayName, paused: false })); + if (audio) promises.push(this.addTrack(audio, { type: "customAudio", displayName, paused: false })); + + if (promises.length === 0) { + this.deps.logger.warn("Attempted to add empty PlatformMediaStream as custom source."); + return source; + } + const [videoId, audioId] = await Promise.all(promises); + return { ...source, trackIds: { videoId, audioId } }; + } + + private async removeTracks({ videoId, audioId }: { videoId?: string; audioId?: string }): Promise { + const promises = []; + if (videoId) promises.push(this.deps.publisher.removeTrack(videoId)); + if (audioId) promises.push(this.deps.publisher.removeTrack(audioId)); + await Promise.all(promises); + } + + private async addTrack(track: PlatformMediaStreamTrack, metadata: TrackMetadata): Promise { + try { + return await this.deps.publisher.addTrack(track, metadata); + } catch (err) { + if (err instanceof TrackTypeError) { + this.deps.logger.warn(err.message); + return undefined; + } + throw err; + } + } + + private notify(): void { + this.deps.onStateChanged(); + } +} diff --git a/packages/tsunami/src/controllers/DeviceOrchestrator.ts b/packages/tsunami/src/controllers/DeviceOrchestrator.ts new file mode 100644 index 000000000..b9b38a829 --- /dev/null +++ b/packages/tsunami/src/controllers/DeviceOrchestrator.ts @@ -0,0 +1,216 @@ +import type { Logger } from "@fishjam-cloud/ts-client"; + +import { prepareConstraints } from "../devices/constraints"; +import type { + DeviceItem, + DeviceType, + IDeviceManager, + PlatformMediaStream, + PlatformMediaStreamTrack, +} from "../devices/deviceManager"; +import { correctDevicesOnSafari, getAvailableMedia } from "../devices/mediaInitializer"; +import type { BandwidthLimits, InitializeDevicesResult, InitializeDevicesSettings, StreamConfig } from "../mediaTypes"; +import type { ClientState } from "../state/clientState"; +import type { StateStore } from "../state/StateStore"; +import { CustomSourceController } from "./CustomSourceController"; +import { ScreenShareController } from "./ScreenShareController"; +import { TrackDeviceController } from "./TrackDeviceController"; +import type { TrackPublisher } from "./TrackPublisher"; + +export type DeviceOrchestratorDeps = { + publisher: TrackPublisher; + deviceManager: IDeviceManager; + store: StateStore>; + logger: Logger; + videoConstraints?: MediaTrackConstraints | boolean; + audioConstraints?: MediaTrackConstraints | boolean; + bandwidthLimits: BandwidthLimits; + videoStreamConfig?: StreamConfig; + audioStreamConfig?: StreamConfig; +}; + +/** + * Created only when an `IDeviceManager` is injected. Owns device + * initialization, hardware enumeration, and the per-source controllers, and + * mirrors all of it into the client state store. + */ +export class DeviceOrchestrator { + public readonly camera: TrackDeviceController; + public readonly microphone: TrackDeviceController; + public readonly screenShare: ScreenShareController; + public readonly customSources: CustomSourceController; + + private deviceList: DeviceItem[] = []; + private availableCameras: DeviceItem[] = []; + private availableMicrophones: DeviceItem[] = []; + + private isInitialized = false; + private initializationPromise: Promise | null = null; + private readonly deviceChangeCleanup: () => void; + + public constructor(private readonly deps: DeviceOrchestratorDeps) { + const commonControllerDeps = { + publisher: deps.publisher, + deviceManager: deps.deviceManager, + logger: deps.logger, + getPeerStatus: () => deps.store.getState().peerStatus, + getInitialStream: () => this.getInitialStream(), + invalidateInitialStream: () => { + this.initializationPromise = null; + }, + onStateChanged: () => this.syncStore(), + }; + + this.camera = new TrackDeviceController({ + ...commonControllerDeps, + type: "video", + constraints: deps.videoConstraints, + bandwidthLimits: deps.bandwidthLimits, + streamConfig: deps.videoStreamConfig, + getAvailableDevices: () => this.availableCameras, + onSelectedDeviceChanged: (device) => this.persistLastDevice("video", device), + }); + + this.microphone = new TrackDeviceController({ + ...commonControllerDeps, + type: "audio", + constraints: deps.audioConstraints, + bandwidthLimits: deps.bandwidthLimits, + streamConfig: deps.audioStreamConfig, + getAvailableDevices: () => this.availableMicrophones, + onSelectedDeviceChanged: (device) => this.persistLastDevice("audio", device), + }); + + this.screenShare = new ScreenShareController(commonControllerDeps); + this.customSources = new CustomSourceController(commonControllerDeps); + + this.deviceChangeCleanup = deps.deviceManager.onDeviceChange(() => { + void this.refreshDeviceList().catch((error) => deps.logger.error("Failed to refresh device list", error)); + }); + + this.syncStore(); + } + + public async initializeDevices(settings?: InitializeDevicesSettings): Promise { + if (this.isInitialized) { + return { stream: null, errors: null, status: "already_initialized" }; + } + if (this.initializationPromise) { + return this.initializationPromise; + } + + const persistence = this.deps.deviceManager.persistence; + const lastUsed = { + audio: (await persistence?.getLastDevice("audio")) ?? null, + video: (await persistence?.getLastDevice("video")) ?? null, + }; + + const constraints = { + video: + settings?.enableVideo !== false && prepareConstraints(lastUsed.video?.deviceId, this.deps.videoConstraints), + audio: + settings?.enableAudio !== false && prepareConstraints(lastUsed.audio?.deviceId, this.deps.audioConstraints), + }; + + const initialize = async (): Promise => { + let media = await getAvailableMedia(this.deps.deviceManager, constraints); + await this.refreshDeviceList(); + + if (media.stream) { + media = await correctDevicesOnSafari( + this.deps.deviceManager, + media.stream, + media.errors, + this.deviceList, + constraints, + lastUsed, + ); + } + + const { stream, errors } = media; + + const videoDeviceId = stream?.getVideoTracks()[0]?.getSettings().deviceId; + const audioDeviceId = stream?.getAudioTracks()[0]?.getSettings().deviceId; + + const videoDevice = this.availableCameras.find((device) => device.deviceId === videoDeviceId); + const audioDevice = this.availableMicrophones.find((device) => device.deviceId === audioDeviceId); + + if (videoDevice) this.camera.setSelectedDevice(videoDevice); + if (audioDevice) this.microphone.setSelectedDevice(audioDevice); + + // Both controllers adopt the same stream; each only ever touches tracks + // of its own kind. + this.camera.adoptInitialStream(stream); + this.microphone.adoptInitialStream(stream); + this.camera.setError(errors.video); + this.microphone.setError(errors.audio); + + if (!stream) { + return { status: "failed", errors, stream: null }; + } else if (errors.video || errors.audio) { + return { status: "initialized_with_errors", errors, stream: null }; + } else { + return { status: "initialized", errors: null, stream }; + } + }; + + const initializationPromise = initialize().then( + (result) => { + this.isInitialized = true; + this.syncStore(); + return result; + }, + (error) => { + this.initializationPromise = null; + throw error; + }, + ); + this.initializationPromise = initializationPromise; + + return initializationPromise; + } + + public createMediaStream(tracks: PlatformMediaStreamTrack[]): PlatformMediaStream { + return this.deps.deviceManager.createMediaStream(tracks); + } + + public dispose(): void { + this.deviceChangeCleanup(); + this.camera.dispose(); + this.microphone.dispose(); + this.screenShare.dispose(); + this.customSources.dispose(); + } + + private async getInitialStream(): Promise { + const result = await this.initializationPromise; + return result?.stream ?? null; + } + + private async refreshDeviceList(): Promise { + this.deviceList = await this.deps.deviceManager.enumerateDevices(); + this.availableCameras = this.deviceList.filter((device) => device.kind === "video"); + this.availableMicrophones = this.deviceList.filter((device) => device.kind === "audio"); + this.syncStore(); + } + + private persistLastDevice(type: DeviceType, device: DeviceItem): void { + void Promise.resolve(this.deps.deviceManager.persistence?.saveLastDevice(type, device)).catch((error) => + this.deps.logger.warn({ name: "Failed to persist last device", error }), + ); + } + + private syncStore(): void { + this.deps.store.update({ + camera: this.camera.snapshot(), + microphone: this.microphone.snapshot(), + screenShare: this.screenShare.snapshot(), + customSources: this.customSources.snapshot(), + availableCameras: this.availableCameras, + availableMicrophones: this.availableMicrophones, + cameraError: this.camera.error, + microphoneError: this.microphone.error, + devicesInitialized: this.isInitialized, + } as Partial>); + } +} diff --git a/packages/tsunami/src/controllers/ScreenShareController.ts b/packages/tsunami/src/controllers/ScreenShareController.ts new file mode 100644 index 000000000..0a4730434 --- /dev/null +++ b/packages/tsunami/src/controllers/ScreenShareController.ts @@ -0,0 +1,205 @@ +import { type Logger, type TrackMetadata, TrackTypeError } from "@fishjam-cloud/ts-client"; + +import type { IDeviceManager, PlatformMediaStream, PlatformMediaStreamTrack } from "../devices/deviceManager"; +import type { TracksMiddleware } from "../mediaTypes"; +import type { PeerStatus, ScreenShareState } from "../state/clientState"; +import type { TrackPublisher } from "./TrackPublisher"; + +export type ScreenShareConstraints = { + audioConstraints?: boolean | MediaTrackConstraints; + videoConstraints?: boolean | MediaTrackConstraints; +}; + +export type ScreenShareControllerDeps = { + publisher: TrackPublisher; + deviceManager: IDeviceManager; + logger: Logger; + getPeerStatus: () => PeerStatus; + onStateChanged: () => void; +}; + +/** Owns the screen-share lifecycle: prompt, publish, middleware, teardown. */ +export class ScreenShareController { + private stream: PlatformMediaStream | null = null; + private trackIds: { videoId?: string; audioId?: string } | null = null; + private middleware: TracksMiddleware | null = null; + private middlewareCleanup: (() => void) | null = null; + + private trackEndCleanups: (() => void)[] = []; + private readonly sessionCleanups: (() => void)[]; + private lastSnapshot: ScreenShareState | null = null; + + public constructor(private readonly deps: ScreenShareControllerDeps) { + this.sessionCleanups = [ + deps.publisher.onDisconnected(() => { + if (!this.stream) return; + void this.stop().catch((err) => this.deps.logger.error(err)); + }), + ]; + } + + public snapshot(): ScreenShareState { + const [videoTrack, audioTrack] = this.stream ? getTracksFromStream(this.stream) : [null, null]; + const next: ScreenShareState = { stream: this.stream, videoTrack, audioTrack, middleware: this.middleware }; + + const previous = this.lastSnapshot; + const isUnchanged = + previous && + previous.stream === next.stream && + previous.videoTrack === next.videoTrack && + previous.audioTrack === next.audioTrack && + previous.middleware === next.middleware; + if (isUnchanged) return previous; + + this.lastSnapshot = next; + return next; + } + + public async start(constraints?: ScreenShareConstraints): Promise { + const displayStream = await this.deps.deviceManager.getDisplayMedia({ + video: constraints?.videoConstraints ?? true, + audio: constraints?.audioConstraints ?? true, + }); + + const displayName = this.deps.publisher.getDisplayName(); + + let [video, audio] = getTracksFromStream(displayStream); + + if (this.middleware && video) { + const { videoTrack, audioTrack, onClear } = await this.middleware(video, audio); + video = videoTrack; + audio = audioTrack; + this.middlewareCleanup = onClear; + } + + if (this.deps.publisher.isSignallingActive() && video) { + const addTrackPromises = [this.addTrack(video, { displayName, type: "screenShareVideo", paused: false })]; + if (audio) addTrackPromises.push(this.addTrack(audio, { displayName, type: "screenShareAudio", paused: false })); + + const [videoId, audioId] = await Promise.all(addTrackPromises); + this.stream = displayStream; + this.trackIds = { videoId, audioId }; + } else { + this.stream = displayStream; + this.trackIds = {}; + } + + this.attachTrackEndListeners(displayStream); + this.notify(); + } + + public async stop(): Promise { + if (!this.stream) { + this.deps.logger.warn("No stream to stop"); + return; + } + const [video, audio] = getTracksFromStream(this.stream); + + video?.stop(); + audio?.stop(); + + if (this.deps.getPeerStatus() === "connected") { + const removeTrackPromises: Promise[] = []; + if (this.trackIds?.videoId) removeTrackPromises.push(this.deps.publisher.removeTrack(this.trackIds.videoId)); + if (this.trackIds?.audioId) removeTrackPromises.push(this.deps.publisher.removeTrack(this.trackIds.audioId)); + + await Promise.all(removeTrackPromises); + } + + this.detachTrackEndListeners(); + this.middlewareCleanup?.(); + this.middlewareCleanup = null; + this.stream = null; + this.trackIds = null; + this.notify(); + } + + public async setMiddleware(middleware: TracksMiddleware | null): Promise { + this.middleware = middleware; + if (!this.stream) { + this.notify(); + return; + } + + const [video, audio] = getTracksFromStream(this.stream); + if (!video) return; + + this.middlewareCleanup?.(); + this.middlewareCleanup = null; + + const { videoTrack, audioTrack, onClear } = (await middleware?.(video, audio)) ?? { + videoTrack: video, + audioTrack: audio, + onClear: null, + }; + this.middlewareCleanup = onClear; + + const replacePromises: Promise[] = []; + if (videoTrack && this.trackIds?.videoId) + replacePromises.push(this.deps.publisher.replaceTrack(this.trackIds.videoId, videoTrack)); + if (audioTrack && this.trackIds?.audioId) + replacePromises.push(this.deps.publisher.replaceTrack(this.trackIds.audioId, audioTrack)); + await Promise.all(replacePromises); + + this.notify(); + } + + public dispose(): void { + for (const cleanup of this.sessionCleanups) cleanup(); + this.detachTrackEndListeners(); + this.middlewareCleanup?.(); + this.middlewareCleanup = null; + if (this.stream) { + const [video, audio] = getTracksFromStream(this.stream); + video?.stop(); + audio?.stop(); + } + this.stream = null; + this.trackIds = null; + } + + private async addTrack(track: PlatformMediaStreamTrack, metadata: TrackMetadata): Promise { + try { + return await this.deps.publisher.addTrack(track, metadata); + } catch (err) { + if (err instanceof TrackTypeError) { + this.deps.logger.warn(err.message); + return undefined; + } + throw err; + } + } + + private attachTrackEndListeners(stream: PlatformMediaStream): void { + this.detachTrackEndListeners(); + const [video, audio] = getTracksFromStream(stream); + + const handleTrackEnded = () => { + void this.stop().catch((err) => this.deps.logger.error(err)); + }; + + for (const track of [video, audio]) { + if (!track) continue; + track.addEventListener?.("ended", handleTrackEnded); + this.trackEndCleanups.push(() => track.removeEventListener?.("ended", handleTrackEnded)); + } + } + + private detachTrackEndListeners(): void { + for (const cleanup of this.trackEndCleanups) cleanup(); + this.trackEndCleanups = []; + } + + private notify(): void { + this.deps.onStateChanged(); + } +} + +const getTracksFromStream = ( + stream: PlatformMediaStream, +): [PlatformMediaStreamTrack | null, PlatformMediaStreamTrack | null] => { + const video = stream.getVideoTracks()[0] ?? null; + const audio = stream.getAudioTracks()[0] ?? null; + + return [video, audio]; +}; diff --git a/packages/tsunami/src/controllers/TrackDeviceController.ts b/packages/tsunami/src/controllers/TrackDeviceController.ts new file mode 100644 index 000000000..e0a5bfc3d --- /dev/null +++ b/packages/tsunami/src/controllers/TrackDeviceController.ts @@ -0,0 +1,413 @@ +import { type Logger, type TrackMetadata, TrackTypeError, Variant } from "@fishjam-cloud/ts-client"; + +import { DeviceError, UnknownDeviceError } from "../devices/errors"; +import type { + DeviceItem, + DeviceType, + IDeviceManager, + PlatformMediaStream, + PlatformMediaStreamTrack, +} from "../devices/deviceManager"; +import type { BandwidthLimits, StreamConfig, TrackMiddleware } from "../mediaTypes"; +import type { LocalDeviceState, PeerStatus } from "../state/clientState"; +import { getConfigAndBandwidthFromProps, getTrackFromStream, stopStream } from "../tracks/trackUtils"; +import type { TrackPublisher } from "./TrackPublisher"; + +export type TrackDeviceControllerDeps = { + type: DeviceType; + publisher: TrackPublisher; + deviceManager: IDeviceManager; + constraints: MediaTrackConstraints | boolean | undefined; + bandwidthLimits: BandwidthLimits; + streamConfig?: StreamConfig; + logger: Logger; + getPeerStatus: () => PeerStatus; + /** Devices of this controller's kind, from the orchestrator's last enumeration. */ + getAvailableDevices: () => DeviceItem[]; + getInitialStream: () => Promise; + /** Called when this controller replaces its stream, so the shared initial stream stops being reused. */ + invalidateInitialStream: () => void; + onSelectedDeviceChanged: (device: DeviceItem) => void; + onStateChanged: () => void; +}; + +const DEFAULT_SENT_QUALITIES: Variant[] = [Variant.VARIANT_LOW, Variant.VARIANT_MEDIUM, Variant.VARIANT_HIGH]; + +/** + * Owns the full lifecycle of one local media device (camera or microphone): + * acquisition, soft mute, middleware, device switching, and publishing the + * resulting track to the room. + */ +export class TrackDeviceController { + public error: DeviceError | null = null; + + private stream: PlatformMediaStream | null = null; + private processedTrack: PlatformMediaStreamTrack | null = null; + private displayStream: PlatformMediaStream | null = null; + private displayStreamTrack: PlatformMediaStreamTrack | null = null; + private middleware: TrackMiddleware = null; + private middlewareCleanup: (() => void) | null = null; + private isEnabled = true; + private selectedDevice: DeviceItem | null = null; + + private currentTrackId: string | null = null; + private connectionPromise: Promise | null = null; + + private trackEndCleanup: (() => void) | null = null; + private readonly sessionCleanups: (() => void)[]; + private lastSnapshot: LocalDeviceState | null = null; + + public constructor(private readonly deps: TrackDeviceControllerDeps) { + this.sessionCleanups = [ + deps.publisher.onJoined(() => this.handleJoined()), + deps.publisher.onDisconnected(() => this.handleDisconnected()), + ]; + } + + public get rawTrack(): PlatformMediaStreamTrack | null { + return this.stream && getTrackFromStream(this.stream, this.deps.type); + } + + public get deviceTrack(): PlatformMediaStreamTrack | null { + return this.processedTrack ?? this.rawTrack; + } + + public snapshot(): LocalDeviceState { + const rawDeviceId = this.rawTrack?.getSettings().deviceId; + const next: LocalDeviceState = { + track: this.deviceTrack, + stream: this.getDisplayStream(), + isEnabled: this.isEnabled, + activeDevice: (rawDeviceId && this.deps.getAvailableDevices().find((d) => d.deviceId === rawDeviceId)) || null, + selectedDevice: this.selectedDevice, + middleware: this.middleware, + }; + + const previous = this.lastSnapshot; + const isUnchanged = + previous && + previous.track === next.track && + previous.stream === next.stream && + previous.isEnabled === next.isEnabled && + previous.activeDevice === next.activeDevice && + previous.selectedDevice === next.selectedDevice && + previous.middleware === next.middleware; + if (isUnchanged) return previous; + + this.lastSnapshot = next; + return next; + } + + /** Adopts the stream produced by `initializeDevices` without invalidating it. */ + public adoptInitialStream(stream: PlatformMediaStream | null): void { + if (!stream || this.stream) return; + this.setStream(stream); + this.notify(); + } + + public setSelectedDevice(device: DeviceItem): void { + this.selectedDevice = device; + this.deps.onSelectedDeviceChanged(device); + this.notify(); + } + + public setError(error: DeviceError | null): void { + this.error = error; + this.notify(); + } + + public async startDevice( + deviceId: string | undefined = this.selectedDevice?.deviceId, + ): Promise<[PlatformMediaStreamTrack, null] | [null, DeviceError]> { + const initialStream = await this.deps.getInitialStream(); + + const initialTrack = initialStream && getTrackFromStream(initialStream, this.deps.type); + const isUsingDesiredDevice = !deviceId || deviceId === initialTrack?.getSettings().deviceId; + + if (initialTrack?.enabled && isUsingDesiredDevice) { + if (!this.stream) { + this.setStream(initialStream); + this.notify(); + } + return [initialTrack, null]; + } + + try { + const baseConstraints = typeof this.deps.constraints === "object" ? { ...this.deps.constraints } : {}; + if (deviceId) baseConstraints.deviceId = { exact: deviceId }; + const stream = await this.deps.deviceManager.getUserMedia({ [this.deps.type]: baseConstraints }); + + if (this.stream) stopStream(this.stream, this.deps.type); + this.deps.invalidateInitialStream(); + this.setStream(stream); + + const retrievedTrack = getTrackFromStream(stream, this.deps.type); + if (!retrievedTrack) throw new Error(`getUserMedia returned no ${this.deps.type} track`); + + const retrievedDeviceId = retrievedTrack.getSettings().deviceId; + if (retrievedDeviceId) { + const device = this.deps.getAvailableDevices().find((d) => d.deviceId === retrievedDeviceId); + if (device) this.setSelectedDevice(device); + } + + if (!this.isEnabled) retrievedTrack.enabled = false; + + this.notify(); + return [retrievedTrack, null]; + } catch (err) { + const parsedError = err instanceof DeviceError ? err : new UnknownDeviceError({ cause: err }); + this.error = parsedError; + this.notify(); + return [null, parsedError]; + } + } + + public stopDevice(): void { + if (this.stream) stopStream(this.stream, this.deps.type); + this.deps.invalidateInitialStream(); + this.setStream(null); + this.notify(); + } + + public enableDevice(): void { + const track = this.deviceTrack; + if (!track) return; + track.enabled = true; + this.isEnabled = true; + this.notify(); + } + + public disableDevice(): void { + const track = this.deviceTrack; + if (!track) return; + track.enabled = false; + this.isEnabled = false; + this.notify(); + } + + public async applyMiddleware(newMiddleware: TrackMiddleware): Promise { + this.middlewareCleanup?.(); + this.middlewareCleanup = null; + this.middleware = newMiddleware; + + const rawTrack = this.rawTrack; + if (newMiddleware && rawTrack) { + const { track, onClear } = await newMiddleware(rawTrack); + this.middlewareCleanup = onClear ?? null; + this.processedTrack = track; + this.notify(); + return track; + } + + this.processedTrack = null; + this.notify(); + return rawTrack; + } + + // --- streaming layer (ties the local device to the room) --- + + public async start(deviceId?: string): Promise { + const [track, error] = await this.startDevice(deviceId); + if (error) return error; + + const currentTrackId = await this.getCurrentTrackId(); + if (currentTrackId) { + await this.resumeStreaming(currentTrackId, track); + } else if (this.deps.getPeerStatus() === "connected") { + await this.startStreaming(track); + } + return undefined; + } + + public async stop(): Promise { + const currentTrackId = await this.getCurrentTrackId(); + this.stopDevice(); + if (currentTrackId) await this.pauseStreaming(currentTrackId); + } + + public async toggleDevice(): Promise { + const currentTrackId = await this.getCurrentTrackId(); + if (this.deviceTrack) { + this.stopDevice(); + if (currentTrackId) await this.pauseStreaming(currentTrackId); + return undefined; + } + + const [newTrack, error] = await this.startDevice(); + if (error) return error; + + if (currentTrackId) { + await this.resumeStreaming(currentTrackId, newTrack); + } else if (this.deps.getPeerStatus() === "connected") { + await this.startStreaming(newTrack); + } + return undefined; + } + + public async toggleMute(): Promise { + const currentTrackId = await this.getCurrentTrackId(); + const isTrackCurrentlyEnabled = Boolean(this.deviceTrack?.enabled); + if (!currentTrackId) { + this.deps.logger.warn("Toggling mute is only possible while connected to a room."); + return; + } + + if (isTrackCurrentlyEnabled) { + this.disableDevice(); + await this.pauseStreaming(currentTrackId); + } else if (this.deviceTrack) { + this.enableDevice(); + await this.resumeStreaming(currentTrackId, this.deviceTrack); + } + } + + public async selectDevice(deviceId: string): Promise { + if (!this.deviceTrack) { + const device = this.deps.getAvailableDevices().find((d) => d.deviceId === deviceId); + if (device) this.setSelectedDevice(device); + return undefined; + } + + const [newTrack, error] = await this.startDevice(deviceId); + if (error) return error; + + const currentTrackId = await this.getCurrentTrackId(); + if (!currentTrackId) return undefined; + + await this.deps.publisher.replaceTrack(currentTrackId, newTrack); + return undefined; + } + + public async setTrackMiddleware(middleware: TrackMiddleware): Promise { + const processedTrack = await this.applyMiddleware(middleware); + + const currentTrackId = await this.getCurrentTrackId(); + if (!currentTrackId) return; + + await this.deps.publisher.replaceTrack(currentTrackId, processedTrack); + } + + public dispose(): void { + for (const cleanup of this.sessionCleanups) cleanup(); + this.middlewareCleanup?.(); + this.middlewareCleanup = null; + if (this.stream) stopStream(this.stream, this.deps.type); + this.setStream(null); + } + + private async getCurrentTrackId(): Promise { + if (this.connectionPromise) { + await this.connectionPromise.catch(() => undefined); + } + if (!this.currentTrackId) return null; + return this.deps.publisher.resolveRemoteTrackId(this.currentTrackId); + } + + private async startStreaming(track: PlatformMediaStreamTrack): Promise { + // temporarily setting the local trackId until we have the remoteTrackId + this.currentTrackId = track.id; + + const trackMetadata: TrackMetadata = { type: this.deps.type === "video" ? "camera" : "microphone", paused: false }; + + const displayName = this.deps.publisher.getDisplayName(); + if (displayName) trackMetadata.displayName = displayName; + + const sentQualities = this.deps.streamConfig?.sentQualities ?? DEFAULT_SENT_QUALITIES; + const [maxBandwidth, simulcastConfig] = getConfigAndBandwidthFromProps(sentQualities, this.deps.bandwidthLimits); + + try { + const addTrackJob = this.deps.publisher.addTrack(track, trackMetadata, simulcastConfig, maxBandwidth); + this.connectionPromise = addTrackJob; + this.currentTrackId = await addTrackJob; + } catch (err) { + if (err instanceof TrackTypeError) { + this.deps.logger.warn(err.message); + this.currentTrackId = null; + } + throw err; + } + } + + private async pauseStreaming(trackId: string): Promise { + if (this.deps.getPeerStatus() !== "connected") return; + await this.deps.publisher.replaceTrack(trackId, null); + this.deps.publisher.updateTrackMetadata(trackId, { + type: this.deps.type === "video" ? "camera" : "microphone", + paused: true, + }); + } + + private async resumeStreaming(trackId: string, track: PlatformMediaStreamTrack): Promise { + if (this.deps.getPeerStatus() !== "connected") return; + await this.deps.publisher.replaceTrack(trackId, track); + this.deps.publisher.updateTrackMetadata(trackId, { + type: this.deps.type === "video" ? "camera" : "microphone", + paused: false, + }); + } + + private handleJoined(): void { + const track = this.deviceTrack; + if (!track) return; + // The handler is sync; observe rejections so non-TrackTypeError failures + // from addTrack don't surface as unhandledrejection. + void this.startStreaming(track).catch((err) => { + if (err instanceof TrackTypeError) return; + this.deps.logger.error(err); + }); + } + + private handleDisconnected(): void { + this.currentTrackId = null; + this.connectionPromise = null; + } + + private setStream(stream: PlatformMediaStream | null): void { + this.trackEndCleanup?.(); + this.trackEndCleanup = null; + this.stream = stream; + + if (!stream) { + if (this.processedTrack) { + this.processedTrack.stop(); + this.middlewareCleanup?.(); + this.middlewareCleanup = null; + this.processedTrack = null; + } + return; + } + + const rawTrack = getTrackFromStream(stream, this.deps.type); + if (!rawTrack) return; + + const handleTrackEnded = () => { + if (this.rawTrack !== rawTrack) return; + this.setStream(null); + this.notify(); + }; + rawTrack.addEventListener?.("ended", handleTrackEnded); + this.trackEndCleanup = () => rawTrack.removeEventListener?.("ended", handleTrackEnded); + } + + // The acquisition stream can carry both kinds (initializeDevices shares one + // stream between camera and microphone), so the state exposes a stream + // scoped to exactly the active track. Rebuilt only when that track changes, + // keeping the snapshot reference stable. + private getDisplayStream(): PlatformMediaStream | null { + const activeTrack = this.deviceTrack; + if (!activeTrack) { + this.displayStream = null; + this.displayStreamTrack = null; + return null; + } + if (this.displayStreamTrack !== activeTrack) { + this.displayStream = this.deps.deviceManager.createMediaStream([activeTrack]); + this.displayStreamTrack = activeTrack; + } + return this.displayStream; + } + + private notify(): void { + this.deps.onStateChanged(); + } +} diff --git a/packages/tsunami/src/controllers/TrackPublisher.ts b/packages/tsunami/src/controllers/TrackPublisher.ts new file mode 100644 index 000000000..2dee2f7d6 --- /dev/null +++ b/packages/tsunami/src/controllers/TrackPublisher.ts @@ -0,0 +1,28 @@ +import type { SimulcastConfig, TrackBandwidthLimit, TrackMetadata } from "@fishjam-cloud/ts-client"; + +import type { PlatformMediaStreamTrack } from "../devices/deviceManager"; + +/** + * Narrow signalling surface the device controllers publish through. + * + * Controllers never talk to the signalling client directly — the client hands + * them this adapter, which also makes them trivially testable. + */ +export type TrackPublisher = { + addTrack: ( + track: PlatformMediaStreamTrack, + metadata: TrackMetadata, + simulcastConfig?: SimulcastConfig, + maxBandwidth?: TrackBandwidthLimit, + ) => Promise; + replaceTrack: (trackId: string, newTrack: PlatformMediaStreamTrack | null) => Promise; + removeTrack: (trackId: string) => Promise; + updateTrackMetadata: (trackId: string, metadata: TrackMetadata) => void; + getDisplayName: () => string | undefined; + /** Resolves a local or remote track id to the current remote track id, if the track is still published. */ + resolveRemoteTrackId: (remoteOrLocalTrackId: string) => string | null; + /** `true` while the signalling client exists and can publish tracks. */ + isSignallingActive: () => boolean; + onJoined: (listener: () => void) => () => void; + onDisconnected: (listener: () => void) => () => void; +}; diff --git a/packages/tsunami/src/devices/WebDeviceManager.ts b/packages/tsunami/src/devices/WebDeviceManager.ts index 53c4bbb82..1e72d9e78 100644 --- a/packages/tsunami/src/devices/WebDeviceManager.ts +++ b/packages/tsunami/src/devices/WebDeviceManager.ts @@ -1,4 +1,11 @@ -import type { DeviceItem, DeviceType, IDeviceManager, IDevicePersistence } from "./deviceManager"; +import type { + DeviceItem, + DeviceType, + IDeviceManager, + IDevicePersistence, + PlatformMediaStreamTrack, +} from "./deviceManager"; +import { classifyDeviceError } from "./errors"; export type WebDeviceManagerOptions = { persistence?: IDevicePersistence; @@ -28,15 +35,34 @@ export class WebDeviceManager implements IDeviceManager { } public async getUserMedia(constraints: MediaStreamConstraints): Promise { - return this.getMediaDevices().getUserMedia(constraints); + const mediaDevices = this.getMediaDevices(); + try { + return await mediaDevices.getUserMedia(constraints); + } catch (error) { + throw classifyDeviceError(error); + } } public async getDisplayMedia(options?: DisplayMediaStreamOptions): Promise { - return this.getMediaDevices().getDisplayMedia(options); + const mediaDevices = this.getMediaDevices(); + try { + return await mediaDevices.getDisplayMedia(options); + } catch (error) { + throw classifyDeviceError(error); + } + } + + public createMediaStream(tracks: PlatformMediaStreamTrack[]): MediaStream { + return new MediaStream(tracks as MediaStreamTrack[]); } public onDeviceChange(callback: () => void): () => void { const mediaDevices = this.getMediaDevices(); + + // React Native's polyfilled navigator.mediaDevices has no devicechange + // events; device-list refreshes then only happen on explicit operations. + if (typeof mediaDevices.addEventListener !== "function") return () => {}; + const listener = () => callback(); let subscribed = true; diff --git a/packages/tsunami/src/devices/constraints.ts b/packages/tsunami/src/devices/constraints.ts new file mode 100644 index 000000000..636454ff4 --- /dev/null +++ b/packages/tsunami/src/devices/constraints.ts @@ -0,0 +1,31 @@ +export const VIDEO_TRACK_CONSTRAINTS: MediaTrackConstraints = { + width: { + max: 1280, + ideal: 1280, + min: 640, + }, + height: { + max: 720, + ideal: 720, + min: 320, + }, + frameRate: { + max: 30, + ideal: 24, + }, +}; + +export const prepareConstraints = ( + deviceIdToStart: string | undefined, + constraints: MediaTrackConstraints | undefined | boolean, +): MediaTrackConstraints | undefined | boolean => { + if (!deviceIdToStart) return constraints; + + // The resulting stream will not contain a track of this type, + // which means that the device will not be activated. + if (constraints === false) return false; + + const constraintsObject = constraints === true ? {} : constraints; + + return { ...constraintsObject, deviceId: { ideal: deviceIdToStart } }; +}; diff --git a/packages/tsunami/src/devices/deviceManager.ts b/packages/tsunami/src/devices/deviceManager.ts index d60bcafbe..117178fd1 100644 --- a/packages/tsunami/src/devices/deviceManager.ts +++ b/packages/tsunami/src/devices/deviceManager.ts @@ -12,20 +12,54 @@ export interface IDevicePersistence { saveLastDevice(type: DeviceType, device: DeviceItem): void | Promise; } +/** + * Minimal track surface the SDK core relies on. Both the DOM + * `MediaStreamTrack` and react-native-webrtc's `MediaStreamTrack` satisfy it + * structurally, so the core never depends on either platform's full type. + */ +export interface PlatformMediaStreamTrack { + readonly id: string; + enabled: boolean; + stop(): void; + getSettings(): { deviceId?: string }; + // Optional because react-native-webrtc's bundled declarations do not expose + // the listener methods inherited from its EventTarget shim (FCE-3689), even + // though the runtime objects have them. Callers feature-detect at runtime. + addEventListener?(type: "ended", listener: () => void): void; + removeEventListener?(type: "ended", listener: () => void): void; +} + +/** Minimal stream surface the SDK core relies on — see {@link PlatformMediaStreamTrack}. */ +export interface PlatformMediaStream { + getTracks(): PlatformMediaStreamTrack[]; + getVideoTracks(): PlatformMediaStreamTrack[]; + getAudioTracks(): PlatformMediaStreamTrack[]; +} + /** * Platform boundary for local media acquisition. * * The SDK core uses this interface instead of accessing platform globals such - * as `navigator.mediaDevices`. Implementations may use browser APIs, native - * APIs, or deterministic test doubles. + * as `navigator.mediaDevices`, and only relies on the + * {@link PlatformMediaStream} surface of the returned streams. Implementations + * may use browser APIs, native APIs, or deterministic test doubles. + * + * `getUserMedia` and `getDisplayMedia` reject with a classified `DeviceError` + * — platform error shapes never cross this boundary. * * @typeParam TMediaStream - Stream type returned by the target platform. */ -export interface IDeviceManager { +export interface IDeviceManager { readonly persistence?: IDevicePersistence; enumerateDevices(): Promise; getUserMedia(constraints: MediaStreamConstraints): Promise; getDisplayMedia(options?: DisplayMediaStreamOptions): Promise; onDeviceChange(callback: () => void): () => void; + /** + * Wraps tracks in a platform stream, e.g. to make a middleware-processed + * track renderable. Stream construction is a platform concern — the SDK + * core never touches a `MediaStream` constructor. + */ + createMediaStream(tracks: PlatformMediaStreamTrack[]): TMediaStream; } diff --git a/packages/tsunami/src/devices/errors.ts b/packages/tsunami/src/devices/errors.ts new file mode 100644 index 000000000..e00a13f9d --- /dev/null +++ b/packages/tsunami/src/devices/errors.ts @@ -0,0 +1,74 @@ +import { type ErrorRecoverability, FishjamError } from "../errors/FishjamError"; + +export type DeviceErrorName = "NotAllowedError" | "NotFoundError" | "OverconstrainedError" | "UNHANDLED_ERROR"; + +/** + * A local media device could not be acquired. Rejected by device operations + * and mirrored into the matching `ClientState` error field; a later success + * on the same device clears it. + */ +export abstract class DeviceError extends FishjamError { + public abstract override readonly name: DeviceErrorName; +} + +/** The user (or browser policy) denied access to the device. */ +export class DevicePermissionDeniedError extends DeviceError { + public override readonly name = "NotAllowedError"; + public readonly recoverability: ErrorRecoverability = "user_action"; + + public constructor(options?: { cause?: unknown }) { + super("Permission to use the device was denied", options); + } +} + +/** No device of the requested kind is available. */ +export class DeviceNotFoundError extends DeviceError { + public override readonly name = "NotFoundError"; + public readonly recoverability: ErrorRecoverability = "user_action"; + + public constructor(options?: { cause?: unknown }) { + super("No matching device was found", options); + } +} + +/** The requested constraints cannot be satisfied by any available device. */ +export class DeviceOverconstrainedError extends DeviceError { + public override readonly name = "OverconstrainedError"; + public readonly recoverability: ErrorRecoverability = "retry"; + + public constructor(options?: { cause?: unknown }) { + super("The requested device constraints cannot be satisfied", options); + } +} + +/** Device acquisition failed for a reason the SDK does not recognize. */ +export class UnknownDeviceError extends DeviceError { + public override readonly name = "UNHANDLED_ERROR"; + public readonly recoverability: ErrorRecoverability = "retry"; + + public constructor(options?: { cause?: unknown }) { + super("Device acquisition failed", options); + } +} + +// https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#exceptions +/** + * Maps a platform media-acquisition failure to a {@link DeviceError}. + * Intended for `IDeviceManager` implementations — the platform error shape + * never crosses into the SDK core. + */ +export const classifyDeviceError = (error: unknown): DeviceError => { + if (error instanceof DeviceError) return error; + + const name = error instanceof Error ? error.name : ""; + switch (name) { + case "NotAllowedError": + return new DevicePermissionDeniedError({ cause: error }); + case "OverconstrainedError": + return new DeviceOverconstrainedError({ cause: error }); + case "NotFoundError": + return new DeviceNotFoundError({ cause: error }); + default: + return new UnknownDeviceError({ cause: error }); + } +}; diff --git a/packages/tsunami/src/devices/mediaInitializer.ts b/packages/tsunami/src/devices/mediaInitializer.ts new file mode 100644 index 000000000..262587999 --- /dev/null +++ b/packages/tsunami/src/devices/mediaInitializer.ts @@ -0,0 +1,157 @@ +import { prepareConstraints } from "./constraints"; +import type { DeviceItem, IDeviceManager, PlatformMediaStream } from "./deviceManager"; +import { DeviceError, DeviceOverconstrainedError, DevicePermissionDeniedError, UnknownDeviceError } from "./errors"; + +type AudioVideo = { audio: T; video: T }; + +type MediaConstraints = AudioVideo; +type PreviousDevices = AudioVideo; + +export type GetMediaResult = { stream: PlatformMediaStream | null; errors: AudioVideo }; + +const defaultErrors: AudioVideo = { audio: null, video: null }; + +// The device manager rejects with classified DeviceError instances (see +// IDeviceManager), so this module only routes on `error.name`. +const asDeviceError = (error: unknown): DeviceError => + error instanceof DeviceError ? error : new UnknownDeviceError({ cause: error }); + +const getSingleMedia = async ( + deviceManager: IDeviceManager, + type: T, + constraints: MediaStreamConstraints[T], +): Promise<[PlatformMediaStream, null] | [null, DeviceError]> => { + const baseConstraints = { audio: undefined, video: undefined }; + + try { + const stream = await deviceManager.getUserMedia({ ...baseConstraints, [type]: constraints }); + return [stream, null]; + } catch (err) { + return [null, asDeviceError(err)]; + } +}; + +const tryToGetAudioOnlyThenVideoOnly = async ( + deviceManager: IDeviceManager, + constraints: MediaStreamConstraints, + initialError: DeviceError, +): Promise => { + const [audioStream, audioError] = await getSingleMedia(deviceManager, "audio", constraints.audio); + if (audioStream) return { stream: audioStream, errors: { video: initialError, audio: null } }; + + const [videoStream, videoError] = await getSingleMedia(deviceManager, "video", constraints.video); + return { stream: videoStream, errors: { audio: audioError, video: videoError } }; +}; + +export const getAvailableMedia = async ( + deviceManager: IDeviceManager, + constraints: MediaStreamConstraints, + errors: AudioVideo = defaultErrors, +): Promise => { + try { + return { stream: await deviceManager.getUserMedia(constraints), errors }; + } catch (err: unknown) { + const deviceError = asDeviceError(err); + switch (deviceError.name) { + case errors.audio?.name: + case errors.video?.name: + return { stream: null, errors }; + case "NotFoundError": + case "NotAllowedError": + return tryToGetAudioOnlyThenVideoOnly( + deviceManager, + constraints, + new DevicePermissionDeniedError({ cause: err }), + ); + case "OverconstrainedError": + return getAvailableMedia( + deviceManager, + { audio: unspecifyDevice(constraints.audio), video: unspecifyDevice(constraints.video) }, + { audio: deviceError, video: new DeviceOverconstrainedError({ cause: err }) }, + ); + default: + return { stream: null, errors: { audio: deviceError, video: new UnknownDeviceError({ cause: err }) } }; + } + } +}; + +// Safari changes deviceId between sessions, therefore we cannot rely on deviceId for identification purposes. +// We can switch a random device that comes from safari to one that has the same label as the one used in the previous session. +export const correctDevicesOnSafari = async ( + deviceManager: IDeviceManager, + stream: PlatformMediaStream, + errors: AudioVideo, + devices: DeviceItem[], + constraints: MediaConstraints, + previousDevices: PreviousDevices, +): Promise => { + const shouldCorrectDevices = isAnyDeviceDifferentFromLastSession( + previousDevices.video, + previousDevices.audio, + getCurrentDevicesSettings(stream, devices), + ); + + if (!shouldCorrectDevices) return { stream, errors }; + + const videoIdToStart = devices.find((device) => device.label === previousDevices.video?.label)?.deviceId; + const audioIdToStart = devices.find((device) => device.label === previousDevices.audio?.label)?.deviceId; + + if (!videoIdToStart && !audioIdToStart) return { stream, errors }; + + stopTracks(stream); + + const exactConstraints: MediaStreamConstraints = { + video: !errors.video && prepareConstraints(videoIdToStart, constraints.video), + audio: !errors.audio && prepareConstraints(audioIdToStart, constraints.audio), + }; + + return await getAvailableMedia(deviceManager, exactConstraints, errors); +}; + +type CurrentDevices = AudioVideo; + +const getCurrentDevicesSettings = ( + requestedDevices: PlatformMediaStream, + availableDevices: DeviceItem[], +): CurrentDevices => { + const currentDevices: CurrentDevices = { video: null, audio: null }; + + for (const track of requestedDevices.getTracks()) { + const settings = track.getSettings(); + if (settings.deviceId) { + const currentDevice = availableDevices.find((device) => device.deviceId == settings.deviceId); + if (currentDevice && (currentDevice.kind === "video" || currentDevice.kind === "audio")) { + currentDevices[currentDevice.kind] = currentDevice; + } + } + } + return currentDevices; +}; + +const isDeviceDifferentFromLastSession = (lastDevice: DeviceItem | null, currentDevice: DeviceItem | null) => + lastDevice && (currentDevice?.deviceId !== lastDevice.deviceId || currentDevice?.label !== lastDevice?.label); + +const isAnyDeviceDifferentFromLastSession = ( + lastVideoDevice: DeviceItem | null, + lastAudioDevice: DeviceItem | null, + currentDevices: CurrentDevices | null, +): boolean => + !!( + (currentDevices?.video && isDeviceDifferentFromLastSession(lastVideoDevice, currentDevices?.video || null)) || + (currentDevices?.audio && isDeviceDifferentFromLastSession(lastAudioDevice, currentDevices?.audio || null)) + ); + +const stopTracks = (requestedDevices: PlatformMediaStream) => { + for (const track of requestedDevices.getTracks()) { + track.stop(); + } +}; + +const unspecifyDevice = ( + trackConstraints?: boolean | MediaTrackConstraints, +): boolean | MediaTrackConstraints | undefined => { + if (typeof trackConstraints === "object") { + return { ...trackConstraints, deviceId: undefined }; + } + return trackConstraints; +}; diff --git a/packages/tsunami/src/errors/lifecycleErrors.ts b/packages/tsunami/src/errors/lifecycleErrors.ts index 0842fffda..93166ddcc 100644 --- a/packages/tsunami/src/errors/lifecycleErrors.ts +++ b/packages/tsunami/src/errors/lifecycleErrors.ts @@ -15,3 +15,30 @@ export class ClientDisposedError extends FishjamError { this.name = "ClientDisposedError"; } } + +/** + * Thrown when a device-related method is called on a client that was created + * without a device manager. Construct the client with a `deviceManager` to + * use the device API; a signalling-only client cannot acquire local media. + */ +export class DeviceManagerMissingError extends FishjamError { + public readonly recoverability: ErrorRecoverability = "fatal"; + + public constructor() { + super("This FishjamClient was created without a device manager, so the device API is unavailable"); + this.name = "DeviceManagerMissingError"; + } +} + +/** + * Rejected by `createDataChannels()` when the peer is not connected to a + * room yet; connect first, then retry. + */ +export class DataChannelsNotConnectedError extends FishjamError { + public readonly recoverability: ErrorRecoverability = "retry"; + + public constructor() { + super("Peer is not connected"); + this.name = "DataChannelsNotConnectedError"; + } +} diff --git a/packages/react-client/src/utils/fishjamUrl.ts b/packages/tsunami/src/fishjamUrl.ts similarity index 100% rename from packages/react-client/src/utils/fishjamUrl.ts rename to packages/tsunami/src/fishjamUrl.ts diff --git a/packages/tsunami/src/index.ts b/packages/tsunami/src/index.ts index d5222e1db..1195af177 100644 --- a/packages/tsunami/src/index.ts +++ b/packages/tsunami/src/index.ts @@ -3,17 +3,80 @@ * * @packageDocumentation */ -export type { DeviceItem, DeviceType, IDeviceManager, IDevicePersistence } from "./devices/deviceManager"; +export type { DeviceOrchestrator } from "./controllers/DeviceOrchestrator"; +export type { ScreenShareConstraints } from "./controllers/ScreenShareController"; +export type { TrackDeviceController } from "./controllers/TrackDeviceController"; +export type { + DeviceItem, + DeviceType, + IDeviceManager, + IDevicePersistence, + PlatformMediaStream, + PlatformMediaStreamTrack, +} from "./devices/deviceManager"; +export { + classifyDeviceError, + DeviceError, + type DeviceErrorName, + DeviceNotFoundError, + DeviceOverconstrainedError, + DevicePermissionDeniedError, + UnknownDeviceError, +} from "./devices/errors"; export { LocalStorageDevicePersistence } from "./devices/LocalStorageDevicePersistence"; export { WebDeviceManager, type WebDeviceManagerOptions } from "./devices/WebDeviceManager"; export { type ErrorRecoverability, FishjamError } from "./errors/FishjamError"; -export { ClientDisposedError } from "./errors/lifecycleErrors"; -export { FishjamClient } from "./FishjamClient"; +export { + ClientDisposedError, + DataChannelsNotConnectedError, + DeviceManagerMissingError, +} from "./errors/lifecycleErrors"; +export { FishjamClient, type FishjamClientConfig } from "./FishjamClient"; +export { + buildLivestreamWhepUrl, + buildLivestreamWhipUrl, + extractDomainFromFishjamId, + httpToWebsocketUrl, + resolveFishjamUrl, +} from "./fishjamUrl"; +export type { + BandwidthLimits, + InitializeDevicesResult, + InitializeDevicesSettings, + InitializeDevicesStatus, + MiddlewareResult, + SimulcastBandwidthLimits, + StreamConfig, + TrackMiddleware, + TracksMiddleware, + TracksMiddlewareResult, +} from "./mediaTypes"; +export { + type ClientState, + createInitialClientState, + type CustomSourceState, + type DataChannelState, + type LocalDeviceState, + type PeerStatus, + type ScreenShareState, +} from "./state/clientState"; +export { + localPeerWithTracks, + type PeerTrackView, + type PeerWithTracksView, + type RemotePeerTrackView, + remotePeerWithTracks, + type RemoteTrackQualitySetter, +} from "./state/peerViews"; +export { StateStore, type StateStoreOptions, type StoreListener } from "./state/StateStore"; +export { + getSandboxLivestream, + getSandboxMoqPublisherAccess, + getSandboxMoqSubscriberAccess, + getSandboxPeerToken, + getSandboxViewerToken, + MissingSandboxApiUrlError, + type MoqAccess, + type RoomType, +} from "./sandbox"; export * from "@fishjam-cloud/ts-client"; - -export type MiddlewareResult = { - track: MediaStreamTrack; - onClear?: () => void; -}; - -export type TrackMiddleware = ((track: MediaStreamTrack) => MiddlewareResult | Promise) | null; diff --git a/packages/tsunami/src/mediaTypes.ts b/packages/tsunami/src/mediaTypes.ts new file mode 100644 index 000000000..e567ea76f --- /dev/null +++ b/packages/tsunami/src/mediaTypes.ts @@ -0,0 +1,48 @@ +import type { Variant } from "@fishjam-cloud/ts-client"; + +import type { DeviceError } from "./devices/errors"; +import type { PlatformMediaStream, PlatformMediaStreamTrack } from "./devices/deviceManager"; + +export type MiddlewareResult = { track: PlatformMediaStreamTrack; onClear?: () => void }; + +/** + * Transforms a single local track (e.g. background blur) before it is + * published. Passing `null` removes the current middleware. + */ +export type TrackMiddleware = + | ((track: PlatformMediaStreamTrack) => MiddlewareResult | Promise) + | null; + +export type TracksMiddlewareResult = { + videoTrack: PlatformMediaStreamTrack; + audioTrack: PlatformMediaStreamTrack | null; + onClear: () => void; +}; + +/** + * Transforms a screen-share track pair before it is published. + */ +export type TracksMiddleware = ( + videoTrack: PlatformMediaStreamTrack, + audioTrack: PlatformMediaStreamTrack | null, +) => TracksMiddlewareResult | Promise; + +export type SimulcastBandwidthLimits = { + [Variant.VARIANT_LOW]: number; + [Variant.VARIANT_MEDIUM]: number; + [Variant.VARIANT_HIGH]: number; +}; + +export type BandwidthLimits = { singleStream: number; simulcast: SimulcastBandwidthLimits }; + +export type StreamConfig = { sentQualities?: Variant[] | false }; + +export type InitializeDevicesStatus = "initialized" | "failed" | "initialized_with_errors" | "already_initialized"; + +export type InitializeDevicesResult = { + status: InitializeDevicesStatus; + stream: PlatformMediaStream | null; + errors: { audio: DeviceError | null; video: DeviceError | null } | null; +}; + +export type InitializeDevicesSettings = { enableVideo?: boolean; enableAudio?: boolean }; diff --git a/packages/tsunami/src/sandbox.test.ts b/packages/tsunami/src/sandbox.test.ts new file mode 100644 index 000000000..fcd151e10 --- /dev/null +++ b/packages/tsunami/src/sandbox.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { buildLivestreamWhepUrl, buildLivestreamWhipUrl, httpToWebsocketUrl, resolveFishjamUrl } from "./fishjamUrl"; +import { getSandboxLivestream, getSandboxPeerToken, getSandboxViewerToken, MissingSandboxApiUrlError } from "./sandbox"; + +const mockFetch = (options: { ok: boolean; status?: number; json?: () => Promise }) => { + const fetchSpy = vi.fn(async (_input: string | URL) => ({ + ok: options.ok, + status: options.status ?? (options.ok ? 200 : 500), + json: options.json ?? (async () => ({})), + })); + vi.stubGlobal("fetch", fetchSpy); + return fetchSpy; +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("sandbox helpers", () => { + it("throws a typed error when no sandboxApiUrl is provided", async () => { + await expect(getSandboxPeerToken("", "room", "peer")).rejects.toBeInstanceOf(MissingSandboxApiUrlError); + await expect(getSandboxPeerToken("", "room", "peer")).rejects.toThrow(/sandboxApiUrl/); + }); + + it("getSandboxPeerToken builds the query and returns the peer token", async () => { + const fetchSpy = mockFetch({ ok: true, json: async () => ({ peerToken: "pt-1" }) }); + + const peerToken = await getSandboxPeerToken("https://sandbox.example/api", "my-room", "alice", "audio_only"); + + expect(peerToken).toBe("pt-1"); + const requestedUrl = new URL(fetchSpy.mock.calls[0][0]); + expect(requestedUrl.searchParams.get("roomName")).toBe("my-room"); + expect(requestedUrl.searchParams.get("peerName")).toBe("alice"); + expect(requestedUrl.searchParams.get("roomType")).toBe("audio_only"); + }); + + it("defaults roomType to conference", async () => { + const fetchSpy = mockFetch({ ok: true, json: async () => ({ peerToken: "pt" }) }); + + await getSandboxPeerToken("https://sandbox.example/api", "room", "bob"); + + expect(new URL(fetchSpy.mock.calls[0][0]).searchParams.get("roomType")).toBe("conference"); + }); + + it("getSandboxViewerToken reports a missing livestream room", async () => { + mockFetch({ ok: false, status: 404 }); + + await expect(getSandboxViewerToken("https://sandbox.example/api", "nope")).rejects.toThrow(/does not exist/); + }); + + it("getSandboxLivestream returns the streamer token payload", async () => { + mockFetch({ ok: true, json: async () => ({ streamerToken: "st", room: { id: "1", name: "room" } }) }); + + const data = await getSandboxLivestream("https://sandbox.example/api", "room", true); + + expect(data.streamerToken).toBe("st"); + }); +}); + +describe("fishjam url helpers", () => { + it("resolves a bare fishjam id to the cloud connect url", () => { + expect(resolveFishjamUrl("my-id")).toBe("https://fishjam.io/api/v1/connect/my-id"); + }); + + it("passes a full url through and converts to websocket", () => { + expect(httpToWebsocketUrl(resolveFishjamUrl("https://cloud.example/api/v1/connect/x"))).toBe( + "wss://cloud.example/api/v1/connect/x", + ); + }); + + it("builds livestream urls from the fishjam id domain", () => { + expect(buildLivestreamWhipUrl("https://cloud.example/api/v1/connect/x")).toBe( + "https://cloud.example/api/v1/live/api/whip", + ); + expect(buildLivestreamWhepUrl("bare-id")).toBe("https://fishjam.io/api/v1/live/api/whep"); + }); + + it("rejects an empty fishjam id for livestream urls", () => { + expect(() => buildLivestreamWhipUrl("")).toThrow(/fishjamId is required/); + }); +}); diff --git a/packages/tsunami/src/sandbox.ts b/packages/tsunami/src/sandbox.ts new file mode 100644 index 000000000..1f3b5919b --- /dev/null +++ b/packages/tsunami/src/sandbox.ts @@ -0,0 +1,108 @@ +import { type ErrorRecoverability, FishjamError } from "./errors/FishjamError"; + +type BasicInfo = { id: string; name: string }; + +type RoomManagerResponse = { + peerToken: string; + url: string; + room: BasicInfo; + peer: BasicInfo; +}; + +type MoqAccessResponse = { + connection_url: string; + token: string; +}; + +export type MoqAccess = { + connectionUrl: string; + token: string; +}; + +export type RoomType = "conference" | "livestream" | "audio_only"; + +/** Thrown by the sandbox helpers when no sandboxApiUrl was provided. */ +export class MissingSandboxApiUrlError extends FishjamError { + public readonly recoverability: ErrorRecoverability = "user_action"; + + public constructor() { + super("A sandboxApiUrl is required, you can get it at: https://fishjam.io/app/sandbox"); + this.name = "MissingSandboxApiUrlError"; + } +} + +const requireSandboxApiUrl = (sandboxApiUrl: string): string => { + if (!sandboxApiUrl) throw new MissingSandboxApiUrlError(); + return sandboxApiUrl; +}; + +export const getSandboxPeerToken = async ( + sandboxApiUrl: string, + roomName: string, + peerName: string, + roomType: RoomType = "conference", +): Promise => { + const url = new URL(requireSandboxApiUrl(sandboxApiUrl)); + url.searchParams.set("roomName", roomName); + url.searchParams.set("peerName", peerName); + url.searchParams.set("roomType", roomType); + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to retrieve peer token for peer '${peerName}' in ${roomType} room '${roomName}'.`); + } + + const data: RoomManagerResponse = await response.json(); + return data.peerToken; +}; + +export const getSandboxViewerToken = async (sandboxApiUrl: string, roomName: string): Promise => { + const url = new URL(`${requireSandboxApiUrl(sandboxApiUrl)}/${roomName}/livestream-viewer-token`); + + const response = await fetch(url); + if (!response.ok) { + let message = `Failed to retrieve viewer token for '${roomName}' livestream room.`; + if (response.status === 404) { + message = `A livestream room of name '${roomName}' does not exist.`; + } + throw new Error(message); + } + + const data: { token: string } = await response.json(); + return data.token; +}; + +export const getSandboxLivestream = async ( + sandboxApiUrl: string, + roomName: string, + isPublic: boolean = false, +): Promise<{ streamerToken: string; room: BasicInfo }> => { + const url = new URL(`${requireSandboxApiUrl(sandboxApiUrl)}/livestream`); + url.searchParams.set("roomName", roomName); + url.searchParams.set("public", isPublic.toString()); + + const response = await fetch(url); + if (!response.ok) throw new Error(`Failed to retrieve streamer token for '${roomName}' livestream room.`); + + return response.json(); +}; + +const fetchMoqAccess = async ( + sandboxApiUrl: string, + streamName: string, + type: "subscriber" | "publisher", +): Promise => { + const urlEncodedStreamName = encodeURIComponent(streamName); + + const response = await fetch(`${requireSandboxApiUrl(sandboxApiUrl)}/moq/${urlEncodedStreamName}/${type}`); + if (!response.ok) throw new Error(`Failed to retrieve MoQ ${type} connection for stream '${streamName}'.`); + + const data: MoqAccessResponse = await response.json(); + return { connectionUrl: data.connection_url, token: data.token }; +}; + +export const getSandboxMoqPublisherAccess = (sandboxApiUrl: string, streamName: string): Promise => + fetchMoqAccess(sandboxApiUrl, streamName, "publisher"); + +export const getSandboxMoqSubscriberAccess = (sandboxApiUrl: string, streamName: string): Promise => + fetchMoqAccess(sandboxApiUrl, streamName, "subscriber"); diff --git a/packages/tsunami/src/state/StateStore.test.ts b/packages/tsunami/src/state/StateStore.test.ts new file mode 100644 index 000000000..9da2e8a78 --- /dev/null +++ b/packages/tsunami/src/state/StateStore.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, expectTypeOf, it, vi } from "vitest"; + +import { StateStore } from "./StateStore"; + +type TestState = { + counter: number; + label: string; + slice: { value: number }; + otherSlice: { value: number }; +}; + +const createTestStore = () => + new StateStore({ + counter: 0, + label: "initial", + slice: { value: 1 }, + otherSlice: { value: 2 }, + }); + +describe("StateStore", () => { + describe("snapshot stability and structural sharing", () => { + it("returns the same snapshot object until state actually changes", () => { + const store = createTestStore(); + const snapshotBefore = store.getState(); + + store.update({ counter: 0, label: "initial" }); + + expect(store.getState()).toBe(snapshotBefore); + }); + + it("does not notify listeners for a no-op update", () => { + const store = createTestStore(); + const listener = vi.fn(); + store.subscribe(listener); + + store.update({ counter: 0 }); + + expect(listener).not.toHaveBeenCalled(); + }); + + it("keeps references of slices an update did not touch", () => { + const store = createTestStore(); + const untouchedSliceBefore = store.getState().otherSlice; + + store.update({ slice: { value: 10 } }); + + expect(store.getState().otherSlice).toBe(untouchedSliceBefore); + expect(store.getState().slice).toEqual({ value: 10 }); + }); + + it("replaces the snapshot object when a value changes", () => { + const store = createTestStore(); + const snapshotBefore = store.getState(); + + store.update({ counter: 5 }); + + expect(store.getState()).not.toBe(snapshotBefore); + expect(store.getState().counter).toBe(5); + }); + }); + + describe("notification timing", () => { + it("notifies synchronously, after the snapshot is replaced", () => { + const store = createTestStore(); + let observedCounter: number | null = null; + store.subscribe(() => { + observedCounter = store.getState().counter; + }); + + store.update({ counter: 1 }); + + expect(observedCounter).toBe(1); + }); + + it("notifies once per effective update call", () => { + const store = createTestStore(); + const listener = vi.fn(); + store.subscribe(listener); + + store.update({ counter: 1 }); + store.update({ counter: 1 }); + store.update({ label: "changed" }); + + expect(listener).toHaveBeenCalledTimes(2); + }); + + it("delivers one notification for a multi-slice update", () => { + const store = createTestStore(); + const listener = vi.fn(); + store.subscribe(listener); + + store.update({ counter: 1, label: "changed", slice: { value: 10 } }); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("notifies again when a listener updates the store during a notification", () => { + const store = createTestStore(); + const notifiedCounters: number[] = []; + store.subscribe(() => { + const { counter } = store.getState(); + notifiedCounters.push(counter); + if (counter === 1) store.update({ counter: 2 }); + }); + + store.update({ counter: 1 }); + + expect(notifiedCounters).toEqual([1, 2]); + }); + + it("keeps notifying remaining listeners when one of them throws", () => { + const listenerError = new Error("listener failure"); + const onListenerError = vi.fn(); + const store = new StateStore( + { counter: 0, label: "initial", slice: { value: 1 }, otherSlice: { value: 2 } }, + { onListenerError }, + ); + const throwingListener = vi.fn(() => { + throw listenerError; + }); + const laterListener = vi.fn(); + store.subscribe(throwingListener); + store.subscribe(laterListener); + + store.update({ counter: 1 }); + + expect(throwingListener).toHaveBeenCalledTimes(1); + expect(laterListener).toHaveBeenCalledTimes(1); + expect(onListenerError).toHaveBeenCalledWith(listenerError); + }); + }); + + describe("subscribe", () => { + it("stops notifying after unsubscribe", () => { + const store = createTestStore(); + const listener = vi.fn(); + const unsubscribe = store.subscribe(listener); + + unsubscribe(); + store.update({ counter: 1 }); + + expect(listener).not.toHaveBeenCalled(); + }); + + it("does not notify anyone after clear", () => { + const store = createTestStore(); + const listener = vi.fn(); + store.subscribe(listener); + + store.clear(); + store.update({ counter: 1 }); + + expect(listener).not.toHaveBeenCalled(); + }); + }); + + describe("subscribeToSlice", () => { + it("fires with the next and previous value when the selected slice changes", () => { + const store = createTestStore(); + const sliceListener = vi.fn(); + store.subscribeToSlice((state) => state.slice, sliceListener); + const previousSlice = store.getState().slice; + + store.update({ slice: { value: 10 } }); + + expect(sliceListener).toHaveBeenCalledTimes(1); + expect(sliceListener).toHaveBeenCalledWith({ value: 10 }, previousSlice); + }); + + it("does not fire when only unrelated slices change", () => { + const store = createTestStore(); + const sliceListener = vi.fn(); + store.subscribeToSlice((state) => state.slice, sliceListener); + + store.update({ counter: 1, otherSlice: { value: 20 } }); + + expect(sliceListener).not.toHaveBeenCalled(); + }); + + it("fires once per change of the selected value", () => { + const store = createTestStore(); + const sliceListener = vi.fn(); + store.subscribeToSlice((state) => state.counter, sliceListener); + + store.update({ counter: 1 }); + store.update({ counter: 2 }); + + expect(sliceListener).toHaveBeenCalledTimes(2); + expect(sliceListener).toHaveBeenNthCalledWith(1, 1, 0); + expect(sliceListener).toHaveBeenNthCalledWith(2, 2, 1); + }); + + it("stops firing after unsubscribe", () => { + const store = createTestStore(); + const sliceListener = vi.fn(); + const unsubscribe = store.subscribeToSlice((state) => state.slice, sliceListener); + + unsubscribe(); + store.update({ slice: { value: 10 } }); + + expect(sliceListener).not.toHaveBeenCalled(); + }); + }); + + describe("generics threading", () => { + it("threads the state's type parameters through the store", () => { + type PeerMetadata = { displayName: string }; + type ParameterizedState = { localPeer: { metadata: Metadata } | null }; + + const store = new StateStore>({ localPeer: null }); + const { localPeer } = store.getState(); + + expectTypeOf(localPeer?.metadata).toEqualTypeOf(); + expect(localPeer).toBeNull(); + }); + }); +}); diff --git a/packages/tsunami/src/state/StateStore.ts b/packages/tsunami/src/state/StateStore.ts new file mode 100644 index 000000000..8ca5a1ca9 --- /dev/null +++ b/packages/tsunami/src/state/StateStore.ts @@ -0,0 +1,85 @@ +export type StoreListener = () => void; + +export type StateStoreOptions = { + /** Handles a listener throwing during a flush; defaults to rethrowing asynchronously. */ + onListenerError?: (error: unknown) => void; +}; + +/** + * Observable state container. Snapshots are stable (`getState` returns the + * same object until a value changes) and structurally shared (an update + * replaces only the slices it received). Listeners are notified synchronously + * on every effective `update()` — write related changes as one `update()` call + * to get one notification; UI frameworks coalesce same-tick renders themselves. + */ +export class StateStore { + private snapshot: TState; + private readonly listeners = new Set(); + + public constructor( + initialState: TState, + private readonly options: StateStoreOptions = {}, + ) { + this.snapshot = initialState; + } + + public getState = (): TState => this.snapshot; + + public subscribe = (listener: StoreListener): (() => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + /** Notifies only when the selected value changes (`Object.is`). */ + public subscribeToSlice = ( + selector: (state: TState) => Slice, + listener: (slice: Slice, previousSlice: Slice) => void, + ): (() => void) => { + let lastSeenSlice = selector(this.snapshot); + return this.subscribe(() => { + const nextSlice = selector(this.snapshot); + if (Object.is(nextSlice, lastSeenSlice)) return; + const previousSlice = lastSeenSlice; + lastSeenSlice = nextSlice; + listener(nextSlice, previousSlice); + }); + }; + + /** Applied synchronously; a call that changes nothing keeps the snapshot and notifies nobody. */ + public update(partial: Partial): void { + const keys = Object.keys(partial) as (keyof TState)[]; + const hasChange = keys.some((key) => partial[key] !== this.snapshot[key]); + if (!hasChange) return; + + this.snapshot = { ...this.snapshot, ...partial }; + this.notify(); + } + + /** Drops all listeners. */ + public clear(): void { + this.listeners.clear(); + } + + private notify(): void { + for (const listener of [...this.listeners]) { + try { + listener(); + } catch (error) { + this.handleListenerError(error); + } + } + } + + private handleListenerError(error: unknown): void { + if (this.options.onListenerError) { + this.options.onListenerError(error); + return; + } + // Rethrow out-of-band: the error stays observable without starving other listeners. + queueMicrotask(() => { + throw error; + }); + } +} diff --git a/packages/tsunami/src/state/clientState.ts b/packages/tsunami/src/state/clientState.ts new file mode 100644 index 000000000..cbfa3d9fd --- /dev/null +++ b/packages/tsunami/src/state/clientState.ts @@ -0,0 +1,109 @@ +import type { Component, GenericMetadata, Peer, ReconnectionStatus } from "@fishjam-cloud/ts-client"; + +import type { DeviceItem, PlatformMediaStream, PlatformMediaStreamTrack } from "../devices/deviceManager"; +import type { DeviceError } from "../devices/errors"; +import type { TrackMiddleware, TracksMiddleware } from "../mediaTypes"; + +/** + * Represents the possible statuses of a peer connection. + * + * - `idle` - Peer is not connected, either never connected or successfully disconnected. + * - `connecting` - Peer is in the process of connecting. + * - `connected` - Peer has successfully connected. + * - `error` - There was an error in the connection process. + */ +export type PeerStatus = "connecting" | "connected" | "error" | "idle"; + +export interface LocalDeviceState { + /** Track ready to be rendered or published (post-middleware when one is set). */ + track: PlatformMediaStreamTrack | null; + /** Render-ready stream containing {@link track} (post-middleware when one is set). */ + stream: PlatformMediaStream | null; + /** Soft mute flag — `false` while the track is disabled but the device stays on. */ + isEnabled: boolean; + /** Device backing the current track. */ + activeDevice: DeviceItem | null; + /** Device that will be used on the next start. */ + selectedDevice: DeviceItem | null; + middleware: TrackMiddleware; +} + +export interface ScreenShareState { + stream: PlatformMediaStream | null; + videoTrack: PlatformMediaStreamTrack | null; + audioTrack: PlatformMediaStreamTrack | null; + middleware: TracksMiddleware | null; +} + +export interface CustomSourceState { + stream: PlatformMediaStream; + trackIds?: { videoId?: string; audioId?: string }; +} + +/** + * Flat, synchronously readable snapshot of the client's observable state. + * + * Snapshots use structural sharing: an update replaces only the slices it + * touched, so consumers can detect unchanged slices by reference equality. + */ +export interface DataChannelState { + status: "idle" | "creating" | "ready"; + error: Error | null; +} + +export interface ClientState { + // connection + peerStatus: PeerStatus; + reconnectionStatus: ReconnectionStatus; + + // participants + localPeer: Peer | null; + remotePeers: Record>; + components: Record; + + // local devices + camera: LocalDeviceState; + microphone: LocalDeviceState; + screenShare: ScreenShareState; + customSources: Record; + + // data channels + dataChannel: DataChannelState; + + // available hardware + availableCameras: DeviceItem[]; + availableMicrophones: DeviceItem[]; + cameraError: DeviceError | null; + microphoneError: DeviceError | null; + devicesInitialized: boolean; +} + +const createInitialDeviceState = (): LocalDeviceState => ({ + track: null, + stream: null, + isEnabled: true, + activeDevice: null, + selectedDevice: null, + middleware: null, +}); + +export const createInitialClientState = (): ClientState< + PeerMetadata, + ServerMetadata +> => ({ + peerStatus: "idle", + reconnectionStatus: "idle", + localPeer: null, + remotePeers: {}, + components: {}, + camera: createInitialDeviceState(), + microphone: createInitialDeviceState(), + screenShare: { stream: null, videoTrack: null, audioTrack: null, middleware: null }, + customSources: {}, + dataChannel: { status: "idle", error: null }, + availableCameras: [], + availableMicrophones: [], + cameraError: null, + microphoneError: null, + devicesInitialized: false, +}); diff --git a/packages/tsunami/src/state/peerViews.test.ts b/packages/tsunami/src/state/peerViews.test.ts new file mode 100644 index 000000000..e61c33a5d --- /dev/null +++ b/packages/tsunami/src/state/peerViews.test.ts @@ -0,0 +1,50 @@ +import type { FishjamTrackContext, Peer, TrackMetadata } from "@fishjam-cloud/ts-client"; +import { describe, expect, it, vi } from "vitest"; + +import { localPeerWithTracks, remotePeerWithTracks } from "./peerViews"; + +const buildTrackContext = (trackId: string, type: TrackMetadata["type"]): FishjamTrackContext => + ({ + trackId, + metadata: { type, paused: false }, + stream: null, + track: null, + simulcastConfig: null, + }) as unknown as FishjamTrackContext; + +const buildPeer = (trackContexts: FishjamTrackContext[]): Peer => ({ + id: "peer-1", + type: "webrtc", + tracks: new Map(trackContexts.map((context) => [context.trackId, context])), +}); + +describe("peer views", () => { + it("buckets tracks by their metadata type", () => { + const peer = buildPeer([ + buildTrackContext("c", "camera"), + buildTrackContext("m", "microphone"), + buildTrackContext("sv", "screenShareVideo"), + buildTrackContext("cv1", "customVideo"), + buildTrackContext("cv2", "customVideo"), + ]); + + const view = localPeerWithTracks(peer); + + expect(view.cameraTrack?.trackId).toBe("c"); + expect(view.microphoneTrack?.trackId).toBe("m"); + expect(view.screenShareVideoTrack?.trackId).toBe("sv"); + expect(view.screenShareAudioTrack).toBeUndefined(); + expect(view.customVideoTracks.map((track) => track.trackId)).toEqual(["cv1", "cv2"]); + expect(view.tracks).toHaveLength(5); + }); + + it("wires remote track quality changes to the quality setter", () => { + const peer = buildPeer([buildTrackContext("rv", "screenShareVideo")]); + const qualitySetter = { setTargetTrackEncoding: vi.fn() }; + + const view = remotePeerWithTracks(peer, qualitySetter); + view.screenShareVideoTrack?.setReceivedQuality("h" as never); + + expect(qualitySetter.setTargetTrackEncoding).toHaveBeenCalledWith("rv", "h"); + }); +}); diff --git a/packages/tsunami/src/state/peerViews.ts b/packages/tsunami/src/state/peerViews.ts new file mode 100644 index 000000000..02e94c679 --- /dev/null +++ b/packages/tsunami/src/state/peerViews.ts @@ -0,0 +1,89 @@ +import type { EncodingReason, Metadata, Peer, SimulcastConfig, TrackMetadata, Variant } from "@fishjam-cloud/ts-client"; + +import type { PlatformMediaStream, PlatformMediaStreamTrack } from "../devices/deviceManager"; + +export type PeerTrackView = { + metadata?: TrackMetadata; + trackId: string; + stream: PlatformMediaStream | null; + simulcastConfig: SimulcastConfig | null; + track: PlatformMediaStreamTrack | null; +}; + +export type RemotePeerTrackView = PeerTrackView & { + encoding?: Variant; + encodingReason?: EncodingReason; + setReceivedQuality: (quality: Variant) => void; +}; + +/** A peer with its tracks bucketed by their metadata type. */ +export type PeerWithTracksView = { + id: string; + metadata?: Metadata; + tracks: TrackView[]; + cameraTrack?: TrackView; + microphoneTrack?: TrackView; + screenShareVideoTrack?: TrackView; + screenShareAudioTrack?: TrackView; + customVideoTracks: TrackView[]; + customAudioTracks: TrackView[]; +}; + +/** The narrow surface remote track views adjust receive quality through. */ +export type RemoteTrackQualitySetter = { + setTargetTrackEncoding(trackId: string, encoding: Variant): void; +}; + +type PeerTrackContext = { + metadata?: unknown; + trackId: string; + stream: MediaStream | null; + simulcastConfig?: SimulcastConfig | null; + track: MediaStreamTrack | null; + encoding?: Variant; + encodingReason?: EncodingReason; +}; + +const trackContextToView = (context: PeerTrackContext): PeerTrackView => ({ + metadata: context.metadata as TrackMetadata, + trackId: context.trackId, + stream: context.stream, + simulcastConfig: context.simulcastConfig ?? null, + track: context.track, +}); + +const buildPeerView = ( + peer: Peer, + toTrackView: (context: PeerTrackContext) => TrackView, +): PeerWithTracksView => { + const tracks = [...peer.tracks.values()].map(toTrackView); + + return { + id: peer.id, + metadata: peer.metadata, + tracks, + cameraTrack: tracks.find(({ metadata }) => metadata?.type === "camera"), + microphoneTrack: tracks.find(({ metadata }) => metadata?.type === "microphone"), + screenShareVideoTrack: tracks.find(({ metadata }) => metadata?.type === "screenShareVideo"), + screenShareAudioTrack: tracks.find(({ metadata }) => metadata?.type === "screenShareAudio"), + customVideoTracks: tracks.filter(({ metadata }) => metadata?.type === "customVideo"), + customAudioTracks: tracks.filter(({ metadata }) => metadata?.type === "customAudio"), + }; +}; + +export const localPeerWithTracks = ( + peer: Peer, +): PeerWithTracksView => buildPeerView(peer, trackContextToView); + +export const remotePeerWithTracks = ( + peer: Peer, + qualitySetter: RemoteTrackQualitySetter, +): PeerWithTracksView => + buildPeerView(peer, (context) => ({ + ...trackContextToView(context), + encoding: context.encoding, + encodingReason: context.encodingReason, + setReceivedQuality: (quality: Variant) => { + qualitySetter.setTargetTrackEncoding(context.trackId, quality); + }, + })); diff --git a/packages/tsunami/src/tracks/trackUtils.ts b/packages/tsunami/src/tracks/trackUtils.ts new file mode 100644 index 000000000..ae8f4b93c --- /dev/null +++ b/packages/tsunami/src/tracks/trackUtils.ts @@ -0,0 +1,51 @@ +import type { SimulcastConfig, TrackMetadata } from "@fishjam-cloud/ts-client"; +import { Variant } from "@fishjam-cloud/ts-client"; + +import type { PlatformMediaStream, PlatformMediaStreamTrack } from "../devices/deviceManager"; +import type { BandwidthLimits } from "../mediaTypes"; + +const ALL_VARIANTS: Variant[] = [Variant.VARIANT_LOW, Variant.VARIANT_MEDIUM, Variant.VARIANT_HIGH]; + +const getDisabledEncodings = (enabledVariants: Variant[]): Variant[] => + ALL_VARIANTS.filter((variant) => !enabledVariants.includes(variant)); + +export const getConfigAndBandwidthFromProps = ( + encodings: Variant[] | false | undefined, + bandwidthLimits: BandwidthLimits, +): readonly [number | Map, SimulcastConfig | undefined] => { + if (!encodings) return [bandwidthLimits.singleStream, undefined] as const; + + const config: SimulcastConfig = { + enabled: true, + enabledVariants: encodings, + disabledVariants: getDisabledEncodings(encodings), + }; + + const variantEntries = Object.entries(bandwidthLimits.simulcast).map( + ([key, value]) => [Number(key), value] as [Variant, number], + ); + + const bandwidth = new Map(variantEntries); + return [bandwidth, config] as const; +}; + +function getCertainTypeTracks(stream: PlatformMediaStream, type: "audio" | "video") { + if (type === "audio") return stream.getAudioTracks(); + return stream.getVideoTracks(); +} + +export function getTrackFromStream( + stream: PlatformMediaStream, + type: "audio" | "video", +): PlatformMediaStreamTrack | null { + return getCertainTypeTracks(stream, type)[0] ?? null; +} + +export function stopStream(stream: PlatformMediaStream, type: "audio" | "video"): void { + getCertainTypeTracks(stream, type).forEach((track) => { + track.enabled = false; + track.stop(); + }); +} + +export type { TrackMetadata }; diff --git a/packages/tsunami/src/vad/VoiceActivityMonitor.ts b/packages/tsunami/src/vad/VoiceActivityMonitor.ts new file mode 100644 index 000000000..483365b4a --- /dev/null +++ b/packages/tsunami/src/vad/VoiceActivityMonitor.ts @@ -0,0 +1,179 @@ +import type { FishjamTrackContext, Peer } from "@fishjam-cloud/ts-client"; + +// Matches the backend's -32 dBov voice-activity threshold, converted to the +// linear [0, 1] scale of the WebRTC media-source audioLevel stat. +const SPEECH_THRESHOLD = 10 ** (-32 / 20); +const SILENCE_DEBOUNCE_TICKS = 2; +const LOCAL_POLL_INTERVAL_MS = 100; + +export type VoiceActivityMonitorDeps = { + getLocalPeer: () => Peer | null; + getRemotePeers: () => Record>; + getLocalTrackAudioLevel: (trackId: string) => Promise<{ level: number } | null>; + /** Change notifications that re-scan peers and their microphone tracks. */ + subscribeToPeerChanges: (listener: () => void) => () => void; +}; + +const findMicrophoneTrack = (peer: Peer): FishjamTrackContext | undefined => + [...peer.tracks.values()].find((trackContext) => trackContext.metadata?.type === "microphone"); + +/** + * Voice activity for every peer with a published microphone track, keyed by + * peer id. Kept OUTSIDE the client state store: local speech detection polls + * at 10 Hz and remote activity flips per utterance — routing that through + * state snapshots would notify every store subscriber. + * + * Remote activity mirrors the signalling `voiceActivityChanged` events; local + * activity is detected by polling the microphone's audio level (speech is + * reported instantly, silence after a short debounce). Polling runs only + * while at least one subscriber is registered. + */ +export class VoiceActivityMonitor { + private readonly listeners = new Set<() => void>(); + private snapshot: Record = {}; + + private peerChangesUnsubscribe: (() => void) | null = null; + private readonly remoteTrackSubscriptions = new Map void }>(); + + private localMicrophoneTrackId: string | null = null; + private localSpeaking = false; + private pollAbort: AbortController | null = null; + private pollTimeoutId: ReturnType | null = null; + + public constructor(private readonly deps: VoiceActivityMonitorDeps) {} + + public getSnapshot = (): Record => this.snapshot; + + public subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + if (this.listeners.size === 1) this.start(); + + return () => { + if (!this.listeners.delete(listener)) return; + if (this.listeners.size === 0) this.stop(); + }; + }; + + public dispose(): void { + this.stop(); + this.listeners.clear(); + this.snapshot = {}; + } + + private start(): void { + this.peerChangesUnsubscribe = this.deps.subscribeToPeerChanges(() => this.synchronize()); + this.synchronize(); + } + + private stop(): void { + this.peerChangesUnsubscribe?.(); + this.peerChangesUnsubscribe = null; + for (const { detach } of this.remoteTrackSubscriptions.values()) detach(); + this.remoteTrackSubscriptions.clear(); + this.stopLocalPolling(); + } + + /** Re-scans peers, reconciling remote track listeners and the local poll. */ + private synchronize(): void { + const remotePeers = this.deps.getRemotePeers(); + + for (const [peerId, subscription] of this.remoteTrackSubscriptions) { + const currentContext = remotePeers[peerId] && findMicrophoneTrack(remotePeers[peerId]); + if (currentContext !== subscription.context) { + subscription.detach(); + this.remoteTrackSubscriptions.delete(peerId); + } + } + + for (const [peerId, peer] of Object.entries(remotePeers)) { + if (this.remoteTrackSubscriptions.has(peerId)) continue; + const microphoneContext = findMicrophoneTrack(peer); + if (!microphoneContext) continue; + + const onVoiceActivityChanged = () => this.recompute(); + microphoneContext.on("voiceActivityChanged", onVoiceActivityChanged); + this.remoteTrackSubscriptions.set(peerId, { + context: microphoneContext, + detach: () => microphoneContext.off("voiceActivityChanged", onVoiceActivityChanged), + }); + } + + const localMicrophoneTrackId = this.resolveLocalMicrophoneTrackId(); + if (localMicrophoneTrackId !== this.localMicrophoneTrackId) { + this.localMicrophoneTrackId = localMicrophoneTrackId; + this.stopLocalPolling(); + if (localMicrophoneTrackId) this.startLocalPolling(localMicrophoneTrackId); + } + + this.recompute(); + } + + private resolveLocalMicrophoneTrackId(): string | null { + const localPeer = this.deps.getLocalPeer(); + if (!localPeer) return null; + return findMicrophoneTrack(localPeer)?.trackId ?? null; + } + + private startLocalPolling(microphoneTrackId: string): void { + const abort = new AbortController(); + this.pollAbort = abort; + let silenceTicks = 0; + + const poll = async () => { + if (abort.signal.aborted) return; + + const trackAudio = await this.deps.getLocalTrackAudioLevel(microphoneTrackId); + if (abort.signal.aborted) return; + + if (trackAudio != null && trackAudio.level > SPEECH_THRESHOLD) { + silenceTicks = 0; + this.setLocalSpeaking(true); + } else { + silenceTicks += 1; + if (silenceTicks >= SILENCE_DEBOUNCE_TICKS) this.setLocalSpeaking(false); + } + + if (abort.signal.aborted) return; + this.pollTimeoutId = setTimeout(() => void poll(), LOCAL_POLL_INTERVAL_MS); + }; + + this.pollTimeoutId = setTimeout(() => void poll(), 0); + } + + private stopLocalPolling(): void { + this.pollAbort?.abort(); + this.pollAbort = null; + if (this.pollTimeoutId !== null) clearTimeout(this.pollTimeoutId); + this.pollTimeoutId = null; + this.localSpeaking = false; + } + + private setLocalSpeaking(isSpeaking: boolean): void { + if (this.localSpeaking === isSpeaking) return; + this.localSpeaking = isSpeaking; + this.recompute(); + } + + private recompute(): void { + const next: Record = {}; + + for (const [peerId, peer] of Object.entries(this.deps.getRemotePeers())) { + next[peerId] = findMicrophoneTrack(peer)?.vadStatus === "speech"; + } + + const localPeer = this.deps.getLocalPeer(); + if (localPeer && this.localMicrophoneTrackId) { + next[localPeer.id] = this.localSpeaking; + } + + const previous = this.snapshot; + const previousKeys = Object.keys(previous); + const nextKeys = Object.keys(next); + const isUnchanged = + previousKeys.length === nextKeys.length && nextKeys.every((key) => previous[key] === next[key]); + if (isUnchanged) return; + + this.snapshot = next; + for (const listener of [...this.listeners]) listener(); + } +} diff --git a/packages/tsunami/tsconfig.build.json b/packages/tsunami/tsconfig.build.json index b7c927b54..28ab73ce9 100644 --- a/packages/tsunami/tsconfig.build.json +++ b/packages/tsunami/tsconfig.build.json @@ -1,4 +1,12 @@ { "extends": "./tsconfig.json", - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.spec.ts", "src/**/*.spec.tsx"] + "exclude": [ + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/*.spec.ts", + "src/**/*.spec.tsx" + ], + "compilerOptions": { + "noEmit": false + } } diff --git a/packages/tsunami/tsconfig.json b/packages/tsunami/tsconfig.json index 25cc80539..cb3a9d02e 100644 --- a/packages/tsunami/tsconfig.json +++ b/packages/tsunami/tsconfig.json @@ -2,7 +2,10 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "moduleResolution": "bundler", - "outDir": "dist/" + "outDir": "dist/", + "noEmit": true }, - "include": ["src/**/*"] + "include": [ + "src/**/*" + ] } diff --git a/release-automation/bump-version.sh b/release-automation/bump-version.sh index 66e51def9..5879c71c1 100755 --- a/release-automation/bump-version.sh +++ b/release-automation/bump-version.sh @@ -53,6 +53,9 @@ echo "Updated webrtc-client to $VERSION" corepack yarn workspace @fishjam-cloud/ts-client version "$VERSION" echo "Updated ts-client to $VERSION" +corepack yarn workspace @fishjam-cloud/tsunami version "$VERSION" +echo "Updated tsunami to $VERSION" + corepack yarn workspace @fishjam-cloud/react-client version "$VERSION" echo "Updated react-client to $VERSION" diff --git a/yarn.lock b/yarn.lock index 809e82fb0..1763959c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17,7 +17,161 @@ __metadata: languageName: node linkType: hard -"@ampproject/remapping@npm:^2.2.0, @ampproject/remapping@npm:^2.3.0": +"@algolia/abtesting@npm:1.1.0": + version: 1.1.0 + resolution: "@algolia/abtesting@npm:1.1.0" + dependencies: + "@algolia/client-common": "npm:5.35.0" + "@algolia/requester-browser-xhr": "npm:5.35.0" + "@algolia/requester-fetch": "npm:5.35.0" + "@algolia/requester-node-http": "npm:5.35.0" + checksum: 10c0/0531541f10ab45deffd74188c481f61faf959c2b8293249798d9d47fe76ab53627f8a135c49770bbdde1234057d1a9385a5df5b74b20295ba494002877fed073 + languageName: node + linkType: hard + +"@algolia/client-abtesting@npm:5.35.0": + version: 5.35.0 + resolution: "@algolia/client-abtesting@npm:5.35.0" + dependencies: + "@algolia/client-common": "npm:5.35.0" + "@algolia/requester-browser-xhr": "npm:5.35.0" + "@algolia/requester-fetch": "npm:5.35.0" + "@algolia/requester-node-http": "npm:5.35.0" + checksum: 10c0/3f06a1c2433602355c4463a14a961ae3bef9e7baeeb72d8deea9d19059c08d4c639e24c0450de508a5a82d3904835a178925058a16675e671512f03e2d0d7625 + languageName: node + linkType: hard + +"@algolia/client-analytics@npm:5.35.0": + version: 5.35.0 + resolution: "@algolia/client-analytics@npm:5.35.0" + dependencies: + "@algolia/client-common": "npm:5.35.0" + "@algolia/requester-browser-xhr": "npm:5.35.0" + "@algolia/requester-fetch": "npm:5.35.0" + "@algolia/requester-node-http": "npm:5.35.0" + checksum: 10c0/1e771cdeb5044ff8346e944f4da317a77e939047916dad03790a820540e194f28ccdcb1c790b998388696b3d1ac9e48aa2deb1532f096831891c5c0d5010eb89 + languageName: node + linkType: hard + +"@algolia/client-common@npm:5.35.0": + version: 5.35.0 + resolution: "@algolia/client-common@npm:5.35.0" + checksum: 10c0/c2d614260e5961e96f7de428b748af8ea3041c8530064b1926f35063c4c88e3ee0769537c35f130ff94eb701927cc72f3ccfdd8a5a8513a87ed528fec7fdb79c + languageName: node + linkType: hard + +"@algolia/client-insights@npm:5.35.0": + version: 5.35.0 + resolution: "@algolia/client-insights@npm:5.35.0" + dependencies: + "@algolia/client-common": "npm:5.35.0" + "@algolia/requester-browser-xhr": "npm:5.35.0" + "@algolia/requester-fetch": "npm:5.35.0" + "@algolia/requester-node-http": "npm:5.35.0" + checksum: 10c0/c3b146a433500c73148bba58ab2abef0bca9bce08cf15446e8b6143fdf884dafeba6fe86b9ab77337cce91c70f7ef4cc4ffee87736b96a30bad92a53897bfaa7 + languageName: node + linkType: hard + +"@algolia/client-personalization@npm:5.35.0": + version: 5.35.0 + resolution: "@algolia/client-personalization@npm:5.35.0" + dependencies: + "@algolia/client-common": "npm:5.35.0" + "@algolia/requester-browser-xhr": "npm:5.35.0" + "@algolia/requester-fetch": "npm:5.35.0" + "@algolia/requester-node-http": "npm:5.35.0" + checksum: 10c0/0cecdca5bec6bf059c4569c66b6ecec3d556825b6767d54254b46333a6e8a2aeacff49a08aefc9490d3f5cc4fefb54200f3945117964a23f7a7bbfd2c7993fac + languageName: node + linkType: hard + +"@algolia/client-query-suggestions@npm:5.35.0": + version: 5.35.0 + resolution: "@algolia/client-query-suggestions@npm:5.35.0" + dependencies: + "@algolia/client-common": "npm:5.35.0" + "@algolia/requester-browser-xhr": "npm:5.35.0" + "@algolia/requester-fetch": "npm:5.35.0" + "@algolia/requester-node-http": "npm:5.35.0" + checksum: 10c0/dd0f2a1e41f9203cfbdaa301346c6a363d1da3016b625439453435363bb0f7c14fed49480ae0a2e013131a6632a6d8c1749b0223336f1642a1f26b214ce3c0f7 + languageName: node + linkType: hard + +"@algolia/client-search@npm:5.35.0": + version: 5.35.0 + resolution: "@algolia/client-search@npm:5.35.0" + dependencies: + "@algolia/client-common": "npm:5.35.0" + "@algolia/requester-browser-xhr": "npm:5.35.0" + "@algolia/requester-fetch": "npm:5.35.0" + "@algolia/requester-node-http": "npm:5.35.0" + checksum: 10c0/a9e52b4f21822f723fad21647056439737d956eb9bd70f541fcbdf96ad5ef60ac0782ed1801af9d3671444bfef95dd74133f3d8846a600bcb387dc8f1b910da6 + languageName: node + linkType: hard + +"@algolia/ingestion@npm:1.35.0": + version: 1.35.0 + resolution: "@algolia/ingestion@npm:1.35.0" + dependencies: + "@algolia/client-common": "npm:5.35.0" + "@algolia/requester-browser-xhr": "npm:5.35.0" + "@algolia/requester-fetch": "npm:5.35.0" + "@algolia/requester-node-http": "npm:5.35.0" + checksum: 10c0/342b9adea87d01a5f29168c16365e5ae0cade177b0749042cda29a64eaaacb2086957bbf70c40ad49a361436d744ca585937f27ebfd1edf12c62ec091c5111bb + languageName: node + linkType: hard + +"@algolia/monitoring@npm:1.35.0": + version: 1.35.0 + resolution: "@algolia/monitoring@npm:1.35.0" + dependencies: + "@algolia/client-common": "npm:5.35.0" + "@algolia/requester-browser-xhr": "npm:5.35.0" + "@algolia/requester-fetch": "npm:5.35.0" + "@algolia/requester-node-http": "npm:5.35.0" + checksum: 10c0/2bbad1df73421c7bb77efcaab82c83a2bec919e8d843b638640a6c6294fb8b35ad617ec79cc422636125f8365268bd8071e0dbd15455130fec15c9d920de02db + languageName: node + linkType: hard + +"@algolia/recommend@npm:5.35.0": + version: 5.35.0 + resolution: "@algolia/recommend@npm:5.35.0" + dependencies: + "@algolia/client-common": "npm:5.35.0" + "@algolia/requester-browser-xhr": "npm:5.35.0" + "@algolia/requester-fetch": "npm:5.35.0" + "@algolia/requester-node-http": "npm:5.35.0" + checksum: 10c0/fa81ef425b9f2c63cfda584372d073b7ced020d0b13e3ced2ea0d992562bb642f7b2bab91844dc91bc4dacc260de437a595242704690fff74f1eceb211356293 + languageName: node + linkType: hard + +"@algolia/requester-browser-xhr@npm:5.35.0": + version: 5.35.0 + resolution: "@algolia/requester-browser-xhr@npm:5.35.0" + dependencies: + "@algolia/client-common": "npm:5.35.0" + checksum: 10c0/59061fe14c641714136bedec90d54f5146ccc11c576687bbe800b42c1aff26e7106d76d367b3c58edc2e6a14efd22d1f3c3e2417c6734e35c27042c937476830 + languageName: node + linkType: hard + +"@algolia/requester-fetch@npm:5.35.0": + version: 5.35.0 + resolution: "@algolia/requester-fetch@npm:5.35.0" + dependencies: + "@algolia/client-common": "npm:5.35.0" + checksum: 10c0/45c654f1b5746503b268cf5b1959c81943aea2d0fd78e7a028bfa134bbc2b7e2264ab25fa7429eef1ffa4e347030314577d3b5e92e405326d06ae44813786d4d + languageName: node + linkType: hard + +"@algolia/requester-node-http@npm:5.35.0": + version: 5.35.0 + resolution: "@algolia/requester-node-http@npm:5.35.0" + dependencies: + "@algolia/client-common": "npm:5.35.0" + checksum: 10c0/c33fbeb38ea48660f61c028074bb37c226d50b1cb853938dbc9bcf8e1144930438764d9ea009d1c98da511c1bd2663f363ee27a21f8aea83510525a0ae5a8a6b + languageName: node + linkType: hard + +"@ampproject/remapping@npm:2.3.0, @ampproject/remapping@npm:^2.2.0, @ampproject/remapping@npm:^2.3.0": version: 2.3.0 resolution: "@ampproject/remapping@npm:2.3.0" dependencies: @@ -27,6 +181,236 @@ __metadata: languageName: node linkType: hard +"@angular-devkit/architect@npm:0.2003.32": + version: 0.2003.32 + resolution: "@angular-devkit/architect@npm:0.2003.32" + dependencies: + "@angular-devkit/core": "npm:20.3.32" + rxjs: "npm:7.8.2" + checksum: 10c0/3834b5af72eaa1733d992d44e29c1e122e1752546d7ccf32e6248f04bd18ec2468e59d0f208bf1327f7b3cab07de7cec3c2a025b05b4e65aa2d25116f508d52a + languageName: node + linkType: hard + +"@angular-devkit/core@npm:20.3.32": + version: 20.3.32 + resolution: "@angular-devkit/core@npm:20.3.32" + dependencies: + ajv: "npm:8.18.0" + ajv-formats: "npm:3.0.1" + jsonc-parser: "npm:3.3.1" + picomatch: "npm:4.0.4" + rxjs: "npm:7.8.2" + source-map: "npm:0.7.6" + peerDependencies: + chokidar: ^4.0.0 + peerDependenciesMeta: + chokidar: + optional: true + checksum: 10c0/e3b5d7bb545fed4b0e9dc5df10b406720ded505d3cbc3a7addb9bfc2240297c03cbe8d28aaf48b689814f695f76d83f943e7d804ddfdcd4cc86a4f89a8117a03 + languageName: node + linkType: hard + +"@angular-devkit/schematics@npm:20.3.32": + version: 20.3.32 + resolution: "@angular-devkit/schematics@npm:20.3.32" + dependencies: + "@angular-devkit/core": "npm:20.3.32" + jsonc-parser: "npm:3.3.1" + magic-string: "npm:0.30.17" + ora: "npm:8.2.0" + rxjs: "npm:7.8.2" + checksum: 10c0/7e75f889018bfc8dd63f9457cab9d154815fa7b4b2f38ef071559be47b2f5ccc57fd8d653540dc27b4179e1016eb6ea9a3d578d186ff338c0372591d87e107ea + languageName: node + linkType: hard + +"@angular/build@npm:^20.3.0": + version: 20.3.32 + resolution: "@angular/build@npm:20.3.32" + dependencies: + "@ampproject/remapping": "npm:2.3.0" + "@angular-devkit/architect": "npm:0.2003.32" + "@babel/core": "npm:7.29.7" + "@babel/helper-annotate-as-pure": "npm:7.27.3" + "@babel/helper-split-export-declaration": "npm:7.24.7" + "@inquirer/confirm": "npm:5.1.14" + "@vitejs/plugin-basic-ssl": "npm:2.1.0" + beasties: "npm:0.3.5" + browserslist: "npm:^4.23.0" + esbuild: "npm:0.28.1" + https-proxy-agent: "npm:7.0.6" + istanbul-lib-instrument: "npm:6.0.3" + jsonc-parser: "npm:3.3.1" + listr2: "npm:9.0.1" + lmdb: "npm:3.4.2" + magic-string: "npm:0.30.17" + mrmime: "npm:2.0.1" + parse5-html-rewriting-stream: "npm:8.0.0" + picomatch: "npm:4.0.4" + piscina: "npm:5.2.0" + rollup: "npm:4.59.0" + sass: "npm:1.90.0" + semver: "npm:7.7.2" + source-map-support: "npm:0.5.21" + tinyglobby: "npm:0.2.14" + vite: "npm:7.3.6" + watchpack: "npm:2.4.4" + peerDependencies: + "@angular/compiler": ^20.0.0 + "@angular/compiler-cli": ^20.0.0 + "@angular/core": ^20.0.0 + "@angular/localize": ^20.0.0 + "@angular/platform-browser": ^20.0.0 + "@angular/platform-server": ^20.0.0 + "@angular/service-worker": ^20.0.0 + "@angular/ssr": ^20.3.32 + karma: ^6.4.0 + less: ^4.2.0 + ng-packagr: ^20.0.0 + postcss: ^8.4.0 + tailwindcss: ^2.0.0 || ^3.0.0 || ^4.0.0 + tslib: ^2.3.0 + typescript: ">=5.8 <6.0" + vitest: ^3.1.1 + dependenciesMeta: + lmdb: + optional: true + peerDependenciesMeta: + "@angular/core": + optional: true + "@angular/localize": + optional: true + "@angular/platform-browser": + optional: true + "@angular/platform-server": + optional: true + "@angular/service-worker": + optional: true + "@angular/ssr": + optional: true + karma: + optional: true + less: + optional: true + ng-packagr: + optional: true + postcss: + optional: true + tailwindcss: + optional: true + vitest: + optional: true + checksum: 10c0/ceec02f25962633faca290c29cccd42385073161774dae7bf541b1f5d0904bb0901357db3408c93d048b171453670b056710d9ff36d3753db1d512c862a9ffaf + languageName: node + linkType: hard + +"@angular/cli@npm:^20.3.0": + version: 20.3.32 + resolution: "@angular/cli@npm:20.3.32" + dependencies: + "@angular-devkit/architect": "npm:0.2003.32" + "@angular-devkit/core": "npm:20.3.32" + "@angular-devkit/schematics": "npm:20.3.32" + "@inquirer/prompts": "npm:7.8.2" + "@listr2/prompt-adapter-inquirer": "npm:3.0.1" + "@modelcontextprotocol/sdk": "npm:1.26.0" + "@schematics/angular": "npm:20.3.32" + "@yarnpkg/lockfile": "npm:1.1.0" + algoliasearch: "npm:5.35.0" + ini: "npm:5.0.0" + jsonc-parser: "npm:3.3.1" + listr2: "npm:9.0.1" + npm-package-arg: "npm:13.0.0" + pacote: "npm:21.5.1" + resolve: "npm:1.22.10" + semver: "npm:7.7.2" + yargs: "npm:18.0.0" + zod: "npm:4.1.13" + bin: + ng: bin/ng.js + checksum: 10c0/3d182967e541f0ed3b8f6456896ccd8db474337882a45a48e7dce57a63294e827c2685d9efd7fcb98ba7df50348f4d721d2620e9a4e1032517d86a444391b1bf + languageName: node + linkType: hard + +"@angular/common@npm:^20.3.0": + version: 20.3.27 + resolution: "@angular/common@npm:20.3.27" + dependencies: + tslib: "npm:^2.3.0" + peerDependencies: + "@angular/core": 20.3.27 + rxjs: ^6.5.3 || ^7.4.0 + checksum: 10c0/146be655cfc16a52ade57047251143ce9babaeb9f0342a06f945bc8d1c0af75aeb4bff20fe6997ef3933a0faecc2cda4422c71e148e9d5c14f8777c55528273a + languageName: node + linkType: hard + +"@angular/compiler-cli@npm:^20.3.0": + version: 20.3.27 + resolution: "@angular/compiler-cli@npm:20.3.27" + dependencies: + "@babel/core": "npm:7.29.7" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + chokidar: "npm:^4.0.0" + convert-source-map: "npm:^1.5.1" + reflect-metadata: "npm:^0.2.0" + semver: "npm:^7.0.0" + tslib: "npm:^2.3.0" + yargs: "npm:^18.0.0" + peerDependencies: + "@angular/compiler": 20.3.27 + typescript: ">=5.8 <6.0" + peerDependenciesMeta: + typescript: + optional: true + bin: + ng-xi18n: bundles/src/bin/ng_xi18n.js + ngc: bundles/src/bin/ngc.js + checksum: 10c0/d3f6cf009241b1cb32f33d2a6a62fb099a7464b430eadf3370e4f42f1d3142a7c9bffe4c41984db0d1bc46a546e1f886d4964a23f3d5e6c0d0c7cea043c02d31 + languageName: node + linkType: hard + +"@angular/compiler@npm:^20.3.0": + version: 20.3.27 + resolution: "@angular/compiler@npm:20.3.27" + dependencies: + tslib: "npm:^2.3.0" + checksum: 10c0/de118a4a637d46f2e9622923b20ba6a677746d6a1b03a5b3386713716610a6978b0a78bb1ffaa8cac4dc80e401a596ad542ab91a93f017c63044b7a2c25073ed + languageName: node + linkType: hard + +"@angular/core@npm:^20.3.0": + version: 20.3.27 + resolution: "@angular/core@npm:20.3.27" + dependencies: + tslib: "npm:^2.3.0" + peerDependencies: + "@angular/compiler": 20.3.27 + rxjs: ^6.5.3 || ^7.4.0 + zone.js: ~0.15.0 + peerDependenciesMeta: + "@angular/compiler": + optional: true + zone.js: + optional: true + checksum: 10c0/c86d7484b350f95c43850b7fa1764c697d6d84cc2e07548865457d950e88aee844e572dea7128107ed85eb4d3c5a33d1f29e0117625b5009b24e1874c2d72432 + languageName: node + linkType: hard + +"@angular/platform-browser@npm:^20.3.0": + version: 20.3.27 + resolution: "@angular/platform-browser@npm:20.3.27" + dependencies: + tslib: "npm:^2.3.0" + peerDependencies: + "@angular/animations": 20.3.27 + "@angular/common": 20.3.27 + "@angular/core": 20.3.27 + peerDependenciesMeta: + "@angular/animations": + optional: true + checksum: 10c0/4d5d3490ee540886e2aa355c8525346d315490f706a0dd4975b8315eb1e5300150c4bb66f4cd1873fa0ab3373e83745903d5fa1ea9454e8e74799c2ef1ea0663 + languageName: node + linkType: hard + "@asamuzakjp/css-color@npm:^3.1.2": version: 3.1.4 resolution: "@asamuzakjp/css-color@npm:3.1.4" @@ -148,6 +532,29 @@ __metadata: languageName: node linkType: hard +"@babel/core@npm:7.29.7, @babel/core@npm:^7.18.5, @babel/core@npm:^7.20.0, @babel/core@npm:^7.23.9, @babel/core@npm:^7.29.0": + version: 7.29.7 + resolution: "@babel/core@npm:7.29.7" + dependencies: + "@babel/code-frame": "npm:^7.29.7" + "@babel/generator": "npm:^7.29.7" + "@babel/helper-compilation-targets": "npm:^7.29.7" + "@babel/helper-module-transforms": "npm:^7.29.7" + "@babel/helpers": "npm:^7.29.7" + "@babel/parser": "npm:^7.29.7" + "@babel/template": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + "@jridgewell/remapping": "npm:^2.3.5" + convert-source-map: "npm:^2.0.0" + debug: "npm:^4.1.0" + gensync: "npm:^1.0.0-beta.2" + json5: "npm:^2.2.3" + semver: "npm:^6.3.1" + checksum: 10c0/112fb09c24de7a1de64d1de2c31fe65c4e6af4cb2fb6e6d99ea5373e6fc51e75b88581c0efae4c4c68f119a02a988c7106e95011a41530a2fb8ed793c7eaa07b + languageName: node + linkType: hard + "@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.25.2": version: 7.28.5 resolution: "@babel/core@npm:7.28.5" @@ -194,29 +601,6 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.18.5, @babel/core@npm:^7.20.0, @babel/core@npm:^7.29.0": - version: 7.29.7 - resolution: "@babel/core@npm:7.29.7" - dependencies: - "@babel/code-frame": "npm:^7.29.7" - "@babel/generator": "npm:^7.29.7" - "@babel/helper-compilation-targets": "npm:^7.29.7" - "@babel/helper-module-transforms": "npm:^7.29.7" - "@babel/helpers": "npm:^7.29.7" - "@babel/parser": "npm:^7.29.7" - "@babel/template": "npm:^7.29.7" - "@babel/traverse": "npm:^7.29.7" - "@babel/types": "npm:^7.29.7" - "@jridgewell/remapping": "npm:^2.3.5" - convert-source-map: "npm:^2.0.0" - debug: "npm:^4.1.0" - gensync: "npm:^1.0.0-beta.2" - json5: "npm:^2.2.3" - semver: "npm:^6.3.1" - checksum: 10c0/112fb09c24de7a1de64d1de2c31fe65c4e6af4cb2fb6e6d99ea5373e6fc51e75b88581c0efae4c4c68f119a02a988c7106e95011a41530a2fb8ed793c7eaa07b - languageName: node - linkType: hard - "@babel/core@npm:^7.26.10": version: 7.26.10 resolution: "@babel/core@npm:7.26.10" @@ -292,7 +676,7 @@ __metadata: languageName: node linkType: hard -"@babel/helper-annotate-as-pure@npm:^7.27.1, @babel/helper-annotate-as-pure@npm:^7.27.3": +"@babel/helper-annotate-as-pure@npm:7.27.3, @babel/helper-annotate-as-pure@npm:^7.27.1, @babel/helper-annotate-as-pure@npm:^7.27.3": version: 7.27.3 resolution: "@babel/helper-annotate-as-pure@npm:7.27.3" dependencies: @@ -726,6 +1110,15 @@ __metadata: languageName: node linkType: hard +"@babel/helper-split-export-declaration@npm:7.24.7": + version: 7.24.7 + resolution: "@babel/helper-split-export-declaration@npm:7.24.7" + dependencies: + "@babel/types": "npm:^7.24.7" + checksum: 10c0/0254577d7086bf09b01bbde98f731d4fcf4b7c3fa9634fdb87929801307c1f6202a1352e3faa5492450fa8da4420542d44de604daf540704ff349594a78184f6 + languageName: node + linkType: hard + "@babel/helper-string-parser@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-string-parser@npm:7.25.9" @@ -885,6 +1278,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.23.9": + version: 7.29.8 + resolution: "@babel/parser@npm:7.29.8" + dependencies: + "@babel/types": "npm:^7.29.8" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/acc890c5e6a6dd40863a47b50bac111d7185ee6fbbe163ebe11d5214854ca2adb901462ad4d718a65090ef84bd2230e9e8ab45a2e0caccc685f1f57ab0bb1e28 + languageName: node + linkType: hard + "@babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0": version: 7.29.3 resolution: "@babel/parser@npm:7.29.3" @@ -3671,6 +4075,16 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.24.7, @babel/types@npm:^7.29.8": + version: 7.29.8 + resolution: "@babel/types@npm:7.29.8" + dependencies: + "@babel/helper-string-parser": "npm:^7.29.7" + "@babel/helper-validator-identifier": "npm:^7.29.7" + checksum: 10c0/be7c279f0abf2a086c633e21b49c7ca80275d05283cc5a268b67a708c9914bd0c944f1422b3eb3cb37682a2af5d560abf520ccf9b01b53ecbfe6b71fbc3fdde6 + languageName: node + linkType: hard + "@babel/types@npm:^7.25.2, @babel/types@npm:^7.26.0, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.28.4, @babel/types@npm:^7.28.5, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4": version: 7.28.5 resolution: "@babel/types@npm:7.28.5" @@ -4406,6 +4820,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/aix-ppc64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/aix-ppc64@npm:0.28.1" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/android-arm64@npm:0.25.3": version: 0.25.3 resolution: "@esbuild/android-arm64@npm:0.25.3" @@ -4413,6 +4834,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/android-arm64@npm:0.28.1" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/android-arm@npm:0.25.3": version: 0.25.3 resolution: "@esbuild/android-arm@npm:0.25.3" @@ -4420,6 +4848,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-arm@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/android-arm@npm:0.28.1" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@esbuild/android-x64@npm:0.25.3": version: 0.25.3 resolution: "@esbuild/android-x64@npm:0.25.3" @@ -4427,6 +4862,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/android-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/android-x64@npm:0.28.1" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + "@esbuild/darwin-arm64@npm:0.25.3": version: 0.25.3 resolution: "@esbuild/darwin-arm64@npm:0.25.3" @@ -4434,6 +4876,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/darwin-arm64@npm:0.28.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/darwin-x64@npm:0.25.3": version: 0.25.3 resolution: "@esbuild/darwin-x64@npm:0.25.3" @@ -4441,6 +4890,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/darwin-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/darwin-x64@npm:0.28.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@esbuild/freebsd-arm64@npm:0.25.3": version: 0.25.3 resolution: "@esbuild/freebsd-arm64@npm:0.25.3" @@ -4448,6 +4904,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/freebsd-arm64@npm:0.28.1" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/freebsd-x64@npm:0.25.3": version: 0.25.3 resolution: "@esbuild/freebsd-x64@npm:0.25.3" @@ -4455,6 +4918,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/freebsd-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/freebsd-x64@npm:0.28.1" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/linux-arm64@npm:0.25.3": version: 0.25.3 resolution: "@esbuild/linux-arm64@npm:0.25.3" @@ -4462,6 +4932,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-arm64@npm:0.28.1" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/linux-arm@npm:0.25.3": version: 0.25.3 resolution: "@esbuild/linux-arm@npm:0.25.3" @@ -4469,6 +4946,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-arm@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-arm@npm:0.28.1" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@esbuild/linux-ia32@npm:0.25.3": version: 0.25.3 resolution: "@esbuild/linux-ia32@npm:0.25.3" @@ -4476,6 +4960,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ia32@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-ia32@npm:0.28.1" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/linux-loong64@npm:0.25.3": version: 0.25.3 resolution: "@esbuild/linux-loong64@npm:0.25.3" @@ -4483,6 +4974,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-loong64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-loong64@npm:0.28.1" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + "@esbuild/linux-mips64el@npm:0.25.3": version: 0.25.3 resolution: "@esbuild/linux-mips64el@npm:0.25.3" @@ -4490,6 +4988,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-mips64el@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-mips64el@npm:0.28.1" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + "@esbuild/linux-ppc64@npm:0.25.3": version: 0.25.3 resolution: "@esbuild/linux-ppc64@npm:0.25.3" @@ -4497,6 +5002,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-ppc64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-ppc64@npm:0.28.1" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + "@esbuild/linux-riscv64@npm:0.25.3": version: 0.25.3 resolution: "@esbuild/linux-riscv64@npm:0.25.3" @@ -4504,6 +5016,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-riscv64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-riscv64@npm:0.28.1" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + "@esbuild/linux-s390x@npm:0.25.3": version: 0.25.3 resolution: "@esbuild/linux-s390x@npm:0.25.3" @@ -4511,6 +5030,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-s390x@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-s390x@npm:0.28.1" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + "@esbuild/linux-x64@npm:0.25.3": version: 0.25.3 resolution: "@esbuild/linux-x64@npm:0.25.3" @@ -4518,6 +5044,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/linux-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/linux-x64@npm:0.28.1" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + "@esbuild/netbsd-arm64@npm:0.25.3": version: 0.25.3 resolution: "@esbuild/netbsd-arm64@npm:0.25.3" @@ -4525,9 +5058,23 @@ __metadata: languageName: node linkType: hard -"@esbuild/netbsd-x64@npm:0.25.3": - version: 0.25.3 - resolution: "@esbuild/netbsd-x64@npm:0.25.3" +"@esbuild/netbsd-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/netbsd-arm64@npm:0.28.1" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/netbsd-x64@npm:0.25.3": + version: 0.25.3 + resolution: "@esbuild/netbsd-x64@npm:0.25.3" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/netbsd-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/netbsd-x64@npm:0.28.1" conditions: os=netbsd & cpu=x64 languageName: node linkType: hard @@ -4539,6 +5086,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/openbsd-arm64@npm:0.28.1" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/openbsd-x64@npm:0.25.3": version: 0.25.3 resolution: "@esbuild/openbsd-x64@npm:0.25.3" @@ -4546,6 +5100,20 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/openbsd-x64@npm:0.28.1" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openharmony-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/openharmony-arm64@npm:0.28.1" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/sunos-x64@npm:0.25.3": version: 0.25.3 resolution: "@esbuild/sunos-x64@npm:0.25.3" @@ -4553,6 +5121,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/sunos-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/sunos-x64@npm:0.28.1" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + "@esbuild/win32-arm64@npm:0.25.3": version: 0.25.3 resolution: "@esbuild/win32-arm64@npm:0.25.3" @@ -4560,6 +5135,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-arm64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/win32-arm64@npm:0.28.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/win32-ia32@npm:0.25.3": version: 0.25.3 resolution: "@esbuild/win32-ia32@npm:0.25.3" @@ -4567,6 +5149,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-ia32@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/win32-ia32@npm:0.28.1" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@esbuild/win32-x64@npm:0.25.3": version: 0.25.3 resolution: "@esbuild/win32-x64@npm:0.25.3" @@ -4574,6 +5163,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/win32-x64@npm:0.28.1": + version: 0.28.1 + resolution: "@esbuild/win32-x64@npm:0.28.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.1.2, @eslint-community/eslint-utils@npm:^4.5.0, @eslint-community/eslint-utils@npm:^4.7.0, @eslint-community/eslint-utils@npm:^4.8.0": version: 4.9.0 resolution: "@eslint-community/eslint-utils@npm:4.9.0" @@ -5301,6 +5897,7 @@ __metadata: resolution: "@fishjam-cloud/react-client@workspace:packages/react-client" dependencies: "@fishjam-cloud/ts-client": "workspace:*" + "@fishjam-cloud/tsunami": "workspace:*" "@playwright/test": "npm:^1.51.1" "@testing-library/dom": "npm:^10.4.0" "@testing-library/react": "npm:^16.3.0" @@ -5591,6 +6188,46 @@ __metadata: languageName: unknown linkType: soft +"@fishjam-example/tsunami-angular-example@workspace:examples/tsunami/angular": + version: 0.0.0-use.local + resolution: "@fishjam-example/tsunami-angular-example@workspace:examples/tsunami/angular" + dependencies: + "@angular/build": "npm:^20.3.0" + "@angular/cli": "npm:^20.3.0" + "@angular/common": "npm:^20.3.0" + "@angular/compiler": "npm:^20.3.0" + "@angular/compiler-cli": "npm:^20.3.0" + "@angular/core": "npm:^20.3.0" + "@angular/platform-browser": "npm:^20.3.0" + "@fishjam-cloud/tsunami": "workspace:*" + "@types/events": "npm:^3.0.3" + events: "npm:^3.3.0" + fake-mediastreamtrack: "npm:^2.0.0" + jsdom: "npm:^26.0.0" + rxjs: "npm:^7.8.2" + tslib: "npm:^2.8.1" + typescript: "npm:~5.8.3" + vitest: "npm:^3.1.1" + languageName: unknown + linkType: soft + +"@fishjam-example/tsunami-vanilla-example@workspace:examples/tsunami/vanilla-ts": + version: 0.0.0-use.local + resolution: "@fishjam-example/tsunami-vanilla-example@workspace:examples/tsunami/vanilla-ts" + dependencies: + "@fishjam-cloud/tsunami": "workspace:*" + typescript: "npm:^5.8.3" + vite: "npm:^6.2.6" + languageName: unknown + linkType: soft + +"@gar/promise-retry@npm:^1.0.0, @gar/promise-retry@npm:^1.0.2": + version: 1.0.3 + resolution: "@gar/promise-retry@npm:1.0.3" + checksum: 10c0/885b02c8b0d75b2d215da25f3b639158c4fbe8fefe0d79163304534b9a6d0710db4b7699f7cd3cc1a730792bff04cbe19f4850a62d3e105a663eaeec88f38332 + languageName: node + linkType: hard + "@gerrit0/mini-shiki@npm:^3.2.2": version: 3.3.0 resolution: "@gerrit0/mini-shiki@npm:3.3.0" @@ -5628,6 +6265,15 @@ __metadata: languageName: node linkType: hard +"@hono/node-server@npm:^1.19.9": + version: 1.19.17 + resolution: "@hono/node-server@npm:1.19.17" + peerDependencies: + hono: ^4 + checksum: 10c0/e77907e3b5db8ab68ed4f297ad84807bfdd698636408de86ab8d36dab4658504ac4c0ed9d9436bc272a0172b9b7a5a4b578532e0b5baf97674c7b420cb5cff4b + languageName: node + linkType: hard + "@humanfs/core@npm:^0.19.1": version: 0.19.1 resolution: "@humanfs/core@npm:0.19.1" @@ -5684,6 +6330,268 @@ __metadata: languageName: node linkType: hard +"@inquirer/ansi@npm:^1.0.2": + version: 1.0.2 + resolution: "@inquirer/ansi@npm:1.0.2" + checksum: 10c0/8e408cc628923aa93402e66657482ccaa2ad5174f9db526d9a8b443f9011e9cd8f70f0f534f5fe3857b8a9df3bce1e25f66c96f666d6750490bd46e2b4f3b829 + languageName: node + linkType: hard + +"@inquirer/checkbox@npm:^4.2.1": + version: 4.3.2 + resolution: "@inquirer/checkbox@npm:4.3.2" + dependencies: + "@inquirer/ansi": "npm:^1.0.2" + "@inquirer/core": "npm:^10.3.2" + "@inquirer/figures": "npm:^1.0.15" + "@inquirer/type": "npm:^3.0.10" + yoctocolors-cjs: "npm:^2.1.3" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/771d23bc6b16cd5c21a4f1073e98e306147f90c0e2487fe887ee054b8bf86449f1f9e6e6f9c218c1aa45ae3be2533197d53654abe9c0545981aebb0920d5f471 + languageName: node + linkType: hard + +"@inquirer/confirm@npm:5.1.14": + version: 5.1.14 + resolution: "@inquirer/confirm@npm:5.1.14" + dependencies: + "@inquirer/core": "npm:^10.1.15" + "@inquirer/type": "npm:^3.0.8" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/12f49e8d1564c77c290163e87c9a256cfc087eab0c096738c73b03aa3d59a98c233fb9fb3692f162d67f923d120a4aa8ef819f75d979916dc13456f726c579d1 + languageName: node + linkType: hard + +"@inquirer/confirm@npm:^5.1.14": + version: 5.1.21 + resolution: "@inquirer/confirm@npm:5.1.21" + dependencies: + "@inquirer/core": "npm:^10.3.2" + "@inquirer/type": "npm:^3.0.10" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/a95bbdbb17626c484735a4193ed6b6a6fbb078cf62116ec8e1667f647e534dd6618e688ecc7962585efcc56881b544b8c53db3914599bbf2ab842e7f224b0fca + languageName: node + linkType: hard + +"@inquirer/core@npm:^10.1.15, @inquirer/core@npm:^10.3.2": + version: 10.3.2 + resolution: "@inquirer/core@npm:10.3.2" + dependencies: + "@inquirer/ansi": "npm:^1.0.2" + "@inquirer/figures": "npm:^1.0.15" + "@inquirer/type": "npm:^3.0.10" + cli-width: "npm:^4.1.0" + mute-stream: "npm:^2.0.0" + signal-exit: "npm:^4.1.0" + wrap-ansi: "npm:^6.2.0" + yoctocolors-cjs: "npm:^2.1.3" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/f0f27e07fe288e01e3949b4ad216c19751f025ce77c610366e08d8b0f7a135d064dc074732031d251584b454c576f1e5c849e4abe259186dd5d4974c8f85c13e + languageName: node + linkType: hard + +"@inquirer/editor@npm:^4.2.17": + version: 4.2.23 + resolution: "@inquirer/editor@npm:4.2.23" + dependencies: + "@inquirer/core": "npm:^10.3.2" + "@inquirer/external-editor": "npm:^1.0.3" + "@inquirer/type": "npm:^3.0.10" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/aa02028ee35ae039a4857b6a9490d295a1b3558f042e7454dee0aa36fbc83ac25586a2dfe0b46a5ea7ea151e3f5cb97a8ee6229131b4619f3b3466ad74b9519f + languageName: node + linkType: hard + +"@inquirer/expand@npm:^4.0.17": + version: 4.0.23 + resolution: "@inquirer/expand@npm:4.0.23" + dependencies: + "@inquirer/core": "npm:^10.3.2" + "@inquirer/type": "npm:^3.0.10" + yoctocolors-cjs: "npm:^2.1.3" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/294c92652760c3d1a46c4b900a99fd553ea9e5734ba261d4e71d7b8499d86a8b15e38a2467ddb7c95c197daf7e472bdab209fc3f7c38cbc70842cd291f4ce39d + languageName: node + linkType: hard + +"@inquirer/external-editor@npm:^1.0.3": + version: 1.0.3 + resolution: "@inquirer/external-editor@npm:1.0.3" + dependencies: + chardet: "npm:^2.1.1" + iconv-lite: "npm:^0.7.0" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/82951cb7f3762dd78cca2ea291396841e3f4adfe26004b5badfed1cec4b6a04bb567dff94d0e41b35c61bdd7957317c64c22f58074d14b238d44e44d9e420019 + languageName: node + linkType: hard + +"@inquirer/figures@npm:^1.0.15": + version: 1.0.15 + resolution: "@inquirer/figures@npm:1.0.15" + checksum: 10c0/6e39a040d260ae234ae220180b7994ff852673e20be925f8aa95e78c7934d732b018cbb4d0ec39e600a410461bcb93dca771e7de23caa10630d255692e440f69 + languageName: node + linkType: hard + +"@inquirer/input@npm:^4.2.1": + version: 4.3.1 + resolution: "@inquirer/input@npm:4.3.1" + dependencies: + "@inquirer/core": "npm:^10.3.2" + "@inquirer/type": "npm:^3.0.10" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/9e81d6ae56e5b59f96475ae1327e7e7beeef0d917b83762e0c2ed5a75239ad6b1a39fc05553ce45fe6f6de49681dade8704b5f1c11c2f555663a74d0ac998af3 + languageName: node + linkType: hard + +"@inquirer/number@npm:^3.0.17": + version: 3.0.23 + resolution: "@inquirer/number@npm:3.0.23" + dependencies: + "@inquirer/core": "npm:^10.3.2" + "@inquirer/type": "npm:^3.0.10" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/3944a524be2a2e0834822a0e483f2e2fd56ad597b5feeca2155b956821f88e22e07ce3816f66113b040601636ed7146865aee7d7afb2a06939acc77491330ccc + languageName: node + linkType: hard + +"@inquirer/password@npm:^4.0.17": + version: 4.0.23 + resolution: "@inquirer/password@npm:4.0.23" + dependencies: + "@inquirer/ansi": "npm:^1.0.2" + "@inquirer/core": "npm:^10.3.2" + "@inquirer/type": "npm:^3.0.10" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/9fd3d0462d02735bb1521c4e221d057a94d9aaac308e9a192e59d6c1e8efc707c2376ab627151d589bc3633f6b14b74b60b91c3d473a32adfd100ef1f6cfdef7 + languageName: node + linkType: hard + +"@inquirer/prompts@npm:7.8.2": + version: 7.8.2 + resolution: "@inquirer/prompts@npm:7.8.2" + dependencies: + "@inquirer/checkbox": "npm:^4.2.1" + "@inquirer/confirm": "npm:^5.1.14" + "@inquirer/editor": "npm:^4.2.17" + "@inquirer/expand": "npm:^4.0.17" + "@inquirer/input": "npm:^4.2.1" + "@inquirer/number": "npm:^3.0.17" + "@inquirer/password": "npm:^4.0.17" + "@inquirer/rawlist": "npm:^4.1.5" + "@inquirer/search": "npm:^3.1.0" + "@inquirer/select": "npm:^4.3.1" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/17b5388715ebb4e8e5d957774d016818c4794c5f6fa76dbdb05dad293f023479322b867aa439f2d585ee2f2abe9b0c3d7e77ef5360f012f3aa4b5809b3ce0796 + languageName: node + linkType: hard + +"@inquirer/rawlist@npm:^4.1.5": + version: 4.1.11 + resolution: "@inquirer/rawlist@npm:4.1.11" + dependencies: + "@inquirer/core": "npm:^10.3.2" + "@inquirer/type": "npm:^3.0.10" + yoctocolors-cjs: "npm:^2.1.3" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/33792b40cd0fbf77f547c9c4805087dd1188342c6a5ca512c73b0b6c4d132225fc5ae8bc4fd5035309484da3698a90fcef17aad100b9ae57624fda7b07d92227 + languageName: node + linkType: hard + +"@inquirer/search@npm:^3.1.0": + version: 3.2.2 + resolution: "@inquirer/search@npm:3.2.2" + dependencies: + "@inquirer/core": "npm:^10.3.2" + "@inquirer/figures": "npm:^1.0.15" + "@inquirer/type": "npm:^3.0.10" + yoctocolors-cjs: "npm:^2.1.3" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/e7849663a51fe95e3ce99d274c815b8dc8933d6a5ddcaaf6130bf43f5f10316062c9f7a37c2923a14b8dcd09d202b0bb9cc3eaf0adb0336f6a704ea52e03ef8c + languageName: node + linkType: hard + +"@inquirer/select@npm:^4.3.1": + version: 4.4.2 + resolution: "@inquirer/select@npm:4.4.2" + dependencies: + "@inquirer/ansi": "npm:^1.0.2" + "@inquirer/core": "npm:^10.3.2" + "@inquirer/figures": "npm:^1.0.15" + "@inquirer/type": "npm:^3.0.10" + yoctocolors-cjs: "npm:^2.1.3" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/6978a5a92928b4d439dd6b688f2db51fd49be209f24be224bb81ed8f75b76e0715e79bdb05dab2a33bbdc7091c779a99f8603fe0ca199f059528ca2b1d0d4944 + languageName: node + linkType: hard + +"@inquirer/type@npm:^3.0.10, @inquirer/type@npm:^3.0.7, @inquirer/type@npm:^3.0.8": + version: 3.0.10 + resolution: "@inquirer/type@npm:3.0.10" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/a846c7a570e3bf2657d489bcc5dcdc3179d24c7323719de1951dcdb722400ac76e5b2bfe9765d0a789bc1921fac810983d7999f021f30a78a6a174c23fc78dc9 + languageName: node + linkType: hard + "@isaacs/balanced-match@npm:^4.0.1": version: 4.0.1 resolution: "@isaacs/balanced-match@npm:4.0.1" @@ -5750,6 +6658,13 @@ __metadata: languageName: node linkType: hard +"@istanbuljs/schema@npm:^0.1.3": + version: 0.1.6 + resolution: "@istanbuljs/schema@npm:0.1.6" + checksum: 10c0/bb0d370bf3dd454d2f37f1bccb8921e2da99adacef2da56ef47850e25d7a4de69cf639ead8c189755aef38921369024b4afea3535a5c2ac9082b3e1171bcbc3a + languageName: node + linkType: hard + "@jest/console@npm:^29.7.0": version: 29.7.0 resolution: "@jest/console@npm:29.7.0" @@ -6007,67 +6922,389 @@ __metadata: languageName: node linkType: hard -"@material/material-color-utilities@npm:^0.3.0": - version: 0.3.0 - resolution: "@material/material-color-utilities@npm:0.3.0" - checksum: 10c0/3bef025428b893f2acc9e9e2bd186363a60b7c0836fe43c78222e29fe67dc579618e844f0661a20657ed9f7fd8b94fd43a2961892894d0b6a2ba5264ce2673f8 +"@listr2/prompt-adapter-inquirer@npm:3.0.1": + version: 3.0.1 + resolution: "@listr2/prompt-adapter-inquirer@npm:3.0.1" + dependencies: + "@inquirer/type": "npm:^3.0.7" + peerDependencies: + "@inquirer/prompts": ">= 3 < 8" + listr2: 9.0.1 + checksum: 10c0/ecd53852b377344dc39d7106389f9eb96c8e11835564ec18830c5624c62551d3e51bbf175bcd0769c1f2e1e1c9f8490cc83a0f79dc0dc1291abf76e87b60ef3c languageName: node linkType: hard -"@napi-rs/wasm-runtime@npm:^0.2.11": - version: 0.2.11 - resolution: "@napi-rs/wasm-runtime@npm:0.2.11" - dependencies: - "@emnapi/core": "npm:^1.4.3" - "@emnapi/runtime": "npm:^1.4.3" - "@tybys/wasm-util": "npm:^0.9.0" - checksum: 10c0/049bd14c58b99fbe0967b95e9921c5503df196b59be22948d2155f17652eb305cff6728efd8685338b855da7e476dd2551fbe3a313fc2d810938f0717478441e +"@lmdb/lmdb-darwin-arm64@npm:3.4.2": + version: 3.4.2 + resolution: "@lmdb/lmdb-darwin-arm64@npm:3.4.2" + conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@napi-rs/wasm-runtime@npm:^1.1.4": - version: 1.1.6 - resolution: "@napi-rs/wasm-runtime@npm:1.1.6" - dependencies: - "@tybys/wasm-util": "npm:^0.10.3" - peerDependencies: - "@emnapi/core": ^1.7.1 - "@emnapi/runtime": ^1.7.1 - checksum: 10c0/344518bf3ef65051dda4c00969f293aa4a21ab7dc7822b3f48519b17cd5eaa3f0bc34898d115d50ba59b1817a0cb905d46f7a7223c8249239cd14c28db388e10 +"@lmdb/lmdb-darwin-x64@npm:3.4.2": + version: 3.4.2 + resolution: "@lmdb/lmdb-darwin-x64@npm:3.4.2" + conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@nicolo-ribaudo/chokidar-2@npm:2.1.8-no-fsevents.3": - version: 2.1.8-no-fsevents.3 - resolution: "@nicolo-ribaudo/chokidar-2@npm:2.1.8-no-fsevents.3" - checksum: 10c0/27dcabaa0c9a29b3a60217bd3fff87a22cb43ed77863da570c6828e4d0b8f1c6ee52582cd3d439275a2b1f2051005e648ed866b981f2a03b61c645b7e4806ba7 +"@lmdb/lmdb-linux-arm64@npm:3.4.2": + version: 3.4.2 + resolution: "@lmdb/lmdb-linux-arm64@npm:3.4.2" + conditions: os=linux & cpu=arm64 languageName: node linkType: hard -"@nodelib/fs.scandir@npm:2.1.5": - version: 2.1.5 - resolution: "@nodelib/fs.scandir@npm:2.1.5" - dependencies: - "@nodelib/fs.stat": "npm:2.0.5" - run-parallel: "npm:^1.1.9" - checksum: 10c0/732c3b6d1b1e967440e65f284bd06e5821fedf10a1bea9ed2bb75956ea1f30e08c44d3def9d6a230666574edbaf136f8cfd319c14fd1f87c66e6a44449afb2eb +"@lmdb/lmdb-linux-arm@npm:3.4.2": + version: 3.4.2 + resolution: "@lmdb/lmdb-linux-arm@npm:3.4.2" + conditions: os=linux & cpu=arm languageName: node linkType: hard -"@nodelib/fs.stat@npm:2.0.5, @nodelib/fs.stat@npm:^2.0.2": - version: 2.0.5 - resolution: "@nodelib/fs.stat@npm:2.0.5" - checksum: 10c0/88dafe5e3e29a388b07264680dc996c17f4bda48d163a9d4f5c1112979f0ce8ec72aa7116122c350b4e7976bc5566dc3ddb579be1ceaacc727872eb4ed93926d +"@lmdb/lmdb-linux-x64@npm:3.4.2": + version: 3.4.2 + resolution: "@lmdb/lmdb-linux-x64@npm:3.4.2" + conditions: os=linux & cpu=x64 languageName: node linkType: hard -"@nodelib/fs.walk@npm:^1.2.3, @nodelib/fs.walk@npm:^1.2.8": - version: 1.2.8 - resolution: "@nodelib/fs.walk@npm:1.2.8" - dependencies: - "@nodelib/fs.scandir": "npm:2.1.5" - fastq: "npm:^1.6.0" - checksum: 10c0/db9de047c3bb9b51f9335a7bb46f4fcfb6829fb628318c12115fbaf7d369bfce71c15b103d1fc3b464812d936220ee9bc1c8f762d032c9f6be9acc99249095b1 +"@lmdb/lmdb-win32-arm64@npm:3.4.2": + version: 3.4.2 + resolution: "@lmdb/lmdb-win32-arm64@npm:3.4.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@lmdb/lmdb-win32-x64@npm:3.4.2": + version: 3.4.2 + resolution: "@lmdb/lmdb-win32-x64@npm:3.4.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@material/material-color-utilities@npm:^0.3.0": + version: 0.3.0 + resolution: "@material/material-color-utilities@npm:0.3.0" + checksum: 10c0/3bef025428b893f2acc9e9e2bd186363a60b7c0836fe43c78222e29fe67dc579618e844f0661a20657ed9f7fd8b94fd43a2961892894d0b6a2ba5264ce2673f8 + languageName: node + linkType: hard + +"@modelcontextprotocol/sdk@npm:1.26.0": + version: 1.26.0 + resolution: "@modelcontextprotocol/sdk@npm:1.26.0" + dependencies: + "@hono/node-server": "npm:^1.19.9" + ajv: "npm:^8.17.1" + ajv-formats: "npm:^3.0.1" + content-type: "npm:^1.0.5" + cors: "npm:^2.8.5" + cross-spawn: "npm:^7.0.5" + eventsource: "npm:^3.0.2" + eventsource-parser: "npm:^3.0.0" + express: "npm:^5.2.1" + express-rate-limit: "npm:^8.2.1" + hono: "npm:^4.11.4" + jose: "npm:^6.1.3" + json-schema-typed: "npm:^8.0.2" + pkce-challenge: "npm:^5.0.0" + raw-body: "npm:^3.0.0" + zod: "npm:^3.25 || ^4.0" + zod-to-json-schema: "npm:^3.25.1" + peerDependencies: + "@cfworker/json-schema": ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + "@cfworker/json-schema": + optional: true + zod: + optional: false + checksum: 10c0/b4098789d9fbbc4d9e0df240b18ba28867e4990ca4305322d6724fd03bba6685bbf21b39e02578b959da620b5a704ab1d565d3dbf377778ee9e4a0677b790728 + languageName: node + linkType: hard + +"@msgpackr-extract/msgpackr-extract-darwin-arm64@npm:3.0.4": + version: 3.0.4 + resolution: "@msgpackr-extract/msgpackr-extract-darwin-arm64@npm:3.0.4" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@msgpackr-extract/msgpackr-extract-darwin-x64@npm:3.0.4": + version: 3.0.4 + resolution: "@msgpackr-extract/msgpackr-extract-darwin-x64@npm:3.0.4" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@msgpackr-extract/msgpackr-extract-linux-arm64@npm:3.0.4": + version: 3.0.4 + resolution: "@msgpackr-extract/msgpackr-extract-linux-arm64@npm:3.0.4" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@msgpackr-extract/msgpackr-extract-linux-arm@npm:3.0.4": + version: 3.0.4 + resolution: "@msgpackr-extract/msgpackr-extract-linux-arm@npm:3.0.4" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@msgpackr-extract/msgpackr-extract-linux-x64@npm:3.0.4": + version: 3.0.4 + resolution: "@msgpackr-extract/msgpackr-extract-linux-x64@npm:3.0.4" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@msgpackr-extract/msgpackr-extract-win32-x64@npm:3.0.4": + version: 3.0.4 + resolution: "@msgpackr-extract/msgpackr-extract-win32-x64@npm:3.0.4" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@napi-rs/lzma-linux-x64-gnu@npm:1.5.1": + version: 1.5.1 + resolution: "@napi-rs/lzma-linux-x64-gnu@npm:1.5.1" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@napi-rs/nice-android-arm-eabi@npm:1.1.1": + version: 1.1.1 + resolution: "@napi-rs/nice-android-arm-eabi@npm:1.1.1" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@napi-rs/nice-android-arm64@npm:1.1.1": + version: 1.1.1 + resolution: "@napi-rs/nice-android-arm64@npm:1.1.1" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@napi-rs/nice-darwin-arm64@npm:1.1.1": + version: 1.1.1 + resolution: "@napi-rs/nice-darwin-arm64@npm:1.1.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@napi-rs/nice-darwin-x64@npm:1.1.1": + version: 1.1.1 + resolution: "@napi-rs/nice-darwin-x64@npm:1.1.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@napi-rs/nice-freebsd-x64@npm:1.1.1": + version: 1.1.1 + resolution: "@napi-rs/nice-freebsd-x64@npm:1.1.1" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@napi-rs/nice-linux-arm-gnueabihf@npm:1.1.1": + version: 1.1.1 + resolution: "@napi-rs/nice-linux-arm-gnueabihf@npm:1.1.1" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@napi-rs/nice-linux-arm64-gnu@npm:1.1.1": + version: 1.1.1 + resolution: "@napi-rs/nice-linux-arm64-gnu@npm:1.1.1" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@napi-rs/nice-linux-arm64-musl@npm:1.1.1": + version: 1.1.1 + resolution: "@napi-rs/nice-linux-arm64-musl@npm:1.1.1" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@napi-rs/nice-linux-ppc64-gnu@npm:1.1.1": + version: 1.1.1 + resolution: "@napi-rs/nice-linux-ppc64-gnu@npm:1.1.1" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@napi-rs/nice-linux-riscv64-gnu@npm:1.1.1": + version: 1.1.1 + resolution: "@napi-rs/nice-linux-riscv64-gnu@npm:1.1.1" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@napi-rs/nice-linux-s390x-gnu@npm:1.1.1": + version: 1.1.1 + resolution: "@napi-rs/nice-linux-s390x-gnu@npm:1.1.1" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@napi-rs/nice-linux-x64-gnu@npm:1.1.1": + version: 1.1.1 + resolution: "@napi-rs/nice-linux-x64-gnu@npm:1.1.1" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@napi-rs/nice-linux-x64-musl@npm:1.1.1": + version: 1.1.1 + resolution: "@napi-rs/nice-linux-x64-musl@npm:1.1.1" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@napi-rs/nice-openharmony-arm64@npm:1.1.1": + version: 1.1.1 + resolution: "@napi-rs/nice-openharmony-arm64@npm:1.1.1" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@napi-rs/nice-win32-arm64-msvc@npm:1.1.1": + version: 1.1.1 + resolution: "@napi-rs/nice-win32-arm64-msvc@npm:1.1.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@napi-rs/nice-win32-ia32-msvc@npm:1.1.1": + version: 1.1.1 + resolution: "@napi-rs/nice-win32-ia32-msvc@npm:1.1.1" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@napi-rs/nice-win32-x64-msvc@npm:1.1.1": + version: 1.1.1 + resolution: "@napi-rs/nice-win32-x64-msvc@npm:1.1.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@napi-rs/nice@npm:^1.0.4": + version: 1.1.1 + resolution: "@napi-rs/nice@npm:1.1.1" + dependencies: + "@napi-rs/nice-android-arm-eabi": "npm:1.1.1" + "@napi-rs/nice-android-arm64": "npm:1.1.1" + "@napi-rs/nice-darwin-arm64": "npm:1.1.1" + "@napi-rs/nice-darwin-x64": "npm:1.1.1" + "@napi-rs/nice-freebsd-x64": "npm:1.1.1" + "@napi-rs/nice-linux-arm-gnueabihf": "npm:1.1.1" + "@napi-rs/nice-linux-arm64-gnu": "npm:1.1.1" + "@napi-rs/nice-linux-arm64-musl": "npm:1.1.1" + "@napi-rs/nice-linux-ppc64-gnu": "npm:1.1.1" + "@napi-rs/nice-linux-riscv64-gnu": "npm:1.1.1" + "@napi-rs/nice-linux-s390x-gnu": "npm:1.1.1" + "@napi-rs/nice-linux-x64-gnu": "npm:1.1.1" + "@napi-rs/nice-linux-x64-musl": "npm:1.1.1" + "@napi-rs/nice-openharmony-arm64": "npm:1.1.1" + "@napi-rs/nice-win32-arm64-msvc": "npm:1.1.1" + "@napi-rs/nice-win32-ia32-msvc": "npm:1.1.1" + "@napi-rs/nice-win32-x64-msvc": "npm:1.1.1" + dependenciesMeta: + "@napi-rs/nice-android-arm-eabi": + optional: true + "@napi-rs/nice-android-arm64": + optional: true + "@napi-rs/nice-darwin-arm64": + optional: true + "@napi-rs/nice-darwin-x64": + optional: true + "@napi-rs/nice-freebsd-x64": + optional: true + "@napi-rs/nice-linux-arm-gnueabihf": + optional: true + "@napi-rs/nice-linux-arm64-gnu": + optional: true + "@napi-rs/nice-linux-arm64-musl": + optional: true + "@napi-rs/nice-linux-ppc64-gnu": + optional: true + "@napi-rs/nice-linux-riscv64-gnu": + optional: true + "@napi-rs/nice-linux-s390x-gnu": + optional: true + "@napi-rs/nice-linux-x64-gnu": + optional: true + "@napi-rs/nice-linux-x64-musl": + optional: true + "@napi-rs/nice-openharmony-arm64": + optional: true + "@napi-rs/nice-win32-arm64-msvc": + optional: true + "@napi-rs/nice-win32-ia32-msvc": + optional: true + "@napi-rs/nice-win32-x64-msvc": + optional: true + checksum: 10c0/517eacfd5d5de191f1469a6caad9f9e26924b25079550149fc792fb09d15184013a8a81966a666f08c0a93fbb17a458d50ba9e2e9d6a61141c6c515d083733b2 + languageName: node + linkType: hard + +"@napi-rs/wasm-runtime@npm:^0.2.11": + version: 0.2.11 + resolution: "@napi-rs/wasm-runtime@npm:0.2.11" + dependencies: + "@emnapi/core": "npm:^1.4.3" + "@emnapi/runtime": "npm:^1.4.3" + "@tybys/wasm-util": "npm:^0.9.0" + checksum: 10c0/049bd14c58b99fbe0967b95e9921c5503df196b59be22948d2155f17652eb305cff6728efd8685338b855da7e476dd2551fbe3a313fc2d810938f0717478441e + languageName: node + linkType: hard + +"@napi-rs/wasm-runtime@npm:^1.1.4": + version: 1.1.6 + resolution: "@napi-rs/wasm-runtime@npm:1.1.6" + dependencies: + "@tybys/wasm-util": "npm:^0.10.3" + peerDependencies: + "@emnapi/core": ^1.7.1 + "@emnapi/runtime": ^1.7.1 + checksum: 10c0/344518bf3ef65051dda4c00969f293aa4a21ab7dc7822b3f48519b17cd5eaa3f0bc34898d115d50ba59b1817a0cb905d46f7a7223c8249239cd14c28db388e10 + languageName: node + linkType: hard + +"@nicolo-ribaudo/chokidar-2@npm:2.1.8-no-fsevents.3": + version: 2.1.8-no-fsevents.3 + resolution: "@nicolo-ribaudo/chokidar-2@npm:2.1.8-no-fsevents.3" + checksum: 10c0/27dcabaa0c9a29b3a60217bd3fff87a22cb43ed77863da570c6828e4d0b8f1c6ee52582cd3d439275a2b1f2051005e648ed866b981f2a03b61c645b7e4806ba7 + languageName: node + linkType: hard + +"@nodelib/fs.scandir@npm:2.1.5": + version: 2.1.5 + resolution: "@nodelib/fs.scandir@npm:2.1.5" + dependencies: + "@nodelib/fs.stat": "npm:2.0.5" + run-parallel: "npm:^1.1.9" + checksum: 10c0/732c3b6d1b1e967440e65f284bd06e5821fedf10a1bea9ed2bb75956ea1f30e08c44d3def9d6a230666574edbaf136f8cfd319c14fd1f87c66e6a44449afb2eb + languageName: node + linkType: hard + +"@nodelib/fs.stat@npm:2.0.5, @nodelib/fs.stat@npm:^2.0.2": + version: 2.0.5 + resolution: "@nodelib/fs.stat@npm:2.0.5" + checksum: 10c0/88dafe5e3e29a388b07264680dc996c17f4bda48d163a9d4f5c1112979f0ce8ec72aa7116122c350b4e7976bc5566dc3ddb579be1ceaacc727872eb4ed93926d + languageName: node + linkType: hard + +"@nodelib/fs.walk@npm:^1.2.3, @nodelib/fs.walk@npm:^1.2.8": + version: 1.2.8 + resolution: "@nodelib/fs.walk@npm:1.2.8" + dependencies: + "@nodelib/fs.scandir": "npm:2.1.5" + fastq: "npm:^1.6.0" + checksum: 10c0/db9de047c3bb9b51f9335a7bb46f4fcfb6829fb628318c12115fbaf7d369bfce71c15b103d1fc3b464812d936220ee9bc1c8f762d032c9f6be9acc99249095b1 languageName: node linkType: hard @@ -6091,6 +7328,19 @@ __metadata: languageName: node linkType: hard +"@npmcli/agent@npm:^4.0.0": + version: 4.0.2 + resolution: "@npmcli/agent@npm:4.0.2" + dependencies: + agent-base: "npm:^7.1.0" + http-proxy-agent: "npm:^7.0.0" + https-proxy-agent: "npm:^7.0.1" + lru-cache: "npm:^11.2.1" + socks-proxy-agent: "npm:^8.0.3" + checksum: 10c0/7166c50776ff40dc79295a338da8649a131448f9f2b88694c0826b0e692c0f984402ab8486a5d7458cf0cb272f9130fea3d4aa2a13a26cc07bc10f29abd50ec3 + languageName: node + linkType: hard + "@npmcli/fs@npm:^4.0.0": version: 4.0.0 resolution: "@npmcli/fs@npm:4.0.0" @@ -6100,6 +7350,228 @@ __metadata: languageName: node linkType: hard +"@npmcli/fs@npm:^5.0.0": + version: 5.0.0 + resolution: "@npmcli/fs@npm:5.0.0" + dependencies: + semver: "npm:^7.3.5" + checksum: 10c0/26e376d780f60ff16e874a0ac9bc3399186846baae0b6e1352286385ac134d900cc5dafaded77f38d77f86898fc923ae1cee9d7399f0275b1aa24878915d722b + languageName: node + linkType: hard + +"@npmcli/git@npm:^7.0.0": + version: 7.0.2 + resolution: "@npmcli/git@npm:7.0.2" + dependencies: + "@gar/promise-retry": "npm:^1.0.0" + "@npmcli/promise-spawn": "npm:^9.0.0" + ini: "npm:^6.0.0" + lru-cache: "npm:^11.2.1" + npm-pick-manifest: "npm:^11.0.1" + proc-log: "npm:^6.0.0" + semver: "npm:^7.3.5" + which: "npm:^6.0.0" + checksum: 10c0/1936471c3188aa470d0c0dd4d49724bf144e381d122252d001475d69a96cd9de950936f55fec8c6a673a47cf607b11a662fc8b8a45c67d5c37c795b07d2be8e9 + languageName: node + linkType: hard + +"@npmcli/installed-package-contents@npm:^4.0.0": + version: 4.0.0 + resolution: "@npmcli/installed-package-contents@npm:4.0.0" + dependencies: + npm-bundled: "npm:^5.0.0" + npm-normalize-package-bin: "npm:^5.0.0" + bin: + installed-package-contents: bin/index.js + checksum: 10c0/297f32afc350e92c85981c1c793358af19e63c64d090f4e09997393fa2471f92da52317cb551356dc13594f2bdfad32d02c78bc2c664e2b7e0109d0d8713b39e + languageName: node + linkType: hard + +"@npmcli/node-gyp@npm:^5.0.0": + version: 5.0.0 + resolution: "@npmcli/node-gyp@npm:5.0.0" + checksum: 10c0/dc78219a848a30d26d46cd174816bdf21936aaee15469888cbd04433981ef866b35611275a1f94a31d68ea60cc18747d0d02430e4ce59f8a5c2423ec35b1bbed + languageName: node + linkType: hard + +"@npmcli/package-json@npm:^7.0.0": + version: 7.0.5 + resolution: "@npmcli/package-json@npm:7.0.5" + dependencies: + "@npmcli/git": "npm:^7.0.0" + glob: "npm:^13.0.0" + hosted-git-info: "npm:^9.0.0" + json-parse-even-better-errors: "npm:^5.0.0" + proc-log: "npm:^6.0.0" + semver: "npm:^7.5.3" + spdx-expression-parse: "npm:^4.0.0" + checksum: 10c0/4a04af494cd7273d4a5e930f53f30217dad389c7eaeb4de667aca84be27e668ebd8b16a6923ce58dc3090030f9126885b4cfc790517050acf179d0d9e0ca6de1 + languageName: node + linkType: hard + +"@npmcli/promise-spawn@npm:^9.0.0": + version: 9.0.1 + resolution: "@npmcli/promise-spawn@npm:9.0.1" + dependencies: + which: "npm:^6.0.0" + checksum: 10c0/361872192934bda684f590f140a2edd68add90d5936ca9a2e8792435447847adb59e249d5976950e20bbf213898c04da1b51b62fbc8f258b2fa8601af37fa0e2 + languageName: node + linkType: hard + +"@npmcli/redact@npm:^4.0.0": + version: 4.0.0 + resolution: "@npmcli/redact@npm:4.0.0" + checksum: 10c0/a1e9ba9c70a6b40e175bda2c3dd8cfdaf096e6b7f7a132c855c083c8dfe545c3237cd56702e2e6627a580b1d63373599d49a1192c4078a85bf47bbde824df31c + languageName: node + linkType: hard + +"@npmcli/run-script@npm:^10.0.0": + version: 10.0.4 + resolution: "@npmcli/run-script@npm:10.0.4" + dependencies: + "@npmcli/node-gyp": "npm:^5.0.0" + "@npmcli/package-json": "npm:^7.0.0" + "@npmcli/promise-spawn": "npm:^9.0.0" + node-gyp: "npm:^12.1.0" + proc-log: "npm:^6.0.0" + checksum: 10c0/4d65682491ce7462c6a16a3d20511f70074a0ab384e4e08786ff6c2df9630ad29caa564b5ace49ec3ba5a7f483dfbd13168da903c433a48e59c73189306b1a2f + languageName: node + linkType: hard + +"@parcel/watcher-android-arm64@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-android-arm64@npm:2.6.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@parcel/watcher-darwin-arm64@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-darwin-arm64@npm:2.6.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@parcel/watcher-darwin-x64@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-darwin-x64@npm:2.6.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@parcel/watcher-freebsd-x64@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-freebsd-x64@npm:2.6.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm-glibc@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-linux-arm-glibc@npm:2.6.0" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm-musl@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-linux-arm-musl@npm:2.6.0" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm64-glibc@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-linux-arm64-glibc@npm:2.6.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm64-musl@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-linux-arm64-musl@npm:2.6.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@parcel/watcher-linux-x64-glibc@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-linux-x64-glibc@npm:2.6.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@parcel/watcher-linux-x64-musl@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-linux-x64-musl@npm:2.6.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@parcel/watcher-win32-arm64@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-win32-arm64@npm:2.6.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@parcel/watcher-win32-x64@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-win32-x64@npm:2.6.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@parcel/watcher@npm:^2.4.1": + version: 2.6.0 + resolution: "@parcel/watcher@npm:2.6.0" + dependencies: + "@parcel/watcher-android-arm64": "npm:2.6.0" + "@parcel/watcher-darwin-arm64": "npm:2.6.0" + "@parcel/watcher-darwin-x64": "npm:2.6.0" + "@parcel/watcher-freebsd-x64": "npm:2.6.0" + "@parcel/watcher-linux-arm-glibc": "npm:2.6.0" + "@parcel/watcher-linux-arm-musl": "npm:2.6.0" + "@parcel/watcher-linux-arm64-glibc": "npm:2.6.0" + "@parcel/watcher-linux-arm64-musl": "npm:2.6.0" + "@parcel/watcher-linux-x64-glibc": "npm:2.6.0" + "@parcel/watcher-linux-x64-musl": "npm:2.6.0" + "@parcel/watcher-win32-arm64": "npm:2.6.0" + "@parcel/watcher-win32-x64": "npm:2.6.0" + detect-libc: "npm:^2.0.3" + is-glob: "npm:^4.0.3" + node-addon-api: "npm:^7.0.0" + node-gyp: "npm:latest" + picomatch: "npm:^4.0.4" + dependenciesMeta: + "@parcel/watcher-android-arm64": + optional: true + "@parcel/watcher-darwin-arm64": + optional: true + "@parcel/watcher-darwin-x64": + optional: true + "@parcel/watcher-freebsd-x64": + optional: true + "@parcel/watcher-linux-arm-glibc": + optional: true + "@parcel/watcher-linux-arm-musl": + optional: true + "@parcel/watcher-linux-arm64-glibc": + optional: true + "@parcel/watcher-linux-arm64-musl": + optional: true + "@parcel/watcher-linux-x64-glibc": + optional: true + "@parcel/watcher-linux-x64-musl": + optional: true + "@parcel/watcher-win32-arm64": + optional: true + "@parcel/watcher-win32-x64": + optional: true + checksum: 10c0/0d6799bdf7d59a1ab5e4a79859035f387a66078586d9543eef0b9bd01c9d61cebe3b64eb96f0bd1f8bf50f07ed7266a1628604b66dc63025a3cee19be2e60d65 + languageName: node + linkType: hard + "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 resolution: "@pkgjs/parseargs@npm:0.11.0" @@ -7090,6 +8562,20 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-android-arm-eabi@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.59.0" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@rollup/rollup-android-arm-eabi@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.62.4" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@rollup/rollup-android-arm64@npm:4.40.0": version: 4.40.0 resolution: "@rollup/rollup-android-arm64@npm:4.40.0" @@ -7097,59 +8583,213 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-darwin-arm64@npm:4.40.0": - version: 4.40.0 - resolution: "@rollup/rollup-darwin-arm64@npm:4.40.0" - conditions: os=darwin & cpu=arm64 +"@rollup/rollup-android-arm64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-android-arm64@npm:4.59.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-android-arm64@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-android-arm64@npm:4.62.4" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-darwin-arm64@npm:4.40.0": + version: 4.40.0 + resolution: "@rollup/rollup-darwin-arm64@npm:4.40.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-darwin-arm64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-darwin-arm64@npm:4.59.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-darwin-arm64@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-darwin-arm64@npm:4.62.4" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-darwin-x64@npm:4.40.0": + version: 4.40.0 + resolution: "@rollup/rollup-darwin-x64@npm:4.40.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-darwin-x64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-darwin-x64@npm:4.59.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-darwin-x64@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-darwin-x64@npm:4.62.4" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-freebsd-arm64@npm:4.40.0": + version: 4.40.0 + resolution: "@rollup/rollup-freebsd-arm64@npm:4.40.0" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-freebsd-arm64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-freebsd-arm64@npm:4.59.0" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-freebsd-arm64@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-freebsd-arm64@npm:4.62.4" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-freebsd-x64@npm:4.40.0": + version: 4.40.0 + resolution: "@rollup/rollup-freebsd-x64@npm:4.40.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-freebsd-x64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-freebsd-x64@npm:4.59.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-freebsd-x64@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-freebsd-x64@npm:4.62.4" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm-gnueabihf@npm:4.40.0": + version: 4.40.0 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.40.0" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm-gnueabihf@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.59.0" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm-gnueabihf@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.62.4" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm-musleabihf@npm:4.40.0": + version: 4.40.0 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.40.0" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm-musleabihf@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.59.0" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm-musleabihf@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.62.4" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm64-gnu@npm:4.40.0": + version: 4.40.0 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.40.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm64-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.59.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm64-gnu@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.62.4" + conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-darwin-x64@npm:4.40.0": +"@rollup/rollup-linux-arm64-musl@npm:4.40.0": version: 4.40.0 - resolution: "@rollup/rollup-darwin-x64@npm:4.40.0" - conditions: os=darwin & cpu=x64 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.40.0" + conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-freebsd-arm64@npm:4.40.0": - version: 4.40.0 - resolution: "@rollup/rollup-freebsd-arm64@npm:4.40.0" - conditions: os=freebsd & cpu=arm64 +"@rollup/rollup-linux-arm64-musl@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.59.0" + conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-freebsd-x64@npm:4.40.0": - version: 4.40.0 - resolution: "@rollup/rollup-freebsd-x64@npm:4.40.0" - conditions: os=freebsd & cpu=x64 +"@rollup/rollup-linux-arm64-musl@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.62.4" + conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-arm-gnueabihf@npm:4.40.0": - version: 4.40.0 - resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.40.0" - conditions: os=linux & cpu=arm & libc=glibc +"@rollup/rollup-linux-loong64-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.59.0" + conditions: os=linux & cpu=loong64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-arm-musleabihf@npm:4.40.0": - version: 4.40.0 - resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.40.0" - conditions: os=linux & cpu=arm & libc=musl +"@rollup/rollup-linux-loong64-gnu@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.62.4" + conditions: os=linux & cpu=loong64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-arm64-gnu@npm:4.40.0": - version: 4.40.0 - resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.40.0" - conditions: os=linux & cpu=arm64 & libc=glibc +"@rollup/rollup-linux-loong64-musl@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-loong64-musl@npm:4.59.0" + conditions: os=linux & cpu=loong64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-arm64-musl@npm:4.40.0": - version: 4.40.0 - resolution: "@rollup/rollup-linux-arm64-musl@npm:4.40.0" - conditions: os=linux & cpu=arm64 & libc=musl +"@rollup/rollup-linux-loong64-musl@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-loong64-musl@npm:4.62.4" + conditions: os=linux & cpu=loong64 & libc=musl languageName: node linkType: hard @@ -7167,6 +8807,34 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-ppc64-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.59.0" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-ppc64-gnu@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.62.4" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-ppc64-musl@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-ppc64-musl@npm:4.59.0" + conditions: os=linux & cpu=ppc64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-ppc64-musl@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-ppc64-musl@npm:4.62.4" + conditions: os=linux & cpu=ppc64 & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-riscv64-gnu@npm:4.40.0": version: 4.40.0 resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.40.0" @@ -7174,6 +8842,20 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-riscv64-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.59.0" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-riscv64-gnu@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.62.4" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-riscv64-musl@npm:4.40.0": version: 4.40.0 resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.40.0" @@ -7181,6 +8863,20 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-riscv64-musl@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.59.0" + conditions: os=linux & cpu=riscv64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-riscv64-musl@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.62.4" + conditions: os=linux & cpu=riscv64 & libc=musl + languageName: node + linkType: hard + "@rollup/rollup-linux-s390x-gnu@npm:4.40.0": version: 4.40.0 resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.40.0" @@ -7188,6 +8884,20 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-s390x-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.59.0" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-s390x-gnu@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.62.4" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-x64-gnu@npm:4.40.0": version: 4.40.0 resolution: "@rollup/rollup-linux-x64-gnu@npm:4.40.0" @@ -7195,6 +8905,20 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-x64-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.59.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-x64-gnu@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.62.4" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-x64-musl@npm:4.40.0": version: 4.40.0 resolution: "@rollup/rollup-linux-x64-musl@npm:4.40.0" @@ -7202,6 +8926,48 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-linux-x64-musl@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.59.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-x64-musl@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.62.4" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-openbsd-x64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-openbsd-x64@npm:4.59.0" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-openbsd-x64@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-openbsd-x64@npm:4.62.4" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-openharmony-arm64@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-openharmony-arm64@npm:4.59.0" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-openharmony-arm64@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-openharmony-arm64@npm:4.62.4" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-win32-arm64-msvc@npm:4.40.0": version: 4.40.0 resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.40.0" @@ -7209,6 +8975,20 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-arm64-msvc@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.59.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-win32-arm64-msvc@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.62.4" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-win32-ia32-msvc@npm:4.40.0": version: 4.40.0 resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.40.0" @@ -7216,6 +8996,34 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-ia32-msvc@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.59.0" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@rollup/rollup-win32-ia32-msvc@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.62.4" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@rollup/rollup-win32-x64-gnu@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-win32-x64-gnu@npm:4.59.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-win32-x64-gnu@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-win32-x64-gnu@npm:4.62.4" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@rollup/rollup-win32-x64-msvc@npm:4.40.0": version: 4.40.0 resolution: "@rollup/rollup-win32-x64-msvc@npm:4.40.0" @@ -7223,6 +9031,20 @@ __metadata: languageName: node linkType: hard +"@rollup/rollup-win32-x64-msvc@npm:4.59.0": + version: 4.59.0 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.59.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-win32-x64-msvc@npm:4.62.4": + version: 4.62.4 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.62.4" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@rtsao/scc@npm:^1.1.0": version: 1.1.0 resolution: "@rtsao/scc@npm:1.1.0" @@ -7230,6 +9052,17 @@ __metadata: languageName: node linkType: hard +"@schematics/angular@npm:20.3.32": + version: 20.3.32 + resolution: "@schematics/angular@npm:20.3.32" + dependencies: + "@angular-devkit/core": "npm:20.3.32" + "@angular-devkit/schematics": "npm:20.3.32" + jsonc-parser: "npm:3.3.1" + checksum: 10c0/4b1bcda7ed711cb08c50582c67bdd62b28b63042bb8bb9956adc7a8d0dca26caaa8968788a04a0f6b701e930905de3e795c1425c2486e3534ea5c10a3d34ca88 + languageName: node + linkType: hard + "@shikijs/engine-oniguruma@npm:^3.3.0": version: 3.3.0 resolution: "@shikijs/engine-oniguruma@npm:3.3.0" @@ -7275,6 +9108,64 @@ __metadata: languageName: node linkType: hard +"@sigstore/bundle@npm:^4.0.0": + version: 4.0.0 + resolution: "@sigstore/bundle@npm:4.0.0" + dependencies: + "@sigstore/protobuf-specs": "npm:^0.5.0" + checksum: 10c0/0606ed6274f8e042298cdbcbef293d57de7dc00082e6ab076c8bda9c1765dc502e160aecaa034c112c1f1d08266dd7376437a86e7ecab03e3865cb4e03ee24c2 + languageName: node + linkType: hard + +"@sigstore/core@npm:^3.2.0, @sigstore/core@npm:^3.2.1": + version: 3.2.1 + resolution: "@sigstore/core@npm:3.2.1" + checksum: 10c0/b7f7dadf07234b6fa110dfeedd8453c6d81fa0fc77731c097dc72b3fb9e0e8750e7b3fa82c33f4b9d8bdda1be634eda18231f5dad1679bdf31f204f855926f61 + languageName: node + linkType: hard + +"@sigstore/protobuf-specs@npm:^0.5.0": + version: 0.5.1 + resolution: "@sigstore/protobuf-specs@npm:0.5.1" + checksum: 10c0/4284797bcac5f7250b7cb7000a67e76045d38da05a24a5912085b2472e0635efe4db3c6dd118e4023d7ba7ec5adc06cc8c35bf750cf2b39743e73c9f35311fe0 + languageName: node + linkType: hard + +"@sigstore/sign@npm:^4.1.1": + version: 4.1.1 + resolution: "@sigstore/sign@npm:4.1.1" + dependencies: + "@gar/promise-retry": "npm:^1.0.2" + "@sigstore/bundle": "npm:^4.0.0" + "@sigstore/core": "npm:^3.2.0" + "@sigstore/protobuf-specs": "npm:^0.5.0" + make-fetch-happen: "npm:^15.0.4" + proc-log: "npm:^6.1.0" + checksum: 10c0/88a6e5d2ce49477a52574d5dd5f4531cbb3472435fad29730969b77988efb23bdd5ce031a74f738da5b24c950f99030704b75b8cc65d5179b56ce9ede9711784 + languageName: node + linkType: hard + +"@sigstore/tuf@npm:^4.0.2": + version: 4.0.2 + resolution: "@sigstore/tuf@npm:4.0.2" + dependencies: + "@sigstore/protobuf-specs": "npm:^0.5.0" + tuf-js: "npm:^4.1.0" + checksum: 10c0/eb7ba5b9d4859948bfd5552a1c6d93f0d05b9482bf21dede53779ea429f833dcd13c3a52524596c556729d75d85326ce0a7d0857d3d23ef99784b0e94e948818 + languageName: node + linkType: hard + +"@sigstore/verify@npm:^3.1.1": + version: 3.1.1 + resolution: "@sigstore/verify@npm:3.1.1" + dependencies: + "@sigstore/bundle": "npm:^4.0.0" + "@sigstore/core": "npm:^3.2.1" + "@sigstore/protobuf-specs": "npm:^0.5.0" + checksum: 10c0/3b8c0b224b23a0e215e90a60a03193b77f333d9fd6838671aec2aef1bc9e8d42b9cb5cf246d5fb31135705bef0384e919364c5ba7f749e2cb4c10c93ae856a5c + languageName: node + linkType: hard + "@sinclair/typebox@npm:^0.27.8": version: 0.27.8 resolution: "@sinclair/typebox@npm:0.27.8" @@ -7509,6 +9400,23 @@ __metadata: languageName: node linkType: hard +"@tufjs/canonical-json@npm:2.0.0": + version: 2.0.0 + resolution: "@tufjs/canonical-json@npm:2.0.0" + checksum: 10c0/52c5ffaef1483ed5c3feedfeba26ca9142fa386eea54464e70ff515bd01c5e04eab05d01eff8c2593291dcaf2397ca7d9c512720e11f52072b04c47a5c279415 + languageName: node + linkType: hard + +"@tufjs/models@npm:4.1.0": + version: 4.1.0 + resolution: "@tufjs/models@npm:4.1.0" + dependencies: + "@tufjs/canonical-json": "npm:2.0.0" + minimatch: "npm:^10.1.1" + checksum: 10c0/0a4ab524061c97bb43ccd3ffaaaed224eb41469fa2b748f66599d298798f7556e7158a12a9cbdfb89476df0ae538ca562292ac10909e411aa17f81f72b3e8931 + languageName: node + linkType: hard + "@tybys/wasm-util@npm:^0.10.3": version: 0.10.3 resolution: "@tybys/wasm-util@npm:0.10.3" @@ -7621,6 +9529,20 @@ __metadata: languageName: node linkType: hard +"@types/estree@npm:1.0.8": + version: 1.0.8 + resolution: "@types/estree@npm:1.0.8" + checksum: 10c0/39d34d1afaa338ab9763f37ad6066e3f349444f9052b9676a7cc0252ef9485a41c6d81c9c4e0d26e9077993354edf25efc853f3224dd4b447175ef62bdcc86a5 + languageName: node + linkType: hard + +"@types/estree@npm:1.0.9": + version: 1.0.9 + resolution: "@types/estree@npm:1.0.9" + checksum: 10c0/3ad3286ca2988cd550dafb8f2ad599c8474868e954fa601a36655bdfefd8039f7c714b8c1c7f2ae219ffbd58bd4660e66fa7479a0120fc02d4777057d4865387 + languageName: node + linkType: hard + "@types/events@npm:^3.0.3": version: 3.0.3 resolution: "@types/events@npm:3.0.3" @@ -8698,6 +10620,15 @@ __metadata: languageName: node linkType: hard +"@vitejs/plugin-basic-ssl@npm:2.1.0": + version: 2.1.0 + resolution: "@vitejs/plugin-basic-ssl@npm:2.1.0" + peerDependencies: + vite: ^6.0.0 || ^7.0.0 + checksum: 10c0/aa618be6bd915163aba27b2db780eab2f0e5d800fea48795f638fb6c1d098db8008ba7f84c708dc191159efecf6ec4e0191ebc5bb7a1a31779c01d0252bf56b0 + languageName: node + linkType: hard + "@vitejs/plugin-react-swc@npm:^3.8.1": version: 3.9.0 resolution: "@vitejs/plugin-react-swc@npm:3.9.0" @@ -8845,6 +10776,13 @@ __metadata: languageName: node linkType: hard +"@yarnpkg/lockfile@npm:1.1.0": + version: 1.1.0 + resolution: "@yarnpkg/lockfile@npm:1.1.0" + checksum: 10c0/0bfa50a3d756623d1f3409bc23f225a1d069424dbc77c6fd2f14fb377390cd57ec703dc70286e081c564be9051ead9ba85d81d66a3e68eeb6eb506d4e0c0fbda + languageName: node + linkType: hard + "abab@npm:^2.0.6": version: 2.0.6 resolution: "abab@npm:2.0.6" @@ -8859,6 +10797,13 @@ __metadata: languageName: node linkType: hard +"abbrev@npm:^4.0.0": + version: 4.0.0 + resolution: "abbrev@npm:4.0.0" + checksum: 10c0/b4cc16935235e80702fc90192e349e32f8ef0ed151ef506aa78c81a7c455ec18375c4125414b99f84b2e055199d66383e787675f0bcd87da7a4dbd59f9eac1d5 + languageName: node + linkType: hard + "abort-controller@npm:^3.0.0": version: 3.0.0 resolution: "abort-controller@npm:3.0.0" @@ -8960,6 +10905,32 @@ __metadata: languageName: node linkType: hard +"ajv-formats@npm:3.0.1, ajv-formats@npm:^3.0.1": + version: 3.0.1 + resolution: "ajv-formats@npm:3.0.1" + dependencies: + ajv: "npm:^8.0.0" + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + checksum: 10c0/168d6bca1ea9f163b41c8147bae537e67bd963357a5488a1eaf3abe8baa8eec806d4e45f15b10767e6020679315c7e1e5e6803088dfb84efa2b4e9353b83dd0a + languageName: node + linkType: hard + +"ajv@npm:8.18.0": + version: 8.18.0 + resolution: "ajv@npm:8.18.0" + dependencies: + fast-deep-equal: "npm:^3.1.3" + fast-uri: "npm:^3.0.1" + json-schema-traverse: "npm:^1.0.0" + require-from-string: "npm:^2.0.2" + checksum: 10c0/e7517c426173513a07391be951879932bdf3348feaebd2199f5b901c20f99d60db8cd1591502d4d551dc82f594e82a05c4fe1c70139b15b8937f7afeaed9532f + languageName: node + linkType: hard + "ajv@npm:^6.12.4": version: 6.12.6 resolution: "ajv@npm:6.12.6" @@ -8972,6 +10943,40 @@ __metadata: languageName: node linkType: hard +"ajv@npm:^8.0.0, ajv@npm:^8.17.1": + version: 8.20.0 + resolution: "ajv@npm:8.20.0" + dependencies: + fast-deep-equal: "npm:^3.1.3" + fast-uri: "npm:^3.0.1" + json-schema-traverse: "npm:^1.0.0" + require-from-string: "npm:^2.0.2" + checksum: 10c0/5df9a1c8f83863cde1bd3a9ddb426f599718f88e3dc9153616c79fb28e0be455335830d7f21d745576519f057b371352daa31047b6a33d7036fe08777d60cf2a + languageName: node + linkType: hard + +"algoliasearch@npm:5.35.0": + version: 5.35.0 + resolution: "algoliasearch@npm:5.35.0" + dependencies: + "@algolia/abtesting": "npm:1.1.0" + "@algolia/client-abtesting": "npm:5.35.0" + "@algolia/client-analytics": "npm:5.35.0" + "@algolia/client-common": "npm:5.35.0" + "@algolia/client-insights": "npm:5.35.0" + "@algolia/client-personalization": "npm:5.35.0" + "@algolia/client-query-suggestions": "npm:5.35.0" + "@algolia/client-search": "npm:5.35.0" + "@algolia/ingestion": "npm:1.35.0" + "@algolia/monitoring": "npm:1.35.0" + "@algolia/recommend": "npm:5.35.0" + "@algolia/requester-browser-xhr": "npm:5.35.0" + "@algolia/requester-fetch": "npm:5.35.0" + "@algolia/requester-node-http": "npm:5.35.0" + checksum: 10c0/3b6593dc285e7dadda08dea3998391b9439b4e1144e8da767311e470e69e33067f2396e628cdf24767d52bdc0c08c063991cf76ac787ce5aec36864fcc1e3f9a + languageName: node + linkType: hard + "anser@npm:^1.4.9": version: 1.4.10 resolution: "anser@npm:1.4.10" @@ -9816,6 +11821,15 @@ __metadata: languageName: node linkType: hard +"baseline-browser-mapping@npm:^2.10.44": + version: 2.11.11 + resolution: "baseline-browser-mapping@npm:2.11.11" + bin: + baseline-browser-mapping: dist/cli.cjs + checksum: 10c0/b3eb91b83541a95ea0aa23d34aecefff382cb6b989e88f3411ee1931968cbd4852b874020c788345a082b4090b9643d929e20542438a9c4dac95b74e9632fdb4 + languageName: node + linkType: hard + "baseline-browser-mapping@npm:^2.8.25": version: 2.8.32 resolution: "baseline-browser-mapping@npm:2.8.32" @@ -9834,6 +11848,22 @@ __metadata: languageName: node linkType: hard +"beasties@npm:0.3.5": + version: 0.3.5 + resolution: "beasties@npm:0.3.5" + dependencies: + css-select: "npm:^6.0.0" + css-what: "npm:^7.0.0" + dom-serializer: "npm:^2.0.0" + domhandler: "npm:^5.0.3" + htmlparser2: "npm:^10.0.0" + picocolors: "npm:^1.1.1" + postcss: "npm:^8.4.49" + postcss-media-query-parser: "npm:^0.2.3" + checksum: 10c0/2bb8f386446a1f6d5ee73386971347e16d895f55384510da9fabccada4456a03e6a810e59d63b5faf9b76eb0f18a29f21255d67140b25d8348af7bdf74d0ddad + languageName: node + linkType: hard + "better-opn@npm:~3.0.2": version: 3.0.2 resolution: "better-opn@npm:3.0.2" @@ -9868,6 +11898,30 @@ __metadata: languageName: node linkType: hard +"body-parser@npm:^2.2.1": + version: 2.3.0 + resolution: "body-parser@npm:2.3.0" + dependencies: + bytes: "npm:^3.1.2" + content-type: "npm:^2.0.0" + debug: "npm:^4.4.3" + http-errors: "npm:^2.0.1" + iconv-lite: "npm:^0.7.2" + on-finished: "npm:^2.4.1" + qs: "npm:^6.15.2" + raw-body: "npm:^3.0.2" + type-is: "npm:^2.1.0" + checksum: 10c0/2a8fbbdc471b588338555a3e1a597d1eb0ad0c21cf20fdc3bac5d3f8d9c3a4b19b4163575ab852a43a5dfc0df7770ced5284f979e561f1a24c2f851ce89e695a + languageName: node + linkType: hard + +"boolbase@npm:^1.0.0": + version: 1.0.0 + resolution: "boolbase@npm:1.0.0" + checksum: 10c0/e4b53deb4f2b85c52be0e21a273f2045c7b6a6ea002b0e139c744cb6f95e9ec044439a52883b0d74dedd1ff3da55ed140cfdddfed7fb0cccbed373de5dce1bcf + languageName: node + linkType: hard + "bplist-creator@npm:0.1.0": version: 0.1.0 resolution: "bplist-creator@npm:0.1.0" @@ -9928,7 +11982,16 @@ __metadata: resolution: "brace-expansion@npm:5.0.4" dependencies: balanced-match: "npm:^4.0.2" - checksum: 10c0/359cbcfa80b2eb914ca1f3440e92313fbfe7919ee6b274c35db55bec555aded69dac5ee78f102cec90c35f98c20fa43d10936d0cd9978158823c249257e1643a + checksum: 10c0/359cbcfa80b2eb914ca1f3440e92313fbfe7919ee6b274c35db55bec555aded69dac5ee78f102cec90c35f98c20fa43d10936d0cd9978158823c249257e1643a + languageName: node + linkType: hard + +"brace-expansion@npm:^5.0.8": + version: 5.0.9 + resolution: "brace-expansion@npm:5.0.9" + dependencies: + balanced-match: "npm:^4.0.2" + checksum: 10c0/3dea38884a1c3c8b1c9c44a7402a0c76fca460f70cffb3127242b0b4cbf4472019e022ade021eec44838ff19f1dac2625dfd11dd459d7e1e055b0698a8d52fec languageName: node linkType: hard @@ -9971,6 +12034,21 @@ __metadata: languageName: node linkType: hard +"browserslist@npm:^4.23.0": + version: 4.28.7 + resolution: "browserslist@npm:4.28.7" + dependencies: + baseline-browser-mapping: "npm:^2.10.44" + caniuse-lite: "npm:^1.0.30001806" + electron-to-chromium: "npm:^1.5.393" + node-releases: "npm:^2.0.51" + update-browserslist-db: "npm:^1.2.3" + bin: + browserslist: cli.js + checksum: 10c0/dc41922a9ff0d81e5492498bdab2d932e3a3222f4277814ba38ee64f4e51f26e5244a7cce0777b4cac3ceff6eb196f5cadbb2a832620973a7f9c39611f6bc78e + languageName: node + linkType: hard + "browserslist@npm:^4.24.0, browserslist@npm:^4.24.4": version: 4.24.4 resolution: "browserslist@npm:4.24.4" @@ -10092,7 +12170,7 @@ __metadata: languageName: node linkType: hard -"bytes@npm:3.1.2": +"bytes@npm:3.1.2, bytes@npm:^3.1.2, bytes@npm:~3.1.2": version: 3.1.2 resolution: "bytes@npm:3.1.2" checksum: 10c0/76d1c43cbd602794ad8ad2ae94095cddeb1de78c5dddaa7005c51af10b0176c69971a6d88e805a90c2b6550d76636e43c40d8427a808b8645ede885de4a0358e @@ -10126,6 +12204,24 @@ __metadata: languageName: node linkType: hard +"cacache@npm:^20.0.0, cacache@npm:^20.0.1": + version: 20.0.4 + resolution: "cacache@npm:20.0.4" + dependencies: + "@npmcli/fs": "npm:^5.0.0" + fs-minipass: "npm:^3.0.0" + glob: "npm:^13.0.0" + lru-cache: "npm:^11.1.0" + minipass: "npm:^7.0.3" + minipass-collect: "npm:^2.0.1" + minipass-flush: "npm:^1.0.5" + minipass-pipeline: "npm:^1.2.4" + p-map: "npm:^7.0.2" + ssri: "npm:^13.0.0" + checksum: 10c0/539bf4020e44ba9ca5afc2ec435623ed7e0dd80c020097677e6b4a0545df5cc9d20b473212d01209c8b4aea43c0d095af0bb6da97bcb991642ea6fac0d7c462b + languageName: node + linkType: hard + "call-bind-apply-helpers@npm:^1.0.0, call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": version: 1.0.2 resolution: "call-bind-apply-helpers@npm:1.0.2" @@ -10214,6 +12310,13 @@ __metadata: languageName: node linkType: hard +"caniuse-lite@npm:^1.0.30001806": + version: 1.0.30001806 + resolution: "caniuse-lite@npm:1.0.30001806" + checksum: 10c0/9442c8afff0968e9b2ce0104a7340c1ce4a5c09f469ccb9eef10d25712ea0afe542486e5318c697c70dd502f988cfc04eaa1c9438c92ac3bcf4d708a2a17bee1 + languageName: node + linkType: hard + "case-anything@npm:^2.1.13": version: 2.1.13 resolution: "case-anything@npm:2.1.13" @@ -10274,7 +12377,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^5.2.0, chalk@npm:^5.6.2": +"chalk@npm:^5.2.0, chalk@npm:^5.3.0, chalk@npm:^5.6.2": version: 5.6.2 resolution: "chalk@npm:5.6.2" checksum: 10c0/99a4b0f0e7991796b1e7e3f52dceb9137cae2a9dfc8fc0784a550dc4c558e15ab32ed70b14b21b52beb2679b4892b41a0aa44249bcb996f01e125d58477c6976 @@ -10302,6 +12405,13 @@ __metadata: languageName: node linkType: hard +"chardet@npm:^2.1.1": + version: 2.2.0 + resolution: "chardet@npm:2.2.0" + checksum: 10c0/8d43a1dd3ce535aa070d04139fae62f879b4388394eb1d857364ce416675a1f0f63974fba8208e9cd11b49e1a6da3e65ea6393d0fc654a8c4217c0d74c16b1b4 + languageName: node + linkType: hard + "check-error@npm:^2.1.1": version: 2.1.1 resolution: "check-error@npm:2.1.1" @@ -10328,7 +12438,7 @@ __metadata: languageName: node linkType: hard -"chokidar@npm:^4.0.3": +"chokidar@npm:^4.0.0, chokidar@npm:^4.0.3": version: 4.0.3 resolution: "chokidar@npm:4.0.3" dependencies: @@ -10441,7 +12551,7 @@ __metadata: languageName: node linkType: hard -"cli-spinners@npm:^2.0.0": +"cli-spinners@npm:^2.0.0, cli-spinners@npm:^2.9.2": version: 2.9.2 resolution: "cli-spinners@npm:2.9.2" checksum: 10c0/907a1c227ddf0d7a101e7ab8b300affc742ead4b4ebe920a5bf1bc6d45dce2958fcd195eb28fa25275062fe6fa9b109b93b63bc8033396ed3bcb50297008b3a3 @@ -10468,6 +12578,13 @@ __metadata: languageName: node linkType: hard +"cli-width@npm:^4.1.0": + version: 4.1.0 + resolution: "cli-width@npm:4.1.0" + checksum: 10c0/1fbd56413578f6117abcaf858903ba1f4ad78370a4032f916745fa2c7e390183a9d9029cf837df320b0fdce8137668e522f60a30a5f3d6529ff3872d265a955f + languageName: node + linkType: hard + "client-only@npm:^0.0.1": version: 0.0.1 resolution: "client-only@npm:0.0.1" @@ -10497,6 +12614,17 @@ __metadata: languageName: node linkType: hard +"cliui@npm:^9.0.1": + version: 9.0.1 + resolution: "cliui@npm:9.0.1" + dependencies: + string-width: "npm:^7.2.0" + strip-ansi: "npm:^7.1.0" + wrap-ansi: "npm:^9.0.0" + checksum: 10c0/13441832e9efe7c7a76bd2b8e683555c478d461a9f249dc5db9b17fe8d4b47fa9277b503914b90bd00e4a151abb6b9b02b2288972ffe2e5e3ca40bcb1c2330d3 + languageName: node + linkType: hard + "clone@npm:^1.0.2": version: 1.0.4 resolution: "clone@npm:1.0.4" @@ -10702,6 +12830,34 @@ __metadata: languageName: node linkType: hard +"content-disposition@npm:^1.0.0": + version: 1.1.0 + resolution: "content-disposition@npm:1.1.0" + checksum: 10c0/94e0aef65873e69330f5f187fbc44ebce593bdcb8013dd8a68b7d0f159ca089bd30db3f8095d829f81c341695b60a6085ee6e15e6d775c4a325b586cc8d91974 + languageName: node + linkType: hard + +"content-type@npm:^1.0.5": + version: 1.0.5 + resolution: "content-type@npm:1.0.5" + checksum: 10c0/b76ebed15c000aee4678c3707e0860cb6abd4e680a598c0a26e17f0bfae723ec9cc2802f0ff1bc6e4d80603719010431d2231018373d4dde10f9ccff9dadf5af + languageName: node + linkType: hard + +"content-type@npm:^2.0.0": + version: 2.0.0 + resolution: "content-type@npm:2.0.0" + checksum: 10c0/491539fff707d7594b0ca4fabcc084bef2a31ffa754ff0a4f80c4377e3963cff0394317f9271c24087596c97fa675bc123d61fa34ffe65b4904e7d3d3098de72 + languageName: node + linkType: hard + +"convert-source-map@npm:^1.5.1": + version: 1.9.0 + resolution: "convert-source-map@npm:1.9.0" + checksum: 10c0/281da55454bf8126cbc6625385928c43479f2060984180c42f3a86c8b8c12720a24eac260624a7d1e090004028d2dee78602330578ceec1a08e27cb8bb0a8a5b + languageName: node + linkType: hard + "convert-source-map@npm:^2.0.0": version: 2.0.0 resolution: "convert-source-map@npm:2.0.0" @@ -10709,6 +12865,20 @@ __metadata: languageName: node linkType: hard +"cookie-signature@npm:^1.2.1": + version: 1.2.2 + resolution: "cookie-signature@npm:1.2.2" + checksum: 10c0/54e05df1a293b3ce81589b27dddc445f462f6fa6812147c033350cd3561a42bc14481674e05ed14c7bd0ce1e8bb3dc0e40851bad75415733711294ddce0b7bc6 + languageName: node + linkType: hard + +"cookie@npm:^0.7.1": + version: 0.7.2 + resolution: "cookie@npm:0.7.2" + checksum: 10c0/9596e8ccdbf1a3a88ae02cf5ee80c1c50959423e1022e4e60b91dd87c622af1da309253d8abdb258fb5e3eacb4f08e579dc58b4897b8087574eee0fd35dfa5d2 + languageName: node + linkType: hard + "core-js-compat@npm:^3.43.0": version: 3.47.0 resolution: "core-js-compat@npm:3.47.0" @@ -10734,6 +12904,16 @@ __metadata: languageName: node linkType: hard +"cors@npm:^2.8.5": + version: 2.8.6 + resolution: "cors@npm:2.8.6" + dependencies: + object-assign: "npm:^4" + vary: "npm:^1" + checksum: 10c0/ab2bc57b8af8ef8476682a59647f7c55c1a7d406b559ac06119aa1c5f70b96d35036864d197b24cf86e228e4547231088f1f94ca05061dbb14d89cc0bc9d4cab + languageName: node + linkType: hard + "cosmiconfig@npm:^7.0.0, cosmiconfig@npm:^7.0.1": version: 7.1.0 resolution: "cosmiconfig@npm:7.1.0" @@ -10786,7 +12966,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6": +"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.5, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -10946,6 +13126,26 @@ __metadata: languageName: node linkType: hard +"css-select@npm:^6.0.0": + version: 6.0.0 + resolution: "css-select@npm:6.0.0" + dependencies: + boolbase: "npm:^1.0.0" + css-what: "npm:^7.0.0" + domhandler: "npm:^5.0.3" + domutils: "npm:^3.2.2" + nth-check: "npm:^2.1.1" + checksum: 10c0/2f63e954b8a9475f25ea3ff23d20c556a99e464113e7dff53711bc63e5b579f17ebc82fe49d6443aa65f656e25f97e2b32b9e8143181fe22758536730b6999ca + languageName: node + linkType: hard + +"css-what@npm:^7.0.0": + version: 7.0.0 + resolution: "css-what@npm:7.0.0" + checksum: 10c0/590329e773d8103dcc4f2aefa48d824d5b934b53baae7d8bcc3b61f0a917a0d6b6a1154d7319c6416421e7eb3b1c94704ec21924c485f4b174f0218416211fd9 + languageName: node + linkType: hard + "cssom@npm:^0.5.0": version: 0.5.0 resolution: "cssom@npm:0.5.0" @@ -11218,7 +13418,7 @@ __metadata: languageName: node linkType: hard -"depd@npm:2.0.0, depd@npm:~2.0.0": +"depd@npm:2.0.0, depd@npm:^2.0.0, depd@npm:~2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" checksum: 10c0/58bd06ec20e19529b06f7ad07ddab60e504d9e0faca4bd23079fac2d279c3594334d736508dc350e06e510aba5e22e4594483b3a6562ce7c17dd797f4cc4ad2c @@ -11248,7 +13448,7 @@ __metadata: languageName: node linkType: hard -"detect-libc@npm:^2.0.3": +"detect-libc@npm:^2.0.1, detect-libc@npm:^2.0.3": version: 2.1.2 resolution: "detect-libc@npm:2.1.2" checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4 @@ -11339,6 +13539,24 @@ __metadata: languageName: node linkType: hard +"dom-serializer@npm:^2.0.0": + version: 2.0.0 + resolution: "dom-serializer@npm:2.0.0" + dependencies: + domelementtype: "npm:^2.3.0" + domhandler: "npm:^5.0.2" + entities: "npm:^4.2.0" + checksum: 10c0/d5ae2b7110ca3746b3643d3ef60ef823f5f078667baf530cec096433f1627ec4b6fa8c072f09d079d7cda915fd2c7bc1b7b935681e9b09e591e1e15f4040b8e2 + languageName: node + linkType: hard + +"domelementtype@npm:^2.3.0": + version: 2.3.0 + resolution: "domelementtype@npm:2.3.0" + checksum: 10c0/686f5a9ef0fff078c1412c05db73a0dce096190036f33e400a07e2a4518e9f56b1e324f5c576a0a747ef0e75b5d985c040b0d51945ce780c0dd3c625a18cd8c9 + languageName: node + linkType: hard + "domexception@npm:^4.0.0": version: 4.0.0 resolution: "domexception@npm:4.0.0" @@ -11348,6 +13566,26 @@ __metadata: languageName: node linkType: hard +"domhandler@npm:^5.0.2, domhandler@npm:^5.0.3": + version: 5.0.3 + resolution: "domhandler@npm:5.0.3" + dependencies: + domelementtype: "npm:^2.3.0" + checksum: 10c0/bba1e5932b3e196ad6862286d76adc89a0dbf0c773e5ced1eb01f9af930c50093a084eff14b8de5ea60b895c56a04d5de8bbc4930c5543d029091916770b2d2a + languageName: node + linkType: hard + +"domutils@npm:^3.2.2": + version: 3.2.2 + resolution: "domutils@npm:3.2.2" + dependencies: + dom-serializer: "npm:^2.0.0" + domelementtype: "npm:^2.3.0" + domhandler: "npm:^5.0.3" + checksum: 10c0/47938f473b987ea71cd59e59626eb8666d3aa8feba5266e45527f3b636c7883cca7e582d901531961f742c519d7514636b7973353b648762b2e3bedbf235fada + languageName: node + linkType: hard + "dotenv-expand@npm:~11.0.6": version: 11.0.7 resolution: "dotenv-expand@npm:11.0.7" @@ -11433,6 +13671,13 @@ __metadata: languageName: node linkType: hard +"electron-to-chromium@npm:^1.5.393": + version: 1.5.399 + resolution: "electron-to-chromium@npm:1.5.399" + checksum: 10c0/5b2ab966e0748d00a2bc2dfd7eee203fe872ce2ca4478eea94f1ceda629172770378128cd6d20be4af8c21874ef250163cd47735e01f38f1182f4312d8c7e78b + languageName: node + linkType: hard + "electron-to-chromium@npm:^1.5.73": version: 1.5.141 resolution: "electron-to-chromium@npm:1.5.141" @@ -11468,6 +13713,13 @@ __metadata: languageName: node linkType: hard +"encodeurl@npm:^2.0.0, encodeurl@npm:~2.0.0": + version: 2.0.0 + resolution: "encodeurl@npm:2.0.0" + checksum: 10c0/5d317306acb13e6590e28e27924c754163946a2480de11865c991a3a7eed4315cd3fba378b543ca145829569eefe9b899f3d84bb09870f675ae60bc924b01ceb + languageName: node + linkType: hard + "encodeurl@npm:~1.0.2": version: 1.0.2 resolution: "encodeurl@npm:1.0.2" @@ -11475,13 +13727,6 @@ __metadata: languageName: node linkType: hard -"encodeurl@npm:~2.0.0": - version: 2.0.0 - resolution: "encodeurl@npm:2.0.0" - checksum: 10c0/5d317306acb13e6590e28e27924c754163946a2480de11865c991a3a7eed4315cd3fba378b543ca145829569eefe9b899f3d84bb09870f675ae60bc924b01ceb - languageName: node - linkType: hard - "encoding@npm:^0.1.13": version: 0.1.13 resolution: "encoding@npm:0.1.13" @@ -11510,7 +13755,7 @@ __metadata: languageName: node linkType: hard -"entities@npm:^4.4.0": +"entities@npm:^4.2.0, entities@npm:^4.4.0": version: 4.5.0 resolution: "entities@npm:4.5.0" checksum: 10c0/5b039739f7621f5d1ad996715e53d964035f75ad3b9a4d38c6b3804bb226e282ffeae2443624d8fdd9c47d8e926ae9ac009c54671243f0c3294c26af7cc85250 @@ -11524,6 +13769,20 @@ __metadata: languageName: node linkType: hard +"entities@npm:^7.0.1": + version: 7.0.1 + resolution: "entities@npm:7.0.1" + checksum: 10c0/b4fb9937bb47ecb00aaaceb9db9cdd1cc0b0fb649c0e843d05cf5dbbd2e9d2df8f98721d8b1b286445689c72af7b54a7242fc2d63ef7c9739037a8c73363e7ca + languageName: node + linkType: hard + +"entities@npm:^8.0.0": + version: 8.0.0 + resolution: "entities@npm:8.0.0" + checksum: 10c0/938e631664c19451823344a351aeeafd74fae2d5fa51e4d5b6ff635afaefd4bacf0f609989888c04c42733f46ffdac15211608267ebb02488005891a4793e94d + languageName: node + linkType: hard + "env-editor@npm:^0.4.1": version: 0.4.2 resolution: "env-editor@npm:0.4.2" @@ -11784,6 +14043,95 @@ __metadata: languageName: node linkType: hard +"esbuild@npm:0.28.1, esbuild@npm:^0.27.0 || ^0.28.0": + version: 0.28.1 + resolution: "esbuild@npm:0.28.1" + dependencies: + "@esbuild/aix-ppc64": "npm:0.28.1" + "@esbuild/android-arm": "npm:0.28.1" + "@esbuild/android-arm64": "npm:0.28.1" + "@esbuild/android-x64": "npm:0.28.1" + "@esbuild/darwin-arm64": "npm:0.28.1" + "@esbuild/darwin-x64": "npm:0.28.1" + "@esbuild/freebsd-arm64": "npm:0.28.1" + "@esbuild/freebsd-x64": "npm:0.28.1" + "@esbuild/linux-arm": "npm:0.28.1" + "@esbuild/linux-arm64": "npm:0.28.1" + "@esbuild/linux-ia32": "npm:0.28.1" + "@esbuild/linux-loong64": "npm:0.28.1" + "@esbuild/linux-mips64el": "npm:0.28.1" + "@esbuild/linux-ppc64": "npm:0.28.1" + "@esbuild/linux-riscv64": "npm:0.28.1" + "@esbuild/linux-s390x": "npm:0.28.1" + "@esbuild/linux-x64": "npm:0.28.1" + "@esbuild/netbsd-arm64": "npm:0.28.1" + "@esbuild/netbsd-x64": "npm:0.28.1" + "@esbuild/openbsd-arm64": "npm:0.28.1" + "@esbuild/openbsd-x64": "npm:0.28.1" + "@esbuild/openharmony-arm64": "npm:0.28.1" + "@esbuild/sunos-x64": "npm:0.28.1" + "@esbuild/win32-arm64": "npm:0.28.1" + "@esbuild/win32-ia32": "npm:0.28.1" + "@esbuild/win32-x64": "npm:0.28.1" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/openharmony-arm64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/29cd456a79ce35ac2c7e05fe871330416b2c395c045d849653f843e51378d6e0d6e774d6dcd01b35f4e83238a29bf8decd04fcd34b3780c589a250b21e5f92bb + languageName: node + linkType: hard + "esbuild@npm:^0.25.0": version: 0.25.3 resolution: "esbuild@npm:0.25.3" @@ -11877,7 +14225,7 @@ __metadata: languageName: node linkType: hard -"escape-html@npm:~1.0.3": +"escape-html@npm:^1.0.3, escape-html@npm:~1.0.3": version: 1.0.3 resolution: "escape-html@npm:1.0.3" checksum: 10c0/524c739d776b36c3d29fa08a22e03e8824e3b2fd57500e5e44ecf3cc4707c34c60f9ca0781c0e33d191f2991161504c295e98f68c78fe7baa6e57081ec6ac0a3 @@ -12573,7 +14921,7 @@ __metadata: languageName: node linkType: hard -"etag@npm:~1.8.1": +"etag@npm:^1.8.1, etag@npm:~1.8.1": version: 1.8.1 resolution: "etag@npm:1.8.1" checksum: 10c0/12be11ef62fb9817314d790089a0a49fae4e1b50594135dcb8076312b7d7e470884b5100d249b28c18581b7fd52f8b485689ffae22a11ed9ec17377a33a08f84 @@ -12601,6 +14949,22 @@ __metadata: languageName: node linkType: hard +"eventsource-parser@npm:^3.0.0, eventsource-parser@npm:^3.0.1": + version: 3.1.0 + resolution: "eventsource-parser@npm:3.1.0" + checksum: 10c0/5ab4c6c9a2a042be0b387b6d03810eb580bac4ce90e299ede56458125a97ffe3af8145b2740089fc898a96cfa5aae792ee79f2a06257fba2776b0e7bce037071 + languageName: node + linkType: hard + +"eventsource@npm:^3.0.2": + version: 3.0.7 + resolution: "eventsource@npm:3.0.7" + dependencies: + eventsource-parser: "npm:^3.0.1" + checksum: 10c0/c48a73c38f300e33e9f11375d4ee969f25cbb0519608a12378a38068055ae8b55b6e0e8a49c3f91c784068434efe1d9f01eb49b6315b04b0da9157879ce2f67d + languageName: node + linkType: hard + "execa@npm:^4.0.3": version: 4.1.0 resolution: "execa@npm:4.1.0" @@ -12993,6 +15357,54 @@ __metadata: languageName: node linkType: hard +"express-rate-limit@npm:^8.2.1": + version: 8.6.1 + resolution: "express-rate-limit@npm:8.6.1" + dependencies: + debug: "npm:^4.4.3" + ip-address: "npm:^10.2.0" + peerDependencies: + express: ">= 4.11" + checksum: 10c0/cb0e30283ef48925d8fe9efce6337a5877bb9a581f32f2594b08303dc075c24f1a52187e403c7f9889888d99eaabd9ac22182d2dadbd0b8acf3725e818131052 + languageName: node + linkType: hard + +"express@npm:^5.2.1": + version: 5.2.1 + resolution: "express@npm:5.2.1" + dependencies: + accepts: "npm:^2.0.0" + body-parser: "npm:^2.2.1" + content-disposition: "npm:^1.0.0" + content-type: "npm:^1.0.5" + cookie: "npm:^0.7.1" + cookie-signature: "npm:^1.2.1" + debug: "npm:^4.4.0" + depd: "npm:^2.0.0" + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + etag: "npm:^1.8.1" + finalhandler: "npm:^2.1.0" + fresh: "npm:^2.0.0" + http-errors: "npm:^2.0.0" + merge-descriptors: "npm:^2.0.0" + mime-types: "npm:^3.0.0" + on-finished: "npm:^2.4.1" + once: "npm:^1.4.0" + parseurl: "npm:^1.3.3" + proxy-addr: "npm:^2.0.7" + qs: "npm:^6.14.0" + range-parser: "npm:^1.2.1" + router: "npm:^2.2.0" + send: "npm:^1.1.0" + serve-static: "npm:^2.2.0" + statuses: "npm:^2.0.1" + type-is: "npm:^2.0.1" + vary: "npm:^1.1.2" + checksum: 10c0/45e8c841ad188a41402ddcd1294901e861ee0819f632fb494f2ed344ef9c43315d294d443fb48d594e6586a3b779785120f43321417adaef8567316a55072949 + languageName: node + linkType: hard + "fake-mediastreamtrack@npm:^2.0.0": version: 2.0.0 resolution: "fake-mediastreamtrack@npm:2.0.0" @@ -13071,6 +15483,13 @@ __metadata: languageName: node linkType: hard +"fast-uri@npm:^3.0.1": + version: 3.1.5 + resolution: "fast-uri@npm:3.1.5" + checksum: 10c0/2bf60eb800dd610c65e17be436425dcb21c92aff3a87d442a8bccab0b7b071e88cf1a5d7d1ea946370b937e6fc0375c405c0296c10587e57de4f78be4646d1d0 + languageName: node + linkType: hard + "fastq@npm:^1.6.0": version: 1.19.1 resolution: "fastq@npm:1.19.1" @@ -13193,6 +15612,20 @@ __metadata: languageName: node linkType: hard +"finalhandler@npm:^2.1.0": + version: 2.1.1 + resolution: "finalhandler@npm:2.1.1" + dependencies: + debug: "npm:^4.4.0" + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + on-finished: "npm:^2.4.1" + parseurl: "npm:^1.3.3" + statuses: "npm:^2.0.1" + checksum: 10c0/6bd664e21b7b2e79efcaace7d1a427169f61cce048fae68eb56290e6934e676b78e55d89f5998c5508871345bc59a61f47002dc505dc7288be68cceac1b701e2 + languageName: node + linkType: hard + "find-babel-config@npm:^2.1.1": version: 2.1.2 resolution: "find-babel-config@npm:2.1.2" @@ -13366,6 +15799,13 @@ __metadata: languageName: node linkType: hard +"forwarded@npm:0.2.0": + version: 0.2.0 + resolution: "forwarded@npm:0.2.0" + checksum: 10c0/9b67c3fac86acdbc9ae47ba1ddd5f2f81526fa4c8226863ede5600a3f7c7416ef451f6f1e240a3cc32d0fd79fcfe6beb08fd0da454f360032bde70bf80afbb33 + languageName: node + linkType: hard + "fraction.js@npm:^4.3.7": version: 4.3.7 resolution: "fraction.js@npm:4.3.7" @@ -13380,6 +15820,13 @@ __metadata: languageName: node linkType: hard +"fresh@npm:^2.0.0": + version: 2.0.0 + resolution: "fresh@npm:2.0.0" + checksum: 10c0/0557548194cb9a809a435bf92bcfbc20c89e8b5eb38861b73ced36750437251e39a111fc3a18b98531be9dd91fe1411e4969f229dc579ec0251ce6c5d4900bbc + languageName: node + linkType: hard + "fresh@npm:~0.5.2": version: 0.5.2 resolution: "fresh@npm:0.5.2" @@ -13541,6 +15988,13 @@ __metadata: languageName: node linkType: hard +"get-east-asian-width@npm:^1.5.0": + version: 1.6.0 + resolution: "get-east-asian-width@npm:1.6.0" + checksum: 10c0/7e72e9550fd49ca5b246f9af6bb2afc129c96412845ff6556b3274fd44817a381702ca17028efe9866b261a3d44254cbf21e6c90cf05b4b61675630af776d431 + languageName: node + linkType: hard + "get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.2.7, get-intrinsic@npm:^1.3.0": version: 1.3.0 resolution: "get-intrinsic@npm:1.3.0" @@ -13660,6 +16114,13 @@ __metadata: languageName: node linkType: hard +"glob-to-regexp@npm:^0.4.1": + version: 0.4.1 + resolution: "glob-to-regexp@npm:0.4.1" + checksum: 10c0/0486925072d7a916f052842772b61c3e86247f0a80cc0deb9b5a3e8a1a9faad5b04fb6f58986a09f34d3e96cd2a22a24b7e9882fb1cf904c31e9a310de96c429 + languageName: node + linkType: hard + "glob@npm:^10.0.0, glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.4.1": version: 10.4.5 resolution: "glob@npm:10.4.5" @@ -13810,7 +16271,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": +"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 @@ -13984,6 +16445,13 @@ __metadata: languageName: node linkType: hard +"hono@npm:^4.11.4": + version: 4.12.34 + resolution: "hono@npm:4.12.34" + checksum: 10c0/9d184cb95bc630622d3a8885706feb8e98170556650ce139f09e54c96b9429b3194a296ad4fb0791e7b642a21a03acbf592cb0d19d906ab5e8b78a13337d61bc + languageName: node + linkType: hard + "hosted-git-info@npm:^7.0.0": version: 7.0.2 resolution: "hosted-git-info@npm:7.0.2" @@ -13993,6 +16461,15 @@ __metadata: languageName: node linkType: hard +"hosted-git-info@npm:^9.0.0": + version: 9.0.3 + resolution: "hosted-git-info@npm:9.0.3" + dependencies: + lru-cache: "npm:^11.1.0" + checksum: 10c0/8f216ccb461ca54ae1846745325ced6a880b53101a58c820b813f067caa301576bf974f787df5688b7f0f1b752cdfcbaa2979751d1fcfd604032cc57bc538607 + languageName: node + linkType: hard + "html-encoding-sniffer@npm:^3.0.0": version: 3.0.0 resolution: "html-encoding-sniffer@npm:3.0.0" @@ -14018,6 +16495,18 @@ __metadata: languageName: node linkType: hard +"htmlparser2@npm:^10.0.0": + version: 10.1.0 + resolution: "htmlparser2@npm:10.1.0" + dependencies: + domelementtype: "npm:^2.3.0" + domhandler: "npm:^5.0.3" + domutils: "npm:^3.2.2" + entities: "npm:^7.0.1" + checksum: 10c0/36394e29b80cfcc5e78e0fa4d3aa21fdaac3e6778d23e5c933e625c290987cd9a724a2eb0753ab60ed0c69dfaba0ab115f0ee50fb112fd8f0c4d522e7e0089a2 + languageName: node + linkType: hard + "http-cache-semantics@npm:^4.1.1": version: 4.1.1 resolution: "http-cache-semantics@npm:4.1.1" @@ -14025,7 +16514,7 @@ __metadata: languageName: node linkType: hard -"http-errors@npm:~2.0.1": +"http-errors@npm:^2.0.0, http-errors@npm:^2.0.1, http-errors@npm:~2.0.1": version: 2.0.1 resolution: "http-errors@npm:2.0.1" dependencies: @@ -14059,23 +16548,23 @@ __metadata: languageName: node linkType: hard -"https-proxy-agent@npm:^5.0.1": - version: 5.0.1 - resolution: "https-proxy-agent@npm:5.0.1" +"https-proxy-agent@npm:7.0.6, https-proxy-agent@npm:^7.0.1, https-proxy-agent@npm:^7.0.5, https-proxy-agent@npm:^7.0.6": + version: 7.0.6 + resolution: "https-proxy-agent@npm:7.0.6" dependencies: - agent-base: "npm:6" + agent-base: "npm:^7.1.2" debug: "npm:4" - checksum: 10c0/6dd639f03434003577c62b27cafdb864784ef19b2de430d8ae2a1d45e31c4fd60719e5637b44db1a88a046934307da7089e03d6089ec3ddacc1189d8de8897d1 + checksum: 10c0/f729219bc735edb621fa30e6e84e60ee5d00802b8247aac0d7b79b0bd6d4b3294737a337b93b86a0bd9e68099d031858a39260c976dc14cdbba238ba1f8779ac languageName: node linkType: hard -"https-proxy-agent@npm:^7.0.1, https-proxy-agent@npm:^7.0.5, https-proxy-agent@npm:^7.0.6": - version: 7.0.6 - resolution: "https-proxy-agent@npm:7.0.6" +"https-proxy-agent@npm:^5.0.1": + version: 5.0.1 + resolution: "https-proxy-agent@npm:5.0.1" dependencies: - agent-base: "npm:^7.1.2" + agent-base: "npm:6" debug: "npm:4" - checksum: 10c0/f729219bc735edb621fa30e6e84e60ee5d00802b8247aac0d7b79b0bd6d4b3294737a337b93b86a0bd9e68099d031858a39260c976dc14cdbba238ba1f8779ac + checksum: 10c0/6dd639f03434003577c62b27cafdb864784ef19b2de430d8ae2a1d45e31c4fd60719e5637b44db1a88a046934307da7089e03d6089ec3ddacc1189d8de8897d1 languageName: node linkType: hard @@ -14118,6 +16607,15 @@ __metadata: languageName: node linkType: hard +"iconv-lite@npm:^0.7.0, iconv-lite@npm:^0.7.2, iconv-lite@npm:~0.7.0": + version: 0.7.3 + resolution: "iconv-lite@npm:0.7.3" + dependencies: + safer-buffer: "npm:>= 2.1.2 < 3.0.0" + checksum: 10c0/be2fd2414f7e94be3a63063fa0ad5919c16bc7b6e00c77eea0765ced6db405739f38dd51016583058fba25cc1d0106ba30b83fbb7a7e32f824d4db76f23d8d33 + languageName: node + linkType: hard + "ieee754@npm:^1.1.13, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" @@ -14125,6 +16623,15 @@ __metadata: languageName: node linkType: hard +"ignore-walk@npm:^8.0.0": + version: 8.0.0 + resolution: "ignore-walk@npm:8.0.0" + dependencies: + minimatch: "npm:^10.0.3" + checksum: 10c0/fec71d904adaaf233f2f5a67cc547857d960abe1f41a8b43f675617a322aabe9401fb9afa13aba825d21d91c454d5cad72ecba156e69443f3df40288d6ebd058 + languageName: node + linkType: hard + "ignore@npm:^5.1.1, ignore@npm:^5.2.0, ignore@npm:^5.3.1, ignore@npm:^5.3.2": version: 5.3.2 resolution: "ignore@npm:5.3.2" @@ -14150,6 +16657,13 @@ __metadata: languageName: node linkType: hard +"immutable@npm:^5.0.2": + version: 5.1.9 + resolution: "immutable@npm:5.1.9" + checksum: 10c0/ae7032b60b81b682f3e7e4df13e1ec9a401a603eb81b15bb3e37331ed6666e318c71994c6936e1462eb443d32d04769396562a7e2669da22457c8ba279c03ad2 + languageName: node + linkType: hard + "import-fresh@npm:^3.2.1, import-fresh@npm:^3.3.1": version: 3.3.1 resolution: "import-fresh@npm:3.3.1" @@ -14205,6 +16719,20 @@ __metadata: languageName: node linkType: hard +"ini@npm:5.0.0": + version: 5.0.0 + resolution: "ini@npm:5.0.0" + checksum: 10c0/657491ce766cbb4b335ab221ee8f72b9654d9f0e35c32fe5ff2eb7ab8c5ce72237ff6456555b50cde88e6507a719a70e28e327b450782b4fc20c90326ec8c1a8 + languageName: node + linkType: hard + +"ini@npm:^6.0.0": + version: 6.0.0 + resolution: "ini@npm:6.0.0" + checksum: 10c0/9a7f55f306e2b25b41ae67c8b526e8f4673f057b70852b9025816ef4f15f07bf1ba35ed68ea4471ff7b31718f7ef1bc50d709f8d03cb012e10a3135eb99c7206 + languageName: node + linkType: hard + "ini@npm:~1.3.0": version: 1.3.8 resolution: "ini@npm:1.3.8" @@ -14241,6 +16769,13 @@ __metadata: languageName: node linkType: hard +"ip-address@npm:^10.2.0": + version: 10.4.0 + resolution: "ip-address@npm:10.4.0" + checksum: 10c0/d7b0bd2624fd861afbae6e49036a9b56f9506eaff7ff38592b7b4492dd5c272b53f5356dc8cc019e318acf29a96c90a3d1af1df761960267d23efa96858270fa + languageName: node + linkType: hard + "ip-address@npm:^9.0.5": version: 9.0.5 resolution: "ip-address@npm:9.0.5" @@ -14251,6 +16786,13 @@ __metadata: languageName: node linkType: hard +"ipaddr.js@npm:1.9.1": + version: 1.9.1 + resolution: "ipaddr.js@npm:1.9.1" + checksum: 10c0/0486e775047971d3fdb5fb4f063829bac45af299ae0b82dcf3afa2145338e08290563a2a70f34b732d795ecc8311902e541a8530eeb30d75860a78ff4e94ce2a + languageName: node + linkType: hard + "is-absolute@npm:^1.0.0": version: 1.0.0 resolution: "is-absolute@npm:1.0.0" @@ -14462,6 +17004,13 @@ __metadata: languageName: node linkType: hard +"is-interactive@npm:^2.0.0": + version: 2.0.0 + resolution: "is-interactive@npm:2.0.0" + checksum: 10c0/801c8f6064f85199dc6bf99b5dd98db3282e930c3bc197b32f2c5b89313bb578a07d1b8a01365c4348c2927229234f3681eb861b9c2c92bee72ff397390fa600 + languageName: node + linkType: hard + "is-map@npm:^2.0.3": version: 2.0.3 resolution: "is-map@npm:2.0.3" @@ -14514,6 +17063,13 @@ __metadata: languageName: node linkType: hard +"is-promise@npm:^4.0.0": + version: 4.0.0 + resolution: "is-promise@npm:4.0.0" + checksum: 10c0/ebd5c672d73db781ab33ccb155fb9969d6028e37414d609b115cc534654c91ccd061821d5b987eefaa97cf4c62f0b909bb2f04db88306de26e91bfe8ddc01503 + languageName: node + linkType: hard + "is-regex@npm:^1.2.1": version: 1.2.1 resolution: "is-regex@npm:1.2.1" @@ -14604,6 +17160,20 @@ __metadata: languageName: node linkType: hard +"is-unicode-supported@npm:^1.3.0": + version: 1.3.0 + resolution: "is-unicode-supported@npm:1.3.0" + checksum: 10c0/b8674ea95d869f6faabddc6a484767207058b91aea0250803cbf1221345cb0c56f466d4ecea375dc77f6633d248d33c47bd296fb8f4cdba0b4edba8917e83d8a + languageName: node + linkType: hard + +"is-unicode-supported@npm:^2.0.0": + version: 2.1.0 + resolution: "is-unicode-supported@npm:2.1.0" + checksum: 10c0/a0f53e9a7c1fdbcf2d2ef6e40d4736fdffff1c9f8944c75e15425118ff3610172c87bf7bc6c34d3903b04be59790bb2212ddbe21ee65b5a97030fc50370545a5 + languageName: node + linkType: hard + "is-weakmap@npm:^2.0.2": version: 2.0.2 resolution: "is-weakmap@npm:2.0.2" @@ -14674,6 +17244,13 @@ __metadata: languageName: node linkType: hard +"isexe@npm:^4.0.0": + version: 4.0.0 + resolution: "isexe@npm:4.0.0" + checksum: 10c0/5884815115bceac452877659a9c7726382531592f43dc29e5d48b7c4100661aed54018cb90bd36cb2eaeba521092570769167acbb95c18d39afdccbcca06c5ce + languageName: node + linkType: hard + "istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.0, istanbul-lib-coverage@npm:^3.2.2": version: 3.2.2 resolution: "istanbul-lib-coverage@npm:3.2.2" @@ -14681,6 +17258,19 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-instrument@npm:6.0.3": + version: 6.0.3 + resolution: "istanbul-lib-instrument@npm:6.0.3" + dependencies: + "@babel/core": "npm:^7.23.9" + "@babel/parser": "npm:^7.23.9" + "@istanbuljs/schema": "npm:^0.1.3" + istanbul-lib-coverage: "npm:^3.2.0" + semver: "npm:^7.5.4" + checksum: 10c0/a1894e060dd2a3b9f046ffdc87b44c00a35516f5e6b7baf4910369acca79e506fc5323a816f811ae23d82334b38e3ddeb8b3b331bd2c860540793b59a8689128 + languageName: node + linkType: hard + "istanbul-lib-instrument@npm:^5.0.4": version: 5.2.1 resolution: "istanbul-lib-instrument@npm:5.2.1" @@ -15082,6 +17672,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^6.1.3": + version: 6.2.8 + resolution: "jose@npm:6.2.8" + checksum: 10c0/fe4c6a7197a1adab2251b000c1eaaa8b40b6cb7cfbef43dbc9e959dacd43bf2532827b2d5e0f4069731da79d26e4d9e4368ccb26559a42a884b3fe036927e6e1 + languageName: node + linkType: hard + "joycon@npm:^3.1.1": version: 3.1.1 resolution: "joycon@npm:3.1.1" @@ -15228,6 +17825,13 @@ __metadata: languageName: node linkType: hard +"json-parse-even-better-errors@npm:^5.0.0": + version: 5.0.0 + resolution: "json-parse-even-better-errors@npm:5.0.0" + checksum: 10c0/9a33d120090a7637a2aa850acec610c011d7c6488c5184d7ffc0460ee0401057f3131a4dff70c6510900cf15a95ab99d3f0f2d959f59edfe6438d227e90bf5ca + languageName: node + linkType: hard + "json-schema-traverse@npm:^0.4.1": version: 0.4.1 resolution: "json-schema-traverse@npm:0.4.1" @@ -15235,6 +17839,20 @@ __metadata: languageName: node linkType: hard +"json-schema-traverse@npm:^1.0.0": + version: 1.0.0 + resolution: "json-schema-traverse@npm:1.0.0" + checksum: 10c0/71e30015d7f3d6dc1c316d6298047c8ef98a06d31ad064919976583eb61e1018a60a0067338f0f79cabc00d84af3fcc489bd48ce8a46ea165d9541ba17fb30c6 + languageName: node + linkType: hard + +"json-schema-typed@npm:^8.0.2": + version: 8.0.2 + resolution: "json-schema-typed@npm:8.0.2" + checksum: 10c0/89f5e2fb1495483b705c027203c07277ee6bf2665165ad25a9cb55de5af7f72570326d13d32565180781e4083ad5c9688102f222baed7b353c2f39c1e02b0428 + languageName: node + linkType: hard + "json-stable-stringify-without-jsonify@npm:^1.0.1": version: 1.0.1 resolution: "json-stable-stringify-without-jsonify@npm:1.0.1" @@ -15262,6 +17880,13 @@ __metadata: languageName: node linkType: hard +"jsonc-parser@npm:3.3.1": + version: 3.3.1 + resolution: "jsonc-parser@npm:3.3.1" + checksum: 10c0/269c3ae0a0e4f907a914bf334306c384aabb9929bd8c99f909275ebd5c2d3bc70b9bcd119ad794f339dec9f24b6a4ee9cd5a8ab2e6435e730ad4075388fc2ab6 + languageName: node + linkType: hard + "jsonfile@npm:^6.0.1": version: 6.2.1 resolution: "jsonfile@npm:6.2.1" @@ -15275,6 +17900,13 @@ __metadata: languageName: node linkType: hard +"jsonparse@npm:^1.3.1": + version: 1.3.1 + resolution: "jsonparse@npm:1.3.1" + checksum: 10c0/89bc68080cd0a0e276d4b5ab1b79cacd68f562467008d176dc23e16e97d4efec9e21741d92ba5087a8433526a45a7e6a9d5ef25408696c402ca1cfbc01a90bf0 + languageName: node + linkType: hard + "jsx-ast-utils@npm:^2.4.1 || ^3.0.0": version: 3.3.5 resolution: "jsx-ast-utils@npm:3.3.5" @@ -15535,6 +18167,20 @@ __metadata: languageName: node linkType: hard +"listr2@npm:9.0.1": + version: 9.0.1 + resolution: "listr2@npm:9.0.1" + dependencies: + cli-truncate: "npm:^4.0.0" + colorette: "npm:^2.0.20" + eventemitter3: "npm:^5.0.1" + log-update: "npm:^6.1.0" + rfdc: "npm:^1.4.1" + wrap-ansi: "npm:^9.0.0" + checksum: 10c0/73462e84a3c4f05de5a3cdea5eaa0209c6ab04a2fdb4046545049806e9ba17b6ee84a097ebf7ffc0e903b0f2a9094c0c480cd2f2bb21d7d21e20969e17a3c32b + languageName: node + linkType: hard + "listr2@npm:^8.2.5": version: 8.3.2 resolution: "listr2@npm:8.3.2" @@ -15563,6 +18209,44 @@ __metadata: languageName: node linkType: hard +"lmdb@npm:3.4.2": + version: 3.4.2 + resolution: "lmdb@npm:3.4.2" + dependencies: + "@lmdb/lmdb-darwin-arm64": "npm:3.4.2" + "@lmdb/lmdb-darwin-x64": "npm:3.4.2" + "@lmdb/lmdb-linux-arm": "npm:3.4.2" + "@lmdb/lmdb-linux-arm64": "npm:3.4.2" + "@lmdb/lmdb-linux-x64": "npm:3.4.2" + "@lmdb/lmdb-win32-arm64": "npm:3.4.2" + "@lmdb/lmdb-win32-x64": "npm:3.4.2" + msgpackr: "npm:^1.11.2" + node-addon-api: "npm:^6.1.0" + node-gyp: "npm:latest" + node-gyp-build-optional-packages: "npm:5.2.2" + ordered-binary: "npm:^1.5.3" + weak-lru-cache: "npm:^1.2.2" + dependenciesMeta: + "@lmdb/lmdb-darwin-arm64": + optional: true + "@lmdb/lmdb-darwin-x64": + optional: true + "@lmdb/lmdb-linux-arm": + optional: true + "@lmdb/lmdb-linux-arm64": + optional: true + "@lmdb/lmdb-linux-x64": + optional: true + "@lmdb/lmdb-win32-arm64": + optional: true + "@lmdb/lmdb-win32-x64": + optional: true + bin: + download-lmdb-prebuilds: bin/download-prebuilds.js + checksum: 10c0/6bfbcb837d6997a825672bf69c22509dc30aadebbca2b1f7726a6a99ffa20d7d48db99cba9842f8c957d86ac6f875f673dda14f3969f8ab846e46537ff5d203c + languageName: node + linkType: hard + "load-tsconfig@npm:^0.2.3": version: 0.2.5 resolution: "load-tsconfig@npm:0.2.5" @@ -15663,6 +18347,16 @@ __metadata: languageName: node linkType: hard +"log-symbols@npm:^6.0.0": + version: 6.0.0 + resolution: "log-symbols@npm:6.0.0" + dependencies: + chalk: "npm:^5.3.0" + is-unicode-supported: "npm:^1.3.0" + checksum: 10c0/36636cacedba8f067d2deb4aad44e91a89d9efb3ead27e1846e7b82c9a10ea2e3a7bd6ce28a7ca616bebc60954ff25c67b0f92d20a6a746bb3cc52c3701891f6 + languageName: node + linkType: hard + "log-update@npm:^6.1.0": version: 6.1.0 resolution: "log-update@npm:6.1.0" @@ -15715,6 +18409,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1": + version: 11.5.2 + resolution: "lru-cache@npm:11.5.2" + checksum: 10c0/ece1ad731f5b655e85d67047d04bfc13823dc77aa61c5454924a9869ba600a0104e39cc33d726021feef812bc347ca34a5608a5eb1972a5dd0870b7ecd42c3f2 + languageName: node + linkType: hard + "lru-cache@npm:^5.1.1": version: 5.1.1 resolution: "lru-cache@npm:5.1.1" @@ -15740,7 +18441,7 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.30.17": +"magic-string@npm:0.30.17, magic-string@npm:^0.30.17": version: 0.30.17 resolution: "magic-string@npm:0.30.17" dependencies: @@ -15814,6 +18515,26 @@ __metadata: languageName: node linkType: hard +"make-fetch-happen@npm:^15.0.0, make-fetch-happen@npm:^15.0.1, make-fetch-happen@npm:^15.0.4": + version: 15.0.6 + resolution: "make-fetch-happen@npm:15.0.6" + dependencies: + "@gar/promise-retry": "npm:^1.0.0" + "@npmcli/agent": "npm:^4.0.0" + "@npmcli/redact": "npm:^4.0.0" + cacache: "npm:^20.0.1" + http-cache-semantics: "npm:^4.1.1" + minipass: "npm:^7.0.2" + minipass-fetch: "npm:^5.0.0" + minipass-flush: "npm:^1.0.5" + minipass-pipeline: "npm:^1.2.4" + negotiator: "npm:^1.0.0" + proc-log: "npm:^6.0.0" + ssri: "npm:^13.0.0" + checksum: 10c0/2c5805dee83efd1cd1d3f57505120ae98f4a328be72d82447e24b8f72b8e5475910d7dbc49d7da1c5bd96a62bf8ef6ffda88ebadfdfbec7c715cfde2459c9295 + languageName: node + linkType: hard + "makeerror@npm:1.0.12": version: 1.0.12 resolution: "makeerror@npm:1.0.12" @@ -15860,6 +18581,13 @@ __metadata: languageName: node linkType: hard +"media-typer@npm:^1.1.0": + version: 1.1.1 + resolution: "media-typer@npm:1.1.1" + checksum: 10c0/924644de220c3107fc53ec0299b7c1488503470265d32f1492535bd798cc369ba7ceaf9c2a4533361590dcf7208d1cc7b7032dbc1a5918773f5f5d912cab5211 + languageName: node + linkType: hard + "memoize-one@npm:^5.0.0": version: 5.2.1 resolution: "memoize-one@npm:5.2.1" @@ -15874,6 +18602,13 @@ __metadata: languageName: node linkType: hard +"merge-descriptors@npm:^2.0.0": + version: 2.0.0 + resolution: "merge-descriptors@npm:2.0.0" + checksum: 10c0/95389b7ced3f9b36fbdcf32eb946dc3dd1774c2fdf164609e55b18d03aa499b12bd3aae3a76c1c7185b96279e9803525550d3eb292b5224866060a288f335cb3 + languageName: node + linkType: hard + "merge-stream@npm:^2.0.0": version: 2.0.0 resolution: "merge-stream@npm:2.0.0" @@ -16370,7 +19105,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^3.0.0, mime-types@npm:^3.0.1": +"mime-types@npm:^3.0.0, mime-types@npm:^3.0.1, mime-types@npm:^3.0.2": version: 3.0.2 resolution: "mime-types@npm:3.0.2" dependencies: @@ -16423,6 +19158,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^10.0.3": + version: 10.2.6 + resolution: "minimatch@npm:10.2.6" + dependencies: + brace-expansion: "npm:^5.0.8" + checksum: 10c0/4559a836243b98bd4d17ea9f7edae698717c76399eea7be374f3737f33164e4907f19e9726891ddeb122f750a5a7fa80d2ac43e851d6e5984dc4ff42ec127d3a + languageName: node + linkType: hard + "minimatch@npm:^10.1.1": version: 10.1.1 resolution: "minimatch@npm:10.1.1" @@ -16526,6 +19270,21 @@ __metadata: languageName: node linkType: hard +"minipass-fetch@npm:^5.0.0": + version: 5.0.2 + resolution: "minipass-fetch@npm:5.0.2" + dependencies: + iconv-lite: "npm:^0.7.2" + minipass: "npm:^7.0.3" + minipass-sized: "npm:^2.0.0" + minizlib: "npm:^3.0.1" + dependenciesMeta: + iconv-lite: + optional: true + checksum: 10c0/ce4ab9f21cfabaead2097d95dd33f485af8072fbc6b19611bce694965393453a1639d641c2bcf1c48f2ea7d41ea7fab8278373f1d0bee4e63b0a5b2cdd0ef649 + languageName: node + linkType: hard + "minipass-flush@npm:^1.0.5": version: 1.0.5 resolution: "minipass-flush@npm:1.0.5" @@ -16553,6 +19312,15 @@ __metadata: languageName: node linkType: hard +"minipass-sized@npm:^2.0.0": + version: 2.0.0 + resolution: "minipass-sized@npm:2.0.0" + dependencies: + minipass: "npm:^7.1.2" + checksum: 10c0/f9201696a6f6d68610d04c9c83e3d2e5cb9c026aae1c8cbf7e17f386105cb79c1bb088dbc21bf0b1eb4f3fb5df384fd1e7aa3bf1f33868c416ae8c8a92679db8 + languageName: node + linkType: hard + "minipass@npm:^3.0.0": version: 3.3.6 resolution: "minipass@npm:3.3.6" @@ -16619,6 +19387,13 @@ __metadata: languageName: node linkType: hard +"mrmime@npm:2.0.1": + version: 2.0.1 + resolution: "mrmime@npm:2.0.1" + checksum: 10c0/af05afd95af202fdd620422f976ad67dc18e6ee29beb03dd1ce950ea6ef664de378e44197246df4c7cdd73d47f2e7143a6e26e473084b9e4aa2095c0ad1e1761 + languageName: node + linkType: hard + "ms@npm:2.0.0": version: 2.0.0 resolution: "ms@npm:2.0.0" @@ -16640,6 +19415,56 @@ __metadata: languageName: node linkType: hard +"msgpackr-extract@npm:^3.0.2": + version: 3.0.4 + resolution: "msgpackr-extract@npm:3.0.4" + dependencies: + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "npm:3.0.4" + "@msgpackr-extract/msgpackr-extract-darwin-x64": "npm:3.0.4" + "@msgpackr-extract/msgpackr-extract-linux-arm": "npm:3.0.4" + "@msgpackr-extract/msgpackr-extract-linux-arm64": "npm:3.0.4" + "@msgpackr-extract/msgpackr-extract-linux-x64": "npm:3.0.4" + "@msgpackr-extract/msgpackr-extract-win32-x64": "npm:3.0.4" + node-gyp: "npm:latest" + node-gyp-build-optional-packages: "npm:5.2.2" + dependenciesMeta: + "@msgpackr-extract/msgpackr-extract-darwin-arm64": + optional: true + "@msgpackr-extract/msgpackr-extract-darwin-x64": + optional: true + "@msgpackr-extract/msgpackr-extract-linux-arm": + optional: true + "@msgpackr-extract/msgpackr-extract-linux-arm64": + optional: true + "@msgpackr-extract/msgpackr-extract-linux-x64": + optional: true + "@msgpackr-extract/msgpackr-extract-win32-x64": + optional: true + bin: + download-msgpackr-prebuilds: bin/download-prebuilds.js + checksum: 10c0/582a9d17abbf3019e600e948736695056280ce401fd0235ee2474e95f9952208b9f6cce4d0e355b03b7d3c5630e6c3d11fe5fc27fdedb2311cce48de464338d8 + languageName: node + linkType: hard + +"msgpackr@npm:^1.11.2": + version: 1.12.1 + resolution: "msgpackr@npm:1.12.1" + dependencies: + msgpackr-extract: "npm:^3.0.2" + dependenciesMeta: + msgpackr-extract: + optional: true + checksum: 10c0/145a6477ff46ad3154b999ad9f1d80070f50b01f5a12975aad683455fa8968f3e12503d8397a8828bc15fd297da9694fc7b8ae9f21d3df64f559caa10a3a4731 + languageName: node + linkType: hard + +"mute-stream@npm:^2.0.0": + version: 2.0.0 + resolution: "mute-stream@npm:2.0.0" + checksum: 10c0/2cf48a2087175c60c8dcdbc619908b49c07f7adcfc37d29236b0c5c612d6204f789104c98cc44d38acab7b3c96f4a3ec2cfdc4934d0738d876dbefa2a12c69f4 + languageName: node + linkType: hard + "mz@npm:^2.7.0": version: 2.7.0 resolution: "mz@npm:2.7.0" @@ -16676,6 +19501,15 @@ __metadata: languageName: node linkType: hard +"nanoid@npm:^3.3.16": + version: 3.3.17 + resolution: "nanoid@npm:3.3.17" + bin: + nanoid: bin/nanoid.cjs + checksum: 10c0/06f7949c7cce5c92c6aea66022f29a1eae7f56e05acbfddf1e049e819b4bf234c44a765cc44da7163482b111677948514012e27acdfa56f95309295768bdae32 + languageName: node + linkType: hard + "nanoid@npm:^3.3.8": version: 3.3.11 resolution: "nanoid@npm:3.3.11" @@ -16729,6 +19563,24 @@ __metadata: languageName: node linkType: hard +"node-addon-api@npm:^6.1.0": + version: 6.1.0 + resolution: "node-addon-api@npm:6.1.0" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/d2699c4ad15740fd31482a3b6fca789af7723ab9d393adc6ac45250faaee72edad8f0b10b2b9d087df0de93f1bdc16d97afdd179b26b9ebc9ed68b569faa4bac + languageName: node + linkType: hard + +"node-addon-api@npm:^7.0.0": + version: 7.1.1 + resolution: "node-addon-api@npm:7.1.1" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/fb32a206276d608037fa1bcd7e9921e177fe992fc610d098aa3128baca3c0050fc1e014fa007e9b3874cf865ddb4f5bd9f43ccb7cbbbe4efaff6a83e920b17e9 + languageName: node + linkType: hard + "node-fetch@npm:^2.7.0": version: 2.7.0 resolution: "node-fetch@npm:2.7.0" @@ -16750,6 +19602,39 @@ __metadata: languageName: node linkType: hard +"node-gyp-build-optional-packages@npm:5.2.2": + version: 5.2.2 + resolution: "node-gyp-build-optional-packages@npm:5.2.2" + dependencies: + detect-libc: "npm:^2.0.1" + bin: + node-gyp-build-optional-packages: bin.js + node-gyp-build-optional-packages-optional: optional.js + node-gyp-build-optional-packages-test: build-test.js + checksum: 10c0/c81128c6f91873381be178c5eddcbdf66a148a6a89a427ce2bcd457593ce69baf2a8662b6d22cac092d24aa9c43c230dec4e69b3a0da604503f4777cd77e282b + languageName: node + linkType: hard + +"node-gyp@npm:^12.1.0": + version: 12.4.0 + resolution: "node-gyp@npm:12.4.0" + dependencies: + env-paths: "npm:^2.2.0" + exponential-backoff: "npm:^3.1.1" + graceful-fs: "npm:^4.2.6" + nopt: "npm:^9.0.0" + proc-log: "npm:^6.0.0" + semver: "npm:^7.3.5" + tar: "npm:^7.5.4" + tinyglobby: "npm:^0.2.12" + undici: "npm:^6.25.0" + which: "npm:^6.0.0" + bin: + node-gyp: bin/node-gyp.js + checksum: 10c0/9acb7c798e124275a6f9c1f7eb64b5abd6196bb885a3945fb44ee0dccf435514e88cdfb0f228ee7ff76ef25107c1f39ff37a067bf92fd00b9aff9234db29ff9e + languageName: node + linkType: hard + "node-gyp@npm:latest": version: 11.2.0 resolution: "node-gyp@npm:11.2.0" @@ -16805,7 +19690,7 @@ __metadata: languageName: node linkType: hard -"node-releases@npm:^2.0.50": +"node-releases@npm:^2.0.50, node-releases@npm:^2.0.51": version: 2.0.51 resolution: "node-releases@npm:2.0.51" checksum: 10c0/6cf3fe1f9eabe02ce6de67e9db77b73edc9f5625003791e1f8f239f8e2a851450a5aeb1eff2cc43cfb26312e19b26bfe07626bf428479e6a76f56553fc6148da @@ -16823,6 +19708,17 @@ __metadata: languageName: node linkType: hard +"nopt@npm:^9.0.0": + version: 9.0.0 + resolution: "nopt@npm:9.0.0" + dependencies: + abbrev: "npm:^4.0.0" + bin: + nopt: bin/nopt.js + checksum: 10c0/1822eb6f9b020ef6f7a7516d7b64a8036e09666ea55ac40416c36e4b2b343122c3cff0e2f085675f53de1d2db99a2a89a60ccea1d120bcd6a5347bf6ceb4a7fd + languageName: node + linkType: hard + "normalize-path@npm:^3.0.0, normalize-path@npm:~3.0.0": version: 3.0.0 resolution: "normalize-path@npm:3.0.0" @@ -16837,6 +19733,43 @@ __metadata: languageName: node linkType: hard +"npm-bundled@npm:^5.0.0": + version: 5.0.0 + resolution: "npm-bundled@npm:5.0.0" + dependencies: + npm-normalize-package-bin: "npm:^5.0.0" + checksum: 10c0/6408b38343b51d5e329a0a4af4cf19d7872bc9099f6f7553fbadb5d56e69092d5af76fe501fa0817fcb8af29cf3cc8f8806a88031580f54068e5e80abf1ca870 + languageName: node + linkType: hard + +"npm-install-checks@npm:^8.0.0": + version: 8.0.0 + resolution: "npm-install-checks@npm:8.0.0" + dependencies: + semver: "npm:^7.1.1" + checksum: 10c0/a979cbc8fceacedf91bf59c2883f46f3c56bd421869f6664cce66aa605af14f875041730e66f3d1c543d49bdb032cbb147cdb481a17c541780d016bc2df89141 + languageName: node + linkType: hard + +"npm-normalize-package-bin@npm:^5.0.0": + version: 5.0.0 + resolution: "npm-normalize-package-bin@npm:5.0.0" + checksum: 10c0/9cd875669354ce451779495a111dc1622bedf702f7ad8b36b05b4037a2c961356361cff49c1e2e77d90b80194dffd18fdb52f16bf64e00ccffe6129003a55248 + languageName: node + linkType: hard + +"npm-package-arg@npm:13.0.0": + version: 13.0.0 + resolution: "npm-package-arg@npm:13.0.0" + dependencies: + hosted-git-info: "npm:^9.0.0" + proc-log: "npm:^5.0.0" + semver: "npm:^7.3.5" + validate-npm-package-name: "npm:^6.0.0" + checksum: 10c0/cb5d3378b5fa69320547ad80227932efedcbf772caf9f1350c28527eb6ac5c5d0085d3f2a09ce0a65cae34d7956b9bf40674dd8be91cd35a376bbb30888b2997 + languageName: node + linkType: hard + "npm-package-arg@npm:^11.0.0": version: 11.0.3 resolution: "npm-package-arg@npm:11.0.3" @@ -16849,6 +19782,56 @@ __metadata: languageName: node linkType: hard +"npm-package-arg@npm:^13.0.0": + version: 13.0.2 + resolution: "npm-package-arg@npm:13.0.2" + dependencies: + hosted-git-info: "npm:^9.0.0" + proc-log: "npm:^6.0.0" + semver: "npm:^7.3.5" + validate-npm-package-name: "npm:^7.0.0" + checksum: 10c0/bf4ecdbfac876250f17710c6d0fac014bb345555acc80ce3b9e685d828107f3682378a9c413278c2fe4e958f4aad261677769be9a2b7c3965ab219b5bb852197 + languageName: node + linkType: hard + +"npm-packlist@npm:^10.0.1": + version: 10.0.4 + resolution: "npm-packlist@npm:10.0.4" + dependencies: + ignore-walk: "npm:^8.0.0" + proc-log: "npm:^6.0.0" + checksum: 10c0/500ec00ed5edc3f7136255a8c17dfd36fb718182af61a86a68768aa3b325f69739953fe8888fa8e4765db00e7892a9d0a30093b145d091b23e96b7d1bbf1187e + languageName: node + linkType: hard + +"npm-pick-manifest@npm:^11.0.1": + version: 11.0.3 + resolution: "npm-pick-manifest@npm:11.0.3" + dependencies: + npm-install-checks: "npm:^8.0.0" + npm-normalize-package-bin: "npm:^5.0.0" + npm-package-arg: "npm:^13.0.0" + semver: "npm:^7.3.5" + checksum: 10c0/214a9966de69bbb1e3c4ade8f33e99fefa1bdc7cbef480e4d2dc3fa63104e05f94bd84b56814c13b20bf838398bfc72f39691cb5d06d7c17333fe0ee33fe3e71 + languageName: node + linkType: hard + +"npm-registry-fetch@npm:^19.0.0": + version: 19.1.1 + resolution: "npm-registry-fetch@npm:19.1.1" + dependencies: + "@npmcli/redact": "npm:^4.0.0" + jsonparse: "npm:^1.3.1" + make-fetch-happen: "npm:^15.0.0" + minipass: "npm:^7.0.2" + minipass-fetch: "npm:^5.0.0" + minizlib: "npm:^3.0.1" + npm-package-arg: "npm:^13.0.0" + proc-log: "npm:^6.0.0" + checksum: 10c0/19903dc5cfd6cfc0d6922e4eeac042e95461f4cc58d280e6d6585e187a839a1d039c6a25b909157d7d655016aec8a8a5f3fa75f62cffa87ac133f95842e12b2c + languageName: node + linkType: hard + "npm-run-path@npm:^4.0.0": version: 4.0.1 resolution: "npm-run-path@npm:4.0.1" @@ -16877,6 +19860,15 @@ __metadata: languageName: node linkType: hard +"nth-check@npm:^2.1.1": + version: 2.1.1 + resolution: "nth-check@npm:2.1.1" + dependencies: + boolbase: "npm:^1.0.0" + checksum: 10c0/5fee7ff309727763689cfad844d979aedd2204a817fbaaf0e1603794a7c20db28548d7b024692f953557df6ce4a0ee4ae46cd8ebd9b36cfb300b9226b567c479 + languageName: node + linkType: hard + "nullthrows@npm:^1.1.1": version: 1.1.1 resolution: "nullthrows@npm:1.1.1" @@ -16916,7 +19908,7 @@ __metadata: languageName: node linkType: hard -"object-assign@npm:^4.0.1, object-assign@npm:^4.1.0, object-assign@npm:^4.1.1": +"object-assign@npm:^4, object-assign@npm:^4.0.1, object-assign@npm:^4.1.0, object-assign@npm:^4.1.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" checksum: 10c0/1f4df9945120325d041ccf7b86f31e8bcc14e73d29171e37a7903050e96b81323784ec59f93f102ec635bcf6fa8034ba3ea0a8c7e69fa202b87ae3b6cec5a414 @@ -16998,21 +19990,21 @@ __metadata: languageName: node linkType: hard -"on-finished@npm:~2.3.0": - version: 2.3.0 - resolution: "on-finished@npm:2.3.0" +"on-finished@npm:^2.4.1, on-finished@npm:~2.4.1": + version: 2.4.1 + resolution: "on-finished@npm:2.4.1" dependencies: ee-first: "npm:1.1.1" - checksum: 10c0/c904f9e518b11941eb60279a3cbfaf1289bd0001f600a950255b1dede9fe3df8cd74f38483550b3bb9485165166acb5db500c3b4c4337aec2815c88c96fcc2ea + checksum: 10c0/46fb11b9063782f2d9968863d9cbba33d77aa13c17f895f56129c274318b86500b22af3a160fe9995aa41317efcd22941b6eba747f718ced08d9a73afdb087b4 languageName: node linkType: hard -"on-finished@npm:~2.4.1": - version: 2.4.1 - resolution: "on-finished@npm:2.4.1" +"on-finished@npm:~2.3.0": + version: 2.3.0 + resolution: "on-finished@npm:2.3.0" dependencies: ee-first: "npm:1.1.1" - checksum: 10c0/46fb11b9063782f2d9968863d9cbba33d77aa13c17f895f56129c274318b86500b22af3a160fe9995aa41317efcd22941b6eba747f718ced08d9a73afdb087b4 + checksum: 10c0/c904f9e518b11941eb60279a3cbfaf1289bd0001f600a950255b1dede9fe3df8cd74f38483550b3bb9485165166acb5db500c3b4c4337aec2815c88c96fcc2ea languageName: node linkType: hard @@ -17103,6 +20095,23 @@ __metadata: languageName: node linkType: hard +"ora@npm:8.2.0": + version: 8.2.0 + resolution: "ora@npm:8.2.0" + dependencies: + chalk: "npm:^5.3.0" + cli-cursor: "npm:^5.0.0" + cli-spinners: "npm:^2.9.2" + is-interactive: "npm:^2.0.0" + is-unicode-supported: "npm:^2.0.0" + log-symbols: "npm:^6.0.0" + stdin-discarder: "npm:^0.2.2" + string-width: "npm:^7.2.0" + strip-ansi: "npm:^7.1.0" + checksum: 10c0/7d9291255db22e293ea164f520b6042a3e906576ab06c9cf408bf9ef5664ba0a9f3bd258baa4ada058cfcc2163ef9b6696d51237a866682ce33295349ba02c3a + languageName: node + linkType: hard + "ora@npm:^3.4.0": version: 3.4.0 resolution: "ora@npm:3.4.0" @@ -17117,6 +20126,13 @@ __metadata: languageName: node linkType: hard +"ordered-binary@npm:^1.5.3": + version: 1.6.1 + resolution: "ordered-binary@npm:1.6.1" + checksum: 10c0/27aca7a681b859acdc3607784288462662a4a0575b1c727ef8710c3e81e3a7703d2d64e4401dbba049c50d4ec4d89acbe198fdf5ca975a4709962ecfc42f2bbf + languageName: node + linkType: hard + "own-keys@npm:^1.0.1": version: 1.0.1 resolution: "own-keys@npm:1.0.1" @@ -17203,6 +20219,33 @@ __metadata: languageName: node linkType: hard +"pacote@npm:21.5.1": + version: 21.5.1 + resolution: "pacote@npm:21.5.1" + dependencies: + "@gar/promise-retry": "npm:^1.0.0" + "@npmcli/git": "npm:^7.0.0" + "@npmcli/installed-package-contents": "npm:^4.0.0" + "@npmcli/package-json": "npm:^7.0.0" + "@npmcli/promise-spawn": "npm:^9.0.0" + "@npmcli/run-script": "npm:^10.0.0" + cacache: "npm:^20.0.0" + fs-minipass: "npm:^3.0.0" + minipass: "npm:^7.0.2" + npm-package-arg: "npm:^13.0.0" + npm-packlist: "npm:^10.0.1" + npm-pick-manifest: "npm:^11.0.1" + npm-registry-fetch: "npm:^19.0.0" + proc-log: "npm:^6.0.0" + sigstore: "npm:^4.0.0" + ssri: "npm:^13.0.0" + tar: "npm:^7.4.3" + bin: + pacote: bin/index.js + checksum: 10c0/61649e22d3c03e5de416c2073032120820ef494f8dece2bcf205d1e17f0ea76d02872d5f2e0804832ba39c1ccc73b454152f7466bfa717053e7a552db690cd4a + languageName: node + linkType: hard + "parent-module@npm:^1.0.0": version: 1.0.1 resolution: "parent-module@npm:1.0.1" @@ -17242,6 +20285,26 @@ __metadata: languageName: node linkType: hard +"parse5-html-rewriting-stream@npm:8.0.0": + version: 8.0.0 + resolution: "parse5-html-rewriting-stream@npm:8.0.0" + dependencies: + entities: "npm:^6.0.0" + parse5: "npm:^8.0.0" + parse5-sax-parser: "npm:^8.0.0" + checksum: 10c0/45f685115000c7e8c0b37243062589e486638e6337373553104cbd177ae314c2f0fecad812023394759882a7bb64d5c4dfa55a5146ebfc0e1de4a4d94b0e9b44 + languageName: node + linkType: hard + +"parse5-sax-parser@npm:^8.0.0": + version: 8.0.0 + resolution: "parse5-sax-parser@npm:8.0.0" + dependencies: + parse5: "npm:^8.0.0" + checksum: 10c0/beb228bbe2ce81ace711ed00f12afde779fbd93b1f381a7f68b6344ab07b98f1832cb1d90ef418cbf3e0a9f6f1ce8f75e016a966c727882c78151157305a44fe + languageName: node + linkType: hard + "parse5@npm:^7.0.0, parse5@npm:^7.1.1, parse5@npm:^7.2.1": version: 7.3.0 resolution: "parse5@npm:7.3.0" @@ -17251,7 +20314,16 @@ __metadata: languageName: node linkType: hard -"parseurl@npm:~1.3.3": +"parse5@npm:^8.0.0": + version: 8.0.1 + resolution: "parse5@npm:8.0.1" + dependencies: + entities: "npm:^8.0.0" + checksum: 10c0/c3c1c5aab55f6e4be5245599790e56e64be7764a4a0edd7f98db4fe3bb380f63add752fa047dff0496446c25f4104f0c7c1967723de640bde92306a7bb67ed2f + languageName: node + linkType: hard + +"parseurl@npm:^1.3.3, parseurl@npm:~1.3.3": version: 1.3.3 resolution: "parseurl@npm:1.3.3" checksum: 10c0/90dd4760d6f6174adb9f20cf0965ae12e23879b5f5464f38e92fce8073354341e4b3b76fa3d878351efe7d01e617121955284cfd002ab087fba1a0726ec0b4f5 @@ -17320,6 +20392,13 @@ __metadata: languageName: node linkType: hard +"path-to-regexp@npm:^8.0.0": + version: 8.4.2 + resolution: "path-to-regexp@npm:8.4.2" + checksum: 10c0/05b115c49b47ad252ce05faa32930f643f23769c68b8bcfe78ad833545140c48bbffb3266986d6c8d5db13a64cf12e07e0d72d9882cab830efeefa553533ebaf + languageName: node + linkType: hard + "path-type@npm:^4.0.0": version: 4.0.0 resolution: "path-type@npm:4.0.0" @@ -17348,6 +20427,13 @@ __metadata: languageName: node linkType: hard +"picomatch@npm:4.0.4, picomatch@npm:^4.0.4": + version: 4.0.4 + resolution: "picomatch@npm:4.0.4" + checksum: 10c0/e2c6023372cc7b5764719a5ffb9da0f8e781212fa7ca4bd0562db929df8e117460f00dff3cb7509dacfc06b86de924b247f504d0ce1806a37fac4633081466b0 + languageName: node + linkType: hard + "picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.2.3, picomatch@npm:^2.3.1": version: 2.3.1 resolution: "picomatch@npm:2.3.1" @@ -17369,13 +20455,6 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^4.0.4": - version: 4.0.4 - resolution: "picomatch@npm:4.0.4" - checksum: 10c0/e2c6023372cc7b5764719a5ffb9da0f8e781212fa7ca4bd0562db929df8e117460f00dff3cb7509dacfc06b86de924b247f504d0ce1806a37fac4633081466b0 - languageName: node - linkType: hard - "pidtree@npm:^0.6.0": version: 0.6.0 resolution: "pidtree@npm:0.6.0" @@ -17399,6 +20478,25 @@ __metadata: languageName: node linkType: hard +"piscina@npm:5.2.0": + version: 5.2.0 + resolution: "piscina@npm:5.2.0" + dependencies: + "@napi-rs/nice": "npm:^1.0.4" + dependenciesMeta: + "@napi-rs/nice": + optional: true + checksum: 10c0/61f4377dc7aeff2b3f71c2c03861fad97786b6fdad8a320e6281dd77bcadd4f2c8260a6274184d1c74ea30fddc293f30b10978f371b45a367b03145de3c84d1e + languageName: node + linkType: hard + +"pkce-challenge@npm:^5.0.0": + version: 5.0.1 + resolution: "pkce-challenge@npm:5.0.1" + checksum: 10c0/207f4cb976682f27e8324eb49cf71937c98fbb8341a0b8f6142bc6f664825b30e049a54a21b5c034e823ee3c3d412f10d74bd21de78e17452a6a496c2991f57c + languageName: node + linkType: hard + "pkg-up@npm:^3.1.0": version: 3.1.0 resolution: "pkg-up@npm:3.1.0" @@ -17480,6 +20578,13 @@ __metadata: languageName: node linkType: hard +"postcss-media-query-parser@npm:^0.2.3": + version: 0.2.3 + resolution: "postcss-media-query-parser@npm:0.2.3" + checksum: 10c0/252c8cf24f0e9018516b0d70b7b3d6f5b52e81c4bab2164b49a4e4c1b87bb11f5dbe708c0076990665cb24c70d5fd2f3aee9c922b0f67c7c619e051801484688 + languageName: node + linkType: hard + "postcss-value-parser@npm:^4.2.0": version: 4.2.0 resolution: "postcss-value-parser@npm:4.2.0" @@ -17487,6 +20592,17 @@ __metadata: languageName: node linkType: hard +"postcss@npm:^8.4.49, postcss@npm:^8.5.6": + version: 8.5.25 + resolution: "postcss@npm:8.5.25" + dependencies: + nanoid: "npm:^3.3.16" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/0a12c1e74b456c57122e81f684e02fd98ff4d57526f794d10c996df1147158808f5ae373ac82b988c8de6cbbaa82dbd7b13803b14f5dd3cf7cc6a42ccad5c9f2 + languageName: node + linkType: hard + "postcss@npm:^8.5.3": version: 8.5.3 resolution: "postcss@npm:8.5.3" @@ -17655,6 +20771,13 @@ __metadata: languageName: node linkType: hard +"proc-log@npm:^6.0.0, proc-log@npm:^6.1.0": + version: 6.1.0 + resolution: "proc-log@npm:6.1.0" + checksum: 10c0/4f178d4062733ead9d71a9b1ab24ebcecdfe2250916a5b1555f04fe2eda972a0ec76fbaa8df1ad9c02707add6749219d118a4fc46dc56bdfe4dde4b47d80bb82 + languageName: node + linkType: hard + "process-nextick-args@npm:~2.0.0": version: 2.0.1 resolution: "process-nextick-args@npm:2.0.1" @@ -17765,6 +20888,16 @@ __metadata: languageName: node linkType: hard +"proxy-addr@npm:^2.0.7": + version: 2.0.7 + resolution: "proxy-addr@npm:2.0.7" + dependencies: + forwarded: "npm:0.2.0" + ipaddr.js: "npm:1.9.1" + checksum: 10c0/c3eed999781a35f7fd935f398b6d8920b6fb00bbc14287bc6de78128ccc1a02c89b95b56742bf7cf0362cc333c61d138532049c7dedc7a328ef13343eff81210 + languageName: node + linkType: hard + "psl@npm:^1.1.33": version: 1.15.0 resolution: "psl@npm:1.15.0" @@ -17807,6 +20940,16 @@ __metadata: languageName: node linkType: hard +"qs@npm:^6.14.0, qs@npm:^6.15.2": + version: 6.15.3 + resolution: "qs@npm:6.15.3" + dependencies: + es-define-property: "npm:^1.0.1" + side-channel: "npm:^1.1.1" + checksum: 10c0/8f3f6e45ece255347d57696628401cde29e9ec649fff698b53bd3150dea7cefdf33036e1bc1826b9f110bfa7cb0ec4ab9f5297eca628ce216c55af82c304e08e + languageName: node + linkType: hard + "query-string@npm:^7.1.3": version: 7.1.3 resolution: "query-string@npm:7.1.3" @@ -17842,6 +20985,13 @@ __metadata: languageName: node linkType: hard +"range-parser@npm:^1.2.1": + version: 1.3.0 + resolution: "range-parser@npm:1.3.0" + checksum: 10c0/295494bb6685f9ab50f41a5f4fa5ce445aeeb9673b491bf178460fcc3a812dcf0d164cf1c83074f13a71a87d91ead5e097afd53e0630bc49a5101ed60f2a7529 + languageName: node + linkType: hard + "range-parser@npm:~1.2.1": version: 1.2.1 resolution: "range-parser@npm:1.2.1" @@ -17849,6 +20999,18 @@ __metadata: languageName: node linkType: hard +"raw-body@npm:^3.0.0, raw-body@npm:^3.0.2": + version: 3.0.2 + resolution: "raw-body@npm:3.0.2" + dependencies: + bytes: "npm:~3.1.2" + http-errors: "npm:~2.0.1" + iconv-lite: "npm:~0.7.0" + unpipe: "npm:~1.0.0" + checksum: 10c0/d266678d08e1e7abea62c0ce5864344e980fa81c64f6b481e9842c5beaed2cdcf975f658a3ccd67ad35fc919c1f6664ccc106067801850286a6cbe101de89f29 + languageName: node + linkType: hard + "rc@npm:~1.2.7": version: 1.2.8 resolution: "rc@npm:1.2.8" @@ -18463,6 +21625,13 @@ __metadata: languageName: node linkType: hard +"reflect-metadata@npm:^0.2.0": + version: 0.2.2 + resolution: "reflect-metadata@npm:0.2.2" + checksum: 10c0/1cd93a15ea291e420204955544637c264c216e7aac527470e393d54b4bb075f10a17e60d8168ec96600c7e0b9fcc0cb0bb6e91c3fbf5b0d8c9056f04e6ac1ec2 + languageName: node + linkType: hard + "reflect.getprototypeof@npm:^1.0.6, reflect.getprototypeof@npm:^1.0.9": version: 1.0.10 resolution: "reflect.getprototypeof@npm:1.0.10" @@ -18636,6 +21805,19 @@ __metadata: languageName: node linkType: hard +"resolve@npm:1.22.10, resolve@npm:^1.22.4": + version: 1.22.10 + resolution: "resolve@npm:1.22.10" + dependencies: + is-core-module: "npm:^2.16.0" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" + bin: + resolve: bin/resolve + checksum: 10c0/8967e1f4e2cc40f79b7e080b4582b9a8c5ee36ffb46041dccb20e6461161adf69f843b43067b4a375de926a2cd669157e29a29578191def399dd5ef89a1b5203 + languageName: node + linkType: hard + "resolve@npm:^1.10.1, resolve@npm:^1.22.10": version: 1.22.11 resolution: "resolve@npm:1.22.11" @@ -18663,19 +21845,6 @@ __metadata: languageName: node linkType: hard -"resolve@npm:^1.22.4": - version: 1.22.10 - resolution: "resolve@npm:1.22.10" - dependencies: - is-core-module: "npm:^2.16.0" - path-parse: "npm:^1.0.7" - supports-preserve-symlinks-flag: "npm:^1.0.0" - bin: - resolve: bin/resolve - checksum: 10c0/8967e1f4e2cc40f79b7e080b4582b9a8c5ee36ffb46041dccb20e6461161adf69f843b43067b4a375de926a2cd669157e29a29578191def399dd5ef89a1b5203 - languageName: node - linkType: hard - "resolve@npm:^2.0.0-next.5": version: 2.0.0-next.5 resolution: "resolve@npm:2.0.0-next.5" @@ -18698,6 +21867,19 @@ __metadata: languageName: node linkType: hard +"resolve@patch:resolve@npm%3A1.22.10#optional!builtin, resolve@patch:resolve@npm%3A^1.22.4#optional!builtin": + version: 1.22.10 + resolution: "resolve@patch:resolve@npm%3A1.22.10#optional!builtin::version=1.22.10&hash=c3c19d" + dependencies: + is-core-module: "npm:^2.16.0" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" + bin: + resolve: bin/resolve + checksum: 10c0/52a4e505bbfc7925ac8f4cd91fd8c4e096b6a89728b9f46861d3b405ac9a1ccf4dcbf8befb4e89a2e11370dacd0160918163885cbc669369590f2f31f4c58939 + languageName: node + linkType: hard + "resolve@patch:resolve@npm%3A^1.10.1#optional!builtin, resolve@patch:resolve@npm%3A^1.22.10#optional!builtin": version: 1.22.11 resolution: "resolve@patch:resolve@npm%3A1.22.11#optional!builtin::version=1.22.11&hash=c3c19d" @@ -18725,19 +21907,6 @@ __metadata: languageName: node linkType: hard -"resolve@patch:resolve@npm%3A^1.22.4#optional!builtin": - version: 1.22.10 - resolution: "resolve@patch:resolve@npm%3A1.22.10#optional!builtin::version=1.22.10&hash=c3c19d" - dependencies: - is-core-module: "npm:^2.16.0" - path-parse: "npm:^1.0.7" - supports-preserve-symlinks-flag: "npm:^1.0.0" - bin: - resolve: bin/resolve - checksum: 10c0/52a4e505bbfc7925ac8f4cd91fd8c4e096b6a89728b9f46861d3b405ac9a1ccf4dcbf8befb4e89a2e11370dacd0160918163885cbc669369590f2f31f4c58939 - languageName: node - linkType: hard - "resolve@patch:resolve@npm%3A^2.0.0-next.5#optional!builtin": version: 2.0.0-next.5 resolution: "resolve@patch:resolve@npm%3A2.0.0-next.5#optional!builtin::version=2.0.0-next.5&hash=c3c19d" @@ -18812,6 +21981,96 @@ __metadata: languageName: node linkType: hard +"rollup@npm:4.59.0": + version: 4.59.0 + resolution: "rollup@npm:4.59.0" + dependencies: + "@rollup/rollup-android-arm-eabi": "npm:4.59.0" + "@rollup/rollup-android-arm64": "npm:4.59.0" + "@rollup/rollup-darwin-arm64": "npm:4.59.0" + "@rollup/rollup-darwin-x64": "npm:4.59.0" + "@rollup/rollup-freebsd-arm64": "npm:4.59.0" + "@rollup/rollup-freebsd-x64": "npm:4.59.0" + "@rollup/rollup-linux-arm-gnueabihf": "npm:4.59.0" + "@rollup/rollup-linux-arm-musleabihf": "npm:4.59.0" + "@rollup/rollup-linux-arm64-gnu": "npm:4.59.0" + "@rollup/rollup-linux-arm64-musl": "npm:4.59.0" + "@rollup/rollup-linux-loong64-gnu": "npm:4.59.0" + "@rollup/rollup-linux-loong64-musl": "npm:4.59.0" + "@rollup/rollup-linux-ppc64-gnu": "npm:4.59.0" + "@rollup/rollup-linux-ppc64-musl": "npm:4.59.0" + "@rollup/rollup-linux-riscv64-gnu": "npm:4.59.0" + "@rollup/rollup-linux-riscv64-musl": "npm:4.59.0" + "@rollup/rollup-linux-s390x-gnu": "npm:4.59.0" + "@rollup/rollup-linux-x64-gnu": "npm:4.59.0" + "@rollup/rollup-linux-x64-musl": "npm:4.59.0" + "@rollup/rollup-openbsd-x64": "npm:4.59.0" + "@rollup/rollup-openharmony-arm64": "npm:4.59.0" + "@rollup/rollup-win32-arm64-msvc": "npm:4.59.0" + "@rollup/rollup-win32-ia32-msvc": "npm:4.59.0" + "@rollup/rollup-win32-x64-gnu": "npm:4.59.0" + "@rollup/rollup-win32-x64-msvc": "npm:4.59.0" + "@types/estree": "npm:1.0.8" + fsevents: "npm:~2.3.2" + dependenciesMeta: + "@rollup/rollup-android-arm-eabi": + optional: true + "@rollup/rollup-android-arm64": + optional: true + "@rollup/rollup-darwin-arm64": + optional: true + "@rollup/rollup-darwin-x64": + optional: true + "@rollup/rollup-freebsd-arm64": + optional: true + "@rollup/rollup-freebsd-x64": + optional: true + "@rollup/rollup-linux-arm-gnueabihf": + optional: true + "@rollup/rollup-linux-arm-musleabihf": + optional: true + "@rollup/rollup-linux-arm64-gnu": + optional: true + "@rollup/rollup-linux-arm64-musl": + optional: true + "@rollup/rollup-linux-loong64-gnu": + optional: true + "@rollup/rollup-linux-loong64-musl": + optional: true + "@rollup/rollup-linux-ppc64-gnu": + optional: true + "@rollup/rollup-linux-ppc64-musl": + optional: true + "@rollup/rollup-linux-riscv64-gnu": + optional: true + "@rollup/rollup-linux-riscv64-musl": + optional: true + "@rollup/rollup-linux-s390x-gnu": + optional: true + "@rollup/rollup-linux-x64-gnu": + optional: true + "@rollup/rollup-linux-x64-musl": + optional: true + "@rollup/rollup-openbsd-x64": + optional: true + "@rollup/rollup-openharmony-arm64": + optional: true + "@rollup/rollup-win32-arm64-msvc": + optional: true + "@rollup/rollup-win32-ia32-msvc": + optional: true + "@rollup/rollup-win32-x64-gnu": + optional: true + "@rollup/rollup-win32-x64-msvc": + optional: true + fsevents: + optional: true + bin: + rollup: dist/bin/rollup + checksum: 10c0/f38742da34cfee5e899302615fa157aa77cb6a2a1495e5e3ce4cc9c540d3262e235bbe60caa31562bbfe492b01fdb3e7a8c43c39d842d3293bcf843123b766fc + languageName: node + linkType: hard + "rollup@npm:^4.34.8, rollup@npm:^4.34.9": version: 4.40.0 resolution: "rollup@npm:4.40.0" @@ -18859,9 +22118,96 @@ __metadata: optional: true "@rollup/rollup-linux-arm64-musl": optional: true - "@rollup/rollup-linux-loongarch64-gnu": + "@rollup/rollup-linux-loongarch64-gnu": + optional: true + "@rollup/rollup-linux-powerpc64le-gnu": + optional: true + "@rollup/rollup-linux-riscv64-gnu": + optional: true + "@rollup/rollup-linux-riscv64-musl": + optional: true + "@rollup/rollup-linux-s390x-gnu": + optional: true + "@rollup/rollup-linux-x64-gnu": + optional: true + "@rollup/rollup-linux-x64-musl": + optional: true + "@rollup/rollup-win32-arm64-msvc": + optional: true + "@rollup/rollup-win32-ia32-msvc": + optional: true + "@rollup/rollup-win32-x64-msvc": + optional: true + fsevents: + optional: true + bin: + rollup: dist/bin/rollup + checksum: 10c0/90aa57487d4a9a7de1a47bf42a6091f83f1cb7fe1814650dfec278ab8ddae5736b86535d4c766493517720f334dfd4aa0635405ca8f4f36ed8d3c0f875f2a801 + languageName: node + linkType: hard + +"rollup@npm:^4.43.0": + version: 4.62.4 + resolution: "rollup@npm:4.62.4" + dependencies: + "@napi-rs/lzma-linux-x64-gnu": "npm:1.5.1" + "@rollup/rollup-android-arm-eabi": "npm:4.62.4" + "@rollup/rollup-android-arm64": "npm:4.62.4" + "@rollup/rollup-darwin-arm64": "npm:4.62.4" + "@rollup/rollup-darwin-x64": "npm:4.62.4" + "@rollup/rollup-freebsd-arm64": "npm:4.62.4" + "@rollup/rollup-freebsd-x64": "npm:4.62.4" + "@rollup/rollup-linux-arm-gnueabihf": "npm:4.62.4" + "@rollup/rollup-linux-arm-musleabihf": "npm:4.62.4" + "@rollup/rollup-linux-arm64-gnu": "npm:4.62.4" + "@rollup/rollup-linux-arm64-musl": "npm:4.62.4" + "@rollup/rollup-linux-loong64-gnu": "npm:4.62.4" + "@rollup/rollup-linux-loong64-musl": "npm:4.62.4" + "@rollup/rollup-linux-ppc64-gnu": "npm:4.62.4" + "@rollup/rollup-linux-ppc64-musl": "npm:4.62.4" + "@rollup/rollup-linux-riscv64-gnu": "npm:4.62.4" + "@rollup/rollup-linux-riscv64-musl": "npm:4.62.4" + "@rollup/rollup-linux-s390x-gnu": "npm:4.62.4" + "@rollup/rollup-linux-x64-gnu": "npm:4.62.4" + "@rollup/rollup-linux-x64-musl": "npm:4.62.4" + "@rollup/rollup-openbsd-x64": "npm:4.62.4" + "@rollup/rollup-openharmony-arm64": "npm:4.62.4" + "@rollup/rollup-win32-arm64-msvc": "npm:4.62.4" + "@rollup/rollup-win32-ia32-msvc": "npm:4.62.4" + "@rollup/rollup-win32-x64-gnu": "npm:4.62.4" + "@rollup/rollup-win32-x64-msvc": "npm:4.62.4" + "@types/estree": "npm:1.0.9" + fsevents: "npm:~2.3.2" + dependenciesMeta: + "@napi-rs/lzma-linux-x64-gnu": + optional: true + "@rollup/rollup-android-arm-eabi": + optional: true + "@rollup/rollup-android-arm64": + optional: true + "@rollup/rollup-darwin-arm64": + optional: true + "@rollup/rollup-darwin-x64": + optional: true + "@rollup/rollup-freebsd-arm64": + optional: true + "@rollup/rollup-freebsd-x64": + optional: true + "@rollup/rollup-linux-arm-gnueabihf": + optional: true + "@rollup/rollup-linux-arm-musleabihf": + optional: true + "@rollup/rollup-linux-arm64-gnu": + optional: true + "@rollup/rollup-linux-arm64-musl": + optional: true + "@rollup/rollup-linux-loong64-gnu": optional: true - "@rollup/rollup-linux-powerpc64le-gnu": + "@rollup/rollup-linux-loong64-musl": + optional: true + "@rollup/rollup-linux-ppc64-gnu": + optional: true + "@rollup/rollup-linux-ppc64-musl": optional: true "@rollup/rollup-linux-riscv64-gnu": optional: true @@ -18873,17 +22219,36 @@ __metadata: optional: true "@rollup/rollup-linux-x64-musl": optional: true + "@rollup/rollup-openbsd-x64": + optional: true + "@rollup/rollup-openharmony-arm64": + optional: true "@rollup/rollup-win32-arm64-msvc": optional: true "@rollup/rollup-win32-ia32-msvc": optional: true + "@rollup/rollup-win32-x64-gnu": + optional: true "@rollup/rollup-win32-x64-msvc": optional: true fsevents: optional: true bin: rollup: dist/bin/rollup - checksum: 10c0/90aa57487d4a9a7de1a47bf42a6091f83f1cb7fe1814650dfec278ab8ddae5736b86535d4c766493517720f334dfd4aa0635405ca8f4f36ed8d3c0f875f2a801 + checksum: 10c0/d83bcc89f02db337963dcd0b0d16620129cee263f8437464c02d782dde4e1bb89a6b5b511cd230317ce2c5959fb858884305a9a1a33d1d3da4eef9fe6368414b + languageName: node + linkType: hard + +"router@npm:^2.2.0": + version: 2.2.0 + resolution: "router@npm:2.2.0" + dependencies: + debug: "npm:^4.4.0" + depd: "npm:^2.0.0" + is-promise: "npm:^4.0.0" + parseurl: "npm:^1.3.3" + path-to-regexp: "npm:^8.0.0" + checksum: 10c0/3279de7450c8eae2f6e095e9edacbdeec0abb5cb7249c6e719faa0db2dba43574b4fff5892d9220631c9abaff52dd3cad648cfea2aaace845e1a071915ac8867 languageName: node linkType: hard @@ -18903,7 +22268,7 @@ __metadata: languageName: node linkType: hard -"rxjs@npm:*": +"rxjs@npm:*, rxjs@npm:7.8.2, rxjs@npm:^7.8.2": version: 7.8.2 resolution: "rxjs@npm:7.8.2" dependencies: @@ -18967,6 +22332,23 @@ __metadata: languageName: node linkType: hard +"sass@npm:1.90.0": + version: 1.90.0 + resolution: "sass@npm:1.90.0" + dependencies: + "@parcel/watcher": "npm:^2.4.1" + chokidar: "npm:^4.0.0" + immutable: "npm:^5.0.2" + source-map-js: "npm:>=0.6.2 <2.0.0" + dependenciesMeta: + "@parcel/watcher": + optional: true + bin: + sass: sass.js + checksum: 10c0/cd882a61811447c079cdc78b41f3be8164f2a2ced56e0b256b33da2de383a5f10e11ed15f8080bd832e6df302238e11649b07eb5f6e7d257d9b6982a8325d269 + languageName: node + linkType: hard + "sax@npm:>=0.6.0": version: 1.4.3 resolution: "sax@npm:1.4.3" @@ -19033,21 +22415,21 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.6.0": - version: 7.7.1 - resolution: "semver@npm:7.7.1" +"semver@npm:^7.0.0, semver@npm:^7.1.1, semver@npm:^7.7.2, semver@npm:^7.7.4": + version: 7.8.5 + resolution: "semver@npm:7.8.5" bin: semver: bin/semver.js - checksum: 10c0/fd603a6fb9c399c6054015433051bdbe7b99a940a8fb44b85c2b524c4004b023d7928d47cb22154f8d054ea7ee8597f586605e05b52047f048278e4ac56ae958 + checksum: 10c0/b1f3127a5be8125a94f37188b361c212466c292c6910adce3ec106cff5dc211ccaedc4739c11bb70fda59d6fc1f040a9bca289f4e093451521a2372e5231fe0c languageName: node linkType: hard -"semver@npm:^7.7.2, semver@npm:^7.7.4": - version: 7.8.5 - resolution: "semver@npm:7.8.5" +"semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.6.0": + version: 7.7.1 + resolution: "semver@npm:7.7.1" bin: semver: bin/semver.js - checksum: 10c0/b1f3127a5be8125a94f37188b361c212466c292c6910adce3ec106cff5dc211ccaedc4739c11bb70fda59d6fc1f040a9bca289f4e093451521a2372e5231fe0c + checksum: 10c0/fd603a6fb9c399c6054015433051bdbe7b99a940a8fb44b85c2b524c4004b023d7928d47cb22154f8d054ea7ee8597f586605e05b52047f048278e4ac56ae958 languageName: node linkType: hard @@ -19081,6 +22463,25 @@ __metadata: languageName: node linkType: hard +"send@npm:^1.1.0, send@npm:^1.2.0": + version: 1.2.1 + resolution: "send@npm:1.2.1" + dependencies: + debug: "npm:^4.4.3" + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + etag: "npm:^1.8.1" + fresh: "npm:^2.0.0" + http-errors: "npm:^2.0.1" + mime-types: "npm:^3.0.2" + ms: "npm:^2.1.3" + on-finished: "npm:^2.4.1" + range-parser: "npm:^1.2.1" + statuses: "npm:^2.0.2" + checksum: 10c0/fbbbbdc902a913d65605274be23f3d604065cfc3ee3d78bf9fc8af1dc9fc82667c50d3d657f5e601ac657bac9b396b50ee97bd29cd55436320cf1cddebdcec72 + languageName: node + linkType: hard + "serialize-error@npm:^2.1.0": version: 2.1.0 resolution: "serialize-error@npm:2.1.0" @@ -19100,6 +22501,18 @@ __metadata: languageName: node linkType: hard +"serve-static@npm:^2.2.0": + version: 2.2.1 + resolution: "serve-static@npm:2.2.1" + dependencies: + encodeurl: "npm:^2.0.0" + escape-html: "npm:^1.0.3" + parseurl: "npm:^1.3.3" + send: "npm:^1.2.0" + checksum: 10c0/37986096e8572e2dfaad35a3925fa8da0c0969f8814fd7788e84d4d388bc068cf0c06d1658509788e55bed942a6b6d040a8a267fa92bb9ffb1179f8bacde5fd7 + languageName: node + linkType: hard + "server-only@npm:^0.0.1": version: 0.0.1 resolution: "server-only@npm:0.0.1" @@ -19205,6 +22618,16 @@ __metadata: languageName: node linkType: hard +"side-channel-list@npm:^1.0.1": + version: 1.0.1 + resolution: "side-channel-list@npm:1.0.1" + dependencies: + es-errors: "npm:^1.3.0" + object-inspect: "npm:^1.13.4" + checksum: 10c0/d346c787fd2f9f1c2fdea14f00e8250118db0e7596d85a6cb9faa75f105d31a73a8f7a341c93d7df2a2429098c3d37a77bd3be9e88c37094b8c01807bc77c7a2 + languageName: node + linkType: hard + "side-channel-map@npm:^1.0.1": version: 1.0.1 resolution: "side-channel-map@npm:1.0.1" @@ -19243,6 +22666,19 @@ __metadata: languageName: node linkType: hard +"side-channel@npm:^1.1.1": + version: 1.1.1 + resolution: "side-channel@npm:1.1.1" + dependencies: + es-errors: "npm:^1.3.0" + object-inspect: "npm:^1.13.4" + side-channel-list: "npm:^1.0.1" + side-channel-map: "npm:^1.0.1" + side-channel-weakmap: "npm:^1.0.2" + checksum: 10c0/dc0ab81d67f61bda9247d053ce93f41c3fd8ad2bdcb9cf9d8d2f8540d488f26d87a5e99ebfc07eea49ec025867b2452b705442d974b1478f0395e69f6bfb3270 + languageName: node + linkType: hard + "siginfo@npm:^2.0.0": version: 2.0.0 resolution: "siginfo@npm:2.0.0" @@ -19264,6 +22700,20 @@ __metadata: languageName: node linkType: hard +"sigstore@npm:^4.0.0": + version: 4.1.1 + resolution: "sigstore@npm:4.1.1" + dependencies: + "@sigstore/bundle": "npm:^4.0.0" + "@sigstore/core": "npm:^3.2.1" + "@sigstore/protobuf-specs": "npm:^0.5.0" + "@sigstore/sign": "npm:^4.1.1" + "@sigstore/tuf": "npm:^4.0.2" + "@sigstore/verify": "npm:^3.1.1" + checksum: 10c0/8ebe0c2a7cb3cf9ed9fb775636ab2ae364cbdea9360ea256ab003d83b83dd5eeda8dd899cffcd3853fe711425c481fab2a74246772d75e3ecb9a9483f6700289 + languageName: node + linkType: hard + "simple-plist@npm:^1.1.0": version: 1.4.0 resolution: "simple-plist@npm:1.4.0" @@ -19374,14 +22824,14 @@ __metadata: languageName: node linkType: hard -"source-map-js@npm:^1.2.0, source-map-js@npm:^1.2.1": +"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.2.0, source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1" checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf languageName: node linkType: hard -"source-map-support@npm:~0.5.20, source-map-support@npm:~0.5.21": +"source-map-support@npm:0.5.21, source-map-support@npm:~0.5.20, source-map-support@npm:~0.5.21": version: 0.5.21 resolution: "source-map-support@npm:0.5.21" dependencies: @@ -19398,6 +22848,13 @@ __metadata: languageName: node linkType: hard +"source-map@npm:0.7.6": + version: 0.7.6 + resolution: "source-map@npm:0.7.6" + checksum: 10c0/59f6f05538539b274ba771d2e9e32f6c65451982510564438e048bc1352f019c6efcdc6dd07909b1968144941c14015c2c7d4369fb7c4d7d53ae769716dcc16c + languageName: node + linkType: hard + "source-map@npm:0.8.0-beta.0": version: 0.8.0-beta.0 resolution: "source-map@npm:0.8.0-beta.0" @@ -19421,6 +22878,30 @@ __metadata: languageName: node linkType: hard +"spdx-exceptions@npm:^2.1.0": + version: 2.5.0 + resolution: "spdx-exceptions@npm:2.5.0" + checksum: 10c0/37217b7762ee0ea0d8b7d0c29fd48b7e4dfb94096b109d6255b589c561f57da93bf4e328c0290046115961b9209a8051ad9f525e48d433082fc79f496a4ea940 + languageName: node + linkType: hard + +"spdx-expression-parse@npm:^4.0.0": + version: 4.0.0 + resolution: "spdx-expression-parse@npm:4.0.0" + dependencies: + spdx-exceptions: "npm:^2.1.0" + spdx-license-ids: "npm:^3.0.0" + checksum: 10c0/965c487e77f4fb173f1c471f3eef4eb44b9f0321adc7f93d95e7620da31faa67d29356eb02523cd7df8a7fc1ec8238773cdbf9e45bd050329d2b26492771b736 + languageName: node + linkType: hard + +"spdx-license-ids@npm:^3.0.0": + version: 3.0.23 + resolution: "spdx-license-ids@npm:3.0.23" + checksum: 10c0/8495620f6f2a237749cce922ea2d593a66f7885c301b1a0f5542183e7041182f27f616a8f13345cefdea0c9b3e0899328e0aa8cec100cf4f3fac4bb3bd975515 + languageName: node + linkType: hard + "split-ca@npm:^1.0.1": version: 1.0.1 resolution: "split-ca@npm:1.0.1" @@ -19485,6 +22966,15 @@ __metadata: languageName: node linkType: hard +"ssri@npm:^13.0.0": + version: 13.0.1 + resolution: "ssri@npm:13.0.1" + dependencies: + minipass: "npm:^7.0.3" + checksum: 10c0/cf6408a18676c57ff2ed06b8a20dc64bb3e748e5c7e095332e6aecaa2b8422b1e94a739a8453bf65156a8a47afe23757ba4ab52d3ea3b62322dc40875763e17a + languageName: node + linkType: hard + "stable-hash-x@npm:^0.2.0": version: 0.2.0 resolution: "stable-hash-x@npm:0.2.0" @@ -19568,6 +23058,13 @@ __metadata: languageName: node linkType: hard +"statuses@npm:^2.0.1, statuses@npm:^2.0.2, statuses@npm:~2.0.2": + version: 2.0.2 + resolution: "statuses@npm:2.0.2" + checksum: 10c0/a9947d98ad60d01f6b26727570f3bcceb6c8fa789da64fe6889908fe2e294d57503b14bf2b5af7605c2d36647259e856635cd4c49eab41667658ec9d0080ec3f + languageName: node + linkType: hard + "statuses@npm:~1.5.0": version: 1.5.0 resolution: "statuses@npm:1.5.0" @@ -19575,13 +23072,6 @@ __metadata: languageName: node linkType: hard -"statuses@npm:~2.0.2": - version: 2.0.2 - resolution: "statuses@npm:2.0.2" - checksum: 10c0/a9947d98ad60d01f6b26727570f3bcceb6c8fa789da64fe6889908fe2e294d57503b14bf2b5af7605c2d36647259e856635cd4c49eab41667658ec9d0080ec3f - languageName: node - linkType: hard - "std-env@npm:^3.9.0": version: 3.9.0 resolution: "std-env@npm:3.9.0" @@ -19589,6 +23079,13 @@ __metadata: languageName: node linkType: hard +"stdin-discarder@npm:^0.2.2": + version: 0.2.2 + resolution: "stdin-discarder@npm:0.2.2" + checksum: 10c0/c78375e82e956d7a64be6e63c809c7f058f5303efcaf62ea48350af072bacdb99c06cba39209b45a071c1acbd49116af30df1df9abb448df78a6005b72f10537 + languageName: node + linkType: hard + "stop-iteration-iterator@npm:^1.1.0": version: 1.1.0 resolution: "stop-iteration-iterator@npm:1.1.0" @@ -19676,7 +23173,7 @@ __metadata: languageName: node linkType: hard -"string-width@npm:^7.0.0": +"string-width@npm:^7.0.0, string-width@npm:^7.2.0": version: 7.2.0 resolution: "string-width@npm:7.2.0" dependencies: @@ -19697,6 +23194,16 @@ __metadata: languageName: node linkType: hard +"string-width@npm:^8.2.1": + version: 8.2.2 + resolution: "string-width@npm:8.2.2" + dependencies: + get-east-asian-width: "npm:^1.5.0" + strip-ansi: "npm:^7.1.2" + checksum: 10c0/895624534e4e2f229e880bbc30aa77bdeb758e1a0edce203e0a17b8b4f08b8d3c1156516efc50a35e3efd0052d938a73797692111250ac9f52ee384710a01714 + languageName: node + linkType: hard + "string.prototype.matchall@npm:^4.0.12": version: 4.0.12 resolution: "string.prototype.matchall@npm:4.0.12" @@ -19811,6 +23318,15 @@ __metadata: languageName: node linkType: hard +"strip-ansi@npm:^7.1.2": + version: 7.2.0 + resolution: "strip-ansi@npm:7.2.0" + dependencies: + ansi-regex: "npm:^6.2.2" + checksum: 10c0/544d13b7582f8254811ea97db202f519e189e59d35740c46095897e254e4f1aa9fe1524a83ad6bc5ad67d4dd6c0281d2e0219ed62b880a6238a16a17d375f221 + languageName: node + linkType: hard + "strip-bom@npm:^3.0.0": version: 3.0.0 resolution: "strip-bom@npm:3.0.0" @@ -20052,6 +23568,19 @@ __metadata: languageName: node linkType: hard +"tar@npm:^7.5.4": + version: 7.5.22 + resolution: "tar@npm:7.5.22" + dependencies: + "@isaacs/fs-minipass": "npm:^4.0.0" + chownr: "npm:^3.0.0" + minipass: "npm:^7.1.2" + minizlib: "npm:^3.1.0" + yallist: "npm:^5.0.0" + checksum: 10c0/1311f6be85a8157ac4c9147bae43e13923d2a1aae15e4aa1bd5239e4e03d2cf53cfe103dde7f35832fbb4c938b042856bc8e9a0afd29abd05e2d1608788c4fea + languageName: node + linkType: hard + "terminal-link@npm:^2.1.1": version: 2.1.1 resolution: "terminal-link@npm:2.1.1" @@ -20199,6 +23728,16 @@ __metadata: languageName: node linkType: hard +"tinyglobby@npm:0.2.14": + version: 0.2.14 + resolution: "tinyglobby@npm:0.2.14" + dependencies: + fdir: "npm:^6.4.4" + picomatch: "npm:^4.0.2" + checksum: 10c0/f789ed6c924287a9b7d3612056ed0cda67306cd2c80c249fd280cf1504742b12583a2089b61f4abbd24605f390809017240e250241f09938054c9b363e51c0a6 + languageName: node + linkType: hard + "tinyglobby@npm:^0.2.11, tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.13": version: 0.2.13 resolution: "tinyglobby@npm:0.2.13" @@ -20475,7 +24014,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.1.0, tslib@npm:^2.4.0": +"tslib@npm:^2.0.0, tslib@npm:^2.1.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 @@ -20530,6 +24069,17 @@ __metadata: languageName: node linkType: hard +"tuf-js@npm:^4.1.0": + version: 4.1.0 + resolution: "tuf-js@npm:4.1.0" + dependencies: + "@tufjs/models": "npm:4.1.0" + debug: "npm:^4.4.3" + make-fetch-happen: "npm:^15.0.1" + checksum: 10c0/38330b0b2d16f7f58eccd49b3a6ff0f87dd20743d6f2c26c2621089d8d83d807808e0e660c5be891122538d32db250e3e88267da4421537253e7aa99a45e5800 + languageName: node + linkType: hard + "tweetnacl@npm:^0.14.3": version: 0.14.5 resolution: "tweetnacl@npm:0.14.5" @@ -20574,6 +24124,17 @@ __metadata: languageName: node linkType: hard +"type-is@npm:^2.0.1, type-is@npm:^2.1.0": + version: 2.1.0 + resolution: "type-is@npm:2.1.0" + dependencies: + content-type: "npm:^2.0.0" + media-typer: "npm:^1.1.0" + mime-types: "npm:^3.0.0" + checksum: 10c0/a6018f8f509de48f2c7429305e3a920e73b374fa93127dd0877ae1c2df65a5d33907caac8afb0c37a9b9fc7c49f29e3f55d668963dc845d966930b667c07f50e + languageName: node + linkType: hard + "typed-array-buffer@npm:^1.0.3": version: 1.0.3 resolution: "typed-array-buffer@npm:1.0.3" @@ -20706,7 +24267,7 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.8.3": +"typescript@npm:^5.8.3, typescript@npm:~5.8.3": version: 5.8.3 resolution: "typescript@npm:5.8.3" bin: @@ -20726,7 +24287,7 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5.8.3#optional!builtin": +"typescript@patch:typescript@npm%3A^5.8.3#optional!builtin, typescript@patch:typescript@npm%3A~5.8.3#optional!builtin": version: 5.8.3 resolution: "typescript@patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5" bin: @@ -20811,6 +24372,13 @@ __metadata: languageName: node linkType: hard +"undici@npm:^6.25.0": + version: 6.28.0 + resolution: "undici@npm:6.28.0" + checksum: 10c0/3029a70df06b38b5b2f30732932a1e92544c753cd82c8abdf0d35afad48e0ba91612e79fe3a442dbbb9434d6a9eba2b714d5ea28984c903dda2b5d5444f38354 + languageName: node + linkType: hard + "unicode-canonical-property-names-ecmascript@npm:^2.0.0": version: 2.0.1 resolution: "unicode-canonical-property-names-ecmascript@npm:2.0.1" @@ -21250,7 +24818,21 @@ __metadata: languageName: node linkType: hard -"vary@npm:~1.1.2": +"validate-npm-package-name@npm:^6.0.0": + version: 6.0.2 + resolution: "validate-npm-package-name@npm:6.0.2" + checksum: 10c0/c4c23a8b9fa8deee11eea421d94fbe39f742146c06571b62247212579298186b724ebc5152240a415753bdaf9b8849a487e675ec2968d44660f8a65de6cdef9e + languageName: node + linkType: hard + +"validate-npm-package-name@npm:^7.0.0": + version: 7.0.2 + resolution: "validate-npm-package-name@npm:7.0.2" + checksum: 10c0/adf32e943148e13e8df13d06b855493908e6ae7a847610e8543c6291cbf42f40e653249a5b2275e2e615e3224c574ade5a9064a9e2d1ab629386284ea99e8f39 + languageName: node + linkType: hard + +"vary@npm:^1, vary@npm:^1.1.2, vary@npm:~1.1.2": version: 1.1.2 resolution: "vary@npm:1.1.2" checksum: 10c0/f15d588d79f3675135ba783c91a4083dcd290a2a5be9fcb6514220a1634e23df116847b1cc51f66bfb0644cf9353b2abb7815ae499bab06e46dd33c1a6bf1f4f @@ -21331,6 +24913,61 @@ __metadata: languageName: node linkType: hard +"vite@npm:7.3.6": + version: 7.3.6 + resolution: "vite@npm:7.3.6" + dependencies: + esbuild: "npm:^0.27.0 || ^0.28.0" + fdir: "npm:^6.5.0" + fsevents: "npm:~2.3.3" + picomatch: "npm:^4.0.3" + postcss: "npm:^8.5.6" + rollup: "npm:^4.43.0" + tinyglobby: "npm:^0.2.15" + peerDependencies: + "@types/node": ^20.19.0 || >=22.12.0 + jiti: ">=1.21.0" + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: ">=0.54.8" + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + bin: + vite: bin/vite.js + checksum: 10c0/c6d359f84ad362f5c97ff7988b77c90add04162c60cdf6059375a7d9e3217848c53a8cb6b6c48517bef84b6bba4c9bc85319a889b5236acb7d5a2bc8cb7b9a8d + languageName: node + linkType: hard + "vite@npm:^5.0.0 || ^6.0.0, vite@npm:^6.2.6": version: 6.3.3 resolution: "vite@npm:6.3.3" @@ -21495,6 +25132,16 @@ __metadata: languageName: node linkType: hard +"watchpack@npm:2.4.4": + version: 2.4.4 + resolution: "watchpack@npm:2.4.4" + dependencies: + glob-to-regexp: "npm:^0.4.1" + graceful-fs: "npm:^4.1.2" + checksum: 10c0/6c0901f75ce245d33991225af915eea1c5ae4ba087f3aee2b70dd377d4cacb34bef02a48daf109da9d59b2d31ec6463d924a0d72f8618ae1643dd07b95de5275 + languageName: node + linkType: hard + "wcwidth@npm:^1.0.1": version: 1.0.1 resolution: "wcwidth@npm:1.0.1" @@ -21504,6 +25151,13 @@ __metadata: languageName: node linkType: hard +"weak-lru-cache@npm:^1.2.2": + version: 1.2.2 + resolution: "weak-lru-cache@npm:1.2.2" + checksum: 10c0/744847bd5b96ca86db1cb40d0aea7e92c02bbdb05f501181bf9c581e82fa2afbda32a327ffbe75749302b8492ab449f1c657ca02410d725f5d412d1e6c607d72 + languageName: node + linkType: hard + "webidl-conversions@npm:^3.0.0": version: 3.0.1 resolution: "webidl-conversions@npm:3.0.1" @@ -21713,6 +25367,17 @@ __metadata: languageName: node linkType: hard +"which@npm:^6.0.0": + version: 6.0.1 + resolution: "which@npm:6.0.1" + dependencies: + isexe: "npm:^4.0.0" + bin: + node-which: bin/which.js + checksum: 10c0/7e710e54ea36d2d6183bee2f9caa27a3b47b9baf8dee55a199b736fcf85eab3b9df7556fca3d02b50af7f3dfba5ea3a45644189836df06267df457e354da66d5 + languageName: node + linkType: hard + "why-is-node-running@npm:^2.3.0": version: 2.3.0 resolution: "why-is-node-running@npm:2.3.0" @@ -21750,6 +25415,17 @@ __metadata: languageName: node linkType: hard +"wrap-ansi@npm:^6.2.0": + version: 6.2.0 + resolution: "wrap-ansi@npm:6.2.0" + dependencies: + ansi-styles: "npm:^4.0.0" + string-width: "npm:^4.1.0" + strip-ansi: "npm:^6.0.0" + checksum: 10c0/baad244e6e33335ea24e86e51868fe6823626e3a3c88d9a6674642afff1d34d9a154c917e74af8d845fd25d170c4ea9cf69a47133c3f3656e1252b3d462d9f6c + languageName: node + linkType: hard + "wrap-ansi@npm:^8.1.0": version: 8.1.0 resolution: "wrap-ansi@npm:8.1.0" @@ -21987,6 +25663,27 @@ __metadata: languageName: node linkType: hard +"yargs-parser@npm:^22.0.0": + version: 22.0.0 + resolution: "yargs-parser@npm:22.0.0" + checksum: 10c0/cb7ef81759c4271cb1d96b9351dbbc9a9ce35d3e1122d2b739bf6c432603824fa02c67cc12dcef6ea80283379d63495686e8f41cc7b06c6576e792aba4d33e1c + languageName: node + linkType: hard + +"yargs@npm:18.0.0": + version: 18.0.0 + resolution: "yargs@npm:18.0.0" + dependencies: + cliui: "npm:^9.0.1" + escalade: "npm:^3.1.1" + get-caller-file: "npm:^2.0.5" + string-width: "npm:^7.2.0" + y18n: "npm:^5.0.5" + yargs-parser: "npm:^22.0.0" + checksum: 10c0/bf290e4723876ea9c638c786a5c42ac28e03c9ca2325e1424bf43b94e5876456292d3ed905b853ebbba6daf43ed29e772ac2a6b3c5fb1b16533245d6211778f3 + languageName: node + linkType: hard + "yargs@npm:^16.2.0": version: 16.2.0 resolution: "yargs@npm:16.2.0" @@ -22032,6 +25729,20 @@ __metadata: languageName: node linkType: hard +"yargs@npm:^18.0.0": + version: 18.1.0 + resolution: "yargs@npm:18.1.0" + dependencies: + cliui: "npm:^9.0.1" + escalade: "npm:^3.1.1" + get-caller-file: "npm:^2.0.5" + string-width: "npm:^8.2.1" + y18n: "npm:^5.0.5" + yargs-parser: "npm:^22.0.0" + checksum: 10c0/86052bbed90a9aaaaf1eabe004f47c7420e1b7f8d314170cfb0ad4721f518ec2fb7ba5a6daa20708b1595aba257f1554b2d5b3f5606356f86aa9dae18de0bec5 + languageName: node + linkType: hard + "yocto-queue@npm:^0.1.0": version: 0.1.0 resolution: "yocto-queue@npm:0.1.0" @@ -22039,6 +25750,13 @@ __metadata: languageName: node linkType: hard +"yoctocolors-cjs@npm:^2.1.3": + version: 2.1.3 + resolution: "yoctocolors-cjs@npm:2.1.3" + checksum: 10c0/584168ef98eb5d913473a4858dce128803c4a6cd87c0f09e954fa01126a59a33ab9e513b633ad9ab953786ed16efdd8c8700097a51635aafaeed3fef7712fa79 + languageName: node + linkType: hard + "zip-stream@npm:^6.0.1": version: 6.0.1 resolution: "zip-stream@npm:6.0.1" @@ -22050,9 +25768,32 @@ __metadata: languageName: node linkType: hard +"zod-to-json-schema@npm:^3.25.1": + version: 3.25.2 + resolution: "zod-to-json-schema@npm:3.25.2" + peerDependencies: + zod: ^3.25.28 || ^4 + checksum: 10c0/dd300554393903022487688af14fbda5c719ba8179702bb55b3aa86318830467f0f7beb7d654036975ac963dc4843b72e59636448bfff9a0608f277bb6a14939 + languageName: node + linkType: hard + +"zod@npm:4.1.13": + version: 4.1.13 + resolution: "zod@npm:4.1.13" + checksum: 10c0/d7e74e82dba81a91ffc3239cd85bc034abe193a28f7087a94ab258a3e48e9a7ca4141920cac979a0d781495b48fc547777394149f26be04c3dc642f58bbc3941 + languageName: node + linkType: hard + "zod@npm:^3.24.2": version: 3.24.3 resolution: "zod@npm:3.24.3" checksum: 10c0/ab0369810968d0329a1a141e9418e01e5c9c2a4905cbb7cb7f5a131d6e9487596e1400e21eeff24c4a8ee28dacfa5bd6103893765c055b7a98c2006a5a4fc68d languageName: node linkType: hard + +"zod@npm:^3.25 || ^4.0": + version: 4.4.3 + resolution: "zod@npm:4.4.3" + checksum: 10c0/7ea31b558e88f9faf44f31dd185e2e1cbf51fed3081787fb96cc2534749b50c0acfc6da7f0922a7353ed092dd358c7d50c28ea96c94d04af64191bd33152eca3 + languageName: node + linkType: hard