@mearl/preset 3.0.1 → 3.0.2
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/dist/bundleStore.d.ts +1 -0
- package/dist/bundleStore.js +138 -30
- package/package.json +1 -1
package/dist/bundleStore.d.ts
CHANGED
|
@@ -63,6 +63,7 @@ export declare class BrowserBundleStore {
|
|
|
63
63
|
installation?: BrowserPresetInstallation;
|
|
64
64
|
expectedInstallationToken: string | null;
|
|
65
65
|
}, expectedBundleRevisions?: Record<string, string>): Promise<void>;
|
|
66
|
+
uninstallPreset(expectedRevision: string, expectedInstallationToken: string | null): Promise<void>;
|
|
66
67
|
/** Verifies all resources before a compare-and-swap transaction replaces the active snapshot. */
|
|
67
68
|
install(bundle: PreparedBundle, expectedRevision: string | null, expectedBundleRevisions?: Record<string, string>): Promise<void>;
|
|
68
69
|
remove(id: string, expectedRevision: string): Promise<void>;
|
package/dist/bundleStore.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { BundleError, MAX_BUNDLE_BYTES, MAX_BROWSER_STORAGE_BYTES, MAX_PAGE_RUNTIME_BYTES, bundleDigest, verifyBundle } from './bundle.js';
|
|
2
|
-
import { verifyBrowserPreset, verifyPreparedBrowserPreset } from './browserPreset.js';
|
|
1
|
+
import { BundleError, MAX_BUNDLE_BYTES, MAX_BROWSER_STORAGE_BYTES, MAX_PAGE_RUNTIME_BYTES, bundleDigest, verifyBundle, } from './bundle.js';
|
|
2
|
+
import { verifyBrowserPreset, verifyPreparedBrowserPreset, } from './browserPreset.js';
|
|
3
3
|
import { mergePresetDefaults } from './resolve.js';
|
|
4
4
|
function installationMetadata(value) {
|
|
5
|
-
if (!value ||
|
|
5
|
+
if (!value ||
|
|
6
|
+
Object.keys(value).some(key => !['revision', 'browserRevision', 'overrides'].includes(key)) ||
|
|
7
|
+
!/^[a-f0-9]{64}$/.test(value.revision) ||
|
|
8
|
+
!/^[a-f0-9]{64}$/.test(value.browserRevision))
|
|
6
9
|
throw new BundleError('BUNDLE_INVALID', 'Invalid browser installation identity');
|
|
7
10
|
const overrides = mergePresetDefaults({}, value.overrides);
|
|
8
11
|
if (new TextEncoder().encode(JSON.stringify(overrides)).byteLength > 262144)
|
|
@@ -17,18 +20,25 @@ function captureRevisions(input) {
|
|
|
17
20
|
if (input === undefined)
|
|
18
21
|
return;
|
|
19
22
|
const entries = Object.entries(input);
|
|
20
|
-
if (entries.length > 64 ||
|
|
23
|
+
if (entries.length > 64 ||
|
|
24
|
+
entries.some(([id, revision]) => !/^[a-z][a-z0-9-]{0,79}$/.test(id) || typeof revision !== 'string'))
|
|
21
25
|
throw new BundleError('BUNDLE_INVALID', 'Invalid expected browser bundle selection');
|
|
22
26
|
return Object.fromEntries(entries);
|
|
23
27
|
}
|
|
24
28
|
function checkRevisions(records, expected) {
|
|
25
|
-
if (expected &&
|
|
29
|
+
if (expected &&
|
|
30
|
+
(records.length !== Object.keys(expected).length ||
|
|
31
|
+
records.some(record => !Object.hasOwn(expected, record.id) || expected[record.id] !== record.revision)))
|
|
26
32
|
throw new BundleError('BUNDLE_CONFLICT', 'Browser bundles changed while contributions were being validated');
|
|
27
33
|
}
|
|
28
34
|
function storedBundle(bundle) {
|
|
29
35
|
const { resources, ...metadata } = bundle;
|
|
30
|
-
return {
|
|
31
|
-
|
|
36
|
+
return {
|
|
37
|
+
id: bundle.manifest.id,
|
|
38
|
+
revision: bundle.revision,
|
|
39
|
+
byteLength: Object.values(resources).reduce((sum, bytes) => sum + bytes.byteLength, 0),
|
|
40
|
+
bundle: metadata,
|
|
41
|
+
};
|
|
32
42
|
}
|
|
33
43
|
function bundleObjectHashes(records) {
|
|
34
44
|
const hashes = new Set();
|
|
@@ -61,13 +71,17 @@ function readObjects(records, store) {
|
|
|
61
71
|
const hashes = bundleObjectHashes(records);
|
|
62
72
|
for (const hash of hashes) {
|
|
63
73
|
const request = store.get(hash);
|
|
64
|
-
request.onsuccess = () => {
|
|
65
|
-
|
|
74
|
+
request.onsuccess = () => {
|
|
75
|
+
if (request.result)
|
|
76
|
+
result.set(hash, request.result);
|
|
77
|
+
};
|
|
66
78
|
}
|
|
67
79
|
return result;
|
|
68
80
|
}
|
|
69
81
|
function checkStorageBudget(records, runtimes) {
|
|
70
|
-
if (records.some(record => !Number.isSafeInteger(record.byteLength) ||
|
|
82
|
+
if (records.some(record => !Number.isSafeInteger(record.byteLength) ||
|
|
83
|
+
record.byteLength < 0 ||
|
|
84
|
+
record.byteLength > MAX_BUNDLE_BYTES) ||
|
|
71
85
|
runtimes.some(runtime => !(runtime.bytes instanceof Uint8Array) || runtime.bytes.byteLength > MAX_BUNDLE_BYTES)) {
|
|
72
86
|
throw new BundleError('BUNDLE_INTEGRITY', 'Stored resource size is invalid');
|
|
73
87
|
}
|
|
@@ -100,10 +114,16 @@ export class BrowserBundleStore {
|
|
|
100
114
|
request.onerror = () => reject(request.error);
|
|
101
115
|
request.onsuccess = () => {
|
|
102
116
|
const database = request.result;
|
|
103
|
-
database.onversionchange = () => {
|
|
117
|
+
database.onversionchange = () => {
|
|
118
|
+
database.close();
|
|
119
|
+
this.database = undefined;
|
|
120
|
+
};
|
|
104
121
|
resolve(database);
|
|
105
122
|
};
|
|
106
|
-
}).catch(error => {
|
|
123
|
+
}).catch(error => {
|
|
124
|
+
this.database = undefined;
|
|
125
|
+
throw error;
|
|
126
|
+
});
|
|
107
127
|
return this.database;
|
|
108
128
|
}
|
|
109
129
|
async close() {
|
|
@@ -118,11 +138,19 @@ export class BrowserBundleStore {
|
|
|
118
138
|
const transaction = database.transaction([STORE, PRESET, OBJECTS], 'readonly');
|
|
119
139
|
const request = transaction.objectStore(STORE).getAll();
|
|
120
140
|
let objects = new Map();
|
|
121
|
-
request.onsuccess = () => {
|
|
141
|
+
request.onsuccess = () => {
|
|
142
|
+
objects = readObjects(request.result, transaction.objectStore(OBJECTS));
|
|
143
|
+
};
|
|
122
144
|
const active = transaction.objectStore(PRESET).get('active');
|
|
123
145
|
const installation = transaction.objectStore(PRESET).get('installation');
|
|
124
146
|
const token = transaction.objectStore(PRESET).get('installationToken');
|
|
125
|
-
transaction.oncomplete = () => resolve({
|
|
147
|
+
transaction.oncomplete = () => resolve({
|
|
148
|
+
objects,
|
|
149
|
+
records: request.result,
|
|
150
|
+
active: active.result,
|
|
151
|
+
installation: installation.result,
|
|
152
|
+
installationToken: token.result ?? null,
|
|
153
|
+
});
|
|
126
154
|
transaction.onabort = transaction.onerror = () => reject(transaction.error ?? new Error('Bundle read failed'));
|
|
127
155
|
});
|
|
128
156
|
}
|
|
@@ -132,7 +160,9 @@ export class BrowserBundleStore {
|
|
|
132
160
|
const record = await new Promise((resolve, reject) => {
|
|
133
161
|
const transaction = database.transaction([STORE, OBJECTS], 'readonly');
|
|
134
162
|
const request = transaction.objectStore(STORE).get(id);
|
|
135
|
-
request.onsuccess = () => {
|
|
163
|
+
request.onsuccess = () => {
|
|
164
|
+
objects = readObjects(request.result ? [request.result] : [], transaction.objectStore(OBJECTS));
|
|
165
|
+
};
|
|
136
166
|
transaction.oncomplete = () => resolve(request.result);
|
|
137
167
|
transaction.onabort = transaction.onerror = () => reject(transaction.error ?? new Error('Bundle read failed'));
|
|
138
168
|
});
|
|
@@ -150,7 +180,8 @@ export class BrowserBundleStore {
|
|
|
150
180
|
resources[name] = object.bytes;
|
|
151
181
|
}
|
|
152
182
|
const bundle = await verifyBundle({ ...record.bundle, resources }, this.options.mearlApiVersion);
|
|
153
|
-
if (Object.values(bundle.resources).reduce((sum, bytes) => sum + bytes.byteLength, 0) !==
|
|
183
|
+
if (Object.values(bundle.resources).reduce((sum, bytes) => sum + bytes.byteLength, 0) !==
|
|
184
|
+
record.byteLength) {
|
|
154
185
|
throw new BundleError('BUNDLE_INTEGRITY', 'Stored resource size does not match');
|
|
155
186
|
}
|
|
156
187
|
return bundle;
|
|
@@ -158,13 +189,16 @@ export class BrowserBundleStore {
|
|
|
158
189
|
async list() {
|
|
159
190
|
const bundles = [];
|
|
160
191
|
const errors = [];
|
|
161
|
-
const { objects, records, active, installation: storedInstallation, installationToken } = await this.records();
|
|
192
|
+
const { objects, records, active, installation: storedInstallation, installationToken, } = await this.records();
|
|
162
193
|
for (const record of records) {
|
|
163
194
|
try {
|
|
164
195
|
bundles.push(await this.verified(record, objects));
|
|
165
196
|
}
|
|
166
197
|
catch (error) {
|
|
167
|
-
errors.push({
|
|
198
|
+
errors.push({
|
|
199
|
+
id: record.id,
|
|
200
|
+
error: error instanceof Error ? error.message : 'Invalid stored bundle',
|
|
201
|
+
});
|
|
168
202
|
}
|
|
169
203
|
}
|
|
170
204
|
let activation;
|
|
@@ -181,8 +215,12 @@ export class BrowserBundleStore {
|
|
|
181
215
|
}
|
|
182
216
|
if (activation) {
|
|
183
217
|
for (const [id, revision] of Object.entries(activation.snapshot.bundleRevisions)) {
|
|
184
|
-
if (!bundles.some(bundle => bundle.manifest.id === id && bundle.revision === revision) &&
|
|
185
|
-
errors.
|
|
218
|
+
if (!bundles.some(bundle => bundle.manifest.id === id && bundle.revision === revision) &&
|
|
219
|
+
!errors.some(error => error.id === id)) {
|
|
220
|
+
errors.push({
|
|
221
|
+
id,
|
|
222
|
+
error: 'BUNDLE_INTEGRITY: An active preset package is missing or corrupt',
|
|
223
|
+
});
|
|
186
224
|
}
|
|
187
225
|
}
|
|
188
226
|
}
|
|
@@ -198,8 +236,17 @@ export class BrowserBundleStore {
|
|
|
198
236
|
installationError = error instanceof Error ? error.message : 'Invalid browser installation';
|
|
199
237
|
}
|
|
200
238
|
}
|
|
201
|
-
return {
|
|
202
|
-
|
|
239
|
+
return {
|
|
240
|
+
bundles,
|
|
241
|
+
errors,
|
|
242
|
+
activation,
|
|
243
|
+
activationError,
|
|
244
|
+
installation,
|
|
245
|
+
installationError,
|
|
246
|
+
installationToken,
|
|
247
|
+
activeRevision: active?.revision ?? null,
|
|
248
|
+
revisions: Object.fromEntries(records.map(record => [record.id, record.revision])),
|
|
249
|
+
};
|
|
203
250
|
}
|
|
204
251
|
async activePreset() {
|
|
205
252
|
const database = await this.open();
|
|
@@ -222,7 +269,10 @@ export class BrowserBundleStore {
|
|
|
222
269
|
transaction.oncomplete = () => resolve(request.result);
|
|
223
270
|
transaction.onabort = transaction.onerror = () => reject(transaction.error ?? new Error('Browser runtime read failed'));
|
|
224
271
|
});
|
|
225
|
-
if (!(record?.bytes instanceof Uint8Array) ||
|
|
272
|
+
if (!(record?.bytes instanceof Uint8Array) ||
|
|
273
|
+
record.sha256 !== sha256 ||
|
|
274
|
+
record.bytes.byteLength > MAX_BUNDLE_BYTES ||
|
|
275
|
+
(await bundleDigest(record.bytes)) !== sha256) {
|
|
226
276
|
throw new BundleError('BUNDLE_INTEGRITY', 'Pinned browser runtime is missing or corrupt');
|
|
227
277
|
}
|
|
228
278
|
return Uint8Array.from(record.bytes);
|
|
@@ -248,7 +298,8 @@ export class BrowserBundleStore {
|
|
|
248
298
|
}
|
|
249
299
|
async updateRuntimePins(input, replace) {
|
|
250
300
|
const patch = Object.entries(input).map(([owner, sha256]) => {
|
|
251
|
-
if (!/^[a-zA-Z0-9:._-]{1,200}$/.test(owner) ||
|
|
301
|
+
if (!/^[a-zA-Z0-9:._-]{1,200}$/.test(owner) ||
|
|
302
|
+
(sha256 !== null && !/^[a-f0-9]{64}$/.test(sha256)))
|
|
252
303
|
throw new BundleError('BUNDLE_INVALID', 'Invalid page runtime reference');
|
|
253
304
|
return { owner, sha256 };
|
|
254
305
|
});
|
|
@@ -302,7 +353,9 @@ export class BrowserBundleStore {
|
|
|
302
353
|
const transaction = database.transaction([PRESET, STORE, OBJECTS], 'readonly');
|
|
303
354
|
const current = transaction.objectStore(PRESET).get('active');
|
|
304
355
|
const bundle = transaction.objectStore(STORE).get(id);
|
|
305
|
-
bundle.onsuccess = () => {
|
|
356
|
+
bundle.onsuccess = () => {
|
|
357
|
+
objects = readObjects(bundle.result ? [bundle.result] : [], transaction.objectStore(OBJECTS));
|
|
358
|
+
};
|
|
306
359
|
transaction.oncomplete = () => {
|
|
307
360
|
if (current.result?.revision !== activation.snapshot.revision) {
|
|
308
361
|
reject(new BundleError('BUNDLE_CONFLICT', 'The selected preset changed during profile lookup'));
|
|
@@ -320,10 +373,18 @@ export class BrowserBundleStore {
|
|
|
320
373
|
/** Packages and the exact preset graph become visible together, after host contribution checks. */
|
|
321
374
|
async activatePreset(input, replacements, expectedRevision, association, expectedBundleRevisions) {
|
|
322
375
|
const selection = captureRevisions(expectedBundleRevisions);
|
|
323
|
-
const accepted = association?.installation && {
|
|
324
|
-
|
|
376
|
+
const accepted = association?.installation && {
|
|
377
|
+
installation: installationMetadata(association.installation),
|
|
378
|
+
expected: association.expectedInstallationToken,
|
|
379
|
+
};
|
|
380
|
+
if ((association &&
|
|
381
|
+
association.expectedInstallationToken !== null &&
|
|
382
|
+
!/^[a-f0-9]{64}$/.test(association.expectedInstallationToken)) ||
|
|
383
|
+
(accepted && accepted.installation.browserRevision !== input.revision))
|
|
325
384
|
throw new BundleError('BUNDLE_INVALID', 'Invalid browser installation selection');
|
|
326
|
-
if (replacements.length > 64 ||
|
|
385
|
+
if (replacements.length > 64 ||
|
|
386
|
+
replacements.reduce((total, item) => total +
|
|
387
|
+
Object.values(item.bundle.resources).reduce((sum, bytes) => sum + bytes.byteLength, 0), 0) > MAX_BROWSER_STORAGE_BYTES) {
|
|
327
388
|
throw new BundleError('BUNDLE_QUOTA', 'Preset packages exceed the browser storage budget');
|
|
328
389
|
}
|
|
329
390
|
const expected = replacements.map(item => item.expectedRevision);
|
|
@@ -351,14 +412,20 @@ export class BrowserBundleStore {
|
|
|
351
412
|
transaction.abort();
|
|
352
413
|
return;
|
|
353
414
|
}
|
|
354
|
-
if ((active.result?.revision ?? null) !== expectedRevision ||
|
|
415
|
+
if ((active.result?.revision ?? null) !== expectedRevision ||
|
|
416
|
+
(association &&
|
|
417
|
+
(installation.result ?? null) !== association.expectedInstallationToken) ||
|
|
418
|
+
next.some(item => (records.result.find(record => record.id === item.bundle.manifest.id)?.revision ??
|
|
419
|
+
null) !== item.expectedRevision)) {
|
|
355
420
|
failure = new BundleError('BUNDLE_CONFLICT', 'Preset or packages changed while activation was being prepared');
|
|
356
421
|
transaction.abort();
|
|
357
422
|
return;
|
|
358
423
|
}
|
|
359
424
|
const replacing = new Set(next.map(item => item.bundle.manifest.id));
|
|
360
425
|
const previousManaged = new Set(Object.keys(active.result?.bundleRevisions ?? {}));
|
|
361
|
-
if (accepted &&
|
|
426
|
+
if (accepted &&
|
|
427
|
+
next.some(item => !previousManaged.has(item.bundle.manifest.id) &&
|
|
428
|
+
records.result.some(record => record.id === item.bundle.manifest.id))) {
|
|
362
429
|
failure = new BundleError('BUNDLE_CONFLICT', 'A preset package conflicts with an independently installed browser package');
|
|
363
430
|
transaction.abort();
|
|
364
431
|
return;
|
|
@@ -405,6 +472,47 @@ export class BrowserBundleStore {
|
|
|
405
472
|
transaction.onabort = transaction.onerror = () => reject(failure ?? transaction.error ?? new Error('Preset activation failed'));
|
|
406
473
|
});
|
|
407
474
|
}
|
|
475
|
+
async uninstallPreset(expectedRevision, expectedInstallationToken) {
|
|
476
|
+
const selection = await this.activePreset();
|
|
477
|
+
if (selection?.snapshot.revision !== expectedRevision)
|
|
478
|
+
throw new BundleError('BUNDLE_CONFLICT', 'The browser preset changed before removal');
|
|
479
|
+
const managed = selection.snapshot.bundleRevisions;
|
|
480
|
+
const database = await this.open();
|
|
481
|
+
await new Promise((resolve, reject) => {
|
|
482
|
+
const transaction = database.transaction([STORE, PRESET, OBJECTS, PINS], 'readwrite');
|
|
483
|
+
const store = transaction.objectStore(STORE);
|
|
484
|
+
const presets = transaction.objectStore(PRESET);
|
|
485
|
+
const objects = transaction.objectStore(OBJECTS);
|
|
486
|
+
const records = store.getAll();
|
|
487
|
+
const retained = objects.getAll();
|
|
488
|
+
const pins = transaction.objectStore(PINS).getAll();
|
|
489
|
+
const token = presets.get('installationToken');
|
|
490
|
+
const active = presets.get('active');
|
|
491
|
+
let failure;
|
|
492
|
+
active.onsuccess = () => {
|
|
493
|
+
if (active.result?.revision !== expectedRevision ||
|
|
494
|
+
(token.result ?? null) !== expectedInstallationToken) {
|
|
495
|
+
failure = new BundleError('BUNDLE_CONFLICT', 'The browser preset changed before removal');
|
|
496
|
+
transaction.abort();
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const remaining = records.result.filter(record => {
|
|
500
|
+
if (!Object.hasOwn(managed, record.id) || managed[record.id] !== record.revision)
|
|
501
|
+
return true;
|
|
502
|
+
store.delete(record.id);
|
|
503
|
+
return false;
|
|
504
|
+
});
|
|
505
|
+
const keep = new Set(referencedObjects(remaining, retained.result, pins.result).map(object => object.sha256));
|
|
506
|
+
for (const object of retained.result)
|
|
507
|
+
if (!keep.has(object.sha256))
|
|
508
|
+
objects.delete(object.sha256);
|
|
509
|
+
for (const key of ['active', 'installation', 'installationToken'])
|
|
510
|
+
presets.delete(key);
|
|
511
|
+
};
|
|
512
|
+
transaction.oncomplete = () => resolve();
|
|
513
|
+
transaction.onabort = transaction.onerror = () => reject(failure ?? transaction.error ?? new Error('Browser preset removal failed'));
|
|
514
|
+
});
|
|
515
|
+
}
|
|
408
516
|
/** Verifies all resources before a compare-and-swap transaction replaces the active snapshot. */
|
|
409
517
|
async install(bundle, expectedRevision, expectedBundleRevisions) {
|
|
410
518
|
const selection = captureRevisions(expectedBundleRevisions);
|