Source: lib/net/http_fetch_plugin.js

  1. /*! @license
  2. * Shaka Player
  3. * Copyright 2016 Google LLC
  4. * SPDX-License-Identifier: Apache-2.0
  5. */
  6. goog.provide('shaka.net.HttpFetchPlugin');
  7. goog.require('goog.asserts');
  8. goog.require('shaka.log');
  9. goog.require('shaka.net.HttpPluginUtils');
  10. goog.require('shaka.net.NetworkingEngine');
  11. goog.require('shaka.util.AbortableOperation');
  12. goog.require('shaka.util.Error');
  13. goog.require('shaka.util.MapUtils');
  14. goog.require('shaka.util.Timer');
  15. /**
  16. * @summary A networking plugin to handle http and https URIs via the Fetch API.
  17. * @export
  18. */
  19. shaka.net.HttpFetchPlugin = class {
  20. /**
  21. * @param {string} uri
  22. * @param {shaka.extern.Request} request
  23. * @param {shaka.net.NetworkingEngine.RequestType} requestType
  24. * @param {shaka.extern.ProgressUpdated} progressUpdated Called when a
  25. * progress event happened.
  26. * @param {shaka.extern.HeadersReceived} headersReceived Called when the
  27. * headers for the download are received, but before the body is.
  28. * @param {shaka.extern.SchemePluginConfig} config
  29. * @return {!shaka.extern.IAbortableOperation.<shaka.extern.Response>}
  30. * @export
  31. */
  32. static parse(uri, request, requestType, progressUpdated, headersReceived,
  33. config) {
  34. const headers = new shaka.net.HttpFetchPlugin.Headers_();
  35. shaka.util.MapUtils.asMap(request.headers).forEach((value, key) => {
  36. headers.append(key, value);
  37. });
  38. const controller = new shaka.net.HttpFetchPlugin.AbortController_();
  39. /** @type {!RequestInit} */
  40. const init = {
  41. // Edge does not treat null as undefined for body; https://bit.ly/2luyE6x
  42. body: request.body || undefined,
  43. headers: headers,
  44. method: request.method,
  45. signal: controller.signal,
  46. credentials: request.allowCrossSiteCredentials ? 'include' : undefined,
  47. };
  48. /** @type {shaka.net.HttpFetchPlugin.AbortStatus} */
  49. const abortStatus = {
  50. canceled: false,
  51. timedOut: false,
  52. };
  53. const minBytes = config.minBytesForProgressEvents || 0;
  54. const pendingRequest = shaka.net.HttpFetchPlugin.request_(
  55. uri, request, requestType, init, abortStatus, progressUpdated,
  56. headersReceived, request.streamDataCallback, minBytes);
  57. /** @type {!shaka.util.AbortableOperation} */
  58. const op = new shaka.util.AbortableOperation(pendingRequest, () => {
  59. abortStatus.canceled = true;
  60. controller.abort();
  61. return Promise.resolve();
  62. });
  63. // The fetch API does not timeout natively, so do a timeout manually using
  64. // the AbortController.
  65. const timeoutMs = request.retryParameters.timeout;
  66. if (timeoutMs) {
  67. const timer = new shaka.util.Timer(() => {
  68. abortStatus.timedOut = true;
  69. controller.abort();
  70. });
  71. timer.tickAfter(timeoutMs / 1000);
  72. // To avoid calling |abort| on the network request after it finished, we
  73. // will stop the timer when the requests resolves/rejects.
  74. op.finally(() => {
  75. timer.stop();
  76. });
  77. }
  78. return op;
  79. }
  80. /**
  81. * @param {string} uri
  82. * @param {shaka.extern.Request} request
  83. * @param {shaka.net.NetworkingEngine.RequestType} requestType
  84. * @param {!RequestInit} init
  85. * @param {shaka.net.HttpFetchPlugin.AbortStatus} abortStatus
  86. * @param {shaka.extern.ProgressUpdated} progressUpdated
  87. * @param {shaka.extern.HeadersReceived} headersReceived
  88. * @param {?function(BufferSource):!Promise} streamDataCallback
  89. * @param {number} minBytes
  90. * @return {!Promise<!shaka.extern.Response>}
  91. * @private
  92. */
  93. static async request_(uri, request, requestType, init, abortStatus,
  94. progressUpdated, headersReceived, streamDataCallback, minBytes) {
  95. const fetch = shaka.net.HttpFetchPlugin.fetch_;
  96. const ReadableStream = shaka.net.HttpFetchPlugin.ReadableStream_;
  97. let response;
  98. let arrayBuffer = new ArrayBuffer(0);
  99. let loaded = 0;
  100. let lastLoaded = 0;
  101. let headers = {};
  102. // Last time stamp when we got a progress event.
  103. let lastTime = Date.now();
  104. try {
  105. // The promise returned by fetch resolves as soon as the HTTP response
  106. // headers are available. The download itself isn't done until the promise
  107. // for retrieving the data (arrayBuffer, blob, etc) has resolved.
  108. response = await fetch(uri, init);
  109. // At this point in the process, we have the headers of the response, but
  110. // not the body yet.
  111. headers =
  112. shaka.net.HttpFetchPlugin.headersToGenericObject_(response.headers);
  113. headersReceived(headers);
  114. // In new versions of Chromium, HEAD requests now have a response body
  115. // that is null.
  116. // So just don't try to download the body at all, if it's a HEAD request,
  117. // to avoid null reference errors.
  118. // See: https://crbug.com/1297060
  119. if (init.method != 'HEAD') {
  120. goog.asserts.assert(response.body,
  121. 'non-HEAD responses should have a body');
  122. const contentLengthRaw = response.headers.get('Content-Length');
  123. const contentLength =
  124. contentLengthRaw ? parseInt(contentLengthRaw, 10) : 0;
  125. // Fetch returning a ReadableStream response body is not currently
  126. // supported by all browsers.
  127. // Browser compatibility:
  128. // https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
  129. // If it is not supported, returning the whole segment when
  130. // it's ready (as xhr)
  131. if (!response.body) {
  132. arrayBuffer = await response.arrayBuffer();
  133. const currentTime = Date.now();
  134. // If the time between last time and this time we got progress event
  135. // is long enough, or if a whole segment is downloaded, call
  136. // progressUpdated().
  137. progressUpdated(currentTime - lastTime, arrayBuffer.byteLength, 0);
  138. } else {
  139. // Getting the reader in this way allows us to observe the process of
  140. // downloading the body, instead of just waiting for an opaque
  141. // promise to resolve.
  142. // We first clone the response because calling getReader lock the body
  143. // stream; if we didn't clone it here, we would be unable to get the
  144. // response's arrayBuffer later.
  145. const reader = response.clone().body.getReader();
  146. const start = (controller) => {
  147. const push = async () => {
  148. let readObj;
  149. try {
  150. readObj = await reader.read();
  151. } catch (e) {
  152. // If we abort the request, we'll get an error here.
  153. // Just ignore it
  154. // since real errors will be reported when we read
  155. // the buffer below.
  156. shaka.log.v1('error reading from stream', e.message);
  157. return;
  158. }
  159. if (!readObj.done) {
  160. loaded += readObj.value.byteLength;
  161. if (streamDataCallback) {
  162. await streamDataCallback(readObj.value);
  163. }
  164. }
  165. const currentTime = Date.now();
  166. const chunkSize = loaded - lastLoaded;
  167. // If the time between last time and this time we got
  168. // progress event is long enough, or if a whole segment
  169. // is downloaded, call progressUpdated().
  170. if ((currentTime - lastTime > 100 && chunkSize >= minBytes) ||
  171. readObj.done) {
  172. const numBytesRemaining =
  173. readObj.done ? 0 : contentLength - loaded;
  174. progressUpdated(currentTime - lastTime, chunkSize,
  175. numBytesRemaining);
  176. lastLoaded = loaded;
  177. lastTime = currentTime;
  178. }
  179. if (readObj.done) {
  180. goog.asserts.assert(!readObj.value,
  181. 'readObj should be unset when "done" is true.');
  182. controller.close();
  183. } else {
  184. controller.enqueue(readObj.value);
  185. push();
  186. }
  187. };
  188. push();
  189. };
  190. // Create a ReadableStream to use the reader. We don't need to use the
  191. // actual stream for anything, though, as we are using the response's
  192. // arrayBuffer method to get the body, so we don't store the
  193. // ReadableStream.
  194. new ReadableStream({start}); // eslint-disable-line no-new
  195. arrayBuffer = await response.arrayBuffer();
  196. }
  197. }
  198. if (request.headers['Range']) {
  199. const range = request.headers['Range'].replace('bytes=', '').split('-')
  200. .filter((r) => r).map((r) => parseInt(r, 10));
  201. if (range.length == 2 &&
  202. arrayBuffer.byteLength != (range[1] - range[0] + 1)) {
  203. shaka.log.alwaysWarn(
  204. 'Payload length does not match range requested bytes',
  205. request, response);
  206. }
  207. }
  208. } catch (error) {
  209. if (abortStatus.canceled) {
  210. throw new shaka.util.Error(
  211. shaka.util.Error.Severity.RECOVERABLE,
  212. shaka.util.Error.Category.NETWORK,
  213. shaka.util.Error.Code.OPERATION_ABORTED,
  214. uri, requestType);
  215. } else if (abortStatus.timedOut) {
  216. throw new shaka.util.Error(
  217. shaka.util.Error.Severity.RECOVERABLE,
  218. shaka.util.Error.Category.NETWORK,
  219. shaka.util.Error.Code.TIMEOUT,
  220. uri, requestType);
  221. } else {
  222. throw new shaka.util.Error(
  223. shaka.util.Error.Severity.RECOVERABLE,
  224. shaka.util.Error.Category.NETWORK,
  225. shaka.util.Error.Code.HTTP_ERROR,
  226. uri, error, requestType);
  227. }
  228. }
  229. return shaka.net.HttpPluginUtils.makeResponse(headers, arrayBuffer,
  230. response.status, uri, response.url, request, requestType);
  231. }
  232. /**
  233. * @param {!Headers} headers
  234. * @return {!Object<string, string>}
  235. * @private
  236. */
  237. static headersToGenericObject_(headers) {
  238. const headersObj = {};
  239. headers.forEach((value, key) => {
  240. // Since Edge incorrectly return the header with a leading new line
  241. // character ('\n'), we trim the header here.
  242. headersObj[key.trim()] = value;
  243. });
  244. return headersObj;
  245. }
  246. /**
  247. * Determine if the Fetch API is supported in the browser. Note: this is
  248. * deliberately exposed as a method to allow the client app to use the same
  249. * logic as Shaka when determining support.
  250. * @return {boolean}
  251. * @export
  252. */
  253. static isSupported() {
  254. // On Edge, ReadableStream exists, but attempting to construct it results in
  255. // an error. See https://bit.ly/2zwaFLL
  256. // So this has to check that ReadableStream is present AND usable.
  257. if (window.ReadableStream) {
  258. try {
  259. new ReadableStream({}); // eslint-disable-line no-new
  260. } catch (e) {
  261. return false;
  262. }
  263. } else {
  264. return false;
  265. }
  266. // Old fetch implementations hasn't body and ReadableStream implementation
  267. // See: https://github.com/shaka-project/shaka-player/issues/5088
  268. if (window.Response) {
  269. const response = new Response('');
  270. if (!response.body) {
  271. return false;
  272. }
  273. } else {
  274. return false;
  275. }
  276. return !!(window.fetch && !('polyfill' in window.fetch) &&
  277. window.AbortController);
  278. }
  279. };
  280. /**
  281. * @typedef {{
  282. * canceled: boolean,
  283. * timedOut: boolean
  284. * }}
  285. * @property {boolean} canceled
  286. * Indicates if the request was canceled.
  287. * @property {boolean} timedOut
  288. * Indicates if the request timed out.
  289. */
  290. shaka.net.HttpFetchPlugin.AbortStatus;
  291. /**
  292. * Overridden in unit tests, but compiled out in production.
  293. *
  294. * @const {function(string, !RequestInit)}
  295. * @private
  296. */
  297. shaka.net.HttpFetchPlugin.fetch_ = window.fetch;
  298. /**
  299. * Overridden in unit tests, but compiled out in production.
  300. *
  301. * @const {function(new: AbortController)}
  302. * @private
  303. */
  304. shaka.net.HttpFetchPlugin.AbortController_ = window.AbortController;
  305. /**
  306. * Overridden in unit tests, but compiled out in production.
  307. *
  308. * @const {function(new: ReadableStream, !Object)}
  309. * @private
  310. */
  311. shaka.net.HttpFetchPlugin.ReadableStream_ = window.ReadableStream;
  312. /**
  313. * Overridden in unit tests, but compiled out in production.
  314. *
  315. * @const {function(new: Headers)}
  316. * @private
  317. */
  318. shaka.net.HttpFetchPlugin.Headers_ = window.Headers;
  319. if (shaka.net.HttpFetchPlugin.isSupported()) {
  320. shaka.net.NetworkingEngine.registerScheme(
  321. 'http', shaka.net.HttpFetchPlugin.parse,
  322. shaka.net.NetworkingEngine.PluginPriority.PREFERRED,
  323. /* progressSupport= */ true);
  324. shaka.net.NetworkingEngine.registerScheme(
  325. 'https', shaka.net.HttpFetchPlugin.parse,
  326. shaka.net.NetworkingEngine.PluginPriority.PREFERRED,
  327. /* progressSupport= */ true);
  328. shaka.net.NetworkingEngine.registerScheme(
  329. 'blob', shaka.net.HttpFetchPlugin.parse,
  330. shaka.net.NetworkingEngine.PluginPriority.PREFERRED,
  331. /* progressSupport= */ true);
  332. }