Refactor UrlSource to use fetch instead of XHR & offer more options

This commit is contained in:
Vanilagy
2025-03-22 16:26:04 +01:00
parent b14daf6dca
commit bbc2e088ca
3 changed files with 134 additions and 78 deletions
+9 -1
View File
@@ -68,7 +68,15 @@ export {
} from './codec'; } from './codec';
export { Target, BufferTarget, StreamTarget, StreamTargetChunk, StreamTargetOptions } from './target'; export { Target, BufferTarget, StreamTarget, StreamTargetChunk, StreamTargetOptions } from './target';
export { Rotation, AnyIterable, SetRequired } from './misc'; 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 { export {
InputFormat, InputFormat,
IsobmffInputFormat, IsobmffInputFormat,
+53
View File
@@ -397,3 +397,56 @@ export const SECOND_TO_MICROSECOND_FACTOR = 1e6 * (1 + Number.EPSILON);
* @public * @public
*/ */
export type SetRequired<T, K extends keyof T> = T & Required<Pick<T, K>>; export type SetRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
export const mergeObjectsDeeply = <T extends object, S extends object>(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<keyof S, string>];
} else {
result[key] = b[key] as (T & S)[Extract<keyof S, string>];
}
}
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));
}
}
}
};
+72 -77
View File
@@ -1,3 +1,5 @@
import { mergeObjectsDeeply, retriedFetch } from './misc';
/** /**
* The source base class, representing a resource from which bytes can be read. * The source base class, representing a resource from which bytes can be read.
* @public * @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, * 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. * as it typically comes with increased latency.
@@ -138,76 +158,68 @@ export class BlobSource extends Source {
*/ */
export class UrlSource extends Source { export class UrlSource extends Source {
/** @internal */ /** @internal */
private _url: string; private _url: string | URL;
/** @internal */ /** @internal */
private _withCredentials: boolean; private _options: UrlSourceOptions;
/** @internal */ /** @internal */
private _fullData: ArrayBuffer | null = null; private _fullData: ArrayBuffer | null = null;
constructor( constructor(
url: string, url: string | URL,
options: { options: UrlSourceOptions = {},
/** If credentials are to be included in a cross-origin request. */
withCredentials?: boolean;
} = {},
) { ) {
if (typeof url !== 'string') { if (typeof url !== 'string' && !(url instanceof URL)) {
throw new TypeError('url must be a string.'); throw new TypeError('url must be a string or URL.');
} }
if (!options || typeof options !== 'object') { if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.'); throw new TypeError('options must be an object.');
} }
if (options.withCredentials !== undefined && typeof options.withCredentials !== 'boolean') { if (options.requestInit !== undefined && (!options.requestInit || typeof options.requestInit !== 'object')) {
throw new TypeError('options.withCredentials, when specified, must be a boolean.'); 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(); super();
this._url = url; this._url = url;
this._withCredentials = options.withCredentials ?? false; this._options = options;
} }
/** @internal */ /** @internal */
private _makeRequest( private async _makeRequest(
range?: { start: number; end: number }, range?: { start: number; end: number },
): Promise<{ response: ArrayBuffer; statusCode: number }> { ): Promise<{ response: ArrayBuffer; statusCode: number }> {
return new Promise((resolve, reject) => { const headers: HeadersInit = {};
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;
if (range) { if (range) {
xhr.setRequestHeader('Range', `bytes=${range.start}-${range.end - 1}`); headers['Range'] = `bytes=${range.start}-${range.end - 1}`;
} }
xhr.onload = () => { const response = await retriedFetch(
if (xhr.status >= 200 && xhr.status < 300) { this._url,
const buffer = xhr.response as ArrayBuffer; mergeObjectsDeeply(this._options.requestInit ?? {}, {
method: 'GET',
headers,
}),
this._options.getRetryDelay ?? (() => null),
);
if (!range) { if (!response.ok) {
this._fullData = buffer; throw new Error(`Error fetching ${this._url}: ${response.status} ${response.statusText}`);
} }
resolve({ const buffer = await response.arrayBuffer();
response: buffer,
statusCode: xhr.status,
});
} else {
reject(new Error(`Error fetching ${this._url}: ${xhr.status} ${xhr.statusText}`));
}
};
xhr.onerror = () => { if (!range) {
reject(new Error('Network error occurred.')); this._fullData = buffer;
}; }
xhr.ontimeout = () => { return {
reject(new Error('Request timed out.')); response: buffer,
}; statusCode: response.status,
};
xhr.send();
});
} }
/** @internal */ /** @internal */
@@ -234,44 +246,27 @@ export class UrlSource extends Source {
return this._fullData.byteLength; return this._fullData.byteLength;
} }
const xhr = new XMLHttpRequest(); // Try a range request to get the Content-Range header
xhr.open('GET', this._url, true); const rangeResponse = await retriedFetch(
xhr.responseType = 'arraybuffer'; this._url,
xhr.withCredentials = this._withCredentials; mergeObjectsDeeply(this._options.requestInit ?? {}, {
xhr.setRequestHeader('Range', 'bytes=0-0'); method: 'GET',
headers: { Range: 'bytes=0-0' },
}),
this._options.getRetryDelay ?? (() => null),
);
await new Promise<void>((resolve, reject) => { if (rangeResponse.status === 206) {
xhr.onload = () => { const contentRange = rangeResponse.headers.get('Content-Range');
if (xhr.status >= 200 && xhr.status < 300) { if (contentRange) {
resolve(); const match = contentRange.match(/bytes \d+-\d+\/(\d+)/);
} else { if (match && match[1]) {
reject(new Error(`Error fetching ${this._url} (Range): ${xhr.status} ${xhr.statusText}`)); 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 // If the range request didn't provide the size, make a full GET request
const contentLength = xhr.getResponseHeader('Content-Length');
if (contentLength) {
return parseInt(contentLength, 10);
}
// If neither header is available, make a full GET request
const { response } = await this._makeRequest(); const { response } = await this._makeRequest();
return response.byteLength; return response.byteLength;
} }