Home Reference Source

src/utils/xhr-loader.ts

  1. import { logger } from '../utils/logger';
  2. import type {
  3. LoaderCallbacks,
  4. LoaderContext,
  5. LoaderStats,
  6. Loader,
  7. LoaderConfiguration,
  8. } from '../types/loader';
  9. import { LoadStats } from '../loader/load-stats';
  10.  
  11. const AGE_HEADER_LINE_REGEX = /^age:\s*[\d.]+\s*$/m;
  12.  
  13. class XhrLoader implements Loader<LoaderContext> {
  14. private xhrSetup: Function | null;
  15. private requestTimeout?: number;
  16. private retryTimeout?: number;
  17. private retryDelay: number;
  18. private config: LoaderConfiguration | null = null;
  19. private callbacks: LoaderCallbacks<LoaderContext> | null = null;
  20. public context!: LoaderContext;
  21.  
  22. private loader: XMLHttpRequest | null = null;
  23. public stats: LoaderStats;
  24.  
  25. constructor(config /* HlsConfig */) {
  26. this.xhrSetup = config ? config.xhrSetup : null;
  27. this.stats = new LoadStats();
  28. this.retryDelay = 0;
  29. }
  30.  
  31. destroy(): void {
  32. this.callbacks = null;
  33. this.abortInternal();
  34. this.loader = null;
  35. this.config = null;
  36. }
  37.  
  38. abortInternal(): void {
  39. const loader = this.loader;
  40. self.clearTimeout(this.requestTimeout);
  41. self.clearTimeout(this.retryTimeout);
  42. if (loader) {
  43. loader.onreadystatechange = null;
  44. loader.onprogress = null;
  45. if (loader.readyState !== 4) {
  46. this.stats.aborted = true;
  47. loader.abort();
  48. }
  49. }
  50. }
  51.  
  52. abort(): void {
  53. this.abortInternal();
  54. if (this.callbacks?.onAbort) {
  55. this.callbacks.onAbort(this.stats, this.context, this.loader);
  56. }
  57. }
  58.  
  59. load(
  60. context: LoaderContext,
  61. config: LoaderConfiguration,
  62. callbacks: LoaderCallbacks<LoaderContext>
  63. ): void {
  64. if (this.stats.loading.start) {
  65. throw new Error('Loader can only be used once.');
  66. }
  67. this.stats.loading.start = self.performance.now();
  68. this.context = context;
  69. this.config = config;
  70. this.callbacks = callbacks;
  71. this.retryDelay = config.retryDelay;
  72. this.loadInternal();
  73. }
  74.  
  75. loadInternal(): void {
  76. const { config, context } = this;
  77. if (!config) {
  78. return;
  79. }
  80. const xhr = (this.loader = new self.XMLHttpRequest());
  81.  
  82. const stats = this.stats;
  83. stats.loading.first = 0;
  84. stats.loaded = 0;
  85. const xhrSetup = this.xhrSetup;
  86.  
  87. try {
  88. if (xhrSetup) {
  89. try {
  90. xhrSetup(xhr, context.url);
  91. } catch (e) {
  92. // fix xhrSetup: (xhr, url) => {xhr.setRequestHeader("Content-Language", "test");}
  93. // not working, as xhr.setRequestHeader expects xhr.readyState === OPEN
  94. xhr.open('GET', context.url, true);
  95. xhrSetup(xhr, context.url);
  96. }
  97. }
  98. if (!xhr.readyState) {
  99. xhr.open('GET', context.url, true);
  100. }
  101. } catch (e) {
  102. // IE11 throws an exception on xhr.open if attempting to access an HTTP resource over HTTPS
  103. this.callbacks!.onError(
  104. { code: xhr.status, text: e.message },
  105. context,
  106. xhr
  107. );
  108. return;
  109. }
  110.  
  111. if (context.rangeEnd) {
  112. xhr.setRequestHeader(
  113. 'Range',
  114. 'bytes=' + context.rangeStart + '-' + (context.rangeEnd - 1)
  115. );
  116. }
  117.  
  118. xhr.onreadystatechange = this.readystatechange.bind(this);
  119. xhr.onprogress = this.loadprogress.bind(this);
  120. xhr.responseType = context.responseType as XMLHttpRequestResponseType;
  121. // setup timeout before we perform request
  122. self.clearTimeout(this.requestTimeout);
  123. this.requestTimeout = self.setTimeout(
  124. this.loadtimeout.bind(this),
  125. config.timeout
  126. );
  127. xhr.send();
  128. }
  129.  
  130. readystatechange(): void {
  131. const { context, loader: xhr, stats } = this;
  132. if (!context || !xhr) {
  133. return;
  134. }
  135. const readyState = xhr.readyState;
  136. const config = this.config as LoaderConfiguration;
  137.  
  138. // don't proceed if xhr has been aborted
  139. if (stats.aborted) {
  140. return;
  141. }
  142.  
  143. // >= HEADERS_RECEIVED
  144. if (readyState >= 2) {
  145. // clear xhr timeout and rearm it if readyState less than 4
  146. self.clearTimeout(this.requestTimeout);
  147. if (stats.loading.first === 0) {
  148. stats.loading.first = Math.max(
  149. self.performance.now(),
  150. stats.loading.start
  151. );
  152. }
  153.  
  154. if (readyState === 4) {
  155. xhr.onreadystatechange = null;
  156. xhr.onprogress = null;
  157. const status = xhr.status;
  158. // http status between 200 to 299 are all successful
  159. if (status >= 200 && status < 300) {
  160. stats.loading.end = Math.max(
  161. self.performance.now(),
  162. stats.loading.first
  163. );
  164. let data;
  165. let len: number;
  166. if (context.responseType === 'arraybuffer') {
  167. data = xhr.response;
  168. len = data.byteLength;
  169. } else {
  170. data = xhr.responseText;
  171. len = data.length;
  172. }
  173. stats.loaded = stats.total = len;
  174.  
  175. if (!this.callbacks) {
  176. return;
  177. }
  178. const onProgress = this.callbacks.onProgress;
  179. if (onProgress) {
  180. onProgress(stats, context, data, xhr);
  181. }
  182. if (!this.callbacks) {
  183. return;
  184. }
  185. const response = {
  186. url: xhr.responseURL,
  187. data: data,
  188. };
  189.  
  190. this.callbacks.onSuccess(response, stats, context, xhr);
  191. } else {
  192. // if max nb of retries reached or if http status between 400 and 499 (such error cannot be recovered, retrying is useless), return error
  193. if (
  194. stats.retry >= config.maxRetry ||
  195. (status >= 400 && status < 499)
  196. ) {
  197. logger.error(`${status} while loading ${context.url}`);
  198. this.callbacks!.onError(
  199. { code: status, text: xhr.statusText },
  200. context,
  201. xhr
  202. );
  203. } else {
  204. // retry
  205. logger.warn(
  206. `${status} while loading ${context.url}, retrying in ${this.retryDelay}...`
  207. );
  208. // abort and reset internal state
  209. this.abortInternal();
  210. this.loader = null;
  211. // schedule retry
  212. self.clearTimeout(this.retryTimeout);
  213. this.retryTimeout = self.setTimeout(
  214. this.loadInternal.bind(this),
  215. this.retryDelay
  216. );
  217. // set exponential backoff
  218. this.retryDelay = Math.min(
  219. 2 * this.retryDelay,
  220. config.maxRetryDelay
  221. );
  222. stats.retry++;
  223. }
  224. }
  225. } else {
  226. // readyState >= 2 AND readyState !==4 (readyState = HEADERS_RECEIVED || LOADING) rearm timeout as xhr not finished yet
  227. self.clearTimeout(this.requestTimeout);
  228. this.requestTimeout = self.setTimeout(
  229. this.loadtimeout.bind(this),
  230. config.timeout
  231. );
  232. }
  233. }
  234. }
  235.  
  236. loadtimeout(): void {
  237. logger.warn(`timeout while loading ${this.context.url}`);
  238. const callbacks = this.callbacks;
  239. if (callbacks) {
  240. this.abortInternal();
  241. callbacks.onTimeout(this.stats, this.context, this.loader);
  242. }
  243. }
  244.  
  245. loadprogress(event: ProgressEvent): void {
  246. const stats = this.stats;
  247.  
  248. stats.loaded = event.loaded;
  249. if (event.lengthComputable) {
  250. stats.total = event.total;
  251. }
  252. }
  253.  
  254. getCacheAge(): number | null {
  255. let result: number | null = null;
  256. if (
  257. this.loader &&
  258. AGE_HEADER_LINE_REGEX.test(this.loader.getAllResponseHeaders())
  259. ) {
  260. const ageHeader = this.loader.getResponseHeader('age');
  261. result = ageHeader ? parseFloat(ageHeader) : null;
  262. }
  263. return result;
  264. }
  265. }
  266.  
  267. export default XhrLoader;