source: frontend/node_modules/workbox-background-sync/src/Queue.ts

Last change on this file was 9af201e, checked in by MBK <marija.karapandzova@…>, 12 days ago

Fix frontend appearance

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