@kubohiroya/turbowarp-extension-loader 0.4.0 → 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.
@@ -1,4 +1,4 @@
1
- import { LoaderError, validateManifest } from './index.js';
1
+ import { isRemoteSource, LoaderError, validateManifest } from './index.js';
2
2
  import { createProjectIO } from './project-codec.js';
3
3
  import { readPreparedProject } from './project-data.js';
4
4
  import { collectVerifiedCodes } from './verified-code.js';
@@ -35,19 +35,19 @@ export async function prepareExtensionAddition(input, request, ports, { zip: Zip
35
35
  if (value !== undefined && (!Number.isSafeInteger(value) || value < 1))
36
36
  fail('INVALID_OPTIONS', `Invalid ${key}`);
37
37
  }
38
- const source = plainObject(request.source) && request.source.type === 'embedded' && request.source.path === undefined ?
38
+ const source = plainObject(request.source) && request.source.type === 'embedded-file' && request.source.path === undefined ?
39
39
  { ...request.source, path: `extensions/${request.id}.js` } : request.source;
40
40
  // Reuse the runtime schema before any bytes are read or network requests begin.
41
41
  const draft = validateManifest({ schemaVersion: 1, extensions: [{ id: request.id, source,
42
42
  execution: request.execution === undefined ? 'unsandboxed' : request.execution,
43
43
  sha256: '0'.repeat(64) }] }, loaderOptions).extensions[0];
44
- if (draft.source.type === 'remote' && request.bytes !== undefined)
44
+ if (isRemoteSource(draft.source) && request.bytes !== undefined)
45
45
  fail('INVALID_ADDITION', 'Remote sources must be fetched from their URL', draft.id);
46
46
  if (request.bytes instanceof Uint8Array && (request.bytes.byteLength < 1 || request.bytes.byteLength > limits.maxBytes)) {
47
47
  fail('INVALID_BYTES', 'Extension file is empty or exceeds the byte limit', draft.id);
48
48
  }
49
49
  const suppliedBytes = request.bytes instanceof Uint8Array ? new Uint8Array(request.bytes) : request.bytes;
50
- if (draft.source.type === 'embedded' && !(suppliedBytes instanceof Uint8Array) && !(suppliedBytes instanceof Blob)) {
50
+ if (draft.source.type === 'embedded-file' && !(suppliedBytes instanceof Uint8Array) && !(suppliedBytes instanceof Blob)) {
51
51
  fail('INVALID_BYTES', 'Embedded additions require a JavaScript file', draft.id);
52
52
  }
53
53
  if (suppliedBytes instanceof Blob && (suppliedBytes.size < 1 || suppliedBytes.size > limits.maxBytes)) {
@@ -98,11 +98,11 @@ export async function prepareExtensionAddition(input, request, ports, { zip: Zip
98
98
  Object.hasOwn(project.extensionURLs || {}, draft.id))
99
99
  fail('DUPLICATE_EXTENSION', 'This extension ID already belongs to the project', draft.id);
100
100
  validateManifest({ schemaVersion: 1, extensions: [...previous, draft] }, loaderOptions);
101
- if (draft.source.type === 'embedded' && Object.hasOwn(files, draft.source.path)) {
101
+ if (draft.source.type === 'embedded-file' && Object.hasOwn(files, draft.source.path)) {
102
102
  fail('SOURCE_PATH_COLLISION', 'The embedded path already exists in this project', draft.id);
103
103
  }
104
104
  let bytes;
105
- if (draft.source.type === 'embedded') {
105
+ if (!isRemoteSource(draft.source)) {
106
106
  bytes = suppliedBytes instanceof Blob ? new Uint8Array(await wait(() => suppliedBytes.arrayBuffer())) : suppliedBytes;
107
107
  }
108
108
  else {
@@ -133,7 +133,7 @@ export async function prepareExtensionAddition(input, request, ports, { zip: Zip
133
133
  project.turboWarpPlus = manifest;
134
134
  project.extensions = [...(project.extensions || []), spec.id];
135
135
  files['project.json'] = encoder.encode(JSON.stringify(project));
136
- if (spec.source.type === 'embedded')
136
+ if (spec.source.type === 'embedded-file')
137
137
  files[spec.source.path] = verified;
138
138
  const archive = new Zip();
139
139
  for (const [path, content] of Object.entries(files))
@@ -143,7 +143,7 @@ export async function prepareExtensionAddition(input, request, ports, { zip: Zip
143
143
  io.activate(candidate.context);
144
144
  // Only the newly fetched URL is snapshotted. Other pinned sources keep the host's fetch policy.
145
145
  const pinned = spec.source;
146
- const fetcher = pinned.type === 'remote' ? {
146
+ const fetcher = isRemoteSource(pinned) ? {
147
147
  fetch(url, options) {
148
148
  if (url === pinned.url)
149
149
  return Promise.resolve({ bytes: new Uint8Array(verified), finalURL: url, redirects: [] });
@@ -159,3 +159,62 @@ export async function prepareExtensionAddition(input, request, ports, { zip: Zip
159
159
  externalSignal?.removeEventListener('abort', abort);
160
160
  }
161
161
  }
162
+ const UUIDV7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
163
+ /** Where an unchosen address is put, relative to whatever origin assigns it. */
164
+ export const PROJECT_URI_PREFIX = 'turbowarp-plus-projects';
165
+ /**
166
+ * A fresh identifier for one project: a UUID version 7.
167
+ *
168
+ * Version 7 and not 4 because the first 48 bits are the millisecond clock, so the identifier
169
+ * itself sorts by when the project was first saved. That costs nothing over a random UUID and
170
+ * leaves the ordering available to anything that wants it.
171
+ * @returns {string} The UUID, in the canonical lowercase form.
172
+ */
173
+ export function createProjectUUID() {
174
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
175
+ const time = Date.now();
176
+ for (let index = 0; index < 6; index++)
177
+ bytes[index] = (time / 2 ** (8 * (5 - index))) & 0xff;
178
+ bytes[6] = 0x70 | (bytes[6] & 0x0f);
179
+ bytes[8] = 0x80 | (bytes[8] & 0x3f);
180
+ const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
181
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
182
+ }
183
+ /**
184
+ * The address a project is given when its author has not chosen one.
185
+ *
186
+ * Every project needs an address from its first save, because that address is what identifies it:
187
+ * the browser's caches and the opener's recorded decisions are filed under it. Nothing is served
188
+ * there until the author actually publishes, and that is fine — the point is that the name does
189
+ * not have to change if they ever do.
190
+ *
191
+ * So this address is deliberately opaque. A readable one would be derived from the title and
192
+ * would change when the title changed, filing one work under a second name. Readability belongs
193
+ * to the deliberate act of publishing, where the author names the address themselves; a list that
194
+ * has to be read by a person shows the project's title beside this.
195
+ *
196
+ * The two directory levels divide the projects so that no single directory grows without bound,
197
+ * and they are taken from the **end** of the UUID. A version 7 UUID begins with the top of a
198
+ * millisecond clock, which barely moves, so sharding on its first characters would put every
199
+ * project ever created in one directory. The last characters are the random half.
200
+ * @param {string} origin Where the editor that assigns this is served from; it claims no other host.
201
+ * @param {string} uuid A version 7 UUID, from createProjectUUID.
202
+ * @returns {string} The absolute HTTPS address.
203
+ */
204
+ export function defaultProjectURI(origin, uuid) {
205
+ if (!UUIDV7.test(uuid))
206
+ fail('INVALID_PROJECT_URI', 'A version 7 UUID is required');
207
+ let base;
208
+ try {
209
+ base = new URL(origin);
210
+ }
211
+ catch {
212
+ fail('INVALID_PROJECT_URI', 'An absolute origin is required');
213
+ }
214
+ if (base.protocol !== 'https:' || base.username || base.password) {
215
+ fail('INVALID_PROJECT_URI', 'A project origin must be HTTPS without credentials');
216
+ }
217
+ const tail = uuid.replaceAll('-', '').slice(-4);
218
+ const path = `${base.pathname.replace(/\/*$/, '')}/${PROJECT_URI_PREFIX}/${tail.slice(0, 2)}/${tail}/${uuid}.sb3`;
219
+ return new URL(path, base.origin).href;
220
+ }
@@ -1,16 +1,19 @@
1
- import { type ExecutionMode, type LoaderOptions, type LoaderPorts, type Manifest } from './index.js';
1
+ import { type ExecutionMode, type ExtensionSource, type LoaderOptions, type LoaderPorts, type Manifest } from './index.js';
2
2
  import { type ArchiveLimits, type ZipConstructor } from './project-codec.js';
3
3
  export interface CatalogExtensionRequest {
4
4
  id: string;
5
5
  url: string;
6
6
  /**
7
- * Where the project keeps it: carrying the bytes, or only a reference.
7
+ * Which of the four forms the project keeps it in, as the author decided.
8
8
  *
9
- * Either way the manifest records where the bytes came from, so a project that carries them
10
- * can still recover if its retained copy is lost. There is deliberately no third value:
11
- * where bytes are cached in the browser is the opener's decision, passed to `load()`.
9
+ * The same four names the manifest and the SB3 save format use, so this is the author's
10
+ * decision written once. Either reference form records the address; either embedded form
11
+ * records it too, beside the bytes, which is what keeps a path back to a reference open.
12
+ *
13
+ * Whether the person opening the project actually keeps a copy of a `remote-cacheable`
14
+ * extension is theirs to decline, through `declineCaching` on `load()`.
12
15
  */
13
- distribution: 'embedded' | 'remote';
16
+ distribution: ExtensionSource['type'];
14
17
  execution: ExecutionMode;
15
18
  dependencies?: string[];
16
19
  version?: string;
@@ -1,9 +1,17 @@
1
- import { LoaderError, validateManifest } from './index.js';
2
- import { createProjectIO } from './project-codec.js';
1
+ import { isRemoteSource, LoaderError, validateManifest } from './index.js';
2
+ 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
6
  const encoder = new TextEncoder();
7
+ // A standard TurboWarp host looks for a URL in the project document and knows nothing of this
8
+ // manifest, so for that carrier the bytes have to be the address itself.
9
+ const dataCarrier = (bytes) => {
10
+ let binary = '';
11
+ for (const byte of bytes)
12
+ binary += String.fromCharCode(byte);
13
+ return `${DATA_CARRIER_PREFIX}${btoa(binary)}`;
14
+ };
7
15
  const digest = async (bytes) => Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), value => value.toString(16).padStart(2, '0')).join('');
8
16
  function fail(code, message, id) { throw new LoaderError(code, message, id); }
9
17
  export async function readProjectAuthoringState(input, { zip, signal, archiveOptions } = {}) {
@@ -63,13 +71,16 @@ export async function prepareCatalogExtensionChanges(input, requests, ports, { z
63
71
  try {
64
72
  const seen = new Set();
65
73
  const choices = requests.map(request => {
66
- if (!request || !['remote', 'embedded'].includes(request.distribution) || seen.has(request.id)) {
74
+ if (!request || !['remote-non-cacheable', 'remote-cacheable', 'embedded-file', 'embedded-data']
75
+ .includes(request.distribution) || seen.has(request.id)) {
67
76
  fail('INVALID_ADDITION', 'Invalid or duplicate catalog selection', request?.id);
68
77
  }
69
78
  seen.add(request.id);
70
79
  const execution = request.execution;
71
80
  const spec = validateManifest({ schemaVersion: 1, extensions: [{ id: request.id,
72
- source: { type: 'remote', url: request.url }, execution, sha256: '0'.repeat(64),
81
+ // Validated as a reference whatever the request asks for: this is only here to
82
+ // put the URL and the execution mode through the schema before anything is read.
83
+ source: { type: 'remote-cacheable', url: request.url }, execution, sha256: '0'.repeat(64),
73
84
  ...(request.version ? { version: request.version } : {}) }] }, loaderOptions).extensions[0];
74
85
  if (request.dependencies !== undefined && (!Array.isArray(request.dependencies) ||
75
86
  request.dependencies.some(id => typeof id !== 'string')))
@@ -116,24 +127,29 @@ export async function prepareCatalogExtensionChanges(input, requests, ports, { z
116
127
  }
117
128
  // Copies the replaced entries kept; released below unless a final entry still names them.
118
129
  const replacedPaths = new Set();
130
+ // Bytes for a data carrier, kept until the project document is written.
131
+ const carriers = new Map();
119
132
  for (const choice of choices) {
120
133
  const replaced = prepared.manifest?.extensions.find(spec => spec.id === choice.id)?.source;
121
- if (replaced && replaced.type !== 'remote')
134
+ if (replaced?.type === 'embedded-file')
122
135
  replacedPaths.add(replaced.path);
123
136
  const old = previous.get(choice.id);
124
- // Every catalog request was validated as a Remote source above.
125
- const bytes = old ? encoder.encode(old.code) : await fetchBytes(choice.source.url);
137
+ // Validated as a reference above, so it has an address to fetch and to record.
138
+ if (!isRemoteSource(choice.source))
139
+ fail('INVALID_ADDITION', 'A catalog request is a reference', choice.id);
140
+ const origin = choice.source.url;
141
+ const bytes = old ? encoder.encode(old.code) : await fetchBytes(origin);
126
142
  const sha256 = await wait(() => digest(bytes));
127
143
  if (old && old.sha256 !== sha256)
128
144
  fail('CONVERSION_INTEGRITY', 'Source conversion changed bytes', choice.id);
129
145
  const retainedPath = `extensions/${choice.id}-${sha256}.js`;
130
- // A catalog extension was fetched from an address, so embedding keeps that address
131
- // beside the copy rather than discarding what is already known. Bytes nobody fetched —
132
- // a costume, a sound, an extension written here — have no address to record, which is
133
- // why a url is optional on an embedded source.
134
- const source = choice.distribution === 'embedded' ?
135
- { type: 'embedded', path: retainedPath, url: choice.source.url } :
136
- choice.source;
146
+ // A catalog extension was fetched from an address, so either embedded form keeps that
147
+ // address beside the bytes rather than discarding what is already known. Bytes nobody
148
+ // fetched — a costume, a sound, an extension written here — have no address to record,
149
+ // which is why a url is optional on an embedded source.
150
+ const source = choice.distribution === 'embedded-file' ? { type: 'embedded-file', path: retainedPath, url: origin } :
151
+ choice.distribution === 'embedded-data' ? { type: 'embedded-data', url: origin } :
152
+ { type: choice.distribution, url: origin };
137
153
  // A recorded URL is a promise about what it serves, so it must still serve exactly
138
154
  // the pinned bytes — whether it is the only place they live or sits beside a copy.
139
155
  if (source.url !== undefined) {
@@ -142,13 +158,16 @@ export async function prepareCatalogExtensionChanges(input, requests, ports, { z
142
158
  fail('DIGEST_MISMATCH', 'Remote source does not serve the currently pinned bytes', choice.id);
143
159
  }
144
160
  }
145
- // An embedded choice retains a copy in the project.
146
- if (source.type !== 'remote') {
161
+ // Only the file carrier has an archive entry; the data carrier is written below,
162
+ // into the project document, because that is where a standard host looks for it.
163
+ if (source.type === 'embedded-file') {
147
164
  if (Object.hasOwn(files, source.path) && await wait(() => digest(files[source.path])) !== sha256) {
148
165
  fail('SOURCE_PATH_COLLISION', 'The embedded path belongs to different content', choice.id);
149
166
  }
150
167
  files[source.path] = new Uint8Array(bytes);
151
168
  }
169
+ if (source.type === 'embedded-data')
170
+ carriers.set(choice.id, new Uint8Array(bytes));
152
171
  extensions.set(choice.id, { id: choice.id, source, execution: choice.execution, sha256,
153
172
  ...((old?.version || choice.version) ? { version: old?.version || choice.version } : {}),
154
173
  ...(old?.publisher ? { publisher: old.publisher } : {}),
@@ -156,15 +175,19 @@ export async function prepareCatalogExtensionChanges(input, requests, ports, { z
156
175
  }
157
176
  const manifest = validateManifest({ schemaVersion: 1, extensions: [...extensions.values()] }, loaderOptions);
158
177
  for (const spec of manifest.extensions)
159
- if (spec.source.type !== 'remote')
178
+ if (spec.source.type === 'embedded-file')
160
179
  replacedPaths.delete(spec.source.path);
161
180
  for (const path of replacedPaths)
162
181
  delete files[path];
163
182
  project.turboWarpPlus = manifest;
164
183
  project.extensions = [...new Set([...(project.extensions || []), ...manifest.extensions.map(spec => spec.id)])];
165
184
  const urls = { ...(project.extensionURLs || {}) };
166
- for (const spec of manifest.extensions)
167
- delete urls[spec.id];
185
+ for (const spec of manifest.extensions) {
186
+ if (spec.source.type === 'embedded-data')
187
+ urls[spec.id] = dataCarrier(carriers.get(spec.id));
188
+ else
189
+ delete urls[spec.id];
190
+ }
168
191
  project.extensionURLs = urls;
169
192
  files['project.json'] = encoder.encode(JSON.stringify(project));
170
193
  const archive = new Zip();
@@ -45,11 +45,17 @@ export interface ProjectIO {
45
45
  signal?: AbortSignal;
46
46
  maxBytes?: number;
47
47
  }): Promise<Uint8Array>;
48
+ readData(id: string, options?: {
49
+ signal?: AbortSignal;
50
+ maxBytes?: number;
51
+ }): Promise<Uint8Array>;
48
52
  };
49
53
  restoreJSON<T extends string | Record<string, unknown>>(json: T, context: ProjectContext | null): T;
50
54
  restoreFiles(files: Record<string, Uint8Array>, context: ProjectContext | null): Record<string, Uint8Array>;
51
55
  restoreArchive<T extends ZipArchive>(archive: T, context: ProjectContext | null): T;
52
56
  }
57
+ /** The one carrier spelling this family writes, and the only one a standard host is given. */
58
+ export declare const DATA_CARRIER_PREFIX = "data:text/javascript;base64,";
53
59
  /** Validate the central directory before JSZip normalizes paths or inflates data. */
54
60
  export declare function inspectZip(bytes: Uint8Array, options?: ArchiveLimits): Map<string, ZipEntryMetadata>;
55
61
  /** ZIP implementation is injected; all manifest/archive policy remains in the core. */
@@ -4,6 +4,8 @@ const DEFAULTS = Object.freeze({ maxArchiveBytes: DEFAULT_TOTAL_BYTES, maxTotalB
4
4
  maxEntryBytes: DEFAULT_ENTRY_BYTES, maxProjectBytes: DEFAULT_PROJECT_JSON_BYTES, maxEntries: 4096, maxRatio: 1000 });
5
5
  const decoder = new TextDecoder('utf-8', { fatal: true });
6
6
  const encoder = new TextEncoder();
7
+ /** The one carrier spelling this family writes, and the only one a standard host is given. */
8
+ export const DATA_CARRIER_PREFIX = 'data:text/javascript;base64,';
7
9
  function fail(message) { throw new LoaderError('INVALID_ARCHIVE', message); }
8
10
  const cancelled = (signal) => { if (signal?.aborted)
9
11
  throw new LoaderError('CANCELLED', 'Project preparation cancelled'); };
@@ -255,7 +257,7 @@ export function createProjectIO({ zip: Zip, ...options } = {}) {
255
257
  if (data.extensionURLs !== undefined && (!data.extensionURLs || typeof data.extensionURLs !== 'object' || Array.isArray(data.extensionURLs)))
256
258
  throw new LoaderError('INVALID_PROJECT', 'Project extensionURLs must be an object');
257
259
  for (const spec of manifest.extensions)
258
- if (spec.source.type !== 'remote' && !files.has(spec.source.path))
260
+ if (spec.source.type === 'embedded-file' && !files.has(spec.source.path))
259
261
  fail('Missing embedded extension entry');
260
262
  const context = Object.freeze({});
261
263
  contexts.set(context, { manifest, project: data, files });
@@ -289,6 +291,32 @@ export function createProjectIO({ zip: Zip, ...options } = {}) {
289
291
  if (!bytes || bytes.length > (maxBytes ?? limits.maxEntryBytes))
290
292
  fail('Missing or oversized embedded content');
291
293
  return new Uint8Array(bytes);
294
+ },
295
+ // The data carrier lives in the project document, under the extension's own ID,
296
+ // because that is the one place a standard TurboWarp host looks. Decoding is not
297
+ // evaluating: what comes back is bytes, verified against the digest like any other.
298
+ async readData(id, { signal, maxBytes } = {}) {
299
+ cancelled(signal);
300
+ const carrier = get(active)?.project.extensionURLs?.[id];
301
+ if (typeof carrier !== 'string' || !carrier.startsWith(DATA_CARRIER_PREFIX))
302
+ fail('Missing embedded data carrier');
303
+ const encoded = carrier.slice(DATA_CARRIER_PREFIX.length);
304
+ // Reject before allocating: base64 holds 3 bytes for every 4 characters.
305
+ if (encoded.length / 4 * 3 > (maxBytes ?? limits.maxEntryBytes) + 2)
306
+ fail('Missing or oversized embedded content');
307
+ let binary;
308
+ try {
309
+ binary = atob(encoded);
310
+ }
311
+ catch {
312
+ fail('Embedded data carrier is not base64');
313
+ }
314
+ const bytes = new Uint8Array(binary.length);
315
+ for (let index = 0; index < binary.length; index++)
316
+ bytes[index] = binary.charCodeAt(index);
317
+ if (bytes.length > (maxBytes ?? limits.maxEntryBytes))
318
+ fail('Missing or oversized embedded content');
319
+ return bytes;
292
320
  }
293
321
  }),
294
322
  restoreJSON,
@@ -1,4 +1,4 @@
1
- import { LoaderError } from './index.js';
1
+ import { isRemoteSource, LoaderError } from './index.js';
2
2
  export function createSessionTrustProvider({ requestApproval, maxApprovals = 128, hostPolicy } = {}) {
3
3
  if (typeof requestApproval !== 'function' || !Number.isSafeInteger(maxApprovals) || maxApprovals < 1) {
4
4
  throw new TypeError('A host approval prompt and bounded approval capacity are required');
@@ -25,7 +25,7 @@ export function createSessionTrustProvider({ requestApproval, maxApprovals = 128
25
25
  // An existing URL-only host policy cannot authorize an archive path.
26
26
  // Recheck before cached consent so later host restrictions take precedence.
27
27
  if (policy.canLoad || policy.mode) {
28
- if (code.source.type !== 'remote')
28
+ if (!isRemoteSource(code.source))
29
29
  return Object.freeze({ allowed: false });
30
30
  if (policy.canLoad) {
31
31
  const allowed = await policy.canLoad(code.source.url, { signal });
@@ -1 +1 @@
1
- export {createIndexedDBContentCache} from './dist/indexeddb-cache.js';
1
+ export {createIndexedDBContentCache, SHARED_BUCKET} from './dist/indexeddb-cache.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubohiroya/turbowarp-extension-loader",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Host-independent verified TurboWarp Plus extension loading core",
5
5
  "keywords": [
6
6
  "turbowarp",
@@ -94,6 +94,7 @@
94
94
  "devDependencies": {
95
95
  "@turbowarp/jszip": "3.12.0",
96
96
  "@types/node": "26.6.6",
97
+ "fake-indexeddb": "6.2.7",
97
98
  "typescript": "6.0.3"
98
99
  },
99
100
  "publishConfig": {