123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337 |
- import { getRetryDelay, shouldRetry } from './error-helper';
- import { LoadStats } from '../loader/load-stats';
- import { logger } from '../utils/logger';
- import type { HlsConfig } from '../config';
- import type { RetryConfig } from '../config';
- import type {
- Loader,
- LoaderCallbacks,
- LoaderConfiguration,
- LoaderContext,
- LoaderResponse,
- LoaderStats,
- } from '../types/loader';
- const AGE_HEADER_LINE_REGEX = /^age:\s*[\d.]+\s*$/im;
- class XhrLoader implements Loader<LoaderContext> {
- private xhrSetup:
- | ((xhr: XMLHttpRequest, url: string) => Promise<void> | void)
- | null;
- private requestTimeout?: number;
- private retryTimeout?: number;
- private retryDelay: number;
- private config: LoaderConfiguration | null = null;
- private callbacks: LoaderCallbacks<LoaderContext> | null = null;
- public context: LoaderContext | null = null;
- private loader: XMLHttpRequest | null = null;
- public stats: LoaderStats;
- constructor(config: HlsConfig) {
- this.xhrSetup = config ? config.xhrSetup || null : null;
- this.stats = new LoadStats();
- this.retryDelay = 0;
- }
- destroy() {
- this.callbacks = null;
- this.abortInternal();
- this.loader = null;
- this.config = null;
- this.context = null;
- this.xhrSetup = null;
- }
- abortInternal() {
- const loader = this.loader;
- self.clearTimeout(this.requestTimeout);
- self.clearTimeout(this.retryTimeout);
- if (loader) {
- loader.onreadystatechange = null;
- loader.onprogress = null;
- if (loader.readyState !== 4) {
- this.stats.aborted = true;
- loader.abort();
- }
- }
- }
- abort() {
- this.abortInternal();
- if (this.callbacks?.onAbort) {
- this.callbacks.onAbort(
- this.stats,
- this.context as LoaderContext,
- this.loader,
- );
- }
- }
- load(
- context: LoaderContext,
- config: LoaderConfiguration,
- callbacks: LoaderCallbacks<LoaderContext>,
- ) {
- if (this.stats.loading.start) {
- throw new Error('Loader can only be used once.');
- }
- this.stats.loading.start = self.performance.now();
- this.context = context;
- this.config = config;
- this.callbacks = callbacks;
- this.loadInternal();
- }
- loadInternal() {
- const { config, context } = this;
- if (!config || !context) {
- return;
- }
- const xhr = (this.loader = new self.XMLHttpRequest());
- const stats = this.stats;
- stats.loading.first = 0;
- stats.loaded = 0;
- stats.aborted = false;
- const xhrSetup = this.xhrSetup;
- if (xhrSetup) {
- Promise.resolve()
- .then(() => {
- if (this.loader !== xhr || this.stats.aborted) return;
- return xhrSetup(xhr, context.url);
- })
- .catch((error: Error) => {
- if (this.loader !== xhr || this.stats.aborted) return;
- xhr.open('GET', context.url, true);
- return xhrSetup(xhr, context.url);
- })
- .then(() => {
- if (this.loader !== xhr || this.stats.aborted) return;
- this.openAndSendXhr(xhr, context, config);
- })
- .catch((error: Error) => {
- // IE11 throws an exception on xhr.open if attempting to access an HTTP resource over HTTPS
- this.callbacks?.onError(
- { code: xhr.status, text: error.message },
- context,
- xhr,
- stats,
- );
- return;
- });
- } else {
- this.openAndSendXhr(xhr, context, config);
- }
- }
- openAndSendXhr(
- xhr: XMLHttpRequest,
- context: LoaderContext,
- config: LoaderConfiguration,
- ) {
- if (!xhr.readyState) {
- xhr.open('GET', context.url, true);
- }
- const headers = context.headers;
- const { maxTimeToFirstByteMs, maxLoadTimeMs } = config.loadPolicy;
- if (headers) {
- for (const header in headers) {
- xhr.setRequestHeader(header, headers[header]);
- }
- }
- if (context.rangeEnd) {
- xhr.setRequestHeader(
- 'Range',
- 'bytes=' + context.rangeStart + '-' + (context.rangeEnd - 1),
- );
- }
- xhr.onreadystatechange = this.readystatechange.bind(this);
- xhr.onprogress = this.loadprogress.bind(this);
- xhr.responseType = context.responseType as XMLHttpRequestResponseType;
- // setup timeout before we perform request
- self.clearTimeout(this.requestTimeout);
- config.timeout =
- maxTimeToFirstByteMs && Number.isFinite(maxTimeToFirstByteMs)
- ? maxTimeToFirstByteMs
- : maxLoadTimeMs;
- this.requestTimeout = self.setTimeout(
- this.loadtimeout.bind(this),
- config.timeout,
- );
- xhr.send();
- }
- readystatechange() {
- const { context, loader: xhr, stats } = this;
- if (!context || !xhr) {
- return;
- }
- const readyState = xhr.readyState;
- const config = this.config as LoaderConfiguration;
- // don't proceed if xhr has been aborted
- if (stats.aborted) {
- return;
- }
- // >= HEADERS_RECEIVED
- if (readyState >= 2) {
- if (stats.loading.first === 0) {
- stats.loading.first = Math.max(
- self.performance.now(),
- stats.loading.start,
- );
- // readyState >= 2 AND readyState !==4 (readyState = HEADERS_RECEIVED || LOADING) rearm timeout as xhr not finished yet
- if (config.timeout !== config.loadPolicy.maxLoadTimeMs) {
- self.clearTimeout(this.requestTimeout);
- config.timeout = config.loadPolicy.maxLoadTimeMs;
- this.requestTimeout = self.setTimeout(
- this.loadtimeout.bind(this),
- config.loadPolicy.maxLoadTimeMs -
- (stats.loading.first - stats.loading.start),
- );
- }
- }
- if (readyState === 4) {
- self.clearTimeout(this.requestTimeout);
- xhr.onreadystatechange = null;
- xhr.onprogress = null;
- const status = xhr.status;
- // http status between 200 to 299 are all successful
- const useResponseText =
- xhr.responseType === 'text' ? xhr.responseText : null;
- if (status >= 200 && status < 300) {
- const data = useResponseText ?? xhr.response;
- if (data != null) {
- stats.loading.end = Math.max(
- self.performance.now(),
- stats.loading.first,
- );
- const len =
- xhr.responseType === 'arraybuffer'
- ? data.byteLength
- : data.length;
- stats.loaded = stats.total = len;
- stats.bwEstimate =
- (stats.total * 8000) / (stats.loading.end - stats.loading.first);
- const onProgress = this.callbacks?.onProgress;
- if (onProgress) {
- onProgress(stats, context, data, xhr);
- }
- const response: LoaderResponse = {
- url: xhr.responseURL,
- data: data,
- code: status,
- };
- this.callbacks?.onSuccess(response, stats, context, xhr);
- return;
- }
- }
- // Handle bad status or nullish response
- const retryConfig = config.loadPolicy.errorRetry;
- const retryCount = stats.retry;
- // if max nb of retries reached or if http status between 400 and 499 (such error cannot be recovered, retrying is useless), return error
- const response: LoaderResponse = {
- url: context.url,
- data: undefined,
- code: status,
- };
- if (shouldRetry(retryConfig, retryCount, false, response)) {
- this.retry(retryConfig);
- } else {
- logger.error(`${status} while loading ${context.url}`);
- this.callbacks?.onError(
- { code: status, text: xhr.statusText },
- context,
- xhr,
- stats,
- );
- }
- }
- }
- }
- loadtimeout() {
- if (!this.config) return;
- const retryConfig = this.config.loadPolicy.timeoutRetry;
- const retryCount = this.stats.retry;
- if (shouldRetry(retryConfig, retryCount, true)) {
- this.retry(retryConfig);
- } else {
- logger.warn(`timeout while loading ${this.context?.url}`);
- const callbacks = this.callbacks;
- if (callbacks) {
- this.abortInternal();
- callbacks.onTimeout(
- this.stats,
- this.context as LoaderContext,
- this.loader,
- );
- }
- }
- }
- retry(retryConfig: RetryConfig) {
- const { context, stats } = this;
- this.retryDelay = getRetryDelay(retryConfig, stats.retry);
- stats.retry++;
- logger.warn(
- `${
- status ? 'HTTP Status ' + status : 'Timeout'
- } while loading ${context?.url}, retrying ${stats.retry}/${
- retryConfig.maxNumRetry
- } in ${this.retryDelay}ms`,
- );
- // abort and reset internal state
- this.abortInternal();
- this.loader = null;
- // schedule retry
- self.clearTimeout(this.retryTimeout);
- this.retryTimeout = self.setTimeout(
- this.loadInternal.bind(this),
- this.retryDelay,
- );
- }
- loadprogress(event: ProgressEvent) {
- const stats = this.stats;
- stats.loaded = event.loaded;
- if (event.lengthComputable) {
- stats.total = event.total;
- }
- }
- getCacheAge(): number | null {
- let result: number | null = null;
- if (
- this.loader &&
- AGE_HEADER_LINE_REGEX.test(this.loader.getAllResponseHeaders())
- ) {
- const ageHeader = this.loader.getResponseHeader('age');
- result = ageHeader ? parseFloat(ageHeader) : null;
- }
- return result;
- }
- getResponseHeader(name: string): string | null {
- if (
- this.loader &&
- new RegExp(`^${name}:\\s*[\\d.]+\\s*$`, 'im').test(
- this.loader.getAllResponseHeaders(),
- )
- ) {
- return this.loader.getResponseHeader(name);
- }
- return null;
- }
- }
- export default XhrLoader;
|