@kubohiroya/turbowarp-extension-loader 0.2.0 → 0.3.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/CHANGELOG.md CHANGED
@@ -2,6 +2,49 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.3.0
6
+
7
+ Released on 2026-11-02. A bounded retry for Remote fetching, so one transient failure no longer
8
+ ends a whole load. The only change is the retry below; the fix for the copy a replaced Embedded or
9
+ Cached entry keeps is **not** in this release, because that work is still open — it follows in
10
+ 0.3.1. New exported API and a changed default make this a minor release: a host that wants the
11
+ previous single attempt passes `{retries: 0}`.
12
+
13
+ - The fetch port retries a transient Remote failure a bounded number of times instead of failing on
14
+ the first one. `createFetchPort()` now wraps itself in the new `withFetchRetry(port, options)`, so
15
+ a host on the default port needs no change; a host that injects its own port can wrap it the same
16
+ way, and the core cannot impose the behaviour on it. Only a connection that could not be made and
17
+ an HTTP 408, 425, 429 or 5xx are repeated — `isTransientFetchFailure(error)` is the exported
18
+ classifier, and a `FETCH_FAILED` carries the HTTP status in its `cause`. `DEFAULT_FETCH_RETRY`
19
+ allows 2 retries, 400 ms of backoff doubling to a 2 s cap, and a 5 s budget measured from the
20
+ first attempt; the 30-second fetch and authorization deadline still covers the whole operation, so
21
+ the worst-case wait does not grow. A backoff wait is interrupted by the `AbortSignal` rather than
22
+ waited out, and `{retries: 0}` restores the previous single attempt.
23
+ - A load or a catalog preparation that finally fails now aborts its own `AbortSignal` before
24
+ reporting, so port work the core has stopped waiting for does not keep running unobserved. Every
25
+ entry is still verified and authorized before registration starts, and a failure still rolls the
26
+ host transaction back.
27
+
28
+ ## 0.2.1
29
+
30
+ Default size limits that fit real extensions. A bundled story runtime of about 7 MiB was rejected in
31
+ every Plus form (`INVALID_ARCHIVE` for embedded, `INVALID_BYTES` for remote and cached) although
32
+ the same project loaded from a Data URL, because the loader read at most 1 MiB per extension while
33
+ the project codec stored entries up to 16 MiB.
34
+
35
+ - The loader, `./project-catalog` and `./project-authoring` default to 16 MiB per extension and
36
+ 64 MiB in total, the codec's per-entry and expansion limits. All defaults now come from one module,
37
+ so an entry the codec accepts is one the loader reads. Hosts can still lower them.
38
+ - `./project-codec` accepts a `project.json` of up to 32 MiB by default (it was 4 MiB), the value
39
+ the TurboWarp Plus editor already passed; the same project's `project.json` is 6.8 MB once its
40
+ runtime moves out to an archive entry, so a host using the defaults, such as the packager page,
41
+ rejected it before any extension was read.
42
+ - `createMemoryContentCache` defaults to 32 MiB and the IndexedDB cache to a 16 MiB decompressed
43
+ maximum, so a cache holds an extension of the largest default size.
44
+ - `migrateLegacyDataURL` accepts a Data URL long enough for a 16 MiB extension (it stopped at
45
+ 4,194,304 characters), and its base64 check is linear: the previous pattern overflowed the regex
46
+ stack on a payload of a few MiB and reported `INVALID_ENCODING`.
47
+
5
48
  ## 0.2.0
6
49
 
7
50
  A third source form for extensions whose bytes should be reachable both from a canonical published
package/README.ja.md CHANGED
@@ -15,7 +15,7 @@ npm install @kubohiroya/turbowarp-extension-loader
15
15
  - 拡張ごとの Embedded / Remote / Cached 解決、SHA-256 必須固定、UTF-8 検証、コードごと・全体のサイズ上限。
16
16
  - SHA-256 キーのキャッシュポート。読み出すたびに exact bytes を再検証し、キャッシュ利用時もホストの認可を要求します。
17
17
  - 明示的なホスト認可後、全拡張の検証・認可が揃ってから登録を始めます。登録 ID 不一致・登録失敗はホスト transaction の rollback を要求します。
18
- - キャンセル、取得・認可のタイムアウト、重複ロード拒否、unload、診断イベント。
18
+ - キャンセル、取得・認可のタイムアウト、Remote の一時的な失敗の有限回の再試行、重複ロード拒否、unload、診断イベント。
19
19
 
20
20
  標準拡張・汎用カスタム URL・従来の Data URL は読み替えません。Manifest がない作品ではホストポートを呼び出しません。作品内の `execution: "unsandboxed"` は要求値であり権限ではありません。`trusted` などの未知フィールドは拒否します。
21
21
 
@@ -67,14 +67,32 @@ await loader.unload();
67
67
  - core は JS を評価しません。評価権限と VM 内部 API はホストに限定します。
