@kubohiroya/turbowarp-extension-loader 0.3.1 → 0.4.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 CHANGED
@@ -122,6 +122,56 @@ const loader = createExtensionLoader({...hostPorts, cache});
122
122
  - 新たな Remote バイト列を取得し保存した場合も、権限承認は毎回別途必要です。コード内から追加取得される依存リソースや外部サービスはこのキャッシュの対象外です。
123
123
  - Web/Editor はこのポートを初期化して注入するだけとし、`scratch-gui` と `turbowarp-plus-editor` に別のキャッシュロジックを実装しないでください。
124
124
 
125
+ ## 機能拡張のバイト列がどこから来るか
126
+
127
+ このファミリーは、機能拡張に限らず何を保持するにも3つの方式で整理しています。
128
+
129
+ | 方式 | ブラウザー(IndexedDB) | 作品(SB3) |
130
+ | --- | --- | --- |
131
+ | `remote` | 残さない | 参照だけ |
132
+ | `cached` | 検証したバイト列を残して使い回す | 参照だけ |
133
+ | `embedded` | 使わない | 作品が中身を持つ |
134
+
135
+ `embedded` には2通りあります。**`embedded-file`**(アーカイブ内に見えるファイル)と、
136
+ **`embedded-data`**(作品の文書に `data:` で値を埋め込む形)です。
137
+
138
+ 読み込み時に選べるのは `remote` と `cached` だけです。`embedded` は作者の決定で、既に
139
+ Manifest に入っているので尋ねることがありません。残り2つは開く人が自分のブラウザーについて
140
+ 決めることなので、作品にも Manifest にも載せず、操作ごとに渡します。
141
+
142
+ ```js
143
+ await loader.load(manifest, {
144
+ // 指定の無いものは cached。この選択肢が無かった頃と同じ挙動になります。
145
+ caching: new Map([['someextension', 'remote']])
146
+ });
147
+ ```
148
+
149
+ `remote` は両方向に効きます。その拡張についてキャッシュを読まず、書き込みもしません。
150
+ 何も残したくないと言った人には、何も残りません。
151
+
152
+ ### 作品が保持の仕方をどう書くか
153
+
154
+ `source.type` は表のとおり2値です。作品が中身を持つ `embedded` と、参照だけを持つ `remote`
155
+ です。`embedded` は取得元 URL も記録できます。
156
+
157
+ ```json
158
+ {"type": "embedded", "path": "extensions/thing-<sha256>.js", "url": "https://example.test/thing.js"}
159
+ ```
160
+
161
+ この URL があるから、控えが欠けたときに取得へ落ちられます。無ければ、欠けた時点で終わりです。
162
+
163
+ 0.4.0 より前に書かれた Manifest は、同じものを `cached` と綴ります。**拒否します。** いまは何に
164
+ あたるかをメッセージで示します。このファミリーは、古い版が書いたものを読み続けることを約束して
165
+ いません。黙って読み替えると、直すべき人から変更が見えなくなります。古い版で保存した作品は、
166
+ 保存し直す必要があります。
167
+
168
+ `prepareCatalogExtensionChanges` の `distribution` は `embedded` か `remote` です。**ブラウザー**の
169
+ どこに控えるかは開く人の決定で、`load()` へ渡します。どちらでも Manifest は取得元を記録します。
170
+ カタログの拡張はどこかから取得したものなので、embedded は分かっている取得元を捨てずに控えの横へ
171
+ 残します。URL は、どちらの形で保存されていても、固定したバイト列を返すか照合します。embedded の
172
+ url が省略可能なのは一般の場合のためで、どこからも取得していないバイト列 — コスチューム、音、
173
+ エディター上で書いた拡張 — には記録すべき取得元がありません。
174
+
125
175
  ## ソースとビルド
126
176
 
