@kubohiroya/turbowarp-extension-loader 0.3.1 → 0.5.0
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/README.ja.md +133 -10
- package/README.md +139 -10
- package/dist/index.d.ts +76 -6
- package/dist/index.js +69 -30
- package/dist/indexeddb-cache.d.ts +59 -1
- package/dist/indexeddb-cache.js +136 -11
- package/dist/packager.js +8 -4
- package/dist/project-authoring.d.ts +35 -2
- package/dist/project-authoring.js +67 -8
- package/dist/project-catalog.d.ts +12 -2
- package/dist/project-catalog.js +45 -17
- package/dist/project-codec.d.ts +6 -0
- package/dist/project-codec.js +29 -1
- package/dist/session-trust.js +2 -2
- package/indexeddb-cache.js +1 -1
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { DEFAULT_LOADER_LIMITS } from './limits.js';
|
|
2
|
+
/** Whether the project holds a reference the loader has to fetch, rather than the bytes. */
|
|
3
|
+
export const isRemoteSource = (source) => source.type === 'remote-non-cacheable' || source.type === 'remote-cacheable';
|
|
2
4
|
const DEFAULTS = DEFAULT_LOADER_LIMITS;
|
|
3
5
|
const RESERVED_IDS = new Set(['pen', 'music', 'videoSensing', 'text2speech', 'translate', 'microbit', 'ev3', 'boost', 'wedo2', 'gdxfor', 'makeymakey']);
|
|
4
6
|
const ID = /^[a-zA-Z][a-zA-Z0-9]{0,63}$/;
|
|
@@ -30,18 +32,25 @@ const string = (value, label, max = 256) => {
|
|
|
30
32
|
fail('INVALID_MANIFEST', `Invalid ${label}`);
|
|
31
33
|
return value;
|
|
32
34
|
};
|
|
33
|
-
|
|
35
|
+
// One rule for every address this family records. The code says which address was wrong, since
|
|
36
|
+
// a project's own URI is not one of its sources and should not be reported as one.
|
|
37
|
+
function address(value, code, what) {
|
|
34
38
|
let url;
|
|
39
|
+
// Reported under the caller's code throughout, including a value that is not a string at
|
|
40
|
+
// all, so one wrong address is one error a reader can place.
|
|
41
|
+
if (typeof value !== 'string' || value.length > 4096)
|
|
42
|
+
fail(code, `${what} must be an absolute HTTPS URL`);
|
|
35
43
|
try {
|
|
36
44
|
url = new URL(value);
|
|
37
45
|
}
|
|
38
46
|
catch {
|
|
39
|
-
fail(
|
|
47
|
+
fail(code, `${what} must be an absolute HTTPS URL`);
|
|
40
48
|
}
|
|
41
49
|
if (url.protocol !== 'https:' || url.username || url.password || url.hash)
|
|
42
|
-
fail(
|
|
50
|
+
fail(code, `${what} requires HTTPS without credentials or fragment`);
|
|
43
51
|
return url.href;
|
|
44
52
|
}
|
|
53
|
+
const remoteURL = (value) => address(value, 'INVALID_SOURCE', 'Remote source');
|
|
45
54
|
function embeddedPath(value) {
|
|
46
55
|
const path = string(value, 'embedded path', 256);
|
|
47
56
|
if (!/^extensions\/[a-zA-Z0-9][a-zA-Z0-9._/-]*\.js$/.test(path) || path.split('/').some(part => !part || part === '.' || part === '..'))
|
|
@@ -51,26 +60,42 @@ function embeddedPath(value) {
|
|
|
51
60
|
function sourceOf(source) {
|
|
52
61
|
if (!object(source))
|
|
53
62
|
fail('INVALID_SOURCE', 'Missing extension source');
|
|
54
|
-
|
|
63
|
+
const origin = (value) => remoteURL(string(value, 'URL', 4096));
|
|
64
|
+
if (source.type === 'remote-non-cacheable' || source.type === 'remote-cacheable') {
|
|
55
65
|
keys(source, ['type', 'url'], 'source');
|
|
56
|
-
return Object.freeze({ type:
|
|
66
|
+
return Object.freeze({ type: source.type, url: origin(source.url) });
|
|
57
67
|
}
|
|
58
|
-
if (source.type === 'embedded') {
|
|
59
|
-
keys(source, ['type', 'path'], 'source');
|
|
60
|
-
|
|
68
|
+
if (source.type === 'embedded-file') {
|
|
69
|
+
keys(source, ['type', 'path', 'url'], 'source');
|
|
70
|
+
const path = embeddedPath(source.path);
|
|
71
|
+
if (source.url === undefined)
|
|
72
|
+
return Object.freeze({ type: 'embedded-file', path });
|
|
73
|
+
return Object.freeze({ type: 'embedded-file', path, url: origin(source.url) });
|
|
61
74
|
}
|
|
62
|
-
if (source.type === '
|
|
63
|
-
keys(source, ['type', 'url'
|
|
64
|
-
|
|
65
|
-
|
|
75
|
+
if (source.type === 'embedded-data') {
|
|
76
|
+
keys(source, ['type', 'url'], 'source');
|
|
77
|
+
if (source.url === undefined)
|
|
78
|
+
return Object.freeze({ type: 'embedded-data' });
|
|
79
|
+
return Object.freeze({ type: 'embedded-data', url: origin(source.url) });
|
|
66
80
|
}
|
|
81
|
+
// Name each retired spelling and what replaces it. A project written by an older version
|
|
82
|
+
// fails with something a reader can act on rather than "unsupported", and the reader has to
|
|
83
|
+
// make the choice the old name left implicit rather than have one picked for them.
|
|
84
|
+
if (source.type === 'remote')
|
|
85
|
+
fail('INVALID_SOURCE', 'A remote source is now remote-cacheable or remote-non-cacheable');
|
|
86
|
+
if (source.type === 'cached')
|
|
87
|
+
fail('INVALID_SOURCE', 'A cached source is now embedded-file with a url');
|
|
88
|
+
if (source.type === 'embedded')
|
|
89
|
+
fail('INVALID_SOURCE', 'An embedded source is now embedded-file or embedded-data');
|
|
67
90
|
fail('INVALID_SOURCE', 'Unsupported source type');
|
|
68
91
|
}
|
|
69
92
|
/** Validate and snapshot untrusted data; execution is only a request. */
|
|
70
93
|
export function validateManifest(input, options = {}) {
|
|
71
|
-
keys(input, ['schemaVersion', 'extensions'], 'manifest');
|
|
94
|
+
keys(input, ['schemaVersion', 'uri', 'extensions'], 'manifest');
|
|
72
95
|
if (input.schemaVersion !== 1)
|
|
73
96
|
fail('UNSUPPORTED_SCHEMA', 'Only schemaVersion 1 is supported');
|
|
97
|
+
// The same rule as every other address this family records, so there is one to learn.
|
|
98
|
+
const uri = input.uri === undefined ? undefined : address(input.uri, 'INVALID_PROJECT_URI', 'A project URI');
|
|
74
99
|
const maxExtensions = options.maxExtensions ?? DEFAULTS.maxExtensions;
|
|
75
100
|
if (!Array.isArray(input.extensions) || input.extensions.length > maxExtensions)
|
|
76
101
|
fail('INVALID_MANIFEST', 'Invalid extension list');
|
|
@@ -117,7 +142,8 @@ export function validateManifest(input, options = {}) {
|
|
|
117
142
|
ordered.push(spec);
|
|
118
143
|
};
|
|
119
144
|
extensions.forEach(visit);
|
|
120
|
-
return Object.freeze({ schemaVersion: 1,
|
|
145
|
+
return Object.freeze({ schemaVersion: 1, ...(uri === undefined ? {} : { uri }),
|
|
146
|
+
extensions: Object.freeze(ordered) });
|
|
121
147
|
}
|
|
122
148
|
export function discoverManifest(project, options) {
|
|
123
149
|
if (!object(project))
|
|
@@ -320,10 +346,17 @@ export function createExtensionLoader(ports, options = {}) {
|
|
|
320
346
|
ports.diagnostics?.(Object.freeze({ ...event }));
|
|
321
347
|
}
|
|
322
348
|
catch { /* Diagnostics must not alter authorization. */ } };
|
|
323
|
-
async function resolve(spec, signal, budget) {
|
|
349
|
+
async function resolve(spec, signal, budget, declineCaching) {
|
|
324
350
|
let bytes;
|
|
325
351
|
let cached = false;
|
|
326
|
-
|
|
352
|
+
// The manifest is frozen, so the narrowed source stays valid inside the port callbacks.
|
|
353
|
+
const source = spec.source;
|
|
354
|
+
// One form permits a copy, and only the person can come in under that. The author's
|
|
355
|
+
// `remote-non-cacheable` is a prohibition nothing here can undo, and embedded bytes
|
|
356
|
+
// already travel with the project, so a second copy would buy nothing. The decision
|
|
357
|
+
// holds in both directions: not read for this extension, and not written either.
|
|
358
|
+
const mayCache = source.type === 'remote-cacheable' && !declineCaching?.has(spec.id);
|
|
359
|
+
if (mayCache && ports.cache) {
|
|
327
360
|
const entry = await guarded(() => ports.cache.get(spec.sha256, { signal }), signal);
|
|
328
361
|
if (entry !== undefined && entry !== null) {
|
|
329
362
|
const candidate = snapshotBytes(entry, limits.maxBytes, spec.id);
|
|
@@ -334,26 +367,32 @@ export function createExtensionLoader(ports, options = {}) {
|
|
|
334
367
|
}
|
|
335
368
|
}
|
|
336
369
|
if (!bytes) {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
370
|
+
if (source.type === 'embedded-file' || source.type === 'embedded-data') {
|
|
371
|
+
const reader = source.type === 'embedded-file' ?
|
|
372
|
+
() => ports.embeddedReader.read(source.path, { signal, maxBytes: limits.maxBytes }) :
|
|
373
|
+
() => ports.embeddedReader.readData(spec.id, { signal, maxBytes: limits.maxBytes });
|
|
374
|
+
const available = source.type === 'embedded-file' ?
|
|
375
|
+
typeof ports.embeddedReader?.read === 'function' :
|
|
376
|
+
typeof ports.embeddedReader?.readData === 'function';
|
|
377
|
+
if (!available)
|
|
378
|
+
fail('INVALID_PORT', `Reader for ${source.type} is unavailable`, spec.id);
|
|
343
379
|
try {
|
|
344
|
-
bytes = snapshotBytes(await guarded(
|
|
380
|
+
bytes = snapshotBytes(await guarded(reader, signal), limits.maxBytes, spec.id);
|
|
345
381
|
}
|
|
346
382
|
catch (cause) {
|
|
347
|
-
|
|
383
|
+
// Falling back is only possible where the project recorded where the bytes
|
|
384
|
+
// came from; without that, a missing retained copy is the end of it.
|
|
385
|
+
if (source.url === undefined || signal.aborted)
|
|
348
386
|
throw cause;
|
|
349
387
|
report({ type: 'retained-copy-unavailable', id: spec.id });
|
|
350
388
|
}
|
|
351
389
|
}
|
|
352
|
-
|
|
390
|
+
const url = source.url;
|
|
391
|
+
if (!bytes && url !== undefined) {
|
|
353
392
|
if (typeof ports.fetcher?.fetch !== 'function')
|
|
354
393
|
fail('INVALID_PORT', 'Remote fetch port is unavailable', spec.id);
|
|
355
|
-
const response = await guarded(() => ports.fetcher.fetch(
|
|
356
|
-
if (!response || !Array.isArray(response.redirects) || response.redirects.length || response.finalURL !==
|
|
394
|
+
const response = await guarded(() => ports.fetcher.fetch(url, { signal, maxBytes: limits.maxBytes }), signal);
|
|
395
|
+
if (!response || !Array.isArray(response.redirects) || response.redirects.length || response.finalURL !== url)
|
|
357
396
|
fail('REDIRECT_DENIED', 'Unproven or redirected remote source', spec.id);
|
|
358
397
|
bytes = snapshotBytes(response.bytes, limits.maxBytes, spec.id);
|
|
359
398
|
}
|
|
@@ -373,13 +412,13 @@ export function createExtensionLoader(ports, options = {}) {
|
|
|
373
412
|
catch (cause) {
|
|
374
413
|
throw new LoaderError('INVALID_ENCODING', 'Extension must contain valid UTF-8', spec.id, cause);
|
|
375
414
|
}
|
|
376
|
-
if (!cached && ports.cache)
|
|
415
|
+
if (!cached && mayCache && ports.cache)
|
|
377
416
|
await guarded(() => ports.cache.put(spec.sha256, new Uint8Array(bytes), { signal }), signal);
|
|
378
417
|
const result = Object.freeze({ ...spec, code, byteLength: bytes.byteLength, actualSHA256: actual, cached });
|
|
379
418
|
report({ type: 'verified', id: spec.id, sha256: actual, cached });
|
|
380
419
|
return result;
|
|
381
420
|
}
|
|
382
|
-
async function load(input, { signal: externalSignal } = {}) {
|
|
421
|
+
async function load(input, { signal: externalSignal, declineCaching } = {}) {
|
|
383
422
|
if (state !== 'idle')
|
|
384
423
|
fail('BUSY', 'Unload the previous project before another load');
|
|
385
424
|
const manifest = validateManifest(input, limits);
|
|
@@ -397,7 +436,7 @@ export function createExtensionLoader(ports, options = {}) {
|
|
|
397
436
|
try {
|
|
398
437
|
const budget = { bytes: 0 };
|
|
399
438
|
for (const spec of manifest.extensions) {
|
|
400
|
-
const code = await resolve(spec, signal, budget);
|
|
439
|
+
const code = await resolve(spec, signal, budget, declineCaching);
|
|
401
440
|
const decision = await guarded(() => ports.trustProvider.authorize(code, { signal }), signal);
|
|
402
441
|
if (decision?.allowed !== true || decision.scope !== 'digest' || decision.sha256 !== code.sha256)
|
|
403
442
|
fail('TRUST_DENIED', 'Host did not approve this exact extension digest', spec.id);
|
|
@@ -1,6 +1,44 @@
|
|
|
1
|
+
/** Which projects an entry is kept for, so one project's share can be listed and released. */
|
|
2
|
+
export interface CacheUsage {
|
|
3
|
+
/** The project URI an entry is filed under, or SHARED_BUCKET. */
|
|
4
|
+
readonly bucket: string;
|
|
5
|
+
readonly key: string;
|
|
6
|
+
readonly bytes: number;
|
|
7
|
+
readonly originalBytes: number;
|
|
8
|
+
readonly lastAccess: number;
|
|
9
|
+
}
|
|
10
|
+
export interface CacheBucket {
|
|
11
|
+
readonly bucket: string;
|
|
12
|
+
readonly entries: number;
|
|
13
|
+
readonly bytes: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The bucket for bytes that belong to no one project.
|
|
17
|
+
*
|
|
18
|
+
* A catalog extension is pinned to a version and shared by many projects, so filing it under
|
|
19
|
+
* whichever project happened to fetch it first would make that project look responsible for
|
|
20
|
+
* storage every other one reuses, and releasing that project would evict bytes the rest still
|
|
21
|
+
* want. It is not a URI, so no project can name it.
|
|
22
|
+
*/
|
|
23
|
+
export declare const SHARED_BUCKET = "shared";
|
|
1
24
|
export interface IndexedDBContentCacheOptions {
|
|
2
25
|
indexedDB?: IDBFactory;
|
|
3
26
|
databaseName?: string;
|
|
27
|
+
/**
|
|
28
|
+
* The URI of the project being opened, which entries fetched for it are filed under.
|
|
29
|
+
*
|
|
30
|
+
* Without one, everything is filed as shared: a host that cannot say which project it is
|
|
31
|
+
* opening cannot have per-project accounting, and guessing a name would be worse than
|
|
32
|
+
* admitting there is none.
|
|
33
|
+
*/
|
|
34
|
+
appURI?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Digests that belong to no one project, read at the time of each write.
|
|
37
|
+
*
|
|
38
|
+
* The host knows which extensions came from the catalog; this module cannot. Passing a set
|
|
39
|
+
* the host keeps updating is enough, since it is consulted per write.
|
|
40
|
+
*/
|
|
41
|
+
sharedDigests?: ReadonlySet<string>;
|
|
4
42
|
/** Compressed bytes quota (default 32 MiB). */
|
|
5
43
|
maxBytes?: number;
|
|
6
44
|
/** Maximum decompressed JS size (default 16 MiB, the largest default extension). */
|
|
@@ -20,9 +58,29 @@ export type IndexedDBContentCache = Readonly<{
|
|
|
20
58
|
entries: number;
|
|
21
59
|
bytes: number;
|
|
22
60
|
}>;
|
|
61
|
+
/** Every bucket that holds something, with what it accounts for. */
|
|
62
|
+
listBuckets(): Promise<CacheBucket[]>;
|
|
63
|
+
/** What one bucket holds. SHARED_BUCKET lists what belongs to no one project. */
|
|
64
|
+
listBucket(bucket: string): Promise<CacheUsage[]>;
|
|
65
|
+
/**
|
|
66
|
+
* Release one bucket, and with it any bytes nothing else still refers to.
|
|
67
|
+
*
|
|
68
|
+
* Bytes shared with another bucket stay: they are one copy, referred to twice, and the other
|
|
69
|
+
* project did not ask to lose them.
|
|
70
|
+
* @returns {Promise<{released: number; freed: number}>} Entries dropped and bytes reclaimed.
|
|
71
|
+
*/
|
|
72
|
+
deleteBucket(bucket: string): Promise<{
|
|
73
|
+
released: number;
|
|
74
|
+
freed: number;
|
|
75
|
+
}>;
|
|
76
|
+
/** Release named digests from one bucket, with the same rule. */
|
|
77
|
+
deleteFromBucket(bucket: string, keys: readonly string[]): Promise<{
|
|
78
|
+
released: number;
|
|
79
|
+
freed: number;
|
|
80
|
+
}>;
|
|
23
81
|
close(): Promise<void>;
|
|
24
82
|
}>;
|
|
25
83
|
/** Persistent, content-addressed cache. The Loader independently verifies SHA-256 on every hit.
|
|
26
84
|
* Storage is untrusted: this adapter is not an authorization or integrity boundary.
|
|
27
85
|
*/
|
|
28
|
-
export declare function createIndexedDBContentCache({ indexedDB, databaseName, maxBytes, maxDecompressedBytes, now }?: IndexedDBContentCacheOptions): IndexedDBContentCache;
|
|
86
|
+
export declare function createIndexedDBContentCache({ indexedDB, databaseName, appURI, sharedDigests, maxBytes, maxDecompressedBytes, now }?: IndexedDBContentCacheOptions): IndexedDBContentCache;
|
package/dist/indexeddb-cache.js
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { gzipBytes, gunzipBytes } from './cache-gzip.js';
|
|
2
2
|
import { DEFAULT_ENTRY_BYTES } from './limits.js';
|
|
3
|
+
/**
|
|
4
|
+
* The bucket for bytes that belong to no one project.
|
|
5
|
+
*
|
|
6
|
+
* A catalog extension is pinned to a version and shared by many projects, so filing it under
|
|
7
|
+
* whichever project happened to fetch it first would make that project look responsible for
|
|
8
|
+
* storage every other one reuses, and releasing that project would evict bytes the rest still
|
|
9
|
+
* want. It is not a URI, so no project can name it.
|
|
10
|
+
*/
|
|
11
|
+
export const SHARED_BUCKET = 'shared';
|
|
3
12
|
/** Persistent, content-addressed cache. The Loader independently verifies SHA-256 on every hit.
|
|
4
13
|
* Storage is untrusted: this adapter is not an authorization or integrity boundary.
|
|
5
14
|
*/
|
|
6
|
-
export function createIndexedDBContentCache({ indexedDB = globalThis.indexedDB, databaseName = 'turbowarp-plus-extension-cache', maxBytes = 32 * 1024 * 1024, maxDecompressedBytes = DEFAULT_ENTRY_BYTES, now = Date.now } = {}) {
|
|
15
|
+
export function createIndexedDBContentCache({ indexedDB = globalThis.indexedDB, databaseName = 'turbowarp-plus-extension-cache', appURI, sharedDigests, maxBytes = 32 * 1024 * 1024, maxDecompressedBytes = DEFAULT_ENTRY_BYTES, now = Date.now } = {}) {
|
|
7
16
|
if (!indexedDB || typeof indexedDB.open !== 'function')
|
|
8
17
|
throw new Error('IndexedDB unavailable');
|
|
9
18
|
if (typeof databaseName !== 'string' || !databaseName || !Number.isSafeInteger(maxBytes) || maxBytes < 1 ||
|
|
@@ -14,11 +23,22 @@ export function createIndexedDBContentCache({ indexedDB = globalThis.indexedDB,
|
|
|
14
23
|
function open() {
|
|
15
24
|
if (!dbPromise) {
|
|
16
25
|
dbPromise = new Promise((resolve, reject) => {
|
|
17
|
-
const request = indexedDB.open(databaseName,
|
|
26
|
+
const request = indexedDB.open(databaseName, 2);
|
|
18
27
|
request.onupgradeneeded = () => {
|
|
19
28
|
const db = request.result;
|
|
20
29
|
if (!db.objectStoreNames.contains('content'))
|
|
21
30
|
db.createObjectStore('content', { keyPath: 'key' });
|
|
31
|
+
if (!db.objectStoreNames.contains('usage')) {
|
|
32
|
+
const usage = db.createObjectStore('usage', { keyPath: ['bucket', 'key'] });
|
|
33
|
+
// Finding every claim on one digest is what decides whether releasing a
|
|
34
|
+
// project may take its bytes with it.
|
|
35
|
+
usage.createIndex('key', 'key');
|
|
36
|
+
usage.createIndex('bucket', 'bucket');
|
|
37
|
+
// Entries written before there were buckets have no owner, and inventing
|
|
38
|
+
// one would make a project answer for storage it never asked for. This is
|
|
39
|
+
// a cache: dropping it costs a fetch, which is cheaper than a wrong name.
|
|
40
|
+
request.transaction.objectStore('content').clear();
|
|
41
|
+
}
|
|
22
42
|
};
|
|
23
43
|
request.onsuccess = () => {
|
|
24
44
|
const db = request.result;
|
|
@@ -31,15 +51,15 @@ export function createIndexedDBContentCache({ indexedDB = globalThis.indexedDB,
|
|
|
31
51
|
}
|
|
32
52
|
return dbPromise;
|
|
33
53
|
}
|
|
34
|
-
function transaction(db, mode, work) {
|
|
54
|
+
function transaction(db, mode, work, stores = 'content') {
|
|
35
55
|
return new Promise((resolve, reject) => {
|
|
36
|
-
const tx = db.transaction(
|
|
56
|
+
const tx = db.transaction(stores, mode);
|
|
37
57
|
let value;
|
|
38
58
|
tx.oncomplete = () => resolve(value);
|
|
39
59
|
tx.onerror = () => reject(tx.error || new Error('IndexedDB transaction failed'));
|
|
40
60
|
tx.onabort = () => reject(tx.error || new Error('IndexedDB transaction aborted'));
|
|
41
61
|
try {
|
|
42
|
-
work(tx.objectStore('
|
|
62
|
+
work(tx.objectStore(typeof stores === 'string' ? stores : stores[0]), result => { value = result; }, tx);
|
|
43
63
|
}
|
|
44
64
|
catch (error) {
|
|
45
65
|
try {
|
|
@@ -56,7 +76,14 @@ export function createIndexedDBContentCache({ indexedDB = globalThis.indexedDB,
|
|
|
56
76
|
entry.bytes instanceof ArrayBuffer && Number.isSafeInteger(entry.originalBytes) &&
|
|
57
77
|
entry.originalBytes >= 0 && entry.originalBytes <= maxDecompressedBytes &&
|
|
58
78
|
entry.bytes.byteLength <= maxBytes;
|
|
59
|
-
|
|
79
|
+
const dropClaims = (usage, key) => {
|
|
80
|
+
const claims = usage.index('key').getAllKeys(key);
|
|
81
|
+
claims.onsuccess = () => {
|
|
82
|
+
for (const claim of claims.result)
|
|
83
|
+
usage.delete(claim);
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
function sweep(store, usage, protectedKey = null) {
|
|
60
87
|
const request = store.getAll();
|
|
61
88
|
request.onsuccess = () => {
|
|
62
89
|
let total = 0;
|
|
@@ -64,6 +91,7 @@ export function createIndexedDBContentCache({ indexedDB = globalThis.indexedDB,
|
|
|
64
91
|
for (const record of request.result) {
|
|
65
92
|
if (!validRecord(record)) {
|
|
66
93
|
store.delete(record.key);
|
|
94
|
+
dropClaims(usage, record.key);
|
|
67
95
|
continue;
|
|
68
96
|
}
|
|
69
97
|
total += record.bytes.byteLength;
|
|
@@ -76,10 +104,51 @@ export function createIndexedDBContentCache({ indexedDB = globalThis.indexedDB,
|
|
|
76
104
|
if (record.key === protectedKey)
|
|
77
105
|
continue;
|
|
78
106
|
store.delete(record.key);
|
|
107
|
+
dropClaims(usage, record.key);
|
|
79
108
|
total -= record.bytes.byteLength;
|
|
80
109
|
}
|
|
81
110
|
};
|
|
82
111
|
}
|
|
112
|
+
async function release(bucket, keys) {
|
|
113
|
+
if (typeof bucket !== 'string' || !bucket)
|
|
114
|
+
throw new TypeError('A bucket name is required');
|
|
115
|
+
if (keys !== null && (!Array.isArray(keys) || keys.some(key => !keyValid(key)))) {
|
|
116
|
+
throw new TypeError('Cache keys must be SHA-256 digests');
|
|
117
|
+
}
|
|
118
|
+
const wanted = keys === null ? null : new Set(keys);
|
|
119
|
+
const db = await open();
|
|
120
|
+
return transaction(db, 'readwrite', (store, set, tx) => {
|
|
121
|
+
const usage = tx.objectStore('usage');
|
|
122
|
+
// The same object the transaction resolves with, filled in as the nested requests
|
|
123
|
+
// below run: every one of them completes before the transaction does.
|
|
124
|
+
const result = { released: 0, freed: 0 };
|
|
125
|
+
set(result);
|
|
126
|
+
const mine = usage.index('bucket').getAll(bucket);
|
|
127
|
+
mine.onsuccess = () => {
|
|
128
|
+
for (const claim of mine.result) {
|
|
129
|
+
if (wanted !== null && !wanted.has(claim.key))
|
|
130
|
+
continue;
|
|
131
|
+
usage.delete([bucket, claim.key]);
|
|
132
|
+
result.released++;
|
|
133
|
+
// One copy, referred to more than once. Another project did not ask to lose
|
|
134
|
+
// it, so the bytes only follow the last claim on them.
|
|
135
|
+
const others = usage.index('key').getAll(claim.key);
|
|
136
|
+
others.onsuccess = () => {
|
|
137
|
+
if (others.result.some(other => other.bucket !== bucket))
|
|
138
|
+
return;
|
|
139
|
+
const record = store.get(claim.key);
|
|
140
|
+
record.onsuccess = () => {
|
|
141
|
+
const entry = record.result;
|
|
142
|
+
if (!entry)
|
|
143
|
+
return;
|
|
144
|
+
result.freed += entry.bytes?.byteLength || 0;
|
|
145
|
+
store.delete(claim.key);
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}, ['content', 'usage']);
|
|
151
|
+
}
|
|
83
152
|
return Object.freeze({
|
|
84
153
|
async get(key, { signal } = {}) {
|
|
85
154
|
if (!keyValid(key))
|
|
@@ -128,20 +197,28 @@ export function createIndexedDBContentCache({ indexedDB = globalThis.indexedDB,
|
|
|
128
197
|
if (signal?.aborted)
|
|
129
198
|
throw signal.reason;
|
|
130
199
|
const db = await open();
|
|
131
|
-
|
|
200
|
+
const bucket = appURI === undefined || sharedDigests?.has(key) ? SHARED_BUCKET : appURI;
|
|
201
|
+
return transaction(db, 'readwrite', (store, _set, tx) => {
|
|
202
|
+
const usage = tx.objectStore('usage');
|
|
132
203
|
const time = now();
|
|
133
204
|
if (compressed.byteLength <= maxBytes) {
|
|
134
205
|
store.put({ key, format: 'gzip-v1', bytes: compressed.buffer, originalBytes,
|
|
135
206
|
lastAccess: time });
|
|
207
|
+
usage.put({ bucket, key, lastAccess: time });
|
|
136
208
|
}
|
|
137
|
-
else
|
|
209
|
+
else {
|
|
138
210
|
store.delete(key);
|
|
139
|
-
|
|
140
|
-
|
|
211
|
+
usage.delete([bucket, key]);
|
|
212
|
+
}
|
|
213
|
+
sweep(store, usage, compressed.byteLength <= maxBytes ? key : null);
|
|
214
|
+
}, ['content', 'usage']);
|
|
141
215
|
},
|
|
142
216
|
async clear() {
|
|
143
217
|
const db = await open();
|
|
144
|
-
return transaction(db, 'readwrite', store => {
|
|
218
|
+
return transaction(db, 'readwrite', (store, _set, tx) => {
|
|
219
|
+
store.clear();
|
|
220
|
+
tx.objectStore('usage').clear();
|
|
221
|
+
}, ['content', 'usage']);
|
|
145
222
|
},
|
|
146
223
|
async getSize() {
|
|
147
224
|
const db = await open();
|
|
@@ -152,6 +229,54 @@ export function createIndexedDBContentCache({ indexedDB = globalThis.indexedDB,
|
|
|
152
229
|
}), { entries: 0, bytes: 0 }));
|
|
153
230
|
});
|
|
154
231
|
},
|
|
232
|
+
async listBuckets() {
|
|
233
|
+
const db = await open();
|
|
234
|
+
return transaction(db, 'readonly', (store, set, tx) => {
|
|
235
|
+
const claims = tx.objectStore('usage').getAll();
|
|
236
|
+
const records = store.getAll();
|
|
237
|
+
claims.onsuccess = () => {
|
|
238
|
+
records.onsuccess = () => {
|
|
239
|
+
const sizes = new Map(records.result
|
|
240
|
+
.map(record => [record.key, record.bytes?.byteLength || 0]));
|
|
241
|
+
const totals = new Map();
|
|
242
|
+
for (const claim of claims.result) {
|
|
243
|
+
const total = totals.get(claim.bucket) || { entries: 0, bytes: 0 };
|
|
244
|
+
total.entries++;
|
|
245
|
+
total.bytes += sizes.get(claim.key) || 0;
|
|
246
|
+
totals.set(claim.bucket, total);
|
|
247
|
+
}
|
|
248
|
+
set([...totals].map(([bucket, total]) => Object.freeze({ bucket, ...total })));
|
|
249
|
+
};
|
|
250
|
+
};
|
|
251
|
+
}, ['content', 'usage']);
|
|
252
|
+
},
|
|
253
|
+
async listBucket(bucket) {
|
|
254
|
+
if (typeof bucket !== 'string' || !bucket)
|
|
255
|
+
throw new TypeError('A bucket name is required');
|
|
256
|
+
const db = await open();
|
|
257
|
+
return transaction(db, 'readonly', (store, set, tx) => {
|
|
258
|
+
const claims = tx.objectStore('usage').index('bucket').getAll(bucket);
|
|
259
|
+
const records = store.getAll();
|
|
260
|
+
claims.onsuccess = () => {
|
|
261
|
+
records.onsuccess = () => {
|
|
262
|
+
const byKey = new Map(records.result.map(record => [record.key, record]));
|
|
263
|
+
set(claims.result.flatMap(claim => {
|
|
264
|
+
const record = byKey.get(claim.key);
|
|
265
|
+
if (!record)
|
|
266
|
+
return [];
|
|
267
|
+
return [Object.freeze({ bucket, key: claim.key, bytes: record.bytes?.byteLength || 0,
|
|
268
|
+
originalBytes: record.originalBytes, lastAccess: claim.lastAccess })];
|
|
269
|
+
}));
|
|
270
|
+
};
|
|
271
|
+
};
|
|
272
|
+
}, ['content', 'usage']);
|
|
273
|
+
},
|
|
274
|
+
deleteBucket(bucket) {
|
|
275
|
+
return release(bucket, null);
|
|
276
|
+
},
|
|
277
|
+
deleteFromBucket(bucket, keys) {
|
|
278
|
+
return release(bucket, keys);
|
|
279
|
+
},
|
|
155
280
|
async close() {
|
|
156
281
|
if (dbPromise) {
|
|
157
282
|
const db = await dbPromise;
|
package/dist/packager.js
CHANGED
|
@@ -57,8 +57,12 @@ export async function convertProjectSources(input, ports, { zip: Zip, targets =
|
|
|
57
57
|
if (await digest(bytes) !== record.sha256)
|
|
58
58
|
fail('CONVERSION_INTEGRITY', 'UTF-8 conversion changed verified source bytes');
|
|
59
59
|
own(artifacts, record.id, new Uint8Array(bytes));
|
|
60
|
-
//
|
|
61
|
-
|
|
60
|
+
// A `data:` carrier lives in the project document, and packaging refuses that document's
|
|
61
|
+
// legacy URLs outright, so there would be nowhere to put the bytes.
|
|
62
|
+
if (validated.source.type === 'embedded-data')
|
|
63
|
+
fail('UNSUPPORTED_SOURCE', 'Packaging cannot write a data: carrier');
|
|
64
|
+
// Only a file carrier has an archive entry to write.
|
|
65
|
+
if (validated.source.type === 'embedded-file') {
|
|
62
66
|
const path = validated.source.path;
|
|
63
67
|
if (outputPaths.has(path))
|
|
64
68
|
fail('DUPLICATE_EMBEDDED_PATH', 'Each extension needs a distinct embedded output path');
|
|
@@ -108,7 +112,7 @@ export async function prepareOfflinePackage(input, ports, options = {}) {
|
|
|
108
112
|
resourceDependencies[spec.id].length) {
|
|
109
113
|
fail('UNBUNDLED_DEPENDENCY', `Declare an empty external resource dependency list for ${spec.id}; external resources are not yet bundled`);
|
|
110
114
|
}
|
|
111
|
-
own(targets, spec.id, { type: 'embedded', path: `extensions/${spec.id}-${spec.sha256}.js` });
|
|
115
|
+
own(targets, spec.id, { type: 'embedded-file', path: `extensions/${spec.id}-${spec.sha256}.js` });
|
|
112
116
|
}
|
|
113
117
|
// Options pass through unchanged; convertProjectSources reports a missing ZIP implementation.
|
|
114
118
|
const result = await convertProjectSources(input, ports, { ...options, targets });
|
|
@@ -142,7 +146,7 @@ export async function migrateLegacyDataURL({ id, url, sha256, metadata }, ports,
|
|
|
142
146
|
throw new LoaderError('INVALID_ENCODING', 'Invalid legacy Data URL encoding', id, cause);
|
|
143
147
|
}
|
|
144
148
|
const path = `extensions/${id}.js`;
|
|
145
|
-
const manifest = validateManifest({ schemaVersion: 1, extensions: [{ id, source: { type: 'embedded', path }, execution: 'unsandboxed', sha256 }] });
|
|
149
|
+
const manifest = validateManifest({ schemaVersion: 1, extensions: [{ id, source: { type: 'embedded-file', path }, execution: 'unsandboxed', sha256 }] });
|
|
146
150
|
const [record] = await collect(manifest, { ...ports, embeddedReader: { async read() { return bytes; } } }, options.loaderOptions, options.signal);
|
|
147
151
|
return Object.freeze({ spec: manifest.extensions[0], bytes: encoder.encode(record.code), metadata: metadataFor({ [id]: metadata }, id) });
|
|
148
152
|
}
|
|
@@ -4,7 +4,7 @@ export type ExtensionAdditionRequest = {
|
|
|
4
4
|
id: string;
|
|
5
5
|
execution?: ExecutionMode;
|
|
6
6
|
source: {
|
|
7
|
-
type: 'embedded';
|
|
7
|
+
type: 'embedded-file';
|
|
8
8
|
path?: string;
|
|
9
9
|
};
|
|
10
10
|
bytes: Uint8Array | Blob;
|
|
@@ -12,7 +12,7 @@ export type ExtensionAdditionRequest = {
|
|
|
12
12
|
id: string;
|
|
13
13
|
execution?: ExecutionMode;
|
|
14
14
|
source: {
|
|
15
|
-
type: 'remote';
|
|
15
|
+
type: 'remote-non-cacheable' | 'remote-cacheable';
|
|
16
16
|
url: string;
|
|
17
17
|
};
|
|
18
18
|
bytes?: never;
|
|
@@ -31,3 +31,36 @@ export interface PreparedExtensionAddition {
|
|
|
31
31
|
}
|
|
32
32
|
/** Computes SHA-256 and reviews the candidate without executing code or changing the input. */
|
|
33
33
|
export declare function prepareExtensionAddition(input: unknown, request: ExtensionAdditionRequest, ports: AuthoringPorts, options: ExtensionAdditionOptions): Promise<PreparedExtensionAddition>;
|
|
34
|
+
/** Where an unchosen address is put, relative to whatever origin assigns it. */
|
|
35
|
+
export declare const PROJECT_URI_PREFIX = "turbowarp-plus-projects";
|
|
36
|
+
/**
|
|
37
|
+
* A fresh identifier for one project: a UUID version 7.
|
|
38
|
+
*
|
|
39
|
+
* Version 7 and not 4 because the first 48 bits are the millisecond clock, so the identifier
|
|
40
|
+
* itself sorts by when the project was first saved. That costs nothing over a random UUID and
|
|
41
|
+
* leaves the ordering available to anything that wants it.
|
|
42
|
+
* @returns {string} The UUID, in the canonical lowercase form.
|
|
43
|
+
*/
|
|
44
|
+
export declare function createProjectUUID(): string;
|
|
45
|
+
/**
|
|
46
|
+
* The address a project is given when its author has not chosen one.
|
|
47
|
+
*
|
|
48
|
+
* Every project needs an address from its first save, because that address is what identifies it:
|
|
49
|
+
* the browser's caches and the opener's recorded decisions are filed under it. Nothing is served
|
|
50
|
+
* there until the author actually publishes, and that is fine — the point is that the name does
|
|
51
|
+
* not have to change if they ever do.
|
|
52
|
+
*
|
|
53
|
+
* So this address is deliberately opaque. A readable one would be derived from the title and
|
|
54
|
+
* would change when the title changed, filing one work under a second name. Readability belongs
|
|
55
|
+
* to the deliberate act of publishing, where the author names the address themselves; a list that
|
|
56
|
+
* has to be read by a person shows the project's title beside this.
|
|
57
|
+
*
|
|
58
|
+
* The two directory levels divide the projects so that no single directory grows without bound,
|
|
59
|
+
* and they are taken from the **end** of the UUID. A version 7 UUID begins with the top of a
|
|
60
|
+
* millisecond clock, which barely moves, so sharding on its first characters would put every
|
|
61
|
+
* project ever created in one directory. The last characters are the random half.
|
|
62
|
+
* @param {string} origin Where the editor that assigns this is served from; it claims no other host.
|
|
63
|
+
* @param {string} uuid A version 7 UUID, from createProjectUUID.
|
|
64
|
+
* @returns {string} The absolute HTTPS address.
|
|
65
|
+
*/
|
|
66
|
+
export declare function defaultProjectURI(origin: string, uuid: string): string;
|