68
68
  - ロールバックはホストの拡張登録状態の保証です。一度評価した特権 JS の DOM / ネットワーク等の副作用を巻き戻すことはできません。安全なコードの承認は別途必要です。
69
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` を返す必要があります。ポート自体は信頼済みホストが実装する境界です。
70
+ - `createFetchPort()` はリダイレクト無効、credentials 不送信、referrer 不送信、ストリームの実サイズ制限を適用します。CORS / CSP / offline の fetch 拒否は `LOAD_FAILED` の `cause` に保持し、失敗した HTTP 応答はステータスも同じ `cause` に残します。ネットワーク層の詳細分類はcauseへ委ね、CORS/CSPはEditor側の実ブラウザーE2Eで検証します。
71
+ - injected fetch ポートは `finalURL` と明示的な空 `redirects` を返す必要があります。ポート自体は信頼済みホストが実装する境界であり、再試行もその境界に置いています(後述)。
72
72
  - 取得・認可は応答しないポートでもタイムアウトします。登録処理はキャンセル後も完了を待ってから rollback します。遅れて完了する登録と rollback の競合を避けるため、登録ポートが `AbortSignal` に応答しない場合、登録中の有限時間終了は保証できません。
73
73
  - キャッシュの容量・失効・永続化・出所ログはホストのポートが担当します。壊れたキャッシュを暗黙に無視して Remote へ切り替えません。
74
74
  - `./packager` はEmbedded / Remote変換、明示承認付きData URL移行、lock/license metadata保持、offline SB3準備を提供します。外部依存資源がある拡張は同梱を推測せず拒否します。署名・発行者鍵のpinningはホスト側で追加できる設計で、初期実装は内容・取得元に束縛したセッション承認です。
75
75
  - ZIP の実装は `createProjectIO({zip: JSZip})` に注入します。core の codec が ZIP central directory を直接検査し、重複・経路・宣言容量を検証してから、ストリーム展開の実容量と CRC32 を検査します。外部実装の archive port を使う場合も同等の検証が必要です。
76
76
 
77
- 既定値は最大64拡張、1拡張1 MiB、合計8 MiB、取得・認可30秒です。データURLやHTTPの開発モード例外はありません。標準 TurboWarp / 非対応エディターが独自 Manifest と ZIP エントリを破棄する可能性があるため、保持対応のないホストでの再保存は移行手段になりません。
77
+ 既定値は最大64拡張、1拡張16 MiB、合計64 MiB、取得・認可30秒です。1拡張と合計の既定値は project codec の1 entry と全展開量の上限に揃えてあり、作品に格納できる拡張ファイルは Loader でも読めます。ホストは loader options で下げられます。データURLやHTTPの開発モード例外はありません。標準 TurboWarp / 非対応エディターが独自 Manifest と ZIP エントリを破棄する可能性があるため、保持対応のないホストでの再保存は移行手段になりません。
78
+
79
+ ### Remote の一時的な失敗の再試行
80
+
81
+ 再試行は fetch ポートに置いています。`createFetchPort()` は自身を `withFetchRetry()` で包むため、既定ポートを使うホストは変更不要です。独自ポートを注入するホストも `withFetchRetry(hostPort)` で同じように包めます。core 自身の解決ループに再試行を置かない理由は2つあります。再試行は転送層の方針である一方、同じループの他の失敗(digest 不一致、壊れたキャッシュ、認可の拒否)は決して繰り返してはならないこと。そして core は任意のポートが投げたものを分類できないため、ここで再試行すると推測するか、既存の全ポートに新しいエラー契約を強いることになること。**その帰結として、core は注入されたポートの挙動を強制できません。** 独自ポートを実装するホストが自分で決めることになり、`./project-catalog` と `./project-authoring` は渡されたポートと同じだけ再試行します。
82
+
83
+ | 失敗 | 再試行する | 再試行しない |
84
+ | --- | --- | --- |
85
+ | HTTP 応答があった | 408 / 425 / 429 と 5xx 全般 | それ以外の 4xx。404 は「無い」という答え |
86
+ | 応答が無かった | 接続できなかった場合 | `AbortSignal` による中止。タイムアウトも含む |
87
+ | 要求の前 | — | URL・HTTP・資格情報・フラグメントの拒否(`INVALID_SOURCE`) |
88
+ | 応答の後 | — | リダイレクトと最終 URL の不一致(`FETCH_FAILED` / `REDIRECT_DENIED`)、サイズ上限(`INVALID_BYTES`)、digest 不一致、壊れたキャッシュ、認可の拒否 |
89
+ | キャッシュヒット | — | ハッシュ固定のエントリは CDN に接触しないため対象外 |
90
+
91
+ 分類は `isTransientFetchFailure(error)` として公開し、`createFetchPort()` が出す `FETCH_FAILED` は HTTP ステータスを `cause` に保持するので、包む側から読めます。ただし分類できない事象が1つあります。ブラウザーの `fetch()` は、接続できなかった場合・DNS 失敗・オフライン・CORS / CSP による拒否のいずれでも同一の `TypeError` を返します。仕様が意図して区別させていません。したがって CORS / CSP による拒否も、本来の対象である接続失敗と一緒に再試行されます。これが許容できるのは待ち時間の上限が小さいからであり、上限を小さくした理由でもあります。
92
+
93
+ **待ち時間は二重に制限されます。** `DEFAULT_FETCH_RETRY` は再試行2回、400 ms → 800 ms のバックオフで、最初の試行から5秒を超えて新しい試行を始めません。この上限はバックオフの待機も含むため、1回目が遅ければそれだけで使い切ります。その上に、取得・認可の既定タイムアウト30秒が操作全体を覆います。これは fetch 1件ごとのタイマーではなく、全拡張の取得・認可・登録を1つのタイマーで覆うものです。よって再試行でタイムアウトが倍になることはありません。30秒は本変更の前から最悪値であり、後も最悪値のままです。上限が決めるのは、その30秒のうち1つの URL が問い直しに使える分だけです。バックオフ待機は signal で中断され、待ち切ることはないので、キャンセルは即時のままです。`createFetchPort(fetchImpl, {retries: 0})` で従来の1回だけの挙動に戻せます。`onRetry` が各待機を通知するので、ホストは進捗を表示できます。
94
+
95
+ あきらめたら最後です。登録は全エントリの検証・認可が揃ってから始まるため、再試行を使い切ったエントリが1件あれば操作全体が失敗します。エントリは1件ずつ解決するので、その後のエントリは取得されません。操作の `AbortSignal` も中止するので、core がもう待っていないポートの処理は停止します。ホスト transaction は rollback し、元の作品が復元されます。そこから先は待っている人が再試行機構であり、ホストは作品を開き直すか拡張の追加をやり直すよう求めます。誰も見ていないため再試行で失敗を吸収しなければならないビルド時のカタログ生成とは、意図して方針を逆にしています。
78
96
 
79
97
  ## IndexedDB 永続キャッシュ(ブラウザホスト用)
80
98
 
@@ -90,13 +108,13 @@ import {createIndexedDBContentCache}
90
108
  const cache = createIndexedDBContentCache({
91
109
  databaseName: 'turbowarp-plus-extension-cache',
92
110
  maxBytes: 32 * 1024 * 1024, // compressed storage quota
93
- maxDecompressedBytes: 8 * 1024 * 1024
111
+ maxDecompressedBytes: 16 * 1024 * 1024
94
112
  });
95
113
  const loader = createExtensionLoader({...hostPorts, cache});
96
114
  // 終了・ログアウトなどの明示操作で await cache.clear() / await cache.close()
97
115
  ```
