Home Reference Source

src/utils/fetch-loader.ts

import {
  LoaderCallbacks,
  LoaderContext,
  Loader,
  LoaderStats,
  LoaderConfiguration,
  LoaderOnProgress
} from '../types/loader';
import LoadStats from '../loader/load-stats';
import ChunkCache from '../demux/chunk-cache';

export function fetchSupported () {
  if (self.fetch && self.AbortController && self.ReadableStream && self.Request) {
    try {
      new self.ReadableStream({}); // eslint-disable-line no-new
      return true;
    } catch (e) { /* noop */ }
  }
  return false;
}

class FetchLoader implements Loader<LoaderContext> {
  private fetchSetup: Function;
  private requestTimeout?: number;
  private request!: Request;
  private response!: Response;
  private controller: AbortController;
  public context!: LoaderContext;
  private config: LoaderConfiguration | null = null;
  private callbacks: LoaderCallbacks<LoaderContext> | null = null;
  public stats: LoaderStats;
  public loader: Response | null = null;

  constructor (config /* HlsConfig */) {
    this.fetchSetup = config.fetchSetup || getRequest;
    this.controller = new self.AbortController();
    this.stats = new LoadStats();
  }

  destroy (): void {
    this.loader =
      this.callbacks = null;
    this.abortInternal();
  }

  abortInternal (): void {
    this.stats.aborted = true;
    this.controller.abort();
  }

  abort (): void {
    this.abortInternal();
    if (this.callbacks?.onAbort) {
      this.callbacks.onAbort(this.stats, this.context, this.response);
    }
  }

  load (context: LoaderContext, config: LoaderConfiguration, callbacks: LoaderCallbacks<LoaderContext>): void {
    const stats = this.stats;
    if (stats.loading.start) {
      throw new Error('Loader can only be used once.');
    }
    stats.loading.start = self.performance.now();

    const initParams = getRequestParameters(context, this.controller.signal);
    const onProgress: LoaderOnProgress<LoaderContext> | undefined = callbacks.onProgress;
    const isArrayBuffer = context.responseType === 'arraybuffer';
    const LENGTH = isArrayBuffer ? 'byteLength' : 'length';

    this.context = context;
    this.config = config;
    this.callbacks = callbacks;
    this.request = this.fetchSetup(context, initParams);
    self.clearTimeout(this.requestTimeout);
    this.requestTimeout = self.setTimeout(() => {
      this.abortInternal();
      callbacks.onTimeout(stats, context, this.response);
    }, config.timeout);

    self.fetch(this.request).then((response: Response): Promise<string | ArrayBuffer> => {
      this.response = this.loader = response;

      if (!response.ok) {
        const { status, statusText } = response;
        throw new FetchError(statusText || 'fetch, bad network response', status, response);
      }
      stats.loading.first = Math.max(self.performance.now(), stats.loading.start);
      stats.total = parseInt(response.headers.get('Content-Length') || '0');

      if (onProgress && Number.isFinite(config.highWaterMark)) {
        this.loadProgressively(response, stats, context, config.highWaterMark, onProgress);
      }

      if (isArrayBuffer) {
        return response.arrayBuffer();
      }
      return response.text();
    }).then((responseData: string | ArrayBuffer) => {
      const { response } = this;
      self.clearTimeout(this.requestTimeout);
      stats.loading.end = Math.max(self.performance.now(), stats.loading.first);
      stats.loaded = stats.total = responseData[LENGTH];

      const loaderResponse = {
        url: response.url,
        data: responseData
      };

      if (onProgress && !Number.isFinite(config.highWaterMark)) {
        onProgress(stats, context, responseData, response);
      }

      callbacks.onSuccess(loaderResponse, stats, context, response);
    }).catch((error) => {
      self.clearTimeout(this.requestTimeout);
      if (stats.aborted) {
        return;
      }
      // CORS errors result in an undefined code. Set it to 0 here to align with XHR's behavior
      const code = error.code || 0;
      callbacks.onError({ code, text: error.message }, context, error.details);
    });
  }

  getResponseHeader (name: string): string | null {
    if (this.response) {
      try {
        return this.response.headers.get(name);
      } catch (error) { /* Could not get header */ }
    }
    return null;
  }

  private loadProgressively (response: Response, stats: LoaderStats, context: LoaderContext, highWaterMark: number = 0, onProgress: LoaderOnProgress<LoaderContext>) {
    const chunkCache = new ChunkCache();
    const reader = (response.clone().body as ReadableStream).getReader();

    const pump = () => {
      reader.read().then((data: { done: boolean, value: Uint8Array }) => {
        if (data.done) {
          if (chunkCache.dataLength) {
            onProgress(stats, context, chunkCache.flush(), response);
          }
          return;
        }
        const chunk = data.value;
        const len = chunk.length;
        stats.loaded += len;
        if (len < highWaterMark || chunkCache.dataLength) {
          // The current chunk is too small to to be emitted or the cache already has data
          // Push it to the cache
          chunkCache.push(chunk);
          if (chunkCache.dataLength >= highWaterMark) {
            // flush in order to join the typed arrays
            onProgress(stats, context, chunkCache.flush(), response);
          }
        } else {
          // If there's nothing cached already, and the chache is large enough
          // just emit the progress event
          onProgress(stats, context, chunk, response);
        }
        pump();
      }).catch(() => { /* aborted */ });
    };

    pump();
  }
}

function getRequestParameters (context: LoaderContext, signal): any {
  const initParams: any = {
    method: 'GET',
    mode: 'cors',
    credentials: 'same-origin',
    signal
  };

  if (context.rangeEnd) {
    initParams.headers = new self.Headers({
      Range: 'bytes=' + context.rangeStart + '-' + String(context.rangeEnd - 1)
    });
  }

  return initParams;
}

function getRequest (context: LoaderContext, initParams: any): Request {
  return new self.Request(context.url, initParams);
}

class FetchError extends Error {
  public code: number;
  public details: any;
  constructor (message: string, code: number, details: any) {
    super(message);
    this.code = code;
    this.details = details;
  }
}

export default FetchLoader;