@daugasauron/dolly 0.0.0-stage → 0.1.4-rc.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/LICENSE +21 -0
- package/README.md +89 -2
- package/dist/dolly-build-id.mjs +1 -0
- package/dist/dolly-dso-0.wasm +0 -0
- package/dist/dolly-image-build-id.mjs +1 -0
- package/dist/dolly-kernel-plugin-abi.mjs +2 -0
- package/dist/dolly-process-0.wasm +0 -0
- package/dist/dolly-process-abi.mjs +1 -0
- package/dist/dolly-process-dso.mjs +1575 -0
- package/dist/dolly-process-gate-0.wasm +0 -0
- package/dist/dolly-process-stack.mjs +50 -0
- package/dist/dolly-process-worker.mjs +275 -0
- package/dist/dolly-threads-0.wasm +0 -0
- package/dist/dolly.wasm +0 -0
- package/host/abi.mjs +5 -0
- package/host/dso/abi.mjs +13 -0
- package/host/dso/dso.mjs +26 -0
- package/host/dso/module.json +15 -0
- package/host/http/abi.mjs +28 -0
- package/host/http/broker.mjs +325 -0
- package/host/http/http.mjs +74 -0
- package/host/http/local-services.mjs +37 -0
- package/host/http/module.json +15 -0
- package/host/http/policy.mjs +293 -0
- package/host/modules.mjs +373 -0
- package/host/nena/abi.mjs +19 -0
- package/host/nena/module.json +15 -0
- package/host/nena/nena.mjs +131 -0
- package/host/packages/module.json +13 -0
- package/host/packages/packages.mjs +15 -0
- package/host/packages/service.mjs +59 -0
- package/host/requirements.mjs +62 -0
- package/host/runtime/abi.mjs +8 -0
- package/host/runtime/boot-files.mjs +45 -0
- package/host/runtime/grower.mjs +14 -0
- package/host/runtime/lease-bridge.mjs +65 -0
- package/host/runtime/module.json +16 -0
- package/host/runtime/runtime.mjs +209 -0
- package/host/snapshot/abi.mjs +16 -0
- package/host/snapshot/module.json +15 -0
- package/host/snapshot/snapshot.mjs +138 -0
- package/host/snapshot/transport.mjs +132 -0
- package/host/snapshot/ui.mjs +207 -0
- package/host/stack/abi.mjs +5 -0
- package/host/stack/module.json +17 -0
- package/host/stack/stack.mjs +19 -0
- package/host/threads/abi.mjs +8 -0
- package/host/threads/module.json +13 -0
- package/host/threads/threads.mjs +36 -0
- package/package.json +12 -4
- package/src/alphabetical.mjs +4 -0
- package/src/browser.mjs +237 -0
- package/src/build-log.mjs +30 -0
- package/src/image-entry.mjs +29 -0
- package/src/image-inputs.mjs +20 -0
- package/src/image-names.mjs +11 -0
- package/src/images.mjs +10 -0
- package/src/kernel-plugin.mjs +77 -0
- package/src/packaged-snapshot.mjs +175 -0
- package/src/page-chords.mjs +43 -0
- package/src/page-indicators.mjs +27 -0
- package/src/process-abi.mjs +69 -0
- package/src/process-constants.mjs +242 -0
- package/src/process-supervisor.mjs +1002 -0
- package/src/runtime-worker.mjs +55 -0
- package/src/session-store.mjs +198 -0
- package/src/snapshot-records.mjs +109 -0
- package/src/static-asset.mjs +97 -0
- package/src/terminal-text.mjs +72 -0
- package/src/version.mjs +3 -0
- package/src/wasm-interface.mjs +440 -0
- package/src/worker-image.mjs +137 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { DOLLY_ERRNO } from "./abi.mjs";
|
|
2
|
+
|
|
3
|
+
export class HttpError extends Error {
|
|
4
|
+
constructor(errno, message) { super(message); this.errno = errno; }
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const credentialHeaderNames = new Set([
|
|
8
|
+
"authorization",
|
|
9
|
+
"cookie",
|
|
10
|
+
"proxy-authorization",
|
|
11
|
+
"x-api-key",
|
|
12
|
+
"api-key",
|
|
13
|
+
"x-goog-api-key",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
// Fetch owns these transport headers. Forwarding native libcurl values is not
|
|
17
|
+
// merely ineffective: engines disagree about whether to discard them or turn
|
|
18
|
+
// the request into a CORS preflight. In particular Firefox preflights a
|
|
19
|
+
// caller-supplied User-Agent, which makes otherwise CORS-enabled PyPI GETs
|
|
20
|
+
// fail. Normalize that browser variance at Dolly's broker boundary.
|
|
21
|
+
const browserOwnedTransportHeaderNames = new Set([
|
|
22
|
+
"accept-encoding",
|
|
23
|
+
"connection",
|
|
24
|
+
"content-length",
|
|
25
|
+
"host",
|
|
26
|
+
"transfer-encoding",
|
|
27
|
+
"user-agent",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export function isDollyCredentialHeader(name) {
|
|
31
|
+
return credentialHeaderNames.has(String(name).toLowerCase());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function stripDollyBrowserOwnedHeaders(headers) {
|
|
35
|
+
for (const name of browserOwnedTransportHeaderNames) headers.delete(name);
|
|
36
|
+
return headers;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const defaultLimits = Object.freeze({
|
|
40
|
+
maxRequests: 256,
|
|
41
|
+
maxRequestBytes: 8 * 1024 * 1024,
|
|
42
|
+
maxResponseBytes: Infinity,
|
|
43
|
+
timeoutMilliseconds: 600_000,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function positiveInteger(value, fallback, name) {
|
|
47
|
+
const result = value ?? fallback;
|
|
48
|
+
if (!Number.isSafeInteger(result) || result <= 0) {
|
|
49
|
+
throw new TypeError(`invalid Dolly HTTP policy ${name}`);
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeRule(rule) {
|
|
55
|
+
if (rule === null || typeof rule !== "object") {
|
|
56
|
+
throw new TypeError("invalid Dolly HTTP policy rule");
|
|
57
|
+
}
|
|
58
|
+
const origin = new URL(rule.origin).origin;
|
|
59
|
+
if (origin !== rule.origin || !/^https?:$/.test(new URL(origin).protocol)) {
|
|
60
|
+
throw new TypeError("Dolly HTTP policy origins must be exact HTTP(S) origins");
|
|
61
|
+
}
|
|
62
|
+
if (rule.path !== undefined && rule.pathPrefix !== undefined) {
|
|
63
|
+
throw new TypeError("Dolly HTTP policy rules cannot combine path and pathPrefix");
|
|
64
|
+
}
|
|
65
|
+
const path = rule.path === undefined ? null : String(rule.path);
|
|
66
|
+
const pathPrefix = rule.pathPrefix ?? "/";
|
|
67
|
+
if (path !== null && !path.startsWith("/")) {
|
|
68
|
+
throw new TypeError("Dolly HTTP policy paths must start with /");
|
|
69
|
+
}
|
|
70
|
+
if (typeof pathPrefix !== "string" || !pathPrefix.startsWith("/")) {
|
|
71
|
+
throw new TypeError("Dolly HTTP policy path prefixes must start with /");
|
|
72
|
+
}
|
|
73
|
+
const methods = new Set((rule.methods ?? ["GET", "HEAD"]).map((method) => {
|
|
74
|
+
if (typeof method !== "string" || !/^[A-Z]+$/.test(method)) {
|
|
75
|
+
throw new TypeError("Dolly HTTP policy methods must be uppercase tokens");
|
|
76
|
+
}
|
|
77
|
+
return method;
|
|
78
|
+
}));
|
|
79
|
+
if (rule.credential !== undefined) {
|
|
80
|
+
throw new TypeError(
|
|
81
|
+
"Dolly HTTP credentials belong inside the sandbox; use credentialHeaders",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const credentialHeaders = new Set((rule.credentialHeaders ?? []).map((value) => {
|
|
85
|
+
const name = String(value).toLowerCase();
|
|
86
|
+
if (!credentialHeaderNames.has(name)) {
|
|
87
|
+
throw new TypeError(`invalid Dolly HTTP credential header: ${name}`);
|
|
88
|
+
}
|
|
89
|
+
return name;
|
|
90
|
+
}));
|
|
91
|
+
return Object.freeze({
|
|
92
|
+
origin,
|
|
93
|
+
path,
|
|
94
|
+
pathPrefix,
|
|
95
|
+
methods,
|
|
96
|
+
credentialHeaders,
|
|
97
|
+
maxRequestBytes: positiveInteger(
|
|
98
|
+
rule.maxRequestBytes,
|
|
99
|
+
defaultLimits.maxRequestBytes,
|
|
100
|
+
"maxRequestBytes",
|
|
101
|
+
),
|
|
102
|
+
maxResponseBytes: rule.maxResponseBytes == null ? Infinity :
|
|
103
|
+
positiveInteger(rule.maxResponseBytes, undefined, "maxResponseBytes"),
|
|
104
|
+
timeoutMilliseconds: positiveInteger(
|
|
105
|
+
rule.timeoutMilliseconds,
|
|
106
|
+
defaultLimits.timeoutMilliseconds,
|
|
107
|
+
"timeoutMilliseconds",
|
|
108
|
+
),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Whole segments only: "/v1" admits "/v1" and "/v1/x", never "/v1-admin".
|
|
113
|
+
// Encoded separators could re-segment the path on the server.
|
|
114
|
+
function pathWithin(pathname, prefix) {
|
|
115
|
+
if (/%(?:2f|5c)/i.test(pathname)) return false;
|
|
116
|
+
return pathname === prefix || pathname.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Recipes name a published file by its site path, which the broker resolves
|
|
120
|
+
// to the file's public URL; the embedding fetches the copy its own release
|
|
121
|
+
// holds (applicationBase).
|
|
122
|
+
function normalizeTrustedSource(source, applicationBase) {
|
|
123
|
+
if (source === null || typeof source !== "object" ||
|
|
124
|
+
typeof source.path !== "string" || !source.path.startsWith("/") ||
|
|
125
|
+
!Number.isSafeInteger(source.byteLength) || source.byteLength <= 0) {
|
|
126
|
+
throw new TypeError("invalid trusted Dolly bootstrap source");
|
|
127
|
+
}
|
|
128
|
+
const url = new URL(source.path.slice(1), applicationBase);
|
|
129
|
+
if (!/^https?:$/.test(url.protocol) || url.search || url.hash) {
|
|
130
|
+
throw new TypeError("invalid trusted Dolly bootstrap source URL");
|
|
131
|
+
}
|
|
132
|
+
return Object.freeze({
|
|
133
|
+
href: url.href,
|
|
134
|
+
bootstrap: true,
|
|
135
|
+
maxRequestBytes: 1,
|
|
136
|
+
maxResponseBytes: source.byteLength,
|
|
137
|
+
timeoutMilliseconds: defaultLimits.timeoutMilliseconds,
|
|
138
|
+
credentialHeaders: new Set(),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// A relay is the embedding's transport for an origin whose responses the
|
|
143
|
+
// browser will not let a page read (no CORS headers). The policy still judges
|
|
144
|
+
// the real destination; an admitted request is then fetched from
|
|
145
|
+
// `through` + host + path + query. The relay sees the whole request, so it gets
|
|
146
|
+
// no credential header the embedding did not name for it.
|
|
147
|
+
function normalizeRelay(relay) {
|
|
148
|
+
if (relay === null || typeof relay !== "object") throw new TypeError("invalid Dolly HTTP relay");
|
|
149
|
+
const origin = new URL(relay.origin).origin;
|
|
150
|
+
const through = new URL(relay.through);
|
|
151
|
+
if (origin !== relay.origin || !/^https?:$/.test(new URL(origin).protocol) ||
|
|
152
|
+
through.href !== relay.through || !/^https?:$/.test(through.protocol) ||
|
|
153
|
+
!through.pathname.endsWith("/") || through.search || through.hash ||
|
|
154
|
+
through.username || through.password) {
|
|
155
|
+
throw new TypeError("a Dolly HTTP relay maps an exact origin to an HTTP(S) URL prefix ending in /");
|
|
156
|
+
}
|
|
157
|
+
const credentialHeaders = new Set((relay.credentialHeaders ?? []).map((value) => {
|
|
158
|
+
const name = String(value).toLowerCase();
|
|
159
|
+
if (!credentialHeaderNames.has(name)) throw new TypeError(`invalid Dolly HTTP credential header: ${name}`);
|
|
160
|
+
return name;
|
|
161
|
+
}));
|
|
162
|
+
return [origin, Object.freeze({ through: through.href, credentialHeaders })];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Where an admitted request for a relayed origin is fetched from.
|
|
166
|
+
function relayUrl(relays, target, headers) {
|
|
167
|
+
const relay = relays.get(target.origin);
|
|
168
|
+
if (!relay) return undefined;
|
|
169
|
+
for (const name of credentialHeaderNames) {
|
|
170
|
+
if (!relay.credentialHeaders.has(name)) headers.delete(name);
|
|
171
|
+
}
|
|
172
|
+
return `${relay.through}${target.host}${target.pathname}${target.search}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export class DollyHttpPolicy {
|
|
176
|
+
constructor(configuration, trustedSources = [], applicationBase = globalThis.location?.href, relays = []) {
|
|
177
|
+
if (!Array.isArray(relays) || relays.length > 64) throw new TypeError("invalid Dolly HTTP relays");
|
|
178
|
+
this.relays = new Map(relays.map(normalizeRelay));
|
|
179
|
+
this.hardened = configuration !== undefined;
|
|
180
|
+
this.rules = this.hardened
|
|
181
|
+
? Object.freeze((configuration.rules ?? []).map(normalizeRule))
|
|
182
|
+
: Object.freeze([]);
|
|
183
|
+
this.maxRequests = this.hardened ? positiveInteger(
|
|
184
|
+
configuration?.maxRequests,
|
|
185
|
+
defaultLimits.maxRequests,
|
|
186
|
+
"maxRequests",
|
|
187
|
+
) : Infinity;
|
|
188
|
+
this.trustedSources = new Map(trustedSources.map((source) => {
|
|
189
|
+
const rule = normalizeTrustedSource(source, applicationBase);
|
|
190
|
+
return [rule.href, rule];
|
|
191
|
+
}));
|
|
192
|
+
// Each exact build input may be fetched a few times per page, not endlessly.
|
|
193
|
+
this.maxBootstrapRequests = this.hardened ? 4 * this.trustedSources.size : Infinity;
|
|
194
|
+
this.requests = 0;
|
|
195
|
+
this.bootstrapRequests = 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
authorize(target, method, headers, requestBytes) {
|
|
199
|
+
const upperMethod = method.toUpperCase();
|
|
200
|
+
let rule = upperMethod === "GET" && requestBytes === 0 &&
|
|
201
|
+
target.username === "" && target.password === "" &&
|
|
202
|
+
target.search === "" && target.hash === ""
|
|
203
|
+
? this.trustedSources.get(target.href)
|
|
204
|
+
: undefined;
|
|
205
|
+
if (rule) {
|
|
206
|
+
// The release's recipes and sources are build inputs selected by the
|
|
207
|
+
// embedding page and fetched from its own copy, not capabilities granted
|
|
208
|
+
// by an untrusted Dollyfile. They are exact, read-only, credential-free
|
|
209
|
+
// URLs with byte-for-byte response limits and their own quota, so a
|
|
210
|
+
// large source graph cannot exhaust agent requests.
|
|
211
|
+
if (++this.bootstrapRequests > this.maxBootstrapRequests) {
|
|
212
|
+
throw new HttpError(DOLLY_ERRNO.EDQUOT, "Dolly bootstrap source quota exceeded");
|
|
213
|
+
}
|
|
214
|
+
for (const name of credentialHeaderNames) headers.delete(name);
|
|
215
|
+
} else {
|
|
216
|
+
if (++this.requests > this.maxRequests) {
|
|
217
|
+
throw new HttpError(DOLLY_ERRNO.EDQUOT, "Dolly HTTP request quota exceeded");
|
|
218
|
+
}
|
|
219
|
+
if (this.hardened) {
|
|
220
|
+
rule = this.rules.find((candidate) =>
|
|
221
|
+
candidate.origin === target.origin &&
|
|
222
|
+
(candidate.path === null
|
|
223
|
+
? pathWithin(target.pathname, candidate.pathPrefix)
|
|
224
|
+
: target.pathname === candidate.path) &&
|
|
225
|
+
candidate.methods.has(upperMethod));
|
|
226
|
+
if (!rule) throw new HttpError(DOLLY_ERRNO.EACCES, "Dolly HTTP policy denied the request");
|
|
227
|
+
} else {
|
|
228
|
+
rule = {
|
|
229
|
+
followRedirects: true,
|
|
230
|
+
credentialHeaders: null,
|
|
231
|
+
maxRequestBytes: defaultLimits.maxRequestBytes,
|
|
232
|
+
maxResponseBytes: defaultLimits.maxResponseBytes,
|
|
233
|
+
timeoutMilliseconds: defaultLimits.timeoutMilliseconds,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (requestBytes > rule.maxRequestBytes) {
|
|
238
|
+
throw new HttpError(DOLLY_ERRNO.E2BIG, "Dolly HTTP request exceeds its size limit");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Credentials are ordinary sandbox state. Development mode preserves
|
|
242
|
+
// them. A hardened destination rule must explicitly name which common
|
|
243
|
+
// credential headers may leave for that exact destination.
|
|
244
|
+
if (rule.credentialHeaders !== null) {
|
|
245
|
+
for (const name of credentialHeaderNames) {
|
|
246
|
+
if (!rule.credentialHeaders.has(name)) headers.delete(name);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const relay = rule.bootstrap ? undefined : relayUrl(this.relays, target, headers);
|
|
250
|
+
return relay ? { ...rule, followRedirects: false, relay } : rule;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Trusted browser state for an explicitly opened result tab, never Wasm data.
|
|
255
|
+
export function httpPolicyConfigurations(policy) {
|
|
256
|
+
if (policy.configurations) return policy.configurations;
|
|
257
|
+
return [policy.hardened ? { maxRequests: policy.maxRequests, rules: policy.rules.map(rule => ({
|
|
258
|
+
origin: rule.origin, ...(rule.path === null ? { pathPrefix: rule.pathPrefix } : { path: rule.path }),
|
|
259
|
+
methods: [...rule.methods], credentialHeaders: [...rule.credentialHeaders],
|
|
260
|
+
maxRequestBytes: rule.maxRequestBytes,
|
|
261
|
+
...(Number.isFinite(rule.maxResponseBytes) ? { maxResponseBytes: rule.maxResponseBytes } : {}),
|
|
262
|
+
timeoutMilliseconds: rule.timeoutMilliseconds,
|
|
263
|
+
})) } : null];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function restrictDollyHttpPolicy(policy, inherited, trustedSources, applicationBase) {
|
|
267
|
+
if (!Array.isArray(inherited) || inherited.length === 0 || inherited.length > 16 ||
|
|
268
|
+
new TextEncoder().encode(JSON.stringify(inherited)).byteLength > 65536) {
|
|
269
|
+
throw new Error("Invalid inherited image HTTP policy");
|
|
270
|
+
}
|
|
271
|
+
const configurations = [...new Set([...httpPolicyConfigurations(policy), ...inherited].map(value => JSON.stringify(value)))].map(value => JSON.parse(value));
|
|
272
|
+
const policies = configurations.map(configuration => new DollyHttpPolicy(configuration ?? undefined, trustedSources, applicationBase));
|
|
273
|
+
return {
|
|
274
|
+
configurations,
|
|
275
|
+
// Relays are this page's transport, not inherited authority.
|
|
276
|
+
relays: policy.relays,
|
|
277
|
+
authorize(target, method, headers, bytes) {
|
|
278
|
+
// Every policy must allow it. Sequential header stripping intersects
|
|
279
|
+
// credentials too; neither the parent nor the new embedding can widen it.
|
|
280
|
+
const rules = policies.map(policy => policy.authorize(target, method, headers, bytes));
|
|
281
|
+
const bootstrap = rules.every(rule => rule.bootstrap === true);
|
|
282
|
+
const relay = bootstrap ? undefined : relayUrl(policy.relays, target, headers);
|
|
283
|
+
return {
|
|
284
|
+
bootstrap,
|
|
285
|
+
...(relay && { relay }),
|
|
286
|
+
followRedirects: !relay && rules.every(rule => rule.followRedirects === true),
|
|
287
|
+
maxRequestBytes: Math.min(...rules.map(rule => rule.maxRequestBytes)),
|
|
288
|
+
maxResponseBytes: Math.min(...rules.map(rule => rule.maxResponseBytes)),
|
|
289
|
+
timeoutMilliseconds: Math.min(...rules.map(rule => rule.timeoutMilliseconds)),
|
|
290
|
+
};
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
package/host/modules.mjs
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { hostRequirement, hostRequirements } from "./requirements.mjs";
|
|
2
|
+
import { DOLLY_ERRNO as E } from "../src/process-constants.mjs";
|
|
3
|
+
|
|
4
|
+
// What a build enables beside the runtime its image declares: the broker the
|
|
5
|
+
// engine fetches sources through, the threads its toolchain uses and the
|
|
6
|
+
// modules its compilers and interpreters load.
|
|
7
|
+
export const buildHost = Object.freeze(["http@0", "threads@0", "dso@0"]);
|
|
8
|
+
|
|
9
|
+
// A page's host modules, from the URLs of their manifests. The embedding, trusted
|
|
10
|
+
// code, names them; images select no JS or Worker URLs, and each module's provider
|
|
11
|
+
// is the file its manifest names. A runtime is a module that provides the kernel
|
|
12
|
+
// (README.md); an image declares exactly one, by the same line as any module.
|
|
13
|
+
export async function hostRegistry(urls) {
|
|
14
|
+
const definitions = await Promise.all(urls.map(async url => {
|
|
15
|
+
const { default: manifest } = await import(url, { with: { type: "json" } });
|
|
16
|
+
const contract = Object.freeze({ ...manifest, url });
|
|
17
|
+
return { ...await import(new URL(contract.host, url).href), contract };
|
|
18
|
+
}));
|
|
19
|
+
const byName = new Map(), owners = new Map();
|
|
20
|
+
for (const module of definitions) {
|
|
21
|
+
if (byName.has(module.contract.name)) throw new Error(`duplicate host module: ${module.contract.name}`);
|
|
22
|
+
byName.set(module.contract.name, module);
|
|
23
|
+
for (const name of module.contract.imports) {
|
|
24
|
+
if (owners.has(name)) throw new Error(`duplicate host import owner: ${name}`);
|
|
25
|
+
owners.set(name, module.contract.name);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const contracts = Object.freeze(definitions.map(module => module.contract));
|
|
29
|
+
const runtimes = Object.freeze(contracts.filter(contract => contract.provides === "kernel")
|
|
30
|
+
.map(({ name, version }) => `${name}@${version}`));
|
|
31
|
+
const registry = { byName, owners, contracts, runtimes };
|
|
32
|
+
return Object.freeze({
|
|
33
|
+
urls: Object.freeze([...urls]),
|
|
34
|
+
buildHostFor: declared => [...declared.filter(value => runtimes.includes(value)), ...buildHost],
|
|
35
|
+
selectBoot: route => selectBoot(definitions, route),
|
|
36
|
+
createHost: (...values) => createHost(registry, ...values),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A module may select what a route boots, as a saved session names its image:
|
|
41
|
+
// at most one does, and the page then requires it. The selection carries the
|
|
42
|
+
// image, a bootstrap label, the custom image record it restores and the
|
|
43
|
+
// module's configuration, keyed for createHost.
|
|
44
|
+
async function selectBoot(definitions, route) {
|
|
45
|
+
let selection = null;
|
|
46
|
+
for (const module of definitions) {
|
|
47
|
+
const selected = await module.boot?.(route);
|
|
48
|
+
if (!selected) continue;
|
|
49
|
+
if (selection) throw new Error("two host modules selected the boot");
|
|
50
|
+
const { name, version } = module.contract;
|
|
51
|
+
selection = { ...selected, module: `${name}@${version}`, configuration: { [name]: selected.configuration } };
|
|
52
|
+
}
|
|
53
|
+
return selection;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function createHost({ byName, owners, contracts, runtimes }, side, enabled,
|
|
57
|
+
{ send, resources = {}, configuration = {} } = {}) {
|
|
58
|
+
if (!["browser", "worker"].includes(side)) throw new TypeError("invalid host side");
|
|
59
|
+
const selected = hostRequirements(enabled), instances = new Map(), reasons = new Map(), messages = new Map();
|
|
60
|
+
// The kernel is the one requested module that provides it; a module others
|
|
61
|
+
// only depend on does not make it one.
|
|
62
|
+
const kernels = selected.filter(value => runtimes.includes(value));
|
|
63
|
+
const started = new Set(), pending = new Map(), transfers = [], config = {};
|
|
64
|
+
// nena@0: the acknowledgements of the Worker's side of a switch, the
|
|
65
|
+
// switches in order, the context its providers start with and whether the
|
|
66
|
+
// image ENTRY has started.
|
|
67
|
+
const switched = new Map();
|
|
68
|
+
let switching = Promise.resolve(), startContext, entryContext;
|
|
69
|
+
// The page API (window.__dolly) by property descriptor, so module getters
|
|
70
|
+
// stay live; a child build host's configuration; what an opened result tab
|
|
71
|
+
// or restored session inherits from this page.
|
|
72
|
+
const page = {}, builder = {}, inherited = {};
|
|
73
|
+
// What executables may require: name@version -> the ABI digest its provider
|
|
74
|
+
// implements, for exactly the modules the image declares (see admit) and
|
|
75
|
+
// those its session enabled since (nena@0).
|
|
76
|
+
const admittedAbi = new Map();
|
|
77
|
+
let disposed = false;
|
|
78
|
+
// ask(message) sends a message to the other side and resolves to what its
|
|
79
|
+
// handler returns there (or rejects with what it throws).
|
|
80
|
+
const answers = new Map();
|
|
81
|
+
let asked = 0;
|
|
82
|
+
const ask = (message, transfers = []) => new Promise((resolve, reject) => {
|
|
83
|
+
if (disposed) return reject(new Error("host providers closed"));
|
|
84
|
+
answers.set(++asked, { resolve, reject });
|
|
85
|
+
send({ ...message, answer: asked }, transfers);
|
|
86
|
+
});
|
|
87
|
+
function requireModules(requirements) {
|
|
88
|
+
for (const value of hostRequirements(requirements)) {
|
|
89
|
+
const { name, version } = hostRequirement(value), module = byName.get(name);
|
|
90
|
+
const reason = !module ? "unknown host module" : module.contract.version !== version
|
|
91
|
+
? `host provides ABI ${module.contract.version}` : reasons.get(name) ?? (!instances.has(name) ? "not enabled by the host" : null);
|
|
92
|
+
if (reason) throw new Error(`Required host module ${value} is unavailable: ${reason}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const get = name => instances.get(name);
|
|
96
|
+
// Page UI across modules: the first module that claims a key event takes it
|
|
97
|
+
// from the guest's input and may act on it; a module drawing over the
|
|
98
|
+
// display canvas reports the pixel size pointer input maps to.
|
|
99
|
+
function claimsKey(event) {
|
|
100
|
+
for (const instance of instances.values()) {
|
|
101
|
+
const claim = instance.claimsKey?.(event);
|
|
102
|
+
if (claim) return claim;
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
const surfaceSize = () => [...instances.values()].find(instance => instance.surfaceSize)?.surfaceSize;
|
|
107
|
+
async function attach(value, visiting = new Set(), checked = false) {
|
|
108
|
+
const { name, version } = hostRequirement(value), module = byName.get(name);
|
|
109
|
+
if (instances.has(name) || reasons.has(name)) return;
|
|
110
|
+
if (!module || version !== module.contract.version) {
|
|
111
|
+
reasons.set(name, !module ? "unknown host module" : `host provides ABI ${module.contract.version}`); return;
|
|
112
|
+
}
|
|
113
|
+
if (visiting.has(name)) throw new Error(`host module dependency cycle: ${name}`);
|
|
114
|
+
visiting.add(name);
|
|
115
|
+
for (const dependency of module.contract.dependencies) await attach(dependency, visiting);
|
|
116
|
+
visiting.delete(name);
|
|
117
|
+
try { requireModules(module.contract.dependencies); }
|
|
118
|
+
catch (error) { reasons.set(name, error.message); return; }
|
|
119
|
+
if (side === "browser" && !checked) {
|
|
120
|
+
const reason = await module.check?.(resources);
|
|
121
|
+
if (reason) { reasons.set(name, reason); return; }
|
|
122
|
+
}
|
|
123
|
+
const dependency = name => {
|
|
124
|
+
if (!module.contract.dependencies.some(value => hostRequirement(value).name === name)) {
|
|
125
|
+
throw new Error(`${module.contract.name} accessed undeclared dependency ${name}`);
|
|
126
|
+
}
|
|
127
|
+
return get(name);
|
|
128
|
+
};
|
|
129
|
+
const instance = module[side]?.({ ...resources, send, ask, get: dependency,
|
|
130
|
+
service: () => { for (const instance of instances.values()) instance.service?.(); },
|
|
131
|
+
claimsKey, surfaceSize,
|
|
132
|
+
abi: admittedAbi, modules: module.contract.switches && side === "browser" ? { list, enable, disable } : undefined,
|
|
133
|
+
configuration: configuration[name] ?? {} }) ?? {};
|
|
134
|
+
instances.set(name, instance);
|
|
135
|
+
for (const [type, handler] of Object.entries(instance.messages ?? {})) {
|
|
136
|
+
if (messages.has(type)) throw new Error(`duplicate host message: ${type}`);
|
|
137
|
+
messages.set(type, handler);
|
|
138
|
+
}
|
|
139
|
+
config[name] = instance.configuration ?? {};
|
|
140
|
+
transfers.push(...instance.transfers ?? []);
|
|
141
|
+
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(instance.page ?? {}))) {
|
|
142
|
+
if (key in page) throw new Error(`duplicate page API member: ${key}`);
|
|
143
|
+
Object.defineProperty(page, key, descriptor);
|
|
144
|
+
}
|
|
145
|
+
if (instance.builder) builder[name] = instance.builder;
|
|
146
|
+
for (const [key, value] of Object.entries(instance.inherited ?? {})) {
|
|
147
|
+
if (key in inherited) throw new Error(`duplicate inherited record: ${key}`);
|
|
148
|
+
inherited[key] = value;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// What attach registered for one module, undone. Its provider lets go first,
|
|
152
|
+
// as when the page ends, and then of what a runtime that goes on must not
|
|
153
|
+
// keep: a service it registered, the thread provider.
|
|
154
|
+
function detach(name) {
|
|
155
|
+
const instance = instances.get(name);
|
|
156
|
+
instance.dispose?.();
|
|
157
|
+
instance.disabled?.();
|
|
158
|
+
for (const type of Object.keys(instance.messages ?? {})) messages.delete(type);
|
|
159
|
+
for (const key of Object.keys(instance.page ?? {})) delete page[key];
|
|
160
|
+
for (const key of Object.keys(instance.inherited ?? {})) delete inherited[key];
|
|
161
|
+
delete builder[name];
|
|
162
|
+
delete config[name];
|
|
163
|
+
instances.delete(name);
|
|
164
|
+
started.delete(name);
|
|
165
|
+
admittedAbi.delete(`${name}@${byName.get(name).contract.version}`);
|
|
166
|
+
}
|
|
167
|
+
// The Worker's side of a module starts once; a descriptor it returns is the
|
|
168
|
+
// page side's to acknowledge.
|
|
169
|
+
async function startProvider(name, instance, context) {
|
|
170
|
+
if (started.has(name)) throw new Error(`host provider ${name} initialized twice`);
|
|
171
|
+
started.add(name);
|
|
172
|
+
const descriptor = await instance.start(context);
|
|
173
|
+
if (descriptor === undefined) return;
|
|
174
|
+
await new Promise((resolve, reject) => {
|
|
175
|
+
const timer = setTimeout(() => { pending.delete(name); reject(new Error(`host provider ${name} did not acknowledge initialization`)); }, 10_000);
|
|
176
|
+
pending.set(name, { resolve: () => { clearTimeout(timer); resolve(); }, reject: error => { clearTimeout(timer); reject(error); } });
|
|
177
|
+
send({ type: "host-ready", module: name, descriptor });
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
// Switching a module after boot, for the page side of the one module whose
|
|
181
|
+
// manifest says `switches` (nena@0): an image without that module has no
|
|
182
|
+
// caller, so its set stays what the page chose.
|
|
183
|
+
// The page decides and goes first when enabling, last when disabling; the
|
|
184
|
+
// Worker's side follows by message. Each resolves to null, or to one line
|
|
185
|
+
// saying why not. A module whose manifest says `fixed` is never switched;
|
|
186
|
+
// the modules others depend on (runtime@0, http@0) are among them.
|
|
187
|
+
const requirementOf = ({ name, version }) => `${name}@${version}`;
|
|
188
|
+
const list = () => contracts.map(contract => ({ name: contract.name, version: contract.version,
|
|
189
|
+
gives: contract.gives ?? "", fixed: contract.fixed ?? "", enabled: instances.has(contract.name),
|
|
190
|
+
declared: selected.includes(requirementOf(contract)) }));
|
|
191
|
+
// Enabling hands the Worker's side what boot would have: the page side's
|
|
192
|
+
// configuration and what it transfers, such as a canvas.
|
|
193
|
+
const workerSwitch = (value, enabling, name = hostRequirement(value).name) => new Promise(resolve => {
|
|
194
|
+
switched.set(value, resolve);
|
|
195
|
+
if (enabling) send({ type: "host-switch", module: value, enabling, configuration: config[name] }, instances.get(name).transfers ?? []);
|
|
196
|
+
else send({ type: "host-switch", module: value, enabling });
|
|
197
|
+
});
|
|
198
|
+
const serially = task => switching = switching.then(task, task);
|
|
199
|
+
const enable = value => serially(async () => {
|
|
200
|
+
const { name, version } = hostRequirement(value), module = byName.get(name);
|
|
201
|
+
if (!module || module.contract.version !== version) return "this page has no such host module";
|
|
202
|
+
if (instances.has(name)) return null;
|
|
203
|
+
if (module.contract.fixed) return `fixed: ${module.contract.fixed}`;
|
|
204
|
+
const missing = module.contract.dependencies.find(dependency => !instances.has(hostRequirement(dependency).name));
|
|
205
|
+
if (missing) return `it needs ${missing}`;
|
|
206
|
+
reasons.delete(name);
|
|
207
|
+
try {
|
|
208
|
+
// A browser that never answers a module's check must not hold every
|
|
209
|
+
// later switch: after five seconds the switch is refused and the check
|
|
210
|
+
// told so, that an answer coming later shows nothing on the page.
|
|
211
|
+
const abandoned = new AbortController(), timer = setTimeout(() => abandoned.abort(), 5000);
|
|
212
|
+
const unavailable = await Promise.race([module.check?.({ ...resources, signal: abandoned.signal }), new Promise(resolve =>
|
|
213
|
+
abandoned.signal.addEventListener("abort", () => resolve("the browser did not answer in time")))]);
|
|
214
|
+
clearTimeout(timer);
|
|
215
|
+
if (unavailable) return unavailable;
|
|
216
|
+
await attach(value, new Set(), true);
|
|
217
|
+
if (!instances.has(name)) return reasons.get(name);
|
|
218
|
+
const refusal = await workerSwitch(value, true);
|
|
219
|
+
if (refusal) { detach(name); return refusal; }
|
|
220
|
+
if (entryContext) await instances.get(name).entryStarted?.(entryContext);
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (instances.has(name)) { await workerSwitch(value, false); detach(name); }
|
|
223
|
+
return error.message;
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
});
|
|
227
|
+
const disable = value => serially(async () => {
|
|
228
|
+
const { name, version } = hostRequirement(value), module = byName.get(name);
|
|
229
|
+
if (!module || module.contract.version !== version) return "this page has no such host module";
|
|
230
|
+
if (!instances.has(name)) return null;
|
|
231
|
+
if (module.contract.fixed) return `fixed: ${module.contract.fixed}`;
|
|
232
|
+
const refusal = await workerSwitch(value, false);
|
|
233
|
+
if (refusal) return refusal;
|
|
234
|
+
detach(name);
|
|
235
|
+
return null;
|
|
236
|
+
});
|
|
237
|
+
function dispose() {
|
|
238
|
+
if (disposed) return;
|
|
239
|
+
disposed = true;
|
|
240
|
+
for (const wait of [...pending.values(), ...answers.values()]) wait.reject(new Error("host providers closed"));
|
|
241
|
+
pending.clear();
|
|
242
|
+
answers.clear();
|
|
243
|
+
for (const instance of [...instances.values()].reverse()) instance.dispose?.();
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
for (const value of selected) await attach(value);
|
|
247
|
+
if (kernels.length !== 1) {
|
|
248
|
+
// A requested module this page lacks may be the runtime the image declares.
|
|
249
|
+
requireModules(selected);
|
|
250
|
+
throw new Error(kernels.length ? `${kernels.join(" and ")} both provide the kernel`
|
|
251
|
+
: `no enabled host module provides the kernel: add REQUIRES HOST ${runtimes.join(" or ")}`);
|
|
252
|
+
}
|
|
253
|
+
requireModules(kernels);
|
|
254
|
+
} catch (error) { dispose(); throw error; }
|
|
255
|
+
const kernel = hostRequirement(kernels[0]).name;
|
|
256
|
+
return {
|
|
257
|
+
transfers, configuration: config, page, builder, inherited,
|
|
258
|
+
kernel: instances.get(kernel),
|
|
259
|
+
get enabled() { return [...instances.keys()].map(name => requirementOf(byName.get(name).contract)); },
|
|
260
|
+
require: requireModules, dispose,
|
|
261
|
+
// Executables may use only the modules the image declares. A declared module
|
|
262
|
+
// this host does not enable (a build host) answers its calls with ENOSYS.
|
|
263
|
+
admit(requirements) {
|
|
264
|
+
admittedAbi.clear();
|
|
265
|
+
for (const value of hostRequirements(requirements)) {
|
|
266
|
+
const { name, version } = hostRequirement(value), module = byName.get(name);
|
|
267
|
+
if (module?.digest && module.contract.version === version) admittedAbi.set(value, module.digest);
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
// The Worker calls this after restoring the system image and before starting
|
|
271
|
+
// image-phase modules, in registry order.
|
|
272
|
+
async imageRestored(context) {
|
|
273
|
+
for (const instance of instances.values()) await instance.imageRestored?.(context);
|
|
274
|
+
},
|
|
275
|
+
// The page calls this once the image ENTRY may run, right before it lets
|
|
276
|
+
// the Worker start it: modules may then show their UI and admit services.
|
|
277
|
+
entryStarted(context) {
|
|
278
|
+
entryContext = context;
|
|
279
|
+
return Promise.all([...instances.values()].map(instance => instance.entryStarted?.(context)));
|
|
280
|
+
},
|
|
281
|
+
// The page asks this when the image has ended, before it lets go of the
|
|
282
|
+
// modules: the links ({ text, href }) each one offers beside "start again".
|
|
283
|
+
ended: () => [...instances.values()].flatMap(instance => instance.ended?.() ?? []),
|
|
284
|
+
// The kernel's whole import object: each import is the binding of the
|
|
285
|
+
// module whose manifest owns it.
|
|
286
|
+
imports(module) {
|
|
287
|
+
const imports = {};
|
|
288
|
+
for (const entry of WebAssembly.Module.imports(module)) {
|
|
289
|
+
const name = `${entry.module}.${entry.name}`, owner = owners.get(name);
|
|
290
|
+
// The import of a module this page does not have answers as a disabled one does.
|
|
291
|
+
if (!owner) { (imports[entry.module] ??= {})[entry.name] = () => -E.ENOSYS; continue; }
|
|
292
|
+
const binding = instances.has(owner) ? instances.get(owner).bindings?.[name] : () => -E.ENOSYS;
|
|
293
|
+
if (binding === undefined) throw new Error(`missing host binding: ${name}`);
|
|
294
|
+
// The kernel is instantiated once. An import of a module that can be
|
|
295
|
+
// switched asks at each call who answers it now; a disabled one is ENOSYS.
|
|
296
|
+
(imports[entry.module] ??= {})[entry.name] = byName.get(owner).contract.fixed ? binding
|
|
297
|
+
: (...values) => instances.has(owner) ? instances.get(owner).bindings[name](...values) : -E.ENOSYS;
|
|
298
|
+
}
|
|
299
|
+
return imports;
|
|
300
|
+
},
|
|
301
|
+
async handle(message) {
|
|
302
|
+
if (disposed) return false;
|
|
303
|
+
if (message.type === "host-ready-ack" && side === "worker") {
|
|
304
|
+
const wait = pending.get(message.module);
|
|
305
|
+
if (!wait) throw new Error("unexpected host acknowledgement");
|
|
306
|
+
pending.delete(message.module);
|
|
307
|
+
if (message.error) wait.reject(new Error(message.error)); else wait.resolve();
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
if (message.type === "host-ready" && side === "browser") {
|
|
311
|
+
try {
|
|
312
|
+
const instance = get(message.module);
|
|
313
|
+
if (!instance?.start || started.has(message.module)) throw new Error("invalid host provider handshake");
|
|
314
|
+
started.add(message.module);
|
|
315
|
+
await instance.start(message.descriptor);
|
|
316
|
+
send({ type: "host-ready-ack", module: message.module });
|
|
317
|
+
} catch (error) {
|
|
318
|
+
send({ type: "host-ready-ack", module: message.module, error: error.message });
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
if (message.type === "host-switch" && side === "worker") {
|
|
324
|
+
const { name } = hostRequirement(message.module);
|
|
325
|
+
let refusal = null;
|
|
326
|
+
try {
|
|
327
|
+
if (message.enabling) {
|
|
328
|
+
configuration[name] = message.configuration;
|
|
329
|
+
reasons.delete(name);
|
|
330
|
+
await attach(message.module);
|
|
331
|
+
const instance = instances.get(name);
|
|
332
|
+
if (!instance) throw new Error(reasons.get(name));
|
|
333
|
+
if (instance.start) await startProvider(name, instance, startContext);
|
|
334
|
+
const { digest } = byName.get(name);
|
|
335
|
+
if (digest) admittedAbi.set(message.module, digest);
|
|
336
|
+
} else if (instances.has(name)) detach(name);
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (instances.has(name)) detach(name);
|
|
339
|
+
refusal = error.message;
|
|
340
|
+
}
|
|
341
|
+
send({ type: "host-switch-ack", module: message.module, refusal });
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
if (message.type === "host-switch-ack" && side === "browser") {
|
|
345
|
+
switched.get(message.module)?.(message.refusal);
|
|
346
|
+
switched.delete(message.module);
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
if (message.type === "host-answer") {
|
|
350
|
+
const waiting = answers.get(message.answer);
|
|
351
|
+
answers.delete(message.answer);
|
|
352
|
+
if (message.error === undefined) waiting?.resolve(message.result); else waiting?.reject(new Error(message.error));
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
const handler = messages.get(message.type);
|
|
356
|
+
if (!handler) return false;
|
|
357
|
+
if (message.answer === undefined) {
|
|
358
|
+
await handler(message);
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
try { send({ type: "host-answer", answer: message.answer, result: await handler(message) }); }
|
|
362
|
+
catch (error) { send({ type: "host-answer", answer: message.answer, error: error.message }); }
|
|
363
|
+
return true;
|
|
364
|
+
},
|
|
365
|
+
async start(phase, context) {
|
|
366
|
+
if (side !== "worker" || disposed) throw new Error("host cannot initialize providers");
|
|
367
|
+
startContext = context;
|
|
368
|
+
for (const [name, instance] of [...instances]) {
|
|
369
|
+
if (byName.get(name).contract.phase === phase && instance.start) await startProvider(name, instance, context);
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
}
|