98
116
 
99
- - キーは lowercase SHA-256 hex。**保存する値は生JSではなく gzip 圧縮されたバイト列**(format: `gzip-v1`、元サイズを併記)。取得時に展開して Loader に返します。既定32 MiB は**圧縮後の総保存容量**、展開後の最大サイズは8 MiB、有効期限なし。容量超過時は最終アクセス順LRUで削除します。旧形式の非圧縮エントリは読み込まず削除します。
117
+ - キーは lowercase SHA-256 hex。**保存する値は生JSではなく gzip 圧縮されたバイト列**(format: `gzip-v1`、元サイズを併記)。取得時に展開して Loader に返します。既定32 MiB は**圧縮後の総保存容量**、展開後の最大サイズは16 MiB(1拡張の既定値)、有効期限なし。容量超過時は最終アクセス順LRUで削除します。旧形式の非圧縮エントリは読み込まず削除します。
100
118
  - 展開は出力サイズを制限し、改ざん・gzip破損や展開超過を拒否します。ブラウザの `CompressionStream` / `DecompressionStream` が必要です。非対応環境で生JSへ黙って切り替えて保存することはありません。
101
119
  - キャッシュを利用した際にも Loader コアが **取得バイト列の SHA-256 を再検証** し、毎回 trustProvider の権限判定を適用します。キャッシュが信頼判断を代行することはありません。
102
120
  - キャッシュは IndexedDB を使用できるブラウザ origin に紐付き、ブラウザのストレージ削除・プライベートモード・容量不足等で消える場合があります。ブラウザからの永続化保証はありません。
@@ -130,13 +148,14 @@ npm run check
130
148
 