127
177
  ソースもテストも TypeScript で、`src/*.ts` と `test/*.test.ts` に置き、他の `@kubohiroya/turbowarp-*` パッケージと同じ構成にしています。`tsconfig.json` が出力なしの型検査、`tsconfig.build.json` がそれを継承して出力、`test/tsconfig.json` がテスト用の継承設定です。`npm run build`(`tsc -p tsconfig.build.json`)が ES2022 の ESM と型宣言を `dist/` に出力し、`exports`・`main`/`module`・webpack 4 用のルートファイル(`packager.js` など)はすべて `dist/` を指します。`dist/` は Git に含めず、Git 依存としてインストールされるときに `prepare` スクリプトがビルドします。npm パッケージにはビルド済みの `dist/` を含めます。webpack 4 では従来どおり `./webpack4` の Babel 設定が `dist/` を変換します。
package/README.md CHANGED
@@ -121,6 +121,59 @@ const loader = createExtensionLoader({...hostPorts, cache});
121
121
  - Fetching and storing new Remote bytes still requires a separate authorization every time. Dependent resources and external services fetched from inside the code are outside the scope of this cache.
122
122
  - The Web and Editor hosts should only initialize and inject this port, and should not implement separate cache logic in `scratch-gui` or `turbowarp-plus-editor`.
123
123
 
124
+ ## Where an extension's bytes come from
125
+
126
+ The family settled on three ways to hold anything, extensions included.
127
+
128
+ | Way | Browser (IndexedDB) | Project (SB3) |
129
+ | --- | --- | --- |
130
+ | `remote` | nothing kept | reference only |
131
+ | `cached` | verified bytes kept and reused | reference only |
132
+ | `embedded` | not used | the project carries the bytes |
133
+
134
+ `embedded` has two forms: **`embedded-file`**, a file inside the archive, and **`embedded-data`**,
135
+ a `data:` value inside the project document.
136
+
137
+ Only `remote` and `cached` are a choice at load time. `embedded` is the author's decision and is
138
+ already in the manifest, so there is nothing left to ask. The other two are the opener's decision
139
+ about their own browser, so they travel with neither the project nor the manifest:
140
+
141
+ ```js
142
+ await loader.load(manifest, {
143
+ // Anything absent is cached, which is what every caller got before this option existed.
144
+ caching: new Map([['someextension', 'remote']])
145
+ });
146
+ ```
147
+
148
+ `remote` holds in both directions: the cache is not read for that extension and not written
149
+ either, so a person who asked to leave nothing behind leaves nothing behind.
150
+
151
+ ### How a project says where it keeps an extension
152
+
153
+ `source.type` has two values, matching the table: `embedded` for a project that carries the
154
+ bytes, `remote` for one that carries only a reference. An embedded source may also record the
155
+ URL the bytes came from:
156
+
157
+ ```json
158
+ {"type": "embedded", "path": "extensions/thing-<sha256>.js", "url": "https://example.test/thing.js"}
159
+ ```
160
+
161
+ That URL is what makes falling back possible when a retained copy is missing. Without it, a
162
+ missing copy is the end of it.
163
+
164
+ A manifest written before 0.4.0 spells that same thing `cached`. **It is refused**, with a
165
+ message saying what it is now. This family does not promise to read what older versions wrote,
166
+ and translating it silently would hide the change from whoever has to make it. A project saved
167
+ by an older version has to be written again.
168
+
169
+ `prepareCatalogExtensionChanges` takes `distribution: 'embedded' | 'remote'`, because where
170
+ bytes are cached **in the browser** is the opener's decision and goes to `load()` instead.
171
+ Either way the manifest records where the bytes came from: a catalog extension was fetched from
172
+ an address, so embedding keeps that address beside the retained copy rather than discarding what
173
+ is already known, and the URL is checked against the pinned bytes whichever form it is stored in.
174
+ A url is optional on an embedded source for the general case, where bytes nobody fetched — a
175
+ costume, a sound, an extension written in the editor — have no address to record.
176
+
124
177
  ## Source and build
125
178
 
126
179
  The source and the tests are TypeScript, in `src/*.ts` and `test/*.test.ts`, laid out the same way as the other `@kubohiroya/turbowarp-*` packages: `tsconfig.json` typechecks without emitting, `tsconfig.build.json` extends it to emit, and `test/tsconfig.json` extends it for the tests. `npm run build` (`tsc -p tsconfig.build.json`) emits ES2022 ESM and type declarations to `dist/`, and `exports`, `main`/`module` and the root files used by webpack 4 (`packager.js` and the rest) all point at `dist/`. `dist/` is not committed to Git; the `prepare` script builds it when the package is installed as a Git dependency, and the npm package ships the built `dist/`. Under webpack 4, the Babel configuration in `./webpack4` transpiles `dist/` as before.
