@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 +32 -1
- package/README.ja.md +36 -5
- package/README.md +38 -5
- package/dist/fetch-rejection.d.ts +3 -0
- package/dist/fetch-rejection.js +14 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +57 -2
- package/dist/indexeddb-cache.d.ts +5 -1
- package/dist/indexeddb-cache.js +14 -2
- package/dist/project-authoring.d.ts +3 -2
- package/dist/project-authoring.js +5 -2
- package/dist/project-catalog.js +2 -0
- package/dist/project-codec.d.ts +17 -0
- package/dist/project-codec.js +19 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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
|
|
250
|
+
const uri = defaultProjectURI(location.origin, createProjectUUID());
|
|
237
251
|
```
|
|
238
252
|
|
|
239
|
-
|
|
240
|
-
|
|
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.
|
|
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
|
|
255
|
+
const uri = defaultProjectURI(location.origin, createProjectUUID());
|
|
241
256
|
```
|
|
242
257
|
|
|
243
|
-
The origin is the
|
|
244
|
-
|
|
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,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 })
|
|
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
|
*
|
package/dist/indexeddb-cache.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
|
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() {
|
package/dist/project-catalog.js
CHANGED
|
@@ -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);
|
package/dist/project-codec.d.ts
CHANGED
|
@@ -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,";
|
package/dist/project-codec.js
CHANGED
|
@@ -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
|
}
|