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';
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,
+53
View File
@@ -397,3 +397,56 @@ export const SECOND_TO_MICROSECOND_FACTOR = 1e6 * (1 + Number.EPSILON);
* @public
*/
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));
}
}
}
};
+63 -68
View File
@@ -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}`);
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 (!response.ok) {
throw new Error(`Error fetching ${this._url}: ${response.status} ${response.statusText}`);
}
const buffer = await response.arrayBuffer();
if (!range) {
this._fullData = buffer;
}
resolve({
return {
response: buffer,
statusCode: xhr.status,
});
} else {
reject(new Error(`Error fetching ${this._url}: ${xhr.status} ${xhr.statusText}`));
}
statusCode: response.status,
};
xhr.onerror = () => {
reject(new Error('Network error occurred.'));
};
xhr.ontimeout = () => {
reject(new Error('Request timed out.'));
};
xhr.send();
});
}
/** @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<void>((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}`));
}
};
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 (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);
}
}
// 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;
}