mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-02 21:33:52 +02:00
Refactor UrlSource to use fetch instead of XHR & offer more options
This commit is contained in:
+9
-1
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user