@kubohiroya/turbowarp-extension-loader 0.6.0 → 0.8.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
@@ -1,6 +1,37 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.8.0
4
+
5
+ Released on 2026-11-03. Two additions a host needs to give a project an address at save time and to keep one cache across
6
+ projects. Both are additive: the string form of `appURI`, every existing method and every default
7
+ behave as before.
8
+
9
+ - `createIndexedDBContentCache({appURI})` also accepts `() => string | undefined`, read at each
10
+ write like `sharedDigests`, so a host that keeps one cache while projects are reopened files each
11
+ write under the project open at that moment. `undefined` files the write as shared, as a missing
12
+ `appURI` always has. An `appURI` that is neither a string nor a function is now refused with
13
+ `Invalid IndexedDB cache options` instead of being used as a bucket name.
14
+ - `ProjectIO.assignProjectURI(context, uri, {replace})` in `./project-codec` returns a new context
15
+ whose Manifest carries `uri`, so a project whose Manifest has no address can be given one at its
16
+ first save and keep it on later saves. The address is checked like the `uri` option of `prepare*`
17
+ (`INVALID_PROJECT_URI`). An existing different address is refused with the new code
18
+ `PROJECT_URI_CONFLICT` unless `replace: true`; the same address returns the context unchanged.
19
+
20
+ ## 0.7.0
21
+
22
+ Released on 2026-11-03. A classifier a host needs before offering "try again" after a failed
23
+ operation, and the attempt total in retry reports. Both are additions: no error code, message or
24
+ default changes, so a host on 0.6.0 needs nothing but the version bump to keep working.
25
+
26
+ - `isWorthAskingAgain(error)` answers, for the rejection of a whole `load()`, `./project-catalog` or
27
+ `./project-authoring` operation, whether asking again could answer differently — the question a
28
+ host has before offering "try again". Unlike `isTransientFetchFailure`, which judges one transfer
29
+ inside a fetch port, it says yes to the browser `TypeError` an operation wraps in `LOAD_FAILED`
30
+ and to `TIMEOUT`, and no to `CANCELLED`, `ROLLBACK_FAILED`, any permanent refusal anywhere in an
31
+ `AggregateError` or `cause` chain, and a bare `TypeError` that did not come out of a fetch port.
32
+ The Loader now records which rejections a fetch port produced so it can tell them apart.
33
+ - `onRetry` also reports `attempts`, the most attempts the fetch can make in all, so a host can say
34
+ "attempt 2 of 3" without knowing the retry settings.
4
35
 
5
36
  ## 0.3.1
6
37
 
package/README.ja.md CHANGED
@@ -99,6 +99,8 @@ await loader.unload();
99
99
 
100
100
  あきらめたら最後です。登録は全エントリの検証・認可が揃ってから始まるため、再試行を使い切ったエントリが1件あれば操作全体が失敗します。エントリは1件ずつ解決するので、その後のエントリは取得されません。操作の `AbortSignal` も中止するので、core がもう待っていないポートの処理は停止します。ホスト transaction は rollback し、元の作品が復元されます。そこから先は待っている人が再試行機構であり、ホストは作品を開き直すか拡張の追加をやり直すよう求めます。誰も見ていないため再試行で失敗を吸収しなければならないビルド時のカタログ生成とは、意図して方針を逆にしています。
101
101
 
102
+ **「やり直す」を勧めるかどうかは別の問いで、判定も別です。** `isTransientFetchFailure(error)` は fetch ポートが転送1件を見て、自分で再試行するかを決めるためのものです。操作全体の失敗(`load()`、`./project-catalog`、`./project-authoring` の拒否)を受け取ったホストは、代わりに `isWorthAskingAgain(error)` に問います。いちばん多い経路で両者の答えは逆になります。ブラウザーの `TypeError` は `LOAD_FAILED` に包まれてホストに届き、前者は false、後者は true を返します。`isWorthAskingAgain` が true を返すのは、中の失敗がすべて次の試行で避けられうるものだけのときです。fetch ポートから出た転送の失敗(オフライン・DNS・CORS・CSP)、HTTP 408 / 425 / 429 / 5xx、操作の `TIMEOUT` がそれにあたります。問い直しても同じ拒否が返るもの(`DIGEST_MISMATCH`、`CACHE_INTEGRITY`、`REDIRECT_DENIED`、`INVALID_SOURCE`、`TRUST_DENIED`、`UNSUPPORTED_SCHEMA`、それ以外の HTTP 4xx とその他すべてのコード)は false です。利用者が押したキャンセルである `CANCELLED` も、本人の答えなので false です。`ROLLBACK_FAILED` はホストの復旧が先なので false です。説明できないものも false で、ホスト自身が投げた素の `TypeError` もこれにあたります。Loader は出どころによって転送の失敗と区別します。`AggregateError` や `cause` の連鎖のどこかに恒久的な拒否が1つでもあれば false です。また `onRetry` は全体で何回まで試すかを `attempts` で渡すので、ホストは再試行設定を知らなくても「3回中2回目」と出せます。
103
+
102
104
  ## IndexedDB 永続キャッシュ(ブラウザホスト用)
