Queue.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. /*
  2. Copyright 2018 Google LLC
  3. Use of this source code is governed by an MIT-style
  4. license that can be found in the LICENSE file or at
  5. https://opensource.org/licenses/MIT.
  6. */
  7. import { WorkboxError } from 'workbox-core/_private/WorkboxError.js';
  8. import { logger } from 'workbox-core/_private/logger.js';
  9. import { assert } from 'workbox-core/_private/assert.js';
  10. import { getFriendlyURL } from 'workbox-core/_private/getFriendlyURL.js';
  11. import { QueueStore } from './lib/QueueStore.js';
  12. import { StorableRequest } from './lib/StorableRequest.js';
  13. import './_version.js';
  14. const TAG_PREFIX = 'workbox-background-sync';
  15. const MAX_RETENTION_TIME = 60 * 24 * 7; // 7 days in minutes
  16. const queueNames = new Set();
  17. /**
  18. * Converts a QueueStore entry into the format exposed by Queue. This entails
  19. * converting the request data into a real request and omitting the `id` and
  20. * `queueName` properties.
  21. *
  22. * @param {UnidentifiedQueueStoreEntry} queueStoreEntry
  23. * @return {Queue}
  24. * @private
  25. */
  26. const convertEntry = (queueStoreEntry) => {
  27. const queueEntry = {
  28. request: new StorableRequest(queueStoreEntry.requestData).toRequest(),
  29. timestamp: queueStoreEntry.timestamp,
  30. };
  31. if (queueStoreEntry.metadata) {
  32. queueEntry.metadata = queueStoreEntry.metadata;
  33. }
  34. return queueEntry;
  35. };
  36. /**
  37. * A class to manage storing failed requests in IndexedDB and retrying them
  38. * later. All parts of the storing and replaying process are observable via
  39. * callbacks.
  40. *
  41. * @memberof workbox-background-sync
  42. */
  43. class Queue {
  44. /**
  45. * Creates an instance of Queue with the given options
  46. *
  47. * @param {string} name The unique name for this queue. This name must be
  48. * unique as it's used to register sync events and store requests
  49. * in IndexedDB specific to this instance. An error will be thrown if
  50. * a duplicate name is detected.
  51. * @param {Object} [options]
  52. * @param {Function} [options.onSync] A function that gets invoked whenever
  53. * the 'sync' event fires. The function is invoked with an object
  54. * containing the `queue` property (referencing this instance), and you
  55. * can use the callback to customize the replay behavior of the queue.
  56. * When not set the `replayRequests()` method is called.
  57. * Note: if the replay fails after a sync event, make sure you throw an
  58. * error, so the browser knows to retry the sync event later.
  59. * @param {number} [options.maxRetentionTime=7 days] The amount of time (in
  60. * minutes) a request may be retried. After this amount of time has
  61. * passed, the request will be deleted from the queue.
  62. * @param {boolean} [options.forceSyncFallback=false] If `true`, instead
  63. * of attempting to use background sync events, always attempt to replay
  64. * queued request at service worker startup. Most folks will not need
  65. * this, unless you explicitly target a runtime like Electron that
  66. * exposes the interfaces for background sync, but does not have a working
  67. * implementation.
  68. */
  69. constructor(name, { forceSyncFallback, onSync, maxRetentionTime } = {}) {
  70. this._syncInProgress = false;
  71. this._requestsAddedDuringSync = false;
  72. // Ensure the store name is not already being used
  73. if (queueNames.has(name)) {
  74. throw new WorkboxError('duplicate-queue-name', { name });
  75. }
  76. else {
  77. queueNames.add(name);
  78. }
  79. this._name = name;
  80. this._onSync = onSync || this.replayRequests;
  81. this._maxRetentionTime = maxRetentionTime || MAX_RETENTION_TIME;
  82. this._forceSyncFallback = Boolean(forceSyncFallback);
  83. this._queueStore = new QueueStore(this._name);
  84. this._addSyncListener();
  85. }
  86. /**
  87. * @return {string}
  88. */
  89. get name() {
  90. return this._name;
  91. }
  92. /**
  93. * Stores the passed request in IndexedDB (with its timestamp and any
  94. * metadata) at the end of the queue.
  95. *
  96. * @param {QueueEntry} entry
  97. * @param {Request} entry.request The request to store in the queue.
  98. * @param {Object} [entry.metadata] Any metadata you want associated with the
  99. * stored request. When requests are replayed you'll have access to this
  100. * metadata object in case you need to modify the request beforehand.
  101. * @param {number} [entry.timestamp] The timestamp (Epoch time in
  102. * milliseconds) when the request was first added to the queue. This is
  103. * used along with `maxRetentionTime` to remove outdated requests. In
  104. * general you don't need to set this value, as it's automatically set
  105. * for you (defaulting to `Date.now()`), but you can update it if you
  106. * don't want particular requests to expire.
  107. */
  108. async pushRequest(entry) {
  109. if (process.env.NODE_ENV !== 'production') {
  110. assert.isType(entry, 'object', {
  111. moduleName: 'workbox-background-sync',
  112. className: 'Queue',
  113. funcName: 'pushRequest',
  114. paramName: 'entry',
  115. });
  116. assert.isInstance(entry.request, Request, {
  117. moduleName: 'workbox-background-sync',
  118. className: 'Queue',
  119. funcName: 'pushRequest',
  120. paramName: 'entry.request',
  121. });
  122. }
  123. await this._addRequest(entry, 'push');
  124. }
  125. /**
  126. * Stores the passed request in IndexedDB (with its timestamp and any
  127. * metadata) at the beginning of the queue.
  128. *
  129. * @param {QueueEntry} entry
  130. * @param {Request} entry.request The request to store in the queue.
  131. * @param {Object} [entry.metadata] Any metadata you want associated with the
  132. * stored request. When requests are replayed you'll have access to this
  133. * metadata object in case you need to modify the request beforehand.
  134. * @param {number} [entry.timestamp] The timestamp (Epoch time in
  135. * milliseconds) when the request was first added to the queue. This is
  136. * used along with `maxRetentionTime` to remove outdated requests. In
  137. * general you don't need to set this value, as it's automatically set
  138. * for you (defaulting to `Date.now()`), but you can update it if you
  139. * don't want particular requests to expire.
  140. */
  141. async unshiftRequest(entry) {
  142. if (process.env.NODE_ENV !== 'production') {
  143. assert.isType(entry, 'object', {
  144. moduleName: 'workbox-background-sync',
  145. className: 'Queue',
  146. funcName: 'unshiftRequest',
  147. paramName: 'entry',
  148. });
  149. assert.isInstance(entry.request, Request, {
  150. moduleName: 'workbox-background-sync',
  151. className: 'Queue',
  152. funcName: 'unshiftRequest',
  153. paramName: 'entry.request',
  154. });
  155. }
  156. await this._addRequest(entry, 'unshift');
  157. }
  158. /**
  159. * Removes and returns the last request in the queue (along with its
  160. * timestamp and any metadata). The returned object takes the form:
  161. * `{request, timestamp, metadata}`.
  162. *
  163. * @return {Promise<QueueEntry | undefined>}
  164. */
  165. async popRequest() {
  166. return this._removeRequest('pop');
  167. }
  168. /**
  169. * Removes and returns the first request in the queue (along with its
  170. * timestamp and any metadata). The returned object takes the form:
  171. * `{request, timestamp, metadata}`.
  172. *
  173. * @return {Promise<QueueEntry | undefined>}
  174. */
  175. async shiftRequest() {
  176. return this._removeRequest('shift');
  177. }
  178. /**
  179. * Returns all the entries that have not expired (per `maxRetentionTime`).
  180. * Any expired entries are removed from the queue.
  181. *
  182. * @return {Promise<Array<QueueEntry>>}
  183. */
  184. async getAll() {
  185. const allEntries = await this._queueStore.getAll();
  186. const now = Date.now();
  187. const unexpiredEntries = [];
  188. for (const entry of allEntries) {
  189. // Ignore requests older than maxRetentionTime. Call this function
  190. // recursively until an unexpired request is found.
  191. const maxRetentionTimeInMs = this._maxRetentionTime * 60 * 1000;
  192. if (now - entry.timestamp > maxRetentionTimeInMs) {
  193. await this._queueStore.deleteEntry(entry.id);
  194. }
  195. else {
  196. unexpiredEntries.push(convertEntry(entry));
  197. }
  198. }
  199. return unexpiredEntries;
  200. }
  201. /**
  202. * Returns the number of entries present in the queue.
  203. * Note that expired entries (per `maxRetentionTime`) are also included in this count.
  204. *
  205. * @return {Promise<number>}
  206. */
  207. async size() {
  208. return await this._queueStore.size();
  209. }
  210. /**
  211. * Adds the entry to the QueueStore and registers for a sync event.
  212. *
  213. * @param {Object} entry
  214. * @param {Request} entry.request
  215. * @param {Object} [entry.metadata]
  216. * @param {number} [entry.timestamp=Date.now()]
  217. * @param {string} operation ('push' or 'unshift')
  218. * @private
  219. */
  220. async _addRequest({ request, metadata, timestamp = Date.now() }, operation) {
  221. const storableRequest = await StorableRequest.fromRequest(request.clone());
  222. const entry = {
  223. requestData: storableRequest.toObject(),
  224. timestamp,
  225. };
  226. // Only include metadata if it's present.
  227. if (metadata) {
  228. entry.metadata = metadata;
  229. }
  230. switch (operation) {
  231. case 'push':
  232. await this._queueStore.pushEntry(entry);
  233. break;
  234. case 'unshift':
  235. await this._queueStore.unshiftEntry(entry);
  236. break;
  237. }
  238. if (process.env.NODE_ENV !== 'production') {
  239. logger.log(`Request for '${getFriendlyURL(request.url)}' has ` +
  240. `been added to background sync queue '${this._name}'.`);
  241. }
  242. // Don't register for a sync if we're in the middle of a sync. Instead,
  243. // we wait until the sync is complete and call register if
  244. // `this._requestsAddedDuringSync` is true.
  245. if (this._syncInProgress) {
  246. this._requestsAddedDuringSync = true;
  247. }
  248. else {
  249. await this.registerSync();
  250. }
  251. }
  252. /**
  253. * Removes and returns the first or last (depending on `operation`) entry
  254. * from the QueueStore that's not older than the `maxRetentionTime`.
  255. *
  256. * @param {string} operation ('pop' or 'shift')
  257. * @return {Object|undefined}
  258. * @private
  259. */
  260. async _removeRequest(operation) {
  261. const now = Date.now();
  262. let entry;
  263. switch (operation) {
  264. case 'pop':
  265. entry = await this._queueStore.popEntry();
  266. break;
  267. case 'shift':
  268. entry = await this._queueStore.shiftEntry();
  269. break;
  270. }
  271. if (entry) {
  272. // Ignore requests older than maxRetentionTime. Call this function
  273. // recursively until an unexpired request is found.
  274. const maxRetentionTimeInMs = this._maxRetentionTime * 60 * 1000;
  275. if (now - entry.timestamp > maxRetentionTimeInMs) {
  276. return this._removeRequest(operation);
  277. }
  278. return convertEntry(entry);
  279. }
  280. else {
  281. return undefined;
  282. }
  283. }
  284. /**
  285. * Loops through each request in the queue and attempts to re-fetch it.
  286. * If any request fails to re-fetch, it's put back in the same position in
  287. * the queue (which registers a retry for the next sync event).
  288. */
  289. async replayRequests() {
  290. let entry;
  291. while ((entry = await this.shiftRequest())) {
  292. try {
  293. await fetch(entry.request.clone());
  294. if (process.env.NODE_ENV !== 'production') {
  295. logger.log(`Request for '${getFriendlyURL(entry.request.url)}' ` +
  296. `has been replayed in queue '${this._name}'`);
  297. }
  298. }
  299. catch (error) {
  300. await this.unshiftRequest(entry);
  301. if (process.env.NODE_ENV !== 'production') {
  302. logger.log(`Request for '${getFriendlyURL(entry.request.url)}' ` +
  303. `failed to replay, putting it back in queue '${this._name}'`);
  304. }
  305. throw new WorkboxError('queue-replay-failed', { name: this._name });
  306. }
  307. }
  308. if (process.env.NODE_ENV !== 'production') {
  309. logger.log(`All requests in queue '${this.name}' have successfully ` +
  310. `replayed; the queue is now empty!`);
  311. }
  312. }
  313. /**
  314. * Registers a sync event with a tag unique to this instance.
  315. */
  316. async registerSync() {
  317. // See https://github.com/GoogleChrome/workbox/issues/2393
  318. if ('sync' in self.registration && !this._forceSyncFallback) {
  319. try {
  320. await self.registration.sync.register(`${TAG_PREFIX}:${this._name}`);
  321. }
  322. catch (err) {
  323. // This means the registration failed for some reason, possibly due to
  324. // the user disabling it.
  325. if (process.env.NODE_ENV !== 'production') {
  326. logger.warn(`Unable to register sync event for '${this._name}'.`, err);
  327. }
  328. }
  329. }
  330. }
  331. /**
  332. * In sync-supporting browsers, this adds a listener for the sync event.
  333. * In non-sync-supporting browsers, or if _forceSyncFallback is true, this
  334. * will retry the queue on service worker startup.
  335. *
  336. * @private
  337. */
  338. _addSyncListener() {
  339. // See https://github.com/GoogleChrome/workbox/issues/2393
  340. if ('sync' in self.registration && !this._forceSyncFallback) {
  341. self.addEventListener('sync', (event) => {
  342. if (event.tag === `${TAG_PREFIX}:${this._name}`) {
  343. if (process.env.NODE_ENV !== 'production') {
  344. logger.log(`Background sync for tag '${event.tag}' ` + `has been received`);
  345. }
  346. const syncComplete = async () => {
  347. this._syncInProgress = true;
  348. let syncError;
  349. try {
  350. await this._onSync({ queue: this });
  351. }
  352. catch (error) {
  353. if (error instanceof Error) {
  354. syncError = error;
  355. // Rethrow the error. Note: the logic in the finally clause
  356. // will run before this gets rethrown.
  357. throw syncError;
  358. }
  359. }
  360. finally {
  361. // New items may have been added to the queue during the sync,
  362. // so we need to register for a new sync if that's happened...
  363. // Unless there was an error during the sync, in which
  364. // case the browser will automatically retry later, as long
  365. // as `event.lastChance` is not true.
  366. if (this._requestsAddedDuringSync &&
  367. !(syncError && !event.lastChance)) {
  368. await this.registerSync();
  369. }
  370. this._syncInProgress = false;
  371. this._requestsAddedDuringSync = false;
  372. }
  373. };
  374. event.waitUntil(syncComplete());
  375. }
  376. });
  377. }
  378. else {
  379. if (process.env.NODE_ENV !== 'production') {
  380. logger.log(`Background sync replaying without background sync event`);
  381. }
  382. // If the browser doesn't support background sync, or the developer has
  383. // opted-in to not using it, retry every time the service worker starts up
  384. // as a fallback.
  385. void this._onSync({ queue: this });
  386. }
  387. }
  388. /**
  389. * Returns the set of queue names. This is primarily used to reset the list
  390. * of queue names in tests.
  391. *
  392. * @return {Set<string>}
  393. *
  394. * @private
  395. */
  396. static get _queueNames() {
  397. return queueNames;
  398. }
  399. }
  400. export { Queue };