@kubohiroya/turbowarp-extension-loader 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,21 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.8.0
4
+
5
+ Released on 2026-11-03. Two additions a host needs to give a project an address at save time and to keep one cache across
6
+ projects. Both are additive: the string form of `appURI`, every existing method and every default
7
+ behave as before.
8
+
9
+ - `createIndexedDBContentCache({appURI})` also accepts `() => string | undefined`, read at each
10
+ write like `sharedDigests`, so a host that keeps one cache while projects are reopened files each
11
+ write under the project open at that moment. `undefined` files the write as shared, as a missing
12
+ `appURI` always has. An `appURI` that is neither a string nor a function is now refused with
13
+ `Invalid IndexedDB cache options` instead of being used as a bucket name.
14
+ - `ProjectIO.assignProjectURI(context, uri, {replace})` in `./project-codec` returns a new context
15
+ whose Manifest carries `uri`, so a project whose Manifest has no address can be given one at its
16
+ first save and keep it on later saves. The address is checked like the `uri` option of `prepare*`
17
+ (`INVALID_PROJECT_URI`). An existing different address is refused with the new code
18
+ `PROJECT_URI_CONFLICT` unless `replace: true`; the same address returns the context unchanged.
4
19
 
5
20
  ## 0.7.0
6
21
 
package/README.ja.md CHANGED
@@ -151,6 +151,17 @@ await cache.deleteBucket(manifest.uri); // 手放す。他が参照してい
151
151
  ごとに読むので、カタログの読み込みに合わせて埋めていけます。どの作品を開いているか言えないホスト
152
152
  は、すべてを shared に入れます。名前を推測するのは、無いと認めるより悪いからです。
153
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
+
154
165
  バケットを手放すと claim が落ち、**他が参照していない**バイト列だけが回収されます。2回参照されて
155
166
  いる1つの実体は残ります。もう一方の作品は失うことを求めていません。容量超過で追い出すときは
156
167
  claim も一緒に落ちるので、バケットが「もう無いもの」を並べることはありません。
@@ -228,18 +239,20 @@ Manifest は `uri` を持てます。その作品をダウンロードできる
228
239
  `prepareCatalogExtensionChanges` と `prepareExtensionAddition` は `uri` を受け取ります。作品が
229
240
  最初の住所を得るのも、作者が選んだ住所へ移るのも、この経路です。何も言わなければ作品が持って
230
241
  いる住所がそのまま残り、`readProjectAuthoringState` がその住所を報告するので、ホストは初回保存
231
- かどうかを決める前に見分けられます。割り当てるかどうかはホストの判断です。作者が住所を選んだ
242
+ かどうかを決める前に見分けられます。`./project-codec` で開いた作品の Manifest に住所が
243
+ 無ければ、保存のときに `assignProjectURI` で与えます(「SB3 codec」を参照)。割り当てるかどうかはホストの判断です。作者が住所を選んだ
232
244
  のか、既定が必要な初回保存なのかを知っているのはホストだけだからです。
233
245
 
234
246
  `./project-authoring` の `createProjectUUID` と `defaultProjectURI` が、作者が住所を選んで
235
247
  いないときに与える住所を組みます。
236
248
 
237
249
  ```js
238
- const uri = defaultProjectURI(location.origin + location.pathname, createProjectUUID());
250
+ const uri = defaultProjectURI(location.origin, createProjectUUID());
239
251
  ```
240
252
 
241
- オリジンはエディター自身のもので、配信していない host を名乗ることはありません。この住所は作品
242
- を人に渡すとき SB3 の中に入って一緒に移動するからです。識別子は UUID version 7 なので、初回保存
253
+ オリジンはエディターをブラウザーで起動したドメインのもので、パスは付けません(`editor.html` の
254
+ パスを付けると、住所がその下にできてしまいます)。配信していない host を名乗ることはありません。
255
+ この住所は作品を人に渡すとき SB3 の中に入って一緒に移動するからです。識別子は UUID version 7 なので、初回保存
243
256
  の時刻順に並びます。2段のディレクトリは UUID の**末尾**から取ります。version 7 の先頭はミリ秒
244
257
  時計の上位で、ほとんど動かないので、先頭でシャーディングすると**作られた全作品が1つの
245
258
  ディレクトリに入ります**。
@@ -280,6 +293,22 @@ npm run check
280
293
 
281
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` を復元済みであることを前提とします。
282
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
+
283
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 自体を呼びません。
284
313
 
285
314
  ## 追加のホスト向けAPI
package/README.md CHANGED
@@ -152,6 +152,17 @@ is read at each write, so a host may keep filling it as its catalog loads. A hos
152
152
  which project it is opening files everything as shared, because guessing a name is worse than
153
153
  admitting there is none.
154
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
+
155
166
  Releasing a bucket drops its claims and reclaims only the bytes **nothing else refers to**. One
156
167
  copy referred to twice stays: the other project did not ask to lose it. An eviction takes the
157
168
  claims on it as well, so a bucket never lists bytes that are gone.
@@ -231,7 +242,9 @@ do.
231
242
  `prepareCatalogExtensionChanges` and `prepareExtensionAddition` take a `uri` option, which is how
232
243
  a project gets its first address and how it moves to one the author chose. Saying nothing keeps
233
244
  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
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
235
248
  call: only the host knows whether the author chose an address or whether this is a first save
236
249
  that needs a default.
237
250
 
@@ -239,11 +252,12 @@ that needs a default.
239
252
  is given when its author has not chosen one:
240
253
 
241
254
  ```js
