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.  
  102. const headers = this.context.headers;
  103. if (headers) {
  104. for (const header in headers) {
  105. xhr.setRequestHeader(header, headers[header]);
  106. }
  107. }
  108. } catch (e) {
  109. // IE11 throws an exception on xhr.open if attempting to access an HTTP resource over HTTPS
  110. this.callbacks!.onError(
  111. { code: xhr.status, text: e.message },
  112. context,
  113. xhr
  114. );
  115. return;
  116. }
  117.  
  118. if (context.rangeEnd) {
  119. xhr.setRequestHeader(
  120. 'Range',
  121. 'bytes=' + context.rangeStart + '-' + (context.rangeEnd - 1)
  122. );
  123. }
  124.  
  125. xhr.onreadystatechange = this.readystatechange.bind(this);
  126. xhr.onprogress = this.loadprogress.bind(this);
  127. xhr.responseType = context.responseType as XMLHttpRequestResponseType;
  128. // setup timeout before we perform request
  129. self.clearTimeout(this.requestTimeout);
  130. this.requestTimeout = self.setTimeout(
  131. this.loadtimeout.bind(this),
  132. config.timeout
  133. );
  134. xhr.send();
  135. }
  136.  
  137. readystatechange(): void {
  138. const { context, loader: xhr, stats } = this;
  139. if (!context || !xhr) {
  140. return;
  141. }
  142. const readyState = xhr.readyState;
  143. const config = this.config as LoaderConfiguration;
  144.  
  145. // don't proceed if xhr has been aborted
  146. if (stats.aborted) {
  147. return;
  148. }
  149.  
  150. // >= HEADERS_RECEIVED
  151. if (readyState >= 2) {
  152. // clear xhr timeout and rearm it if readyState less than 4
  153. self.clearTimeout(this.requestTimeout);
  154. if (stats.loading.first === 0) {
  155. stats.loading.first = Math.max(
  156. self.performance.now(),
  157. stats.loading.start
  158. );
  159. }
  160.  
  161. if (readyState === 4) {
  162. xhr.onreadystatechange = null;
  163. xhr.onprogress = null;
  164. const status = xhr.status;
  165. // http status between 200 to 299 are all successful
  166. if (status >= 200 && status < 300) {
  167. stats.loading.end = Math.max(
  168. self.performance.now(),
  169. stats.loading.first
  170. );
  171. let data;
  172. let len: number;
  173. if (context.responseType === 'arraybuffer') {
  174. data = xhr.response;
  175. len = data.byteLength;
  176. } else {
  177. data = xhr.responseText;
  178. len = data.length;
  179. }
  180. stats.loaded = stats.total = len;
  181.  
  182. if (!this.callbacks) {
  183. return;
  184. }
  185. const onProgress = this.callbacks.onProgress;
  186. if (onProgress) {
  187. onProgress(stats, context, data, xhr);
  188. }
  189. if (!this.callbacks) {
  190. return;
  191. }
  192. const response = {
  193. url: xhr.responseURL,
  194. data: data,
  195. };
  196.  
  197. this.callbacks.onSuccess(response, stats, context, xhr);
  198. } else {
  199. // if max nb of retries reached or if http status between 400 and 499 (such error cannot be recovered, retrying is useless), return error
  200. if (
  201. stats.retry >= config.maxRetry ||
  202. (status >= 400 && status < 499)
  203. ) {
  204. logger.error(`${status} while loading ${context.url}`);
  205. this.callbacks!.onError(
  206. { code: status, text: xhr.statusText },
  207. context,
  208. xhr
  209. );
  210. } else {
  211. // retry
  212. logger.warn(
  213. `${status} while loading ${context.url}, retrying in ${this.retryDelay}...`
  214. );
  215. // abort and reset internal state
  216. this.abortInternal();
  217. this.loader = null;
  218. // schedule retry
  219. self.clearTimeout(this.retryTimeout);
  220. this.retryTimeout = self.setTimeout(
  221. this.loadInternal.bind(this),
  222. this.retryDelay
  223. );
  224. // set exponential backoff
  225. this.retryDelay = Math.min(
  226. 2 * this.retryDelay,
  227. config.maxRetryDelay
  228. );
  229. stats.retry++;
  230. }
  231. }
  232. } else {
  233. // readyState >= 2 AND readyState !==4 (readyState = HEADERS_RECEIVED || LOADING) rearm timeout as xhr not finished yet
  234. self.clearTimeout(this.requestTimeout);
  235. this.requestTimeout = self.setTimeout(
  236. this.loadtimeout.bind(this),
  237. config.timeout
  238. );
  239. }
  240. }
  241. }
  242.  
  243. loadtimeout(): void {
  244. logger.warn(`timeout while loading ${this.context.url}`);
  245. const callbacks = this.callbacks;
  246. if (callbacks) {
  247. this.abortInternal();
  248. callbacks.onTimeout(this.stats, this.context, this.loader);
  249. }
  250. }
  251.  
  252. loadprogress(event: ProgressEvent): void {
  253. const stats = this.stats;
  254.  
  255. stats.loaded = event.loaded;
  256. if (event.lengthComputable) {
  257. stats.total = event.total;
  258. }
  259. }
  260.  
  261. getCacheAge(): number | null {
  262. let result: number | null = null;
  263. if (
  264. this.loader &&
  265. AGE_HEADER_LINE_REGEX.test(this.loader.getAllResponseHeaders())
  266. ) {
  267. const ageHeader = this.loader.getResponseHeader('age');
  268. result = ageHeader ? parseFloat(ageHeader) : null;
  269. }
  270. return result;
  271. }
  272. }
  273.  
  274. export default XhrLoader;