131
149
  `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
150
 
133
- 未対応形式は ZIP64、multidisk、暗号化、STORE/DEFLATE 以外、UTF-8 以外の非 ASCII entry 名、Unicode path extra field です。既定上限は archive と全展開量64 MiB、1 entry16 MiB、project.json 4 MiB、4096 entry、圧縮比1000です。上限はホスト起動設定で変更できます。Manifest のない ZIP もこの安全検査を受け、通過後は元 input をそのまま返します。このため、従来 VM が許していた未対応 ZIP もフラグ有効時には拒否されます。フラグ OFF では codec 自体を呼びません。
151
+ 未対応形式は 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
152
 
135
153
  ## 追加のホスト向けAPI
136
154
 
137
155
  - `isAuthorizedCode(value)` はコアが発行し現在登録中の認可証跡だけを識別します。コピー・作品データによる偽造を拒否し、transaction終了時に失効します。
138
156
  - `./session-trust` の `createSessionTrustProvider` はid/digest/source/version/publisher/capabilities/dependenciesへ束縛した承認を保持します。明示された外部ホストのURL/execution方針をUI前に毎回適用し、判定できないEmbedded要求は拒否します。
139
- - `./memory-cache` の `createMemoryContentCache` はコピー・容量・TTL・LRUを提供し、コアはヒット時にも内容を再検証します。
157
+ - `./memory-cache` の `createMemoryContentCache` はコピー・容量(既定32 MiB、既定最大サイズの拡張2件分)・TTL・LRUを提供し、コアはヒット時にも内容を再検証します。
158
+ - `withFetchRetry(port, options)` は任意の fetch ポートを上記の有限回の再試行で包み、`isTransientFetchFailure(error)` はその分類器、`DEFAULT_FETCH_RETRY` は既定値(再試行2回、400 ms から倍々で上限2秒、合計5秒)です。
140
159
  - `./webpack4` はGUI/Packager用の同一コアをwebpack 4でビルドするための構成補助です。
141
160
 
142
161
  実GUI、実Packager 3.14.0の生成HTML、Desktop 1.17.0の互換性はEditor側のE2Eが担当します。標準Desktopや標準WebへLoaderを無断インストールしません。標準Desktopの保存は独自Manifest/ZIPエントリを破棄することを実測しているため、保持対応エディターで保存してください。
package/README.md CHANGED
@@ -15,7 +15,7 @@ npm install @kubohiroya/turbowarp-extension-loader
15
15
  - Per-extension Embedded / Remote / Cached resolution, a mandatory pinned SHA-256, UTF-8 validation, and per-code and total size limits.
16
16
  - A SHA-256-keyed cache port. Every read re-verifies the exact bytes, and a cache hit still requires host authorization.
17
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.
18
+ - Cancellation, fetch and authorization timeouts, a bounded retry of transient Remote failures, duplicate load rejection, unload, and diagnostic events.
19
19
 
20
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
21
 
@@ -67,14 +67,32 @@ The real VM adapter is owned by `scratch-gui/src/lib/tw-plus-extension-registrar
67
67
  - The core does not evaluate JS. Evaluation privileges and VM internal APIs stay with the host.
