From bbc2e088caa3db105bf87ebc4e584352f7010352 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Sat, 22 Mar 2025 16:26:04 +0100 Subject: [PATCH] Refactor UrlSource to use fetch instead of XHR & offer more options --- src/index.ts | 10 +++- src/misc.ts | 53 ++++++++++++++++++ src/source.ts | 149 ++++++++++++++++++++++++-------------------------- 3 files changed, 134 insertions(+), 78 deletions(-) diff --git a/src/index.ts b/src/index.ts index 8d5af6d..5ce839b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,7 +68,15 @@ export { } from './codec'; export { Target, BufferTarget, StreamTarget, StreamTargetChunk, StreamTargetOptions } from './target'; export { Rotation, AnyIterable, SetRequired } from './misc'; -export { Source, BufferSource, StreamSource, StreamSourceOptions, BlobSource, UrlSource } from './source'; +export { + Source, + BufferSource, + StreamSource, + StreamSourceOptions, + BlobSource, + UrlSource, + UrlSourceOptions, +} from './source'; export { InputFormat, IsobmffInputFormat, diff --git a/src/misc.ts b/src/misc.ts index 71bb447..117fbcc 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -397,3 +397,56 @@ export const SECOND_TO_MICROSECOND_FACTOR = 1e6 * (1 + Number.EPSILON); * @public */ export type SetRequired = T & Required>; + +export const mergeObjectsDeeply = (a: T, b: S): T & S => { + const result = { ...a } as T & S; + + for (const key in b) { + if ( + typeof a[key as unknown as keyof T] === 'object' + && a[key as unknown as keyof T] !== null + && typeof b[key] === 'object' + && b[key] !== null + ) { + result[key] = mergeObjectsDeeply( + a[key as unknown as keyof T] as object, + b[key], + ) as (T & S)[Extract]; + } else { + result[key] = b[key] as (T & S)[Extract]; + } + } + + return result; +}; + +export const retriedFetch = async ( + url: string | URL, + requestInit: RequestInit, + getRetryDelay: (previousAttempts: number) => number | null, +) => { + let attempts = 0; + + while (true) { + try { + return await fetch(url, requestInit); + } catch (error) { + console.error('Retrying failed fetch. Error:', error); + + attempts++; + + const retryDelayInSeconds = getRetryDelay(attempts); + if (retryDelayInSeconds === null) { + throw error; + } + + if (!Number.isFinite(retryDelayInSeconds) || retryDelayInSeconds < 0) { + throw new TypeError('Retry delay must be a non-negative finite number.'); + } + + if (retryDelayInSeconds > 0) { + await new Promise(resolve => setTimeout(resolve, 1000 * retryDelayInSeconds)); + } + } + } +}; diff --git a/src/source.ts b/src/source.ts index 9ca82d3..bb0bb19 100644 --- a/src/source.ts +++ b/src/source.ts @@ -1,3 +1,5 @@ +import { mergeObjectsDeeply, retriedFetch } from './misc'; + /** * The source base class, representing a resource from which bytes can be read. * @public @@ -131,6 +133,24 @@ export class BlobSource extends Source { } } +/** + * Options for UrlSource. + * @public + */ +export type UrlSourceOptions = { + /** + * The RequestInit used by the Fetch API. Can be used to further control the requests, such as setting + * custom headers. + */ + requestInit?: RequestInit; + + /** + * A function that returns the delay (in seconds) before retrying a failed request. The function is called + * with the number of previous, unsuccessful attempts. If the function returns `null`, no more retries will be made. + */ + getRetryDelay?: (previousAttempts: number) => number | null; +}; + /** * A source backed by a URL. This is useful for reading data from the network. Be careful using this source however, * as it typically comes with increased latency. @@ -138,76 +158,68 @@ export class BlobSource extends Source { */ export class UrlSource extends Source { /** @internal */ - private _url: string; + private _url: string | URL; /** @internal */ - private _withCredentials: boolean; + private _options: UrlSourceOptions; /** @internal */ private _fullData: ArrayBuffer | null = null; constructor( - url: string, - options: { - /** If credentials are to be included in a cross-origin request. */ - withCredentials?: boolean; - } = {}, + url: string | URL, + options: UrlSourceOptions = {}, ) { - if (typeof url !== 'string') { - throw new TypeError('url must be a string.'); + if (typeof url !== 'string' && !(url instanceof URL)) { + throw new TypeError('url must be a string or URL.'); } if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); } - if (options.withCredentials !== undefined && typeof options.withCredentials !== 'boolean') { - throw new TypeError('options.withCredentials, when specified, must be a boolean.'); + if (options.requestInit !== undefined && (!options.requestInit || typeof options.requestInit !== 'object')) { + throw new TypeError('options.requestInit, when provided, must be an object.'); + } + if (options.getRetryDelay !== undefined && typeof options.getRetryDelay !== 'function') { + throw new TypeError('options.getRetryDelay, when provided, must be a function.'); } super(); this._url = url; - this._withCredentials = options.withCredentials ?? false; + this._options = options; } /** @internal */ - private _makeRequest( + private async _makeRequest( range?: { start: number; end: number }, ): Promise<{ response: ArrayBuffer; statusCode: number }> { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); // We use XMLHttpRequest instead of fetch since it supports more protocols - xhr.open('GET', this._url, true); - xhr.responseType = 'arraybuffer'; - xhr.withCredentials = this._withCredentials; + const headers: HeadersInit = {}; - if (range) { - xhr.setRequestHeader('Range', `bytes=${range.start}-${range.end - 1}`); - } + if (range) { + headers['Range'] = `bytes=${range.start}-${range.end - 1}`; + } - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - const buffer = xhr.response as ArrayBuffer; + const response = await retriedFetch( + this._url, + mergeObjectsDeeply(this._options.requestInit ?? {}, { + method: 'GET', + headers, + }), + this._options.getRetryDelay ?? (() => null), + ); - if (!range) { - this._fullData = buffer; - } + if (!response.ok) { + throw new Error(`Error fetching ${this._url}: ${response.status} ${response.statusText}`); + } - resolve({ - response: buffer, - statusCode: xhr.status, - }); - } else { - reject(new Error(`Error fetching ${this._url}: ${xhr.status} ${xhr.statusText}`)); - } - }; + const buffer = await response.arrayBuffer(); - xhr.onerror = () => { - reject(new Error('Network error occurred.')); - }; + if (!range) { + this._fullData = buffer; + } - xhr.ontimeout = () => { - reject(new Error('Request timed out.')); - }; - - xhr.send(); - }); + return { + response: buffer, + statusCode: response.status, + }; } /** @internal */ @@ -234,44 +246,27 @@ export class UrlSource extends Source { return this._fullData.byteLength; } - const xhr = new XMLHttpRequest(); - xhr.open('GET', this._url, true); - xhr.responseType = 'arraybuffer'; - xhr.withCredentials = this._withCredentials; - xhr.setRequestHeader('Range', 'bytes=0-0'); + // Try a range request to get the Content-Range header + const rangeResponse = await retriedFetch( + this._url, + mergeObjectsDeeply(this._options.requestInit ?? {}, { + method: 'GET', + headers: { Range: 'bytes=0-0' }, + }), + this._options.getRetryDelay ?? (() => null), + ); - await new Promise((resolve, reject) => { - xhr.onload = () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve(); - } else { - reject(new Error(`Error fetching ${this._url} (Range): ${xhr.status} ${xhr.statusText}`)); + if (rangeResponse.status === 206) { + const contentRange = rangeResponse.headers.get('Content-Range'); + if (contentRange) { + const match = contentRange.match(/bytes \d+-\d+\/(\d+)/); + if (match && match[1]) { + return parseInt(match[1], 10); } - }; - - xhr.onerror = () => { - reject(new Error('Network error occurred.')); - }; - - xhr.send(); - }); - - // Check for Content-Range header (e.g., "bytes 0-0/1234" where 1234 is the total size) - const contentRange = xhr.getResponseHeader('Content-Range'); - if (contentRange) { - const match = contentRange.match(/bytes \d+-\d+\/(\d+)/); - if (match && match[1]) { - return parseInt(match[1], 10); } } - // If Content-Range is not available, check Content-Length - const contentLength = xhr.getResponseHeader('Content-Length'); - if (contentLength) { - return parseInt(contentLength, 10); - } - - // If neither header is available, make a full GET request + // If the range request didn't provide the size, make a full GET request const { response } = await this._makeRequest(); return response.byteLength; }