@kubohiroya/turbowarp-extension-loader 0.1.0 → 0.2.1
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/CHANGELOG.md +87 -0
- package/README.ja.md +190 -0
- package/README.md +97 -74
- package/dist/index.d.ts +12 -0
- package/dist/index.js +27 -8
- package/dist/indexeddb-cache.d.ts +1 -1
- package/dist/indexeddb-cache.js +2 -1
- package/dist/limits.d.ts +19 -0
- package/dist/limits.js +16 -0
- package/dist/memory-cache.js +2 -1
- package/dist/packager.js +6 -3
- package/dist/project-authoring.js +2 -1
- package/dist/project-catalog.d.ts +1 -1
- package/dist/project-catalog.js +11 -7
- package/dist/project-codec.js +4 -3
- package/package.json +41 -21
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.2.1
|
|
6
|
+
|
|
7
|
+
Default size limits that fit real extensions. A bundled story runtime of about 7 MiB was rejected in
|
|
8
|
+
every Plus form (`INVALID_ARCHIVE` for embedded, `INVALID_BYTES` for remote and cached) although
|
|
9
|
+
the same project loaded from a Data URL, because the loader read at most 1 MiB per extension while
|
|
10
|
+
the project codec stored entries up to 16 MiB.
|
|
11
|
+
|
|
12
|
+
- The loader, `./project-catalog` and `./project-authoring` default to 16 MiB per extension and
|
|
13
|
+
64 MiB in total, the codec's per-entry and expansion limits. All defaults now come from one module,
|
|
14
|
+
so an entry the codec accepts is one the loader reads. Hosts can still lower them.
|
|
15
|
+
- `./project-codec` accepts a `project.json` of up to 32 MiB by default (it was 4 MiB), the value
|
|
16
|
+
the TurboWarp Plus editor already passed; the same project's `project.json` is 6.8 MB once its
|
|
17
|
+
runtime moves out to an archive entry, so a host using the defaults, such as the packager page,
|
|
18
|
+
rejected it before any extension was read.
|
|
19
|
+
- `createMemoryContentCache` defaults to 32 MiB and the IndexedDB cache to a 16 MiB decompressed
|
|
20
|
+
maximum, so a cache holds an extension of the largest default size.
|
|
21
|
+
- `migrateLegacyDataURL` accepts a Data URL long enough for a 16 MiB extension (it stopped at
|
|
22
|
+
4,194,304 characters), and its base64 check is linear: the previous pattern overflowed the regex
|
|
23
|
+
stack on a payload of a few MiB and reported `INVALID_ENCODING`.
|
|
24
|
+
|
|
25
|
+
## 0.2.0
|
|
26
|
+
|
|
27
|
+
A third source form for extensions whose bytes should be reachable both from a canonical published
|
|
28
|
+
location and offline.
|
|
29
|
+
|
|
30
|
+
- `source` accepts `{type: 'cached', url, path}` beside `embedded` and `remote`. It names both
|
|
31
|
+
locations at once: `url` stays canonical and a verified copy is retained at `path` inside the
|
|
32
|
+
project. Resolution reads the retained copy first and falls back to the URL, reporting a
|
|
33
|
+
`retained-copy-unavailable` diagnostic when the entry is absent. The copy is not a weaker source —
|
|
34
|
+
the Manifest pins the SHA-256, so it either is exactly the pinned code or the load fails with
|
|
35
|
+
`DIGEST_MISMATCH`, which is why it may be read before the network. `url` follows the same HTTPS,
|
|
36
|
+
redirect and final-URL policy as `remote`, `path` the same `extensions/*.js` policy as `embedded`,
|
|
37
|
+
and the cache port still takes precedence over both locations.
|
|
38
|
+
- `./project-catalog` accepts `distribution: 'cached'`, and `./project-codec` and `./packager`
|
|
39
|
+
require and write the retained entry for a cached source as they do for an embedded one.
|
|
40
|
+
- This keeps `schemaVersion: 1`. A Manifest that does not use `cached` stays loadable by 0.1.0; one
|
|
41
|
+
that does needs 0.2.0 or newer, and older versions reject it with `INVALID_SOURCE` rather than
|
|
42
|
+
loading it in a weaker form.
|
|
43
|
+
|
|
44
|
+
The rest of this release was already on `main` but not in 0.1.0: the `v0.1.0` tag was cut before
|
|
45
|
+
these merged, and a published npm version cannot be replaced, so the archive on npm still carries
|
|
46
|
+
the Japanese `README.md` and these reach the registry here.
|
|
47
|
+
|
|
48
|
+
- `README.md` is the English document and the Japanese one is `README.ja.md`, each linking to the
|
|
49
|
+
other. Both state that hosts should pin an exact version, that a 0.x minor may change the
|
|
50
|
+
contract, and that obtaining the package never authorizes extension code to execute — the host's
|
|
51
|
+
`trustProvider` decides that every time.
|
|
52
|
+
- `files` also carries `README.ja.md` and `CHANGELOG.md`, and the package declares `keywords`,
|
|
53
|
+
`author` and `publishConfig.registry`.
|
|
54
|
+
- The TypeScript layout now matches the other `@kubohiroya/turbowarp-*` packages: the tests are
|
|
55
|
+
TypeScript too (`test/*.test.ts`, run on the Node.js type-stripping runtime), `tsconfig.json`
|
|
56
|
+
typechecks without emitting while `tsconfig.build.json` emits, `test/tsconfig.json` covers the
|
|
57
|
+
tests, and `npm run check` runs typecheck, tests and a pack dry run — which is what CI runs.
|
|
58
|
+
`npm run build` is `tsc -p tsconfig.build.json`, and `@types/node` is a dev dependency.
|
|
59
|
+
- The core keeps the strictness it was written with. `noUncheckedIndexedAccess` and
|
|
60
|
+
`exactOptionalPropertyTypes`, which some sibling packages enable, would need non-null assertions
|
|
61
|
+
and option-spread rewrites inside verified loading paths, so they stay off.
|
|
62
|
+
|
|
63
|
+
## 0.1.0
|
|
64
|
+
|
|
65
|
+
First npm release of the GUI-independent extension loading core, published under MPL-2.0. The
|
|
66
|
+
package was previously `private: true` and `UNLICENSED`, consumable only as a pinned Git
|
|
67
|
+
dependency.
|
|
68
|
+
|
|
69
|
+
- Verified project extension loading: schema v1 Manifest validation in the `turboWarpPlus` field of
|
|
70
|
+
`project.json`, Embedded / Remote resolution with a mandatory pinned SHA-256, UTF-8 validation,
|
|
71
|
+
per-code and total size limits, dependency ordering, and transactional registration that rolls
|
|
72
|
+
back on an ID mismatch or a registration failure.
|
|
73
|
+
- `./project-codec`: SB3 ZIP inspection and round-tripping that preserves the Manifest, the original
|
|
74
|
+
Plus URL metadata, embedded bytes and unknown ZIP entries.
|
|
75
|
+
- `./project-authoring` and `./project-catalog`: adding one extension from a JS file or an HTTPS URL,
|
|
76
|
+
and applying a catalog selection to an SB3 candidate in one pass, without executing any JS.
|
|
77
|
+
- `./packager`: Embedded / Remote conversion, Data URL migration with explicit approval, and offline
|
|
78
|
+
SB3 preparation.
|
|
79
|
+
- `./session-trust`, `./memory-cache` and `./indexeddb-cache`: host ports for session approval bound
|
|
80
|
+
to content and source, an in-memory content cache, and a cache-first persistent browser cache that
|
|
81
|
+
stores gzip-compressed bytes with bounded decompression and LRU eviction.
|
|
82
|
+
- `./webpack4`: configuration helper for building the same core under webpack 4.
|
|
83
|
+
- The core is TypeScript; `npm run build` emits ES2022 ESM and `dist/*.d.ts` declarations.
|
|
84
|
+
- `release.yml` is the family's tag-triggered release: a pushed `v<version>` tag verifies, packs,
|
|
85
|
+
attaches the archive to the GitHub Release, publishes through a trusted publisher with
|
|
86
|
+
provenance, and points `latest` and `next` at the release. This first version is published by
|
|
87
|
+
hand, because npm cannot be given a trusted publisher for a package that does not exist yet.
|
package/README.ja.md
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# turbowarp-extension-loader
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 日本語
|
|
4
|
+
|
|
5
|
+
[Editor Issue #4](https://github.com/kubohiroya/turbowarp-plus-editor/issues/4) の GUI 非依存コアです。独立Gitリポジトリで管理し、`@kubohiroya/turbowarp-extension-loader` として npm に MPL-2.0 で公開しています。既存の配布ライブラリや npm パッケージを置き換えません。
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @kubohiroya/turbowarp-extension-loader
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 実装範囲
|
|
12
|
+
|
|
13
|
+
- `project.json` の独自フィールド `turboWarpPlus` に schema v1 の Manifest を配置します。標準 SB3 仕様ではありません。
|
|
14
|
+
- Manifest の厳格検証、未知フィールド拒否、重複・標準拡張 ID 拒否、依存順、循環・欠落依存拒否。
|
|
15
|
+
- 拡張ごとの Embedded / Remote / Cached 解決、SHA-256 必須固定、UTF-8 検証、コードごと・全体のサイズ上限。
|
|
16
|
+
- SHA-256 キーのキャッシュポート。読み出すたびに exact bytes を再検証し、キャッシュ利用時もホストの認可を要求します。
|
|
17
|
+
- 明示的なホスト認可後、全拡張の検証・認可が揃ってから登録を始めます。登録 ID 不一致・登録失敗はホスト transaction の rollback を要求します。
|
|
18
|
+
- キャンセル、取得・認可のタイムアウト、重複ロード拒否、unload、診断イベント。
|
|
19
|
+
|
|
20
|
+
標準拡張・汎用カスタム URL・従来の Data URL は読み替えません。Manifest がない作品ではホストポートを呼び出しません。作品内の `execution: "unsandboxed"` は要求値であり権限ではありません。`trusted` などの未知フィールドは拒否します。
|
|
21
|
+
|
|
22
|
+
### ソースの形式
|
|
23
|
+
|
|
24
|
+
`source` は拡張のバイト列の所在を示し、3つの形式のいずれかを取ります。0.2.0で追加した `cached` は2つの所在を同時に示すため、正本の公開場所を保ったままオフラインでも動作します。
|
|
25
|
+
|
|
26
|
+
| 形式 | 構造 | 解決順 |
|
|
27
|
+
| --- | --- | --- |
|
|
28
|
+
| `embedded` | `{type: 'embedded', path}` | `path` のアーカイブ内エントリ |
|
|
29
|
+
| `remote` | `{type: 'remote', url}` | `url` のHTTPS取得 |
|
|
30
|
+
| `cached` | `{type: 'cached', url, path}` | `path` の保持コピー → `url` のHTTPS取得 |
|
|
31
|
+
|
|
32
|
+
保持コピーは弱いソースではありません。ManifestがSHA-256を固定しているため、保持コピーは固定されたコードそのものか、さもなくば `DIGEST_MISMATCH` で失敗します。だからネットワークより先に読んでよいのです。エントリが無い場合は `retained-copy-unavailable` を診断として報告してURLへ退避し、そのURLは `remote` と同じredirect・最終URL方針のもとで固定バイト列を返さなければなりません。`path` は `embedded` と同じ `extensions/*.js` 方針に従い、cache portは両方の所在より優先されます。
|
|
33
|
+
|
|
34
|
+
`cached` は `schemaVersion: 1` を維持するので、使っていないManifestは0.1.0でそのまま読めます。使っているManifestは0.2.0以降を必要とし、古い版は弱い形式で読み込まずに `INVALID_SOURCE` で拒否します。
|
|
35
|
+
|
|
36
|
+
## ホスト側の接続
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
import {createExtensionLoader, createFetchPort} from '@kubohiroya/turbowarp-extension-loader';
|
|
40
|
+
|
|
41
|
+
const loader = createExtensionLoader({
|
|
42
|
+
embeddedReader: archiveReader,
|
|
43
|
+
fetcher: createFetchPort(),
|
|
44
|
+
trustProvider: {
|
|
45
|
+
async authorize(resolved, {signal}) {
|
|
46
|
+
const approved = await showHostPermissionDialog(resolved, signal);
|
|
47
|
+
return {allowed: approved, scope: 'digest', sha256: resolved.sha256};
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
registrar: hostTransactionalRegistrar,
|
|
51
|
+
diagnostics: event => displayDiagnostic(event)
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await loader.loadProject(parsedProjectJSON);
|
|
55
|
+
// A new project must unload the prior project first.
|
|
56
|
+
await loader.unload();
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`trustProvider` はホストが所有します。要求 ID、バージョン、申告発行者、取得元、SHA-256、要求 capability を受け取り、承認 UI やホストの固定ポリシーを使って判断してください。作品内の名前・ドメイン・発行者文字列だけを承認の根拠にしません。SHA-256 は内容の一貫性を保証するだけで安全性や発行者の本人性は保証しません。ホストは承認履歴の保存・取消・有効期限を管理します。
|
|
60
|
+
|
|
61
|
+
登録ポートは `begin({signal})` → `register(authorizedCode,{signal})` → `commit({signal})` を実装し、どの登録が失敗しても `rollback()` で登録状態を復旧できる必要があります。`register()` は実際の `getInfo().id` と対応する登録結果 `{id}` を返します。ホストは既存 VM 内の ID 競合も拒否してください。`unload(ids)` は依存の逆順で呼ばれます。契約は TypeScript の型(`dist/*.d.ts` として公開)に記載しています。
|
|
62
|
+
|
|
63
|
+
実VMアダプターは `scratch-gui/src/lib/tw-plus-extension-registrar.js` が所有します。VMへ追加する `getExtensionServicePort()` と組み合わせ、同一VMの登録テーブル・dispatchサービスの所有権を追跡してrollback/unloadします。Editorと別バンドルのPackagerもVM内の同じサービスを利用します。標準VMを使用する同一バンドルではdispatch同一性を確認し、未対応の別バンドルでは拒否します。Loaderコア自身はVM private APIへ依存しません。
|
|
64
|
+
|
|
65
|
+
## セキュリティ境界と制約
|
|
66
|
+
|
|
67
|
+
- core は JS を評価しません。評価権限と VM 内部 API はホストに限定します。
|
|
68
|
+
- ロールバックはホストの拡張登録状態の保証です。一度評価した特権 JS の DOM / ネットワーク等の副作用を巻き戻すことはできません。安全なコードの承認は別途必要です。
|
|
69
|
+
- 初期版は Remote の HTTP、資格情報付き URL、フラグメント、リダイレクトをすべて拒否します。HTTPS URL の正常取得だけで認可しません。
|
|
70
|
+
- `createFetchPort()` はリダイレクト無効、credentials 不送信、referrer 不送信、ストリームの実サイズ制限を適用します。CORS / CSP / offline の fetch 拒否は `LOAD_FAILED` の `cause` に保持します。ネットワーク層の詳細分類はcauseへ委ね、CORS/CSPはEditor側の実ブラウザーE2Eで検証します。
|
|
71
|
+
- injected fetch ポートは `finalURL` と明示的な空 `redirects` を返す必要があります。ポート自体は信頼済みホストが実装する境界です。
|
|
72
|
+
- 取得・認可は応答しないポートでもタイムアウトします。登録処理はキャンセル後も完了を待ってから rollback します。遅れて完了する登録と rollback の競合を避けるため、登録ポートが `AbortSignal` に応答しない場合、登録中の有限時間終了は保証できません。
|
|
73
|
+
- キャッシュの容量・失効・永続化・出所ログはホストのポートが担当します。壊れたキャッシュを暗黙に無視して Remote へ切り替えません。
|
|
74
|
+
- `./packager` はEmbedded / Remote変換、明示承認付きData URL移行、lock/license metadata保持、offline SB3準備を提供します。外部依存資源がある拡張は同梱を推測せず拒否します。署名・発行者鍵のpinningはホスト側で追加できる設計で、初期実装は内容・取得元に束縛したセッション承認です。
|
|
75
|
+
- ZIP の実装は `createProjectIO({zip: JSZip})` に注入します。core の codec が ZIP central directory を直接検査し、重複・経路・宣言容量を検証してから、ストリーム展開の実容量と CRC32 を検査します。外部実装の archive port を使う場合も同等の検証が必要です。
|
|
76
|
+
|
|
77
|
+
既定値は最大64拡張、1拡張16 MiB、合計64 MiB、取得・認可30秒です。1拡張と合計の既定値は project codec の1 entry と全展開量の上限に揃えてあり、作品に格納できる拡張ファイルは Loader でも読めます。ホストは loader options で下げられます。データURLやHTTPの開発モード例外はありません。標準 TurboWarp / 非対応エディターが独自 Manifest と ZIP エントリを破棄する可能性があるため、保持対応のないホストでの再保存は移行手段になりません。
|
|
78
|
+
|
|
79
|
+
## IndexedDB 永続キャッシュ(ブラウザホスト用)
|
|
80
|
+
|
|
81
|
+
固定された npm `name@exact-version` のCDN配信を想定し、**cache first** とします。ハッシュで固定されたキャッシュがあればオンライン・オフラインを問わずCDNへ問い合わせません。キャッシュミス時のみ既存Remote Fetch Portが取得します。URLに `latest`、semver範囲、バージョン省略等の可変指定を使わないことをホスト側で保証してください。キャッシュの保持にはTTLを設けず、容量超過時のLRU、明示消去、ブラウザのストレージ削除のみで削除します。gzip展開後のSHA-256照合とunsandboxed実行認可は毎回必要です。
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
`./indexeddb-cache` はリモートで取得・SHA-256 検証済みの JS バイト列を、ブラウザ再起動をまたいで再利用するための **任意の Cache Port アダプター**です。Loader コア側には IndexedDB 依存を持たせません。
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
import {createIndexedDBContentCache}
|
|
88
|
+
from '@kubohiroya/turbowarp-extension-loader/indexeddb-cache';
|
|
89
|
+
|
|
90
|
+
const cache = createIndexedDBContentCache({
|
|
91
|
+
databaseName: 'turbowarp-plus-extension-cache',
|
|
92
|
+
maxBytes: 32 * 1024 * 1024, // compressed storage quota
|
|
93
|
+
maxDecompressedBytes: 16 * 1024 * 1024
|
|
94
|
+
});
|
|
95
|
+
const loader = createExtensionLoader({...hostPorts, cache});
|
|
96
|
+
// 終了・ログアウトなどの明示操作で await cache.clear() / await cache.close()
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- キーは lowercase SHA-256 hex。**保存する値は生JSではなく gzip 圧縮されたバイト列**(format: `gzip-v1`、元サイズを併記)。取得時に展開して Loader に返します。既定32 MiB は**圧縮後の総保存容量**、展開後の最大サイズは16 MiB(1拡張の既定値)、有効期限なし。容量超過時は最終アクセス順LRUで削除します。旧形式の非圧縮エントリは読み込まず削除します。
|
|
100
|
+
- 展開は出力サイズを制限し、改ざん・gzip破損や展開超過を拒否します。ブラウザの `CompressionStream` / `DecompressionStream` が必要です。非対応環境で生JSへ黙って切り替えて保存することはありません。
|
|
101
|
+
- キャッシュを利用した際にも Loader コアが **取得バイト列の SHA-256 を再検証** し、毎回 trustProvider の権限判定を適用します。キャッシュが信頼判断を代行することはありません。
|
|
102
|
+
- キャッシュは IndexedDB を使用できるブラウザ origin に紐付き、ブラウザのストレージ削除・プライベートモード・容量不足等で消える場合があります。ブラウザからの永続化保証はありません。
|
|
103
|
+
- IndexedDB が利用できない環境では生成時に明示的に失敗します。ホストは必要に応じて `createMemoryContentCache` にフォールバックできます。ただしキャッシュ障害を無条件に無視してコード実行するのではなく、ホスト側で失敗方針を決めてください。
|
|
104
|
+
- 新たな Remote バイト列を取得し保存した場合も、権限承認は毎回別途必要です。コード内から追加取得される依存リソースや外部サービスはこのキャッシュの対象外です。
|
|
105
|
+
- Web/Editor はこのポートを初期化して注入するだけとし、`scratch-gui` と `turbowarp-plus-editor` に別のキャッシュロジックを実装しないでください。
|
|
106
|
+
|
|
107
|
+
## ソースとビルド
|
|
108
|
+
|
|
109
|
+
ソースもテストも 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/` を変換します。
|
|
110
|
+
|
|
111
|
+
型の厳しさはコアを書いたときの設定を保っています(`strict` と `noUnusedLocals`・`noUnusedParameters`・`noImplicitOverride`・`noImplicitReturns`・`noFallthroughCasesInSwitch`・`verbatimModuleSyntax`)。兄弟パッケージの一部が有効にしている `noUncheckedIndexedAccess` と `exactOptionalPropertyTypes` は、まだ有効にしていません。
|
|
112
|
+
|
|
113
|
+
## 検証
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
npm ci --ignore-scripts
|
|
117
|
+
npm run check
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`npm run check` は `src` と `test` の型検査、テスト、パッケージの dry-run を順に実行します。`npm test` は `dist/` をビルドしてから、パッケージ名経由(`exports` 解決)で公開APIを検証します。テストは Node.js の型除去実行で TypeScript のまま動かし、不正値が拒否されることを確かめるケースでは、公開型をゆるめる代わりにその値をキャストで明示的に渡します。Node.js >=26 の組み込み test runner / Web Crypto を使い、Loader 本体を実行時依存パッケージなしで headless 検証します。ZIP codec の fixture 検証には開発依存の `@turbowarp/jszip` を使います。`test/fixtures/extension.js` はホストに渡すバイト列の fixture で、テスト中に評価しません。実ネットワーク、CORS / CSP、VM の private API、ブラウザー拡張実行の検証とは区別します。
|
|
121
|
+
|
|
122
|
+
## 導入と切戻し
|
|
123
|
+
|
|
124
|
+
ホストの起動時固定・既定 OFF のフィーチャーフラグの内側に接続します。フラグ OFF で Loader ポートを呼び出さず、従来の作品読込経路へ戻せる設計とします。未知の Manifest を保持できないホストでは有効化せず、公開前に SB3 保存→再読込の fixture を検証してください。
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
## SB3 codec
|
|
128
|
+
|
|
129
|
+
`./project-codec` は `createProjectIO({zip: JSZip, ...limits})` と `inspectZip(bytes, limits)` を提供します。GUI は Manifest を解釈せず、`prepare` → `activate(context)` と `restoreJSON` / `restoreFiles` / `restoreArchive` を接続します。`embeddedReader` は同じインスタンスを Loader ports に渡します。
|
|
130
|
+
|
|
131
|
+
`prepare` は JSON object / string / UTF-8 bytes / ArrayBuffer / Blob と SB3 ZIP を受け取り、`{manifest, input, context}` を返します。Manifest があれば core で検証し、legacy input の Plus ID に対する `extensionURLs` を除去して自動 URL 実行を防ぎます。ID 自体は GUI の登録 hook 用に残します。保存時は Manifest・元の Plus URL metadata・embedded bytes・未知の ZIP entry・未知の top-level metadata を復元します。`restoreArchive` は GUI の `toJSON` hook が `project.json` を復元済みであることを前提とします。
|
|
132
|
+
|
|
133
|
+
未対応形式は ZIP64、multidisk、暗号化、STORE/DEFLATE 以外、UTF-8 以外の非 ASCII entry 名、Unicode path extra field です。既定上限は archive と全展開量64 MiB、1 entry16 MiB、project.json 32 MiB、4096 entry、圧縮比1000です。上限はホスト起動設定で変更できます。Manifest のない ZIP もこの安全検査を受け、通過後は元 input をそのまま返します。このため、従来 VM が許していた未対応 ZIP もフラグ有効時には拒否されます。フラグ OFF では codec 自体を呼びません。
|
|
134
|
+
|
|
135
|
+
## 追加のホスト向けAPI
|
|
136
|
+
|
|
137
|
+
- `isAuthorizedCode(value)` はコアが発行し現在登録中の認可証跡だけを識別します。コピー・作品データによる偽造を拒否し、transaction終了時に失効します。
|
|
138
|
+
- `./session-trust` の `createSessionTrustProvider` はid/digest/source/version/publisher/capabilities/dependenciesへ束縛した承認を保持します。明示された外部ホストのURL/execution方針をUI前に毎回適用し、判定できないEmbedded要求は拒否します。
|
|
139
|
+
- `./memory-cache` の `createMemoryContentCache` はコピー・容量(既定32 MiB、既定最大サイズの拡張2件分)・TTL・LRUを提供し、コアはヒット時にも内容を再検証します。
|
|
140
|
+
- `./webpack4` はGUI/Packager用の同一コアをwebpack 4でビルドするための構成補助です。
|
|
141
|
+
|
|
142
|
+
実GUI、実Packager 3.14.0の生成HTML、Desktop 1.17.0の互換性はEditor側のE2Eが担当します。標準Desktopや標準WebへLoaderを無断インストールしません。標準Desktopの保存は独自Manifest/ZIPエントリを破棄することを実測しているため、保持対応エディターで保存してください。
|
|
143
|
+
|
|
144
|
+
## JSファイル・URLからの作品追加API
|
|
145
|
+
|
|
146
|
+
`./project-authoring` の `prepareExtensionAddition` は、[Editor Issue #11](https://github.com/kubohiroya/turbowarp-plus-editor/issues/11) の専用UI(カスタム拡張画面)のために作りました。TurboWarp-Plus-Editor は2026-11-01にこの画面をやめ、拡張の入口を TurboWarp Plus の一覧(下の `./project-catalog`)だけにしたため、今は Editor から使っていません。UIはIDとファイルまたはURLを渡すだけで、Manifest編集・URLポリシー・SHA-256計算はこのコアが担当します。
|
|
147
|
+
|
|
148
|
+
```js
|
|
149
|
+
import JSZip from '@turbowarp/jszip';
|
|
150
|
+
import {prepareExtensionAddition} from '@kubohiroya/turbowarp-extension-loader/project-authoring';
|
|
151
|
+
|
|
152
|
+
const candidate = await prepareExtensionAddition(currentSB3, {
|
|
153
|
+
id: 'myextension',
|
|
154
|
+
source: {type: 'embedded'},
|
|
155
|
+
bytes: selectedFile
|
|
156
|
+
}, {fetcher, trustProvider, cache, diagnostics}, {zip: JSZip, signal});
|
|
157
|
+
// For a Remote extension, use {id, source: {type: 'remote', url: httpsURL}}.
|
|
158
|
+
// Apply candidate.projectBytes only after the host checks that the project is still current.
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
- JSファイルはEmbedded、HTTPS URLはRemoteとして追加します。Embeddedの既定パスは `extensions/<id>.js` です。RemoteはURLから実際に取得し、呼出側が渡したバイト列による代用を拒否します。
|
|
162
|
+
- IDは拡張作者の `getInfo().id` に合わせて入力します。取り込み時にJSを評価してIDを抽出しません。実行時のregistrarが最終的なID一致を検査します。
|
|
163
|
+
- 空・過大・非UTF-8のJS、予約済みID、既存ID、既存ZIPエントリへの上書きを拒否します。HTTPS・最終URL・リダイレクトは既存Loaderと同じ検証を適用します。
|
|
164
|
+
- SHA-256は取得したexact bytesから自動計算します。Remoteでは、その取得結果をスナップショットとして候補検証に使います。作品保存後の再取得時にも固定ハッシュを照合するため、サーバー側の差替えを自動で信頼しません。
|
|
165
|
+
- 候補作品の全拡張を既存Loaderで検証し、ホストの明示的承認を確認してから `{projectBytes, spec, manifest}` を返します。このAPIはJSを実行せず、元の作品・入力バイト列・実VMを変更しません。承認済みのセッションtrustProviderを実VMへの適用時にも共有すると、同じ内容・取得元への重複確認を避けられます。
|
|
166
|
+
- 標準拡張、通常のcustom URL、既存Plus Manifest、埋め込みコード、画像等のZIPエントリ、未知metadata、配布lockを保持します。生成候補は通常ロードと同じZIP制限で再検査します。
|
|
167
|
+
- VM適用はホストが所有します。プロジェクト切替・キャンセル・登録失敗の扱いと元作品の復元を実装し、UIを閉じた後や別作品に古い結果を適用しないでください。
|
|
168
|
+
- 返却したSB3は通常保存と `prepareOfflinePackage` にそのまま渡せます。Remoteの同梱・外部依存の宣言・受取人の承認境界は従来のPackager APIを再利用します。起動時フラグは引き続き既定OFFです。
|
|
169
|
+
|
|
170
|
+
## 一覧からの一括設定API
|
|
171
|
+
|
|
172
|
+
`./project-catalog` の `prepareCatalogExtensionChanges(currentSB3, requests, ports, options)` は、カタログの選択をまとめてSB3候補へ反映します。requestは `{id, url, distribution: 'embedded' | 'remote' | 'cached', execution: 'sandboxed' | 'unsandboxed', dependencies?, version?}` です。JS取得・SHA-256計算・依存順・全件検証・ホスト承認を共通Loaderで行い、全件承認後に `{projectBytes, manifest}` を返します。実VMには触りません。
|
|
173
|
+
|
|
174
|
+
既存Plus拡張の配布方式・実行モードを変える場合は、検証済みコードとSHA-256・発行者・バージョン・lockを保持します。Remote出力先が現在のコードを返さない場合、更新として勝手に追従せず拒否します。`readProjectAuthoringState(input, options)` は検証済みManifestを返し、UIの状態復元に使えます。これらの作品情報は実行許可ではありません。
|
|
175
|
+
|
|
176
|
+
Manifestの実行要求は `sandboxed` と `unsandboxed` の両方を扱います。ホストは要求された実行モードを守るregistrarを注入してください。ローカルで検証したEditor/Packager統合ではVM所有の隔離iframeまたは承認済みホスト実行へ分岐し、サンドボックスありをサンドボックスなしへ昇格しません。認可キーは実行モードも含み、外部host policyと一致しない要求は拒否します。`prepareExtensionAddition` でも任意の `execution` を渡せ、省略時だけ従来の `unsandboxed` です。
|
|
177
|
+
|
|
178
|
+
複数パッケージの候補適用、実行中拡張の所有権移行、登録失敗時の元SB3復元はGUI/VMの責務です。既存legacy拡張の移行は、VMが一時detach/commit/rollbackの所有境界を提供する場合に限ります。元サービスのdispose後に復旧できない場合も、元SB3を回復用に保持してください。
|
|
179
|
+
|
|
180
|
+
## 公開範囲
|
|
181
|
+
|
|
182
|
+
Loaderコアと型・ドキュメントは、このGitHubリポジトリと npm パッケージ `@kubohiroya/turbowarp-extension-loader` から提供します。ホストは exact version を固定して依存してください。0.x の間は minor でも契約が変わりえます。Editor・GUI・VM・Packagerの統合実装は別のリポジトリで管理され、このコアの公開によってそれらが更新・配信されることはありません。導入時はホスト側の互換性検証と、起動時固定・既定OFFのフラグが必要です。
|
|
183
|
+
|
|
184
|
+
タグ `v<version>` を push すると、`release.yml` が検証・pack・GitHub Releaseへの添付・npmへの公開(Trusted Publisher、provenance付き)を行い、`latest` と `next` をその版に向けます。初回の版だけは、npm にパッケージがまだないため Trusted Publisher を設定できません。手作業で公開してから、npmjs.com で `release.yml` を Trusted Publisher に設定します。
|
|
185
|
+
|
|
186
|
+
npm への公開は、検証済みコードをホストへ配布する手段にすぎません。パッケージの入手自体が拡張コードの実行認可を与えることはなく、認可はホストの `trustProvider` が毎回判断します。
|
|
187
|
+
|
|
188
|
+
## ライセンス
|
|
189
|
+
|
|
190
|
+
MPL-2.0。全文は [LICENSE](LICENSE) を参照してください。
|
package/README.md
CHANGED
|
@@ -1,19 +1,39 @@
|
|
|
1
1
|
# turbowarp-extension-loader
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
English | [日本語](README.ja.md)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The GUI-independent core of [Editor Issue #4](https://github.com/kubohiroya/turbowarp-plus-editor/issues/4). It lives in its own Git repository and is published to npm as `@kubohiroya/turbowarp-extension-loader` under MPL-2.0. It does not replace any existing distribution library or npm package.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
```sh
|
|
8
|
+
npm install @kubohiroya/turbowarp-extension-loader
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Scope
|
|
12
|
+
|
|
13
|
+
- A schema v1 Manifest lives in `turboWarpPlus`, a non-standard field of `project.json`. This is not part of the standard SB3 specification.
|
|
14
|
+
- Strict Manifest validation: unknown fields rejected, duplicate and standard extension IDs rejected, dependency ordering enforced, cyclic and missing dependencies rejected.
|
|
15
|
+
- Per-extension Embedded / Remote / Cached resolution, a mandatory pinned SHA-256, UTF-8 validation, and per-code and total size limits.
|
|
16
|
+
- A SHA-256-keyed cache port. Every read re-verifies the exact bytes, and a cache hit still requires host authorization.
|
|
17
|
+
- Registration starts only after explicit host authorization and only once every extension has been verified and authorized. A registration ID mismatch or a registration failure requires the host transaction to roll back.
|
|
18
|
+
- Cancellation, fetch and authorization timeouts, duplicate load rejection, unload, and diagnostic events.
|
|
19
|
+
|
|
20
|
+
Standard extensions, generic custom URLs and legacy Data URLs are not reinterpreted. For a project without a Manifest, no host port is called. `execution: "unsandboxed"` inside a project is a request, not a permission. Unknown fields such as `trusted` are rejected.
|
|
21
|
+
|
|
22
|
+
### Source forms
|
|
23
|
+
|
|
24
|
+
A `source` names where one extension's bytes live, and takes one of three forms. Added in 0.2.0, `cached` names both locations at once, so a project keeps working offline without giving up a canonical published location.
|
|
13
25
|
|
|
14
|
-
|
|
26
|
+
| Form | Shape | Resolution order |
|
|
27
|
+
| --- | --- | --- |
|
|
28
|
+
| `embedded` | `{type: 'embedded', path}` | the archive entry at `path` |
|
|
29
|
+
| `remote` | `{type: 'remote', url}` | an HTTPS fetch of `url` |
|
|
30
|
+
| `cached` | `{type: 'cached', url, path}` | the retained copy at `path`, then an HTTPS fetch of `url` |
|
|
15
31
|
|
|
16
|
-
|
|
32
|
+
A retained copy is not a weaker source: the Manifest pins the SHA-256, so the copy either is exactly the pinned code or the load fails with `DIGEST_MISMATCH`. That is why it may be read before the network. When the entry is absent the loader reports a `retained-copy-unavailable` diagnostic and falls back to the URL, which must still serve the pinned bytes under the same redirect and final-URL policy as `remote`. `path` follows the same `extensions/*.js` policy as `embedded`, and the cache port still takes precedence over both locations.
|
|
33
|
+
|
|
34
|
+
`cached` keeps `schemaVersion: 1`, so a Manifest that does not use it stays loadable by 0.1.0. A Manifest that does use it needs 0.2.0 or newer; older versions reject it with `INVALID_SOURCE` rather than loading it in a weaker form.
|
|
35
|
+
|
|
36
|
+
## Host integration
|
|
17
37
|
|
|
18
38
|
```js
|
|
19
39
|
import {createExtensionLoader, createFetchPort} from '@kubohiroya/turbowarp-extension-loader';
|
|
@@ -36,32 +56,31 @@ await loader.loadProject(parsedProjectJSON);
|
|
|
36
56
|
await loader.unload();
|
|
37
57
|
```
|
|
38
58
|
|
|
39
|
-
`trustProvider`
|
|
59
|
+
The `trustProvider` is owned by the host. It receives the requested ID, the version, the claimed publisher, the source, the SHA-256 and the requested capabilities, and decides using an approval UI or a fixed host policy. Do not base approval on names, domains or publisher strings carried inside the project alone. A SHA-256 guarantees content consistency only — not safety, and not the identity of the publisher. Storing, revoking and expiring approval history is the host's responsibility.
|
|
40
60
|
|
|
41
|
-
|
|
61
|
+
The registration port implements `begin({signal})` → `register(authorizedCode, {signal})` → `commit({signal})`, and must be able to restore the registration state with `rollback()` when any registration fails. `register()` returns a registration result `{id}` that corresponds to the actual `getInfo().id`. The host should also reject ID collisions inside an existing VM. `unload(ids)` is called in reverse dependency order. The contracts are written in the TypeScript types published as `dist/*.d.ts`.
|
|
42
62
|
|
|
43
|
-
|
|
63
|
+
The real VM adapter is owned by `scratch-gui/src/lib/tw-plus-extension-registrar.js`. Combined with `getExtensionServicePort()`, which is added to the VM, it tracks ownership of the registration table and dispatch service of the same VM so that rollback and unload work. The Packager, a separate bundle from the Editor, uses the same service inside the VM. Within one bundle using a standard VM it confirms dispatch identity; in an unsupported separate bundle it refuses. The Loader core itself does not depend on any VM private API.
|
|
44
64
|
|
|
45
|
-
##
|
|
65
|
+
## Security boundary and constraints
|
|
46
66
|
|
|
47
|
-
- core
|
|
48
|
-
-
|
|
49
|
-
-
|
|
50
|
-
- `createFetchPort()`
|
|
51
|
-
- injected fetch
|
|
52
|
-
-
|
|
53
|
-
-
|
|
54
|
-
- `./packager`
|
|
55
|
-
- ZIP
|
|
67
|
+
- The core does not evaluate JS. Evaluation privileges and VM internal APIs stay with the host.
|
|
68
|
+
- Rollback is a guarantee about the host's extension registration state. It cannot undo DOM, network or other side effects of privileged JS that has already been evaluated. Approving that the code is safe remains a separate decision.
|
|
69
|
+
- The initial version rejects HTTP for Remote, URLs carrying credentials, fragments and redirects. A successful HTTPS fetch alone does not authorize anything.
|
|
70
|
+
- `createFetchPort()` disables redirects, sends no credentials, sends no referrer, and enforces the real size limit on the stream. A fetch refused by CORS / CSP / offline conditions is preserved in the `cause` of `LOAD_FAILED`. Detailed classification of network-layer failures is left to `cause`; CORS and CSP are verified by real-browser E2E tests on the Editor side.
|
|
71
|
+
- An injected fetch port must return `finalURL` and an explicitly empty `redirects`. The port itself is a boundary implemented by the trusted host.
|
|
72
|
+
- Fetching and authorization time out even against a port that never responds. Registration waits for completion before rolling back, even after cancellation. To avoid a race between a late-completing registration and a rollback, a registration port that does not respond to `AbortSignal` cannot guarantee that an in-flight registration finishes in bounded time.
|
|
73
|
+
- Cache capacity, invalidation, persistence and provenance logging belong to the host's port. A corrupted cache is never silently ignored in favour of switching to Remote.
|
|
74
|
+
- `./packager` provides Embedded / Remote conversion, Data URL migration with explicit approval, lock and license metadata preservation, and offline SB3 preparation. An extension with external dependent resources is rejected rather than guessed into a bundle. Signature and publisher key pinning can be added by the host; the initial implementation is session approval bound to content and source.
|
|
75
|
+
- The ZIP implementation is injected through `createProjectIO({zip: JSZip})`. The core codec inspects the ZIP central directory directly and validates duplicates, paths and declared capacity before checking the real expanded size and CRC32 of the stream. An externally implemented archive port needs equivalent validation.
|
|
56
76
|
|
|
57
|
-
|
|
77
|
+
The defaults are at most 64 extensions, 16 MiB per extension, 64 MiB in total, and 30 seconds for fetching and authorization. The per-extension and total defaults equal the project codec's per-entry and expansion limits, so any extension file a project can carry is also one the Loader reads; a host can lower them through its loader options. There is no development-mode exception for Data URLs or HTTP. Because standard TurboWarp and unsupported editors may discard the non-standard Manifest and ZIP entries, re-saving in a host without preservation support is not a migration path.
|
|
58
78
|
|
|
59
|
-
## IndexedDB
|
|
79
|
+
## IndexedDB persistent cache (for browser hosts)
|
|
60
80
|
|
|
61
|
-
|
|
81
|
+
The design assumes CDN delivery of a pinned npm `name@exact-version`, and is **cache first**. When a hash-pinned cache entry exists, the CDN is not contacted whether online or offline. Only a cache miss goes through the existing Remote fetch port. The host must guarantee that URLs never use a mutable specifier such as `latest`, a semver range, or an omitted version. Cached content has no TTL: it is removed only by LRU eviction on quota overflow, by explicit clearing, or by the browser deleting site storage. SHA-256 verification after gzip decompression and authorization for unsandboxed execution are still required every time.
|
|
62
82
|
|
|
63
|
-
|
|
64
|
-
`./indexeddb-cache` はリモートで取得・SHA-256 検証済みの JS バイト列を、ブラウザ再起動をまたいで再利用するための **任意の Cache Port アダプター**です。Loader コア側には IndexedDB 依存を持たせません。
|
|
83
|
+
`./indexeddb-cache` is an **optional Cache Port adapter** that reuses JS bytes already fetched remotely and verified by SHA-256 across browser restarts. The Loader core carries no IndexedDB dependency.
|
|
65
84
|
|
|
66
85
|
```js
|
|
67
86
|
import {createIndexedDBContentCache}
|
|
@@ -70,59 +89,59 @@ import {createIndexedDBContentCache}
|
|
|
70
89
|
const cache = createIndexedDBContentCache({
|
|
71
90
|
databaseName: 'turbowarp-plus-extension-cache',
|
|
72
91
|
maxBytes: 32 * 1024 * 1024, // compressed storage quota
|
|
73
|
-
maxDecompressedBytes:
|
|
92
|
+
maxDecompressedBytes: 16 * 1024 * 1024
|
|
74
93
|
});
|
|
75
94
|
const loader = createExtensionLoader({...hostPorts, cache});
|
|
76
|
-
//
|
|
95
|
+
// On an explicit action such as exit or sign-out: await cache.clear() / await cache.close()
|
|
77
96
|
```
|
|
78
97
|
|
|
79
|
-
-
|
|
80
|
-
-
|
|
81
|
-
-
|
|
82
|
-
-
|
|
83
|
-
- IndexedDB
|
|
84
|
-
-
|
|
85
|
-
- Web
|
|
98
|
+
- The key is a lowercase SHA-256 hex digest. **The stored value is gzip-compressed bytes, not raw JS** (format `gzip-v1`, with the original size recorded alongside). It is decompressed on read and returned to the Loader. The default 32 MiB is the **total compressed storage quota**, the maximum decompressed size is 16 MiB (the per-extension default), and entries do not expire. On quota overflow, entries are evicted in least-recently-accessed order. Uncompressed entries in the old format are deleted rather than read.
|
|
99
|
+
- Decompression bounds the output size and rejects tampering, gzip corruption and oversized expansion. The browser's `CompressionStream` / `DecompressionStream` is required. The adapter never silently falls back to storing raw JS in an unsupported environment.
|
|
100
|
+
- Even on a cache hit, the Loader core **re-verifies the SHA-256 of the returned bytes** and applies the `trustProvider` decision every time. The cache never stands in for a trust decision.
|
|
101
|
+
- The cache is bound to a browser origin that can use IndexedDB, and may disappear when the browser clears storage, in private mode, when storage runs out, and so on. The browser offers no persistence guarantee.
|
|
102
|
+
- Where IndexedDB is unavailable, creation fails explicitly. A host may fall back to `createMemoryContentCache` if it wants to, but decide a failure policy on the host side rather than unconditionally ignoring a cache failure and executing code anyway.
|
|
103
|
+
- 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.
|
|
104
|
+
- 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`.
|
|
105
|
+
|
|
106
|
+
## Source and build
|
|
86
107
|
|
|
87
|
-
|
|
108
|
+
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.
|
|
88
109
|
|
|
89
|
-
|
|
110
|
+
The core keeps the strictness it was written with: `strict`, plus `noUnusedLocals`, `noUnusedParameters`, `noImplicitOverride`, `noImplicitReturns`, `noFallthroughCasesInSwitch` and `verbatimModuleSyntax`. It does not yet enable `noUncheckedIndexedAccess` or `exactOptionalPropertyTypes`, which some sibling packages do.
|
|
90
111
|
|
|
91
|
-
##
|
|
112
|
+
## Verification
|
|
92
113
|
|
|
93
114
|
```sh
|
|
94
115
|
npm ci --ignore-scripts
|
|
95
|
-
npm run
|
|
96
|
-
npm test
|
|
116
|
+
npm run check
|
|
97
117
|
```
|
|
98
118
|
|
|
99
|
-
`npm test`
|
|
119
|
+
`npm run check` typechecks `src` and `test`, runs the tests, and packs the tarball as a dry run. `npm test` builds `dist/` and then verifies the public API through the package name, so that `exports` resolution is exercised. The tests run as TypeScript on the Node.js type-stripping runtime; where a case exists to prove that a bad value is refused, it casts that value in deliberately rather than weakening the public types. They use the built-in test runner and Web Crypto of Node.js >=26 to verify the Loader headlessly with no runtime dependency. ZIP codec fixture verification uses the dev dependency `@turbowarp/jszip`. `test/fixtures/extension.js` is a fixture of the bytes handed to the host and is never evaluated during the tests. This is distinct from verifying real networking, CORS / CSP, VM private APIs, or browser extension execution.
|
|
100
120
|
|
|
101
|
-
##
|
|
102
|
-
|
|
103
|
-
ホストの起動時固定・既定 OFF のフィーチャーフラグの内側に接続します。フラグ OFF で Loader ポートを呼び出さず、従来の作品読込経路へ戻せる設計とします。未知の Manifest を保持できないホストでは有効化せず、公開前に SB3 保存→再読込の fixture を検証してください。
|
|
121
|
+
## Rollout and rollback
|
|
104
122
|
|
|
123
|
+
Wire the Loader inside a host feature flag that is fixed at startup and off by default. With the flag off, no Loader port is called and the host falls back to its previous project loading path. Do not enable it on a host that cannot preserve an unknown Manifest, and verify an SB3 save → reload fixture before release.
|
|
105
124
|
|
|
106
125
|
## SB3 codec
|
|
107
126
|
|
|
108
|
-
`./project-codec`
|
|
127
|
+
`./project-codec` provides `createProjectIO({zip: JSZip, ...limits})` and `inspectZip(bytes, limits)`. The GUI does not interpret the Manifest; it wires `prepare` → `activate(context)` together with `restoreJSON` / `restoreFiles` / `restoreArchive`. Pass the same `embeddedReader` instance to the Loader ports.
|
|
109
128
|
|
|
110
|
-
`prepare`
|
|
129
|
+
`prepare` accepts a JSON object, a string, UTF-8 bytes, an ArrayBuffer, a Blob and an SB3 ZIP, and returns `{manifest, input, context}`. When a Manifest is present, the core validates it and strips `extensionURLs` for Plus IDs in legacy input to prevent automatic URL execution. The IDs themselves remain for the GUI's registration hook. On save it restores the Manifest, the original Plus URL metadata, embedded bytes, unknown ZIP entries and unknown top-level metadata. `restoreArchive` assumes the GUI's `toJSON` hook has already restored `project.json`.
|
|
111
130
|
|
|
112
|
-
|
|
131
|
+
Unsupported formats are ZIP64, multidisk, encryption, compression other than STORE/DEFLATE, non-ASCII entry names that are not UTF-8, and the Unicode path extra field. The default limits are 64 MiB for the archive and for total expansion, 16 MiB per entry, 32 MiB for `project.json`, 4096 entries, and a compression ratio of 1000. The limits are configurable through the host's startup settings. A ZIP without a Manifest goes through the same safety checks, and the original input is returned unchanged once it passes. As a result, an unsupported ZIP that the previous VM accepted is rejected while the flag is on. With the flag off, the codec is not called at all.
|
|
113
132
|
|
|
114
|
-
##
|
|
133
|
+
## Additional host-facing API
|
|
115
134
|
|
|
116
|
-
- `isAuthorizedCode(value)`
|
|
117
|
-
- `./session-trust`
|
|
118
|
-
- `./memory-cache`
|
|
119
|
-
- `./webpack4`
|
|
135
|
+
- `isAuthorizedCode(value)` identifies only an authorization trail issued by the core and currently being registered. Copies and forgeries from project data are rejected, and the trail expires when the transaction ends.
|
|
136
|
+
- `createSessionTrustProvider` in `./session-trust` keeps approval bound to id, digest, source, version, publisher, capabilities and dependencies. It applies the explicit external host URL / execution policy before the UI every time, and rejects an Embedded request it cannot decide.
|
|
137
|
+
- `createMemoryContentCache` in `./memory-cache` provides copying, capacity (32 MiB by default, two extensions of the largest default size), TTL and LRU, and the core re-verifies the content even on a hit.
|
|
138
|
+
- `./webpack4` is a configuration helper for building the same core with webpack 4 for the GUI and Packager.
|
|
120
139
|
|
|
121
|
-
|
|
140
|
+
Compatibility with the real GUI, with the HTML generated by the real Packager 3.14.0, and with Desktop 1.17.0 is covered by E2E tests on the Editor side. Do not install the Loader into standard Desktop or standard Web without permission. Saving in standard Desktop has been measured to discard the non-standard Manifest and ZIP entries, so save in an editor that preserves them.
|
|
122
141
|
|
|
123
|
-
## JS
|
|
142
|
+
## Adding an extension from a JS file or URL
|
|
124
143
|
|
|
125
|
-
`./project-authoring`
|
|
144
|
+
`prepareExtensionAddition` in `./project-authoring` was written for the dedicated custom-extension UI of [Editor Issue #11](https://github.com/kubohiroya/turbowarp-plus-editor/issues/11). TurboWarp-Plus-Editor dropped that screen on 2026-11-01 and made the TurboWarp Plus catalog list (`./project-catalog`, below) the only entry point for extensions, so the Editor no longer calls this API. The UI only passes an ID and either a file or a URL; Manifest editing, URL policy and SHA-256 computation are handled by this core.
|
|
126
145
|
|
|
127
146
|
```js
|
|
128
147
|
import JSZip from '@turbowarp/jszip';
|
|
@@ -137,29 +156,33 @@ const candidate = await prepareExtensionAddition(currentSB3, {
|
|
|
137
156
|
// Apply candidate.projectBytes only after the host checks that the project is still current.
|
|
138
157
|
```
|
|
139
158
|
|
|
140
|
-
- JS
|
|
141
|
-
- ID
|
|
142
|
-
-
|
|
143
|
-
- SHA-256
|
|
144
|
-
-
|
|
145
|
-
-
|
|
146
|
-
- VM
|
|
147
|
-
-
|
|
159
|
+
- A JS file is added as Embedded and an HTTPS URL as Remote. The default Embedded path is `extensions/<id>.js`. Remote is actually fetched from the URL, and bytes substituted by the caller are rejected.
|
|
160
|
+
- Enter the ID to match the extension author's `getInfo().id`. The JS is not evaluated during import in order to extract the ID. The registrar checks the final ID match at run time.
|
|
161
|
+
- Empty, oversized and non-UTF-8 JS, reserved IDs, existing IDs and overwriting an existing ZIP entry are rejected. HTTPS, final URL and redirect validation is the same as in the existing Loader.
|
|
162
|
+
- The SHA-256 is computed automatically from the exact bytes fetched. For Remote, that fetch result is used as a snapshot for candidate verification. Because the pinned hash is also checked when the project is re-fetched after saving, a server-side substitution is never trusted automatically.
|
|
163
|
+
- Every extension of the candidate project is verified with the existing Loader and explicit host approval is confirmed before `{projectBytes, spec, manifest}` is returned. This API does not execute JS and does not modify the original project, the input bytes or the real VM. Sharing the approved session `trustProvider` when applying the result to the real VM avoids asking again for the same content and source.
|
|
164
|
+
- Standard extensions, ordinary custom URLs, an existing Plus Manifest, embedded code, ZIP entries such as images, unknown metadata and distribution locks are all preserved. The generated candidate is re-checked against the same ZIP limits as a normal load.
|
|
165
|
+
- Applying the result to the VM is owned by the host. Implement project switching, cancellation and registration failure handling together with restoring the original project, and do not apply a stale result after the UI is closed or to a different project.
|
|
166
|
+
- The returned SB3 can be passed directly to a normal save and to `prepareOfflinePackage`. Remote bundling, declaring external dependencies and the recipient's approval boundary reuse the existing Packager API. The startup flag remains off by default.
|
|
167
|
+
|
|
168
|
+
## Bulk configuration from a catalog
|
|
169
|
+
|
|
170
|
+
`prepareCatalogExtensionChanges(currentSB3, requests, ports, options)` in `./project-catalog` applies a catalog selection to an SB3 candidate in one pass. A request is `{id, url, distribution: 'embedded' | 'remote' | 'cached', execution: 'sandboxed' | 'unsandboxed', dependencies?, version?}`. JS fetching, SHA-256 computation, dependency ordering, verification of every entry and host approval all go through the shared Loader, and `{projectBytes, manifest}` is returned once every entry is approved. The real VM is untouched.
|
|
148
171
|
|
|
149
|
-
|
|
172
|
+
When changing the distribution method or execution mode of an existing Plus extension, the verified code, SHA-256, publisher, version and lock are preserved. If the Remote destination does not return the current code, it is rejected rather than silently adopted as an update. `readProjectAuthoringState(input, options)` returns the verified Manifest and can be used to restore UI state. This project information is not an execution permission.
|
|
150
173
|
|
|
151
|
-
|
|
174
|
+
A Manifest execution request may be either `sandboxed` or `unsandboxed`. The host must inject a registrar that honours the requested execution mode. The locally verified Editor and Packager integrations branch to a VM-owned isolated iframe or to approved host execution, and never promote sandboxed to unsandboxed. The authorization key includes the execution mode, and a request that does not match the external host policy is rejected. `prepareExtensionAddition` also accepts an arbitrary `execution`; only omitting it keeps the previous `unsandboxed` default.
|
|
152
175
|
|
|
153
|
-
|
|
176
|
+
Applying candidates across multiple packages, transferring ownership of a running extension, and restoring the original SB3 on registration failure are responsibilities of the GUI and VM. Migrating an existing legacy extension is supported only where the VM provides an ownership boundary for temporary detach, commit and rollback. Keep the original SB3 for recovery even where the original service cannot be restored after disposal.
|
|
154
177
|
|
|
155
|
-
|
|
178
|
+
## Distribution scope
|
|
156
179
|
|
|
157
|
-
|
|
180
|
+
The Loader core, its types and its documentation are provided from this GitHub repository and from the npm package `@kubohiroya/turbowarp-extension-loader`. Hosts should depend on an exact pinned version. While the package is on 0.x, a minor release may change the contract. The Editor, GUI, VM and Packager integrations are maintained in separate repositories, and publishing this core neither updates nor ships them. Rollout requires host-side compatibility verification and a flag that is fixed at startup and off by default.
|
|
158
181
|
|
|
159
|
-
|
|
182
|
+
Pushing a `v<version>` tag runs `release.yml`, which verifies, packs, attaches the archive to the GitHub Release, publishes to npm through a trusted publisher with provenance, and points `latest` and `next` at that release. The first version is the exception: npm cannot be given a trusted publisher for a package that does not exist yet, so that one is published by hand and the trusted publisher for `release.yml` is configured on npmjs.com afterwards.
|
|
160
183
|
|
|
161
|
-
|
|
184
|
+
Publishing to npm is only a way to deliver verified code to a host. Obtaining the package never authorizes extension code to execute; that decision is made by the host's `trustProvider` every time.
|
|
162
185
|
|
|
163
|
-
##
|
|
186
|
+
## License
|
|
164
187
|
|
|
165
|
-
MPL-2.0
|
|
188
|
+
[MPL-2.0](LICENSE)
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where one extension's bytes live.
|
|
3
|
+
*
|
|
4
|
+
* `cached` names both locations at once: the canonical one is `url`, and a verified copy is
|
|
5
|
+
* retained at `path` inside the project so the extension also resolves offline. The copy is not a
|
|
6
|
+
* weaker source — the Manifest pins the SHA-256, so a retained copy either is exactly the pinned
|
|
7
|
+
* code or the load fails, which is why it may be read before the network.
|
|
8
|
+
*/
|
|
1
9
|
export type ExtensionSource = Readonly<{
|
|
2
10
|
type: 'embedded';
|
|
3
11
|
path: string;
|
|
4
12
|
} | {
|
|
5
13
|
type: 'remote';
|
|
6
14
|
url: string;
|
|
15
|
+
} | {
|
|
16
|
+
type: 'cached';
|
|
17
|
+
url: string;
|
|
18
|
+
path: string;
|
|
7
19
|
}>;
|
|
8
20
|
export type ExecutionMode = 'sandboxed' | 'unsandboxed';
|
|
9
21
|
export interface ExtensionSpec {
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import { DEFAULT_LOADER_LIMITS } from './limits.js';
|
|
2
|
+
const DEFAULTS = DEFAULT_LOADER_LIMITS;
|
|
2
3
|
const RESERVED_IDS = new Set(['pen', 'music', 'videoSensing', 'text2speech', 'translate', 'microbit', 'ev3', 'boost', 'wedo2', 'gdxfor', 'makeymakey']);
|
|
3
4
|
const ID = /^[a-zA-Z][a-zA-Z0-9]{0,63}$/;
|
|
4
5
|
const SHA256 = /^[a-f0-9]{64}$/;
|
|
@@ -41,6 +42,12 @@ function remoteURL(value) {
|
|
|
41
42
|
fail('INVALID_SOURCE', 'Remote source requires HTTPS without credentials or fragment');
|
|
42
43
|
return url.href;
|
|
43
44
|
}
|
|
45
|
+
function embeddedPath(value) {
|
|
46
|
+
const path = string(value, 'embedded path', 256);
|
|
47
|
+
if (!/^extensions\/[a-zA-Z0-9][a-zA-Z0-9._/-]*\.js$/.test(path) || path.split('/').some(part => !part || part === '.' || part === '..'))
|
|
48
|
+
fail('INVALID_SOURCE', 'Unsafe embedded path');
|
|
49
|
+
return path;
|
|
50
|
+
}
|
|
44
51
|
function sourceOf(source) {
|
|
45
52
|
if (!object(source))
|
|
46
53
|
fail('INVALID_SOURCE', 'Missing extension source');
|
|
@@ -50,10 +57,12 @@ function sourceOf(source) {
|
|
|
50
57
|
}
|
|
51
58
|
if (source.type === 'embedded') {
|
|
52
59
|
keys(source, ['type', 'path'], 'source');
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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) });
|
|
57
66
|
}
|
|
58
67
|
fail('INVALID_SOURCE', 'Unsupported source type');
|
|
59
68
|
}
|
|
@@ -226,12 +235,20 @@ export function createExtensionLoader(ports, options = {}) {
|
|
|
226
235
|
if (!bytes) {
|
|
227
236
|
// The manifest is frozen, so the narrowed source stays valid inside the port callbacks.
|
|
228
237
|
const source = spec.source;
|
|
229
|
-
if (source.type
|
|
238
|
+
if (source.type !== 'remote') {
|
|
230
239
|
if (typeof ports.embeddedReader?.read !== 'function')
|
|
231
240
|
fail('INVALID_PORT', 'Embedded reader is unavailable', spec.id);
|
|
232
|
-
|
|
241
|
+
// A cached source may have lost its retained copy; its URL remains the canonical one.
|
|
242
|
+
try {
|
|
243
|
+
bytes = snapshotBytes(await guarded(() => ports.embeddedReader.read(source.path, { signal, maxBytes: limits.maxBytes }), signal), limits.maxBytes, spec.id);
|
|
244
|
+
}
|
|
245
|
+
catch (cause) {
|
|
246
|
+
if (source.type === 'embedded' || signal.aborted)
|
|
247
|
+
throw cause;
|
|
248
|
+
report({ type: 'retained-copy-unavailable', id: spec.id });
|
|
249
|
+
}
|
|
233
250
|
}
|
|
234
|
-
|
|
251
|
+
if (!bytes && source.type !== 'embedded') {
|
|
235
252
|
if (typeof ports.fetcher?.fetch !== 'function')
|
|
236
253
|
fail('INVALID_PORT', 'Remote fetch port is unavailable', spec.id);
|
|
237
254
|
const response = await guarded(() => ports.fetcher.fetch(source.url, { signal, maxBytes: limits.maxBytes }), signal);
|
|
@@ -239,6 +256,8 @@ export function createExtensionLoader(ports, options = {}) {
|
|
|
239
256
|
fail('REDIRECT_DENIED', 'Unproven or redirected remote source', spec.id);
|
|
240
257
|
bytes = snapshotBytes(response.bytes, limits.maxBytes, spec.id);
|
|
241
258
|
}
|
|
259
|
+
if (!bytes)
|
|
260
|
+
fail('SOURCE_UNAVAILABLE', 'Extension bytes could not be read', spec.id);
|
|
242
261
|
}
|
|
243
262
|
budget.bytes += bytes.byteLength;
|
|
244
263
|
if (budget.bytes > limits.maxTotalBytes)
|
|
@@ -3,7 +3,7 @@ export interface IndexedDBContentCacheOptions {
|
|
|
3
3
|
databaseName?: string;
|
|
4
4
|
/** Compressed bytes quota (default 32 MiB). */
|
|
5
5
|
maxBytes?: number;
|
|
6
|
-
/** Maximum decompressed JS size (default
|
|
6
|
+
/** Maximum decompressed JS size (default 16 MiB, the largest default extension). */
|
|
7
7
|
maxDecompressedBytes?: number;
|
|
8
8
|
now?: () => number;
|
|
9
9
|
}
|
package/dist/indexeddb-cache.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { gzipBytes, gunzipBytes } from './cache-gzip.js';
|
|
2
|
+
import { DEFAULT_ENTRY_BYTES } from './limits.js';
|
|
2
3
|
/** Persistent, content-addressed cache. The Loader independently verifies SHA-256 on every hit.
|
|
3
4
|
* Storage is untrusted: this adapter is not an authorization or integrity boundary.
|
|
4
5
|
*/
|
|
5
|
-
export function createIndexedDBContentCache({ indexedDB = globalThis.indexedDB, databaseName = 'turbowarp-plus-extension-cache', maxBytes = 32 * 1024 * 1024, maxDecompressedBytes =
|
|
6
|
+
export function createIndexedDBContentCache({ indexedDB = globalThis.indexedDB, databaseName = 'turbowarp-plus-extension-cache', maxBytes = 32 * 1024 * 1024, maxDecompressedBytes = DEFAULT_ENTRY_BYTES, now = Date.now } = {}) {
|
|
6
7
|
if (!indexedDB || typeof indexedDB.open !== 'function')
|
|
7
8
|
throw new Error('IndexedDB unavailable');
|
|
8
9
|
if (typeof databaseName !== 'string' || !databaseName || !Number.isSafeInteger(maxBytes) || maxBytes < 1 ||
|
package/dist/limits.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one source of the default size limits. A file the project codec accepts as an archive entry
|
|
3
|
+
* must also be one the loader reads, so the per-extension and total defaults are the codec's entry
|
|
4
|
+
* and expansion limits; real extensions such as a bundled story runtime reach several MiB.
|
|
5
|
+
*/
|
|
6
|
+
export declare const DEFAULT_ENTRY_BYTES: number;
|
|
7
|
+
export declare const DEFAULT_TOTAL_BYTES: number;
|
|
8
|
+
/** A project.json may itself carry large scripts, such as legacy Data URL extensions or story data. */
|
|
9
|
+
export declare const DEFAULT_PROJECT_JSON_BYTES: number;
|
|
10
|
+
export declare const DEFAULT_LOADER_LIMITS: Readonly<{
|
|
11
|
+
maxExtensions: 64;
|
|
12
|
+
maxBytes: number;
|
|
13
|
+
maxTotalBytes: number;
|
|
14
|
+
timeoutMs: 30000;
|
|
15
|
+
}>;
|
|
16
|
+
/** A cache holds at least one extension of the largest default size. */
|
|
17
|
+
export declare const DEFAULT_MEMORY_CACHE_BYTES: number;
|
|
18
|
+
/** Base64 of the largest default extension, plus room for the `data:` prefix. */
|
|
19
|
+
export declare const MAX_LEGACY_DATA_URL_LENGTH: number;
|
package/dist/limits.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const MiB = 1024 * 1024;
|
|
2
|
+
/**
|
|
3
|
+
* The one source of the default size limits. A file the project codec accepts as an archive entry
|
|
4
|
+
* must also be one the loader reads, so the per-extension and total defaults are the codec's entry
|
|
5
|
+
* and expansion limits; real extensions such as a bundled story runtime reach several MiB.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_ENTRY_BYTES = 16 * MiB;
|
|
8
|
+
export const DEFAULT_TOTAL_BYTES = 64 * MiB;
|
|
9
|
+
/** A project.json may itself carry large scripts, such as legacy Data URL extensions or story data. */
|
|
10
|
+
export const DEFAULT_PROJECT_JSON_BYTES = 32 * MiB;
|
|
11
|
+
export const DEFAULT_LOADER_LIMITS = Object.freeze({ maxExtensions: 64, maxBytes: DEFAULT_ENTRY_BYTES,
|
|
12
|
+
maxTotalBytes: DEFAULT_TOTAL_BYTES, timeoutMs: 30000 });
|
|
13
|
+
/** A cache holds at least one extension of the largest default size. */
|
|
14
|
+
export const DEFAULT_MEMORY_CACHE_BYTES = 2 * DEFAULT_ENTRY_BYTES;
|
|
15
|
+
/** Base64 of the largest default extension, plus room for the `data:` prefix. */
|
|
16
|
+
export const MAX_LEGACY_DATA_URL_LENGTH = Math.ceil(DEFAULT_ENTRY_BYTES / 3) * 4 + 1024;
|
package/dist/memory-cache.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { DEFAULT_MEMORY_CACHE_BYTES } from './limits.js';
|
|
1
2
|
/** Bounded host-session storage; the Loader re-verifies bytes on every cache hit. */
|
|
2
|
-
export function createMemoryContentCache({ maxBytes =
|
|
3
|
+
export function createMemoryContentCache({ maxBytes = DEFAULT_MEMORY_CACHE_BYTES, ttlMs = 60 * 60 * 1000, now = Date.now } = {}) {
|
|
3
4
|
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || !Number.isSafeInteger(ttlMs) || ttlMs < 1 || typeof now !== 'function') {
|
|
4
5
|
throw new TypeError('Invalid cache capacity or expiry');
|
|
5
6
|
}
|
package/dist/packager.js
CHANGED
|
@@ -2,6 +2,7 @@ import { LoaderError, validateManifest } from './index.js';
|
|
|
2
2
|
import { createProjectIO } from './project-codec.js';
|
|
3
3
|
import { collectVerifiedCodes as collect } from './verified-code.js';
|
|
4
4
|
import { readPreparedProject as decodePrepared } from './project-data.js';
|
|
5
|
+
import { MAX_LEGACY_DATA_URL_LENGTH } from './limits.js';
|
|
5
6
|
const encoder = new TextEncoder();
|
|
6
7
|
function fail(code, message) { throw new LoaderError(code, message); }
|
|
7
8
|
const cancelled = (signal) => { if (signal?.aborted)
|
|
@@ -56,7 +57,8 @@ export async function convertProjectSources(input, ports, { zip: Zip, targets =
|
|
|
56
57
|
if (await digest(bytes) !== record.sha256)
|
|
57
58
|
fail('CONVERSION_INTEGRITY', 'UTF-8 conversion changed verified source bytes');
|
|
58
59
|
own(artifacts, record.id, new Uint8Array(bytes));
|
|
59
|
-
|
|
60
|
+
// Embedded and cached both keep a copy in the archive, so both need their entry written.
|
|
61
|
+
if (validated.source.type !== 'remote') {
|
|
60
62
|
const path = validated.source.path;
|
|
61
63
|
if (outputPaths.has(path))
|
|
62
64
|
fail('DUPLICATE_EMBEDDED_PATH', 'Each extension needs a distinct embedded output path');
|
|
@@ -117,7 +119,7 @@ export async function prepareOfflinePackage(input, ports, options = {}) {
|
|
|
117
119
|
}
|
|
118
120
|
/** Migrate a legacy Data URL only after the host explicitly pins and approves its bytes. */
|
|
119
121
|
export async function migrateLegacyDataURL({ id, url, sha256, metadata }, ports, options = {}) {
|
|
120
|
-
if (typeof url !== 'string' || url.length >
|
|
122
|
+
if (typeof url !== 'string' || url.length > MAX_LEGACY_DATA_URL_LENGTH)
|
|
121
123
|
fail('INVALID_SOURCE', 'Invalid or oversized legacy Data URL');
|
|
122
124
|
const match = /^data:(?:application|text)\/(?:javascript|x-javascript)(?:;charset=utf-8)?(;base64)?;?,([\s\S]*)$/i.exec(url);
|
|
123
125
|
if (!match)
|
|
@@ -125,7 +127,8 @@ export async function migrateLegacyDataURL({ id, url, sha256, metadata }, ports,
|
|
|
125
127
|
let bytes;
|
|
126
128
|
try {
|
|
127
129
|
if (match[1]) {
|
|
128
|
-
|
|
130
|
+
// A repeated group overflows the regex stack on a multi-MiB payload; this form is linear.
|
|
131
|
+
if (match[2].length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(match[2]))
|
|
129
132
|
throw new Error('Invalid base64');
|
|
130
133
|
bytes = Uint8Array.from(atob(match[2]), character => character.charCodeAt(0));
|
|
131
134
|
}
|
|
@@ -2,6 +2,7 @@ import { LoaderError, validateManifest } from './index.js';
|
|
|
2
2
|
import { createProjectIO } from './project-codec.js';
|
|
3
3
|
import { readPreparedProject } from './project-data.js';
|
|
4
4
|
import { collectVerifiedCodes } from './verified-code.js';
|
|
5
|
+
import { DEFAULT_LOADER_LIMITS } from './limits.js';
|
|
5
6
|
const encoder = new TextEncoder();
|
|
6
7
|
function fail(code, message, id) { throw new LoaderError(code, message, id); }
|
|
7
8
|
const plainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value) &&
|
|
@@ -28,7 +29,7 @@ export async function prepareExtensionAddition(input, request, ports, { zip: Zip
|
|
|
28
29
|
}
|
|
29
30
|
if (typeof ports?.trustProvider?.authorize !== 'function')
|
|
30
31
|
fail('INVALID_PORT', 'An explicit host trust provider is required');
|
|
31
|
-
const limits = { maxBytes:
|
|
32
|
+
const limits = { maxBytes: DEFAULT_LOADER_LIMITS.maxBytes, timeoutMs: DEFAULT_LOADER_LIMITS.timeoutMs, ...loaderOptions };
|
|
32
33
|
for (const key of ['maxBytes', 'maxTotalBytes', 'maxExtensions', 'timeoutMs']) {
|
|
33
34
|
const value = limits[key];
|
|
34
35
|
if (value !== undefined && (!Number.isSafeInteger(value) || value < 1))
|
|
@@ -3,7 +3,7 @@ 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';
|
|
6
|
+
distribution: 'embedded' | 'remote' | 'cached';
|
|
7
7
|
execution: ExecutionMode;
|
|
8
8
|
dependencies?: string[];
|
|
9
9
|
version?: string;
|
package/dist/project-catalog.js
CHANGED
|
@@ -2,6 +2,7 @@ import { LoaderError, validateManifest } from './index.js';
|
|
|
2
2
|
import { createProjectIO } from './project-codec.js';
|
|
3
3
|
import { readPreparedProject } from './project-data.js';
|
|
4
4
|
import { collectVerifiedCodes } from './verified-code.js';
|
|
5
|
+
import { DEFAULT_LOADER_LIMITS, DEFAULT_TOTAL_BYTES } from './limits.js';
|
|
5
6
|
const encoder = new TextEncoder();
|
|
6
7
|
const digest = async (bytes) => Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), value => value.toString(16).padStart(2, '0')).join('');
|
|
7
8
|
function fail(code, message, id) { throw new LoaderError(code, message, id); }
|
|
@@ -11,8 +12,7 @@ export async function readProjectAuthoringState(input, { zip, signal, archiveOpt
|
|
|
11
12
|
return prepared.manifest;
|
|
12
13
|
}
|
|
13
14
|
export async function prepareCatalogExtensionChanges(input, requests, ports, { zip: Zip, signal: externalSignal, loaderOptions = {}, archiveOptions } = {}) {
|
|
14
|
-
const limits = {
|
|
15
|
-
timeoutMs: 30000, ...loaderOptions };
|
|
15
|
+
const limits = { ...DEFAULT_LOADER_LIMITS, ...loaderOptions };
|
|
16
16
|
for (const key of ['maxExtensions', 'maxBytes', 'maxTotalBytes', 'timeoutMs']) {
|
|
17
17
|
if (!Number.isSafeInteger(limits[key]) || limits[key] < 1)
|
|
18
18
|
fail('INVALID_OPTIONS', `Invalid ${key}`);
|
|
@@ -21,7 +21,7 @@ export async function prepareCatalogExtensionChanges(input, requests, ports, { z
|
|
|
21
21
|
fail('INVALID_ADDITION', 'A bounded catalog selection is required');
|
|
22
22
|
}
|
|
23
23
|
if (input instanceof Uint8Array || input instanceof ArrayBuffer) {
|
|
24
|
-
if (input.byteLength > (archiveOptions?.maxArchiveBytes ??
|
|
24
|
+
if (input.byteLength > (archiveOptions?.maxArchiveBytes ?? DEFAULT_TOTAL_BYTES)) {
|
|
25
25
|
fail('ARCHIVE_SIZE_LIMIT', 'Project exceeds the archive byte limit');
|
|
26
26
|
}
|
|
27
27
|
input = input.slice(0);
|
|
@@ -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'].includes(request.distribution) || seen.has(request.id)) {
|
|
66
|
+
if (!request || !['remote', 'embedded', 'cached'].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);
|
|
@@ -121,15 +121,19 @@ export async function prepareCatalogExtensionChanges(input, requests, ports, { z
|
|
|
121
121
|
const sha256 = await wait(() => digest(bytes));
|
|
122
122
|
if (old && old.sha256 !== sha256)
|
|
123
123
|
fail('CONVERSION_INTEGRITY', 'Source conversion changed bytes', choice.id);
|
|
124
|
+
const retainedPath = `extensions/${choice.id}-${sha256}.js`;
|
|
124
125
|
const source = choice.distribution === 'embedded' ?
|
|
125
|
-
{ type: 'embedded', path:
|
|
126
|
-
|
|
126
|
+
{ type: 'embedded', path: retainedPath } : choice.distribution === 'cached' ?
|
|
127
|
+
{ type: 'cached', url: choice.source.url, path: retainedPath } : choice.source;
|
|
128
|
+
// A URL stays canonical for remote and cached, so it must still serve the pinned bytes.
|
|
129
|
+
if (source.type !== 'embedded') {
|
|
127
130
|
const remoteBytes = old ? await fetchBytes(source.url) : bytes;
|
|
128
131
|
if (await wait(() => digest(remoteBytes)) !== sha256) {
|
|
129
132
|
fail('DIGEST_MISMATCH', 'Remote source does not serve the currently pinned bytes', choice.id);
|
|
130
133
|
}
|
|
131
134
|
}
|
|
132
|
-
|
|
135
|
+
// Embedded and cached both retain a copy in the project.
|
|
136
|
+
if (source.type !== 'remote') {
|
|
133
137
|
if (Object.hasOwn(files, source.path) && await wait(() => digest(files[source.path])) !== sha256) {
|
|
134
138
|
fail('SOURCE_PATH_COLLISION', 'The embedded path belongs to different content', choice.id);
|
|
135
139
|
}
|
package/dist/project-codec.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { discoverManifest, LoaderError } from './index.js';
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { DEFAULT_ENTRY_BYTES, DEFAULT_PROJECT_JSON_BYTES, DEFAULT_TOTAL_BYTES } from './limits.js';
|
|
3
|
+
const DEFAULTS = Object.freeze({ maxArchiveBytes: DEFAULT_TOTAL_BYTES, maxTotalBytes: DEFAULT_TOTAL_BYTES,
|
|
4
|
+
maxEntryBytes: DEFAULT_ENTRY_BYTES, maxProjectBytes: DEFAULT_PROJECT_JSON_BYTES, maxEntries: 4096, maxRatio: 1000 });
|
|
4
5
|
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
5
6
|
const encoder = new TextEncoder();
|
|
6
7
|
function fail(message) { throw new LoaderError('INVALID_ARCHIVE', message); }
|
|
@@ -254,7 +255,7 @@ export function createProjectIO({ zip: Zip, ...options } = {}) {
|
|
|
254
255
|
if (data.extensionURLs !== undefined && (!data.extensionURLs || typeof data.extensionURLs !== 'object' || Array.isArray(data.extensionURLs)))
|
|
255
256
|
throw new LoaderError('INVALID_PROJECT', 'Project extensionURLs must be an object');
|
|
256
257
|
for (const spec of manifest.extensions)
|
|
257
|
-
if (spec.source.type
|
|
258
|
+
if (spec.source.type !== 'remote' && !files.has(spec.source.path))
|
|
258
259
|
fail('Missing embedded extension entry');
|
|
259
260
|
const context = Object.freeze({});
|
|
260
261
|
contexts.set(context, { manifest, project: data, files });
|
package/package.json
CHANGED
|
@@ -1,12 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubohiroya/turbowarp-extension-loader",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"type": "module",
|
|
3
|
+
"version": "0.2.1",
|
|
5
4
|
"description": "Host-independent verified TurboWarp Plus extension loading core",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"turbowarp",
|
|
7
|
+
"scratch",
|
|
8
|
+
"extension",
|
|
9
|
+
"loader",
|
|
10
|
+
"sb3",
|
|
11
|
+
"manifest",
|
|
12
|
+
"sha-256"
|
|
13
|
+
],
|
|
6
14
|
"license": "MPL-2.0",
|
|
15
|
+
"author": {
|
|
16
|
+
"name": "Hiroya Kubo",
|
|
17
|
+
"email": "hiroya@cuc.ac.jp",
|
|
18
|
+
"url": "https://github.com/kubohiroya"
|
|
19
|
+
},
|
|
20
|
+
"type": "module",
|
|
7
21
|
"engines": {
|
|
8
22
|
"node": ">=26"
|
|
9
23
|
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/kubohiroya/turbowarp-extension-loader.git"
|
|
27
|
+
},
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/kubohiroya/turbowarp-plus-editor/issues/4"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/kubohiroya/turbowarp-extension-loader#readme",
|
|
32
|
+
"main": "./dist/index.js",
|
|
33
|
+
"module": "./dist/index.js",
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
10
35
|
"exports": {
|
|
11
36
|
".": {
|
|
12
37
|
"types": "./dist/index.d.ts",
|
|
@@ -43,7 +68,12 @@
|
|
|
43
68
|
}
|
|
44
69
|
},
|
|
45
70
|
"files": [
|
|
71
|
+
"CHANGELOG.md",
|
|
72
|
+
"LICENSE",
|
|
73
|
+
"README.ja.md",
|
|
74
|
+
"README.md",
|
|
46
75
|
"dist",
|
|
76
|
+
"docs",
|
|
47
77
|
"indexeddb-cache.js",
|
|
48
78
|
"memory-cache.js",
|
|
49
79
|
"packager.js",
|
|
@@ -51,33 +81,23 @@
|
|
|
51
81
|
"project-catalog.js",
|
|
52
82
|
"project-codec.js",
|
|
53
83
|
"session-trust.js",
|
|
54
|
-
"webpack4.cjs"
|
|
55
|
-
"docs",
|
|
56
|
-
"LICENSE",
|
|
57
|
-
"README.md"
|
|
84
|
+
"webpack4.cjs"
|
|
58
85
|
],
|
|
59
86
|
"scripts": {
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"test": "npm run build && node --test test/*.test.
|
|
87
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p test",
|
|
88
|
+
"build": "node -e \"require('node:fs').rmSync('dist', {recursive: true, force: true})\" && tsc -p tsconfig.build.json",
|
|
89
|
+
"test": "npm run build && node --test test/*.test.ts",
|
|
90
|
+
"pack:check": "npm pack --dry-run --ignore-scripts",
|
|
91
|
+
"check": "npm run typecheck && npm test && npm run pack:check",
|
|
63
92
|
"prepare": "npm run build"
|
|
64
93
|
},
|
|
65
94
|
"devDependencies": {
|
|
66
95
|
"@turbowarp/jszip": "3.12.0",
|
|
96
|
+
"@types/node": "26.6.6",
|
|
67
97
|
"typescript": "6.0.3"
|
|
68
98
|
},
|
|
69
|
-
"main": "./dist/index.js",
|
|
70
|
-
"module": "./dist/index.js",
|
|
71
|
-
"types": "./dist/index.d.ts",
|
|
72
|
-
"repository": {
|
|
73
|
-
"type": "git",
|
|
74
|
-
"url": "git+https://github.com/kubohiroya/turbowarp-extension-loader.git"
|
|
75
|
-
},
|
|
76
|
-
"bugs": {
|
|
77
|
-
"url": "https://github.com/kubohiroya/turbowarp-plus-editor/issues/4"
|
|
78
|
-
},
|
|
79
|
-
"homepage": "https://github.com/kubohiroya/turbowarp-extension-loader#readme",
|
|
80
99
|
"publishConfig": {
|
|
81
|
-
"access": "public"
|
|
100
|
+
"access": "public",
|
|
101
|
+
"registry": "https://registry.npmjs.org/"
|
|
82
102
|
}
|
|
83
103
|
}
|