68
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
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.
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`, and a failed HTTP response keeps its status there. 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, which is where retrying lives as well; see below.
72
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
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
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
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.
76
76
 
77
- The defaults are at most 64 extensions, 1 MiB per extension, 8 MiB in total, and 30 seconds for fetching and authorization. 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.
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.
78
+
79
+ ### Retrying a transient Remote failure
80
+
81
+ Retrying lives at the fetch port. `createFetchPort()` wraps itself in `withFetchRetry()`, so a host on the default port needs no change, and a host that injects its own port can wrap it the same way with `withFetchRetry(hostPort)`. The core does not retry inside its own resolution loop, for two reasons: retrying is a transport policy, while the other failures in that loop — a digest mismatch, a corrupt cache entry, a trust refusal — must never be repeated; and the core cannot classify what an arbitrary port throws, so retrying there would either guess or force every existing port into a new error contract. **The consequence is that the core cannot impose this on an injected port.** A host that implements its own port decides for itself, and `./project-catalog` and `./project-authoring` retry exactly as much as the port they are handed does.
82
+
83
+ | Failure | Retried | Not retried |
84
+ | --- | --- | --- |
85
+ | An HTTP response | 408, 425, 429 and every 5xx | every other 4xx, 404 among them: the answer is that it is not there |
86
+ | No response at all | a connection that could not be made | an `AbortSignal` abort, the deadline included |
87
+ | Before the request | — | a rejected URL, HTTP, credentials or a fragment (`INVALID_SOURCE`) |
88
+ | After the response | — | a redirect or an unproven final URL (`FETCH_FAILED`, `REDIRECT_DENIED`), the byte limit (`INVALID_BYTES`), a digest mismatch, a corrupt cache entry, a trust refusal |
89
+ | A cache hit | — | a hash-pinned entry never contacts the CDN, so there is nothing to retry |
90
+
91
+ `isTransientFetchFailure(error)` is the exported classifier, and a `FETCH_FAILED` from `createFetchPort()` keeps the HTTP status in its `cause` so a wrapper can read it. One case cannot be classified: in a browser, `fetch()` rejects with the same `TypeError` whether the connection could not be made, DNS failed, the browser is offline, or CORS or CSP refused the request. That is deliberate in the specification. A CORS or CSP refusal is therefore retried along with the connection failure this was written for, which is affordable only because the budget is small — and is why it is small.
92
+
93
+ **The wait is bounded twice.** `DEFAULT_FETCH_RETRY` allows 2 retries, backing off 400 ms and then 800 ms, and starts no attempt more than 5 seconds after the first one; the budget covers the backoff, so a slow first attempt spends it. Above that, the 30-second fetch and authorization deadline still covers the whole operation — every extension's fetch, authorization and registration under one timer, not one timer per fetch. Retrying therefore does not multiply the deadline: 30 seconds was the worst case before this and remains the worst case after it, and the budget only decides how much of those 30 seconds one URL may spend asking again. A backoff wait is interrupted by the signal rather than waited out, so cancelling stays immediate. `createFetchPort(fetchImpl, {retries: 0})` restores the previous single attempt, and `onRetry` reports every wait so a host can show progress.
94
+
95
+ Giving up is final. Registration starts only once every entry is verified and authorized, so one entry whose retries are spent ends the whole operation: the entries after it are never fetched, because entries are resolved one at a time; the operation's `AbortSignal` is aborted so port work it no longer waits for stops; the host transaction rolls back and the original project is restored. From there the person waiting is the retry mechanism, and the host asks them to open the project or add the extension again. The asymmetry with build-time catalog generation, where nobody is watching and a retry has to absorb the failure, is deliberate.
78
96
 
79
97
  ## IndexedDB persistent cache (for browser hosts)
80
98
 
@@ -89,13 +107,13 @@ import {createIndexedDBContentCache}
89
107
  const cache = createIndexedDBContentCache({
90
108
  databaseName: 'turbowarp-plus-extension-cache',
91
109
  maxBytes: 32 * 1024 * 1024, // compressed storage quota
92
- maxDecompressedBytes: 8 * 1024 * 1024
110
+ maxDecompressedBytes: 16 * 1024 * 1024
93
111
  });
94
112
  const loader = createExtensionLoader({...hostPorts, cache});
95
113
  // On an explicit action such as exit or sign-out: await cache.clear() / await cache.close()
96
114
  ```
97
115
 
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 8 MiB, 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.
116
+ - 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
117
  - 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
118
  - 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
119
  - 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.
@@ -128,13 +146,14 @@ Wire the Loader inside a host feature flag that is fixed at startup and off by d
128
146
 
129
147
  `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`.
130
148
 
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, 4 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.
149
+ 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.
132
150
 
133
151
  ## Additional host-facing API
134
152
 
135
153
  - `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
154
  - `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, TTL and LRU, and the core re-verifies the content even on a hit.
