@daugasauron/dolly 0.0.0-stage → 0.1.4-rc.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/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,325 @@
|
|
|
1
|
+
import { HttpError, isDollyCredentialHeader, stripDollyBrowserOwnedHeaders } from "./policy.mjs";
|
|
2
|
+
import { DOLLY_ERRNO as errno } from "./abi.mjs";
|
|
3
|
+
import { decodeStaticAsset, sitePath } from "../../src/static-asset.mjs";
|
|
4
|
+
import { DOLLY_HTTP_SLOT_COUNT, DOLLY_HTTP_CHUNK_CAPACITY, DOLLY_HTTP_MAX_METHOD, DOLLY_HTTP_MAX_URL,
|
|
5
|
+
DOLLY_HTTP_MAX_HEADERS, DOLLY_HTTP_MAX_BODY, DOLLY_HTTP_HEADER_SIZE, DOLLY_HTTP_WORD_SEQUENCE,
|
|
6
|
+
DOLLY_HTTP_WORD_STATUS, DOLLY_HTTP_WORD_LENGTH, DOLLY_HTTP_WORD_EOF, DOLLY_HTTP_WORD_ERROR,
|
|
7
|
+
DOLLY_HTTP_WORD_KIND, DOLLY_HTTP_STATE_WRITABLE, DOLLY_HTTP_STATE_READABLE, DOLLY_HTTP_STATE_FAILED,
|
|
8
|
+
DOLLY_HTTP_KIND_URL, DOLLY_HTTP_KIND_HEADER, DOLLY_HTTP_KIND_BODY } from "./abi.mjs";
|
|
9
|
+
|
|
10
|
+
const encoder = new TextEncoder();
|
|
11
|
+
const now = () => performance.timeOrigin + performance.now();
|
|
12
|
+
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
13
|
+
export const DOLLY_HTTP_LIMITS = Object.freeze({ method: DOLLY_HTTP_MAX_METHOD, url: DOLLY_HTTP_MAX_URL,
|
|
14
|
+
headers: DOLLY_HTTP_MAX_HEADERS, body: DOLLY_HTTP_MAX_BODY });
|
|
15
|
+
|
|
16
|
+
// Host-only acknowledgement: admission copies or rejects the spans before the
|
|
17
|
+
// import returns. One descriptor at a time, but independent streaming transfers.
|
|
18
|
+
export function createHttpAdmission(postRequest) {
|
|
19
|
+
const control = new Int32Array(new SharedArrayBuffer(8));
|
|
20
|
+
return { control, dispatch(request) {
|
|
21
|
+
Atomics.store(control, 0, 1);
|
|
22
|
+
postRequest(request);
|
|
23
|
+
while (Atomics.load(control, 0) !== 0) Atomics.wait(control, 0, 1);
|
|
24
|
+
return Atomics.load(control, 1);
|
|
25
|
+
} };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The Worker's end of the mailbox pool: it copies what a request descriptor
|
|
29
|
+
// names out of kernel memory and writes the page's records into the slots.
|
|
30
|
+
export class HttpMailbox {
|
|
31
|
+
constructor(buffer, address) {
|
|
32
|
+
if (!(buffer instanceof SharedArrayBuffer) || !Number.isSafeInteger(address) ||
|
|
33
|
+
address <= 0 || address % 64 !== 0 ||
|
|
34
|
+
address > buffer.byteLength - DOLLY_HTTP_SLOT_COUNT * (DOLLY_HTTP_HEADER_SIZE + DOLLY_HTTP_CHUNK_CAPACITY)) {
|
|
35
|
+
throw new TypeError("invalid HTTP mailbox pool bounds");
|
|
36
|
+
}
|
|
37
|
+
this.bytes = new Uint8Array(buffer);
|
|
38
|
+
this.words = new Int32Array(buffer);
|
|
39
|
+
this.address = address;
|
|
40
|
+
// The transfer (the page's number for it) that last ended in each slot, and its error.
|
|
41
|
+
this.ended = Array(DOLLY_HTTP_SLOT_COUNT).fill(null);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
word(index) { return (this.address + index * (DOLLY_HTTP_HEADER_SIZE + DOLLY_HTTP_CHUNK_CAPACITY)) / 4; }
|
|
45
|
+
|
|
46
|
+
// The page's admission message for a descriptor: a cancellation, a request
|
|
47
|
+
// copied out of `memory`, or the errno it fails with once the page has
|
|
48
|
+
// judged the slot. All spans are checked before any guest byte is decoded.
|
|
49
|
+
admission(memory, message) {
|
|
50
|
+
const sequence = message.sequence >>> 0;
|
|
51
|
+
if (message.method === 0n) {
|
|
52
|
+
return { sequence, cancel: message.methodSize === 0n && message.url === 0n && message.urlSize === 0n &&
|
|
53
|
+
message.headers === 0n && message.headersSize === 0n && message.body === 0n && message.bodySize === 0n &&
|
|
54
|
+
message.flags === 0 };
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
if (!(memory instanceof SharedArrayBuffer) || (message.flags & ~3) !== 0)
|
|
58
|
+
throw new HttpError(errno.EINVAL, "invalid HTTP admission");
|
|
59
|
+
const spans = {};
|
|
60
|
+
for (const [name, maximum] of Object.entries(DOLLY_HTTP_LIMITS)) {
|
|
61
|
+
const start = Number(message[name]), size = Number(message[`${name}Size`]);
|
|
62
|
+
if (!Number.isSafeInteger(size) || size < 0 || size > maximum)
|
|
63
|
+
throw new HttpError(errno.E2BIG, "HTTP argument exceeds its byte limit");
|
|
64
|
+
if (!Number.isSafeInteger(start) || start < 0 || (size !== 0 && start === 0) ||
|
|
65
|
+
start > memory.byteLength - size)
|
|
66
|
+
throw new HttpError(errno.EFAULT, "HTTP argument is outside Wasm memory");
|
|
67
|
+
spans[name] = [start, start + size];
|
|
68
|
+
}
|
|
69
|
+
if (message.methodSize === 0n || message.urlSize === 0n)
|
|
70
|
+
throw new HttpError(errno.EINVAL, "HTTP method and URL must not be empty");
|
|
71
|
+
const bytes = new Uint8Array(memory);
|
|
72
|
+
const text = name => {
|
|
73
|
+
const value = decoder.decode(bytes.slice(...spans[name]));
|
|
74
|
+
if (value.includes("\0")) throw new HttpError(errno.EINVAL, "NUL in HTTP metadata");
|
|
75
|
+
return value;
|
|
76
|
+
};
|
|
77
|
+
return { sequence, request: { method: text("method"), url: text("url"), headers: text("headers"),
|
|
78
|
+
body: message.bodySize === 0n ? null : bytes.slice(...spans.body), flags: message.flags } };
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return { sequence, error: error instanceof HttpError ? error.errno : errno.EINVAL };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
current(word, sequence) {
|
|
85
|
+
return (Atomics.load(this.words, word + DOLLY_HTTP_WORD_SEQUENCE) >>> 0) === sequence;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Writes one record once the slot is writable: 0, or the errno that ended
|
|
89
|
+
// the transfer (the guest's cancellation, the page's failure, the deadline,
|
|
90
|
+
// in milliseconds of the epoch: page and Worker count from different origins).
|
|
91
|
+
async publish({ index, transfer, sequence, bytes, status, eof, kind, deadline }) {
|
|
92
|
+
const { words } = this, word = this.word(index);
|
|
93
|
+
for (;;) {
|
|
94
|
+
const ended = this.ended[index];
|
|
95
|
+
if (ended?.transfer === transfer) return ended.error;
|
|
96
|
+
if (!this.current(word, sequence)) return errno.ECANCELED;
|
|
97
|
+
if (now() >= deadline) return errno.ETIMEDOUT;
|
|
98
|
+
const state = Atomics.load(words, word);
|
|
99
|
+
if (state === DOLLY_HTTP_STATE_WRITABLE) break;
|
|
100
|
+
const waiting = Atomics.waitAsync(words, word, state, deadline - now());
|
|
101
|
+
if (waiting.async) await waiting.value;
|
|
102
|
+
}
|
|
103
|
+
if (bytes.length > DOLLY_HTTP_CHUNK_CAPACITY) return errno.E2BIG;
|
|
104
|
+
this.bytes.set(bytes, word * 4 + DOLLY_HTTP_HEADER_SIZE);
|
|
105
|
+
Atomics.store(words, word + DOLLY_HTTP_WORD_STATUS, status);
|
|
106
|
+
Atomics.store(words, word + DOLLY_HTTP_WORD_LENGTH, bytes.length);
|
|
107
|
+
Atomics.store(words, word + DOLLY_HTTP_WORD_EOF, eof ? 1 : 0);
|
|
108
|
+
Atomics.store(words, word + DOLLY_HTTP_WORD_ERROR, 0);
|
|
109
|
+
Atomics.store(words, word + DOLLY_HTTP_WORD_KIND, kind);
|
|
110
|
+
Atomics.store(words, word, DOLLY_HTTP_STATE_READABLE);
|
|
111
|
+
Atomics.notify(words, word);
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A transfer's end, once: a late result must not republish an error Wasm
|
|
116
|
+
// already consumed, nor reach a slot the guest has reused.
|
|
117
|
+
fail({ index, transfer, sequence, error }) {
|
|
118
|
+
const word = this.word(index);
|
|
119
|
+
if (this.ended[index]?.transfer === transfer || !this.current(word, sequence)) return;
|
|
120
|
+
this.ended[index] = { transfer, error };
|
|
121
|
+
Atomics.store(this.words, word + DOLLY_HTTP_WORD_ERROR, error);
|
|
122
|
+
Atomics.store(this.words, word, DOLLY_HTTP_STATE_FAILED);
|
|
123
|
+
Atomics.notify(this.words, word);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The page's broker: policy, Fetch and the slots' bookkeeping. It never reads
|
|
128
|
+
// kernel memory: `mailbox` (HttpMailbox, or messages to it) writes the slots.
|
|
129
|
+
export class NetworkTransport {
|
|
130
|
+
// `site` is the URL of the root of the site serving this release, if any.
|
|
131
|
+
constructor(mailbox, policy, { fetchRequest = globalThis.fetch.bind(globalThis), site } = {}) {
|
|
132
|
+
this.mailbox = mailbox;
|
|
133
|
+
this.capacity = DOLLY_HTTP_CHUNK_CAPACITY;
|
|
134
|
+
this.policy = policy;
|
|
135
|
+
this.fetchRequest = fetchRequest;
|
|
136
|
+
this.site = site;
|
|
137
|
+
// Host bookkeeping, never derived from a guest's claimed active count.
|
|
138
|
+
this.slots = Array(DOLLY_HTTP_SLOT_COUNT).fill(null);
|
|
139
|
+
this.closed = false;
|
|
140
|
+
this.requestCount = 0;
|
|
141
|
+
this.completedRequestCount = 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
get active() { return this.slots.some(slot => slot !== null); }
|
|
145
|
+
|
|
146
|
+
// A URL is absolute, or a site path: /vVERSION/FILE is a file of the site
|
|
147
|
+
// serving this release, never above its root and of no other version. The
|
|
148
|
+
// policy then judges it like any other URL.
|
|
149
|
+
target(url) {
|
|
150
|
+
const absolute = URL.parse(url);
|
|
151
|
+
if (absolute || !url.startsWith("/")) return absolute;
|
|
152
|
+
const file = URL.parse(sitePath(url).slice(1), this.site);
|
|
153
|
+
return file?.href.startsWith(this.site) ? file : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
slotIndex(sequence) {
|
|
157
|
+
if (!Number.isInteger(sequence) || sequence <= 0 || sequence > 0xffffffff)
|
|
158
|
+
throw new HttpError(errno.EINVAL, "invalid HTTP handle");
|
|
159
|
+
return (sequence - 1) % DOLLY_HTTP_SLOT_COUNT;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// An admission message (HttpMailbox.admission): 0 or a negative errno.
|
|
163
|
+
async dispatch({ sequence, cancel, request, error }) {
|
|
164
|
+
try {
|
|
165
|
+
if (this.closed) return -errno.ECANCELED;
|
|
166
|
+
const index = this.slotIndex(sequence);
|
|
167
|
+
if (cancel !== undefined) {
|
|
168
|
+
if (!cancel) throw new HttpError(errno.EINVAL, "invalid HTTP cancellation");
|
|
169
|
+
const transfer = this.slots[index];
|
|
170
|
+
if (transfer && transfer.sequence !== sequence) return -errno.ESTALE;
|
|
171
|
+
transfer?.cancel();
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
if (this.slots[index]) return -errno.EBUSY;
|
|
175
|
+
if (error) return -error;
|
|
176
|
+
this.request({ ...request, sequence });
|
|
177
|
+
return 0;
|
|
178
|
+
} catch (error) {
|
|
179
|
+
return -(error instanceof HttpError ? error.errno : errno.EINVAL);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
request(request) {
|
|
184
|
+
if (this.closed) throw new HttpError(errno.ECANCELED, "HTTP broker closed");
|
|
185
|
+
const index = this.slotIndex(request.sequence);
|
|
186
|
+
if (this.slots[index]) throw new HttpError(errno.EBUSY, "HTTP slot is settling");
|
|
187
|
+
const transfer = new HttpTransfer(this, index, request.sequence);
|
|
188
|
+
this.slots[index] = transfer;
|
|
189
|
+
transfer.id = ++this.requestCount;
|
|
190
|
+
transfer.pending = transfer.run(request).finally(() => {
|
|
191
|
+
this.completedRequestCount++;
|
|
192
|
+
this.slots[index] = null;
|
|
193
|
+
});
|
|
194
|
+
return transfer.pending;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Whole-runtime teardown only. Process cancellation uses an exact handle.
|
|
198
|
+
close() {
|
|
199
|
+
this.closed = true;
|
|
200
|
+
for (const transfer of this.slots) transfer?.cancel();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
class HttpTransfer {
|
|
205
|
+
constructor(broker, index, sequence) {
|
|
206
|
+
this.broker = broker;
|
|
207
|
+
this.index = index;
|
|
208
|
+
this.sequence = sequence;
|
|
209
|
+
this.controller = new AbortController();
|
|
210
|
+
this.deadline = Infinity;
|
|
211
|
+
this.terminalError = 0;
|
|
212
|
+
this.reader = null;
|
|
213
|
+
this.controller.signal.addEventListener("abort", () => {
|
|
214
|
+
void this.reader?.cancel().catch(() => {});
|
|
215
|
+
}, { once: true });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
check() {
|
|
219
|
+
if (this.terminalError) throw new HttpError(this.terminalError, "HTTP transfer ended");
|
|
220
|
+
if (this.controller.signal.aborted || performance.now() >= this.deadline)
|
|
221
|
+
throw new HttpError(errno.ETIMEDOUT, "HTTP deadline exceeded");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The mailbox ends the slot's transfer once, and only while the guest's
|
|
225
|
+
// slot still holds this sequence; a waiting record then gives up.
|
|
226
|
+
fail(error) {
|
|
227
|
+
if (this.terminalError) return;
|
|
228
|
+
this.terminalError = error;
|
|
229
|
+
this.broker.mailbox.fail({ index: this.index, transfer: this.id, sequence: this.sequence, error });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
cancel() {
|
|
233
|
+
this.fail(errno.ECANCELED);
|
|
234
|
+
this.controller.abort();
|
|
235
|
+
// Keep the host slot until run() settles. Repeated guest cancellations
|
|
236
|
+
// cannot accumulate unlimited providers that ignore AbortSignal.
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async publish(bytes, status, eof, kind) {
|
|
240
|
+
this.check();
|
|
241
|
+
if (bytes.length > this.broker.capacity) throw new HttpError(errno.E2BIG, "HTTP record exceeds slot capacity");
|
|
242
|
+
const error = await this.broker.mailbox.publish({ index: this.index, transfer: this.id, sequence: this.sequence,
|
|
243
|
+
bytes: bytes.slice(), status, eof, kind, deadline: now() + this.deadline - performance.now() });
|
|
244
|
+
if (error) throw new HttpError(error, "HTTP transfer ended");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async run({ method, url, headers: headerBlock, body, flags }) {
|
|
248
|
+
let timeout, failure = errno.EINVAL;
|
|
249
|
+
try {
|
|
250
|
+
this.check();
|
|
251
|
+
const target = this.broker.target(url);
|
|
252
|
+
if (target === null) throw new HttpError(errno.EINVAL, "HTTP requires an absolute URL or a site path");
|
|
253
|
+
if (target.protocol !== "http:" && target.protocol !== "https:")
|
|
254
|
+
throw new HttpError(errno.EPROTONOSUPPORT, "HTTP requires HTTP(S)");
|
|
255
|
+
if (target.username !== "" || target.password !== "")
|
|
256
|
+
throw new HttpError(errno.EINVAL, "HTTP requires a credential-free URL");
|
|
257
|
+
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(method))
|
|
258
|
+
throw new HttpError(errno.EINVAL, "invalid HTTP method");
|
|
259
|
+
const headers = new Headers();
|
|
260
|
+
for (const line of headerBlock.split(/\r?\n/)) {
|
|
261
|
+
if (line === "") continue;
|
|
262
|
+
const colon = line.indexOf(":");
|
|
263
|
+
if (colon <= 0) throw new HttpError(errno.EINVAL, "invalid HTTP request header");
|
|
264
|
+
headers.append(line.slice(0, colon), line.slice(colon + 1));
|
|
265
|
+
}
|
|
266
|
+
stripDollyBrowserOwnedHeaders(headers);
|
|
267
|
+
const upperMethod = method.toUpperCase();
|
|
268
|
+
const rule = this.broker.policy.authorize(target, upperMethod, headers, body?.byteLength ?? 0);
|
|
269
|
+
this.deadline = performance.now() + rule.timeoutMilliseconds;
|
|
270
|
+
timeout = setTimeout(() => {
|
|
271
|
+
this.fail(errno.ETIMEDOUT);
|
|
272
|
+
this.controller.abort();
|
|
273
|
+
}, rule.timeoutMilliseconds);
|
|
274
|
+
failure = errno.EIO;
|
|
275
|
+
const init = {
|
|
276
|
+
method: upperMethod, headers,
|
|
277
|
+
body: body === null || upperMethod === "GET" || upperMethod === "HEAD" ? undefined : body,
|
|
278
|
+
credentials: "omit",
|
|
279
|
+
redirect: (flags & 2) && rule.followRedirects === true ? "follow" : "error",
|
|
280
|
+
referrerPolicy: "no-referrer",
|
|
281
|
+
signal: this.controller.signal,
|
|
282
|
+
};
|
|
283
|
+
const source = rule.relay ? new URL(rule.relay) : target;
|
|
284
|
+
let response = await this.broker.fetchRequest(source, init);
|
|
285
|
+
if (rule.bootstrap === true)
|
|
286
|
+
response = await decodeStaticAsset(response, source, init, rule.maxResponseBytes, this.broker.fetchRequest);
|
|
287
|
+
const status = response.status;
|
|
288
|
+
// A relay is transport, and where the site is served is the page's
|
|
289
|
+
// business: the program is told the URL it asked for.
|
|
290
|
+
await this.publish(encoder.encode(rule.relay ? target.href : url.startsWith("/") ? url : response.url),
|
|
291
|
+
status, false, DOLLY_HTTP_KIND_URL);
|
|
292
|
+
await this.publish(encoder.encode(`HTTP/1.1 ${status} ${response.statusText}\r\n`), status, false, DOLLY_HTTP_KIND_HEADER);
|
|
293
|
+
for (const [name, value] of response.headers) {
|
|
294
|
+
if (!isDollyCredentialHeader(name))
|
|
295
|
+
await this.publish(encoder.encode(`${name}: ${value}\r\n`), status, false, DOLLY_HTTP_KIND_HEADER);
|
|
296
|
+
}
|
|
297
|
+
await this.publish(encoder.encode("\r\n"), status, false, DOLLY_HTTP_KIND_HEADER);
|
|
298
|
+
let responseBytes = 0;
|
|
299
|
+
const publishBody = async bytes => {
|
|
300
|
+
if (bytes.length > Number.MAX_SAFE_INTEGER - responseBytes)
|
|
301
|
+
throw new HttpError(errno.E2BIG, "HTTP response byte counter overflow");
|
|
302
|
+
responseBytes += bytes.length;
|
|
303
|
+
if (responseBytes > rule.maxResponseBytes)
|
|
304
|
+
throw new HttpError(errno.E2BIG, "HTTP response exceeds its size limit");
|
|
305
|
+
for (let offset = 0; offset < bytes.length; offset += this.broker.capacity)
|
|
306
|
+
await this.publish(bytes.subarray(offset, offset + this.broker.capacity), status, false, DOLLY_HTTP_KIND_BODY);
|
|
307
|
+
};
|
|
308
|
+
if (response.body !== null) {
|
|
309
|
+
this.reader = response.body.getReader();
|
|
310
|
+
for (;;) {
|
|
311
|
+
const { done, value } = await this.reader.read();
|
|
312
|
+
if (done) break;
|
|
313
|
+
await publishBody(value);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
await this.publish(new Uint8Array(), status, true, DOLLY_HTTP_KIND_BODY);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
this.fail(error instanceof HttpError ? error.errno :
|
|
319
|
+
this.controller.signal.aborted ? errno.ETIMEDOUT : failure);
|
|
320
|
+
} finally {
|
|
321
|
+
clearTimeout(timeout);
|
|
322
|
+
this.controller.abort();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createHttpAdmission, HttpMailbox, NetworkTransport } from "./broker.mjs";
|
|
2
|
+
import { DollyHttpPolicy, httpPolicyConfigurations, restrictDollyHttpPolicy } from "./policy.mjs";
|
|
3
|
+
import { localServicesTransport } from "./local-services.mjs";
|
|
4
|
+
export { DOLLY_HTTP_ABI_DIGEST as digest } from "./abi.mjs";
|
|
5
|
+
|
|
6
|
+
// The page takes the embedding's policy once, with the image directory's
|
|
7
|
+
// trusted sources and, in a custom tab, the policies it inherited; a builder or
|
|
8
|
+
// headless host is given its network instead. Modules depending on http@0 add
|
|
9
|
+
// their local services to `services`.
|
|
10
|
+
export function browser({ applicationBase, configuration, send, ask }) {
|
|
11
|
+
let transport, admission, network = configuration.network;
|
|
12
|
+
const services = {};
|
|
13
|
+
const instance = {
|
|
14
|
+
services,
|
|
15
|
+
get transport() { return transport; },
|
|
16
|
+
page: {
|
|
17
|
+
get httpActive() { return transport?.active ?? false; },
|
|
18
|
+
get httpRequestCount() { return transport?.requestCount ?? 0; },
|
|
19
|
+
get httpCompletedRequestCount() { return transport?.completedRequestCount ?? 0; },
|
|
20
|
+
},
|
|
21
|
+
start(message) {
|
|
22
|
+
if (transport || !(message.admission instanceof SharedArrayBuffer) || message.admission.byteLength !== 8) {
|
|
23
|
+
throw new Error("invalid HTTP provider handshake");
|
|
24
|
+
}
|
|
25
|
+
admission = new Int32Array(message.admission);
|
|
26
|
+
// The Worker writes the records into the guest's slots.
|
|
27
|
+
const mailbox = { publish: record => ask({ type: "http-publish", ...record }, [record.bytes.buffer]),
|
|
28
|
+
fail: ending => send({ type: "http-fail", ...ending }) };
|
|
29
|
+
transport = new NetworkTransport(mailbox, network.policy, { fetchRequest: network.fetchRequest, site: network.site });
|
|
30
|
+
},
|
|
31
|
+
messages: {
|
|
32
|
+
async "http-request"(message) {
|
|
33
|
+
if (!transport) throw new Error("HTTP request before provider initialization");
|
|
34
|
+
const result = await transport.dispatch(message);
|
|
35
|
+
Atomics.store(admission, 1, result);
|
|
36
|
+
Atomics.store(admission, 0, 0);
|
|
37
|
+
Atomics.notify(admission, 0);
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
dispose() { transport?.close(); },
|
|
41
|
+
};
|
|
42
|
+
if (!network) {
|
|
43
|
+
// A custom tab intersects the policy of the page that built it with its
|
|
44
|
+
// own; a missing inheritance fails closed.
|
|
45
|
+
const { embedding, sources, inherited } = configuration;
|
|
46
|
+
let policy = new DollyHttpPolicy(embedding?.policy, sources, applicationBase, embedding?.relays);
|
|
47
|
+
if (inherited) policy = restrictDollyHttpPolicy(policy, inherited.policies, sources, applicationBase);
|
|
48
|
+
// A site path names a file of this page's site.
|
|
49
|
+
const site = new URL(applicationBase).href;
|
|
50
|
+
network = { ...localServicesTransport(policy, services), site };
|
|
51
|
+
// Builders inherit the policy and no local service; an opened result tab
|
|
52
|
+
// or restored session inherits the policy configurations.
|
|
53
|
+
instance.builder = { network: { ...localServicesTransport(policy), site } };
|
|
54
|
+
instance.inherited = { policies: httpPolicyConfigurations(policy) };
|
|
55
|
+
}
|
|
56
|
+
return instance;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The Worker copies each request out of kernel memory for the page to judge,
|
|
60
|
+
// and writes the page's records into the slots.
|
|
61
|
+
export function worker({ send, get }) {
|
|
62
|
+
const admission = createHttpAdmission(request => send({ type: "http-request", ...request }));
|
|
63
|
+
let mailbox;
|
|
64
|
+
return {
|
|
65
|
+
bindings: { "env.dolly_http_dispatch": (method, methodSize, url, urlSize, headers, headersSize,
|
|
66
|
+
body, bodySize, flags, sequence) => admission.dispatch(mailbox.admission(get("runtime").memory.buffer,
|
|
67
|
+
{ method, methodSize, url, urlSize, headers, headersSize, body, bodySize, flags, sequence })) },
|
|
68
|
+
messages: { "http-publish": record => mailbox.publish(record), "http-fail": ending => mailbox.fail(ending) },
|
|
69
|
+
start({ kernel, memory }) {
|
|
70
|
+
mailbox = new HttpMailbox(memory.buffer, Number(kernel.dolly_http_mailbox_address()));
|
|
71
|
+
return { admission: admission.control.buffer };
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { HttpError } from "./policy.mjs";
|
|
2
|
+
import { DOLLY_ERRNO } from "./abi.mjs";
|
|
3
|
+
|
|
4
|
+
function reservedLocalURL(url) {
|
|
5
|
+
const host = url.hostname.toLowerCase().replace(/\.$/, "");
|
|
6
|
+
return host === "dolly.invalid" || host.endsWith(".dolly.invalid");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Review all local authority here. Remote rules never grant these services;
|
|
10
|
+
// absent services (including in build workers) fail closed, not to Fetch.
|
|
11
|
+
// A service owns one reserved origin and admits a request by returning its
|
|
12
|
+
// limits from authorize(url, method, bytes). Enabled modules add theirs once
|
|
13
|
+
// the image ENTRY starts: build@0 (host/build/build.mjs) and packages@0
|
|
14
|
+
// (host/packages/packages.mjs).
|
|
15
|
+
export function localServicesTransport(remotePolicy, services = {}, remoteFetch = globalThis.fetch.bind(globalThis)) {
|
|
16
|
+
function localRule(url, method, bytes) {
|
|
17
|
+
const service = Object.values(services).find(candidate => candidate.origin === url.origin);
|
|
18
|
+
const rule = !url.username && !url.password && !url.search && !url.hash
|
|
19
|
+
? service?.authorize(url, method, bytes) : undefined;
|
|
20
|
+
if (!rule) throw new HttpError(DOLLY_ERRNO.EACCES, "Browser-local service request denied");
|
|
21
|
+
return [service, rule];
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
policy: { authorize(url, method, headers, bytes) {
|
|
25
|
+
if (!reservedLocalURL(url)) return remotePolicy.authorize(url, method, headers, bytes);
|
|
26
|
+
const [, rule] = localRule(url, method, bytes);
|
|
27
|
+
for (const name of [...headers.keys()]) headers.delete(name);
|
|
28
|
+
return rule;
|
|
29
|
+
} },
|
|
30
|
+
fetchRequest: (url, init) => {
|
|
31
|
+
url = new URL(url);
|
|
32
|
+
if (!reservedLocalURL(url)) return remoteFetch(url, init);
|
|
33
|
+
const [service] = localRule(url, init.method, init.body?.byteLength ?? 0);
|
|
34
|
+
return service.fetch(url, { ...init, headers: new Headers() });
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "http",
|
|
3
|
+
"version": 0,
|
|
4
|
+
"gives": "HTTP(S) requests under the page's policy",
|
|
5
|
+
"fixed": "the page's HTTP policy is consumed once, at load",
|
|
6
|
+
"dependencies": ["runtime@0"],
|
|
7
|
+
"phase": "kernel",
|
|
8
|
+
"imports": ["env.dolly_http_dispatch"],
|
|
9
|
+
"host": "http.mjs",
|
|
10
|
+
"contracts": ["dolly-http-0.wat"],
|
|
11
|
+
"process": [],
|
|
12
|
+
"headers": ["http.h", "http-abi.h"],
|
|
13
|
+
"kernel": ["kernel.c"],
|
|
14
|
+
"client": ["client.c"]
|
|
15
|
+
}
|