103
105
 
104
106
  固定された npm `name@exact-version` のCDN配信を想定し、**cache first** とします。ハッシュで固定されたキャッシュがあればオンライン・オフラインを問わずCDNへ問い合わせません。キャッシュミス時のみ既存Remote Fetch Portが取得します。URLに `latest`、semver範囲、バージョン省略等の可変指定を使わないことをホスト側で保証してください。キャッシュの保持にはTTLを設けず、容量超過時のLRU、明示消去、ブラウザのストレージ削除のみで削除します。gzip展開後のSHA-256照合とunsandboxed実行認可は毎回必要です。
@@ -149,6 +151,17 @@ await cache.deleteBucket(manifest.uri); // 手放す。他が参照してい
149
151
  ごとに読むので、カタログの読み込みに合わせて埋めていけます。どの作品を開いているか言えないホスト
150
152
  は、すべてを shared に入れます。名前を推測するのは、無いと認めるより悪いからです。
151
153
 
154
+ 作品を開き直しても1つのキャッシュを使い続けるホスト(たとえば VM ごとに1つ)は、`appURI` に
155
+ 関数を渡します。`sharedDigests` と同じく書き込みごと(`put` を呼んだとき)に読むので、各書き込みは
156
+ その時点で開いている作品の下に入り、関数が `undefined` を返した書き込みは shared に入ります。
157
+ 文字列を渡せば、これまでどおりキャッシュの寿命の間ずっと1つの作品を指します。
158
+
159
+ ```js
160
+ let openProject; // いま開いている作品の URI。無ければ undefined
161
+ const cache = createIndexedDBContentCache({appURI: () => openProject, sharedDigests: catalogDigests});
162
+ // 読み込むたびに: openProject = prepared.manifest?.uri;
163
+ ```
164
+
152
165
  バケットを手放すと claim が落ち、**他が参照していない**バイト列だけが回収されます。2回参照されて
153
166
  いる1つの実体は残ります。もう一方の作品は失うことを求めていません。容量超過で追い出すときは
154
167
  claim も一緒に落ちるので、バケットが「もう無いもの」を並べることはありません。
@@ -226,18 +239,20 @@ Manifest は `uri` を持てます。その作品をダウンロードできる
226
239
  `prepareCatalogExtensionChanges` と `prepareExtensionAddition` は `uri` を受け取ります。作品が
227
240
  最初の住所を得るのも、作者が選んだ住所へ移るのも、この経路です。何も言わなければ作品が持って
228
241
  いる住所がそのまま残り、`readProjectAuthoringState` がその住所を報告するので、ホストは初回保存
229
- かどうかを決める前に見分けられます。割り当てるかどうかはホストの判断です。作者が住所を選んだ
242
+ かどうかを決める前に見分けられます。`./project-codec` で開いた作品の Manifest に住所が
243
+ 無ければ、保存のときに `assignProjectURI` で与えます(「SB3 codec」を参照)。割り当てるかどうかはホストの判断です。作者が住所を選んだ
230
244
  のか、既定が必要な初回保存なのかを知っているのはホストだけだからです。
231
245
 
232
246
  `./project-authoring` の `createProjectUUID` と `defaultProjectURI` が、作者が住所を選んで
233
247
  いないときに与える住所を組みます。
234
248
 
235
249
  ```js
236
- const uri = defaultProjectURI(location.origin + location.pathname, createProjectUUID());
250
+ const uri = defaultProjectURI(location.origin, createProjectUUID());
237
251
  ```
238
252
 
239
- オリジンはエディター自身のもので、配信していない host を名乗ることはありません。この住所は作品
240
- を人に渡すとき SB3 の中に入って一緒に移動するからです。識別子は UUID version 7 なので、初回保存
253
+ オリジンはエディターをブラウザーで起動したドメインのもので、パスは付けません(`editor.html` の
254
+ パスを付けると、住所がその下にできてしまいます)。配信していない host を名乗ることはありません。
255
+ この住所は作品を人に渡すとき SB3 の中に入って一緒に移動するからです。識別子は UUID version 7 なので、初回保存
241
256
  の時刻順に並びます。2段のディレクトリは UUID の**末尾**から取ります。version 7 の先頭はミリ秒
242
257
  時計の上位で、ほとんど動かないので、先頭でシャーディングすると**作られた全作品が1つの
243
258
  ディレクトリに入ります**。
@@ -278,6 +293,22 @@ npm run check
278
293
 
279
294
  `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` を復元済みであることを前提とします。
280
295
 
