@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 +50 -0
- package/README.md +53 -0
- package/dist/index.d.ts +38 -4
- package/dist/index.js +25 -18
- package/dist/project-catalog.d.ts +8 -1
- package/dist/project-catalog.js +11 -6
- package/package.json +1 -1
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
356
|
-
if (!response || !Array.isArray(response.redirects) || response.redirects.length || response.finalURL !==
|
|
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
|
-
|
|
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;
|
package/dist/project-catalog.js
CHANGED
|
@@ -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'
|
|
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
|
|
132
|
-
|
|
133
|
-
// A URL
|
|
134
|
-
|
|
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
|
-
//
|
|
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);
|