@kubohiroya/turbowarp-extension-loader 0.4.0 → 0.5.1
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 +113 -39
- package/README.md +119 -42
- package/dist/index.d.ts +63 -27
- package/dist/index.js +57 -25
- package/dist/indexeddb-cache.d.ts +59 -1
- package/dist/indexeddb-cache.js +136 -11
- package/dist/packager.js +12 -5
- package/dist/project-authoring.d.ts +35 -2
- package/dist/project-authoring.js +72 -10
- package/dist/project-catalog.d.ts +9 -6
- package/dist/project-catalog.js +47 -20
- 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
|
@@ -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
|
|
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)) {
|
|
@@ -94,15 +94,18 @@ export async function prepareExtensionAddition(input, request, ports, { zip: Zip
|
|
|
94
94
|
if (project.extensionURLs !== undefined && !plainObject(project.extensionURLs))
|
|
95
95
|
fail('INVALID_PROJECT', 'Project extensionURLs must be an object');
|
|
96
96
|
const previous = prepared.manifest?.extensions || [];
|
|
97
|
+
// Carried through every rebuild below: an address that changed when an extension was
|
|
98
|
+
// added would not be an address.
|
|
99
|
+
const carried = prepared.manifest?.uri === undefined ? {} : { uri: prepared.manifest.uri };
|
|
97
100
|
if (previous.some(spec => spec.id === draft.id) || (project.extensions || []).includes(draft.id) ||
|
|
98
101
|
Object.hasOwn(project.extensionURLs || {}, draft.id))
|
|
99
102
|
fail('DUPLICATE_EXTENSION', 'This extension ID already belongs to the project', draft.id);
|
|
100
|
-
validateManifest({ schemaVersion: 1, extensions: [...previous, draft] }, loaderOptions);
|
|
101
|
-
if (draft.source.type === 'embedded' && Object.hasOwn(files, draft.source.path)) {
|
|
103
|
+
validateManifest({ schemaVersion: 1, ...carried, extensions: [...previous, draft] }, loaderOptions);
|
|
104
|
+
if (draft.source.type === 'embedded-file' && Object.hasOwn(files, draft.source.path)) {
|
|
102
105
|
fail('SOURCE_PATH_COLLISION', 'The embedded path already exists in this project', draft.id);
|
|
103
106
|
}
|
|
104
107
|
let bytes;
|
|
105
|
-
if (draft.source
|
|
108
|
+
if (!isRemoteSource(draft.source)) {
|
|
106
109
|
bytes = suppliedBytes instanceof Blob ? new Uint8Array(await wait(() => suppliedBytes.arrayBuffer())) : suppliedBytes;
|
|
107
110
|
}
|
|
108
111
|
else {
|
|
@@ -128,12 +131,12 @@ export async function prepareExtensionAddition(input, request, ports, { zip: Zip
|
|
|
128
131
|
}
|
|
129
132
|
const verified = new Uint8Array(bytes);
|
|
130
133
|
const sha256 = await wait(() => digest(verified));
|
|
131
|
-
const manifest = validateManifest({ schemaVersion: 1, extensions: [...previous, { ...draft, sha256 }] }, loaderOptions);
|
|
134
|
+
const manifest = validateManifest({ schemaVersion: 1, ...carried, extensions: [...previous, { ...draft, sha256 }] }, loaderOptions);
|
|
132
135
|
const spec = manifest.extensions.find(item => item.id === draft.id);
|
|
133
136
|
project.turboWarpPlus = manifest;
|
|
134
137
|
project.extensions = [...(project.extensions || []), spec.id];
|
|
135
138
|
files['project.json'] = encoder.encode(JSON.stringify(project));
|
|
136
|
-
if (spec.source.type === 'embedded')
|
|
139
|
+
if (spec.source.type === 'embedded-file')
|
|
137
140
|
files[spec.source.path] = verified;
|
|
138
141
|
const archive = new Zip();
|
|
139
142
|
for (const [path, content] of Object.entries(files))
|
|
@@ -143,7 +146,7 @@ export async function prepareExtensionAddition(input, request, ports, { zip: Zip
|
|
|
143
146
|
io.activate(candidate.context);
|
|
144
147
|
// Only the newly fetched URL is snapshotted. Other pinned sources keep the host's fetch policy.
|
|
145
148
|
const pinned = spec.source;
|
|
146
|
-
const fetcher = pinned
|
|
149
|
+
const fetcher = isRemoteSource(pinned) ? {
|
|
147
150
|
fetch(url, options) {
|
|
148
151
|
if (url === pinned.url)
|
|
149
152
|
return Promise.resolve({ bytes: new Uint8Array(verified), finalURL: url, redirects: [] });
|
|
@@ -159,3 +162,62 @@ export async function prepareExtensionAddition(input, request, ports, { zip: Zip
|
|
|
159
162
|
externalSignal?.removeEventListener('abort', abort);
|
|
160
163
|
}
|
|
161
164
|
}
|
|
165
|
+
const UUIDV7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
166
|
+
/** Where an unchosen address is put, relative to whatever origin assigns it. */
|
|
167
|
+
export const PROJECT_URI_PREFIX = 'turbowarp-plus-projects';
|
|
168
|
+
/**
|
|
169
|
+
* A fresh identifier for one project: a UUID version 7.
|
|
170
|
+
*
|
|
171
|
+
* Version 7 and not 4 because the first 48 bits are the millisecond clock, so the identifier
|
|
172
|
+
* itself sorts by when the project was first saved. That costs nothing over a random UUID and
|
|
173
|
+
* leaves the ordering available to anything that wants it.
|
|
174
|
+
* @returns {string} The UUID, in the canonical lowercase form.
|
|
175
|
+
*/
|
|
176
|
+
export function createProjectUUID() {
|
|
177
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
178
|
+
const time = Date.now();
|
|
179
|
+
for (let index = 0; index < 6; index++)
|
|
180
|
+
bytes[index] = (time / 2 ** (8 * (5 - index))) & 0xff;
|
|
181
|
+
bytes[6] = 0x70 | (bytes[6] & 0x0f);
|
|
182
|
+
bytes[8] = 0x80 | (bytes[8] & 0x3f);
|
|
183
|
+
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
|
184
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The address a project is given when its author has not chosen one.
|
|
188
|
+
*
|
|
189
|
+
* Every project needs an address from its first save, because that address is what identifies it:
|
|
190
|
+
* the browser's caches and the opener's recorded decisions are filed under it. Nothing is served
|
|
191
|
+
* there until the author actually publishes, and that is fine — the point is that the name does
|
|
192
|
+
* not have to change if they ever do.
|
|
193
|
+
*
|
|
194
|
+
* So this address is deliberately opaque. A readable one would be derived from the title and
|
|
195
|
+
* would change when the title changed, filing one work under a second name. Readability belongs
|
|
196
|
+
* to the deliberate act of publishing, where the author names the address themselves; a list that
|
|
197
|
+
* has to be read by a person shows the project's title beside this.
|
|
198
|
+
*
|
|
199
|
+
* The two directory levels divide the projects so that no single directory grows without bound,
|
|
200
|
+
* and they are taken from the **end** of the UUID. A version 7 UUID begins with the top of a
|
|
201
|
+
* millisecond clock, which barely moves, so sharding on its first characters would put every
|
|
202
|
+
* project ever created in one directory. The last characters are the random half.
|
|
203
|
+
* @param {string} origin Where the editor that assigns this is served from; it claims no other host.
|
|
204
|
+
* @param {string} uuid A version 7 UUID, from createProjectUUID.
|
|
205
|
+
* @returns {string} The absolute HTTPS address.
|
|
206
|
+
*/
|
|
207
|
+
export function defaultProjectURI(origin, uuid) {
|
|
208
|
+
if (!UUIDV7.test(uuid))
|
|
209
|
+
fail('INVALID_PROJECT_URI', 'A version 7 UUID is required');
|
|
210
|
+
let base;
|
|
211
|
+
try {
|
|
212
|
+
base = new URL(origin);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
fail('INVALID_PROJECT_URI', 'An absolute origin is required');
|
|
216
|
+
}
|
|
217
|
+
if (base.protocol !== 'https:' || base.username || base.password) {
|
|
218
|
+
fail('INVALID_PROJECT_URI', 'A project origin must be HTTPS without credentials');
|
|
219
|
+
}
|
|
220
|
+
const tail = uuid.replaceAll('-', '').slice(-4);
|
|
221
|
+
const path = `${base.pathname.replace(/\/*$/, '')}/${PROJECT_URI_PREFIX}/${tail.slice(0, 2)}/${tail}/${uuid}.sb3`;
|
|
222
|
+
return new URL(path, base.origin).href;
|
|
223
|
+
}
|
|
@@ -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
|
-
*
|
|
7
|
+
* Which of the four forms the project keeps it in, as the author decided.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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: '
|
|
16
|
+
distribution: ExtensionSource['type'];
|
|
14
17
|
execution: ExecutionMode;
|
|
15
18
|
dependencies?: string[];
|
|
16
19
|
version?: string;
|
package/dist/project-catalog.js
CHANGED
|
@@ -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']
|
|
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
|
-
|
|
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
|
|
134
|
+
if (replaced?.type === 'embedded-file')
|
|
122
135
|
replacedPaths.add(replaced.path);
|
|
123
136
|
const old = previous.get(choice.id);
|
|
124
|
-
//
|
|
125
|
-
|
|
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
|
|
131
|
-
// beside the
|
|
132
|
-
// a costume, a sound, an extension written here — have no address to record,
|
|
133
|
-
// why a url is optional on an embedded source.
|
|
134
|
-
const source = choice.distribution === 'embedded' ?
|
|
135
|
-
{ type: 'embedded',
|
|
136
|
-
|
|
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,29 +158,40 @@ 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
|
-
//
|
|
146
|
-
|
|
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 } : {}),
|
|
155
174
|
capabilities: old?.capabilities || [], dependencies: choice.dependencies });
|
|
156
175
|
}
|
|
157
|
-
|
|
176
|
+
// The project's own address survives a change to its extensions. Rebuilding the manifest
|
|
177
|
+
// without it would file the same work under a new name the next time it was saved, which
|
|
178
|
+
// is the one thing an address is for.
|
|
179
|
+
const manifest = validateManifest({ schemaVersion: 1, ...(prepared.manifest?.uri === undefined ?
|
|
180
|
+
{} : { uri: prepared.manifest.uri }), extensions: [...extensions.values()] }, loaderOptions);
|
|
158
181
|
for (const spec of manifest.extensions)
|
|
159
|
-
if (spec.source.type
|
|
182
|
+
if (spec.source.type === 'embedded-file')
|
|
160
183
|
replacedPaths.delete(spec.source.path);
|
|
161
184
|
for (const path of replacedPaths)
|
|
162
185
|
delete files[path];
|
|
163
186
|
project.turboWarpPlus = manifest;
|
|
164
187
|
project.extensions = [...new Set([...(project.extensions || []), ...manifest.extensions.map(spec => spec.id)])];
|
|
165
188
|
const urls = { ...(project.extensionURLs || {}) };
|
|
166
|
-
for (const spec of manifest.extensions)
|
|
167
|
-
|
|
189
|
+
for (const spec of manifest.extensions) {
|
|
190
|
+
if (spec.source.type === 'embedded-data')
|
|
191
|
+
urls[spec.id] = dataCarrier(carriers.get(spec.id));
|
|
192
|
+
else
|
|
193
|
+
delete urls[spec.id];
|
|
194
|
+
}
|
|
168
195
|
project.extensionURLs = urls;
|
|
169
196
|
files['project.json'] = encoder.encode(JSON.stringify(project));
|
|
170
197
|
const archive = new Zip();
|
package/dist/project-codec.d.ts
CHANGED
|
@@ -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. */
|
package/dist/project-codec.js
CHANGED
|
@@ -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
|
|
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,
|
package/dist/session-trust.js
CHANGED
|
@@ -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
|
|
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 });
|
package/indexeddb-cache.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.5.1",
|
|
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": {
|