296
+ `assignProjectURI(context, uri, {replace})` は、Manifest が `uri` を持つ新しい context を返し、
297
+ 以後の restore に使います。Manifest に住所の無い作品は、これで最初の保存のときに住所を得ます。
298
+ ホストが返された context を保てば、その後の保存でも同じ住所が入ります。住所は `prepare*` の
299
+ `uri` と同じ規則で検証し(HTTPS で、資格情報もフラグメントも無いこと。違えば
300
+ `INVALID_PROJECT_URI`)、正規化した形で記録します。**作品が既に持っている住所は、
301
+ `replace: true` と言わない限り置き換えません**。違う住所は `PROJECT_URI_CONFLICT` で拒否し、
302
+ 同じ住所なら context をそのまま返します。渡した context は変わらず有効で、`null` の context
303
+ (Manifest が無い)は住所を載せる先が無いので拒否します。
304
+
305
+ ```js
306
+ let saved = prepared.context;
307
+ // 保存のとき、prepared.manifest が uri を持たない場合だけ:
308
+ if (saved && !prepared.manifest.uri) saved = io.assignProjectURI(saved, defaultProjectURI(location.origin, createProjectUUID()));
309
+ io.restoreJSON(json, saved);
310
+ ```
311
+
281
312
  未対応形式は 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 自体を呼びません。
282
313
 
283
314
  ## 追加のホスト向けAPI
@@ -285,7 +316,7 @@ npm run check
285
316
  - `isAuthorizedCode(value)` はコアが発行し現在登録中の認可証跡だけを識別します。コピー・作品データによる偽造を拒否し、transaction終了時に失効します。
286
317
  - `./session-trust` の `createSessionTrustProvider` はid/digest/source/version/publisher/capabilities/dependenciesへ束縛した承認を保持します。明示された外部ホストのURL/execution方針をUI前に毎回適用し、判定できないEmbedded要求は拒否します。
287
318
  - `./memory-cache` の `createMemoryContentCache` はコピー・容量(既定32 MiB、既定最大サイズの拡張2件分)・TTL・LRUを提供し、コアはヒット時にも内容を再検証します。
288
- - `withFetchRetry(port, options)` は任意の fetch ポートを上記の有限回の再試行で包み、`isTransientFetchFailure(error)` はその分類器、`DEFAULT_FETCH_RETRY` は既定値(再試行2回、400 ms から倍々で上限2秒、合計5秒)です。
319
+ - `withFetchRetry(port, options)` は任意の fetch ポートを上記の有限回の再試行で包み、`isTransientFetchFailure(error)` はその分類器、`isWorthAskingAgain(error)` は失敗した操作を受け取ったホストにやり直しを勧めるべきかを答える判定、`DEFAULT_FETCH_RETRY` は既定値(再試行2回、400 ms から倍々で上限2秒、合計5秒)です。
289
320
  - `./webpack4` はGUI/Packager用の同一コアをwebpack 4でビルドするための構成補助です。
290
321
 
291
322
  実GUI、実Packager 3.14.0の生成HTML、Desktop 1.17.0の互換性はEditor側のE2Eが担当します。標準Desktopや標準WebへLoaderを無断インストールしません。標準Desktopの保存は独自Manifest/ZIPエントリを破棄することを実測しているため、保持対応エディターで保存してください。
