@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/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
- function remoteURL(value) {
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('INVALID_SOURCE', 'Remote source must be an absolute HTTPS URL');
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('INVALID_SOURCE', 'Remote source requires HTTPS without credentials or fragment');
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
- if (source.type === 'remote') {
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: 'remote', url: remoteURL(string(source.url, 'URL', 4096)) });
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
- return Object.freeze({ type: 'embedded', path: embeddedPath(source.path) });
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 === 'cached') {
63
- keys(source, ['type', 'url', 'path'], 'source');
64
- // The same HTTPS and path policies as the two single-location forms it combines.
65
- return Object.freeze({ type: 'cached', url: remoteURL(string(source.url, 'URL', 4096)), path: embeddedPath(source.path) });
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, extensions: Object.freeze(ordered) });
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
- if (ports.cache) {
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
- // The manifest is frozen, so the narrowed source stays valid inside the port callbacks.
338
- const source = spec.source;
339
- if (source.type !== 'remote') {
340
- if (typeof ports.embeddedReader?.read !== 'function')
341
- fail('INVALID_PORT', 'Embedded reader is unavailable', spec.id);
342
- // A cached source may have lost its retained copy; its URL remains the canonical one.
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(() => ports.embeddedReader.read(source.path, { signal, maxBytes: limits.maxBytes }), signal), limits.maxBytes, spec.id);
380
+ bytes = snapshotBytes(await guarded(reader, signal), limits.maxBytes, spec.id);
345
381
  }
346
382
  catch (cause) {
347
- if (source.type === 'embedded' || signal.aborted)
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
- if (!bytes && source.type !== 'embedded') {
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(source.url, { signal, maxBytes: limits.maxBytes }), signal);
356
- if (!response || !Array.isArray(response.redirects) || response.redirects.length || response.finalURL !== source.url)
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;
@@ -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, 1);
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('content', mode);
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('content'), result => { value = result; }, tx);
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
- function sweep(store, protectedKey = null) {
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
- return transaction(db, 'readwrite', store => {
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
- sweep(store, compressed.byteLength <= maxBytes ? key : null);
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 => { store.clear(); });
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
- // Embedded and cached both keep a copy in the archive, so both need their entry written.
61
- if (validated.source.type !== 'remote') {
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;