Bug 1852754: part 9) Add tests for dynamically loading <link rel="prefetch"> elements...
[gecko.git] / services / settings / RemoteSettings.worker.mjs
blob66228f226ef814fe4e059229c9cfb9e00fc7cc99
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3  * You can obtain one at http://mozilla.org/MPL/2.0/. */
5 /**
6  * A worker dedicated to Remote Settings.
7  */
9 // These files are imported into the worker scope, and are not shared singletons
10 // with the main thread.
11 /* eslint-disable mozilla/reject-import-system-module-from-non-system */
12 import { CanonicalJSON } from "resource://gre/modules/CanonicalJSON.sys.mjs";
13 import { IDBHelpers } from "resource://services-settings/IDBHelpers.sys.mjs";
14 import { SharedUtils } from "resource://services-settings/SharedUtils.sys.mjs";
15 import { jsesc } from "resource://gre/modules/third_party/jsesc/jsesc.mjs";
16 /* eslint-enable mozilla/reject-import-system-module-from-non-system */
18 const IDB_RECORDS_STORE = "records";
19 const IDB_TIMESTAMPS_STORE = "timestamps";
21 let gShutdown = false;
23 const Agent = {
24   /**
25    * Return the canonical JSON serialization of the specified records.
26    * It has to match what is done on the server (See Kinto/kinto-signer).
27    *
28    * @param {Array<Object>} records
29    * @param {String} timestamp
30    * @returns {String}
31    */
32   async canonicalStringify(records, timestamp) {
33     // Sort list by record id.
34     let allRecords = records.sort((a, b) => {
35       if (a.id < b.id) {
36         return -1;
37       }
38       return a.id > b.id ? 1 : 0;
39     });
40     // All existing records are replaced by the version from the server
41     // and deleted records are removed.
42     for (let i = 0; i < allRecords.length /* no increment! */; ) {
43       const rec = allRecords[i];
44       const next = allRecords[i + 1];
45       if ((next && rec.id == next.id) || rec.deleted) {
46         allRecords.splice(i, 1); // remove local record
47       } else {
48         i++;
49       }
50     }
51     const toSerialize = {
52       last_modified: "" + timestamp,
53       data: allRecords,
54     };
55     return CanonicalJSON.stringify(toSerialize, jsesc);
56   },
58   /**
59    * If present, import the JSON file into the Remote Settings IndexedDB
60    * for the specified bucket and collection.
61    * (eg. blocklists/certificates, main/onboarding)
62    * @param {String} bucket
63    * @param {String} collection
64    * @returns {int} Number of records loaded from dump or -1 if no dump found.
65    */
66   async importJSONDump(bucket, collection) {
67     const { data: records, timestamp } = await SharedUtils.loadJSONDump(
68       bucket,
69       collection
70     );
71     if (records === null) {
72       // Return -1 if file is missing.
73       return -1;
74     }
75     if (gShutdown) {
76       throw new Error("Can't import when we've started shutting down.");
77     }
78     await importDumpIDB(bucket, collection, records, timestamp);
79     return records.length;
80   },
82   /**
83    * Check that the specified file matches the expected size and SHA-256 hash.
84    * @param {String} fileUrl file URL to read from
85    * @param {Number} size expected file size
86    * @param {String} size expected file SHA-256 as hex string
87    * @returns {boolean}
88    */
89   async checkFileHash(fileUrl, size, hash) {
90     let resp;
91     try {
92       resp = await fetch(fileUrl);
93     } catch (e) {
94       // File does not exist.
95       return false;
96     }
97     const buffer = await resp.arrayBuffer();
98     return SharedUtils.checkContentHash(buffer, size, hash);
99   },
101   async prepareShutdown() {
102     gShutdown = true;
103     // Ensure we can iterate and abort (which may delete items) by cloning
104     // the list.
105     let transactions = Array.from(gPendingTransactions);
106     for (let transaction of transactions) {
107       try {
108         transaction.abort();
109       } catch (ex) {
110         // We can hit this case if the transaction has finished but
111         // we haven't heard about it yet.
112       }
113     }
114   },
116   _test_only_import(bucket, collection, records, timestamp) {
117     return importDumpIDB(bucket, collection, records, timestamp);
118   },
122  * Wrap worker invocations in order to return the `callbackId` along
123  * the result. This will allow to transform the worker invocations
124  * into promises in `RemoteSettingsWorker.sys.mjs`.
125  */
126 self.onmessage = event => {
127   const { callbackId, method, args = [] } = event.data;
128   Agent[method](...args)
129     .then(result => {
130       self.postMessage({ callbackId, result });
131     })
132     .catch(error => {
133       console.log(`RemoteSettingsWorker error: ${error}`);
134       self.postMessage({ callbackId, error: "" + error });
135     });
138 let gPendingTransactions = new Set();
141  * Import the records into the Remote Settings Chrome IndexedDB.
143  * Note: This duplicates some logics from `kinto-offline-client.js`.
145  * @param {String} bucket
146  * @param {String} collection
147  * @param {Array<Object>} records
148  * @param {Number} timestamp
149  */
150 async function importDumpIDB(bucket, collection, records, timestamp) {
151   // Open the DB. It will exist since if we are running this, it means
152   // we already tried to read the timestamp in `remote-settings.sys.mjs`
153   const db = await IDBHelpers.openIDB(false /* do not allow upgrades */);
155   // try...finally to ensure we always close the db.
156   try {
157     if (gShutdown) {
158       throw new Error("Can't import when we've started shutting down.");
159     }
161     // Each entry of the dump will be stored in the records store.
162     // They are indexed by `_cid`.
163     const cid = bucket + "/" + collection;
164     // We can just modify the items in-place, as we got them from SharedUtils.loadJSONDump().
165     records.forEach(item => {
166       item._cid = cid;
167     });
168     // Store the collection timestamp.
169     let { transaction, promise } = IDBHelpers.executeIDB(
170       db,
171       [IDB_RECORDS_STORE, IDB_TIMESTAMPS_STORE],
172       "readwrite",
173       ([recordsStore, timestampStore], rejectTransaction) => {
174         // Wipe before loading
175         recordsStore.delete(IDBKeyRange.bound([cid], [cid, []], false, true));
176         IDBHelpers.bulkOperationHelper(
177           recordsStore,
178           {
179             reject: rejectTransaction,
180             completion() {
181               timestampStore.put({ cid, value: timestamp });
182             },
183           },
184           "put",
185           records
186         );
187       }
188     );
189     gPendingTransactions.add(transaction);
190     promise = promise.finally(() => gPendingTransactions.delete(transaction));
191     await promise;
192   } finally {
193     // Close now that we're done.
194     db.close();
195   }