@serwist/background-sync 9.0.0-preview.2 → 9.0.0-preview.20

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