242
- const uri = defaultProjectURI(location.origin + location.pathname, createProjectUUID());
255
+ const uri = defaultProjectURI(location.origin, createProjectUUID());
243
256
  ```
244
257
 
245
- The origin is the editor's own, never a host it does not serve from, because this address travels
246
- inside the SB3 when the work is handed on. The identifier is a UUID version 7, so it sorts by when
258
+ The origin is that of the domain the editor was opened at, with no path (passing the path of
259
+ `editor.html` would put the address under it). It is never a host the editor does not serve from,
260
+ because this address travels inside the SB3 when the work is handed on. The identifier is a UUID version 7, so it sorts by when
247
261
  the project was first saved, and the two directory levels are taken from its **end** — a version 7
248
262
  UUID begins with the top of a millisecond clock, which barely moves, so sharding on its first
249
263
  characters would put every project ever created in one directory.
@@ -285,6 +299,23 @@ Wire the Loader inside a host feature flag that is fixed at startup and off by d
285
299
 
286
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`.
287
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
+
288
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.
289
320
 
290
321
  ## Additional host-facing API
@@ -30,8 +30,12 @@ export interface IndexedDBContentCacheOptions {
30
30
  * Without one, everything is filed as shared: a host that cannot say which project it is
31
31
  * opening cannot have per-project accounting, and guessing a name would be worse than
32
32
  * admitting there is none.
33
+ *
34
+ * A host that keeps one cache while projects are opened and closed passes a function instead:
35
+ * it is read at the time of each write, like `sharedDigests`, so each write is filed under the
36
+ * project open at that moment. A function that returns `undefined` files that write as shared.
33
37
  */
34
- appURI?: string;
38
+ appURI?: string | (() => string | undefined);
35
39
  /**
36
40
  * Digests that belong to no one project, read at the time of each write.
37
41
  *
@@ -17,8 +17,17 @@ export function createIndexedDBContentCache({ indexedDB = globalThis.indexedDB,
17
17
  throw new Error('IndexedDB unavailable');
18
18
  if (typeof databaseName !== 'string' || !databaseName || !Number.isSafeInteger(maxBytes) || maxBytes < 1 ||
19
19
  !Number.isSafeInteger(maxDecompressedBytes) || maxDecompressedBytes < 1 ||
20
- typeof now !== 'function')
20
+ typeof now !== 'function' ||
21
+ (appURI !== undefined && typeof appURI !== 'string' && typeof appURI !== 'function')) {
21
22
  throw new TypeError('Invalid IndexedDB cache options');
23
+ }
24
+ /** The project a write is filed under, read when it happens. */
25
+ function currentAppURI() {
26
+ const uri = typeof appURI === 'function' ? appURI() : appURI;
27
+ if (uri !== undefined && typeof uri !== 'string')
28
+ throw new TypeError('appURI must return a string or undefined');
29
+ return uri;
30
+ }
22
31
  let dbPromise;
23
32
  function open() {
24
33
  if (!dbPromise) {
@@ -192,12 +201,15 @@ export function createIndexedDBContentCache({ indexedDB = globalThis.indexedDB,
192
201
  throw signal.reason;
193
202
  if (bytes.byteLength > maxDecompressedBytes)
194
203
  throw new RangeError('Cache input exceeds decompressed size limit');
204
+ // Read when put() is called, not after compressing: the project open now is the one
205
+ // these bytes were fetched for, even if another is opened before the write lands.
206
+ const uri = currentAppURI();
195
207
  const originalBytes = bytes.byteLength;
196
208
  const compressed = await gzipBytes(bytes);
197
209
  if (signal?.aborted)
198
210
  throw signal.reason;
199
211
  const db = await open();
200
- const bucket = appURI === undefined || sharedDigests?.has(key) ? SHARED_BUCKET : appURI;
212
+ const bucket = uri === undefined || sharedDigests?.has(key) ? SHARED_BUCKET : uri;
201
213
  return transaction(db, 'readwrite', (store, _set, tx) => {
202
214
  const usage = tx.objectStore('usage');
203
215
  const time = now();
@@ -47,8 +47,9 @@ export declare const PROJECT_URI_PREFIX = "turbowarp-plus-projects";
47
47
  * A fresh identifier for one project: a UUID version 7.
48
48
  *
49
49
  * Version 7 and not 4 because the first 48 bits are the millisecond clock, so the identifier
50
- * itself sorts by when the project was first saved. That costs nothing over a random UUID and
51
- * leaves the ordering available to anything that wants it.
50
+ * itself sorts by when the project was first saved, to the millisecond. That costs nothing over a
51
+ * random UUID and leaves the ordering available to anything that wants it. Two identifiers made in
52
+ * the same millisecond are ordered by their random bits, not by which came first.
52
53
  * @returns {string} The UUID, in the canonical lowercase form.
53
54
  */
54
55
  export declare function createProjectUUID(): string;
@@ -173,8 +173,9 @@ export const PROJECT_URI_PREFIX = 'turbowarp-plus-projects';
173
173
  * A fresh identifier for one project: a UUID version 7.
174
174
  *
175
175
  * Version 7 and not 4 because the first 48 bits are the millisecond clock, so the identifier
176
- * itself sorts by when the project was first saved. That costs nothing over a random UUID and
177
- * 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.
178
179
  * @returns {string} The UUID, in the canonical lowercase form.
179
180
  */
180
181
  export function createProjectUUID() {
@@ -53,6 +53,23 @@ export interface ProjectIO {
53
53
  restoreJSON<T extends string | Record<string, unknown>>(json: T, context: ProjectContext | null): T;
54
54
  restoreFiles(files: Record<string, Uint8Array>, context: ProjectContext | null): Record<string, Uint8Array>;
55
55
  restoreArchive<T extends ZipArchive>(archive: T, context: ProjectContext | null): T;
56
+ /**
57
+ * A context like `context` whose Manifest carries `uri`, for the restore calls that follow.
58
+ *
59
+ * This is how a project whose Manifest has no address gets one when it is first saved, and
60
+ * keeps it on every later save: the host holds on to the context this returns. The address
61
+ * follows the same rule as the `uri` option of `prepareExtensionAddition` and
62
+ * `prepareCatalogExtensionChanges` (`INVALID_PROJECT_URI` unless HTTPS without credentials or
63
+ * fragment), and comes back normalized in the Manifest.
64
+ *
65
+ * An address the project already has is never replaced unless `replace` says so: a different
66
+ * one is refused with `PROJECT_URI_CONFLICT`, and the same one returns `context` itself. The
67
+ * context passed in is left as it was. A project with no Manifest (a `null` context) has no
68
+ * address to carry, and is refused.
69
+ */
70
+ assignProjectURI(context: ProjectContext, uri: string, options?: {
71
+ replace?: boolean;
72
+ }): ProjectContext;
56
73
  }
57
74
  /** The one carrier spelling this family writes, and the only one a standard host is given. */
58
75
  export declare const DATA_CARRIER_PREFIX = "data:text/javascript;base64,";
@@ -1,4 +1,4 @@
1
- import { discoverManifest, LoaderError } from './index.js';
1
+ import { discoverManifest, LoaderError, validateManifest } from './index.js';
2
2
  import { DEFAULT_ENTRY_BYTES, DEFAULT_PROJECT_JSON_BYTES, DEFAULT_TOTAL_BYTES } from './limits.js';
3
3
  const DEFAULTS = Object.freeze({ maxArchiveBytes: DEFAULT_TOTAL_BYTES, maxTotalBytes: DEFAULT_TOTAL_BYTES,
4
4
  maxEntryBytes: DEFAULT_ENTRY_BYTES, maxProjectBytes: DEFAULT_PROJECT_JSON_BYTES, maxEntries: 4096, maxRatio: 1000 });
@@ -342,6 +342,24 @@ export function createProjectIO({ zip: Zip, ...options } = {}) {
342
342
  if (name !== 'project.json' && (!archive.file(name) || name.startsWith('extensions/')))
343
343
  archive.file(name, new Uint8Array(bytes), { dir: name.endsWith('/'), createFolders: false });
344
344
  return archive;
345
+ },
346
+ assignProjectURI(context, uri, { replace = false } = {}) {
347
+ const state = get(context);
348
+ if (!state)
349
+ throw new TypeError('A project without a manifest has no address to assign');
350
+ // The one address rule, through the one place that applies it.
351
+ const address = validateManifest({ schemaVersion: 1, uri, extensions: [] }).uri;
352
+ const current = state.manifest.uri;
353
+ if (current === address)
354
+ return context;
355
+ // Saying nothing keeps the address a project has; replacing it has to be asked for.
356
+ if (current !== undefined && replace !== true) {
357
+ throw new LoaderError('PROJECT_URI_CONFLICT', 'This project already has a different address');
358
+ }
359
+ const manifest = Object.freeze({ schemaVersion: 1, uri: address, extensions: state.manifest.extensions });
360
+ const next = Object.freeze({});
361
+ contexts.set(next, { manifest, project: state.project, files: state.files });
362
+ return next;
345
363
  }
346
364
  });
347
365
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubohiroya/turbowarp-extension-loader",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Host-independent verified TurboWarp Plus extension loading core",
5
5
  "keywords": [
6
6
  "turbowarp",