package/README.md CHANGED
@@ -99,6 +99,8 @@ Retrying lives at the fetch port. `createFetchPort()` wraps itself in `withFetch
99
99
 
100
100
  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.
101
101
 
102
+ **Whether to offer "try again" is a separate question, with a separate classifier.** `isTransientFetchFailure(error)` is for a fetch port looking at one transfer, deciding whether to retry by itself. A host holding the rejection of a whole operation — `load()`, `./project-catalog` or `./project-authoring` — asks `isWorthAskingAgain(error)` instead, and the two differ on the most common case: a browser `TypeError` reaches the host wrapped in `LOAD_FAILED`, which the first classifier answers no to and the second answers yes. `isWorthAskingAgain` says yes only when every failure inside is one a later attempt could avoid: a transport failure that came out of a fetch port (offline, DNS, CORS, CSP), an HTTP 408, 425, 429 or 5xx, or the operation's `TIMEOUT`. It says no for a refusal asking again would repeat (`DIGEST_MISMATCH`, `CACHE_INTEGRITY`, `REDIRECT_DENIED`, `INVALID_SOURCE`, `TRUST_DENIED`, `UNSUPPORTED_SCHEMA`, any other HTTP 4xx and every other code), for `CANCELLED`, because a person cancelling is their own answer, for `ROLLBACK_FAILED`, because the host must recover first, and for anything it cannot account for — including a bare `TypeError` the host itself threw, which the Loader tells apart from a transport failure by where it came from. One permanent refusal anywhere in an `AggregateError` or a `cause` chain makes the answer no. `onRetry` also reports `attempts`, the most a fetch can make in all, so a host can say "attempt 2 of 3" without knowing the retry settings.
103
+
102
104
  ## IndexedDB persistent cache (for browser hosts)
103
105
 
104
106
  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.
@@ -150,6 +152,17 @@ is read at each write, so a host may keep filling it as its catalog loads. A hos
150
152
  which project it is opening files everything as shared, because guessing a name is worse than
151
153
  admitting there is none.
152
154
 
155
+ A host that keeps one cache while projects are opened and closed — one per VM, say — passes
156
+ `appURI` as a function instead. Like `sharedDigests`, it is read at each write (when `put` is
157
+ called), so each write is filed under the project open at that moment, and a function that returns
158
+ `undefined` files that write as shared. A string still names one project for the cache's lifetime.
159
+
160
+ ```js
161
+ let openProject; // the URI of the project open now, or undefined
162
+ const cache = createIndexedDBContentCache({appURI: () => openProject, sharedDigests: catalogDigests});
163
+ // on each load: openProject = prepared.manifest?.uri;
164
+ ```
165
+
153
166
  Releasing a bucket drops its claims and reclaims only the bytes **nothing else refers to**. One
154
167
  copy referred to twice stays: the other project did not ask to lose it. An eviction takes the
155
168
  claims on it as well, so a bucket never lists bytes that are gone.
@@ -229,7 +242,9 @@ do.
229
242
  `prepareCatalogExtensionChanges` and `prepareExtensionAddition` take a `uri` option, which is how
230
243
  a project gets its first address and how it moves to one the author chose. Saying nothing keeps
231
244
  the address the project already has, and `readProjectAuthoringState` reports that address, so a
232
- host can tell a first save from a later one before deciding. Whether to assign one is the host's
245
+ host can tell a first save from a later one before deciding. A project opened through
246
+ `./project-codec` whose Manifest has no address is given one at save time with
247
+ `assignProjectURI` (see "SB3 codec"). Whether to assign one is the host's
233
248
  call: only the host knows whether the author chose an address or whether this is a first save
234
249
  that needs a default.
235
250
 
@@ -237,11 +252,12 @@ that needs a default.
237
252
  is given when its author has not chosen one:
238
253
 
239
254
  ```js
240
- const uri = defaultProjectURI(location.origin + location.pathname, createProjectUUID());
255
+ const uri = defaultProjectURI(location.origin, createProjectUUID());
241
256
  ```
242
257
 
243
- The origin is the editor's own, never a host it does not serve from, because this address travels
244
- inside the SB3 when the work is handed on. The identifier is a UUID version 7, so it sorts by when
258
+ The origin is that of the domain the editor was opened at, with no path (passing the path of
259
+ `editor.html` would put the address under it). It is never a host the editor does not serve from,
260
+ because this address travels inside the SB3 when the work is handed on. The identifier is a UUID version 7, so it sorts by when
245
261
  the project was first saved, and the two directory levels are taken from its **end** — a version 7
246
262
  UUID begins with the top of a millisecond clock, which barely moves, so sharding on its first
247
263
  characters would put every project ever created in one directory.
@@ -283,6 +299,23 @@ Wire the Loader inside a host feature flag that is fixed at startup and off by d
283
299
 
284
300
  `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`.
285
301
 
302
+ `assignProjectURI(context, uri, {replace})` returns a new context whose Manifest carries `uri`, for
303
+ the restore calls that follow. It is how a project whose Manifest has no address gets one when it is
304
+ first saved: the host keeps the context it returns, so every later save carries the same address.
305
+ The address follows the same rule as the `uri` option of `prepare*` (`INVALID_PROJECT_URI` unless
306
+ HTTPS without credentials or fragment) and is recorded normalized. **An address the project already
307
+ has is never replaced unless `replace: true` says so**: a different one is refused with
308
+ `PROJECT_URI_CONFLICT`, and the same one returns the context unchanged. The context passed in stays
309
+ valid and unchanged, and a `null` context (no Manifest) is refused, since there is nothing to carry
310
+ the address.
311
+
312
+ ```js
313
+ let saved = prepared.context;
314
+ // at save time, only when prepared.manifest has no uri:
315
+ if (saved && !prepared.manifest.uri) saved = io.assignProjectURI(saved, defaultProjectURI(location.origin, createProjectUUID()));
316
+ io.restoreJSON(json, saved);
317
+ ```
318
+
286
319
  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.
287
320
 
288
321
  ## Additional host-facing API
@@ -290,7 +323,7 @@ Unsupported formats are ZIP64, multidisk, encryption, compression other than STO
290
323
  - `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.
291
324
  - `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.
292
325
  - `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.