155
+ - `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.
156
+ - `withFetchRetry(port, options)` wraps any fetch port in the bounded retry described above, `isTransientFetchFailure(error)` is the classifier it applies, and `DEFAULT_FETCH_RETRY` holds the defaults (2 retries, 400 ms of backoff doubling to a 2 s cap, a 5 s budget).
138
157
  - `./webpack4` is a configuration helper for building the same core with webpack 4 for the GUI and Packager.
139
158
 
140
159
  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.
package/dist/index.d.ts CHANGED
@@ -65,6 +65,20 @@ export interface FetchResult {
65
65
  export interface FetchPort {
66
66
  fetch(url: string, options: ReadOptions): Promise<FetchResult>;
67
67
  }
68
+ export interface FetchRetryOptions {
69
+ /** Attempts after the first one; 0 turns retrying off. */
70
+ retries?: number;
71
+ baseDelayMs?: number;
72
+ maxDelayMs?: number;
73
+ /** Wall clock since the first attempt; no further attempt starts once it is spent. */
74
+ budgetMs?: number;
75
+ onRetry?: (attempt: Readonly<{
76
+ url: string;
77
+ attempt: number;
78
+ delayMs: number;
79
+ error: unknown;
80
+ }>) => void;
81
+ }
68
82
  export interface RegistrationTransaction {
69
83
  register(code: AuthorizedCode, options: {
70
84
  signal: AbortSignal;
@@ -141,7 +155,54 @@ export declare class LoaderError extends Error {
141
155
  export declare function validateManifest(input: unknown, options?: Pick<LoaderOptions, 'maxExtensions'>): Manifest;
142
156
  export declare function discoverManifest(project: unknown, options?: Pick<LoaderOptions, 'maxExtensions'>): Manifest | null;
143
157
  export declare const parseManifest: typeof validateManifest;
144
- /** Browser fetch port rejects redirects and streams with a byte limit. */
145
- export declare function createFetchPort(fetchImpl?: typeof fetch): FetchPort;
158
+ /**
159
+ * The bounded retry the default fetch port applies.
160
+ *
161
+ * A load already has one deadline (`timeoutMs`, 30 seconds by default) covering every extension's
162
+ * fetch, authorization and registration, so retrying can never extend the wait a person already
163
+ * faces. This budget is the separate, tighter ceiling on how long retrying one URL may go on:
164
+ * 400 ms then 800 ms of backoff, and no attempt begins more than 5 seconds after the first one.
165
+ */
166
+ export declare const DEFAULT_FETCH_RETRY: Readonly<{
167
+ retries: 2;
168
+ baseDelayMs: 400;
169
+ maxDelayMs: 2000;
170
+ budgetMs: 5000;
171
+ }>;
172
+ /**
173
+ * Whether asking again could plausibly answer differently.
174
+ *
175
+ * A `FETCH_FAILED` carrying an HTTP status is an answer from the server, and only a request
176
+ * timeout, a too-early retry, a rate limit and a server-side error are worth repeating; 404 is the
177
+ * answer that it is not there. Every other `LoaderError` is a policy or validation refusal — a
178
+ * redirect, a byte limit, a rejected URL, a cancellation — and repeating it returns the refusal.
179
+ *
180
+ * A rejection that is not a `LoaderError` is a transport failure with no response at all. In a
181
+ * browser one `TypeError` covers a connection that could not be made, DNS failure and being
182
+ * offline, but also a CORS or CSP refusal: fetch deliberately makes them indistinguishable, so a
183
+ * permanent refusal is retried along with the connection failure this was written for. That is
184
+ * affordable only because the budget is small, and it is why the budget is small.
185
+ */
186
+ export declare function isTransientFetchFailure(error: unknown): boolean;
187
+ /**
188
+ * Retry a transient Remote failure a bounded number of times, then give the failure up.
189
+ *
190
+ * Retrying belongs to the fetch port and not to the loader's own loop: it is a transport policy,
191
+ * while the loop's other failures — a digest mismatch, a corrupt cache entry, a trust refusal —
192
+ * must never be repeated, and the core cannot classify what an arbitrary port throws. So the core
193
+ * cannot impose this on an injected port: a host that implements its own port keeps full control
194
+ * and gets no retry unless it asks, either by wrapping its port in this helper or by retrying
195
+ * inside it. `createFetchPort()` wraps itself, so a host on the default port needs no change.
196
+ *
197
+ * Giving up is final here. One entry that finally fails ends the whole operation, because
198
+ * registration starts only once every entry is verified and authorized; the person waiting is the
199
+ * retry mechanism from that point on, and the host asks them to try again.
200
+ */
201
+ export declare function withFetchRetry(port: FetchPort, options?: FetchRetryOptions): FetchPort;
202
+ /**
203
+ * Browser fetch port rejects redirects and streams with a byte limit, and retries a transient
204
+ * failure within `DEFAULT_FETCH_RETRY`. Pass `{retries: 0}` for a single attempt.
205
+ */
206
+ export declare function createFetchPort(fetchImpl?: typeof fetch, retry?: FetchRetryOptions): FetchPort;
146
207
  /** The host owns all ports, especially trust decisions and privileged execution. */
147
208
  export declare function createExtensionLoader(ports: LoaderPorts, options?: LoaderOptions): ExtensionLoader;
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
- const DEFAULTS = Object.freeze({ maxExtensions: 64, maxBytes: 1024 * 1024, maxTotalBytes: 8 * 1024 * 1024, timeoutMs: 30000 });
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}$/;
@@ -160,13 +161,114 @@ async function guarded(task, signal) {
160
161
  signal.removeEventListener('abort', abort);
161
162
  }
162
163
  }
163
- /** Browser fetch port rejects redirects and streams with a byte limit. */
164
- export function createFetchPort(fetchImpl = globalThis.fetch) {
164
+ /** A backoff wait the signal interrupts, so a cancelled load never sits out its remaining delay. */
165
+ function delay(ms, signal) {
166
+ checkSignal(signal);
167
+ return new Promise((resolve, reject) => {
168
+ let timer;
169
+ const abort = () => {
170
+ clearTimeout(timer);
171
+ try {
172
+ checkSignal(signal);
173
+ }
174
+ catch (error) {
175
+ reject(error);
176
+ }
177
+ };
178
+ timer = setTimeout(() => { signal.removeEventListener('abort', abort); resolve(); }, ms);
179
+ signal.addEventListener('abort', abort, { once: true });
180
+ });
181
+ }
182
+ /**
183
+ * The bounded retry the default fetch port applies.
184
+ *
185
+ * A load already has one deadline (`timeoutMs`, 30 seconds by default) covering every extension's
186
+ * fetch, authorization and registration, so retrying can never extend the wait a person already
187
+ * faces. This budget is the separate, tighter ceiling on how long retrying one URL may go on:
188
+ * 400 ms then 800 ms of backoff, and no attempt begins more than 5 seconds after the first one.
189
+ */
190
+ export const DEFAULT_FETCH_RETRY = Object.freeze({ retries: 2, baseDelayMs: 400, maxDelayMs: 2000, budgetMs: 5000 });
191
+ const transientStatus = (status) => status === 408 || status === 425 || status === 429 || status >= 500;
192
+ /**
193
+ * Whether asking again could plausibly answer differently.
194
+ *
195
+ * A `FETCH_FAILED` carrying an HTTP status is an answer from the server, and only a request
196
+ * timeout, a too-early retry, a rate limit and a server-side error are worth repeating; 404 is the
197
+ * answer that it is not there. Every other `LoaderError` is a policy or validation refusal — a
198
+ * redirect, a byte limit, a rejected URL, a cancellation — and repeating it returns the refusal.
199
+ *
200
+ * A rejection that is not a `LoaderError` is a transport failure with no response at all. In a
201
+ * browser one `TypeError` covers a connection that could not be made, DNS failure and being
202
+ * offline, but also a CORS or CSP refusal: fetch deliberately makes them indistinguishable, so a
203
+ * permanent refusal is retried along with the connection failure this was written for. That is
204
+ * affordable only because the budget is small, and it is why the budget is small.
205
+ */
206
+ export function isTransientFetchFailure(error) {
207
+ if (error instanceof LoaderError) {
208
+ const status = error.cause?.status;
209
+ return error.code === 'FETCH_FAILED' && typeof status === 'number' && transientStatus(status);
210
+ }
211
+ // An abort is the host's own decision or the deadline's, never something to ask again.
212
+ return !(error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError'));
213
+ }
214
+ /**
215
+ * Retry a transient Remote failure a bounded number of times, then give the failure up.
216
+ *
217
+ * Retrying belongs to the fetch port and not to the loader's own loop: it is a transport policy,
218
+ * while the loop's other failures — a digest mismatch, a corrupt cache entry, a trust refusal —
219
+ * must never be repeated, and the core cannot classify what an arbitrary port throws. So the core
220
+ * cannot impose this on an injected port: a host that implements its own port keeps full control
221
+ * and gets no retry unless it asks, either by wrapping its port in this helper or by retrying
222
+ * inside it. `createFetchPort()` wraps itself, so a host on the default port needs no change.
223
+ *
224
+ * Giving up is final here. One entry that finally fails ends the whole operation, because
225
+ * registration starts only once every entry is verified and authorized; the person waiting is the
226
+ * retry mechanism from that point on, and the host asks them to try again.
227
+ */
228
+ export function withFetchRetry(port, options = {}) {
229
+ if (typeof port?.fetch !== 'function')
230
+ fail('INVALID_PORT', 'A fetch port is required to retry');
231
+ const { retries, baseDelayMs, maxDelayMs, budgetMs } = { ...DEFAULT_FETCH_RETRY, ...options };
232
+ for (const [key, value] of Object.entries({ retries, baseDelayMs, maxDelayMs, budgetMs })) {
233
+ if (!Number.isSafeInteger(value) || value < 0)
234
+ fail('INVALID_OPTIONS', `Invalid ${key}`);
235
+ }
165
236
  return Object.freeze({
237
+ async fetch(url, readOptions) {
238
+ const started = Date.now();
239
+ for (let attempt = 0;; attempt++) {
240
+ try {
241
+ return await port.fetch(url, readOptions);
242
+ }
243
+ catch (error) {
244
+ const delayMs = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
245
+ // The budget covers the waiting too, so no attempt starts once it is spent.
246
+ if (attempt >= retries || Date.now() - started + delayMs > budgetMs || !isTransientFetchFailure(error))
247
+ throw error;
248
+ try {
249
+ options.onRetry?.(Object.freeze({ url, attempt: attempt + 1, delayMs, error }));
250
+ }
251
+ catch { /* Progress reporting must not change the outcome. */ }
252
+ await delay(delayMs, readOptions.signal);
253
+ }
254
+ }
255
+ }
256
+ });
257
+ }
258
+ /**
259
+ * Browser fetch port rejects redirects and streams with a byte limit, and retries a transient
260
+ * failure within `DEFAULT_FETCH_RETRY`. Pass `{retries: 0}` for a single attempt.
261
+ */
262
+ export function createFetchPort(fetchImpl = globalThis.fetch, retry = {}) {
263
+ return withFetchRetry(Object.freeze({
166
264
  async fetch(url, { signal, maxBytes }) {
167
265
  const response = await fetchImpl(remoteURL(url), { signal, redirect: 'error', credentials: 'omit', referrerPolicy: 'no-referrer' });
168
- if (!response.ok || response.redirected || response.type === 'opaque' || response.url !== remoteURL(url))
266
+ if (response.redirected || response.type === 'opaque' || response.url !== remoteURL(url))
169
267
  fail('FETCH_FAILED', 'Remote response is unavailable or redirected');
268
+ // The status is kept on the cause so a wrapper can tell a rate limit or a server-side
269
+ // error from an answer such as 404, which repeating would only ask for again.
270
+ if (!response.ok)
271
+ throw new LoaderError('FETCH_FAILED', `Remote response is unavailable (HTTP ${response.status})`, undefined, Object.freeze({ status: response.status }));
170
272
  const declared = response.headers.get('content-length');
171
273
  if (declared !== null && Number(declared) > maxBytes)
172
274
  fail('INVALID_BYTES', 'Remote extension exceeds byte limit');
@@ -202,7 +304,7 @@ export function createFetchPort(fetchImpl = globalThis.fetch) {
202
304
  }
203
305
  return { bytes, finalURL: response.url, redirects: [] };
204
306
  }
205
- });
307
+ }), retry);
206
308
  }
207
309
  /** The host owns all ports, especially trust decisions and privileged execution. */
208
310
  export function createExtensionLoader(ports, options = {}) {
@@ -340,6 +442,9 @@ export function createExtensionLoader(ports, options = {}) {
340
442
  }
341
443
  if (state !== 'failed')
342
444
  state = 'idle';
445
+ // Rollback is done, so nothing of this operation should still be running on its signal:
446
+ // stop the port work the core has already stopped waiting for, retries included.
447
+ controller.abort(error);
343
448
  report({ type: 'error', code: error.code, id: error.extensionId });
344
449
  throw error;
345
450
  }
@@ -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 8 MiB). */
6
+ /** Maximum decompressed JS size (default 16 MiB, the largest default extension). */
7
7
  maxDecompressedBytes?: number;
8
8
  now?: () => number;
9
9
  }
@@ -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 = 8 * 1024 * 1024, now = Date.now } = {}) {
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 ||
@@ -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;
@@ -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 = 8 * 1024 * 1024, ttlMs = 60 * 60 * 1000, now = Date.now } = {}) {
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)
@@ -118,7 +119,7 @@ export async function prepareOfflinePackage(input, ports, options = {}) {
118
119
  }
119
120
  /** Migrate a legacy Data URL only after the host explicitly pins and approves its bytes. */
120
121
  export async function migrateLegacyDataURL({ id, url, sha256, metadata }, ports, options = {}) {
121
- if (typeof url !== 'string' || url.length > 4 * 1024 * 1024)
122
+ if (typeof url !== 'string' || url.length > MAX_LEGACY_DATA_URL_LENGTH)
122
123
  fail('INVALID_SOURCE', 'Invalid or oversized legacy Data URL');
123
124
  const match = /^data:(?:application|text)\/(?:javascript|x-javascript)(?:;charset=utf-8)?(;base64)?;?,([\s\S]*)$/i.exec(url);
124
125
  if (!match)
@@ -126,7 +127,8 @@ export async function migrateLegacyDataURL({ id, url, sha256, metadata }, ports,
126
127
  let bytes;
127
128
  try {
128
129
  if (match[1]) {
129
- if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(match[2]))
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]))
130
132
  throw new Error('Invalid base64');
131
133
  bytes = Uint8Array.from(atob(match[2]), character => character.charCodeAt(0));
132
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: 1024 * 1024, timeoutMs: 30000, ...loaderOptions };
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))
@@ -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 = { maxExtensions: 64, maxBytes: 1024 * 1024, maxTotalBytes: 8 * 1024 * 1024,
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 ?? 64 * 1024 * 1024)) {
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);
@@ -167,6 +167,13 @@ export async function prepareCatalogExtensionChanges(input, requests, ports, { z
167
167
  check();
168
168
  return Object.freeze({ projectBytes, manifest });
169
169
  }
170
+ catch (error) {
171
+ // The selection is one operation: once an entry finally fails, nothing else about it is
172
+ // wanted, so stop the port work it started instead of leaving it running unobserved.
173
+ controller.abort(error instanceof LoaderError ? error :
174
+ new LoaderError('LOAD_FAILED', 'Catalog changes failed', undefined, error));
175
+ throw error;
176
+ }
170
177
  finally {
171
178
  clearTimeout(timer);
172
179
  externalSignal?.removeEventListener('abort', abort);
@@ -1,6 +1,7 @@
1
1
  import { discoverManifest, LoaderError } from './index.js';
2
- const DEFAULTS = Object.freeze({ maxArchiveBytes: 64 * 1024 * 1024, maxTotalBytes: 64 * 1024 * 1024,
3
- maxEntryBytes: 16 * 1024 * 1024, maxProjectBytes: 4 * 1024 * 1024, maxEntries: 4096, maxRatio: 1000 });
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); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubohiroya/turbowarp-extension-loader",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Host-independent verified TurboWarp Plus extension loading core",
5
5
  "keywords": [
6
6
  "turbowarp",