@kubohiroya/turbowarp-extension-loader 0.6.0 → 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 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実行認可は毎回必要です。
@@ -285,7 +287,7 @@ npm run check
285
287
  - `isAuthorizedCode(value)` はコアが発行し現在登録中の認可証跡だけを識別します。コピー・作品データによる偽造を拒否し、transaction終了時に失効します。
286
288
  - `./session-trust` の `createSessionTrustProvider` はid/digest/source/version/publisher/capabilities/dependenciesへ束縛した承認を保持します。明示された外部ホストのURL/execution方針をUI前に毎回適用し、判定できないEmbedded要求は拒否します。
287
289
  - `./memory-cache` の `createMemoryContentCache` はコピー・容量(既定32 MiB、既定最大サイズの拡張2件分)・TTL・LRUを提供し、コアはヒット時にも内容を再検証します。
288
- - `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秒)です。
289
291
  - `./webpack4` はGUI/Packager用の同一コアをwebpack 4でビルドするための構成補助です。
290
292
 
291
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.
@@ -290,7 +292,7 @@ Unsupported formats are ZIP64, multidisk, encryption, compression other than STO
290
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.
291
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.
292
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.
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).
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).
294
296
  - `./webpack4` is a configuration helper for building the same core with webpack 4 for the GUI and Packager.
295
297
 
296
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,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);
@@ -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);
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubohiroya/turbowarp-extension-loader",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Host-independent verified TurboWarp Plus extension loading core",
5
5
  "keywords": [
6
6
  "turbowarp",