From 04682d02c75e8dede26b5ca741e99ab64f9c2ff9 Mon Sep 17 00:00:00 2001 From: Loewy Date: Wed, 12 Aug 2026 13:00:23 -0700 Subject: [PATCH] Improve web HLS playback diagnostics --- package.json | 1 + src/Video.web.tsx | 67 ++++++++++---- src/webHls.ts | 208 +++++++++++++++++++++++++++++++++++++++++++ test/.gitignore | 1 + test/tsconfig.json | 9 ++ test/web-hls.test.js | 148 ++++++++++++++++++++++++++++++ 6 files changed, 416 insertions(+), 18 deletions(-) create mode 100644 src/webHls.ts create mode 100644 test/.gitignore create mode 100644 test/tsconfig.json create mode 100644 test/web-hls.test.js diff --git a/package.json b/package.json index 0f0d3a02..792f4890 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "docs": "yarn --cwd docs build", "release": "release-it", "test": "echo no test available", + "test:web": "yarn tsc -p test/tsconfig.json && node --test test/web-hls.test.js", "check-ios": "scripts/swift-format.sh && scripts/swift-lint.sh && scripts/clang-format.sh", "check-android": "scripts/kotlin-lint.sh", "check-all": "yarn check-android; yarn check-ios; yarn lint", diff --git a/src/Video.web.tsx b/src/Video.web.tsx index a93564c8..1e381a95 100644 --- a/src/Video.web.tsx +++ b/src/Video.web.tsx @@ -8,11 +8,16 @@ import React, { } from 'react'; import Hls from 'hls.js'; import type {VideoRef, ReactVideoProps, VideoMetadata} from './types'; +import { + createWebPlaybackErrorReporter, + describeHlsError, + describeMediaError, + isHlsSource, + type WebHlsPlaybackMode, +} from './webHls'; const HLS_MIME = 'application/vnd.apple.mpegurl'; -const isHlsSource = (uri: string) => /\.m3u8(\?|#|$)/i.test(uri); - // `poster` is either a (deprecated) plain URI string or a poster object whose // `source` follows the RN image-source shape. The DOM only takes a URL string. const resolvePosterUri = ( @@ -252,6 +257,17 @@ const Video = forwardRef( // on anything object-shaped would tear down and reload the stream constantly. const onErrorRef = useRef(onError); onErrorRef.current = onError; + const playbackErrorReporterRef = + useRef>(); + if (!playbackErrorReporterRef.current) { + playbackErrorReporterRef.current = createWebPlaybackErrorReporter( + (error) => onErrorRef.current?.({error}), + ); + } + const playbackDiagnosticsRef = useRef<{ + mode: WebHlsPlaybackMode; + nativeHlsSupport: string; + }>(); const uri = source?.uri as string | undefined; @@ -261,14 +277,18 @@ const Video = forwardRef( return; } - // Safari (and iOS WebKit) plays HLS natively; everywhere else the browser - // has no HLS support at all, so an .m3u8 has to go through hls.js. - const needsMse = - isHlsSource(uri) && !video.canPlayType(HLS_MIME) && Hls.isSupported(); + playbackErrorReporterRef.current?.reset(); + const nativeHlsSupport = video.canPlayType(HLS_MIME); + const mode: WebHlsPlaybackMode = + isHlsSource(uri) && !nativeHlsSupport && Hls.isSupported() + ? 'hls.js' + : 'native'; + playbackDiagnosticsRef.current = {mode, nativeHlsSupport}; - if (!needsMse) { + if (mode === 'native') { video.src = uri; return () => { + playbackErrorReporterRef.current?.reset(); video.removeAttribute('src'); video.load(); }; @@ -281,16 +301,19 @@ const Video = forwardRef( if (!data.fatal) { return; } - onErrorRef.current?.({ - error: { - errorString: `${data.type}: ${data.details}`, - }, - }); + const errorString = `${describeHlsError( + data, + )}; mode=hls.js; nativeHlsSupport=${nativeHlsSupport || 'none'}`; + playbackErrorReporterRef.current?.addHls( + errorString, + `hls.js/${data.type}`, + ); }); hls.loadSource(uri); hls.attachMedia(video); return () => { + playbackErrorReporterRef.current?.reset(); hls.destroy(); }; }, [uri]); @@ -349,15 +372,23 @@ const Video = forwardRef( }} onLoadedData={() => onReadyForDisplay?.()} onError={() => { - if (!nativeRef.current?.error) { + const video = nativeRef.current; + if (!video?.error) { return; } - onError?.({ - error: { - errorString: nativeRef.current.error.message ?? 'Unknown error', - code: nativeRef.current.error.code, - }, + const diagnostics = playbackDiagnosticsRef.current; + const mediaError = describeMediaError({ + currentSrc: video.currentSrc, + networkState: video.networkState, + readyState: video.readyState, + error: video.error, + playbackMode: diagnostics?.mode, + nativeHlsSupport: diagnostics?.nativeHlsSupport, }); + playbackErrorReporterRef.current?.addMedia( + mediaError, + video.error.code, + ); }} onLoadedMetadata={() => { if (source?.startPosition) { diff --git a/src/webHls.ts b/src/webHls.ts new file mode 100644 index 00000000..e3ac990c --- /dev/null +++ b/src/webHls.ts @@ -0,0 +1,208 @@ +export type WebHlsPlaybackMode = 'hls.js' | 'native'; + +export interface HlsErrorDetails { + type: string; + details: string; + reason?: string; + error?: {message?: string}; + response?: { + code?: number; + text?: string; + url?: string; + data?: unknown; + }; + url?: string; +} + +export interface MediaElementDiagnostics { + currentSrc: string; + networkState: number; + readyState: number; + error: {code: number; message?: string} | null; + playbackMode?: WebHlsPlaybackMode; + nativeHlsSupport?: string; +} + +export interface WebVideoError { + errorString: string; + code?: number; + domain: string; +} + +const HLS_SOURCE_PATTERN = /\.m3u8(\?|#|$)/i; + +const MEDIA_ERROR_NAMES: Record = { + 1: 'MEDIA_ERR_ABORTED', + 2: 'MEDIA_ERR_NETWORK', + 3: 'MEDIA_ERR_DECODE', + 4: 'MEDIA_ERR_SRC_NOT_SUPPORTED', +}; + +export const isHlsSource = (uri: string): boolean => + HLS_SOURCE_PATTERN.test(uri); + +const compactText = (value: string): string => + value.replace(/\s+/g, ' ').trim(); + +const limitedText = (value: string, maxLength = 160): string => { + const compact = compactText(value); + return compact.length > maxLength + ? `${compact.slice(0, maxLength)}…` + : compact; +}; + +const SAFE_RESPONSE_MESSAGES = new Set([ + 'Playlist does not exist', + 'Video stream not found', +]); + +export const sanitizeDiagnosticUrl = (value: string): string => { + try { + const url = new URL(value); + url.username = ''; + url.password = ''; + url.search = url.search ? '?' : ''; + url.hash = url.hash ? '#' : ''; + return url.toString().replaceAll('%3Credacted%3E', ''); + } catch { + return value.replace(/[?#].*$/, '?'); + } +}; + +const sanitizeHlsText = (value: string, data: HlsErrorDetails): string => { + let sanitized = value; + for (const url of [data.response?.url, data.url]) { + if (url) { + sanitized = sanitized.replaceAll(url, sanitizeDiagnosticUrl(url)); + } + } + return limitedText(sanitized); +}; + +export const describeHlsError = (data: HlsErrorDetails): string => { + const parts = [`hls.js fatal ${data.type}: ${data.details}`]; + + if (data.reason && data.reason !== data.details) { + parts.push(`reason=${sanitizeHlsText(data.reason, data)}`); + } + + if (data.error?.message && data.error.message !== data.reason) { + parts.push(`message=${sanitizeHlsText(data.error.message, data)}`); + } + + const response = data.response; + if (response) { + if (response.code !== undefined) { + const statusText = response.text ? ` ${limitedText(response.text)}` : ''; + parts.push(`HTTP ${response.code}${statusText}`); + } + if (typeof response.data === 'string' && response.data.trim()) { + const responseBody = compactText(response.data); + if (SAFE_RESPONSE_MESSAGES.has(responseBody)) { + parts.push(`response=${responseBody}`); + } else { + parts.push(`responseBodyLength=${response.data.length}`); + } + } + if (response.url) { + parts.push(`url=${sanitizeDiagnosticUrl(response.url)}`); + } + } else if (data.url) { + parts.push(`url=${sanitizeDiagnosticUrl(data.url)}`); + } + + return parts.join('; '); +}; + +export const createWebPlaybackErrorReporter = ( + report: (error: WebVideoError) => void, + debounceMs = 50, +) => { + let hlsError: string | undefined; + let hlsDomain: string | undefined; + let mediaError: string | undefined; + let mediaCode: number | undefined; + let timeout: ReturnType | undefined; + let reported = false; + + const clearTimer = () => { + if (timeout !== undefined) { + clearTimeout(timeout); + timeout = undefined; + } + }; + + const schedule = () => { + if (reported) { + return; + } + clearTimer(); + timeout = setTimeout(() => { + timeout = undefined; + reported = true; + const errorString = [hlsError, mediaError].filter(Boolean).join('; '); + report({ + errorString, + code: mediaCode, + domain: hlsError + ? mediaError + ? 'hls.js/HTMLMediaElement' + : hlsDomain || 'hls.js' + : 'HTMLMediaElement', + }); + }, debounceMs); + }; + + const reset = () => { + clearTimer(); + hlsError = undefined; + hlsDomain = undefined; + mediaError = undefined; + mediaCode = undefined; + reported = false; + }; + + return { + addHls(errorString: string, domain: string) { + if (reported) { + return; + } + hlsError = errorString; + hlsDomain = domain; + schedule(); + }, + addMedia(errorString: string, code: number) { + if (reported) { + return; + } + mediaError = errorString; + mediaCode = code; + schedule(); + }, + reset, + }; +}; + +export const describeMediaError = (media: MediaElementDiagnostics): string => { + const code = media.error?.code ?? 0; + const errorName = MEDIA_ERROR_NAMES[code] ?? 'MEDIA_ERR_UNKNOWN'; + const parts = [`HTMLMediaElement ${errorName} (${code})`]; + const message = media.error?.message?.trim(); + + if (message) { + parts.push(`message=${limitedText(message)}`); + } + if (media.playbackMode) { + parts.push(`mode=${media.playbackMode}`); + } + if (media.nativeHlsSupport !== undefined) { + parts.push(`nativeHlsSupport=${media.nativeHlsSupport || 'none'}`); + } + if (media.currentSrc) { + parts.push(`src=${sanitizeDiagnosticUrl(media.currentSrc)}`); + } + parts.push(`networkState=${media.networkState}`); + parts.push(`readyState=${media.readyState}`); + + return parts.join('; '); +}; diff --git a/test/.gitignore b/test/.gitignore new file mode 100644 index 00000000..c3af8579 --- /dev/null +++ b/test/.gitignore @@ -0,0 +1 @@ +lib/ diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 00000000..530a3d8b --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "composite": false, + "rootDir": "../src", + "outDir": "./lib" + }, + "include": ["../src/webHls.ts"] +} diff --git a/test/web-hls.test.js b/test/web-hls.test.js new file mode 100644 index 00000000..35f88b8b --- /dev/null +++ b/test/web-hls.test.js @@ -0,0 +1,148 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + createWebPlaybackErrorReporter, + describeHlsError, + describeMediaError, + isHlsSource, + sanitizeDiagnosticUrl, +} = require('./lib/webHls'); + +test('recognizes HLS playlist URLs with query strings', () => { + assert.equal(isHlsSource('https://api.example/playlist/42.m3u8?vod=1'), true); + assert.equal(isHlsSource('https://cdn.example/video.mp4'), false); +}); + +test('includes fatal HLS network and response details', () => { + assert.equal( + describeHlsError({ + type: 'networkError', + details: 'manifestParsingError', + reason: 'no EXTM3U delimiter', + error: {message: 'manifest parsing failed'}, + response: { + code: 200, + text: 'OK', + data: 'Playlist does not exist', + url: 'https://api.example/playlist/42.m3u8', + }, + }), + 'hls.js fatal networkError: manifestParsingError; ' + + 'reason=no EXTM3U delimiter; message=manifest parsing failed; HTTP 200 OK; ' + + 'response=Playlist does not exist; ' + + 'url=https://api.example/playlist/42.m3u8', + ); +}); + +test('redacts URL credentials, query values, and unknown response bodies', () => { + assert.equal( + sanitizeDiagnosticUrl( + 'https://user:password@cdn.example/42.m3u8?token=secret#private', + ), + 'https://cdn.example/42.m3u8?#', + ); + assert.equal( + describeHlsError({ + type: 'networkError', + details: 'manifestLoadError', + response: { + code: 403, + data: 'internal credential: secret', + url: 'https://cdn.example/42.m3u8?token=secret', + }, + }), + 'hls.js fatal networkError: manifestLoadError; HTTP 403; ' + + 'responseBodyLength=27; url=https://cdn.example/42.m3u8?', + ); + assert.equal( + describeHlsError({ + type: 'keySystemError', + details: 'licenseRequestFailed', + error: { + message: + 'License request failed (https://license.example/key?token=secret)', + }, + url: 'https://license.example/key?token=secret', + }), + 'hls.js fatal keySystemError: licenseRequestFailed; ' + + 'message=License request failed (https://license.example/key?); ' + + 'url=https://license.example/key?', + ); +}); + +const wait = (milliseconds) => + new Promise((resolve) => setTimeout(resolve, milliseconds)); + +for (const order of ['hls-first', 'media-first']) { + test(`combines HLS and media errors once when ${order}`, async () => { + const reports = []; + const reporter = createWebPlaybackErrorReporter( + (error) => reports.push(error), + 5, + ); + const addHls = () => + reporter.addHls( + 'hls.js fatal mediaError: bufferAppendError', + 'hls.js/mediaError', + ); + const addMedia = () => + reporter.addMedia('HTMLMediaElement MEDIA_ERR_DECODE (3)', 3); + + if (order === 'hls-first') { + addHls(); + addMedia(); + } else { + addMedia(); + addHls(); + } + await wait(15); + + assert.deepEqual(reports, [ + { + errorString: + 'hls.js fatal mediaError: bufferAppendError; ' + + 'HTMLMediaElement MEDIA_ERR_DECODE (3)', + code: 3, + domain: 'hls.js/HTMLMediaElement', + }, + ]); + }); +} + +test('reset cancels a pending source error and permits the next source', async () => { + const reports = []; + const reporter = createWebPlaybackErrorReporter( + (error) => reports.push(error), + 5, + ); + reporter.addHls('stale source failure', 'hls.js/networkError'); + reporter.reset(); + reporter.addMedia('current source failure', 4); + await wait(15); + + assert.deepEqual(reports, [ + { + errorString: 'current source failure', + code: 4, + domain: 'HTMLMediaElement', + }, + ]); +}); + +test('names media error code 4 and records the selected playback path', () => { + assert.equal( + describeMediaError({ + currentSrc: 'https://api.example/playlist/42.m3u8', + networkState: 3, + readyState: 0, + error: {code: 4}, + playbackMode: 'native', + nativeHlsSupport: 'maybe', + }), + 'HTMLMediaElement MEDIA_ERR_SRC_NOT_SUPPORTED (4); mode=native; ' + + 'nativeHlsSupport=maybe; src=https://api.example/playlist/42.m3u8; ' + + 'networkState=3; readyState=0', + ); +});