@kubohiroya/turbowarp-extension-loader 0.3.1 → 0.5.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/README.ja.md +133 -10
- package/README.md +139 -10
- package/dist/index.d.ts +76 -6
- package/dist/index.js +69 -30
- package/dist/indexeddb-cache.d.ts +59 -1
- package/dist/indexeddb-cache.js +136 -11
- package/dist/packager.js +8 -4
- package/dist/project-authoring.d.ts +35 -2
- package/dist/project-authoring.js +67 -8
- package/dist/project-catalog.d.ts +12 -2
- package/dist/project-catalog.js +45 -17
- package/dist/project-codec.d.ts +6 -0
- package/dist/project-codec.js +29 -1
- package/dist/session-trust.js +2 -2
- package/indexeddb-cache.js +1 -1
- package/package.json +2 -1
package/README.ja.md
CHANGED
|
@@ -12,7 +12,7 @@ npm install @kubohiroya/turbowarp-extension-loader
|
|
|
12
12
|
|
|
13
13
|
- `project.json` の独自フィールド `turboWarpPlus` に schema v1 の Manifest を配置します。標準 SB3 仕様ではありません。
|
|
14
14
|
- Manifest の厳格検証、未知フィールド拒否、重複・標準拡張 ID 拒否、依存順、循環・欠落依存拒否。
|
|
15
|
-
-
|
|
15
|
+
- 拡張ごとに4つの source 形式のいずれかで解決、SHA-256 必須固定、UTF-8 検証、コードごと・全体のサイズ上限。
|
|
16
16
|
- SHA-256 キーのキャッシュポート。読み出すたびに exact bytes を再検証し、キャッシュ利用時もホストの認可を要求します。
|
|
17
17
|
- 明示的なホスト認可後、全拡張の検証・認可が揃ってから登録を始めます。登録 ID 不一致・登録失敗はホスト transaction の rollback を要求します。
|
|
18
18
|
- キャンセル、取得・認可のタイムアウト、Remote の一時的な失敗の有限回の再試行、重複ロード拒否、unload、診断イベント。
|
|
@@ -21,17 +21,22 @@ npm install @kubohiroya/turbowarp-extension-loader
|
|
|
21
21
|
|
|
22
22
|
### ソースの形式
|
|
23
23
|
|
|
24
|
-
`source`
|
|
24
|
+
`source` は拡張のバイト列の所在を示します。作者が決めるもので、4つの形式があります。この4つは SB3 の保存形式および選択画面と同じ名前なので、層の間で読み替えが起きません。
|
|
25
25
|
|
|
26
|
-
| 形式 | 構造 |
|
|
26
|
+
| 形式 | 構造 | バイト列の出どころ |
|
|
27
27
|
| --- | --- | --- |
|
|
28
|
-
| `
|
|
29
|
-
| `remote` | `{type
|
|
30
|
-
| `
|
|
28
|
+
| `remote-non-cacheable` | `{type, url}` | 毎回 `url` を HTTPS 取得 |
|
|
29
|
+
| `remote-cacheable` | `{type, url}` | cache port、無ければ `url` を HTTPS 取得 |
|
|
30
|
+
| `embedded-file` | `{type, path, url?}` | `path` のアーカイブ内エントリ、無ければ `url` |
|
|
31
|
+
| `embedded-data` | `{type, url?}` | 作品の文書の `data:` 運び手、無ければ `url` |
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
embedded の2つは兄弟です。どちらも「作品だけで、オフラインで、どこでも動く」を実現します。違いは運び手で、運び手が「ほかに誰が読めるか」を決めます。`embedded-data` は、標準 TurboWarp がこの Manifest を知らないまま作品を開けるようにする形です。標準ホストは作品の文書の中の URL を探すので、バイト列がその URL そのものになります。
|
|
33
34
|
|
|
34
|
-
`
|
|
35
|
+
持っている中身は弱いソースではありません。ManifestがSHA-256を固定しているため、固定されたコードそのものか、さもなくば `DIGEST_MISMATCH` で失敗します。運び手が欠けている場合は `retained-copy-unavailable` を診断として報告して `url` へ退避し、その URL は参照形と同じ redirect・最終URL方針のもとで固定バイト列を返さなければなりません。`path` は `extensions/*.js` 方針に従います。
|
|
36
|
+
|
|
37
|
+
キャッシュを読み書きするのは `remote-cacheable` だけです。embedded の作品は既に中身を持っているので、ブラウザーに2つ目の控えを置いても得るものがなく、作者もそれを求めていません。
|
|
38
|
+
|
|
39
|
+
古い版が使っていた3つの綴り(`remote`、`cached`、`embedded`)は**拒否します**。それぞれ、何が置き換えるかをメッセージで示します。対になった2つは読み替えもしません。古い名前は選択を暗黙にしていたので、それを決めるのは読む人です。
|
|
35
40
|
|
|
36
41
|
## ホスト側の接続
|
|
37
42
|
|
|
@@ -71,7 +76,7 @@ await loader.unload();
|
|
|
71
76
|
- injected fetch ポートは `finalURL` と明示的な空 `redirects` を返す必要があります。ポート自体は信頼済みホストが実装する境界であり、再試行もその境界に置いています(後述)。
|
|
72
77
|
- 取得・認可は応答しないポートでもタイムアウトします。登録処理はキャンセル後も完了を待ってから rollback します。遅れて完了する登録と rollback の競合を避けるため、登録ポートが `AbortSignal` に応答しない場合、登録中の有限時間終了は保証できません。
|
|
73
78
|
- キャッシュの容量・失効・永続化・出所ログはホストのポートが担当します。壊れたキャッシュを暗黙に無視して Remote へ切り替えません。
|
|
74
|
-
- `./packager` は
|
|
79
|
+
- `./packager` は source 形式の相互変換、明示承認付きData URL移行、lock/license metadata保持、offline SB3準備を提供します。外部依存資源がある拡張は同梱を推測せず拒否します。署名・発行者鍵のpinningはホスト側で追加できる設計で、初期実装は内容・取得元に束縛したセッション承認です。
|
|
75
80
|
- ZIP の実装は `createProjectIO({zip: JSZip})` に注入します。core の codec が ZIP central directory を直接検査し、重複・経路・宣言容量を検証してから、ストリーム展開の実容量と CRC32 を検査します。外部実装の archive port を使う場合も同等の検証が必要です。
|
|
76
81
|
|
|
77
82
|
既定値は最大64拡張、1拡張16 MiB、合計64 MiB、取得・認可30秒です。1拡張と合計の既定値は project codec の1 entry と全展開量の上限に揃えてあり、作品に格納できる拡張ファイルは Loader でも読めます。ホストは loader options で下げられます。データURLやHTTPの開発モード例外はありません。標準 TurboWarp / 非対応エディターが独自 Manifest と ZIP エントリを破棄する可能性があるため、保持対応のないホストでの再保存は移行手段になりません。
|
|
@@ -122,6 +127,124 @@ const loader = createExtensionLoader({...hostPorts, cache});
|
|
|
122
127
|
- 新たな Remote バイト列を取得し保存した場合も、権限承認は毎回別途必要です。コード内から追加取得される依存リソースや外部サービスはこのキャッシュの対象外です。
|
|
123
128
|
- Web/Editor はこのポートを初期化して注入するだけとし、`scratch-gui` と `turbowarp-plus-editor` に別のキャッシュロジックを実装しないでください。
|
|
124
129
|
|
|
130
|
+
### そのバイト列が誰のものか、どう手放すか
|
|
131
|
+
|
|
132
|
+
キャッシュは内容アドレス方式です。キーが digest なので、2つの作品が同じ拡張を使っていても実体は
|
|
133
|
+
1つで、持っていないバイト列のキーに書き込むことはできません。だから共有が安全で、同時に
|
|
134
|
+
**エントリは誰のものでもありません**。もう1つのストアに「claim」を記録することで、作品ごとの
|
|
135
|
+
取り分を一覧し、手放せるようにしています。バイト列を二重に持つことはありません。
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
const cache = createIndexedDBContentCache({appURI: manifest.uri, sharedDigests: catalogDigests});
|
|
139
|
+
|
|
140
|
+
await cache.listBuckets(); // 何かを持っている作品すべてと、その量
|
|
141
|
+
await cache.listBucket(manifest.uri); // この作品が持っているもの
|
|
142
|
+
await cache.listBucket(SHARED_BUCKET); // どの作品のものでもないもの
|
|
143
|
+
await cache.deleteBucket(manifest.uri); // 手放す。他が参照していないバイト列も回収する
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
カタログの拡張は版が固定で多くの作品が共有するので、`sharedDigests` でどれか1つの作品の下に
|
|
147
|
+
入らないようにします。そうしないと、最初に取得した作品が、他の全作品が使い回す容量の責任を
|
|
148
|
+
負っているように見え、その作品を手放すと他がまだ使いたいバイト列まで落ちます。この集合は書き込み
|
|
149
|
+
ごとに読むので、カタログの読み込みに合わせて埋めていけます。どの作品を開いているか言えないホスト
|
|
150
|
+
は、すべてを shared に入れます。名前を推測するのは、無いと認めるより悪いからです。
|
|
151
|
+
|
|
152
|
+
バケットを手放すと claim が落ち、**他が参照していない**バイト列だけが回収されます。2回参照されて
|
|
153
|
+
いる1つの実体は残ります。もう一方の作品は失うことを求めていません。容量超過で追い出すときは
|
|
154
|
+
claim も一緒に落ちるので、バケットが「もう無いもの」を並べることはありません。
|
|
155
|
+
|
|
156
|
+
誰が入れたかの記録は無いので、作品を手放すとは「**その作品が名指す digest** を手放す」ことです。
|
|
157
|
+
別の作品が使い回したはずのバイト列を落とす可能性があります。代償は取得1回だけですが、人に見せる
|
|
158
|
+
一覧でそれ以上を約束しないようにしたいです。
|
|
159
|
+
|
|
160
|
+
## 機能拡張のバイト列がどこから来るか
|
|
161
|
+
|
|
162
|
+
このファミリーは、機能拡張に限らず何を保持するにも4つの形式で整理しています。どの形式かは
|
|
163
|
+
**作品の作者**が決めます。
|
|
164
|
+
|
|
165
|
+
| 形式 | ブラウザー(IndexedDB) | 作品(SB3) | 標準 TurboWarp |
|
|
166
|
+
| --- | --- | --- | --- |
|
|
167
|
+
| `remote-non-cacheable` | 残さない | 参照だけ | 開けない |
|
|
168
|
+
| `remote-cacheable` | 検証した控えを残してよい | 参照だけ | 開けない |
|
|
169
|
+
| `embedded-file` | 使わない | 作品が中身を持つ | 開けない |
|
|
170
|
+
| `embedded-data` | 使わない | 作品が中身を持つ | **開ける** |
|
|
171
|
+
|
|
172
|
+
### 作者が決めること、開く人が断れること
|
|
173
|
+
|
|
174
|
+
4つはすべて作者の決定で、既に Manifest に入っています。読み込み時に残る問いはもっと狭く、
|
|
175
|
+
**`remote-cacheable` の控えを、開く人が自分のブラウザーに残すかどうか**だけです。保存先は
|
|
176
|
+
その人の端末なので、作者が許した範囲の中でなら下げられます。範囲の中でだけです。
|
|
177
|
+
|
|
178
|
+
```js
|
|
179
|
+
await loader.load(manifest, {
|
|
180
|
+
// 控えを残さない拡張の id。指定しなければ、作者が許したとおりになります。
|
|
181
|
+
declineCaching: new Set(['someextension'])
|
|
182
|
+
});
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Map ではなく Set なのは、これが**断ることしかできない**からです。`remote-non-cacheable` は
|
|
186
|
+
禁止であり、書ける反対がありません。作者には開く人から見えない理由があります——控えがあると、
|
|
187
|
+
URL が固定したバイト列を返さなくなったことを控えが尽きるまで隠してしまう。共用の機械に渡す
|
|
188
|
+
作品にコードを残したくない。断るのは常に安全で、代償はせいぜい取得1回です。他の形式を名指し
|
|
189
|
+
ても効果はありません。残り3つはどちらにしてもキャッシュの扱いが決まっています。
|
|
190
|
+
|
|
191
|
+
断る指定は両方向に効きます。その拡張についてキャッシュを読まず、書き込みもしません。
|
|
192
|
+
何も残したくないと言った人には、何も残りません。
|
|
193
|
+
|
|
194
|
+
### 参照形へ戻れるかどうか
|
|
195
|
+
|
|
196
|
+
embedded の `url` は、バイト列の取得元を記録します。
|
|
197
|
+
|
|
198
|
+
```json
|
|
199
|
+
{"type": "embedded-file", "path": "extensions/thing-<sha256>.js", "url": "https://example.test/thing.js"}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
これは2つの働きをします。運び手が欠けたときに取得へ落ちられること、そして**参照形へ戻る道を
|
|
203
|
+
残す唯一の手がかり**であること。無ければ、その作品を `remote-cacheable` へ変換することは
|
|
204
|
+
二度とできません。指す先が存在しないからです。
|
|
205
|
+
|
|
206
|
+
`prepareCatalogExtensionChanges` の `distribution` も同じ4つの名前を取ります。カタログの拡張は
|
|
207
|
+
どこかから取得したものなので、embedded のどちらの形も、分かっている取得元を捨てずに中身の横へ
|
|
208
|
+
残します。URL は、どの形で保存されていても、固定したバイト列を返すか照合します。url が省略可能
|
|
209
|
+
なのは一般の場合のためで、どこからも取得していないバイト列 — コスチューム、音、エディター上で
|
|
210
|
+
書いた拡張 — には記録すべき取得元がありません。
|
|
211
|
+
|
|
212
|
+
## 作品を同定するもの
|
|
213
|
+
|
|
214
|
+
Manifest は `uri` を持てます。その作品をダウンロードできる住所で、同時にその作品を同定するもの
|
|
215
|
+
です。
|
|
216
|
+
|
|
217
|
+
```json
|
|
218
|
+
{"schemaVersion": 1, "uri": "https://example.test/editor/turbowarp-plus-projects/66/66aa/0193f0a1-2b3c-7d4e-8f01-2233445566aa.sb3", "extensions": []}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
1つの作品に1つの URI。何度編集しても変わりません。だから指紋ではなく住所なのです。指紋は保存
|
|
222
|
+
するたびに変わるので、ブラウザーのキャッシュも開く人の選択の記録も、触るたびに同じ作品を別名で
|
|
223
|
+
綴じてしまいます。住所は中身が変わっても変わりません。
|
|
224
|
+
|
|
225
|
+
`./project-authoring` の `createProjectUUID` と `defaultProjectURI` が、作者が住所を選んで
|
|
226
|
+
いないときに与える住所を組みます。
|
|
227
|
+
|
|
228
|
+
```js
|
|
229
|
+
const uri = defaultProjectURI(location.origin + location.pathname, createProjectUUID());
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
オリジンはエディター自身のもので、配信していない host を名乗ることはありません。この住所は作品
|
|
233
|
+
を人に渡すとき SB3 の中に入って一緒に移動するからです。識別子は UUID version 7 なので、初回保存
|
|
234
|
+
の時刻順に並びます。2段のディレクトリは UUID の**末尾**から取ります。version 7 の先頭はミリ秒
|
|
235
|
+
時計の上位で、ほとんど動かないので、先頭でシャーディングすると**作られた全作品が1つの
|
|
236
|
+
ディレクトリに入ります**。
|
|
237
|
+
|
|
238
|
+
この住所は意図的に不透明です。読みやすい住所はタイトル由来になり、タイトルを変えると変わるので、
|
|
239
|
+
1つの作品が2つ目の名前で綴じられます。読みやすさは「公開する」という意図的な操作のもので、その
|
|
240
|
+
ときは作者が自分で住所を名乗ります。人が読む一覧には、これと並べて作品のタイトルを出します。
|
|
241
|
+
|
|
242
|
+
作者がそこへ公開するまで、この住所は何も返しません。それが想定どおりです。**解決しない URI は
|
|
243
|
+
何も語りません。** 保存したが公開していない作品と、公開したが住所が腐った作品は、まったく同じに
|
|
244
|
+
見えます。解決するかどうかで何かを決めてはいけません。まして何を消すかを決めてはいけません。
|
|
245
|
+
URI は作品自身の申告で、検証していません。作品は他の住所も名乗れます。それで壊れるのは帳簿だけ
|
|
246
|
+
です。キャッシュは digest をキーにしていて、ヒットごとに照合するからです。
|
|
247
|
+
|
|
125
248
|
## ソースとビルド
|
|
126
249
|
|
|
127
250
|
ソースもテストも TypeScript で、`src/*.ts` と `test/*.test.ts` に置き、他の `@kubohiroya/turbowarp-*` パッケージと同じ構成にしています。`tsconfig.json` が出力なしの型検査、`tsconfig.build.json` がそれを継承して出力、`test/tsconfig.json` がテスト用の継承設定です。`npm run build`(`tsc -p tsconfig.build.json`)が ES2022 の ESM と型宣言を `dist/` に出力し、`exports`・`main`/`module`・webpack 4 用のルートファイル(`packager.js` など)はすべて `dist/` を指します。`dist/` は Git に含めず、Git 依存としてインストールされるときに `prepare` スクリプトがビルドします。npm パッケージにはビルド済みの `dist/` を含めます。webpack 4 では従来どおり `./webpack4` の Babel 設定が `dist/` を変換します。
|
|
@@ -188,7 +311,7 @@ const candidate = await prepareExtensionAddition(currentSB3, {
|
|
|
188
311
|
|
|
189
312
|
## 一覧からの一括設定API
|
|
190
313
|
|
|
191
|
-
`./project-catalog` の `prepareCatalogExtensionChanges(currentSB3, requests, ports, options)` は、カタログの選択をまとめてSB3候補へ反映します。requestは `{id, url, distribution: '
|
|
314
|
+
`./project-catalog` の `prepareCatalogExtensionChanges(currentSB3, requests, ports, options)` は、カタログの選択をまとめてSB3候補へ反映します。requestは `{id, url, distribution: 'remote-non-cacheable' | 'remote-cacheable' | 'embedded-file' | 'embedded-data', execution: 'sandboxed' | 'unsandboxed', dependencies?, version?}` です。JS取得・SHA-256計算・依存順・全件検証・ホスト承認を共通Loaderで行い、全件承認後に `{projectBytes, manifest}` を返します。実VMには触りません。
|
|
192
315
|
|
|
193
316
|
既存Plus拡張の配布方式・実行モードを変える場合は、検証済みコードとSHA-256・発行者・バージョン・lockを保持します。Remote出力先が現在のコードを返さない場合、更新として勝手に追従せず拒否します。`readProjectAuthoringState(input, options)` は検証済みManifestを返し、UIの状態復元に使えます。これらの作品情報は実行許可ではありません。
|
|
194
317
|
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ npm install @kubohiroya/turbowarp-extension-loader
|
|
|
12
12
|
|
|
13
13
|
- A schema v1 Manifest lives in `turboWarpPlus`, a non-standard field of `project.json`. This is not part of the standard SB3 specification.
|
|
14
14
|
- Strict Manifest validation: unknown fields rejected, duplicate and standard extension IDs rejected, dependency ordering enforced, cyclic and missing dependencies rejected.
|
|
15
|
-
- Per-extension
|
|
15
|
+
- Per-extension resolution in any of the four source forms, a mandatory pinned SHA-256, UTF-8 validation, and per-code and total size limits.
|
|
16
16
|
- A SHA-256-keyed cache port. Every read re-verifies the exact bytes, and a cache hit still requires host authorization.
|
|
17
17
|
- Registration starts only after explicit host authorization and only once every extension has been verified and authorized. A registration ID mismatch or a registration failure requires the host transaction to roll back.
|
|
18
18
|
- Cancellation, fetch and authorization timeouts, a bounded retry of transient Remote failures, duplicate load rejection, unload, and diagnostic events.
|
|
@@ -21,17 +21,22 @@ Standard extensions, generic custom URLs and legacy Data URLs are not reinterpre
|
|
|
21
21
|
|
|
22
22
|
### Source forms
|
|
23
23
|
|
|
24
|
-
A `source` names where one extension's bytes live,
|
|
24
|
+
A `source` names where one extension's bytes live, as the author of the project decided. There are four forms, and they are the same four names the SB3 save format and the screen that offers the choice use, so nothing is translated between layers.
|
|
25
25
|
|
|
26
|
-
| Form | Shape |
|
|
26
|
+
| Form | Shape | Where the bytes come from |
|
|
27
27
|
| --- | --- | --- |
|
|
28
|
-
| `
|
|
29
|
-
| `remote` | `{type
|
|
30
|
-
| `
|
|
28
|
+
| `remote-non-cacheable` | `{type, url}` | an HTTPS fetch of `url`, every time |
|
|
29
|
+
| `remote-cacheable` | `{type, url}` | the cache port, else an HTTPS fetch of `url` |
|
|
30
|
+
| `embedded-file` | `{type, path, url?}` | the archive entry at `path`, else `url` |
|
|
31
|
+
| `embedded-data` | `{type, url?}` | the `data:` carrier in the project document, else `url` |
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
The two embedded forms are siblings: both make a project that runs offline, anywhere, on its own. They differ in the carrier, and the carrier decides who else can read it — `embedded-data` is what lets a standard TurboWarp host open the project without knowing this Manifest, because that host looks for a URL in the project document and the bytes are that URL.
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
A carried copy is not a weaker source: the Manifest pins the SHA-256, so the copy either is exactly the pinned code or the load fails with `DIGEST_MISMATCH`. When the carrier is missing the loader reports a `retained-copy-unavailable` diagnostic and falls back to `url` under the same redirect and final-URL policy as a reference. `path` follows the `extensions/*.js` policy.
|
|
36
|
+
|
|
37
|
+
The cache is reached for `remote-cacheable` and for nothing else. An embedded project already carries the bytes, so a second copy in the browser buys nothing and the author never asked for one.
|
|
38
|
+
|
|
39
|
+
The three spellings older versions used — `remote`, `cached` and `embedded` — are **refused**, each with a message naming what replaces it. The two that became a pair are not translated at all: the old name left the choice implicit and the reader is the one who has to make it.
|
|
35
40
|
|
|
36
41
|
## Host integration
|
|
37
42
|
|
|
@@ -71,7 +76,7 @@ The real VM adapter is owned by `scratch-gui/src/lib/tw-plus-extension-registrar
|
|
|
71
76
|
- An injected fetch port must return `finalURL` and an explicitly empty `redirects`. The port itself is a boundary implemented by the trusted host, which is where retrying lives as well; see below.
|
|
72
77
|
- Fetching and authorization time out even against a port that never responds. Registration waits for completion before rolling back, even after cancellation. To avoid a race between a late-completing registration and a rollback, a registration port that does not respond to `AbortSignal` cannot guarantee that an in-flight registration finishes in bounded time.
|
|
73
78
|
- Cache capacity, invalidation, persistence and provenance logging belong to the host's port. A corrupted cache is never silently ignored in favour of switching to Remote.
|
|
74
|
-
- `./packager` provides
|
|
79
|
+
- `./packager` provides conversion between the source forms, Data URL migration with explicit approval, lock and license metadata preservation, and offline SB3 preparation. An extension with external dependent resources is rejected rather than guessed into a bundle. Signature and publisher key pinning can be added by the host; the initial implementation is session approval bound to content and source.
|
|
75
80
|
- The ZIP implementation is injected through `createProjectIO({zip: JSZip})`. The core codec inspects the ZIP central directory directly and validates duplicates, paths and declared capacity before checking the real expanded size and CRC32 of the stream. An externally implemented archive port needs equivalent validation.
|
|
76
81
|
|
|
77
82
|
The defaults are at most 64 extensions, 16 MiB per extension, 64 MiB in total, and 30 seconds for fetching and authorization. The per-extension and total defaults equal the project codec's per-entry and expansion limits, so any extension file a project can carry is also one the Loader reads; a host can lower them through its loader options. There is no development-mode exception for Data URLs or HTTP. Because standard TurboWarp and unsupported editors may discard the non-standard Manifest and ZIP entries, re-saving in a host without preservation support is not a migration path.
|
|
@@ -121,6 +126,130 @@ const loader = createExtensionLoader({...hostPorts, cache});
|
|
|
121
126
|
- Fetching and storing new Remote bytes still requires a separate authorization every time. Dependent resources and external services fetched from inside the code are outside the scope of this cache.
|
|
122
127
|
- The Web and Editor hosts should only initialize and inject this port, and should not implement separate cache logic in `scratch-gui` or `turbowarp-plus-editor`.
|
|
123
128
|
|
|
129
|
+
### Whose bytes these are, and releasing them
|
|
130
|
+
|
|
131
|
+
The cache is content-addressed: the key is the digest, so one extension that two projects both use
|
|
132
|
+
is one copy, and no project can put the wrong bytes under a key it does not have the bytes for.
|
|
133
|
+
That is why sharing is safe, and it is also why an entry does not belong to anyone. A second store
|
|
134
|
+
records the claims, so a project's share can be listed and released without storing its bytes
|
|
135
|
+
twice.
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
const cache = createIndexedDBContentCache({appURI: manifest.uri, sharedDigests: catalogDigests});
|
|
139
|
+
|
|
140
|
+
await cache.listBuckets(); // every project that holds something, and how much
|
|
141
|
+
await cache.listBucket(manifest.uri); // what this one holds
|
|
142
|
+
await cache.listBucket(SHARED_BUCKET); // what belongs to no one project
|
|
143
|
+
await cache.deleteBucket(manifest.uri); // release it, and any bytes nothing else refers to
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
A catalog extension is pinned to a version and shared by many projects, so `sharedDigests` keeps it
|
|
147
|
+
out of whichever project happened to fetch it first: that project would otherwise look responsible
|
|
148
|
+
for storage every other one reuses, and releasing it would evict bytes the rest still want. The set
|
|
149
|
+
is read at each write, so a host may keep filling it as its catalog loads. A host that cannot say
|
|
150
|
+
which project it is opening files everything as shared, because guessing a name is worse than
|
|
151
|
+
admitting there is none.
|
|
152
|
+
|
|
153
|
+
Releasing a bucket drops its claims and reclaims only the bytes **nothing else refers to**. One
|
|
154
|
+
copy referred to twice stays: the other project did not ask to lose it. An eviction takes the
|
|
155
|
+
claims on it as well, so a bucket never lists bytes that are gone.
|
|
156
|
+
|
|
157
|
+
Because there is no record of who put an entry there, releasing a project means releasing the
|
|
158
|
+
digests **that project names** — which may evict bytes another project would have reused. That
|
|
159
|
+
costs a fetch and nothing else, but a list shown to a person should not claim more than that.
|
|
160
|
+
|
|
161
|
+
## Where an extension's bytes come from
|
|
162
|
+
|
|
163
|
+
The family holds anything — extensions included — in one of four forms, and the author of the
|
|
164
|
+
project decides which.
|
|
165
|
+
|
|
166
|
+
| Form | Browser (IndexedDB) | Project (SB3) | Standard TurboWarp |
|
|
167
|
+
| --- | --- | --- | --- |
|
|
168
|
+
| `remote-non-cacheable` | nothing kept | reference only | cannot open it |
|
|
169
|
+
| `remote-cacheable` | a verified copy may be kept | reference only | cannot open it |
|
|
170
|
+
| `embedded-file` | not used | the project carries the bytes | cannot open it |
|
|
171
|
+
| `embedded-data` | not used | the project carries the bytes | **can open it** |
|
|
172
|
+
|
|
173
|
+
### What the author decides, and what the opener may decline
|
|
174
|
+
|
|
175
|
+
All four are the author's decision and are already in the manifest. What is left to ask at load
|
|
176
|
+
time is narrower: whether the person opening the project keeps a copy of a `remote-cacheable`
|
|
177
|
+
extension in **their own** browser. Storage is their device, so they can come in under what the
|
|
178
|
+
author permitted — and only under it:
|
|
179
|
+
|
|
180
|
+
```js
|
|
181
|
+
await loader.load(manifest, {
|
|
182
|
+
// IDs this person will not keep a copy of. Leaving it out keeps what the author permitted.
|
|
183
|
+
declineCaching: new Set(['someextension'])
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
A set, not a map, because this can only ever refuse. `remote-non-cacheable` is a prohibition with
|
|
188
|
+
no opposite to write: the author has reasons the opener cannot see — a kept copy hides a URL that
|
|
189
|
+
stopped serving the pinned bytes until the copy runs out, and a work handed to a shared machine
|
|
190
|
+
should leave no code behind. Declining is always safe; the worst it costs is a fetch. Naming
|
|
191
|
+
anything else has no effect, since the other three forms reach the cache either way.
|
|
192
|
+
|
|
193
|
+
Declining holds in both directions: the cache is not read for that extension and not written
|
|
194
|
+
either, so a person who asked to leave nothing behind leaves nothing behind.
|
|
195
|
+
|
|
196
|
+
### Where a reference can still be reached from
|
|
197
|
+
|
|
198
|
+
`url` on an embedded source records where the bytes came from:
|
|
199
|
+
|
|
200
|
+
```json
|
|
201
|
+
{"type": "embedded-file", "path": "extensions/thing-<sha256>.js", "url": "https://example.test/thing.js"}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
It does two things: it lets a missing carrier fall back to a fetch, and it is the only thing that
|
|
205
|
+
keeps a path back to a reference form open. Without it the project can never be converted to
|
|
206
|
+
`remote-cacheable` again, because there is nothing to point at.
|
|
207
|
+
|
|
208
|
+
`prepareCatalogExtensionChanges` takes `distribution` as one of the same four names. A catalog
|
|
209
|
+
extension was fetched from an address, so both embedded forms keep that address beside the bytes
|
|
210
|
+
rather than discarding what is already known, and the URL is checked against the pinned bytes
|
|
211
|
+
whichever form it is stored in. A url is optional for the general case, where bytes nobody
|
|
212
|
+
fetched — a costume, a sound, an extension written in the editor — have no address to record.
|
|
213
|
+
|
|
214
|
+
## What identifies one project
|
|
215
|
+
|
|
216
|
+
A manifest may carry a `uri`: the address the project can be downloaded from, which is also what
|
|
217
|
+
identifies it.
|
|
218
|
+
|
|
219
|
+
```json
|
|
220
|
+
{"schemaVersion": 1, "uri": "https://example.test/editor/turbowarp-plus-projects/66/66aa/0193f0a1-2b3c-7d4e-8f01-2233445566aa.sb3", "extensions": []}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
One work, one URI, across every edit. That is why it is an address and not a fingerprint: a
|
|
224
|
+
fingerprint changes on every save, so a browser cache and a record of what the opener chose would
|
|
225
|
+
file one work under a new name each time it was touched. An address does not change when the bytes
|
|
226
|
+
do.
|
|
227
|
+
|
|
228
|
+
`createProjectUUID` and `defaultProjectURI` in `./project-authoring` build the address a project
|
|
229
|
+
is given when its author has not chosen one:
|
|
230
|
+
|
|
231
|
+
```js
|
|
232
|
+
const uri = defaultProjectURI(location.origin + location.pathname, createProjectUUID());
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The origin is the editor's own, never a host it does not serve from, because this address travels
|
|
236
|
+
inside the SB3 when the work is handed on. The identifier is a UUID version 7, so it sorts by when
|
|
237
|
+
the project was first saved, and the two directory levels are taken from its **end** — a version 7
|
|
238
|
+
UUID begins with the top of a millisecond clock, which barely moves, so sharding on its first
|
|
239
|
+
characters would put every project ever created in one directory.
|
|
240
|
+
|
|
241
|
+
The address is deliberately opaque. A readable one would be derived from the title and would
|
|
242
|
+
change when the title changed, filing one work under a second name; readability belongs to the
|
|
243
|
+
deliberate act of publishing, where the author names the address themselves. A list meant for a
|
|
244
|
+
person shows the project's title beside this.
|
|
245
|
+
|
|
246
|
+
Nothing is served from the address until the author publishes there, and that is expected. **A URI
|
|
247
|
+
that does not resolve says nothing**: a work saved but never published and a published work whose
|
|
248
|
+
address rotted look exactly alike. Never decide anything by whether it resolves, least of all what
|
|
249
|
+
to delete. The URI is also the project's own claim and is not verified — a work can name any
|
|
250
|
+
address, including another's. What that can spoil is bookkeeping and nothing else, because the
|
|
251
|
+
cache is keyed by digest and every hit is verified against it.
|
|
252
|
+
|
|
124
253
|
## Source and build
|
|
125
254
|
|
|
126
255
|
The source and the tests are TypeScript, in `src/*.ts` and `test/*.test.ts`, laid out the same way as the other `@kubohiroya/turbowarp-*` packages: `tsconfig.json` typechecks without emitting, `tsconfig.build.json` extends it to emit, and `test/tsconfig.json` extends it for the tests. `npm run build` (`tsc -p tsconfig.build.json`) emits ES2022 ESM and type declarations to `dist/`, and `exports`, `main`/`module` and the root files used by webpack 4 (`packager.js` and the rest) all point at `dist/`. `dist/` is not committed to Git; the `prepare` script builds it when the package is installed as a Git dependency, and the npm package ships the built `dist/`. Under webpack 4, the Babel configuration in `./webpack4` transpiles `dist/` as before.
|
|
@@ -186,7 +315,7 @@ const candidate = await prepareExtensionAddition(currentSB3, {
|
|
|
186
315
|
|
|
187
316
|
## Bulk configuration from a catalog
|
|
188
317
|
|
|
189
|
-
`prepareCatalogExtensionChanges(currentSB3, requests, ports, options)` in `./project-catalog` applies a catalog selection to an SB3 candidate in one pass. A request is `{id, url, distribution: '
|
|
318
|
+
`prepareCatalogExtensionChanges(currentSB3, requests, ports, options)` in `./project-catalog` applies a catalog selection to an SB3 candidate in one pass. A request is `{id, url, distribution: 'remote-non-cacheable' | 'remote-cacheable' | 'embedded-file' | 'embedded-data', execution: 'sandboxed' | 'unsandboxed', dependencies?, version?}`. JS fetching, SHA-256 computation, dependency ordering, verification of every entry and host approval all go through the shared Loader, and `{projectBytes, manifest}` is returned once every entry is approved. The real VM is untouched.
|
|
190
319
|
|
|
191
320
|
When changing the distribution method or execution mode of an existing Plus extension, the verified code, SHA-256, publisher, version and lock are preserved. If the Remote destination does not return the current code, it is rejected rather than silently adopted as an update. `readProjectAuthoringState(input, options)` returns the verified Manifest and can be used to restore UI state. This project information is not an execution permission.
|
|
192
321
|
|
package/dist/index.d.ts
CHANGED
|
@@ -6,17 +6,57 @@
|
|
|
6
6
|
* weaker source — the Manifest pins the SHA-256, so a retained copy either is exactly the pinned
|
|
7
7
|
* code or the load fails, which is why it may be read before the network.
|
|
8
8
|
*/
|
|
9
|
+
/**
|
|
10
|
+
* Where a project keeps an extension.
|
|
11
|
+
*
|
|
12
|
+
* `embedded` is the project carrying the bytes; `remote` is the project carrying only a
|
|
13
|
+
* reference. An embedded source may also record the URL the bytes came from, which is what makes
|
|
14
|
+
* falling back possible when a retained copy is missing — and is the only difference between the
|
|
15
|
+
* two forms this used to spell as separate types.
|
|
16
|
+
*
|
|
17
|
+
* A manifest written before 0.4.0 spells that same embedded source `cached`. It is refused, not
|
|
18
|
+
* translated: this family does not promise to read what older versions wrote, and a refusal
|
|
19
|
+
* names the problem where a silent translation would hide it.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* Where one extension's bytes live, as the author of the project decided.
|
|
23
|
+
*
|
|
24
|
+
* Four names, used unchanged by the SB3 save format and by the screen that offers the choice, so
|
|
25
|
+
* nothing has to be translated between layers. Two hold a reference and two hold the bytes:
|
|
26
|
+
*
|
|
27
|
+
* - `remote-non-cacheable` — a URL, fetched every time. Nothing is kept anywhere.
|
|
28
|
+
* - `remote-cacheable` — a URL, and a verified copy may be kept in this browser.
|
|
29
|
+
* - `embedded-file` — the bytes are a file inside the archive.
|
|
30
|
+
* - `embedded-data` — the bytes are a `data:` value in the project document, which is also what
|
|
31
|
+
* lets a standard TurboWarp host open the project without knowing this manifest.
|
|
32
|
+
*
|
|
33
|
+
* The two embedded forms are siblings: both make a project that runs offline, anywhere, on its
|
|
34
|
+
* own. They differ in the carrier, and the carrier is what decides who else can read it.
|
|
35
|
+
*
|
|
36
|
+
* `url` on an embedded source records where the bytes came from, which is what keeps a path back
|
|
37
|
+
* to a reference open. Bytes nobody fetched — written here, or authored locally — have no origin
|
|
38
|
+
* to record, so it is optional.
|
|
39
|
+
*/
|
|
40
|
+
export type RemoteExtensionSource = Readonly<{
|
|
41
|
+
type: 'remote-non-cacheable' | 'remote-cacheable';
|
|
42
|
+
url: string;
|
|
43
|
+
}>;
|
|
9
44
|
export type ExtensionSource = Readonly<{
|
|
10
|
-
type: '
|
|
11
|
-
path: string;
|
|
12
|
-
} | {
|
|
13
|
-
type: 'remote';
|
|
45
|
+
type: 'remote-non-cacheable';
|
|
14
46
|
url: string;
|
|
15
47
|
} | {
|
|
16
|
-
type: '
|
|
48
|
+
type: 'remote-cacheable';
|
|
17
49
|
url: string;
|
|
50
|
+
} | {
|
|
51
|
+
type: 'embedded-file';
|
|
18
52
|
path: string;
|
|
53
|
+
url?: string;
|
|
54
|
+
} | {
|
|
55
|
+
type: 'embedded-data';
|
|
56
|
+
url?: string;
|
|
19
57
|
}>;
|
|
58
|
+
/** Whether the project holds a reference the loader has to fetch, rather than the bytes. */
|
|
59
|
+
export declare const isRemoteSource: (source: ExtensionSource) => source is RemoteExtensionSource;
|
|
20
60
|
export type ExecutionMode = 'sandboxed' | 'unsandboxed';
|
|
21
61
|
export interface ExtensionSpec {
|
|
22
62
|
readonly id: string;
|
|
@@ -30,6 +70,19 @@ export interface ExtensionSpec {
|
|
|
30
70
|
}
|
|
31
71
|
export interface Manifest {
|
|
32
72
|
readonly schemaVersion: 1;
|
|
73
|
+
/**
|
|
74
|
+
* The address this project can be downloaded from, which is also what identifies it.
|
|
75
|
+
*
|
|
76
|
+
* One work, one URI, across every edit: it is what the browser's caches and the opener's
|
|
77
|
+
* recorded decisions are filed under, so a fingerprint would not do — that changes on every
|
|
78
|
+
* save and would file one work under a new name each time. An address does not.
|
|
79
|
+
*
|
|
80
|
+
* It is the project's own claim and is not verified. Nothing is served from it until the
|
|
81
|
+
* author actually publishes there, so a URI that does not resolve says nothing: a work saved
|
|
82
|
+
* but never published and a published work whose address rotted look exactly alike. Never
|
|
83
|
+
* decide anything, least of all what to delete, by whether it resolves.
|
|
84
|
+
*/
|
|
85
|
+
readonly uri?: string;
|
|
33
86
|
readonly extensions: readonly ExtensionSpec[];
|
|
34
87
|
}
|
|
35
88
|
export interface ResolvedCode extends ExtensionSpec {
|
|
@@ -52,6 +105,16 @@ export interface AuthorizedCode extends ResolvedCode {
|
|
|
52
105
|
}
|
|
53
106
|
export interface OperationOptions {
|
|
54
107
|
signal?: AbortSignal;
|
|
108
|
+
/**
|
|
109
|
+
* Extension IDs the person opening the project will not keep a copy of in this browser.
|
|
110
|
+
*
|
|
111
|
+
* The author sets the ceiling and the opener may come in under it, so this can only ever
|
|
112
|
+
* refuse. A set says that in the type: there is no way to write "cache this anyway" for a
|
|
113
|
+
* source the author marked `remote-non-cacheable`, and declining is always safe — the worst
|
|
114
|
+
* it costs is a fetch. Naming anything else has no effect: `remote-non-cacheable` keeps
|
|
115
|
+
* nothing already, and embedded bytes travel with the project.
|
|
116
|
+
*/
|
|
117
|
+
declineCaching?: ReadonlySet<string>;
|
|
55
118
|
}
|
|
56
119
|
export interface ReadOptions {
|
|
57
120
|
signal: AbortSignal;
|
|
@@ -99,8 +162,15 @@ export type DiagnosticEvent = Readonly<{
|
|
|
99
162
|
code?: string;
|
|
100
163
|
}>;
|
|
101
164
|
export interface LoaderPorts {
|
|
165
|
+
/**
|
|
166
|
+
* The two embedded carriers, each reached the way it is addressed: a file by its path, a
|
|
167
|
+
* `data:` value by the extension's ID, because the manifest entry already carries one and
|
|
168
|
+
* writing the value into the manifest as well would put the bytes in the project twice.
|
|
169
|
+
* A host implements the carriers it can reach; a source needing the other one fails.
|
|
170
|
+
*/
|
|
102
171
|
embeddedReader?: {
|
|
103
|
-
read(path: string, options: ReadOptions): Promise<Uint8Array>;
|
|
172
|
+
read?(path: string, options: ReadOptions): Promise<Uint8Array>;
|
|
173
|
+
readData?(id: string, options: ReadOptions): Promise<Uint8Array>;
|
|
104
174
|
};
|
|
105
175
|
fetcher?: FetchPort;
|
|
106
176
|
trustProvider: {
|