package/dist/index.d.ts CHANGED
@@ -6,16 +6,25 @@
6
6
  * weaker source — the Manifest pins the SHA-256, so a retained copy either is exactly the pinned
7
7
  * code or the load fails, which is why it may be read before the network.
8
8
  */
9
+ /**
10
+ * Where a project keeps an extension.
11
+ *
12
+ * `embedded` is the project carrying the bytes; `remote` is the project carrying only a
13
+ * reference. An embedded source may also record the URL the bytes came from, which is what makes
14
+ * falling back possible when a retained copy is missing — and is the only difference between the
15
+ * two forms this used to spell as separate types.
16
+ *
17
+ * A manifest written before 0.4.0 spells that same embedded source `cached`. It is refused, not
18
+ * translated: this family does not promise to read what older versions wrote, and a refusal
19
+ * names the problem where a silent translation would hide it.
20
+ */
9
21
  export type ExtensionSource = Readonly<{
10
22
  type: 'embedded';
11
23
  path: string;
24
+ url?: string;
12
25
  } | {
13
26
  type: 'remote';
14
27
  url: string;
15
- } | {
16
- type: 'cached';
17
- url: string;
18
- path: string;
19
28
  }>;
20
29
  export type ExecutionMode = 'sandboxed' | 'unsandboxed';
21
30
  export interface ExtensionSpec {
@@ -50,8 +59,33 @@ export interface AuthorizedCode extends ResolvedCode {
50
59
  sha256: string;
51
60
  }>;
52
61
  }
62
+ /**
63
+ * Where one extension's bytes come from while the project runs.
64
+ *
65
+ * The family settled on three ways to hold anything, extensions included. `remote` fetches every
66
+ * time: nothing is kept in the browser and nothing is kept in the project. `cached` keeps
67
+ * verified bytes in the browser and reuses them, so opening again costs no network while the
68
+ * project still holds only a reference. `embedded` is the project carrying the bytes itself, in
69
+ * either of its two forms — `embedded-file`, a file inside the archive, and `embedded-data`, a
70
+ * `data:` value inside the project document.
71
+ *
72
+ * Only the first two are a choice here. `embedded` is the author's decision and is already in
73
+ * the manifest, so there is nothing to ask at load time. These two are the opener's decision,
74
+ * about this browser, so they travel with neither the project nor the manifest and are passed in
75
+ * for each operation.
76
+ *
77
+ * Note that `source.type` has a value also spelled `cached`, for a project that carries bytes
78
+ * and records where they came from. By the vocabulary above, a project that carries bytes is
79
+ * `embedded`, so that name is the one out of step; see the README.
80
+ */
81
+ export type CachePolicy = 'cached' | 'remote';
53
82
  export interface OperationOptions {
54
83
  signal?: AbortSignal;
84
+ /**
85
+ * Per extension ID, where its bytes come from. Anything absent is `cached`, which is what
86
+ * every caller got before this existed.
87
+ */
88
+ caching?: ReadonlyMap<string, CachePolicy>;
55
89
  }
56
90
  export interface ReadOptions {
57
91
  signal: AbortSignal;
package/dist/index.js CHANGED
@@ -56,14 +56,16 @@ function sourceOf(source) {
56
56
  return Object.freeze({ type: 'remote', url: remoteURL(string(source.url, 'URL', 4096)) });
57
57
  }
58
58
  if (source.type === 'embedded') {
59
- keys(source, ['type', 'path'], 'source');
60
- return Object.freeze({ type: 'embedded', path: embeddedPath(source.path) });
61
- }
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) });
59
+ keys(source, ['type', 'path', 'url'], 'source');
60
+ const path = embeddedPath(source.path);
61
+ if (source.url === undefined)
62
+ return Object.freeze({ type: 'embedded', path });
63
+ return Object.freeze({ type: 'embedded', path, url: remoteURL(string(source.url, 'URL', 4096)) });
66
64
  }
