@decartai/sdk 0.1.20 → 0.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -10
- package/dist/package.js +1 -1
- package/dist/realtime/browser/frame-metadata-diagnostics.js +23 -4
- package/dist/realtime/browser/index.js +4 -1
- package/dist/realtime/browser/preflight.js +0 -1
- package/dist/realtime/browser/prepare-connection.js +5 -4
- package/dist/realtime/client.js +3 -7
- package/dist/realtime/config-realtime.js +2 -1
- package/dist/realtime/media-channel.js +3 -1
- package/dist/realtime/observability/connection-quality.d.ts +4 -4
- package/dist/realtime/observability/webrtc-stats.d.ts +2 -3
- package/dist/realtime/react-native/prepare-connection.js +1 -2
- package/dist/realtime/stream-session.js +6 -3
- package/dist/realtime/subscribe-client.js +41 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -88,6 +88,18 @@ A connected realtime session exposes an SDK `subscribeToken` once it reaches a
|
|
|
88
88
|
connected state. Share that SDK token with viewers — `client.realtime.subscribe`
|
|
89
89
|
uses it to request receive-only LiveKit credentials from Decart, then connects to
|
|
90
90
|
the LiveKit room for the styled output stream. No viewer camera is required.
|
|
91
|
+
Browser-produced streams can include LiveKit frame metadata for glass-to-glass
|
|
92
|
+
latency, so viewers also need a browser runtime that can create the SDK's
|
|
93
|
+
frame-metadata worker; unsupported runtimes reject those tokens before joining.
|
|
94
|
+
|
|
95
|
+
> **Viewers must be on this SDK version or newer.** A browser publisher now
|
|
96
|
+
> advertises frame timing by default, which makes the server append a packet
|
|
97
|
+
> trailer to every frame in the room. Viewers on an older `@decartai/sdk`
|
|
98
|
+
> ignore the token's `frame_timing` flag and join without a strip worker — they
|
|
99
|
+
> connect successfully but decode nothing, so the video element stays black with
|
|
100
|
+
> no error raised. React Native viewers cannot strip trailers at all and are
|
|
101
|
+
> rejected with `UNSUPPORTED_PLATFORM_FEATURE`. Upgrade viewers before upgrading
|
|
102
|
+
> publishers.
|
|
91
103
|
|
|
92
104
|
**Producer** — capture the token from the active session:
|
|
93
105
|
|
|
@@ -175,12 +187,12 @@ realtimeClient.getConnectionQuality(); // latest report, or null before the firs
|
|
|
175
187
|
> setInterval(() => console.log(realtimeClient.getConnectionQuality()?.metrics.g2gMs), 1000);
|
|
176
188
|
> ```
|
|
177
189
|
|
|
178
|
-
**Glass-to-glass latency
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
190
|
+
**Glass-to-glass latency.** Network RTT hides the dominant cost in real-time video — model
|
|
191
|
+
inference — so a session can read "good" while actually feeling laggy. In browser sessions, the SDK
|
|
192
|
+
automatically attaches a capture timestamp using LiveKit frame metadata when the required worker is
|
|
193
|
+
available; the server propagates it through inference and the SDK matches it to output playout.
|
|
194
|
+
This surfaces **startup** (`ttffMs`) and **steady-state** (`g2gMs`) latency. When present,
|
|
195
|
+
glass-to-glass drives the latency verdict instead of RTT.
|
|
184
196
|
|
|
185
197
|
> Frame metadata is currently experimental in LiveKit and requires encoded-transform support.
|
|
186
198
|
> It does not alter visible pixels. `g2gDropRatio` remains `null` until frame IDs are propagated
|
|
@@ -189,7 +201,6 @@ matches it to output playout. This surfaces **startup** (`ttffMs`) and **steady-
|
|
|
189
201
|
```typescript
|
|
190
202
|
const realtimeClient = await client.realtime.connect(stream, {
|
|
191
203
|
model,
|
|
192
|
-
debugQuality: true,
|
|
193
204
|
});
|
|
194
205
|
|
|
195
206
|
// g2g updates every stats tick — read it from the `stats` event (the
|
|
@@ -338,9 +349,9 @@ Add them to `app.json`:
|
|
|
338
349
|
Run `npx expo prebuild` and rebuild the native app. LiveKit does not run in Expo
|
|
339
350
|
Go. Bare React Native apps must follow LiveKit's native setup instructions.
|
|
340
351
|
|
|
341
|
-
Outgoing `mirror
|
|
342
|
-
|
|
343
|
-
|
|
352
|
+
Outgoing `mirror` and deep connectivity preflight are browser-only and fail with
|
|
353
|
+
`UNSUPPORTED_PLATFORM_FEATURE` on React Native. Mirror the local preview with your
|
|
354
|
+
native video view instead.
|
|
344
355
|
|
|
345
356
|
## Development
|
|
346
357
|
|
package/dist/package.js
CHANGED
|
@@ -52,8 +52,8 @@ const TIME_SYNC_UPDATE = "timeSyncUpdate";
|
|
|
52
52
|
function createFrameReader(tracker) {
|
|
53
53
|
let attachedTrack = null;
|
|
54
54
|
const onTimeSyncUpdate = ({ timestamp, rtpTimestamp }) => {
|
|
55
|
-
const frameMetadata = attachedTrack?.lookupFrameMetadata({ rtpTimestamp });
|
|
56
|
-
if (frameMetadata) tracker.recordFrame(frameMetadata.userTimestamp,
|
|
55
|
+
const frameMetadata = attachedTrack?.lookupFrameMetadata?.({ rtpTimestamp });
|
|
56
|
+
if (frameMetadata) tracker.recordFrame(frameMetadata.userTimestamp, timestamp);
|
|
57
57
|
};
|
|
58
58
|
const detach = () => {
|
|
59
59
|
attachedTrack?.off(TIME_SYNC_UPDATE, onTimeSyncUpdate);
|
|
@@ -83,8 +83,27 @@ function createBrowserFrameMetadataDiagnostics() {
|
|
|
83
83
|
dispose: () => reader.dispose()
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
|
+
const WORKER_URL = () => new URL("./frame-metadata-worker.js", import.meta.url);
|
|
86
87
|
function createFrameMetadataWorker() {
|
|
87
|
-
return new Worker(
|
|
88
|
+
return new Worker(WORKER_URL());
|
|
89
|
+
}
|
|
90
|
+
function isFrameMetadataWorkerSameOrigin(workerUrl, pageOrigin) {
|
|
91
|
+
if (workerUrl.protocol !== "http:" && workerUrl.protocol !== "https:") return true;
|
|
92
|
+
return workerUrl.origin === pageOrigin;
|
|
93
|
+
}
|
|
94
|
+
function isFrameMetadataRuntimeSupported() {
|
|
95
|
+
if (typeof window === "undefined") return false;
|
|
96
|
+
try {
|
|
97
|
+
if (!isFrameMetadataWorkerSameOrigin(WORKER_URL(), window.location?.origin)) return false;
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
const maybeWindow = window;
|
|
102
|
+
const userAgent = window.navigator?.userAgent?.toLowerCase() ?? "";
|
|
103
|
+
const isChromiumBased = /(?:chrome|chromium|crmo)\//.test(userAgent) && !/crios\//.test(userAgent);
|
|
104
|
+
const scriptTransformSupported = typeof maybeWindow.RTCRtpScriptTransform !== "undefined" && !isChromiumBased;
|
|
105
|
+
const insertableStreamsSupported = typeof maybeWindow.RTCRtpSender?.prototype?.createEncodedStreams !== "undefined" && typeof maybeWindow.RTCRtpReceiver?.prototype?.createEncodedStreams !== "undefined";
|
|
106
|
+
return scriptTransformSupported || insertableStreamsSupported;
|
|
88
107
|
}
|
|
89
108
|
//#endregion
|
|
90
|
-
export { FrameMetadataTracker, createBrowserFrameMetadataDiagnostics, createFrameMetadataWorker };
|
|
109
|
+
export { FrameMetadataTracker, createBrowserFrameMetadataDiagnostics, createFrameMetadataWorker, isFrameMetadataRuntimeSupported, isFrameMetadataWorkerSameOrigin };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createRealTimeClient } from "../client.js";
|
|
2
2
|
import { createRealTimeSubscribeClient } from "../subscribe-client.js";
|
|
3
|
+
import { createFrameMetadataWorker, isFrameMetadataRuntimeSupported } from "./frame-metadata-diagnostics.js";
|
|
3
4
|
import { createPreflight } from "./preflight.js";
|
|
4
5
|
import { prepareBrowserConnection } from "./prepare-connection.js";
|
|
5
6
|
//#region src/realtime/browser/index.ts
|
|
@@ -16,7 +17,9 @@ const createBrowserRealtime = (options) => {
|
|
|
16
17
|
baseUrl: options.subscribeBaseUrl,
|
|
17
18
|
apiKey: options.apiKey,
|
|
18
19
|
integration: options.integration,
|
|
19
|
-
logger: options.logger
|
|
20
|
+
logger: options.logger,
|
|
21
|
+
createFrameMetadataWorker,
|
|
22
|
+
isFrameMetadataRuntimeSupported
|
|
20
23
|
});
|
|
21
24
|
const preflight = createPreflight({
|
|
22
25
|
logger: options.logger,
|
|
@@ -166,7 +166,6 @@ async function runActiveProbe(args) {
|
|
|
166
166
|
source = createSyntheticSource(model.width, model.height, resolveFpsNumber(model.fps));
|
|
167
167
|
const connectTask = connect(source.stream, {
|
|
168
168
|
model,
|
|
169
|
-
debugQuality: true,
|
|
170
169
|
onRemoteStream: () => {}
|
|
171
170
|
});
|
|
172
171
|
signal?.addEventListener("abort", () => connectTask.then((c) => c.disconnect()).catch(() => {}), { once: true });
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { RealtimeObservability } from "../observability/realtime-observability.js";
|
|
2
2
|
import { isDesktopSafari } from "../../utils/platform.js";
|
|
3
3
|
import { createLiveKitMediaChannel } from "../media-channel.js";
|
|
4
|
-
import { createBrowserFrameMetadataDiagnostics, createFrameMetadataWorker } from "./frame-metadata-diagnostics.js";
|
|
4
|
+
import { createBrowserFrameMetadataDiagnostics, createFrameMetadataWorker, isFrameMetadataRuntimeSupported } from "./frame-metadata-diagnostics.js";
|
|
5
5
|
import { createMirroredStream, shouldMirrorTrack } from "./mirror-stream.js";
|
|
6
6
|
//#region src/realtime/browser/prepare-connection.ts
|
|
7
|
-
const prepareBrowserConnection = ({ stream, mirror,
|
|
7
|
+
const prepareBrowserConnection = ({ stream, mirror, preferredVideoCodec, fps, logger, observability: observabilityOptions }) => {
|
|
8
8
|
let inputStream = stream ?? new MediaStream();
|
|
9
9
|
let disposeMirroring = () => {};
|
|
10
10
|
if (mirror !== false) try {
|
|
@@ -18,11 +18,12 @@ const prepareBrowserConnection = ({ stream, mirror, debugQuality, preferredVideo
|
|
|
18
18
|
logger.warn("Failed to mirror input stream; falling back to un-mirrored input", { error: error instanceof Error ? error.message : String(error) });
|
|
19
19
|
}
|
|
20
20
|
let pendingWorker;
|
|
21
|
-
if (
|
|
21
|
+
if (isFrameMetadataRuntimeSupported()) try {
|
|
22
22
|
pendingWorker = createFrameMetadataWorker();
|
|
23
23
|
} catch (error) {
|
|
24
|
-
logger.
|
|
24
|
+
logger.debug("Frame-metadata worker unavailable; glass-to-glass latency measurement disabled", { error: error instanceof Error ? error.message : String(error) });
|
|
25
25
|
}
|
|
26
|
+
else logger.debug("Frame-metadata runtime unavailable; glass-to-glass latency measurement disabled");
|
|
26
27
|
const frameTiming = pendingWorker !== void 0;
|
|
27
28
|
const takeFrameMetadataWorker = () => {
|
|
28
29
|
if (pendingWorker) {
|
package/dist/realtime/client.js
CHANGED
|
@@ -27,11 +27,9 @@ const realTimeClientConnectOptionsSchema = z.object({
|
|
|
27
27
|
"vp9"
|
|
28
28
|
]).optional(),
|
|
29
29
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* `connectionQuality` signals. Browser-only and experimental because it
|
|
34
|
-
* relies on LiveKit's frame-metadata worker and encoded transforms.
|
|
30
|
+
* @deprecated Glass-to-glass measurement now runs automatically in browsers
|
|
31
|
+
* when LiveKit frame metadata is available. This legacy flag is accepted for
|
|
32
|
+
* compatibility and no longer gates measurement.
|
|
35
33
|
*/
|
|
36
34
|
debugQuality: z.boolean().optional()
|
|
37
35
|
});
|
|
@@ -43,7 +41,6 @@ const createRealTimeClient = (opts) => {
|
|
|
43
41
|
if (!parsedOptions.success) throw parsedOptions.error;
|
|
44
42
|
const { onRemoteStream, onConnectionChange, onConnectionQuality, onQueuePosition, initialState, resolution, preferredVideoCodec } = parsedOptions.data;
|
|
45
43
|
const mirror = parsedOptions.data.mirror ?? false;
|
|
46
|
-
const debugQuality = parsedOptions.data.debugQuality ?? false;
|
|
47
44
|
let session;
|
|
48
45
|
let observability;
|
|
49
46
|
let preparedConnection;
|
|
@@ -59,7 +56,6 @@ const createRealTimeClient = (opts) => {
|
|
|
59
56
|
preparedConnection = opts.prepareConnection({
|
|
60
57
|
stream,
|
|
61
58
|
mirror,
|
|
62
|
-
debugQuality,
|
|
63
59
|
preferredVideoCodec,
|
|
64
60
|
fps: resolveFpsNumber(options.model.fps),
|
|
65
61
|
logger,
|
|
@@ -46,7 +46,9 @@ var LiveKitMediaChannel = class {
|
|
|
46
46
|
if (this.config.createFrameMetadataWorker) try {
|
|
47
47
|
worker = this.config.createFrameMetadataWorker();
|
|
48
48
|
} catch (error) {
|
|
49
|
-
|
|
49
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
50
|
+
this.logger.warn("Failed to create LiveKit frame-metadata worker", { error: detail });
|
|
51
|
+
throw new Error(`Failed to create LiveKit frame-metadata worker: ${detail}`, { cause: error });
|
|
50
52
|
}
|
|
51
53
|
this.frameMetadataEnabled = worker !== void 0;
|
|
52
54
|
try {
|
|
@@ -16,10 +16,10 @@ type ConnectionQualityMetrics = {
|
|
|
16
16
|
rttMs: number | null;
|
|
17
17
|
/**
|
|
18
18
|
* Mid-stream (steady-state) glass-to-glass latency (ms) — the real per-frame
|
|
19
|
-
* camera→display latency through the model, excluding startup.
|
|
20
|
-
* when
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* camera→display latency through the model, excluding startup. Populated in
|
|
20
|
+
* browser sessions when LiveKit frame metadata is available and past warm-up;
|
|
21
|
+
* null otherwise. When present it drives the latency verdict instead of
|
|
22
|
+
* `rttMs`.
|
|
23
23
|
*/
|
|
24
24
|
g2gMs: number | null;
|
|
25
25
|
/**
|
|
@@ -133,9 +133,8 @@ type WebRTCStats = {
|
|
|
133
133
|
};
|
|
134
134
|
/**
|
|
135
135
|
* True glass-to-glass latency + end-to-end drop signal, merged in by
|
|
136
|
-
* `RealtimeObservability` when
|
|
137
|
-
*
|
|
138
|
-
* populate it.
|
|
136
|
+
* `RealtimeObservability` when frame-metadata measurement is active. Null
|
|
137
|
+
* otherwise — the stats collector does not populate it.
|
|
139
138
|
*/
|
|
140
139
|
glassToGlass: G2GMetrics | null;
|
|
141
140
|
};
|
|
@@ -10,10 +10,9 @@ function assertReactNativeReady() {
|
|
|
10
10
|
function unsupportedReactNativeFeature(feature) {
|
|
11
11
|
throw createUnsupportedPlatformFeatureError(feature, "React Native");
|
|
12
12
|
}
|
|
13
|
-
const prepareReactNativeConnection = ({ stream, mirror,
|
|
13
|
+
const prepareReactNativeConnection = ({ stream, mirror, preferredVideoCodec, observability: observabilityOptions }) => {
|
|
14
14
|
assertReactNativeReady();
|
|
15
15
|
if (mirror !== false) unsupportedReactNativeFeature("Outgoing video mirroring");
|
|
16
|
-
if (debugQuality) unsupportedReactNativeFeature("debugQuality");
|
|
17
16
|
return {
|
|
18
17
|
stream: stream ?? new MediaStream(),
|
|
19
18
|
observability: new RealtimeObservability(observabilityOptions),
|
|
@@ -4,8 +4,11 @@ import { SignalingChannel } from "./signaling-channel.js";
|
|
|
4
4
|
import mitt from "mitt";
|
|
5
5
|
import pRetry, { AbortError } from "p-retry";
|
|
6
6
|
//#region src/realtime/stream-session.ts
|
|
7
|
-
function encodeSubscribeToken(roomName) {
|
|
8
|
-
return btoa(JSON.stringify({
|
|
7
|
+
function encodeSubscribeToken(roomName, options = {}) {
|
|
8
|
+
return btoa(JSON.stringify({
|
|
9
|
+
room_name: roomName,
|
|
10
|
+
...options.frameTiming ? { frame_timing: true } : {}
|
|
11
|
+
}));
|
|
9
12
|
}
|
|
10
13
|
function getInitialImageSizeKb(image) {
|
|
11
14
|
if (!image) return null;
|
|
@@ -137,7 +140,7 @@ var StreamSession = class {
|
|
|
137
140
|
this.setState("connected");
|
|
138
141
|
this.events.emit("sessionStarted", {
|
|
139
142
|
sessionId: roomInfo.sessionId,
|
|
140
|
-
subscribeToken: encodeSubscribeToken(roomInfo.roomName)
|
|
143
|
+
subscribeToken: encodeSubscribeToken(roomInfo.roomName, { frameTiming: this.config.frameTiming })
|
|
141
144
|
});
|
|
142
145
|
} catch (error) {
|
|
143
146
|
this.config.observability?.finishConnectionBreakdown({
|
|
@@ -1,15 +1,28 @@
|
|
|
1
|
-
import { classifyWebrtcError } from "../utils/errors.js";
|
|
1
|
+
import { ERROR_CODES, classifyWebrtcError, createSDKError } from "../utils/errors.js";
|
|
2
2
|
import { createConsoleLogger } from "../utils/logger.js";
|
|
3
3
|
import { createEventBuffer } from "./event-buffer.js";
|
|
4
4
|
import { REALTIME_CONFIG } from "./config-realtime.js";
|
|
5
5
|
import { loadLiveKitClient } from "./livekit.js";
|
|
6
6
|
import { RealtimeObservability } from "./observability/realtime-observability.js";
|
|
7
7
|
//#region src/realtime/subscribe-client.ts
|
|
8
|
+
function isDecartSDKError(error) {
|
|
9
|
+
return typeof error === "object" && error !== null && typeof error.code === "string" && typeof error.message === "string";
|
|
10
|
+
}
|
|
11
|
+
function createFrameMetadataSubscribeUnsupportedError(detail) {
|
|
12
|
+
const suffix = detail ? `: ${detail}` : "";
|
|
13
|
+
return createSDKError(ERROR_CODES.UNSUPPORTED_PLATFORM_FEATURE, `This realtime stream requires LiveKit frame metadata, which this SDK environment does not support${suffix}.`, {
|
|
14
|
+
feature: "LiveKit frame metadata subscribe",
|
|
15
|
+
platform: "this SDK environment"
|
|
16
|
+
});
|
|
17
|
+
}
|
|
8
18
|
function decodeSubscribeToken(token) {
|
|
9
19
|
try {
|
|
10
20
|
const payload = JSON.parse(atob(token));
|
|
11
21
|
if (!payload.room_name || typeof payload.room_name !== "string") throw new Error("Invalid subscribe token format");
|
|
12
|
-
return {
|
|
22
|
+
return {
|
|
23
|
+
room_name: payload.room_name,
|
|
24
|
+
...payload.frame_timing === true ? { frame_timing: true } : {}
|
|
25
|
+
};
|
|
13
26
|
} catch {
|
|
14
27
|
throw new Error("Invalid subscribe token");
|
|
15
28
|
}
|
|
@@ -50,10 +63,11 @@ const createRealTimeSubscribeClient = (opts) => {
|
|
|
50
63
|
const { baseUrl, apiKey, integration } = opts;
|
|
51
64
|
const logger = opts.logger ?? createConsoleLogger("info");
|
|
52
65
|
const subscribe = async (options) => {
|
|
53
|
-
const { room_name: roomName } = decodeSubscribeToken(options.token);
|
|
66
|
+
const { room_name: roomName, frame_timing: frameTiming } = decodeSubscribeToken(options.token);
|
|
54
67
|
const { emitter, emitOrBuffer, flush, stop } = createEventBuffer();
|
|
55
68
|
let observability;
|
|
56
69
|
let room;
|
|
70
|
+
let frameMetadataWorker;
|
|
57
71
|
let currentState = "connecting";
|
|
58
72
|
let remoteStream = null;
|
|
59
73
|
const setState = (state) => {
|
|
@@ -77,7 +91,25 @@ const createRealTimeSubscribeClient = (opts) => {
|
|
|
77
91
|
apiKey,
|
|
78
92
|
roomName
|
|
79
93
|
});
|
|
80
|
-
|
|
94
|
+
if (frameTiming) {
|
|
95
|
+
if (!opts.createFrameMetadataWorker) throw createFrameMetadataSubscribeUnsupportedError("this platform has no frame-metadata worker");
|
|
96
|
+
if (!(opts.isFrameMetadataRuntimeSupported?.() ?? false)) throw createFrameMetadataSubscribeUnsupportedError("encoded transforms are unavailable or the SDK is served cross-origin");
|
|
97
|
+
try {
|
|
98
|
+
frameMetadataWorker = opts.createFrameMetadataWorker();
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw createFrameMetadataSubscribeUnsupportedError(`failed to create the required worker (${error instanceof Error ? error.message : String(error)})`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
room = new LiveKitRoom({
|
|
105
|
+
...REALTIME_CONFIG.livekit.roomOptions,
|
|
106
|
+
...frameMetadataWorker ? { frameMetadata: { worker: frameMetadataWorker } } : {}
|
|
107
|
+
});
|
|
108
|
+
} catch (error) {
|
|
109
|
+
frameMetadataWorker?.terminate();
|
|
110
|
+
frameMetadataWorker = void 0;
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
81
113
|
const activeRoom = room;
|
|
82
114
|
activeRoom.on(RoomEvent.TrackSubscribed, (track, _pub, participant) => {
|
|
83
115
|
if (!participant.identity.startsWith(REALTIME_CONFIG.livekit.inferenceServerIdentityPrefix)) return;
|
|
@@ -114,6 +146,11 @@ const createRealTimeSubscribeClient = (opts) => {
|
|
|
114
146
|
} catch (error) {
|
|
115
147
|
observability?.stop();
|
|
116
148
|
if (room) room.disconnect().catch(() => {});
|
|
149
|
+
frameMetadataWorker?.terminate();
|
|
150
|
+
if (isDecartSDKError(error)) {
|
|
151
|
+
logger.error("Realtime subscribe error", { error: error.message });
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
117
154
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
118
155
|
logger.error("Realtime subscribe error", { error: err.message });
|
|
119
156
|
throw classifyWebrtcError(err);
|