Queue.mjs 13 KB

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