293
- - `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).
326
+ - `withFetchRetry(port, options)` wraps any fetch port in the bounded retry described above, `isTransientFetchFailure(error)` is the classifier it applies, `isWorthAskingAgain(error)` tells a host holding a failed operation whether to offer trying again, and `DEFAULT_FETCH_RETRY` holds the defaults (2 retries, 400 ms of backoff doubling to a 2 s cap, a 5 s budget).
294
327
  - `./webpack4` is a configuration helper for building the same core with webpack 4 for the GUI and Packager.
295
328
 
296
329
  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.
@@ -0,0 +1,3 @@
1
+ /** Record that a fetch port rejected with this value; a primitive cannot be recorded. */
2
+ export declare function markFetchRejection(error: unknown): void;
3
+ export declare const isFetchRejection: (error: unknown) => boolean;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Which rejections came out of a fetch port.
3
+ *
4
+ * A browser reports a transport failure as a bare `TypeError`, and so does any host code with a
5
+ * bug in it. Only where the rejection came from tells them apart, so every place the Loader calls a
6
+ * fetch port records what that port rejected with. Internal: not part of the package exports.
7
+ */
8
+ const rejections = new WeakSet();
9
+ /** Record that a fetch port rejected with this value; a primitive cannot be recorded. */
10
+ export function markFetchRejection(error) {
11
+ if (error !== null && typeof error === 'object')
12
+ rejections.add(error);
13
+ }
14
+ export const isFetchRejection = (error) => error !== null && typeof error === 'object' && rejections.has(error);
package/dist/index.d.ts CHANGED
@@ -135,9 +135,15 @@ export interface FetchRetryOptions {
135
135
  maxDelayMs?: number;
136
136
  /** Wall clock since the first attempt; no further attempt starts once it is spent. */
137
137
  budgetMs?: number;
138
+ /**
139
+ * Called before each backoff wait. `attempt` is the retry about to be waited for (1 for the
140
+ * first), and `attempts` the most this fetch can make in all, the first one included, so a host
141
+ * can say "attempt 2 of 3" without knowing the retry settings. The budget may end it sooner.
142
+ */
138
143
  onRetry?: (attempt: Readonly<{
139
144
  url: string;
140
145
  attempt: number;
146
+ attempts: number;
141
147
  delayMs: number;
142
148
  error: unknown;
143
149
  }>) => void;
@@ -254,6 +260,24 @@ export declare const DEFAULT_FETCH_RETRY: Readonly<{
254
260
  * affordable only because the budget is small, and it is why the budget is small.
255
261
  */
256
262
  export declare function isTransientFetchFailure(error: unknown): boolean;
263
+ /**
264
+ * Whether a whole operation that failed could answer differently if the person asked again.
265
+ *
266
+ * This is for the host holding the rejection of `load()`, `./project-catalog` or
267
+ * `./project-authoring` — the moment to decide whether to offer "try again". It is a different
268
+ * question from `isTransientFetchFailure`, which a fetch port asks about one transfer to decide
269
+ * whether to retry by itself, and which says no to the wrapper an operation rejects with.
270
+ *
271
+ * Yes when every failure inside is one a later attempt could avoid: a transport failure a fetch
272
+ * port rejected with (offline, DNS, CORS, CSP — a browser cannot tell them apart), an HTTP 408, 425,
273
+ * 429 or 5xx, or the operation's deadline. No when anything inside is a refusal asking again would
274
+ * repeat — a digest mismatch, a corrupt cache entry, a redirect, an invalid source or manifest, a
275
+ * trust refusal, any other HTTP 4xx — when the person cancelled, which is their own answer, when
276
+ * rollback failed and the host must recover first, and for anything the Loader cannot account for,
277
+ * including a bare `TypeError` that did not come out of a fetch port. One permanent refusal in an
278
+ * `AggregateError` or a `cause` chain outweighs any number of transient ones.
279
+ */
280
+ export declare function isWorthAskingAgain(error: unknown): boolean;
257
281
  /**
258
282
  * Retry a transient Remote failure a bounded number of times, then give the failure up.
259
283
  *
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { DEFAULT_LOADER_LIMITS } from './limits.js';
2
+ import { isFetchRejection, markFetchRejection } from './fetch-rejection.js';
2
3
  /** Whether the project holds a reference the loader has to fetch, rather than the bytes. */
3
4
  export const isRemoteSource = (source) => source.type === 'remote-non-cacheable' || source.type === 'remote-cacheable';
4
5
  const DEFAULTS = DEFAULT_LOADER_LIMITS;
@@ -237,6 +238,59 @@ export function isTransientFetchFailure(error) {
237
238
  // An abort is the host's own decision or the deadline's, never something to ask again.
238
239
  return !(error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError'));
239
240
  }
241
+ function worthAskingAgain(error, depth, seen) {
242
+ // `seen` holds only the path down to here: the same error twice side by side is not a cycle.
243
+ if (depth > 8 || seen.has(error))
244
+ return false;
245
+ seen.add(error);
246
+ try {
247
+ return classifyForAskingAgain(error, depth, seen);
248
+ }
249
+ finally {
250
+ seen.delete(error);
251
+ }
252
+ }
253
+ function classifyForAskingAgain(error, depth, seen) {
254
+ if (error instanceof LoaderError) {
255
+ // The deadline is not anyone's answer: the network, a server or a slow person may be faster next time.
256
+ if (error.code === 'TIMEOUT')
257
+ return true;
258
+ if (error.code === 'FETCH_FAILED')
259
+ return isTransientFetchFailure(error);
260
+ // A wrapper says nothing by itself; what it wraps decides, and with nothing wrapped it is unaccounted for.
261
+ if (error.code === 'LOAD_FAILED')
262
+ return error.cause !== undefined && worthAskingAgain(error.cause, depth + 1, seen);
263
+ // Every other code is a refusal asking again would repeat, the person's own cancellation, or
264
+ // a failed rollback the host must recover from first.
265
+ return false;
266
+ }
267
+ if (error instanceof AggregateError) {
268
+ const inner = Array.isArray(error.errors) ? error.errors : [];
269
+ return inner.length > 0 && inner.every(item => worthAskingAgain(item, depth + 1, seen));
270
+ }
271
+ // A bare rejection counts only when a fetch port produced it; the same TypeError from host code is a bug.
272
+ return isFetchRejection(error) && isTransientFetchFailure(error);
273
+ }
274
+ /**
275
+ * Whether a whole operation that failed could answer differently if the person asked again.
276
+ *
277
+ * This is for the host holding the rejection of `load()`, `./project-catalog` or
278
+ * `./project-authoring` — the moment to decide whether to offer "try again". It is a different
279
+ * question from `isTransientFetchFailure`, which a fetch port asks about one transfer to decide
280
+ * whether to retry by itself, and which says no to the wrapper an operation rejects with.
281
+ *
282
+ * Yes when every failure inside is one a later attempt could avoid: a transport failure a fetch
283
+ * port rejected with (offline, DNS, CORS, CSP — a browser cannot tell them apart), an HTTP 408, 425,
284
+ * 429 or 5xx, or the operation's deadline. No when anything inside is a refusal asking again would
285
+ * repeat — a digest mismatch, a corrupt cache entry, a redirect, an invalid source or manifest, a
286
+ * trust refusal, any other HTTP 4xx — when the person cancelled, which is their own answer, when
287
+ * rollback failed and the host must recover first, and for anything the Loader cannot account for,
288
+ * including a bare `TypeError` that did not come out of a fetch port. One permanent refusal in an
289
+ * `AggregateError` or a `cause` chain outweighs any number of transient ones.
290
+ */
291
+ export function isWorthAskingAgain(error) {
292
+ return worthAskingAgain(error, 0, new Set());
293
+ }
240
294
  /**
241
295
  * Retry a transient Remote failure a bounded number of times, then give the failure up.
242
296
  *
@@ -272,7 +326,7 @@ export function withFetchRetry(port, options = {}) {
272
326
  if (attempt >= retries || Date.now() - started + delayMs > budgetMs || !isTransientFetchFailure(error))
273
327
  throw error;
274
328
  try {
275
- options.onRetry?.(Object.freeze({ url, attempt: attempt + 1, delayMs, error }));
329
+ options.onRetry?.(Object.freeze({ url, attempt: attempt + 1, attempts: retries + 1, delayMs, error }));
276
330
  }
277
331
  catch { /* Progress reporting must not change the outcome. */ }
278
332
  await delay(delayMs, readOptions.signal);
@@ -391,7 +445,8 @@ export function createExtensionLoader(ports, options = {}) {
391
445
  if (!bytes && url !== undefined) {
392
446
  if (typeof ports.fetcher?.fetch !== 'function')
393
447
  fail('INVALID_PORT', 'Remote fetch port is unavailable', spec.id);
394
- const response = await guarded(() => ports.fetcher.fetch(url, { signal, maxBytes: limits.maxBytes }), signal);
448
+ const response = await guarded(() => Promise.resolve().then(() => ports.fetcher.fetch(url, { signal, maxBytes: limits.maxBytes }))
449
+ .catch((error) => { markFetchRejection(error); throw error; }), signal);
395
450
  if (!response || !Array.isArray(response.redirects) || response.redirects.length || response.finalURL !== url)
396
451
  fail('REDIRECT_DENIED', 'Unproven or redirected remote source', spec.id);
397
452
  bytes = snapshotBytes(response.bytes, limits.maxBytes, spec.id);
@@ -30,8 +30,12 @@ export interface IndexedDBContentCacheOptions {
30
30
  * Without one, everything is filed as shared: a host that cannot say which project it is
31
31
  * opening cannot have per-project accounting, and guessing a name would be worse than
32
32
  * admitting there is none.
33
+ *
34
+ * A host that keeps one cache while projects are opened and closed passes a function instead:
35
+ * it is read at the time of each write, like `sharedDigests`, so each write is filed under the
36
+ * project open at that moment. A function that returns `undefined` files that write as shared.
33
37
  */
34
- appURI?: string;
38
+ appURI?: string | (() => string | undefined);
35
39
  /**
36
40
  * Digests that belong to no one project, read at the time of each write.
37
41
  *
@@ -17,8 +17,17 @@ export function createIndexedDBContentCache({ indexedDB = globalThis.indexedDB,
17
17
  throw new Error('IndexedDB unavailable');
18
18
  if (typeof databaseName !== 'string' || !databaseName || !Number.isSafeInteger(maxBytes) || maxBytes < 1 ||
19
19
  !Number.isSafeInteger(maxDecompressedBytes) || maxDecompressedBytes < 1 ||
20
- typeof now !== 'function')
20
+ typeof now !== 'function' ||
21
+ (appURI !== undefined && typeof appURI !== 'string' && typeof appURI !== 'function')) {
21
22
  throw new TypeError('Invalid IndexedDB cache options');
23
+ }
24
+ /** The project a write is filed under, read when it happens. */
25
+ function currentAppURI() {
26
+ const uri = typeof appURI === 'function' ? appURI() : appURI;
27
+ if (uri !== undefined && typeof uri !== 'string')
28
+ throw new TypeError('appURI must return a string or undefined');
29
+ return uri;
30
+ }
22
31
  let dbPromise;
23
32
  function open() {
24
33
  if (!dbPromise) {
@@ -192,12 +201,15 @@ export function createIndexedDBContentCache({ indexedDB = globalThis.indexedDB,
192
201
  throw signal.reason;
193
202
  if (bytes.byteLength > maxDecompressedBytes)
194
203
  throw new RangeError('Cache input exceeds decompressed size limit');
204
+ // Read when put() is called, not after compressing: the project open now is the one
205
+ // these bytes were fetched for, even if another is opened before the write lands.
206
+ const uri = currentAppURI();
195
207
  const originalBytes = bytes.byteLength;
196
208
  const compressed = await gzipBytes(bytes);
197
209
  if (signal?.aborted)
198
210
  throw signal.reason;
199
211
  const db = await open();
200
- const bucket = appURI === undefined || sharedDigests?.has(key) ? SHARED_BUCKET : appURI;
212
+ const bucket = uri === undefined || sharedDigests?.has(key) ? SHARED_BUCKET : uri;
201
213
  return transaction(db, 'readwrite', (store, _set, tx) => {
202
214
  const usage = tx.objectStore('usage');
203
215
  const time = now();
@@ -47,8 +47,9 @@ export declare const PROJECT_URI_PREFIX = "turbowarp-plus-projects";
47
47
  * A fresh identifier for one project: a UUID version 7.
48
48
  *
49
49
  * Version 7 and not 4 because the first 48 bits are the millisecond clock, so the identifier
50
- * itself sorts by when the project was first saved. That costs nothing over a random UUID and
51
- * leaves the ordering available to anything that wants it.
50
+ * itself sorts by when the project was first saved, to the millisecond. That costs nothing over a
51
+ * random UUID and leaves the ordering available to anything that wants it. Two identifiers made in
52
+ * the same millisecond are ordered by their random bits, not by which came first.
52
53
  * @returns {string} The UUID, in the canonical lowercase form.
53
54
  */
54
55
  export declare function createProjectUUID(): string;
@@ -3,6 +3,7 @@ import { createProjectIO } from './project-codec.js';
3
3
  import { readPreparedProject } from './project-data.js';
4
4
  import { collectVerifiedCodes } from './verified-code.js';
5
5
  import { DEFAULT_LOADER_LIMITS } from './limits.js';
6
+ import { markFetchRejection } from './fetch-rejection.js';
6
7
  const encoder = new TextEncoder();
7
8
  function fail(code, message, id) { throw new LoaderError(code, message, id); }
8
9
  const plainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value) &&
@@ -119,6 +120,7 @@ export async function prepareExtensionAddition(input, request, ports, { zip: Zip
119
120
  response = await wait(() => ports.fetcher.fetch(url, { signal, maxBytes: limits.maxBytes }));
120
121
  }
121
122
  catch (cause) {
123
+ markFetchRejection(cause);
122
124
  if (cause instanceof LoaderError)
123
125
  throw cause;
124
126
  throw new LoaderError('LOAD_FAILED', 'Remote extension could not be fetched', draft.id, cause);
@@ -171,8 +173,9 @@ export const PROJECT_URI_PREFIX = 'turbowarp-plus-projects';
171
173
  * A fresh identifier for one project: a UUID version 7.
172
174
  *
173
175
  * Version 7 and not 4 because the first 48 bits are the millisecond clock, so the identifier
174
- * itself sorts by when the project was first saved. That costs nothing over a random UUID and
175
- * leaves the ordering available to anything that wants it.
176
+ * itself sorts by when the project was first saved, to the millisecond. That costs nothing over a
177
+ * random UUID and leaves the ordering available to anything that wants it. Two identifiers made in
178
+ * the same millisecond are ordered by their random bits, not by which came first.
176
179
  * @returns {string} The UUID, in the canonical lowercase form.
177
180
  */
178
181
  export function createProjectUUID() {
@@ -3,6 +3,7 @@ import { createProjectIO, DATA_CARRIER_PREFIX } from './project-codec.js';
3
3
  import { readPreparedProject } from './project-data.js';
4
4
  import { collectVerifiedCodes } from './verified-code.js';
5
5
  import { DEFAULT_LOADER_LIMITS, DEFAULT_TOTAL_BYTES } from './limits.js';
6
+ import { markFetchRejection } from './fetch-rejection.js';
6
7
  const encoder = new TextEncoder();
7
8
  // A standard TurboWarp host looks for a URL in the project document and knows nothing of this
8
9
  // manifest, so for that carrier the bytes have to be the address itself.
@@ -111,6 +112,7 @@ export async function prepareCatalogExtensionChanges(input, requests, ports, { z
111
112
  response = await wait(() => ports.fetcher.fetch(url, { signal, maxBytes }));
112
113
  }
113
114
  catch (cause) {
115
+ markFetchRejection(cause);
114
116
  if (cause instanceof LoaderError)
115
117
  throw cause;
116
118
  throw new LoaderError('LOAD_FAILED', 'Catalog extension could not be fetched', undefined, cause);
@@ -53,6 +53,23 @@ export interface ProjectIO {
53
53
  restoreJSON<T extends string | Record<string, unknown>>(json: T, context: ProjectContext | null): T;
54
54
  restoreFiles(files: Record<string, Uint8Array>, context: ProjectContext | null): Record<string, Uint8Array>;
55
55
  restoreArchive<T extends ZipArchive>(archive: T, context: ProjectContext | null): T;
56
+ /**
57
+ * A context like `context` whose Manifest carries `uri`, for the restore calls that follow.
58
+ *
59
+ * This is how a project whose Manifest has no address gets one when it is first saved, and
60
+ * keeps it on every later save: the host holds on to the context this returns. The address
61
+ * follows the same rule as the `uri` option of `prepareExtensionAddition` and
62
+ * `prepareCatalogExtensionChanges` (`INVALID_PROJECT_URI` unless HTTPS without credentials or
63
+ * fragment), and comes back normalized in the Manifest.
64
+ *
65
+ * An address the project already has is never replaced unless `replace` says so: a different
66
+ * one is refused with `PROJECT_URI_CONFLICT`, and the same one returns `context` itself. The
67
+ * context passed in is left as it was. A project with no Manifest (a `null` context) has no
68
+ * address to carry, and is refused.
69
+ */
70
+ assignProjectURI(context: ProjectContext, uri: string, options?: {
71
+ replace?: boolean;
72
+ }): ProjectContext;
56
73
  }
57
74
  /** The one carrier spelling this family writes, and the only one a standard host is given. */
58
75
  export declare const DATA_CARRIER_PREFIX = "data:text/javascript;base64,";
@@ -1,4 +1,4 @@
1
- import { discoverManifest, LoaderError } from './index.js';
1
+ import { discoverManifest, LoaderError, validateManifest } from './index.js';
2
2
  import { DEFAULT_ENTRY_BYTES, DEFAULT_PROJECT_JSON_BYTES, DEFAULT_TOTAL_BYTES } from './limits.js';
3
3
  const DEFAULTS = Object.freeze({ maxArchiveBytes: DEFAULT_TOTAL_BYTES, maxTotalBytes: DEFAULT_TOTAL_BYTES,
4
4
  maxEntryBytes: DEFAULT_ENTRY_BYTES, maxProjectBytes: DEFAULT_PROJECT_JSON_BYTES, maxEntries: 4096, maxRatio: 1000 });
@@ -342,6 +342,24 @@ export function createProjectIO({ zip: Zip, ...options } = {}) {
342
342
  if (name !== 'project.json' && (!archive.file(name) || name.startsWith('extensions/')))
343
343
  archive.file(name, new Uint8Array(bytes), { dir: name.endsWith('/'), createFolders: false });
344
344
  return archive;
345
+ },
346
+ assignProjectURI(context, uri, { replace = false } = {}) {
347
+ const state = get(context);
348
+ if (!state)
349
+ throw new TypeError('A project without a manifest has no address to assign');
350
+ // The one address rule, through the one place that applies it.
351
+ const address = validateManifest({ schemaVersion: 1, uri, extensions: [] }).uri;
352
+ const current = state.manifest.uri;
353
+ if (current === address)
354
+ return context;
355
+ // Saying nothing keeps the address a project has; replacing it has to be asked for.
356
+ if (current !== undefined && replace !== true) {
357
+ throw new LoaderError('PROJECT_URI_CONFLICT', 'This project already has a different address');
358
+ }
359
+ const manifest = Object.freeze({ schemaVersion: 1, uri: address, extensions: state.manifest.extensions });
360
+ const next = Object.freeze({});
361
+ contexts.set(next, { manifest, project: state.project, files: state.files });
362
+ return next;
345
363
  }
346
364
  });
347
365
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubohiroya/turbowarp-extension-loader",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Host-independent verified TurboWarp Plus extension loading core",
5
5
  "keywords": [
6
6
  "turbowarp",