@kubohiroya/turbowarp-extension-loader 0.2.1 → 0.3.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 +33 -0
- package/README.ja.md +22 -3
- package/README.md +22 -3
- package/dist/index.d.ts +63 -2
- package/dist/index.js +108 -4
- package/dist/project-catalog.js +17 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.3.1
|
|
6
|
+
|
|
7
|
+
Released on 2026-11-02. One fix, the one 0.3.0 said would follow here: `./project-catalog` no
|
|
8
|
+
longer leaves behind the archive copy a replaced Embedded or Cached entry kept. No exported API
|
|
9
|
+
and no default changes, so a host needs nothing but the version bump.
|
|
10
|
+
|
|
11
|
+
- `./project-catalog` releases the archive copy a replaced Embedded or Cached entry kept. Switching
|
|
12
|
+
an extension to Remote left the old `extensions/…` file in the SB3 although no Manifest entry named
|
|
13
|
+
it any more. A copy the new entry still uses (Cached ↔ Embedded) stays.
|
|
14
|
+
|
|
15
|
+
## 0.3.0
|
|
16
|
+
|
|
17
|
+
Released on 2026-11-02. A bounded retry for Remote fetching, so one transient failure no longer
|
|
18
|
+
ends a whole load. The only change is the retry below; the fix for the copy a replaced Embedded or
|
|
19
|
+
Cached entry keeps is **not** in this release, because that work is still open — it follows in
|
|
20
|
+
0.3.1. New exported API and a changed default make this a minor release: a host that wants the
|
|
21
|
+
previous single attempt passes `{retries: 0}`.
|
|
22
|
+
|
|
23
|
+
- The fetch port retries a transient Remote failure a bounded number of times instead of failing on
|
|
24
|
+
the first one. `createFetchPort()` now wraps itself in the new `withFetchRetry(port, options)`, so
|
|
25
|
+
a host on the default port needs no change; a host that injects its own port can wrap it the same
|
|
26
|
+
way, and the core cannot impose the behaviour on it. Only a connection that could not be made and
|
|
27
|
+
an HTTP 408, 425, 429 or 5xx are repeated — `isTransientFetchFailure(error)` is the exported
|
|
28
|
+
classifier, and a `FETCH_FAILED` carries the HTTP status in its `cause`. `DEFAULT_FETCH_RETRY`
|
|
29
|
+
allows 2 retries, 400 ms of backoff doubling to a 2 s cap, and a 5 s budget measured from the
|
|
30
|
+
first attempt; the 30-second fetch and authorization deadline still covers the whole operation, so
|
|
31
|
+
the worst-case wait does not grow. A backoff wait is interrupted by the `AbortSignal` rather than
|
|
32
|
+
waited out, and `{retries: 0}` restores the previous single attempt.
|
|
33
|
+
- A load or a catalog preparation that finally fails now aborts its own `AbortSignal` before
|
|
34
|
+
reporting, so port work the core has stopped waiting for does not keep running unobserved. Every
|
|
35
|
+
entry is still verified and authorized before registration starts, and a failure still rolls the
|
|
36
|
+
host transaction back.
|
|
37
|
+
|
|
5
38
|
## 0.2.1
|
|
6
39
|
|
|
7
40
|
Default size limits that fit real extensions. A bundled story runtime of about 7 MiB was rejected in
|
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
|
-
-
|
|
18
|
+
- キャンセル、取得・認可のタイムアウト、Remote の一時的な失敗の有限回の再試行、重複ロード拒否、unload、診断イベント。
|
|
19
19
|
|
|
20
20
|
標準拡張・汎用カスタム URL・従来の Data URL は読み替えません。Manifest がない作品ではホストポートを呼び出しません。作品内の `execution: "unsandboxed"` は要求値であり権限ではありません。`trusted` などの未知フィールドは拒否します。
|
|
21
21
|
|
|
@@ -67,8 +67,8 @@ 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`
|
|
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はホスト側で追加できる設計で、初期実装は内容・取得元に束縛したセッション承認です。
|
|
@@ -76,6 +76,24 @@ await loader.unload();
|
|
|
76
76
|
|
|
77
77
|
既定値は最大64拡張、1拡張16 MiB、合計64 MiB、取得・認可30秒です。1拡張と合計の既定値は project codec の1 entry と全展開量の上限に揃えてあり、作品に格納できる拡張ファイルは Loader でも読めます。ホストは loader options で下げられます。データURLやHTTPの開発モード例外はありません。標準 TurboWarp / 非対応エディターが独自 Manifest と ZIP エントリを破棄する可能性があるため、保持対応のないホストでの再保存は移行手段になりません。
|
|
78
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 し、元の作品が復元されます。そこから先は待っている人が再試行機構であり、ホストは作品を開き直すか拡張の追加をやり直すよう求めます。誰も見ていないため再試行で失敗を吸収しなければならないビルド時のカタログ生成とは、意図して方針を逆にしています。
|
|
96
|
+
|
|
79
97
|
## IndexedDB 永続キャッシュ(ブラウザホスト用)
|
|
80
98
|
|
|
81
99
|
固定された npm `name@exact-version` のCDN配信を想定し、**cache first** とします。ハッシュで固定されたキャッシュがあればオンライン・オフラインを問わずCDNへ問い合わせません。キャッシュミス時のみ既存Remote Fetch Portが取得します。URLに `latest`、semver範囲、バージョン省略等の可変指定を使わないことをホスト側で保証してください。キャッシュの保持にはTTLを設けず、容量超過時のLRU、明示消去、ブラウザのストレージ削除のみで削除します。gzip展開後のSHA-256照合とunsandboxed実行認可は毎回必要です。
|
|
@@ -137,6 +155,7 @@ npm run check
|
|
|
137
155
|
- `isAuthorizedCode(value)` はコアが発行し現在登録中の認可証跡だけを識別します。コピー・作品データによる偽造を拒否し、transaction終了時に失効します。
|
|
138
156
|
- `./session-trust` の `createSessionTrustProvider` はid/digest/source/version/publisher/capabilities/dependenciesへ束縛した承認を保持します。明示された外部ホストのURL/execution方針をUI前に毎回適用し、判定できないEmbedded要求は拒否します。
|
|
139
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,8 +67,8 @@ 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
|
|
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.
|
|
@@ -76,6 +76,24 @@ The real VM adapter is owned by `scratch-gui/src/lib/tw-plus-extension-registrar
|
|
|
76
76
|
|
|
77
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
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.
|
|
96
|
+
|
|
79
97
|
## IndexedDB persistent cache (for browser hosts)
|
|
80
98
|
|
|
81
99
|
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.
|
|
@@ -135,6 +153,7 @@ Unsupported formats are ZIP64, multidisk, encryption, compression other than STO
|
|
|
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
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
|
-
/**
|
|
145
|
-
|
|
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
|
@@ -161,13 +161,114 @@ async function guarded(task, signal) {
|
|
|
161
161
|
signal.removeEventListener('abort', abort);
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
|
-
/**
|
|
165
|
-
|
|
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
|
+
}
|
|
166
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({
|
|
167
264
|
async fetch(url, { signal, maxBytes }) {
|
|
168
265
|
const response = await fetchImpl(remoteURL(url), { signal, redirect: 'error', credentials: 'omit', referrerPolicy: 'no-referrer' });
|
|
169
|
-
if (
|
|
266
|
+
if (response.redirected || response.type === 'opaque' || response.url !== remoteURL(url))
|
|
170
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 }));
|
|
171
272
|
const declared = response.headers.get('content-length');
|
|
172
273
|
if (declared !== null && Number(declared) > maxBytes)
|
|
173
274
|
fail('INVALID_BYTES', 'Remote extension exceeds byte limit');
|
|
@@ -203,7 +304,7 @@ export function createFetchPort(fetchImpl = globalThis.fetch) {
|
|
|
203
304
|
}
|
|
204
305
|
return { bytes, finalURL: response.url, redirects: [] };
|
|
205
306
|
}
|
|
206
|
-
});
|
|
307
|
+
}), retry);
|
|
207
308
|
}
|
|
208
309
|
/** The host owns all ports, especially trust decisions and privileged execution. */
|
|
209
310
|
export function createExtensionLoader(ports, options = {}) {
|
|
@@ -341,6 +442,9 @@ export function createExtensionLoader(ports, options = {}) {
|
|
|
341
442
|
}
|
|
342
443
|
if (state !== 'failed')
|
|
343
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);
|
|
344
448
|
report({ type: 'error', code: error.code, id: error.extensionId });
|
|
345
449
|
throw error;
|
|
346
450
|
}
|
package/dist/project-catalog.js
CHANGED
|
@@ -114,7 +114,12 @@ export async function prepareCatalogExtensionChanges(input, requests, ports, { z
|
|
|
114
114
|
fetched.set(url, bytes);
|
|
115
115
|
return bytes;
|
|
116
116
|
}
|
|
117
|
+
// Copies the replaced entries kept; released below unless a final entry still names them.
|
|
118
|
+
const replacedPaths = new Set();
|
|
117
119
|
for (const choice of choices) {
|
|
120
|
+
const replaced = prepared.manifest?.extensions.find(spec => spec.id === choice.id)?.source;
|
|
121
|
+
if (replaced && replaced.type !== 'remote')
|
|
122
|
+
replacedPaths.add(replaced.path);
|
|
118
123
|
const old = previous.get(choice.id);
|
|
119
124
|
// Every catalog request was validated as a Remote source above.
|
|
120
125
|
const bytes = old ? encoder.encode(old.code) : await fetchBytes(choice.source.url);
|
|
@@ -145,6 +150,11 @@ export async function prepareCatalogExtensionChanges(input, requests, ports, { z
|
|
|
145
150
|
capabilities: old?.capabilities || [], dependencies: choice.dependencies });
|
|
146
151
|
}
|
|
147
152
|
const manifest = validateManifest({ schemaVersion: 1, extensions: [...extensions.values()] }, loaderOptions);
|
|
153
|
+
for (const spec of manifest.extensions)
|
|
154
|
+
if (spec.source.type !== 'remote')
|
|
155
|
+
replacedPaths.delete(spec.source.path);
|
|
156
|
+
for (const path of replacedPaths)
|
|
157
|
+
delete files[path];
|
|
148
158
|
project.turboWarpPlus = manifest;
|
|
149
159
|
project.extensions = [...new Set([...(project.extensions || []), ...manifest.extensions.map(spec => spec.id)])];
|
|
150
160
|
const urls = { ...(project.extensionURLs || {}) };
|
|
@@ -167,6 +177,13 @@ export async function prepareCatalogExtensionChanges(input, requests, ports, { z
|
|
|
167
177
|
check();
|
|
168
178
|
return Object.freeze({ projectBytes, manifest });
|
|
169
179
|
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
// The selection is one operation: once an entry finally fails, nothing else about it is
|
|
182
|
+
// wanted, so stop the port work it started instead of leaving it running unobserved.
|
|
183
|
+
controller.abort(error instanceof LoaderError ? error :
|
|
184
|
+
new LoaderError('LOAD_FAILED', 'Catalog changes failed', undefined, error));
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
170
187
|
finally {
|
|
171
188
|
clearTimeout(timer);
|
|
172
189
|
externalSignal?.removeEventListener('abort', abort);
|