@kubohiroya/turbowarp-extension-loader 0.5.1 → 0.7.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 +16 -0
- package/README.ja.md +9 -1
- package/README.md +10 -1
- 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/project-authoring.d.ts +10 -0
- package/dist/project-authoring.js +7 -3
- package/dist/project-catalog.d.ts +10 -0
- package/dist/project-catalog.js +7 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.7.0
|
|
6
|
+
|
|
7
|
+
Released on 2026-11-03. A classifier a host needs before offering "try again" after a failed
|
|
8
|
+
operation, and the attempt total in retry reports. Both are additions: no error code, message or
|
|
9
|
+
default changes, so a host on 0.6.0 needs nothing but the version bump to keep working.
|
|
10
|
+
|
|
11
|
+
- `isWorthAskingAgain(error)` answers, for the rejection of a whole `load()`, `./project-catalog` or
|
|
12
|
+
`./project-authoring` operation, whether asking again could answer differently — the question a
|
|
13
|
+
host has before offering "try again". Unlike `isTransientFetchFailure`, which judges one transfer
|
|
14
|
+
inside a fetch port, it says yes to the browser `TypeError` an operation wraps in `LOAD_FAILED`
|
|
15
|
+
and to `TIMEOUT`, and no to `CANCELLED`, `ROLLBACK_FAILED`, any permanent refusal anywhere in an
|
|
16
|
+
`AggregateError` or `cause` chain, and a bare `TypeError` that did not come out of a fetch port.
|
|
17
|
+
The Loader now records which rejections a fetch port produced so it can tell them apart.
|
|
18
|
+
- `onRetry` also reports `attempts`, the most attempts the fetch can make in all, so a host can say
|
|
19
|
+
"attempt 2 of 3" without knowing the retry settings.
|
|
20
|
+
|
|
5
21
|
## 0.3.1
|
|
6
22
|
|
|
7
23
|
Released on 2026-11-02. One fix, the one 0.3.0 said would follow here: `./project-catalog` no
|
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実行認可は毎回必要です。
|
|
@@ -223,6 +225,12 @@ Manifest は `uri` を持てます。その作品をダウンロードできる
|
|
|
223
225
|
するたびに変わるので、ブラウザーのキャッシュも開く人の選択の記録も、触るたびに同じ作品を別名で
|
|
224
226
|
綴じてしまいます。住所は中身が変わっても変わりません。
|
|
225
227
|
|
|
228
|
+
`prepareCatalogExtensionChanges` と `prepareExtensionAddition` は `uri` を受け取ります。作品が
|
|
229
|
+
最初の住所を得るのも、作者が選んだ住所へ移るのも、この経路です。何も言わなければ作品が持って
|
|
230
|
+
いる住所がそのまま残り、`readProjectAuthoringState` がその住所を報告するので、ホストは初回保存
|
|
231
|
+
かどうかを決める前に見分けられます。割り当てるかどうかはホストの判断です。作者が住所を選んだ
|
|
232
|
+
のか、既定が必要な初回保存なのかを知っているのはホストだけだからです。
|
|
233
|
+
|
|
226
234
|
`./project-authoring` の `createProjectUUID` と `defaultProjectURI` が、作者が住所を選んで
|
|
227
235
|
いないときに与える住所を組みます。
|
|
228
236
|
|
|
@@ -279,7 +287,7 @@ npm run check
|
|
|
279
287
|
- `isAuthorizedCode(value)` はコアが発行し現在登録中の認可証跡だけを識別します。コピー・作品データによる偽造を拒否し、transaction終了時に失効します。
|
|
280
288
|
- `./session-trust` の `createSessionTrustProvider` はid/digest/source/version/publisher/capabilities/dependenciesへ束縛した承認を保持します。明示された外部ホストのURL/execution方針をUI前に毎回適用し、判定できないEmbedded要求は拒否します。
|
|
281
289
|
- `./memory-cache` の `createMemoryContentCache` はコピー・容量(既定32 MiB、既定最大サイズの拡張2件分)・TTL・LRUを提供し、コアはヒット時にも内容を再検証します。
|
|
282
|
-
- `withFetchRetry(port, options)` は任意の fetch ポートを上記の有限回の再試行で包み、`isTransientFetchFailure(error)` はその分類器、`DEFAULT_FETCH_RETRY` は既定値(再試行2回、400 ms から倍々で上限2秒、合計5秒)です。
|
|
290
|
+
- `withFetchRetry(port, options)` は任意の fetch ポートを上記の有限回の再試行で包み、`isTransientFetchFailure(error)` はその分類器、`isWorthAskingAgain(error)` は失敗した操作を受け取ったホストにやり直しを勧めるべきかを答える判定、`DEFAULT_FETCH_RETRY` は既定値(再試行2回、400 ms から倍々で上限2秒、合計5秒)です。
|
|
283
291
|
- `./webpack4` はGUI/Packager用の同一コアをwebpack 4でビルドするための構成補助です。
|
|
284
292
|
|
|
285
293
|
実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.
|
|
@@ -226,6 +228,13 @@ fingerprint changes on every save, so a browser cache and a record of what the o
|
|
|
226
228
|
file one work under a new name each time it was touched. An address does not change when the bytes
|
|
227
229
|
do.
|
|
228
230
|
|
|
231
|
+
`prepareCatalogExtensionChanges` and `prepareExtensionAddition` take a `uri` option, which is how
|
|
232
|
+
a project gets its first address and how it moves to one the author chose. Saying nothing keeps
|
|
233
|
+
the address the project already has, and `readProjectAuthoringState` reports that address, so a
|
|
234
|
+
host can tell a first save from a later one before deciding. Whether to assign one is the host's
|
|
235
|
+
call: only the host knows whether the author chose an address or whether this is a first save
|
|
236
|
+
that needs a default.
|
|
237
|
+
|
|
229
238
|
`createProjectUUID` and `defaultProjectURI` in `./project-authoring` build the address a project
|
|
230
239
|
is given when its author has not chosen one:
|
|
231
240
|
|
|
@@ -283,7 +292,7 @@ Unsupported formats are ZIP64, multidisk, encryption, compression other than STO
|
|
|
283
292
|
- `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.
|
|
284
293
|
- `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.
|
|
285
294
|
- `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.
|
|
286
|
-
- `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).
|
|
295
|
+
- `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).
|
|
287
296
|
- `./webpack4` is a configuration helper for building the same core with webpack 4 for the GUI and Packager.
|
|
288
297
|
|
|
289
298
|
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);
|
|
@@ -23,6 +23,16 @@ export interface ExtensionAdditionOptions {
|
|
|
23
23
|
signal?: AbortSignal;
|
|
24
24
|
loaderOptions?: LoaderOptions;
|
|
25
25
|
archiveOptions?: ArchiveLimits;
|
|
26
|
+
/**
|
|
27
|
+
* The address to give this project, or nothing to keep the one it has.
|
|
28
|
+
*
|
|
29
|
+
* Whether to assign one is the host's call, not this function's: only the host knows whether
|
|
30
|
+
* the author chose an address or whether this is a first save that needs a default. So this
|
|
31
|
+
* sets what it is given and leaves the existing address alone when it is given nothing —
|
|
32
|
+
* `readProjectAuthoringState` reports what a project already carries, so the host can tell
|
|
33
|
+
* the two apart before deciding.
|
|
34
|
+
*/
|
|
35
|
+
uri?: string;
|
|
26
36
|
}
|
|
27
37
|
export interface PreparedExtensionAddition {
|
|
28
38
|
readonly projectBytes: Uint8Array;
|
|
@@ -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) &&
|
|
@@ -21,7 +22,7 @@ function snapshotProject(input) {
|
|
|
21
22
|
* Add a file-backed Embedded or URL-backed Remote extension without evaluating it.
|
|
22
23
|
* A host approval is required before the candidate is returned; the original is untouched.
|
|
23
24
|
*/
|
|
24
|
-
export async function prepareExtensionAddition(input, request, ports, { zip: Zip, signal: externalSignal, loaderOptions = {}, archiveOptions } = {}) {
|
|
25
|
+
export async function prepareExtensionAddition(input, request, ports, { zip: Zip, signal: externalSignal, loaderOptions = {}, archiveOptions, uri } = {}) {
|
|
25
26
|
if (typeof Zip !== 'function' || typeof Zip.loadAsync !== 'function')
|
|
26
27
|
throw new TypeError('A compatible JSZip constructor is required');
|
|
27
28
|
if (!plainObject(request) || Object.keys(request).some(key => !['id', 'source', 'bytes', 'execution'].includes(key))) {
|
|
@@ -95,8 +96,10 @@ export async function prepareExtensionAddition(input, request, ports, { zip: Zip
|
|
|
95
96
|
fail('INVALID_PROJECT', 'Project extensionURLs must be an object');
|
|
96
97
|
const previous = prepared.manifest?.extensions || [];
|
|
97
98
|
// Carried through every rebuild below: an address that changed when an extension was
|
|
98
|
-
// added would not be an address.
|
|
99
|
-
|
|
99
|
+
// added would not be an address. The host may set one, which is how a project gets its
|
|
100
|
+
// first; saying nothing keeps what is there.
|
|
101
|
+
const address = uri ?? prepared.manifest?.uri;
|
|
102
|
+
const carried = address === undefined ? {} : { uri: address };
|
|
100
103
|
if (previous.some(spec => spec.id === draft.id) || (project.extensions || []).includes(draft.id) ||
|
|
101
104
|
Object.hasOwn(project.extensionURLs || {}, draft.id))
|
|
102
105
|
fail('DUPLICATE_EXTENSION', 'This extension ID already belongs to the project', draft.id);
|
|
@@ -117,6 +120,7 @@ export async function prepareExtensionAddition(input, request, ports, { zip: Zip
|
|
|
117
120
|
response = await wait(() => ports.fetcher.fetch(url, { signal, maxBytes: limits.maxBytes }));
|
|
118
121
|
}
|
|
119
122
|
catch (cause) {
|
|
123
|
+
markFetchRejection(cause);
|
|
120
124
|
if (cause instanceof LoaderError)
|
|
121
125
|
throw cause;
|
|
122
126
|
throw new LoaderError('LOAD_FAILED', 'Remote extension could not be fetched', draft.id, cause);
|
|
@@ -23,6 +23,16 @@ export interface CatalogOptions {
|
|
|
23
23
|
signal?: AbortSignal;
|
|
24
24
|
loaderOptions?: LoaderOptions;
|
|
25
25
|
archiveOptions?: ArchiveLimits;
|
|
26
|
+
/**
|
|
27
|
+
* The address to give this project, or nothing to keep the one it has.
|
|
28
|
+
*
|
|
29
|
+
* Whether to assign one is the host's call, not this function's: only the host knows whether
|
|
30
|
+
* the author chose an address or whether this is a first save that needs a default. So this
|
|
31
|
+
* sets what it is given and leaves the existing address alone when it is given nothing —
|
|
32
|
+
* `readProjectAuthoringState` reports what a project already carries, so the host can tell
|
|
33
|
+
* the two apart before deciding.
|
|
34
|
+
*/
|
|
35
|
+
uri?: string;
|
|
26
36
|
}
|
|
27
37
|
export type CatalogPorts = Pick<LoaderPorts, 'fetcher' | 'trustProvider' | 'cache' | 'diagnostics'>;
|
|
28
38
|
/** Read project-owned distribution requests; these records never grant execution permission. */
|
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.
|
|
@@ -19,7 +20,7 @@ export async function readProjectAuthoringState(input, { zip, signal, archiveOpt
|
|
|
19
20
|
const prepared = await io.prepare(input, { signal });
|
|
20
21
|
return prepared.manifest;
|
|
21
22
|
}
|
|
22
|
-
export async function prepareCatalogExtensionChanges(input, requests, ports, { zip: Zip, signal: externalSignal, loaderOptions = {}, archiveOptions } = {}) {
|
|
23
|
+
export async function prepareCatalogExtensionChanges(input, requests, ports, { zip: Zip, signal: externalSignal, loaderOptions = {}, archiveOptions, uri } = {}) {
|
|
23
24
|
const limits = { ...DEFAULT_LOADER_LIMITS, ...loaderOptions };
|
|
24
25
|
for (const key of ['maxExtensions', 'maxBytes', 'maxTotalBytes', 'timeoutMs']) {
|
|
25
26
|
if (!Number.isSafeInteger(limits[key]) || limits[key] < 1)
|
|
@@ -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);
|
|
@@ -176,8 +178,10 @@ export async function prepareCatalogExtensionChanges(input, requests, ports, { z
|
|
|
176
178
|
// The project's own address survives a change to its extensions. Rebuilding the manifest
|
|
177
179
|
// without it would file the same work under a new name the next time it was saved, which
|
|
178
180
|
// is the one thing an address is for.
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
+
const address = uri ?? prepared.manifest?.uri;
|
|
182
|
+
const manifest = validateManifest({ schemaVersion: 1,
|
|
183
|
+
...(address === undefined ? {} : { uri: address }),
|
|
184
|
+
extensions: [...extensions.values()] }, loaderOptions);
|
|
181
185
|
for (const spec of manifest.extensions)
|
|
182
186
|
if (spec.source.type === 'embedded-file')
|
|
183
187
|
replacedPaths.delete(spec.source.path);
|