@volter/browser-wali 0.1.5 → 0.1.6
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.md +73 -0
- package/dist/browser-wali-command.d.ts +3 -0
- package/dist/browser-wali-command.d.ts.map +1 -1
- package/dist/browser-wali-command.js +2 -1
- package/dist/browser-wali-command.js.map +1 -1
- package/dist/capabilities.d.ts +73 -1
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +86 -2
- package/dist/capabilities.js.map +1 -1
- package/dist/child-processes.d.ts +24 -0
- package/dist/child-processes.d.ts.map +1 -0
- package/dist/child-processes.js +134 -0
- package/dist/child-processes.js.map +1 -0
- package/dist/codec-protocol.d.ts +66 -0
- package/dist/codec-protocol.d.ts.map +1 -0
- package/dist/codec-protocol.js +59 -0
- package/dist/codec-protocol.js.map +1 -0
- package/dist/codec-remote.d.ts +33 -0
- package/dist/codec-remote.d.ts.map +1 -0
- package/dist/codec-remote.js +190 -0
- package/dist/codec-remote.js.map +1 -0
- package/dist/codec-worker.d.ts +2 -0
- package/dist/codec-worker.d.ts.map +1 -0
- package/dist/codec-worker.js +438 -0
- package/dist/codec-worker.js.map +1 -0
- package/dist/coherent-file-locks.d.ts.map +1 -1
- package/dist/coherent-file-locks.js +17 -5
- package/dist/coherent-file-locks.js.map +1 -1
- package/dist/descriptors.d.ts +49 -0
- package/dist/descriptors.d.ts.map +1 -0
- package/dist/descriptors.js +115 -0
- package/dist/descriptors.js.map +1 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +2 -2
- package/dist/events.js.map +1 -1
- package/dist/filesystem-protocol.d.ts +28 -1
- package/dist/filesystem-protocol.d.ts.map +1 -1
- package/dist/filesystem-protocol.js +18 -0
- package/dist/filesystem-protocol.js.map +1 -1
- package/dist/filesystem-remote.d.ts +26 -2
- package/dist/filesystem-remote.d.ts.map +1 -1
- package/dist/filesystem-remote.js +132 -23
- package/dist/filesystem-remote.js.map +1 -1
- package/dist/filesystem-worker.js +274 -34
- package/dist/filesystem-worker.js.map +1 -1
- package/dist/filesystem.d.ts +30 -1
- package/dist/filesystem.d.ts.map +1 -1
- package/dist/filesystem.js +180 -50
- package/dist/filesystem.js.map +1 -1
- package/dist/fork-worker.d.ts +2 -0
- package/dist/fork-worker.d.ts.map +1 -0
- package/dist/fork-worker.js +52 -0
- package/dist/fork-worker.js.map +1 -0
- package/dist/host.d.ts +36 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +153 -0
- package/dist/host.js.map +1 -0
- package/dist/loopback-host.d.ts +25 -1
- package/dist/loopback-host.d.ts.map +1 -1
- package/dist/loopback-host.js +447 -13
- package/dist/loopback-host.js.map +1 -1
- package/dist/memory.d.ts +65 -2
- package/dist/memory.d.ts.map +1 -1
- package/dist/memory.js +306 -49
- package/dist/memory.js.map +1 -1
- package/dist/module-layout.d.ts +2 -0
- package/dist/module-layout.d.ts.map +1 -1
- package/dist/module-layout.js +4 -2
- package/dist/module-layout.js.map +1 -1
- package/dist/monotonic-clock.d.ts +12 -0
- package/dist/monotonic-clock.d.ts.map +1 -0
- package/dist/monotonic-clock.js +14 -0
- package/dist/monotonic-clock.js.map +1 -0
- package/dist/network-protocol.d.ts +12 -1
- package/dist/network-protocol.d.ts.map +1 -1
- package/dist/network-protocol.js +6 -0
- package/dist/network-protocol.js.map +1 -1
- package/dist/network-worker.js +149 -20
- package/dist/network-worker.js.map +1 -1
- package/dist/network.d.ts +15 -3
- package/dist/network.d.ts.map +1 -1
- package/dist/network.js +74 -39
- package/dist/network.js.map +1 -1
- package/dist/process-adapter.d.ts +4 -1
- package/dist/process-adapter.d.ts.map +1 -1
- package/dist/process-adapter.js +3 -3
- package/dist/process-adapter.js.map +1 -1
- package/dist/process-bridge.d.ts +43 -0
- package/dist/process-bridge.d.ts.map +1 -1
- package/dist/process-bridge.js +30 -1
- package/dist/process-bridge.js.map +1 -1
- package/dist/program.d.ts +60 -1
- package/dist/program.d.ts.map +1 -1
- package/dist/program.js +92 -31
- package/dist/program.js.map +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +491 -134
- package/dist/runtime.js.map +1 -1
- package/dist/select.d.ts +16 -0
- package/dist/select.d.ts.map +1 -0
- package/dist/select.js +100 -0
- package/dist/select.js.map +1 -0
- package/dist/shared-pipes.d.ts +17 -6
- package/dist/shared-pipes.d.ts.map +1 -1
- package/dist/shared-pipes.js +94 -70
- package/dist/shared-pipes.js.map +1 -1
- package/dist/signals.d.ts +48 -0
- package/dist/signals.d.ts.map +1 -0
- package/dist/signals.js +232 -0
- package/dist/signals.js.map +1 -0
- package/dist/snapshot-filesystem.d.ts +128 -2
- package/dist/snapshot-filesystem.d.ts.map +1 -1
- package/dist/snapshot-filesystem.js +481 -53
- package/dist/snapshot-filesystem.js.map +1 -1
- package/dist/spawn-policy.d.ts +5 -0
- package/dist/spawn-policy.d.ts.map +1 -0
- package/dist/spawn-policy.js +29 -0
- package/dist/spawn-policy.js.map +1 -0
- package/dist/stdin.d.ts +2 -0
- package/dist/stdin.d.ts.map +1 -1
- package/dist/stdin.js +4 -0
- package/dist/stdin.js.map +1 -1
- package/dist/symlink.d.ts +8 -0
- package/dist/symlink.d.ts.map +1 -1
- package/dist/symlink.js +13 -0
- package/dist/symlink.js.map +1 -1
- package/dist/system.d.ts +152 -1
- package/dist/system.d.ts.map +1 -1
- package/dist/system.js +226 -7
- package/dist/system.js.map +1 -1
- package/dist/thread-coordinator-worker.d.ts +1 -0
- package/dist/thread-coordinator-worker.d.ts.map +1 -1
- package/dist/thread-coordinator-worker.js +1 -0
- package/dist/thread-coordinator-worker.js.map +1 -1
- package/dist/thread-worker.js +241 -122
- package/dist/thread-worker.js.map +1 -1
- package/dist/threads.d.ts +12 -2
- package/dist/threads.d.ts.map +1 -1
- package/dist/threads.js +30 -10
- package/dist/threads.js.map +1 -1
- package/dist/types.d.ts +39 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/websocket-peer.d.ts.map +1 -1
- package/dist/websocket-peer.js +4 -14
- package/dist/websocket-peer.js.map +1 -1
- package/dist/worker-program-protocol.d.ts +70 -3
- package/dist/worker-program-protocol.d.ts.map +1 -1
- package/dist/worker-program-worker.js +115 -23
- package/dist/worker-program-worker.js.map +1 -1
- package/dist/worker-program.d.ts +25 -1
- package/dist/worker-program.d.ts.map +1 -1
- package/dist/worker-program.js +470 -61
- package/dist/worker-program.js.map +1 -1
- package/dist/worker-standby.d.ts +3 -0
- package/dist/worker-standby.d.ts.map +1 -0
- package/dist/worker-standby.js +17 -0
- package/dist/worker-standby.js.map +1 -0
- package/package.json +20 -6
package/dist/loopback-host.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { browserRegisteredServer } from "@volter/browser-runtime/browser-http.js";
|
|
1
2
|
import { WaliWebSocketPeer } from "./websocket-peer.js";
|
|
2
3
|
const MAX_ACTIVE_CONNECTIONS = 32;
|
|
3
4
|
const MAX_REQUEST_BYTES = 16 * 1024 * 1024;
|
|
@@ -10,18 +11,77 @@ export class BrowserWaliLoopbackHost {
|
|
|
10
11
|
bridge;
|
|
11
12
|
webSocketRegistry;
|
|
12
13
|
nextConnection = 1;
|
|
14
|
+
tcpServers = new Map();
|
|
15
|
+
/** Per listening WALI port, a raw byte connection to it: what a socket client of another lane is given. */
|
|
16
|
+
rawOpeners = new Map();
|
|
17
|
+
listenObservers = new Set();
|
|
18
|
+
listenReleases = new Map();
|
|
13
19
|
constructor(bridge, webSocketRegistry) {
|
|
14
20
|
this.bridge = bridge;
|
|
15
21
|
this.webSocketRegistry = webSocketRegistry;
|
|
16
22
|
}
|
|
23
|
+
/** Raw TCP is opt-in per virtual port; other ports keep their HTTP bridge. */
|
|
24
|
+
listenTcp(port, accept) {
|
|
25
|
+
if (!validPort(port) || this.tcpServers.has(port) || this.bridge.getServerPorts().includes(port)) {
|
|
26
|
+
throw new Error(`Loopback port ${port} is invalid or already in use.`);
|
|
27
|
+
}
|
|
28
|
+
const server = { accept, sockets: new Set() };
|
|
29
|
+
this.tcpServers.set(port, server);
|
|
30
|
+
return () => {
|
|
31
|
+
if (this.tcpServers.get(port) !== server)
|
|
32
|
+
return;
|
|
33
|
+
this.tcpServers.delete(port);
|
|
34
|
+
for (const socket of [...server.sockets])
|
|
35
|
+
socket.close();
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Called for every port a WALI program listens on, now and later; the
|
|
40
|
+
* function it returns runs when that listener closes. The host a page
|
|
41
|
+
* builds uses it to give the port to its other lanes' socket clients.
|
|
42
|
+
*/
|
|
43
|
+
onListen(observer) {
|
|
44
|
+
this.listenObservers.add(observer);
|
|
45
|
+
for (const port of this.rawOpeners.keys())
|
|
46
|
+
this.notifyListen(port, observer);
|
|
47
|
+
return () => { this.listenObservers.delete(observer); };
|
|
48
|
+
}
|
|
49
|
+
/** A raw TCP connection to a WALI program's listening port: bytes both ways, as a socket client sees them. */
|
|
50
|
+
connectTcp(port) {
|
|
51
|
+
const open = this.rawOpeners.get(port);
|
|
52
|
+
if (!open)
|
|
53
|
+
throw Object.assign(new Error(`Nothing listens on loopback port ${port}.`), { code: "ECONNREFUSED" });
|
|
54
|
+
return open();
|
|
55
|
+
}
|
|
56
|
+
notifyListen(port, observer) {
|
|
57
|
+
try {
|
|
58
|
+
const release = observer(port);
|
|
59
|
+
if (release)
|
|
60
|
+
this.listenReleases.set(port, [...(this.listenReleases.get(port) ?? []), release]);
|
|
61
|
+
}
|
|
62
|
+
catch { /* an observer that cannot take the port leaves it HTTP-only */ }
|
|
63
|
+
}
|
|
64
|
+
releaseListen(port) {
|
|
65
|
+
this.rawOpeners.delete(port);
|
|
66
|
+
const releases = this.listenReleases.get(port) ?? [];
|
|
67
|
+
this.listenReleases.delete(port);
|
|
68
|
+
for (const release of releases) {
|
|
69
|
+
try {
|
|
70
|
+
release();
|
|
71
|
+
}
|
|
72
|
+
catch { /* already gone */ }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
17
75
|
attach(port) {
|
|
18
76
|
const registered = new Set();
|
|
19
77
|
const pending = new Map();
|
|
20
78
|
const outgoing = new Map();
|
|
21
79
|
const webSockets = new Map();
|
|
22
80
|
const webSocketRegistrations = new Map();
|
|
81
|
+
const tcp = new Map();
|
|
82
|
+
const raws = new Map();
|
|
23
83
|
let closed = false;
|
|
24
|
-
const activeConnections = () => pending.size + outgoing.size + webSockets.size;
|
|
84
|
+
const activeConnections = () => pending.size + outgoing.size + webSockets.size + tcp.size + raws.size;
|
|
25
85
|
const send = (message) => {
|
|
26
86
|
if (!closed)
|
|
27
87
|
port.postMessage(message);
|
|
@@ -29,6 +89,7 @@ export class BrowserWaliLoopbackHost {
|
|
|
29
89
|
const unregister = (virtualPort) => {
|
|
30
90
|
if (!registered.delete(virtualPort))
|
|
31
91
|
return;
|
|
92
|
+
this.releaseListen(virtualPort);
|
|
32
93
|
webSocketRegistrations.get(virtualPort)?.();
|
|
33
94
|
webSocketRegistrations.delete(virtualPort);
|
|
34
95
|
for (const [connectionId, active] of webSockets) {
|
|
@@ -59,6 +120,184 @@ export class BrowserWaliLoopbackHost {
|
|
|
59
120
|
send({ type: "incoming", connectionId, port: virtualPort, bytes: peer.handshake });
|
|
60
121
|
return peer;
|
|
61
122
|
};
|
|
123
|
+
// A client's connection as the program sees it: accepted at once
|
|
124
|
+
// (`incoming`, with no bytes, so a server that speaks first can), its
|
|
125
|
+
// writes following on it, and either side may end it.
|
|
126
|
+
const openRaw = (virtualPort) => {
|
|
127
|
+
if (closed)
|
|
128
|
+
throw new Error("WALI loopback host is closed.");
|
|
129
|
+
if (activeConnections() >= MAX_ACTIVE_CONNECTIONS)
|
|
130
|
+
throw new Error("WALI loopback concurrency exceeded.");
|
|
131
|
+
const connectionId = this.nextConnection++;
|
|
132
|
+
const dataListeners = new Set();
|
|
133
|
+
const closeListeners = new Set();
|
|
134
|
+
let ended = false;
|
|
135
|
+
const end = (notify) => {
|
|
136
|
+
if (ended)
|
|
137
|
+
return;
|
|
138
|
+
ended = true;
|
|
139
|
+
raws.delete(connectionId);
|
|
140
|
+
if (notify)
|
|
141
|
+
send({ type: "peer-close", connectionId });
|
|
142
|
+
for (const listener of closeListeners)
|
|
143
|
+
listener();
|
|
144
|
+
};
|
|
145
|
+
raws.set(connectionId, {
|
|
146
|
+
receive: (bytes) => { for (const listener of dataListeners)
|
|
147
|
+
listener(bytes); },
|
|
148
|
+
closed: () => end(false),
|
|
149
|
+
});
|
|
150
|
+
const socket = {
|
|
151
|
+
onData: (listener) => { dataListeners.add(listener); },
|
|
152
|
+
onClose: (listener) => { closeListeners.add(listener); },
|
|
153
|
+
write: (bytes) => { if (!ended)
|
|
154
|
+
send({ type: "data", connectionId, bytes }); },
|
|
155
|
+
close: () => end(true),
|
|
156
|
+
};
|
|
157
|
+
send({ type: "incoming", connectionId, port: virtualPort, bytes: new Uint8Array(0) });
|
|
158
|
+
return socket;
|
|
159
|
+
};
|
|
160
|
+
// A request answered as it arrives: the head resolves the Response and
|
|
161
|
+
// the body streams, so an event stream (SSE) is read as it is written
|
|
162
|
+
// rather than held to the buffered door's 30-second answer. The WALI wire
|
|
163
|
+
// carries no credit to pause the program, so what it has written waits in
|
|
164
|
+
// this stream for its reader, and the whole answer is held to the same
|
|
165
|
+
// 32 MiB the buffered door and the outgoing stream are.
|
|
166
|
+
const streamingFetch = async (virtualPort, incoming) => {
|
|
167
|
+
const body = incoming.method === "GET" || incoming.method === "HEAD" ? undefined : new Uint8Array(await incoming.arrayBuffer());
|
|
168
|
+
const headers = {};
|
|
169
|
+
incoming.headers.forEach((value, name) => { if (name !== "content-length" && name !== "transfer-encoding" && name !== "connection")
|
|
170
|
+
headers[name] = value; });
|
|
171
|
+
const target = new URL(incoming.url);
|
|
172
|
+
const socket = openRaw(virtualPort);
|
|
173
|
+
return new Promise((resolve, reject) => {
|
|
174
|
+
let buffered = new Uint8Array(0);
|
|
175
|
+
let controller;
|
|
176
|
+
let framing;
|
|
177
|
+
let finished = false;
|
|
178
|
+
let streamed = 0;
|
|
179
|
+
const deliver = (bytes) => {
|
|
180
|
+
streamed += bytes.byteLength;
|
|
181
|
+
if (streamed > MAX_RESPONSE_BYTES) {
|
|
182
|
+
finish(new Error("WALI loopback response exceeds 32 MiB."));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
controller.enqueue(bytes);
|
|
186
|
+
};
|
|
187
|
+
const finish = (error) => {
|
|
188
|
+
if (finished)
|
|
189
|
+
return;
|
|
190
|
+
finished = true;
|
|
191
|
+
if (!controller)
|
|
192
|
+
reject(error ?? new Error("WALI server closed before returning an HTTP response."));
|
|
193
|
+
else if (error)
|
|
194
|
+
controller.error(error);
|
|
195
|
+
else
|
|
196
|
+
controller.close();
|
|
197
|
+
socket.close();
|
|
198
|
+
};
|
|
199
|
+
const drain = () => {
|
|
200
|
+
if (!controller || !framing)
|
|
201
|
+
return;
|
|
202
|
+
if (framing.kind === "close") {
|
|
203
|
+
if (buffered.byteLength) {
|
|
204
|
+
const bytes = buffered;
|
|
205
|
+
buffered = new Uint8Array(0);
|
|
206
|
+
deliver(bytes);
|
|
207
|
+
}
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (framing.kind === "length") {
|
|
211
|
+
const take = Math.min(framing.remaining, buffered.byteLength);
|
|
212
|
+
if (take) {
|
|
213
|
+
const bytes = buffered.slice(0, take);
|
|
214
|
+
buffered = buffered.slice(take);
|
|
215
|
+
framing.remaining -= take;
|
|
216
|
+
deliver(bytes);
|
|
217
|
+
}
|
|
218
|
+
if (finished)
|
|
219
|
+
return;
|
|
220
|
+
if (framing.remaining === 0)
|
|
221
|
+
finish();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
for (;;) {
|
|
225
|
+
const lineEnd = indexOf(buffered, [13, 10]);
|
|
226
|
+
if (lineEnd < 0)
|
|
227
|
+
return;
|
|
228
|
+
const size = Number.parseInt(new TextDecoder().decode(buffered.subarray(0, lineEnd)).split(";")[0].trim(), 16);
|
|
229
|
+
if (!Number.isSafeInteger(size) || size < 0) {
|
|
230
|
+
finish(new Error("WALI server sent an invalid chunk."));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (size === 0) {
|
|
234
|
+
finish();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (buffered.byteLength < lineEnd + 2 + size + 2)
|
|
238
|
+
return;
|
|
239
|
+
const bytes = buffered.slice(lineEnd + 2, lineEnd + 2 + size);
|
|
240
|
+
buffered = buffered.slice(lineEnd + 2 + size + 2);
|
|
241
|
+
deliver(bytes);
|
|
242
|
+
if (finished)
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
socket.onData((bytes) => {
|
|
247
|
+
if (finished)
|
|
248
|
+
return;
|
|
249
|
+
const next = new Uint8Array(buffered.byteLength + bytes.byteLength);
|
|
250
|
+
next.set(buffered);
|
|
251
|
+
next.set(bytes, buffered.byteLength);
|
|
252
|
+
buffered = next;
|
|
253
|
+
if (!framing) {
|
|
254
|
+
const boundary = indexOf(buffered, [13, 10, 13, 10]);
|
|
255
|
+
if (boundary < 0) {
|
|
256
|
+
if (buffered.byteLength > MAX_HEADER_BYTES)
|
|
257
|
+
finish(new Error("WALI server response headers exceed 64 KiB."));
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const lines = new TextDecoder().decode(buffered.subarray(0, boundary)).split("\r\n");
|
|
261
|
+
buffered = buffered.slice(boundary + 4);
|
|
262
|
+
const status = /^HTTP\/1\.[01] (\d{3})(?: (.*))?$/u.exec(lines.shift() ?? "");
|
|
263
|
+
if (!status) {
|
|
264
|
+
finish(new Error("WALI server sent an invalid HTTP status line."));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const responseHeaders = new Headers();
|
|
268
|
+
for (const line of lines) {
|
|
269
|
+
const separator = line.indexOf(":");
|
|
270
|
+
if (separator > 0)
|
|
271
|
+
responseHeaders.append(line.slice(0, separator).trim(), line.slice(separator + 1).trim());
|
|
272
|
+
}
|
|
273
|
+
const length = responseHeaders.get("content-length");
|
|
274
|
+
framing = /\bchunked\b/iu.test(responseHeaders.get("transfer-encoding") ?? "") ? { kind: "chunked" }
|
|
275
|
+
: length !== null ? { kind: "length", remaining: Number(length) } : { kind: "close" };
|
|
276
|
+
const statusCode = Number(status[1]);
|
|
277
|
+
const empty = incoming.method === "HEAD" || statusCode === 204 || statusCode === 304;
|
|
278
|
+
for (const name of ["transfer-encoding", "connection", "keep-alive"])
|
|
279
|
+
responseHeaders.delete(name);
|
|
280
|
+
const stream = empty ? null : new ReadableStream({
|
|
281
|
+
start: (started) => { controller = started; },
|
|
282
|
+
cancel: () => { finished = true; socket.close(); },
|
|
283
|
+
});
|
|
284
|
+
resolve(new Response(stream, { status: statusCode, statusText: status[2] ?? "", headers: responseHeaders }));
|
|
285
|
+
if (empty) {
|
|
286
|
+
finished = true;
|
|
287
|
+
socket.close();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
drain();
|
|
292
|
+
});
|
|
293
|
+
socket.onClose(() => { if (framing?.kind === "close" || !controller)
|
|
294
|
+
finish();
|
|
295
|
+
else if (!finished)
|
|
296
|
+
finish(new Error("WALI server closed mid-response.")); });
|
|
297
|
+
incoming.signal.addEventListener("abort", () => finish(incoming.signal.reason), { once: true });
|
|
298
|
+
socket.write(serializeRequest(incoming.method, `${target.pathname}${target.search}`, headers, body));
|
|
299
|
+
});
|
|
300
|
+
};
|
|
62
301
|
const request = (virtualPort, method, url, headers, body) => {
|
|
63
302
|
if (closed)
|
|
64
303
|
return Promise.reject(new Error("WALI loopback host is closed."));
|
|
@@ -97,21 +336,106 @@ export class BrowserWaliLoopbackHost {
|
|
|
97
336
|
return;
|
|
98
337
|
outgoing.delete(connectionId);
|
|
99
338
|
window.clearTimeout(active.timeout);
|
|
339
|
+
active.abort.abort();
|
|
100
340
|
send({ type: "peer-close", connectionId });
|
|
101
341
|
};
|
|
102
342
|
const dispatchOutgoing = async (connectionId, peerClosed) => {
|
|
103
343
|
const active = outgoing.get(connectionId);
|
|
104
344
|
if (!active || active.handling)
|
|
105
345
|
return;
|
|
346
|
+
let parsed;
|
|
347
|
+
try {
|
|
348
|
+
parsed = parseRequest(active.chunks, active.byteLength, peerClosed);
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
finishOutgoing(connectionId);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
// Headers and body may arrive in different writes. An incomplete
|
|
355
|
+
// request still owns its socket and waits for the remaining bytes.
|
|
356
|
+
if (!parsed) {
|
|
357
|
+
if (peerClosed)
|
|
358
|
+
finishOutgoing(connectionId);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
active.handling = true;
|
|
362
|
+
const body = parsed.body?.buffer.slice(parsed.body.byteOffset, parsed.body.byteOffset + parsed.body.byteLength);
|
|
363
|
+
// A streamed response is framed by the connection's close, so its head
|
|
364
|
+
// carries no length; the response deadline covers only the wait for it.
|
|
365
|
+
let streamed = 0;
|
|
366
|
+
const head = (statusCode, statusMessage, headers) => {
|
|
367
|
+
window.clearTimeout(active.timeout);
|
|
368
|
+
send({ type: "data", connectionId, bytes: serializeHead(statusCode, statusMessage, headers) });
|
|
369
|
+
};
|
|
370
|
+
const chunk = (bytes) => {
|
|
371
|
+
streamed += bytes.byteLength;
|
|
372
|
+
if (streamed > MAX_RESPONSE_BYTES)
|
|
373
|
+
throw new Error("Browser loopback response exceeds 32 MiB.");
|
|
374
|
+
if (bytes.byteLength > 0)
|
|
375
|
+
send({ type: "data", connectionId, bytes: Uint8Array.from(bytes) });
|
|
376
|
+
};
|
|
106
377
|
try {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
378
|
+
// A server the page answers as Fetch (a Host Fetch service) streams
|
|
379
|
+
// its body as it is produced, and the client's close reaches its
|
|
380
|
+
// handler as an abort, as a Node guest's loopback fetch does.
|
|
381
|
+
const registered = browserRegisteredServer(this.bridge, active.port);
|
|
382
|
+
const fetchable = registered?.fetch;
|
|
383
|
+
const streaming = registered?.handleStreamingRequest;
|
|
384
|
+
// A streamed answer's timing is the client's, as on a real socket: its
|
|
385
|
+
// close aborts the handler, so no deadline is needed to reclaim the
|
|
386
|
+
// connection. A local model answers its head only after prefill.
|
|
387
|
+
if (fetchable || streaming || this.bridge.handleStreamingRequest)
|
|
388
|
+
window.clearTimeout(active.timeout);
|
|
389
|
+
if (fetchable) {
|
|
390
|
+
const response = await fetchable(new Request(`http://127.0.0.1:${active.port}${parsed.url}`, {
|
|
391
|
+
method: parsed.method,
|
|
392
|
+
headers: parsed.headers,
|
|
393
|
+
...(body && body.byteLength > 0 && parsed.method !== "GET" && parsed.method !== "HEAD" ? { body } : {}),
|
|
394
|
+
signal: active.abort.signal,
|
|
395
|
+
}));
|
|
396
|
+
const reader = response.body?.getReader();
|
|
397
|
+
try {
|
|
398
|
+
if (!outgoing.has(connectionId))
|
|
399
|
+
return;
|
|
400
|
+
head(response.status, response.statusText, Object.fromEntries(response.headers));
|
|
401
|
+
for (;;) {
|
|
402
|
+
const next = reader ? await reader.read() : { done: true, value: undefined };
|
|
403
|
+
if (next.done || !outgoing.has(connectionId))
|
|
404
|
+
break;
|
|
405
|
+
chunk(next.value);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
finally {
|
|
409
|
+
await reader?.cancel().catch(() => undefined);
|
|
410
|
+
reader?.releaseLock();
|
|
411
|
+
}
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
// A worker's port streams through its registration's door, paced by
|
|
415
|
+
// nothing but the client, and the client's close cancels it.
|
|
416
|
+
if (streaming) {
|
|
417
|
+
await streaming.call(registered, parsed.method, parsed.url, parsed.headers, body ? new Uint8Array(body) : undefined, (statusCode, statusMessage, headers) => { if (outgoing.has(connectionId))
|
|
418
|
+
head(statusCode, statusMessage, headers); }, (bytes) => { if (outgoing.has(connectionId))
|
|
419
|
+
chunk(bytes); }, () => undefined, { signal: active.abort.signal });
|
|
111
420
|
return;
|
|
112
421
|
}
|
|
113
|
-
|
|
114
|
-
|
|
422
|
+
// A guest's server streams through the bridge's own door when it has one.
|
|
423
|
+
if (this.bridge.handleStreamingRequest) {
|
|
424
|
+
let ended;
|
|
425
|
+
const end = new Promise((resolve) => { ended = resolve; });
|
|
426
|
+
const handled = await this.bridge.handleStreamingRequest(active.port, parsed.method, parsed.url, parsed.headers, body, {
|
|
427
|
+
start: (statusCode, statusMessage, headers) => { if (outgoing.has(connectionId))
|
|
428
|
+
head(statusCode, statusMessage, headers); },
|
|
429
|
+
chunk: (bytes) => { if (outgoing.has(connectionId))
|
|
430
|
+
chunk(bytes); },
|
|
431
|
+
end: () => ended(),
|
|
432
|
+
});
|
|
433
|
+
if (handled) {
|
|
434
|
+
await Promise.race([end, new Promise((resolve) => active.abort.signal.addEventListener("abort", () => resolve(), { once: true }))]);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const response = await this.bridge.handleRequest(active.port, parsed.method, parsed.url, parsed.headers, body);
|
|
115
439
|
if (!outgoing.has(connectionId))
|
|
116
440
|
return;
|
|
117
441
|
const bytes = serializeResponse({
|
|
@@ -124,9 +448,10 @@ export class BrowserWaliLoopbackHost {
|
|
|
124
448
|
throw new Error("Browser loopback response exceeds 32 MiB.");
|
|
125
449
|
send({ type: "data", connectionId, bytes });
|
|
126
450
|
}
|
|
127
|
-
catch {
|
|
451
|
+
catch (error) {
|
|
128
452
|
// The native client observes EOF when a browser-local peer rejects or
|
|
129
|
-
// cannot parse its request.
|
|
453
|
+
// cannot parse its request; the page's console keeps the cause.
|
|
454
|
+
console.warn(`WALI loopback request to port ${active.port} failed:`, error);
|
|
130
455
|
}
|
|
131
456
|
finally {
|
|
132
457
|
finishOutgoing(connectionId);
|
|
@@ -136,7 +461,7 @@ export class BrowserWaliLoopbackHost {
|
|
|
136
461
|
const message = event.data;
|
|
137
462
|
if (message.type === "listen") {
|
|
138
463
|
let result = 0;
|
|
139
|
-
if (!validPort(message.port) || this.bridge.getServerPorts().includes(message.port))
|
|
464
|
+
if (!validPort(message.port) || this.bridge.getServerPorts().includes(message.port) || this.tcpServers.has(message.port))
|
|
140
465
|
result = -98;
|
|
141
466
|
else {
|
|
142
467
|
try {
|
|
@@ -144,6 +469,7 @@ export class BrowserWaliLoopbackHost {
|
|
|
144
469
|
listening: true,
|
|
145
470
|
address: () => ({ port: message.port, address: "0.0.0.0", family: "IPv4" }),
|
|
146
471
|
handleRequest: (method, url, headers, body) => request(message.port, method, url, headers, body),
|
|
472
|
+
fetch: (incoming) => streamingFetch(message.port, incoming),
|
|
147
473
|
};
|
|
148
474
|
this.bridge.registerServer(server, message.port);
|
|
149
475
|
if (this.webSocketRegistry) {
|
|
@@ -152,6 +478,9 @@ export class BrowserWaliLoopbackHost {
|
|
|
152
478
|
}));
|
|
153
479
|
}
|
|
154
480
|
registered.add(message.port);
|
|
481
|
+
this.rawOpeners.set(message.port, () => openRaw(message.port));
|
|
482
|
+
for (const observer of this.listenObservers)
|
|
483
|
+
this.notifyListen(message.port, observer);
|
|
155
484
|
}
|
|
156
485
|
catch {
|
|
157
486
|
webSocketRegistrations.get(message.port)?.();
|
|
@@ -164,10 +493,70 @@ export class BrowserWaliLoopbackHost {
|
|
|
164
493
|
return;
|
|
165
494
|
}
|
|
166
495
|
if (message.type === "connect") {
|
|
496
|
+
const server = this.tcpServers.get(message.port);
|
|
497
|
+
if (server && Number.isSafeInteger(message.connectionId) && message.connectionId < 0
|
|
498
|
+
&& activeConnections() < MAX_ACTIVE_CONNECTIONS && !tcp.has(message.connectionId)
|
|
499
|
+
&& !outgoing.has(message.connectionId)) {
|
|
500
|
+
const id = message.connectionId;
|
|
501
|
+
const dataListeners = new Set();
|
|
502
|
+
const closeListeners = new Set();
|
|
503
|
+
let received = 0;
|
|
504
|
+
let written = 0;
|
|
505
|
+
let ended = false;
|
|
506
|
+
const timer = window.setTimeout(() => socket.close(), RESPONSE_TIMEOUT_MS);
|
|
507
|
+
const socket = {
|
|
508
|
+
onData: (listener) => { dataListeners.add(listener); },
|
|
509
|
+
onClose: (listener) => { closeListeners.add(listener); },
|
|
510
|
+
write: (bytes) => {
|
|
511
|
+
if (ended)
|
|
512
|
+
return;
|
|
513
|
+
written += bytes.byteLength;
|
|
514
|
+
if (written > MAX_RESPONSE_BYTES) {
|
|
515
|
+
socket.close();
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
send({ type: "data", connectionId: id, bytes });
|
|
519
|
+
},
|
|
520
|
+
close: () => {
|
|
521
|
+
if (ended)
|
|
522
|
+
return;
|
|
523
|
+
ended = true;
|
|
524
|
+
window.clearTimeout(timer);
|
|
525
|
+
tcp.delete(id);
|
|
526
|
+
server.sockets.delete(socket);
|
|
527
|
+
send({ type: "peer-close", connectionId: id });
|
|
528
|
+
for (const listener of closeListeners)
|
|
529
|
+
listener();
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
tcp.set(id, { socket, receive: (bytes) => {
|
|
533
|
+
received += bytes.byteLength;
|
|
534
|
+
if (received > MAX_REQUEST_BYTES) {
|
|
535
|
+
socket.close();
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
try {
|
|
539
|
+
for (const listener of dataListeners)
|
|
540
|
+
listener(bytes);
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
socket.close();
|
|
544
|
+
}
|
|
545
|
+
} });
|
|
546
|
+
server.sockets.add(socket);
|
|
547
|
+
send({ type: "ack", requestId: message.requestId, result: 0 });
|
|
548
|
+
try {
|
|
549
|
+
server.accept(socket);
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
socket.close();
|
|
553
|
+
}
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
167
556
|
let result = 0;
|
|
168
557
|
if (!validPort(message.port) || !Number.isSafeInteger(message.connectionId)
|
|
169
558
|
|| !this.bridge.getServerPorts().includes(message.port)
|
|
170
|
-
|| activeConnections() >= MAX_ACTIVE_CONNECTIONS || outgoing.has(message.connectionId)) {
|
|
559
|
+
|| activeConnections() >= MAX_ACTIVE_CONNECTIONS || outgoing.has(message.connectionId) || tcp.has(message.connectionId)) {
|
|
171
560
|
result = -111;
|
|
172
561
|
}
|
|
173
562
|
else {
|
|
@@ -178,6 +567,7 @@ export class BrowserWaliLoopbackHost {
|
|
|
178
567
|
byteLength: 0,
|
|
179
568
|
timeout,
|
|
180
569
|
handling: false,
|
|
570
|
+
abort: new AbortController(),
|
|
181
571
|
});
|
|
182
572
|
}
|
|
183
573
|
send({ type: "ack", requestId: message.requestId, result });
|
|
@@ -187,6 +577,22 @@ export class BrowserWaliLoopbackHost {
|
|
|
187
577
|
unregister(message.port);
|
|
188
578
|
return;
|
|
189
579
|
}
|
|
580
|
+
const raw = raws.get(message.connectionId);
|
|
581
|
+
if (raw) {
|
|
582
|
+
if (message.type === "outgoing")
|
|
583
|
+
raw.receive(message.bytes);
|
|
584
|
+
else if (message.type === "closed")
|
|
585
|
+
raw.closed();
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
const stream = tcp.get(message.connectionId);
|
|
589
|
+
if (stream) {
|
|
590
|
+
if (message.type === "outgoing")
|
|
591
|
+
stream.receive(message.bytes);
|
|
592
|
+
else if (message.type === "closed")
|
|
593
|
+
stream.socket.close();
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
190
596
|
const response = pending.get(message.connectionId);
|
|
191
597
|
const outbound = outgoing.get(message.connectionId);
|
|
192
598
|
const webSocket = webSockets.get(message.connectionId)?.peer;
|
|
@@ -224,7 +630,11 @@ export class BrowserWaliLoopbackHost {
|
|
|
224
630
|
return;
|
|
225
631
|
}
|
|
226
632
|
if (outbound) {
|
|
227
|
-
|
|
633
|
+
// A client that closes while its response streams has gone.
|
|
634
|
+
if (outbound.handling)
|
|
635
|
+
finishOutgoing(message.connectionId);
|
|
636
|
+
else
|
|
637
|
+
void dispatchOutgoing(message.connectionId, true);
|
|
228
638
|
return;
|
|
229
639
|
}
|
|
230
640
|
if (!response)
|
|
@@ -247,6 +657,10 @@ export class BrowserWaliLoopbackHost {
|
|
|
247
657
|
if (closed)
|
|
248
658
|
return;
|
|
249
659
|
closed = true;
|
|
660
|
+
for (const { socket } of [...tcp.values()])
|
|
661
|
+
socket.close();
|
|
662
|
+
for (const raw of [...raws.values()])
|
|
663
|
+
raw.closed();
|
|
250
664
|
for (const virtualPort of [...registered])
|
|
251
665
|
unregister(virtualPort);
|
|
252
666
|
for (const active of pending.values()) {
|
|
@@ -254,8 +668,10 @@ export class BrowserWaliLoopbackHost {
|
|
|
254
668
|
active.reject(new Error("WALI loopback server stopped."));
|
|
255
669
|
}
|
|
256
670
|
pending.clear();
|
|
257
|
-
for (const active of outgoing.values())
|
|
671
|
+
for (const active of outgoing.values()) {
|
|
258
672
|
window.clearTimeout(active.timeout);
|
|
673
|
+
active.abort.abort();
|
|
674
|
+
}
|
|
259
675
|
outgoing.clear();
|
|
260
676
|
for (const active of webSockets.values())
|
|
261
677
|
active.peer.terminate("WALI loopback host stopped.");
|
|
@@ -330,6 +746,24 @@ function parseRequest(chunks, byteLength, closed) {
|
|
|
330
746
|
return;
|
|
331
747
|
return { method: request[1], url, headers, body: bytes.slice(bodyStart) };
|
|
332
748
|
}
|
|
749
|
+
/** The head of a response whose body follows as it is produced, ended by the close. */
|
|
750
|
+
function serializeHead(statusCode, statusMessage, raw) {
|
|
751
|
+
if (!Number.isInteger(statusCode) || statusCode < 100 || statusCode > 599) {
|
|
752
|
+
throw new Error("Invalid browser loopback response status.");
|
|
753
|
+
}
|
|
754
|
+
const headers = {};
|
|
755
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
756
|
+
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/u.test(name))
|
|
757
|
+
continue;
|
|
758
|
+
headers[name.toLowerCase()] = (Array.isArray(value) ? value.join(", ") : value).replace(/[\u0000\r\n]/gu, "");
|
|
759
|
+
}
|
|
760
|
+
delete headers["transfer-encoding"];
|
|
761
|
+
delete headers["content-length"];
|
|
762
|
+
headers.connection = "close";
|
|
763
|
+
const reason = (statusMessage || "OK").replace(/[\u0000\r\n]/gu, "");
|
|
764
|
+
return new TextEncoder().encode(`HTTP/1.1 ${statusCode} ${reason}\r\n${Object.entries(headers)
|
|
765
|
+
.map(([name, value]) => `${name}: ${value}`).join("\r\n")}\r\n\r\n`);
|
|
766
|
+
}
|
|
333
767
|
function serializeResponse(response) {
|
|
334
768
|
if (!Number.isInteger(response.statusCode) || response.statusCode < 100 || response.statusCode > 599) {
|
|
335
769
|
throw new Error("Invalid browser loopback response status.");
|