@decartai/sdk 0.0.48 → 0.0.50

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/dist/index.d.ts CHANGED
@@ -1,15 +1,18 @@
1
+ import { LogLevel, Logger, createConsoleLogger, noopLogger } from "./utils/logger.js";
1
2
  import { ImageModelDefinition, ImageModels, Model, ModelDefinition, RealTimeModels, VideoModelDefinition, VideoModels, isImageModel, isRealtimeModel, isVideoModel, models } from "./shared/model.js";
2
3
  import { FileInput, ProcessOptions, ReactNativeFile } from "./process/types.js";
3
4
  import { ProcessClient } from "./process/client.js";
4
5
  import { JobStatus, JobStatusResponse, JobSubmitResponse, QueueJobResult, QueueSubmitAndPollOptions, QueueSubmitOptions } from "./queue/types.js";
5
6
  import { QueueClient } from "./queue/client.js";
6
7
  import { DecartSDKError, ERROR_CODES } from "./utils/errors.js";
8
+ import { ConnectionPhase, DiagnosticEvent, DiagnosticEventName, DiagnosticEvents, IceCandidateEvent, IceStateEvent, PeerConnectionStateEvent, PhaseTimingEvent, ReconnectEvent, SelectedCandidatePairEvent, SignalingStateEvent, VideoStallEvent } from "./realtime/diagnostics.js";
7
9
  import { ConnectionState } from "./realtime/types.js";
8
10
  import { SetInput } from "./realtime/methods.js";
9
11
  import { RealTimeSubscribeClient, SubscribeEvents, SubscribeOptions } from "./realtime/subscribe-client.js";
12
+ import { WebRTCStats } from "./realtime/webrtc-stats.js";
10
13
  import { Events, RealTimeClient, RealTimeClientConnectOptions, RealTimeClientInitialState } from "./realtime/client.js";
11
14
  import { ModelState } from "./shared/types.js";
12
- import { CreateTokenResponse, TokensClient } from "./tokens/client.js";
15
+ import { CreateTokenOptions, CreateTokenResponse, TokensClient } from "./tokens/client.js";
13
16
 
14
17
  //#region src/index.d.ts
