@volter/browser-wali 0.1.4 → 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.
Files changed (158) hide show
  1. package/README.md +73 -0
  2. package/dist/browser-wali-command.d.ts +3 -0
  3. package/dist/browser-wali-command.d.ts.map +1 -1
  4. package/dist/browser-wali-command.js +2 -1
  5. package/dist/browser-wali-command.js.map +1 -1
  6. package/dist/capabilities.d.ts +73 -1
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +86 -2
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/child-processes.d.ts +24 -0
  11. package/dist/child-processes.d.ts.map +1 -0
  12. package/dist/child-processes.js +134 -0
  13. package/dist/child-processes.js.map +1 -0
  14. package/dist/codec-protocol.d.ts +66 -0
  15. package/dist/codec-protocol.d.ts.map +1 -0
  16. package/dist/codec-protocol.js +59 -0
  17. package/dist/codec-protocol.js.map +1 -0
  18. package/dist/codec-remote.d.ts +33 -0
  19. package/dist/codec-remote.d.ts.map +1 -0
  20. package/dist/codec-remote.js +190 -0
  21. package/dist/codec-remote.js.map +1 -0
  22. package/dist/codec-worker.d.ts +2 -0
  23. package/dist/codec-worker.d.ts.map +1 -0
  24. package/dist/codec-worker.js +438 -0
  25. package/dist/codec-worker.js.map +1 -0
  26. package/dist/coherent-file-locks.d.ts.map +1 -1
  27. package/dist/coherent-file-locks.js +17 -5
  28. package/dist/coherent-file-locks.js.map +1 -1
  29. package/dist/descriptors.d.ts +49 -0
  30. package/dist/descriptors.d.ts.map +1 -0
  31. package/dist/descriptors.js +115 -0
  32. package/dist/descriptors.js.map +1 -0
  33. package/dist/events.d.ts.map +1 -1
  34. package/dist/events.js +2 -2
  35. package/dist/events.js.map +1 -1
  36. package/dist/filesystem-protocol.d.ts +28 -1
  37. package/dist/filesystem-protocol.d.ts.map +1 -1
  38. package/dist/filesystem-protocol.js +18 -0
  39. package/dist/filesystem-protocol.js.map +1 -1
  40. package/dist/filesystem-remote.d.ts +26 -2
  41. package/dist/filesystem-remote.d.ts.map +1 -1
  42. package/dist/filesystem-remote.js +132 -23
  43. package/dist/filesystem-remote.js.map +1 -1
  44. package/dist/filesystem-worker.js +274 -34
  45. package/dist/filesystem-worker.js.map +1 -1
  46. package/dist/filesystem.d.ts +30 -1
  47. package/dist/filesystem.d.ts.map +1 -1
  48. package/dist/filesystem.js +180 -50
  49. package/dist/filesystem.js.map +1 -1
  50. package/dist/fork-worker.d.ts +2 -0
  51. package/dist/fork-worker.d.ts.map +1 -0
  52. package/dist/fork-worker.js +52 -0
  53. package/dist/fork-worker.js.map +1 -0
  54. package/dist/host.d.ts +36 -0
  55. package/dist/host.d.ts.map +1 -0
  56. package/dist/host.js +153 -0
  57. package/dist/host.js.map +1 -0
  58. package/dist/loopback-host.d.ts +25 -1
  59. package/dist/loopback-host.d.ts.map +1 -1
  60. package/dist/loopback-host.js +447 -13
  61. package/dist/loopback-host.js.map +1 -1
  62. package/dist/memory.d.ts +65 -2
  63. package/dist/memory.d.ts.map +1 -1
  64. package/dist/memory.js +306 -49
  65. package/dist/memory.js.map +1 -1
  66. package/dist/module-layout.d.ts +2 -0
  67. package/dist/module-layout.d.ts.map +1 -1
  68. package/dist/module-layout.js +4 -2
  69. package/dist/module-layout.js.map +1 -1
  70. package/dist/monotonic-clock.d.ts +12 -0
  71. package/dist/monotonic-clock.d.ts.map +1 -0
  72. package/dist/monotonic-clock.js +14 -0
  73. package/dist/monotonic-clock.js.map +1 -0
  74. package/dist/network-protocol.d.ts +12 -1
  75. package/dist/network-protocol.d.ts.map +1 -1
  76. package/dist/network-protocol.js +6 -0
  77. package/dist/network-protocol.js.map +1 -1
  78. package/dist/network-worker.js +149 -20
  79. package/dist/network-worker.js.map +1 -1
  80. package/dist/network.d.ts +15 -3
  81. package/dist/network.d.ts.map +1 -1
  82. package/dist/network.js +74 -39
  83. package/dist/network.js.map +1 -1
  84. package/dist/process-adapter.d.ts +4 -1
  85. package/dist/process-adapter.d.ts.map +1 -1
  86. package/dist/process-adapter.js +3 -3
  87. package/dist/process-adapter.js.map +1 -1
  88. package/dist/process-bridge.d.ts +43 -0
  89. package/dist/process-bridge.d.ts.map +1 -1
  90. package/dist/process-bridge.js +30 -1
  91. package/dist/process-bridge.js.map +1 -1
  92. package/dist/program.d.ts +60 -1
  93. package/dist/program.d.ts.map +1 -1
  94. package/dist/program.js +92 -31
  95. package/dist/program.js.map +1 -1
  96. package/dist/runtime.d.ts.map +1 -1
  97. package/dist/runtime.js +491 -134
  98. package/dist/runtime.js.map +1 -1
  99. package/dist/select.d.ts +16 -0
  100. package/dist/select.d.ts.map +1 -0
  101. package/dist/select.js +100 -0
  102. package/dist/select.js.map +1 -0
  103. package/dist/shared-pipes.d.ts +17 -6
  104. package/dist/shared-pipes.d.ts.map +1 -1
  105. package/dist/shared-pipes.js +94 -70
  106. package/dist/shared-pipes.js.map +1 -1
  107. package/dist/signals.d.ts +48 -0
  108. package/dist/signals.d.ts.map +1 -0
  109. package/dist/signals.js +232 -0
  110. package/dist/signals.js.map +1 -0
  111. package/dist/snapshot-filesystem.d.ts +128 -2
  112. package/dist/snapshot-filesystem.d.ts.map +1 -1
  113. package/dist/snapshot-filesystem.js +481 -53
  114. package/dist/snapshot-filesystem.js.map +1 -1
  115. package/dist/spawn-policy.d.ts +5 -0
  116. package/dist/spawn-policy.d.ts.map +1 -0
  117. package/dist/spawn-policy.js +29 -0
  118. package/dist/spawn-policy.js.map +1 -0
  119. package/dist/stdin.d.ts +2 -0
  120. package/dist/stdin.d.ts.map +1 -1
  121. package/dist/stdin.js +4 -0
  122. package/dist/stdin.js.map +1 -1
  123. package/dist/symlink.d.ts +8 -0
  124. package/dist/symlink.d.ts.map +1 -1
  125. package/dist/symlink.js +13 -0
  126. package/dist/symlink.js.map +1 -1
  127. package/dist/system.d.ts +152 -1
  128. package/dist/system.d.ts.map +1 -1
  129. package/dist/system.js +226 -7
  130. package/dist/system.js.map +1 -1
  131. package/dist/thread-coordinator-worker.d.ts +1 -0
  132. package/dist/thread-coordinator-worker.d.ts.map +1 -1
  133. package/dist/thread-coordinator-worker.js +1 -0
  134. package/dist/thread-coordinator-worker.js.map +1 -1
  135. package/dist/thread-worker.js +241 -122
  136. package/dist/thread-worker.js.map +1 -1
  137. package/dist/threads.d.ts +12 -2
  138. package/dist/threads.d.ts.map +1 -1
  139. package/dist/threads.js +30 -10
  140. package/dist/threads.js.map +1 -1
  141. package/dist/types.d.ts +39 -0
  142. package/dist/types.d.ts.map +1 -1
  143. package/dist/websocket-peer.d.ts.map +1 -1
  144. package/dist/websocket-peer.js +4 -14
  145. package/dist/websocket-peer.js.map +1 -1
  146. package/dist/worker-program-protocol.d.ts +70 -3
  147. package/dist/worker-program-protocol.d.ts.map +1 -1
  148. package/dist/worker-program-worker.js +115 -23
  149. package/dist/worker-program-worker.js.map +1 -1
  150. package/dist/worker-program.d.ts +25 -1
  151. package/dist/worker-program.d.ts.map +1 -1
  152. package/dist/worker-program.js +470 -61
  153. package/dist/worker-program.js.map +1 -1
  154. package/dist/worker-standby.d.ts +3 -0
  155. package/dist/worker-standby.d.ts.map +1 -0
  156. package/dist/worker-standby.js +17 -0
  157. package/dist/worker-standby.js.map +1 -0
  158. package/package.json +21 -7
@@ -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
- const parsed = parseRequest(active.chunks, active.byteLength, peerClosed);
108
- if (!parsed) {
109
- if (peerClosed)
110
- finishOutgoing(connectionId);
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
- active.handling = true;
114
- const response = await this.bridge.handleRequest(active.port, parsed.method, parsed.url, parsed.headers, parsed.body?.buffer.slice(parsed.body.byteOffset, parsed.body.byteOffset + parsed.body.byteLength));
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
- void dispatchOutgoing(message.connectionId, true);
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.");