65
+ // Name the one spelling that used to mean an embedded source, so a project written by an
66
+ // older version fails with something a reader can act on rather than "unsupported".
67
+ if (source.type === 'cached')
68
+ fail('INVALID_SOURCE', 'A cached source is now an embedded source with a url');
67
69
  fail('INVALID_SOURCE', 'Unsupported source type');
68
70
  }
69
71
  /** Validate and snapshot untrusted data; execution is only a request. */
@@ -320,10 +322,13 @@ export function createExtensionLoader(ports, options = {}) {
320
322
  ports.diagnostics?.(Object.freeze({ ...event }));
321
323
  }
322
324
  catch { /* Diagnostics must not alter authorization. */ } };
323
- async function resolve(spec, signal, budget) {
325
+ async function resolve(spec, signal, budget, caching) {
324
326
  let bytes;
325
327
  let cached = false;
326
- if (ports.cache) {
328
+ // Remote is the person's decision to leave nothing behind, so it holds in both
329
+ // directions: the cache is not read for this extension, and not written either.
330
+ const mayCache = caching?.get(spec.id) !== 'remote';
331
+ if (mayCache && ports.cache) {
327
332
  const entry = await guarded(() => ports.cache.get(spec.sha256, { signal }), signal);
328
333
  if (entry !== undefined && entry !== null) {
329
334
  const candidate = snapshotBytes(entry, limits.maxBytes, spec.id);
@@ -336,24 +341,26 @@ export function createExtensionLoader(ports, options = {}) {
336
341
  if (!bytes) {
337
342
  // The manifest is frozen, so the narrowed source stays valid inside the port callbacks.
338
343
  const source = spec.source;
339
- if (source.type !== 'remote') {
344
+ if (source.type === 'embedded') {
340
345
  if (typeof ports.embeddedReader?.read !== 'function')
341
346
  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.
343
347
  try {
344
348
  bytes = snapshotBytes(await guarded(() => ports.embeddedReader.read(source.path, { signal, maxBytes: limits.maxBytes }), signal), limits.maxBytes, spec.id);
345
349
  }
346
350
  catch (cause) {
347
- if (source.type === 'embedded' || signal.aborted)
351
+ // Falling back is only possible where the project recorded where the bytes
352
+ // came from; without that, a missing retained copy is the end of it.
353
+ if (source.url === undefined || signal.aborted)
348
354
  throw cause;
349
355
  report({ type: 'retained-copy-unavailable', id: spec.id });
350
356
  }
351
357
  }
352
- if (!bytes && source.type !== 'embedded') {
358
+ const url = source.url;
359
+ if (!bytes && url !== undefined) {
353
360
  if (typeof ports.fetcher?.fetch !== 'function')
354
361
  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)
362
+ const response = await guarded(() => ports.fetcher.fetch(url, { signal, maxBytes: limits.maxBytes }), signal);
363
+ if (!response || !Array.isArray(response.redirects) || response.redirects.length || response.finalURL !== url)
357
364
  fail('REDIRECT_DENIED', 'Unproven or redirected remote source', spec.id);
358
365
  bytes = snapshotBytes(response.bytes, limits.maxBytes, spec.id);
359
366
  }
@@ -373,13 +380,13 @@ export function createExtensionLoader(ports, options = {}) {
373
380
  catch (cause) {
374
381
  throw new LoaderError('INVALID_ENCODING', 'Extension must contain valid UTF-8', spec.id, cause);
375
382
  }
376
- if (!cached && ports.cache)
383
+ if (!cached && mayCache && ports.cache)
377
384
  await guarded(() => ports.cache.put(spec.sha256, new Uint8Array(bytes), { signal }), signal);
378
385
  const result = Object.freeze({ ...spec, code, byteLength: bytes.byteLength, actualSHA256: actual, cached });
379
386
  report({ type: 'verified', id: spec.id, sha256: actual, cached });
380
387
  return result;
381
388
  }
382
- async function load(input, { signal: externalSignal } = {}) {
389
+ async function load(input, { signal: externalSignal, caching } = {}) {
383
390
  if (state !== 'idle')
384
391
  fail('BUSY', 'Unload the previous project before another load');
385
392
  const manifest = validateManifest(input, limits);
@@ -397,7 +404,7 @@ export function createExtensionLoader(ports, options = {}) {
397
404
  try {
398
405
  const budget = { bytes: 0 };
399
406
  for (const spec of manifest.extensions) {
400
- const code = await resolve(spec, signal, budget);
407
+ const code = await resolve(spec, signal, budget, caching);
401
408
  const decision = await guarded(() => ports.trustProvider.authorize(code, { signal }), signal);
402
409
  if (decision?.allowed !== true || decision.scope !== 'digest' || decision.sha256 !== code.sha256)
403
410
  fail('TRUST_DENIED', 'Host did not approve this exact extension digest', spec.id);
@@ -3,7 +3,14 @@ import { type ArchiveLimits, type ZipConstructor } from './project-codec.js';
3
3
  export interface CatalogExtensionRequest {
4
4
  id: string;
5
5
  url: string;
6
- distribution: 'embedded' | 'remote' | 'cached';
6
+ /**
7
+ * Where the project keeps it: carrying the bytes, or only a reference.
8
+ *
9
+ * Either way the manifest records where the bytes came from, so a project that carries them
10
+ * can still recover if its retained copy is lost. There is deliberately no third value:
11
+ * where bytes are cached in the browser is the opener's decision, passed to `load()`.
12
+ */
13
+ distribution: 'embedded' | 'remote';
7
14
  execution: ExecutionMode;
8
15
  dependencies?: string[];
9
16
  version?: string;
@@ -63,7 +63,7 @@ export async function prepareCatalogExtensionChanges(input, requests, ports, { z
63
63
  try {
64
64
  const seen = new Set();
65
65
  const choices = requests.map(request => {
66
- if (!request || !['remote', 'embedded', 'cached'].includes(request.distribution) || seen.has(request.id)) {
66
+ if (!request || !['remote', 'embedded'].includes(request.distribution) || seen.has(request.id)) {
67
67
  fail('INVALID_ADDITION', 'Invalid or duplicate catalog selection', request?.id);
68
68
  }
69
69
  seen.add(request.id);
@@ -127,17 +127,22 @@ export async function prepareCatalogExtensionChanges(input, requests, ports, { z
127
127
  if (old && old.sha256 !== sha256)
128
128
  fail('CONVERSION_INTEGRITY', 'Source conversion changed bytes', choice.id);
129
129
  const retainedPath = `extensions/${choice.id}-${sha256}.js`;
130
+ // A catalog extension was fetched from an address, so embedding keeps that address
131
+ // beside the copy rather than discarding what is already known. Bytes nobody fetched —
132
+ // a costume, a sound, an extension written here — have no address to record, which is
133
+ // why a url is optional on an embedded source.
130
134
  const source = choice.distribution === 'embedded' ?
131
- { type: 'embedded', path: retainedPath } : choice.distribution === 'cached' ?
132
- { type: 'cached', url: choice.source.url, path: retainedPath } : choice.source;
133
- // A URL stays canonical for remote and cached, so it must still serve the pinned bytes.
134
- if (source.type !== 'embedded') {
135
+ { type: 'embedded', path: retainedPath, url: choice.source.url } :
136
+ choice.source;
137
+ // A recorded URL is a promise about what it serves, so it must still serve exactly
138
+ // the pinned bytes — whether it is the only place they live or sits beside a copy.
139
+ if (source.url !== undefined) {
135
140
  const remoteBytes = old ? await fetchBytes(source.url) : bytes;
136
141
  if (await wait(() => digest(remoteBytes)) !== sha256) {
137
142
  fail('DIGEST_MISMATCH', 'Remote source does not serve the currently pinned bytes', choice.id);
138
143
  }
139
144
  }
140
- // Embedded and cached both retain a copy in the project.
145
+ // An embedded choice retains a copy in the project.
141
146
  if (source.type !== 'remote') {
142
147
  if (Object.hasOwn(files, source.path) && await wait(() => digest(files[source.path])) !== sha256) {
143
148
  fail('SOURCE_PATH_COLLISION', 'The embedded path belongs to different content', choice.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubohiroya/turbowarp-extension-loader",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Host-independent verified TurboWarp Plus extension loading core",
5
5
  "keywords": [
6
6
  "turbowarp",