diff --git a/.github/workflows/publish-on-merge.yml b/.github/workflows/publish-on-merge.yml index dbf9b186..c8774b60 100644 --- a/.github/workflows/publish-on-merge.yml +++ b/.github/workflows/publish-on-merge.yml @@ -12,6 +12,7 @@ on: type: boolean permissions: + id-token: write # Required for Trusted Publisher on NPMJS contents: write jobs: @@ -41,12 +42,33 @@ jobs: sudo chmod +x /usr/local/bin/logger - name: Setup Node.js + id: setup-node uses: actions/setup-node@v6 with: node-version: 22 registry-url: "https://registry.npmjs.org" cache: "yarn" + # npm Trusted Publisher requires CLI >= 11.5.1 for OIDC token exchange. + # Bypass the broken arborist in the cached Node 22 image by extracting the tarball directly. + - name: Upgrade NPM + env: + NODE_VERSION: ${{ steps.setup-node.outputs.node-version }} + NPM_VERSION: 11.5.1 + run: | + NODE_VERSION_STRIPED=${NODE_VERSION#v} + NPM_DIR="/opt/hostedtoolcache/node/$NODE_VERSION_STRIPED/x64/lib/node_modules/npm" + + logger -l debug -m "NODE_VERSION_STRIPED: $NODE_VERSION_STRIPED" + logger -l debug -m "NPM_DIR: $NPM_DIR" + logger -l debug -m "NPM_VERSION: $NPM_VERSION" + + logger -l info -m "Upgrading NPM to version: $NPM_VERSION" + curl -fsSL https://registry.npmjs.org/npm/-/npm-$NPM_VERSION.tgz | tar -xz --strip-components=1 -C "$NPM_DIR" \ + || logger -l error -m "Failed to upgrade NPM" + + logger -l info -m "Upgrade NPM succeeded" + - name: Install dependencies run: | logger -l info -m "Installing dependencies" @@ -113,7 +135,7 @@ jobs: - name: Publish to NPM env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_PACKAGE_UPLOAD_TOKEN }} + NODE_AUTH_TOKEN: '' # unset in order to use OIDC run: | if [ "${{ github.event.inputs.dry_run }}" = "true" ]; then logger -l info -m "🔍 DRY RUN MODE: Would publish version ${{ steps.version.outputs.version }} with tag ${{ steps.version.outputs.tag }}" diff --git a/package.json b/package.json index 7490f2fe..894d7e33 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@d-id/client-sdk", "private": false, - "version": "1.1.63", + "version": "1.1.65", "type": "module", "description": "d-id client sdk", "repository": { diff --git a/src/auth/get-auth-header.test.ts b/src/auth/get-auth-header.test.ts index 1fd48b36..7608ab96 100644 --- a/src/auth/get-auth-header.test.ts +++ b/src/auth/get-auth-header.test.ts @@ -128,7 +128,7 @@ describe('getAuthHeader', () => { const auth: Auth = { type: 'key', clientKey: 'test-client-key' }; const result = getAuthHeader(auth); - expect(result).toBe('Client-Key test-client-key.generated-external-id'); + expect(result).toBe('Client-Key test-client-key.generated-external-id_mocked-random-id'); }); it('should use provided externalId in Client-Key header', () => { @@ -136,7 +136,8 @@ describe('getAuthHeader', () => { const externalId = 'user-123'; const result = getAuthHeader(auth, externalId); - expect(result).toBe('Client-Key test-client-key.user-123'); + expect(result).toBe('Client-Key test-client-key.user-123_mocked-random-id'); + expect(result).toContain('user-123'); }); it('should use externalId from localStorage when not provided', () => { @@ -146,7 +147,8 @@ describe('getAuthHeader', () => { const auth: Auth = { type: 'key', clientKey: 'test-client-key' }; const result = getAuthHeader(auth); - expect(result).toBe('Client-Key test-client-key.stored-user-id'); + expect(result).toBe('Client-Key test-client-key.stored-user-id_mocked-random-id'); + expect(result).toContain('stored-user-id'); }); it('should generate new externalId and store it when localStorage is empty', () => { @@ -156,7 +158,8 @@ describe('getAuthHeader', () => { const auth: Auth = { type: 'key', clientKey: 'test-client-key' }; const result = getAuthHeader(auth); - expect(result).toBe('Client-Key test-client-key.new-generated-id'); + expect(result).toBe('Client-Key test-client-key.new-generated-id_mocked-random-id'); + expect(result).toContain('new-generated-id'); expect(window.localStorage.getItem('did_external_key_id')).toBe(mockRandomId); }); diff --git a/src/auth/get-auth-header.ts b/src/auth/get-auth-header.ts index 7959b887..2e3eddc1 100644 --- a/src/auth/get-auth-header.ts +++ b/src/auth/get-auth-header.ts @@ -18,13 +18,14 @@ export function getExternalId(externalId?: string): string { return key; } +let sessionKey = getRandom(); export function getAuthHeader(auth: Auth, externalId?: string) { if (auth.type === 'bearer') { return `Bearer ${auth.token}`; } else if (auth.type === 'basic') { return `Basic ${'token' in auth ? auth.token : btoa(`${auth.username}:${auth.password}`)}`; } else if (auth.type === 'key') { - return `Client-Key ${auth.clientKey}.${getExternalId(externalId)}`; + return `Client-Key ${auth.clientKey}.${getExternalId(externalId)}_${sessionKey}`; } else { throw new Error(`Unknown auth type: ${auth}`); } diff --git a/src/services/streaming-manager/stats/report.test.ts b/src/services/streaming-manager/stats/report.test.ts new file mode 100644 index 00000000..4f062ef3 --- /dev/null +++ b/src/services/streaming-manager/stats/report.test.ts @@ -0,0 +1,157 @@ +import { formatStats } from './report'; + +type StatEntry = Record; + +function buildStats(entries: StatEntry[]): RTCStatsReport { + const map = new Map(); + for (const entry of entries) { + map.set(entry.id, entry); + } + return map as unknown as RTCStatsReport; +} + +const inboundRtpVideo: StatEntry = { + id: 'IT_video', + type: 'inbound-rtp', + kind: 'video', + codecId: 'CIT_video_vp8', + timestamp: 1_700_000_000_000, + bytesReceived: 1_234_567, + packetsReceived: 1_000, + packetsLost: 2, + framesDropped: 1, + framesDecoded: 600, + jitter: 0.01, + jitterBufferDelay: 30, + jitterBufferEmittedCount: 600, + frameWidth: 1280, + frameHeight: 720, + framesPerSecond: 30, + freezeCount: 0, + totalFreezesDuration: 0, +}; + +const codecVp8: StatEntry = { id: 'CIT_video_vp8', type: 'codec', mimeType: 'video/VP8' }; +const codecH264: StatEntry = { id: 'CIT_video_h264', type: 'codec', mimeType: 'video/H264' }; +const codecAudio: StatEntry = { id: 'CIT_audio_opus', type: 'codec', mimeType: 'audio/opus' }; +const inboundRtpAudio: StatEntry = { id: 'IT_audio', type: 'inbound-rtp', kind: 'audio' }; + +const nominatedPair: StatEntry = { + id: 'CP_nominated', + type: 'candidate-pair', + nominated: true, + currentRoundTripTime: 0.05, +}; +const backupPair: StatEntry = { + id: 'CP_backup', + type: 'candidate-pair', + nominated: false, + currentRoundTripTime: 0.2, +}; + +describe('formatStats', () => { + describe('codec extraction', () => { + it.each([ + ['codec stat is iterated before inbound-rtp video', [codecVp8, inboundRtpVideo]], + ['codec stat is iterated after inbound-rtp video', [inboundRtpVideo, codecVp8]], + ])('returns the codec when %s', (_label, entries) => { + expect(formatStats(buildStats(entries)).codec).toBe('VP8'); + }); + + it('returns the codec linked by codecId when multiple video codecs exist', () => { + const inboundUsingH264 = { ...inboundRtpVideo, codecId: codecH264.id }; + expect(formatStats(buildStats([codecVp8, codecH264, inboundUsingH264])).codec).toBe('H264'); + }); + + it.each([ + ['codecId does not match any codec entry', [codecVp8, { ...inboundRtpVideo, codecId: 'unknown-id' }]], + [ + 'inbound-rtp omits codecId entirely', + [ + codecVp8, + (() => { + const { codecId, ...rest } = inboundRtpVideo; + return rest; + })(), + ], + ], + ])('falls back to any video codec when %s', (_label, entries) => { + expect(formatStats(buildStats(entries)).codec).toBe('VP8'); + }); + + it('ignores audio codec entries when picking a video codec', () => { + expect(formatStats(buildStats([codecAudio, codecVp8, inboundRtpVideo])).codec).toBe('VP8'); + }); + + it('returns an empty codec string when no video codec is present', () => { + const inboundNoLink = { ...inboundRtpVideo, codecId: undefined }; + expect(formatStats(buildStats([codecAudio, inboundNoLink])).codec).toBe(''); + }); + + it('does not throw when a codec entry has no mimeType', () => { + const malformed = { id: 'CIT_bad', type: 'codec' }; + expect(() => formatStats(buildStats([malformed, inboundRtpVideo]))).not.toThrow(); + }); + }); + + describe('inbound-rtp routing', () => { + it('returns an empty report when no video inbound-rtp is present', () => { + expect(formatStats(buildStats([codecVp8, inboundRtpAudio]))).toEqual({}); + }); + + it('passes inbound-rtp fields through to the result', () => { + const result = formatStats(buildStats([codecVp8, inboundRtpVideo])); + expect(result).toMatchObject({ + codec: 'VP8', + timestamp: inboundRtpVideo.timestamp, + bytesReceived: inboundRtpVideo.bytesReceived, + packetsReceived: inboundRtpVideo.packetsReceived, + packetsLost: inboundRtpVideo.packetsLost, + framesDropped: inboundRtpVideo.framesDropped, + framesDecoded: inboundRtpVideo.framesDecoded, + jitter: inboundRtpVideo.jitter, + jitterBufferDelay: inboundRtpVideo.jitterBufferDelay, + jitterBufferEmittedCount: inboundRtpVideo.jitterBufferEmittedCount, + frameWidth: inboundRtpVideo.frameWidth, + frameHeight: inboundRtpVideo.frameHeight, + framesPerSecond: inboundRtpVideo.framesPerSecond, + freezeCount: inboundRtpVideo.freezeCount, + freezeDuration: inboundRtpVideo.totalFreezesDuration, + }); + }); + + it('derives avgJitterDelayInInterval as jitterBufferDelay / jitterBufferEmittedCount', () => { + const result = formatStats(buildStats([codecVp8, inboundRtpVideo])); + expect(result.avgJitterDelayInInterval).toBeCloseTo( + inboundRtpVideo.jitterBufferDelay / inboundRtpVideo.jitterBufferEmittedCount + ); + }); + }); + + describe('RTT priority', () => { + function rttFromPairs(pairs: StatEntry[]): number { + return formatStats(buildStats([...pairs, codecVp8, inboundRtpVideo])).rtt; + } + + it('uses the nominated pair when no other pair is present', () => { + expect(rttFromPairs([nominatedPair])).toBe(nominatedPair.currentRoundTripTime); + }); + + it('uses the nominated pair when a backup pair appears before it', () => { + expect(rttFromPairs([backupPair, nominatedPair])).toBe(nominatedPair.currentRoundTripTime); + }); + + it('keeps the nominated pair when a backup pair appears after it', () => { + expect(rttFromPairs([nominatedPair, backupPair])).toBe(nominatedPair.currentRoundTripTime); + }); + + it('uses the backup pair when no nominated pair is present', () => { + expect(rttFromPairs([backupPair])).toBe(backupPair.currentRoundTripTime); + }); + + it('ignores candidate-pair entries with non-positive RTT', () => { + const zeroRttPair = { ...backupPair, currentRoundTripTime: 0 }; + expect(rttFromPairs([zeroRttPair])).toBe(0); + }); + }); +}); diff --git a/src/services/streaming-manager/stats/report.ts b/src/services/streaming-manager/stats/report.ts index 2c615891..6bd76f4e 100644 --- a/src/services/streaming-manager/stats/report.ts +++ b/src/services/streaming-manager/stats/report.ts @@ -77,50 +77,67 @@ function extractAnomalies(stats: AnalyticsRTCStatsReport[]): AnalyticsRTCStatsRe export function formatStats(stats: RTCStatsReport): SlimRTCStatsReport { let codec = ''; let currRtt: number = 0; + let videoInboundRtp: RTCInboundRtpStreamStats | null = null; + const codecIdToMime = new Map(); + // RTCStatsReport iteration order is not guaranteed across browsers. + // Walk the full report once to collect codec/rtt/inbound-rtp before returning, + // otherwise we may return before the codec entry is seen and emit codec=''. for (const report of stats.values()) { - if (report && report.type === 'codec' && report.mimeType.startsWith('video')) { - codec = report.mimeType.split('/')[1]; - } - if (report && report.type === 'candidate-pair') { - const rtt = report.currentRoundTripTime; - const candidatePair = report as any; - const isNominated = candidatePair.nominated === true; - - // Prioritize RTT from the nominated candidate-pair (the active connection path). - // This ensures we capture the actual network latency being used, not just any candidate. - // Only update if we have a valid positive RTT value to avoid overwriting with invalid data. + if (!report) continue; + + if (report.type === 'codec' && report.mimeType?.startsWith('video')) { + codecIdToMime.set(report.id, report.mimeType.split('/')[1]); + } else if (report.type === 'candidate-pair') { + const pair = report as RTCIceCandidatePairStats; + const rtt = pair.currentRoundTripTime ?? 0; + // Prefer RTT from the nominated candidate-pair (the active connection path). + // Fall back to the first valid pair only until a nominated value arrives. if (rtt > 0) { - if (isNominated) { + if (pair.nominated === true) { currRtt = rtt; } else if (currRtt === 0) { currRtt = rtt; } } - } - if (report && report.type === 'inbound-rtp' && report.kind === 'video') { - return { - codec, - rtt: currRtt, - timestamp: report.timestamp, - bytesReceived: report.bytesReceived, - packetsReceived: report.packetsReceived, - packetsLost: report.packetsLost, - framesDropped: report.framesDropped, - framesDecoded: report.framesDecoded, - jitter: report.jitter, - jitterBufferDelay: report.jitterBufferDelay, - jitterBufferEmittedCount: report.jitterBufferEmittedCount, - avgJitterDelayInInterval: report.jitterBufferDelay / report.jitterBufferEmittedCount, - frameWidth: report.frameWidth, - frameHeight: report.frameHeight, - framesPerSecond: report.framesPerSecond, - freezeCount: report.freezeCount, - freezeDuration: report.totalFreezesDuration, - } as SlimRTCStatsReport; + } else if (report.type === 'inbound-rtp' && report.kind === 'video') { + videoInboundRtp = report as RTCInboundRtpStreamStats; } } - return {} as SlimRTCStatsReport; + + if (!videoInboundRtp) { + return {} as SlimRTCStatsReport; + } + + // WebRTC marks every numeric field optional, but SlimRTCStatsReport expects + // required values. Single boundary cast avoids per-field null checks below. + const inbound = videoInboundRtp as Required; + + if (inbound.codecId && codecIdToMime.has(inbound.codecId)) { + codec = codecIdToMime.get(inbound.codecId)!; + } else if (codecIdToMime.size > 0) { + codec = codecIdToMime.values().next().value ?? ''; + } + + return { + codec, + rtt: currRtt, + timestamp: inbound.timestamp, + bytesReceived: inbound.bytesReceived, + packetsReceived: inbound.packetsReceived, + packetsLost: inbound.packetsLost, + framesDropped: inbound.framesDropped, + framesDecoded: inbound.framesDecoded, + jitter: inbound.jitter, + jitterBufferDelay: inbound.jitterBufferDelay, + jitterBufferEmittedCount: inbound.jitterBufferEmittedCount, + avgJitterDelayInInterval: inbound.jitterBufferDelay / inbound.jitterBufferEmittedCount, + frameWidth: inbound.frameWidth, + frameHeight: inbound.frameHeight, + framesPerSecond: inbound.framesPerSecond, + freezeCount: inbound.freezeCount, + freezeDuration: inbound.totalFreezesDuration, + } as SlimRTCStatsReport; } export function createVideoStatsReport(