xhr-loader.ts 9.5 KB

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