15
18
  type DecartClientOptions = {
@@ -17,11 +20,15 @@ type DecartClientOptions = {
17
20
  apiKey?: never;
18
21
  baseUrl?: string;
19
22
  integration?: string;
23
+ logger?: Logger;
24
+ telemetry?: boolean;
20
25
  } | {
21
26
  proxy?: never;
22
27
  apiKey?: string;
23
28
  baseUrl?: string;
24
29
  integration?: string;
30
+ logger?: Logger;
31
+ telemetry?: boolean;
25
32
  };
26
33
  /**
27
34
  * Create a Decart API client.
@@ -121,4 +128,4 @@ declare const createDecartClient: (options?: DecartClientOptions) => {
121
128
  tokens: TokensClient;
122
129
  };
123
130
  //#endregion
124
- export { type ConnectionState, type CreateTokenResponse, DecartClientOptions, type DecartSDKError, ERROR_CODES, type FileInput, type ImageModelDefinition, type ImageModels, type JobStatus, type JobStatusResponse, type JobSubmitResponse, type Model, type ModelDefinition, type ModelState, type ProcessClient, type ProcessOptions, type QueueClient, type QueueJobResult, type QueueSubmitAndPollOptions, type QueueSubmitOptions, type ReactNativeFile, type RealTimeClient, type RealTimeClientConnectOptions, type RealTimeClientInitialState, type Events as RealTimeEvents, type RealTimeModels, type RealTimeSubscribeClient, type SetInput, type SubscribeEvents, type SubscribeOptions, type TokensClient, type VideoModelDefinition, type VideoModels, createDecartClient, isImageModel, isRealtimeModel, isVideoModel, models };
131
+ export { type ConnectionPhase, type ConnectionState, type CreateTokenOptions, type CreateTokenResponse, DecartClientOptions, type DecartSDKError, type DiagnosticEvent, type DiagnosticEventName, type DiagnosticEvents, ERROR_CODES, type FileInput, type IceCandidateEvent, type IceStateEvent, type ImageModelDefinition, type ImageModels, type JobStatus, type JobStatusResponse, type JobSubmitResponse, type LogLevel, type Logger, type Model, type ModelDefinition, type ModelState, type PeerConnectionStateEvent, type PhaseTimingEvent, type ProcessClient, type ProcessOptions, type QueueClient, type QueueJobResult, type QueueSubmitAndPollOptions, type QueueSubmitOptions, type ReactNativeFile, type RealTimeClient, type RealTimeClientConnectOptions, type RealTimeClientInitialState, type Events as RealTimeEvents, type RealTimeModels, type RealTimeSubscribeClient, type ReconnectEvent, type SelectedCandidatePairEvent, type SetInput, type SignalingStateEvent, type SubscribeEvents, type SubscribeOptions, type TokensClient, type VideoModelDefinition, type VideoModels, type VideoStallEvent, type WebRTCStats, createConsoleLogger, createDecartClient, isImageModel, isRealtimeModel, isVideoModel, models, noopLogger };
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import { isImageModel, isRealtimeModel, isVideoModel, models } from "./shared/mo
5
5
  import { createRealTimeClient } from "./realtime/client.js";
6
6
  import { createTokensClient } from "./tokens/client.js";
7
7
  import { readEnv } from "./utils/env.js";
8
+ import { createConsoleLogger, noopLogger } from "./utils/logger.js";
8
9
  import { z } from "zod";
9
10
 
10
11
  //#region src/index.ts
@@ -56,11 +57,15 @@ const createDecartClient = (options = {}) => {
56
57
  if (isProxyMode && "proxy" in parsedOptions.data && parsedOptions.data.proxy) baseUrl = parsedOptions.data.proxy;
57
58
  else baseUrl = parsedOptions.data.baseUrl || "https://api.decart.ai";
58
59
  const { integration } = parsedOptions.data;
60
+ const logger = "logger" in options && options.logger ? options.logger : noopLogger;
61
+ const telemetryEnabled = "telemetry" in options && options.telemetry === false ? false : true;
59
62
  return {
60
63
  realtime: createRealTimeClient({
61
64
  baseUrl: "wss://api3.decart.ai",
62
65
  apiKey: apiKey || "",
63
- integration
66
+ integration,
67
+ logger,
68
+ telemetryEnabled
64
69
  }),
65
70
  process: createProcessClient({
66
71
  baseUrl,
@@ -81,4 +86,4 @@ const createDecartClient = (options = {}) => {
81
86
  };
82
87
 
83
88
  //#endregion
84
- export { ERROR_CODES, createDecartClient, isImageModel, isRealtimeModel, isVideoModel, models };
89
+ export { ERROR_CODES, createConsoleLogger, createDecartClient, isImageModel, isRealtimeModel, isVideoModel, models, noopLogger };
@@ -1,6 +1,8 @@
1
1
  import { DecartSDKError } from "../utils/errors.js";
2
+ import { DiagnosticEvent } from "./diagnostics.js";
2
3
  import { ConnectionState } from "./types.js";
3
4
  import { SetInput } from "./methods.js";
5
+ import { WebRTCStats } from "./webrtc-stats.js";
4
6
  import { z } from "zod";
5
7
 
6
8
  //#region src/realtime/client.d.ts
@@ -41,6 +43,8 @@ type Events = {
41
43
  generationTick: {
42
44
  seconds: number;
43
45
  };
46
+ diagnostic: DiagnosticEvent;
47
+ stats: WebRTCStats;
44
48
  };
45
49
  type RealTimeClient = {
46
50
  set: (input: SetInput) => Promise<void>;
@@ -1,11 +1,13 @@
1
- import { createWebrtcError } from "../utils/errors.js";
1
+ import { classifyWebrtcError } from "../utils/errors.js";
2
2
  import { modelDefinitionSchema } from "../shared/model.js";
3
3
  import { modelStateSchema } from "../shared/types.js";
4
4
  import { AudioStreamManager } from "./audio-stream-manager.js";
5
5
  import { createEventBuffer } from "./event-buffer.js";
6
6
  import { realtimeMethods } from "./methods.js";
7
7
  import { decodeSubscribeToken, encodeSubscribeToken } from "./subscribe-client.js";
8
+ import { NullTelemetryReporter, TelemetryReporter } from "./telemetry-reporter.js";
8
9
  import { WebRTCManager } from "./webrtc-manager.js";
10
+ import { WebRTCStatsCollector } from "./webrtc-stats.js";
9
11
  import { z } from "zod";
10
12
 
11
13
  //#region src/realtime/client.ts
@@ -58,7 +60,7 @@ const realTimeClientConnectOptionsSchema = z.object({
58
60
  customizeOffer: createAsyncFunctionSchema(z.function()).optional()
59
61
  });
60
62
  const createRealTimeClient = (opts) => {
61
- const { baseUrl, apiKey, integration } = opts;
63
+ const { baseUrl, apiKey, integration, logger } = opts;
62
64
  const connect = async (stream, options) => {
63
65
  const parsedOptions = realTimeClientConnectOptionsSchema.safeParse(options);
64
66
  if (!parsedOptions.success) throw parsedOptions.error;
@@ -71,6 +73,8 @@ const createRealTimeClient = (opts) => {
71
73
  inputStream = audioStreamManager.getStream();
72
74
  } else inputStream = stream ?? new MediaStream();
73
75
  let webrtcManager;
76
+ let telemetryReporter = new NullTelemetryReporter();
77
+ let handleConnectionStateChange = null;
74
78
  try {
75
79
  const initialImage = initialState?.image ? await imageToBase64(initialState.image) : void 0;
76
80
  const initialPrompt = initialState?.prompt ? {
@@ -82,13 +86,22 @@ const createRealTimeClient = (opts) => {
82
86
  webrtcManager = new WebRTCManager({
83
87
  webrtcUrl: `${url}?api_key=${encodeURIComponent(apiKey)}&model=${encodeURIComponent(options.model.name)}`,
84
88
  integration,
89
+ logger,
90
+ onDiagnostic: (name, data) => {
91
+ emitOrBuffer("diagnostic", {
92
+ name,
93
+ data
94
+ });
95
+ addTelemetryDiagnostic(name, data);
96
+ },
85
97
  onRemoteStream,
86
98
  onConnectionStateChange: (state) => {
87
99
  emitOrBuffer("connectionChange", state);
100
+ handleConnectionStateChange?.(state);
88
101
  },
89
102
  onError: (error) => {
90
- console.error("WebRTC error:", error);
91
- emitOrBuffer("error", createWebrtcError(error));
103
+ logger.error("WebRTC error", { error: error.message });
104
+ emitOrBuffer("error", classifyWebrtcError(error));
92
105
  },
93
106
  customizeOffer: options.customizeOffer,
94
107
  vp8MinBitrate: 300,
@@ -100,9 +113,42 @@ const createRealTimeClient = (opts) => {
100
113
  const manager = webrtcManager;
101
114
  let sessionId = null;
102
115
  let subscribeToken = null;
116
+ const pendingTelemetryDiagnostics = [];
117
+ let telemetryReporterReady = false;
118
+ const addTelemetryDiagnostic = (name, data, timestamp = Date.now()) => {
119
+ if (!opts.telemetryEnabled) return;
120
+ if (!telemetryReporterReady) {
121
+ pendingTelemetryDiagnostics.push({
122
+ name,
123
+ data,
124
+ timestamp
125
+ });
126
+ return;
127
+ }
128
+ telemetryReporter.addDiagnostic({
129
+ name,
130
+ data,
131
+ timestamp
132
+ });
133
+ };
103
134
  const sessionIdListener = (msg) => {
104
135
  subscribeToken = encodeSubscribeToken(msg.session_id, msg.server_ip, msg.server_port);
105
136
  sessionId = msg.session_id;
137
+ if (opts.telemetryEnabled) {
138
+ if (telemetryReporterReady) telemetryReporter.stop();
139
+ const reporter = new TelemetryReporter({
140
+ apiKey,
141
+ sessionId: msg.session_id,
142
+ model: options.model.name,
143
+ integration,
144
+ logger
145
+ });
146
+ reporter.start();
147
+ telemetryReporter = reporter;
148
+ telemetryReporterReady = true;
149
+ for (const diagnostic of pendingTelemetryDiagnostics) telemetryReporter.addDiagnostic(diagnostic);
150
+ pendingTelemetryDiagnostics.length = 0;
151
+ }
106
152
  };
107
153
  manager.getWebsocketMessageEmitter().on("sessionId", sessionIdListener);
108
154
  const tickListener = (msg) => {
@@ -111,12 +157,74 @@ const createRealTimeClient = (opts) => {
111
157
  manager.getWebsocketMessageEmitter().on("generationTick", tickListener);
112
158
  await manager.connect(inputStream);
113
159
  const methods = realtimeMethods(manager, imageToBase64);
160
+ let statsCollector = null;
161
+ let statsCollectorPeerConnection = null;
162
+ const STALL_FPS_THRESHOLD = .5;
163
+ let videoStalled = false;
164
+ let stallStartMs = 0;
165
+ const startStatsCollection = () => {
166
+ statsCollector?.stop();
167
+ videoStalled = false;
168
+ stallStartMs = 0;
169
+ statsCollector = new WebRTCStatsCollector();
170
+ const pc = manager.getPeerConnection();
171
+ statsCollectorPeerConnection = pc;
172
+ if (pc) statsCollector.start(pc, (stats) => {
173
+ emitOrBuffer("stats", stats);
174
+ telemetryReporter.addStats(stats);
175
+ const fps = stats.video?.framesPerSecond ?? 0;
176
+ if (!videoStalled && stats.video && fps < STALL_FPS_THRESHOLD) {
177
+ videoStalled = true;
178
+ stallStartMs = Date.now();
179
+ emitOrBuffer("diagnostic", {
180
+ name: "videoStall",
181
+ data: {
182
+ stalled: true,
183
+ durationMs: 0
184
+ }
185
+ });
186
+ addTelemetryDiagnostic("videoStall", {
187
+ stalled: true,
188
+ durationMs: 0
189
+ }, stallStartMs);
190
+ } else if (videoStalled && fps >= STALL_FPS_THRESHOLD) {
191
+ const durationMs = Date.now() - stallStartMs;
192
+ videoStalled = false;
193
+ emitOrBuffer("diagnostic", {
194
+ name: "videoStall",
195
+ data: {
196
+ stalled: false,
197
+ durationMs
198
+ }
199
+ });
200
+ addTelemetryDiagnostic("videoStall", {
201
+ stalled: false,
202
+ durationMs
203
+ });
204
+ }
205
+ });
206
+ return () => {
207
+ statsCollector?.stop();
208
+ statsCollector = null;
209
+ statsCollectorPeerConnection = null;
210
+ };
211
+ };
212
+ handleConnectionStateChange = (state) => {
213
+ if (!opts.telemetryEnabled) return;
214
+ if (state !== "connected" && state !== "generating") return;
215
+ const peerConnection = manager.getPeerConnection();
216
+ if (!peerConnection || peerConnection === statsCollectorPeerConnection) return;
217
+ startStatsCollection();
218
+ };
219
+ if (opts.telemetryEnabled) startStatsCollection();
114
220
  const client = {
115
221
  set: methods.set,
116
222
  setPrompt: methods.setPrompt,
117
223
  isConnected: () => manager.isConnected(),
118
224
  getConnectionState: () => manager.getConnectionState(),
119
225
  disconnect: () => {
226
+ statsCollector?.stop();
227
+ telemetryReporter.stop();
120
228
  stop();
121
229
  manager.cleanup();
122
230
  audioStreamManager?.cleanup();
@@ -142,6 +250,7 @@ const createRealTimeClient = (opts) => {
142
250
  flush();
143
251
  return client;
144
252
  } catch (error) {
253
+ telemetryReporter.stop();
145
254
  webrtcManager?.cleanup();
146
255
  audioStreamManager?.cleanup();
147
256
  throw error;
@@ -156,13 +265,20 @@ const createRealTimeClient = (opts) => {
156
265
  webrtcManager = new WebRTCManager({
157
266
  webrtcUrl: subscribeUrl,
158
267
  integration,
268
+ logger,
269
+ onDiagnostic: (name, data) => {
270
+ emitOrBuffer("diagnostic", {
271
+ name,
272
+ data
273
+ });
274
+ },
159
275
  onRemoteStream: options.onRemoteStream,
160
276
  onConnectionStateChange: (state) => {
161
277
  emitOrBuffer("connectionChange", state);
162
278
  },
163
279
  onError: (error) => {
164
- console.error("WebRTC subscribe error:", error);
165
- emitOrBuffer("error", createWebrtcError(error));
280
+ logger.error("WebRTC subscribe error", { error: error.message });
281
+ emitOrBuffer("error", classifyWebrtcError(error));
166
282
  }
167
283
  });
168
284
  const manager = webrtcManager;
@@ -0,0 +1,78 @@
1
+ //#region src/realtime/diagnostics.d.ts
2
+ /** Connection phase names for timing events. */
3
+ type ConnectionPhase = "websocket" | "avatar-image" | "initial-prompt" | "webrtc-handshake" | "total";
4
+ type PhaseTimingEvent = {
5
+ phase: ConnectionPhase;
6
+ durationMs: number;
7
+ success: boolean;
8
+ error?: string;
9
+ };
10
+ type IceCandidateEvent = {
11
+ source: "local" | "remote";
12
+ candidateType: "host" | "srflx" | "prflx" | "relay" | "unknown";
13
+ protocol: "udp" | "tcp" | "unknown";
14
+ address?: string;
15
+ port?: number;
16
+ };
17
+ type IceStateEvent = {
18
+ state: string;
19
+ previousState: string;
20
+ timestampMs: number;
21
+ };
22
+ type PeerConnectionStateEvent = {
23
+ state: string;
24
+ previousState: string;
25
+ timestampMs: number;
26
+ };
27
+ type SignalingStateEvent = {
28
+ state: string;
29
+ previousState: string;
30
+ timestampMs: number;
31
+ };
32
+ type SelectedCandidatePairEvent = {
33
+ local: {
34
+ candidateType: string;
35
+ protocol: string;
36
+ address?: string;
37
+ port?: number;
38
+ };
39
+ remote: {
40
+ candidateType: string;
41
+ protocol: string;
42
+ address?: string;
43
+ port?: number;
44
+ };
45
+ };
46
+ type ReconnectEvent = {
47
+ attempt: number;
48
+ maxAttempts: number;
49
+ durationMs: number;
50
+ success: boolean;
51
+ error?: string;
52
+ };
53
+ type VideoStallEvent = {
54
+ /** True when a stall is detected, false when recovered. */
55
+ stalled: boolean;
56
+ /** Duration of the stall in ms (0 when stall first detected, actual duration on recovery). */
57
+ durationMs: number;
58
+ };
59
+ /** All diagnostic event types keyed by name. */
60
+ type DiagnosticEvents = {
61
+ phaseTiming: PhaseTimingEvent;
62
+ iceCandidate: IceCandidateEvent;
63
+ iceStateChange: IceStateEvent;
64
+ peerConnectionStateChange: PeerConnectionStateEvent;
65
+ signalingStateChange: SignalingStateEvent;
66
+ selectedCandidatePair: SelectedCandidatePairEvent;
67
+ reconnect: ReconnectEvent;
68
+ videoStall: VideoStallEvent;
69
+ };
70
+ type DiagnosticEventName = keyof DiagnosticEvents;
71
+ /** A single diagnostic event with its name and typed data. */
72
+ type DiagnosticEvent = { [K in DiagnosticEventName]: {
73
+ name: K;
74
+ data: DiagnosticEvents[K];
75
+ } }[DiagnosticEventName];
76
+ /** Callback for emitting diagnostic events from the connection/manager layers. */
77
+ //#endregion
78
+ export { ConnectionPhase, DiagnosticEvent, DiagnosticEventName, DiagnosticEvents, IceCandidateEvent, IceStateEvent, PeerConnectionStateEvent, PhaseTimingEvent, ReconnectEvent, SelectedCandidatePairEvent, SignalingStateEvent, VideoStallEvent };
@@ -1,4 +1,5 @@
1
1
  import { DecartSDKError } from "../utils/errors.js";
2
+ import { DiagnosticEvent } from "./diagnostics.js";
2
3
  import { ConnectionState } from "./types.js";
3
4
 
4
5
  //#region src/realtime/subscribe-client.d.ts
@@ -6,6 +7,7 @@ import { ConnectionState } from "./types.js";
6
7
  type SubscribeEvents = {
7
8
  connectionChange: ConnectionState;
8
9
  error: DecartSDKError;
10
+ diagnostic: DiagnosticEvent;
9
11
  };
10
12
  type RealTimeSubscribeClient = {
11
13
  isConnected: () => boolean;
@@ -0,0 +1,120 @@
1
+ import { VERSION } from "../version.js";
2
+ import { buildAuthHeaders } from "../shared/request.js";
3
+
4
+ //#region src/realtime/telemetry-reporter.ts
5
+ const DEFAULT_REPORT_INTERVAL_MS = 1e4;
6
+ const TELEMETRY_URL = "https://platform.decart.ai/api/v1/telemetry";
7
+ /**
8
+ * Maximum number of items per array (stats / diagnostics) in a single report.
9
+ * Matches the backend Zod schema which enforces `z.array().max(120)`.
10
+ */
11
+ const MAX_ITEMS_PER_REPORT = 120;
12
+ /** No-op reporter that silently discards all data. Used when telemetry is disabled. */
13
+ var NullTelemetryReporter = class {
14
+ start() {}
15
+ addStats() {}
16
+ addDiagnostic() {}
17
+ flush() {}
18
+ stop() {}
19
+ };
20
+ var TelemetryReporter = class {
21
+ apiKey;
22
+ sessionId;
23
+ model;
24
+ integration;
25
+ logger;
26
+ reportIntervalMs;
27
+ intervalId = null;
28
+ statsBuffer = [];
29
+ diagnosticsBuffer = [];
30
+ constructor(options) {
31
+ this.apiKey = options.apiKey;
32
+ this.sessionId = options.sessionId;
33
+ this.model = options.model;
34
+ this.integration = options.integration;
35
+ this.logger = options.logger;
36
+ this.reportIntervalMs = options.reportIntervalMs ?? DEFAULT_REPORT_INTERVAL_MS;
37
+ }
38
+ /** Start the periodic reporting timer. */
39
+ start() {
40
+ if (this.intervalId !== null) return;
41
+ this.intervalId = setInterval(() => this.flush(), this.reportIntervalMs);
42
+ }
43
+ /** Add a stats snapshot to the buffer. */
44
+ addStats(stats) {
45
+ this.statsBuffer.push(stats);
46
+ }
47
+ /** Add a diagnostic event to the buffer. */
48
+ addDiagnostic(event) {
49
+ this.diagnosticsBuffer.push(event);
50
+ }
51
+ /** Flush buffered data immediately. */
52
+ flush() {
53
+ this.sendReport(false);
54
+ }
55
+ /** Stop the reporter and send a final report with keepalive. */
56
+ stop() {
57
+ if (this.intervalId !== null) {
58
+ clearInterval(this.intervalId);
59
+ this.intervalId = null;
60
+ }
61
+ this.sendReport(true);
62
+ }
63
+ /**
64
+ * Build a single chunk from the front of the buffers, respecting MAX_ITEMS_PER_REPORT.
65
+ * Returns null when both buffers are empty.
66
+ */
67
+ createReportChunk() {
68
+ if (this.statsBuffer.length === 0 && this.diagnosticsBuffer.length === 0) return null;
69
+ const tags = {
70
+ session_id: this.sessionId,
71
+ sdk_version: VERSION,
72
+ ...this.model ? { model: this.model } : {},
73
+ ...this.integration ? { integration: this.integration } : {}
74
+ };
75
+ return {
76
+ sessionId: this.sessionId,
77
+ timestamp: Date.now(),
78
+ sdkVersion: VERSION,
79
+ ...this.model ? { model: this.model } : {},
80
+ tags,
81
+ stats: this.statsBuffer.splice(0, MAX_ITEMS_PER_REPORT),
82
+ diagnostics: this.diagnosticsBuffer.splice(0, MAX_ITEMS_PER_REPORT)
83
+ };
84
+ }
85
+ sendReport(keepalive) {
86
+ if (this.statsBuffer.length === 0 && this.diagnosticsBuffer.length === 0) return;
87
+ try {
88
+ const commonHeaders = {
89
+ ...buildAuthHeaders({
90
+ apiKey: this.apiKey,
91
+ integration: this.integration
92
+ }),
93
+ "Content-Type": "application/json"
94
+ };
95
+ let chunk = this.createReportChunk();
96
+ while (chunk !== null) {
97
+ const isLast = this.statsBuffer.length === 0 && this.diagnosticsBuffer.length === 0;
98
+ fetch(TELEMETRY_URL, {
99
+ method: "POST",
100
+ headers: commonHeaders,
101
+ body: JSON.stringify(chunk),
102
+ keepalive: keepalive && isLast
103
+ }).then((response) => {
104
+ if (!response.ok) this.logger.warn("Telemetry report rejected", {
105
+ status: response.status,
106
+ statusText: response.statusText
107
+ });
108
+ }).catch((error) => {
109
+ this.logger.debug("Telemetry report failed", { error: String(error) });
110
+ });
111
+ chunk = this.createReportChunk();
112
+ }
113
+ } catch (error) {
114
+ this.logger.debug("Telemetry report failed", { error: String(error) });
115
+ }
116
+ }
117
+ };
118
+
119
+ //#endregion
120
+ export { NullTelemetryReporter, TelemetryReporter };
@@ -4,15 +4,28 @@ import mitt from "mitt";
4
4
  //#region src/realtime/webrtc-connection.ts
5
5
  const ICE_SERVERS = [{ urls: "stun:stun.l.google.com:19302" }];
6
6
  const AVATAR_SETUP_TIMEOUT_MS = 3e4;
7
+ const noopDiagnostic = () => {};
7
8
  var WebRTCConnection = class {
8
9
  pc = null;
9
10
  ws = null;
10
11
  localStream = null;
11
12
  connectionReject = null;
13
+ logger;
14
+ emitDiagnostic;
12
15
  state = "disconnected";
13
16
  websocketMessagesEmitter = mitt();
14
17
  constructor(callbacks = {}) {
15
18
  this.callbacks = callbacks;
19
+ this.logger = callbacks.logger ?? {
20
+ debug() {},
21
+ info() {},
22
+ warn() {},
23
+ error() {}
24
+ };
25
+ this.emitDiagnostic = callbacks.onDiagnostic ?? noopDiagnostic;
26
+ }
27
+ getPeerConnection() {
28
+ return this.pc;
16
29
  }
17
30
  async connect(url, localStream, timeout, integration) {
18
31
  const deadline = Date.now() + timeout;
@@ -25,24 +38,37 @@ var WebRTCConnection = class {
25
38
  });
26
39
  connectAbort.catch(() => {});
27
40
  this.connectionReject = (error) => rejectConnect(error);
41
+ const totalStart = performance.now();
28
42
  try {
43
+ const wsStart = performance.now();
29
44
  await Promise.race([new Promise((resolve, reject) => {
30
45
  const timer = setTimeout(() => reject(/* @__PURE__ */ new Error("WebSocket timeout")), timeout);
31
46
  this.ws = new WebSocket(wsUrl);
32
47
  this.ws.onopen = () => {
33
48
  clearTimeout(timer);
49
+ this.emitDiagnostic("phaseTiming", {
50
+ phase: "websocket",
51
+ durationMs: performance.now() - wsStart,
52
+ success: true
53
+ });
34
54
  resolve();
35
55
  };
36
56
  this.ws.onmessage = (e) => {
37
57
  try {
38
58
  this.handleSignalingMessage(JSON.parse(e.data));
39
59
  } catch (err) {
40
- console.error("[WebRTC] Parse error:", err);
60
+ this.logger.error("Signaling message parse error", { error: String(err) });
41
61
  }
42
62
  };
43
63
  this.ws.onerror = () => {
44
64
  clearTimeout(timer);
45
65
  const error = /* @__PURE__ */ new Error("WebSocket error");
66
+ this.emitDiagnostic("phaseTiming", {
67
+ phase: "websocket",
68
+ durationMs: performance.now() - wsStart,
69
+ success: false,
70
+ error: error.message
71
+ });
46
72
  reject(error);
47
73
  rejectConnect(error);
48
74
  };
@@ -53,27 +79,65 @@ var WebRTCConnection = class {
53
79
  rejectConnect(/* @__PURE__ */ new Error("WebSocket closed"));
54
80
  };
55
81
  }), connectAbort]);
56
- if (this.callbacks.initialImage) await Promise.race([this.setImageBase64(this.callbacks.initialImage, {
57
- prompt: this.callbacks.initialPrompt?.text,
58
- enhance: this.callbacks.initialPrompt?.enhance
59
- }), connectAbort]);
60
- else if (this.callbacks.initialPrompt) await Promise.race([this.sendInitialPrompt(this.callbacks.initialPrompt), connectAbort]);
82
+ if (this.callbacks.initialImage) {
83
+ const imageStart = performance.now();
84
+ await Promise.race([this.setImageBase64(this.callbacks.initialImage, {
85
+ prompt: this.callbacks.initialPrompt?.text,
86
+ enhance: this.callbacks.initialPrompt?.enhance
87
+ }), connectAbort]);
88
+ this.emitDiagnostic("phaseTiming", {
89
+ phase: "avatar-image",
90
+ durationMs: performance.now() - imageStart,
91
+ success: true
92
+ });
93
+ } else if (this.callbacks.initialPrompt) {
94
+ const promptStart = performance.now();
95
+ await Promise.race([this.sendInitialPrompt(this.callbacks.initialPrompt), connectAbort]);
96
+ this.emitDiagnostic("phaseTiming", {
97
+ phase: "initial-prompt",
98
+ durationMs: performance.now() - promptStart,
99
+ success: true
100
+ });
101
+ }
102
+ const handshakeStart = performance.now();
61
103
  await this.setupNewPeerConnection();
62
104
  await Promise.race([new Promise((resolve, reject) => {
63
105
  const checkConnection = setInterval(() => {
64
106
  if (this.state === "connected" || this.state === "generating") {
65
107
  clearInterval(checkConnection);
108
+ this.emitDiagnostic("phaseTiming", {
109
+ phase: "webrtc-handshake",
110
+ durationMs: performance.now() - handshakeStart,
111
+ success: true
112
+ });
66
113
  resolve();
67
114
  } else if (this.state === "disconnected") {
68
115
  clearInterval(checkConnection);
116
+ this.emitDiagnostic("phaseTiming", {
117
+ phase: "webrtc-handshake",
118
+ durationMs: performance.now() - handshakeStart,
119
+ success: false,
120
+ error: "Connection lost during handshake"
121
+ });
69
122
  reject(/* @__PURE__ */ new Error("Connection lost during WebRTC handshake"));
70
123
  } else if (Date.now() >= deadline) {
71
124
  clearInterval(checkConnection);
125
+ this.emitDiagnostic("phaseTiming", {
126
+ phase: "webrtc-handshake",
127
+ durationMs: performance.now() - handshakeStart,
128
+ success: false,
129
+ error: "Timeout"
130
+ });
72
131
  reject(/* @__PURE__ */ new Error("Connection timeout"));
73
132
  }
74
133
  }, 100);
75
134
  connectAbort.catch(() => clearInterval(checkConnection));
76
135
  }), connectAbort]);
136
+ this.emitDiagnostic("phaseTiming", {
137
+ phase: "total",
138
+ durationMs: performance.now() - totalStart,
139
+ success: true
140
+ });
77
141
  } finally {
78
142
  this.connectionReject = null;
79
143
  }
@@ -82,6 +146,7 @@ var WebRTCConnection = class {
82
146
  try {
83
147
  if (msg.type === "error") {
84
148
  const error = new Error(msg.error);
149
+ error.source = "server";
85
150
  this.callbacks.onError?.(error);
86
151
  if (this.connectionReject) {
87
152
  this.connectionReject(error);
@@ -144,7 +209,14 @@ var WebRTCConnection = class {
144
209
  });
145
210
  break;
146
211
  case "ice-candidate":
147
- if (msg.candidate) await this.pc.addIceCandidate(msg.candidate);
212
+ if (msg.candidate) {
213
+ await this.pc.addIceCandidate(msg.candidate);
214
+ this.emitDiagnostic("iceCandidate", {
215
+ source: "remote",
216
+ candidateType: msg.candidate.candidate?.match(/typ (\w+)/)?.[1] ?? "unknown",
217
+ protocol: msg.candidate.candidate?.match(/udp|tcp/i)?.[0]?.toLowerCase() ?? "unknown"
218
+ });
219
+ }
148
220
  break;
149
221
  case "ice-restart": {
150
222
  const turnConfig = msg.turn_config;
@@ -153,7 +225,7 @@ var WebRTCConnection = class {
153
225
  }
154
226
  }
155
227
  } catch (error) {
156
- console.error("[WebRTC] Error:", error);
228
+ this.logger.error("Signaling handler error", { error: String(error) });
157
229
  this.callbacks.onError?.(error);
158
230
  this.connectionReject?.(error);
159
231
  }
@@ -163,7 +235,7 @@ var WebRTCConnection = class {
163
235
  this.ws.send(JSON.stringify(message));
164
236
  return true;
165
237
  }
166
- console.warn("[WebRTC] Message dropped: WebSocket is not open");
238
+ this.logger.warn("Message dropped: WebSocket is not open");
167
239
  return false;
168
240
  }
169
241
  async setImageBase64(imageBase64, options) {
@@ -265,22 +337,90 @@ var WebRTCConnection = class {
265
337
  type: "ice-candidate",
266
338
  candidate: e.candidate
267
339
  });
340
+ if (e.candidate) this.emitDiagnostic("iceCandidate", {
341
+ source: "local",
342
+ candidateType: e.candidate.type ?? "unknown",
343
+ protocol: e.candidate.protocol ?? "unknown",
344
+ address: e.candidate.address ?? void 0,
345
+ port: e.candidate.port ?? void 0
346
+ });
268
347
  };
348
+ let prevPcState = "new";
269
349
  this.pc.onconnectionstatechange = () => {
270
350
  if (!this.pc) return;
271
351
  const s = this.pc.connectionState;
352
+ this.emitDiagnostic("peerConnectionStateChange", {
353
+ state: s,
354
+ previousState: prevPcState,
355
+ timestampMs: performance.now()
356
+ });
357
+ prevPcState = s;
358
+ if (s === "connected") this.emitSelectedCandidatePair();
272
359
  const nextState = s === "connected" ? "connected" : ["connecting", "new"].includes(s) ? "connecting" : "disconnected";
273
360
  if (this.state === "generating" && nextState !== "disconnected") return;
274
361
  this.setState(nextState);
275
362
  };
363
+ let prevIceState = "new";
276
364
  this.pc.oniceconnectionstatechange = () => {
277
- if (this.pc?.iceConnectionState === "failed") {
365
+ if (!this.pc) return;
366
+ const newIceState = this.pc.iceConnectionState;
367
+ this.emitDiagnostic("iceStateChange", {
368
+ state: newIceState,
369
+ previousState: prevIceState,
370
+ timestampMs: performance.now()
371
+ });
372
+ prevIceState = newIceState;
373
+ if (newIceState === "failed") {
278
374
  this.setState("disconnected");
279
375
  this.callbacks.onError?.(/* @__PURE__ */ new Error("ICE connection failed"));
280
376
  }
281
377
  };
378
+ let prevSignalingState = "stable";
379
+ this.pc.onsignalingstatechange = () => {
380
+ if (!this.pc) return;
381
+ const newState = this.pc.signalingState;
382
+ this.emitDiagnostic("signalingStateChange", {
383
+ state: newState,
384
+ previousState: prevSignalingState,
385
+ timestampMs: performance.now()
386
+ });
387
+ prevSignalingState = newState;
388
+ };
282
389
  this.handleSignalingMessage({ type: "ready" });
283
390
  }
391
+ async emitSelectedCandidatePair() {
392
+ if (!this.pc) return;
393
+ try {
394
+ const stats = await this.pc.getStats();
395
+ let found = false;
396
+ stats.forEach((report) => {
397
+ if (found) return;
398
+ if (report.type === "candidate-pair" && report.state === "succeeded") {
399
+ found = true;
400
+ let localCandidate;
401
+ let remoteCandidate;
402
+ stats.forEach((r) => {
403
+ if (r.id === report.localCandidateId) localCandidate = r;
404
+ if (r.id === report.remoteCandidateId) remoteCandidate = r;
405
+ });
406
+ if (localCandidate && remoteCandidate) this.emitDiagnostic("selectedCandidatePair", {
407
+ local: {
408
+ candidateType: String(localCandidate.candidateType ?? "unknown"),
409
+ protocol: String(localCandidate.protocol ?? "unknown"),
410
+ address: localCandidate.address,
411
+ port: localCandidate.port
412
+ },
413
+ remote: {
414
+ candidateType: String(remoteCandidate.candidateType ?? "unknown"),
415
+ protocol: String(remoteCandidate.protocol ?? "unknown"),
416
+ address: remoteCandidate.address,
417
+ port: remoteCandidate.port
418
+ }
419
+ });
420
+ }
421
+ });
422
+ } catch {}
423
+ }
284
424
  cleanup() {
285
425
  this.pc?.close();
286
426
  this.pc = null;
@@ -292,17 +432,17 @@ var WebRTCConnection = class {
292
432
  applyCodecPreference(preferredCodecName) {
293
433
  if (!this.pc) return;
294
434
  if (typeof RTCRtpSender === "undefined" || typeof RTCRtpSender.getCapabilities !== "function") {
295
- console.warn("RTCRtpSender capabilities are not available in this environment.");
435
+ this.logger.debug("RTCRtpSender capabilities not available in this environment");
296
436
  return;
297
437
  }
298
438
  const videoTransceiver = this.pc.getTransceivers().find((r) => r.sender.track?.kind === "video" || r.receiver.track?.kind === "video");
299
439
  if (!videoTransceiver) {
300
- console.error("Could not find video transceiver. Ensure track is added to peer connection.");
440
+ this.logger.warn("Video transceiver not found for codec preference");
301
441
  return;
302
442
  }
303
443
  const capabilities = RTCRtpSender.getCapabilities("video");
304
444
  if (!capabilities) {
305
- console.error("Could not get video sender capabilities.");
445
+ this.logger.warn("Video sender capabilities unavailable");
306
446
  return;
307
447
  }
308
448
  const preferredCodecs = [];
@@ -313,13 +453,13 @@ var WebRTCConnection = class {
313
453
  });
314
454
  const orderedCodecs = [...preferredCodecs, ...otherCodecs];
315
455
  if (orderedCodecs.length === 0) {
316
- console.warn("No video codecs found to set preferences for.");
456
+ this.logger.debug("No video codecs found for preference setting");
317
457
  return;
318
458
  }
319
459
  try {
320
460
  videoTransceiver.setCodecPreferences(orderedCodecs);
321
461
  } catch {
322
- console.warn("[WebRTC] setCodecPreferences not supported, skipping codec preference.");
462
+ this.logger.debug("setCodecPreferences not supported, skipping");
323
463
  }
324
464
  }
325
465
  modifyVP8Bitrate(offer) {
@@ -20,6 +20,7 @@ const RETRY_OPTIONS = {
20
20
  var WebRTCManager = class {
21
21
  connection;
22
22
  config;
23
+ logger;
23
24
  localStream = null;
24
25
  subscribeMode = false;
25
26
  managerState = "disconnected";
@@ -29,6 +30,12 @@ var WebRTCManager = class {
29
30
  reconnectGeneration = 0;
30
31
  constructor(config) {
31
32
  this.config = config;
33
+ this.logger = config.logger ?? {
34
+ debug() {},
35
+ info() {},
36
+ warn() {},
37
+ error() {}
38
+ };
32
39
  this.connection = new WebRTCConnection({
33
40
  onRemoteStream: config.onRemoteStream,
34
41
  onStateChange: (state) => this.handleConnectionStateChange(state),
@@ -38,7 +45,9 @@ var WebRTCManager = class {
38
45
  vp8StartBitrate: config.vp8StartBitrate,
39
46
  modelName: config.modelName,
40
47
  initialImage: config.initialImage,
41
- initialPrompt: config.initialPrompt
48
+ initialPrompt: config.initialPrompt,
49
+ logger: this.logger,
50
+ onDiagnostic: config.onDiagnostic
42
51
  });
43
52
  }
44
53
  emitState(state) {
@@ -72,8 +81,11 @@ var WebRTCManager = class {
72
81
  const reconnectGeneration = ++this.reconnectGeneration;
73
82
  this.isReconnecting = true;
74
83
  this.emitState("reconnecting");
84
+ const reconnectStart = performance.now();
75
85
  try {
86
+ let attemptCount = 0;
76
87
  await pRetry(async () => {
88
+ attemptCount++;
77
89
  if (this.intentionalDisconnect || reconnectGeneration !== this.reconnectGeneration) throw new AbortError("Reconnect cancelled");
78
90
  if (!this.subscribeMode && !this.localStream) throw new AbortError("Reconnect cancelled: no local stream");
79
91
  this.connection.cleanup();
@@ -86,7 +98,17 @@ var WebRTCManager = class {
86
98
  ...RETRY_OPTIONS,
87
99
  onFailedAttempt: (error) => {
88
100
  if (this.intentionalDisconnect || reconnectGeneration !== this.reconnectGeneration) return;
89
- console.error(`[WebRTC] Reconnect attempt failed: ${error.message}`);
101
+ this.logger.warn("Reconnect attempt failed", {
102
+ error: error.message,
103
+ attempt: error.attemptNumber
104
+ });
105
+ this.config.onDiagnostic?.("reconnect", {
106
+ attempt: error.attemptNumber,
107
+ maxAttempts: RETRY_OPTIONS.retries + 1,
108
+ durationMs: performance.now() - reconnectStart,
109
+ success: false,
110
+ error: error.message
111
+ });
90
112
  this.connection.cleanup();
91
113
  },
92
114
  shouldRetry: (error) => {
@@ -95,6 +117,12 @@ var WebRTCManager = class {
95
117
  return !PERMANENT_ERRORS.some((err) => msg.includes(err));
96
118
  }
97
119
  });
120
+ this.config.onDiagnostic?.("reconnect", {
121
+ attempt: attemptCount,
122
+ maxAttempts: RETRY_OPTIONS.retries + 1,
123
+ durationMs: performance.now() - reconnectStart,
124
+ success: true
125
+ });
98
126
  } catch (error) {
99
127
  this.isReconnecting = false;
100
128
  if (this.intentionalDisconnect || reconnectGeneration !== this.reconnectGeneration) return;
@@ -117,7 +145,10 @@ var WebRTCManager = class {
117
145
  }, {
118
146
  ...RETRY_OPTIONS,
119
147
  onFailedAttempt: (error) => {
120
- console.error(`[WebRTC] Failed to connect: ${error.message}`);
148
+ this.logger.warn("Connection attempt failed", {
149
+ error: error.message,
150
+ attempt: error.attemptNumber
151
+ });
121
152
  this.connection.cleanup();
122
153
  },
123
154
  shouldRetry: (error) => {
@@ -144,6 +175,9 @@ var WebRTCManager = class {
144
175
  getConnectionState() {
145
176
  return this.managerState;
146
177
  }
178
+ getPeerConnection() {
179
+ return this.connection.getPeerConnection();
180
+ }
147
181
  getWebsocketMessageEmitter() {
148
182
  return this.connection.websocketMessagesEmitter;
149
183
  }
@@ -0,0 +1,59 @@
1
+ //#region src/realtime/webrtc-stats.d.ts
2
+ type WebRTCStats = {
3
+ timestamp: number;
4
+ video: {
5
+ framesDecoded: number;
6
+ framesDropped: number;
7
+ framesPerSecond: number;
8
+ frameWidth: number;
9
+ frameHeight: number;
10
+ bytesReceived: number;
11
+ packetsReceived: number;
12
+ packetsLost: number;
13
+ jitter: number;
14
+ /** Estimated inbound bitrate in bits/sec, computed from bytesReceived delta. */
15
+ bitrate: number;
16
+ freezeCount: number;
17
+ totalFreezesDuration: number;
18
+ /** Delta: packets lost since previous sample. */
19
+ packetsLostDelta: number;
20
+ /** Delta: frames dropped since previous sample. */
21
+ framesDroppedDelta: number;
22
+ /** Delta: freeze count since previous sample. */
23
+ freezeCountDelta: number;
24
+ /** Delta: freeze duration (seconds) since previous sample. */
25
+ freezeDurationDelta: number;
26
+ } | null;
27
+ audio: {
28
+ bytesReceived: number;
29
+ packetsReceived: number;
30
+ packetsLost: number;
31
+ jitter: number;
32
+ /** Estimated inbound bitrate in bits/sec, computed from bytesReceived delta. */
33
+ bitrate: number;
34
+ /** Delta: packets lost since previous sample. */
35
+ packetsLostDelta: number;
36
+ } | null;
37
+ /** Outbound video track stats (from the local camera/screen share being sent). */
38
+ outboundVideo: {
39
+ /** Why the encoder is limiting quality: "none", "bandwidth", "cpu", or "other". */
40
+ qualityLimitationReason: string;
41
+ /** Cumulative time (seconds) spent in each quality limitation state. */
42
+ qualityLimitationDurations: Record<string, number>;
43
+ bytesSent: number;
44
+ packetsSent: number;
45
+ framesPerSecond: number;
46
+ frameWidth: number;
47
+ frameHeight: number;
48
+ /** Estimated outbound bitrate in bits/sec, computed from bytesSent delta. */
49
+ bitrate: number;
50
+ } | null;
51
+ connection: {
52
+ /** Current round-trip time in seconds, or null if unavailable. */
53
+ currentRoundTripTime: number | null;
54
+ /** Available outgoing bitrate estimate in bits/sec, or null if unavailable. */
55
+ availableOutgoingBitrate: number | null;
56
+ };
57
+ };
58
+ //#endregion
59
+ export { WebRTCStats };
@@ -0,0 +1,154 @@
1
+ //#region src/realtime/webrtc-stats.ts
2
+ const DEFAULT_INTERVAL_MS = 1e3;
3
+ const MIN_INTERVAL_MS = 500;
4
+ var WebRTCStatsCollector = class {
5
+ pc = null;
6
+ intervalId = null;
7
+ prevBytesVideo = 0;
8
+ prevBytesAudio = 0;
9
+ prevBytesSentVideo = 0;
10
+ prevTimestamp = 0;
11
+ prevPacketsLostVideo = 0;
12
+ prevFramesDropped = 0;
13
+ prevFreezeCount = 0;
14
+ prevFreezeDuration = 0;
15
+ prevPacketsLostAudio = 0;
16
+ onStats = null;
17
+ intervalMs;
18
+ constructor(options = {}) {
19
+ this.intervalMs = Math.max(options.intervalMs ?? DEFAULT_INTERVAL_MS, MIN_INTERVAL_MS);
20
+ }
21
+ /** Attach to a peer connection and start polling. */
22
+ start(pc, onStats) {
23
+ this.stop();
24
+ this.pc = pc;
25
+ this.onStats = onStats;
26
+ this.prevBytesVideo = 0;
27
+ this.prevBytesAudio = 0;
28
+ this.prevBytesSentVideo = 0;
29
+ this.prevTimestamp = 0;
30
+ this.prevPacketsLostVideo = 0;
31
+ this.prevFramesDropped = 0;
32
+ this.prevFreezeCount = 0;
33
+ this.prevFreezeDuration = 0;
34
+ this.prevPacketsLostAudio = 0;
35
+ this.intervalId = setInterval(() => this.collect(), this.intervalMs);
36
+ }
37
+ /** Stop polling and release resources. */
38
+ stop() {
39
+ if (this.intervalId !== null) {
40
+ clearInterval(this.intervalId);
41
+ this.intervalId = null;
42
+ }
43
+ this.pc = null;
44
+ this.onStats = null;
45
+ }
46
+ isRunning() {
47
+ return this.intervalId !== null;
48
+ }
49
+ async collect() {
50
+ if (!this.pc || !this.onStats) return;
51
+ try {
52
+ const rawStats = await this.pc.getStats();
53
+ const stats = this.parse(rawStats);
54
+ this.onStats(stats);
55
+ } catch {
56
+ this.stop();
57
+ }
58
+ }
59
+ parse(rawStats) {
60
+ const now = performance.now();
61
+ const elapsed = this.prevTimestamp > 0 ? (now - this.prevTimestamp) / 1e3 : 0;
62
+ let video = null;
63
+ let audio = null;
64
+ let outboundVideo = null;
65
+ const connection = {
66
+ currentRoundTripTime: null,
67
+ availableOutgoingBitrate: null
68
+ };
69
+ rawStats.forEach((report) => {
70
+ if (report.type === "inbound-rtp" && report.kind === "video") {
71
+ const bytesReceived = report.bytesReceived ?? 0;
72
+ const bitrate = elapsed > 0 ? (bytesReceived - this.prevBytesVideo) * 8 / elapsed : 0;
73
+ this.prevBytesVideo = bytesReceived;
74
+ const r = report;
75
+ const packetsLost = r.packetsLost ?? 0;
76
+ const framesDropped = r.framesDropped ?? 0;
77
+ const freezeCount = r.freezeCount ?? 0;
78
+ const freezeDuration = r.totalFreezesDuration ?? 0;
79
+ video = {
80
+ framesDecoded: r.framesDecoded ?? 0,
81
+ framesDropped,
82
+ framesPerSecond: r.framesPerSecond ?? 0,
83
+ frameWidth: r.frameWidth ?? 0,
84
+ frameHeight: r.frameHeight ?? 0,
85
+ bytesReceived,
86
+ packetsReceived: r.packetsReceived ?? 0,
87
+ packetsLost,
88
+ jitter: r.jitter ?? 0,
89
+ bitrate: Math.round(bitrate),
90
+ freezeCount,
91
+ totalFreezesDuration: freezeDuration,
92
+ packetsLostDelta: Math.max(0, packetsLost - this.prevPacketsLostVideo),
93
+ framesDroppedDelta: Math.max(0, framesDropped - this.prevFramesDropped),
94
+ freezeCountDelta: Math.max(0, freezeCount - this.prevFreezeCount),
95
+ freezeDurationDelta: Math.max(0, freezeDuration - this.prevFreezeDuration)
96
+ };
97
+ this.prevPacketsLostVideo = packetsLost;
98
+ this.prevFramesDropped = framesDropped;
99
+ this.prevFreezeCount = freezeCount;
100
+ this.prevFreezeDuration = freezeDuration;
101
+ }
102
+ if (report.type === "outbound-rtp" && report.kind === "video") {
103
+ const r = report;
104
+ const bytesSent = r.bytesSent ?? 0;
105
+ const outBitrate = elapsed > 0 ? (bytesSent - this.prevBytesSentVideo) * 8 / elapsed : 0;
106
+ this.prevBytesSentVideo = bytesSent;
107
+ outboundVideo = {
108
+ qualityLimitationReason: r.qualityLimitationReason ?? "none",
109
+ qualityLimitationDurations: r.qualityLimitationDurations ?? {},
110
+ bytesSent,
111
+ packetsSent: r.packetsSent ?? 0,
112
+ framesPerSecond: r.framesPerSecond ?? 0,
113
+ frameWidth: r.frameWidth ?? 0,
114
+ frameHeight: r.frameHeight ?? 0,
115
+ bitrate: Math.round(outBitrate)
116
+ };
117
+ }
118
+ if (report.type === "inbound-rtp" && report.kind === "audio") {
119
+ const bytesReceived = report.bytesReceived ?? 0;
120
+ const bitrate = elapsed > 0 ? (bytesReceived - this.prevBytesAudio) * 8 / elapsed : 0;
121
+ this.prevBytesAudio = bytesReceived;
122
+ const r = report;
123
+ const audioPacketsLost = r.packetsLost ?? 0;
124
+ audio = {
125
+ bytesReceived,
126
+ packetsReceived: r.packetsReceived ?? 0,
127
+ packetsLost: audioPacketsLost,
128
+ jitter: r.jitter ?? 0,
129
+ bitrate: Math.round(bitrate),
130
+ packetsLostDelta: Math.max(0, audioPacketsLost - this.prevPacketsLostAudio)
131
+ };
132
+ this.prevPacketsLostAudio = audioPacketsLost;
133
+ }
134
+ if (report.type === "candidate-pair") {
135
+ const r = report;
136
+ if (r.state === "succeeded") {
137
+ connection.currentRoundTripTime = r.currentRoundTripTime ?? null;
138
+ connection.availableOutgoingBitrate = r.availableOutgoingBitrate ?? null;
139
+ }
140
+ }
141
+ });
142
+ this.prevTimestamp = now;
143
+ return {
144
+ timestamp: Date.now(),
145
+ video,
146
+ audio,
147
+ outboundVideo,
148
+ connection
149
+ };
150
+ }
151
+ };
152
+
153
+ //#endregion
154
+ export { WebRTCStatsCollector };
@@ -1,5 +1,9 @@
1
1
  //#region src/tokens/client.d.ts
2
2
 
3
+ type CreateTokenOptions = {
4
+ /** Custom key-value pairs to attach to the client token. */
5
+ metadata?: Record<string, unknown>;
6
+ };
3
7
  type CreateTokenResponse = {
4
8
  apiKey: string;
5
9
  expiresAt: string;
@@ -7,15 +11,20 @@ type CreateTokenResponse = {
7
11
  type TokensClient = {
8
12
  /**
9
13
  * Create a client token.
14
+ * @param options - Optional configuration for the token.
15
+ * @param options.metadata - Custom key-value pairs to attach to the token.
10
16
  * @returns A short-lived API key safe for client-side use.
11
17
  * @example
12
18
  * ```ts
13
19
  * const client = createDecartClient({ apiKey: process.env.DECART_API_KEY });
14
20
  * const token = await client.tokens.create();
15
21
  * // Returns: { apiKey: "ek_...", expiresAt: "2024-12-15T12:10:00Z" }
22
+ *
23
+ * // With metadata:
24
+ * const token = await client.tokens.create({ metadata: { role: "viewer" } });
16
25
  * ```
17
26
  */
18
- create: () => Promise<CreateTokenResponse>;
27
+ create: (options?: CreateTokenOptions) => Promise<CreateTokenResponse>;
19
28
  };
20
29
  //#endregion
21
- export { CreateTokenResponse, TokensClient };
30
+ export { CreateTokenOptions, CreateTokenResponse, TokensClient };
@@ -4,14 +4,18 @@ import { buildAuthHeaders } from "../shared/request.js";
4
4
  //#region src/tokens/client.ts
5
5
  const createTokensClient = (opts) => {
6
6
  const { baseUrl, apiKey, integration } = opts;
7
- const create = async () => {
8
- const headers = buildAuthHeaders({
9
- apiKey,
10
- integration
11
- });
7
+ const create = async (options) => {
8
+ const headers = {
9
+ ...buildAuthHeaders({
10
+ apiKey,
11
+ integration
12
+ }),
13
+ "content-type": "application/json"
14
+ };
12
15
  const response = await fetch(`${baseUrl}/v1/client/tokens`, {
13
16
  method: "POST",
14
- headers
17
+ headers,
18
+ body: JSON.stringify(options?.metadata ? { metadata: options.metadata } : {})
15
19
  });
16
20
  if (!response.ok) {
17
21
  const errorText = await response.text().catch(() => "Unknown error");
@@ -8,7 +8,6 @@ type DecartSDKError = {
8
8
  declare const ERROR_CODES: {
9
9
  readonly INVALID_API_KEY: "INVALID_API_KEY";
10
10
  readonly INVALID_BASE_URL: "INVALID_BASE_URL";
11
- readonly WEB_RTC_ERROR: "WEB_RTC_ERROR";
12
11
  readonly PROCESSING_ERROR: "PROCESSING_ERROR";
13
12
  readonly INVALID_INPUT: "INVALID_INPUT";
14
13
  readonly INVALID_OPTIONS: "INVALID_OPTIONS";
@@ -18,6 +17,11 @@ declare const ERROR_CODES: {
18
17
  readonly QUEUE_RESULT_ERROR: "QUEUE_RESULT_ERROR";
19
18
  readonly JOB_NOT_COMPLETED: "JOB_NOT_COMPLETED";
20
19
  readonly TOKEN_CREATE_ERROR: "TOKEN_CREATE_ERROR";
20
+ readonly WEBRTC_WEBSOCKET_ERROR: "WEBRTC_WEBSOCKET_ERROR";
21
+ readonly WEBRTC_ICE_ERROR: "WEBRTC_ICE_ERROR";
22
+ readonly WEBRTC_TIMEOUT_ERROR: "WEBRTC_TIMEOUT_ERROR";
23
+ readonly WEBRTC_SERVER_ERROR: "WEBRTC_SERVER_ERROR";
24
+ readonly WEBRTC_SIGNALING_ERROR: "WEBRTC_SIGNALING_ERROR";
21
25
  };
22
26
  //#endregion
23
27
  export { DecartSDKError, ERROR_CODES };
@@ -2,7 +2,6 @@
2
2
  const ERROR_CODES = {
3
3
  INVALID_API_KEY: "INVALID_API_KEY",
4
4
  INVALID_BASE_URL: "INVALID_BASE_URL",
5
- WEB_RTC_ERROR: "WEB_RTC_ERROR",
6
5
  PROCESSING_ERROR: "PROCESSING_ERROR",
7
6
  INVALID_INPUT: "INVALID_INPUT",
8
7
  INVALID_OPTIONS: "INVALID_OPTIONS",
@@ -11,7 +10,12 @@ const ERROR_CODES = {
11
10
  QUEUE_STATUS_ERROR: "QUEUE_STATUS_ERROR",
12
11
  QUEUE_RESULT_ERROR: "QUEUE_RESULT_ERROR",
13
12
  JOB_NOT_COMPLETED: "JOB_NOT_COMPLETED",
14
- TOKEN_CREATE_ERROR: "TOKEN_CREATE_ERROR"
13
+ TOKEN_CREATE_ERROR: "TOKEN_CREATE_ERROR",
14
+ WEBRTC_WEBSOCKET_ERROR: "WEBRTC_WEBSOCKET_ERROR",
15
+ WEBRTC_ICE_ERROR: "WEBRTC_ICE_ERROR",
16
+ WEBRTC_TIMEOUT_ERROR: "WEBRTC_TIMEOUT_ERROR",
17
+ WEBRTC_SERVER_ERROR: "WEBRTC_SERVER_ERROR",
18
+ WEBRTC_SIGNALING_ERROR: "WEBRTC_SIGNALING_ERROR"
15
19
  };
16
20
  function createSDKError(code, message, data, cause) {
17
21
  return {
@@ -27,8 +31,38 @@ function createInvalidApiKeyError() {
27
31
  function createInvalidBaseUrlError(url) {
28
32
  return createSDKError(ERROR_CODES.INVALID_BASE_URL, `Invalid base URL${url ? `: ${url}` : ""}`);
29
33
  }
30
- function createWebrtcError(error) {
31
- return createSDKError(ERROR_CODES.WEB_RTC_ERROR, "WebRTC error", { cause: error });
34
+ function createWebrtcWebsocketError(error) {
35
+ return createSDKError(ERROR_CODES.WEBRTC_WEBSOCKET_ERROR, "WebSocket connection failed", void 0, error);
36
+ }
37
+ function createWebrtcIceError(error) {
38
+ return createSDKError(ERROR_CODES.WEBRTC_ICE_ERROR, "ICE connection failed", void 0, error);
39
+ }
40
+ function createWebrtcTimeoutError(phase, timeoutMs, cause) {
41
+ const hasTimeout = typeof timeoutMs === "number" && Number.isFinite(timeoutMs);
42
+ return createSDKError(ERROR_CODES.WEBRTC_TIMEOUT_ERROR, hasTimeout ? `${phase} timed out after ${timeoutMs}ms` : `${phase} timed out`, hasTimeout ? {
43
+ phase,
44
+ timeoutMs
45
+ } : { phase }, cause);
46
+ }
47
+ function createWebrtcServerError(message) {
48
+ return createSDKError(ERROR_CODES.WEBRTC_SERVER_ERROR, message);
49
+ }
50
+ function createWebrtcSignalingError(error) {
51
+ return createSDKError(ERROR_CODES.WEBRTC_SIGNALING_ERROR, "Signaling error", void 0, error);
52
+ }
53
+ /**
54
+ * Classify a raw WebRTC error into a specific SDK error based on its message.
55
+ */
56
+ function classifyWebrtcError(error) {
57
+ const msg = error.message.toLowerCase();
58
+ if (error.source === "server") return createWebrtcServerError(error.message);
59
+ if (msg.includes("websocket")) return createWebrtcWebsocketError(error);
60
+ if (msg.includes("ice connection failed")) return createWebrtcIceError(error);
61
+ if (msg.includes("timeout") || msg.includes("timed out")) {
62
+ const timeoutMatch = msg.match(/(\d+)\s*ms/);
63
+ return createWebrtcTimeoutError("connection", timeoutMatch ? Number.parseInt(timeoutMatch[1], 10) : void 0, error);
64
+ }
65
+ return createWebrtcSignalingError(error);
32
66
  }
33
67
  function createInvalidInputError(message) {
34
68
  return createSDKError(ERROR_CODES.INVALID_INPUT, message);
@@ -47,4 +81,4 @@ function createQueueResultError(message, status) {
47
81
  }
48
82
 
49
83
  //#endregion
50
- export { ERROR_CODES, createInvalidApiKeyError, createInvalidBaseUrlError, createInvalidInputError, createModelNotFoundError, createQueueResultError, createQueueStatusError, createQueueSubmitError, createSDKError, createWebrtcError };
84
+ export { ERROR_CODES, classifyWebrtcError, createInvalidApiKeyError, createInvalidBaseUrlError, createInvalidInputError, createModelNotFoundError, createQueueResultError, createQueueStatusError, createQueueSubmitError, createSDKError };
@@ -0,0 +1,18 @@
1
+ //#region src/utils/logger.d.ts
2
+ type LogLevel = "debug" | "info" | "warn" | "error";
3
+ type Logger = {
4
+ debug: (message: string, data?: Record<string, unknown>) => void;
5
+ info: (message: string, data?: Record<string, unknown>) => void;
6
+ warn: (message: string, data?: Record<string, unknown>) => void;
7
+ error: (message: string, data?: Record<string, unknown>) => void;
8
+ };
9
+ /** A logger that discards all messages. Zero overhead. */
10
+ declare const noopLogger: Logger;
11
+ /**
12
+ * Creates a console-based logger that only logs messages at or above the given level.
13
+ *
14
+ * @param minLevel - Minimum log level to output. Default: "warn".
15
+ */
16
+ declare function createConsoleLogger(minLevel?: LogLevel): Logger;
17
+ //#endregion
18
+ export { LogLevel, Logger, createConsoleLogger, noopLogger };
@@ -0,0 +1,37 @@
1
+ //#region src/utils/logger.ts
2
+ /** A logger that discards all messages. Zero overhead. */
3
+ const noopLogger = {
4
+ debug() {},
5
+ info() {},
6
+ warn() {},
7
+ error() {}
8
+ };
9
+ const LOG_LEVELS = {
10
+ debug: 0,
11
+ info: 1,
12
+ warn: 2,
13
+ error: 3
14
+ };
15
+ /**
16
+ * Creates a console-based logger that only logs messages at or above the given level.
17
+ *
18
+ * @param minLevel - Minimum log level to output. Default: "warn".
19
+ */
20
+ function createConsoleLogger(minLevel = "warn") {
21
+ const threshold = LOG_LEVELS[minLevel];
22
+ const log = (level, message, data) => {
23
+ if (LOG_LEVELS[level] < threshold) return;
24
+ const prefix = "[DecartSDK]";
25
+ if (data) console[level](prefix, message, data);
26
+ else console[level](prefix, message);
27
+ };
28
+ return {
29
+ debug: (msg, data) => log("debug", msg, data),
30
+ info: (msg, data) => log("info", msg, data),
31
+ warn: (msg, data) => log("warn", msg, data),
32
+ error: (msg, data) => log("error", msg, data)
33
+ };
34
+ }
35
+
36
+ //#endregion
37
+ export { createConsoleLogger, noopLogger };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decartai/sdk",
3
- "version": "0.0.48",
3
+ "version": "0.0.50",
4
4
  "description": "Decart's JavaScript SDK",
5
5
  "type": "module",
6
6
  "license": "MIT",