arm64js 0.6.0 → 0.7.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/README.md +73 -1
- package/dist/connection.d.ts +39 -0
- package/dist/connection.js +213 -0
- package/dist/http.d.ts +7 -0
- package/dist/http.js +314 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/vm.d.ts +33 -0
- package/dist/vm.js +156 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -268,6 +268,76 @@ const newSnapshot = vm.snapshot();
|
|
|
268
268
|
localStorage.set('snapshotId', newSnapshot.id);
|
|
269
269
|
```
|
|
270
270
|
|
|
271
|
+
Creating snapshot for a larger VM may take some time, you can track progress using the `onProgress` callback:
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
const snapshot = await vm.snapshot({
|
|
275
|
+
name: 'my snapshot',
|
|
276
|
+
onProgress: (done, total) => console.log(`${Math.round((done / total) * 100)}%`),
|
|
277
|
+
});
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### `vm.listen(port, onConnection)` → `{ port, close() }`
|
|
281
|
+
|
|
282
|
+
Creates a TCP server emulation on the webpage side "listening" to a specific port where VM can connect to. The application from inside the VM can open a TCP connection to `host.internal:<port>` and talk to it as it would to a normal TCP server.
|
|
283
|
+
|
|
284
|
+
Until the page "listens" on a port, connections to it are refused.
|
|
285
|
+
|
|
286
|
+
```js
|
|
287
|
+
const listener = await vm.listen(9000, async (conn) => {
|
|
288
|
+
for await (const chunk of conn) {
|
|
289
|
+
// chunk is a Uint8Array with the bytes the program sent
|
|
290
|
+
await conn.write(chunk); // send bytes (or a string) back
|
|
291
|
+
}
|
|
292
|
+
await conn.end(); // no more data from the page
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
await vm.exec('echo hello | nc host.internal 9000');
|
|
296
|
+
|
|
297
|
+
// to stop taking new connections (open ones keep going):
|
|
298
|
+
await listener.close();
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
`onConnection` is a callback that returns the TCP `Connection`.
|
|
302
|
+
|
|
303
|
+
A `Connection` has:
|
|
304
|
+
|
|
305
|
+
- `readable` and `writable`: standard web streams of `Uint8Array`. The connection itself is also async-iterable.
|
|
306
|
+
- `write(data)`: sends bytes or a string. It resolves once the data fits in the 1 MiB send window, so awaiting each write keeps memory bounded.
|
|
307
|
+
- `end()`: tells the VM "the page has nothing more to send" without cutting off what the VM sends back. In TCP terms it is a half close, the same as shutdown(SHUT_WR).
|
|
308
|
+
- `close()`: ends the connection both ways. What the page already wrote still reaches the VM, then it reads end-of-file. Anything the VM sends after that is dropped. `closed` is a promise that resolves when the connection is over.
|
|
309
|
+
|
|
310
|
+
Both directions have flow control: if the page reads slowly, the VM's writes slow down, and the other way around.
|
|
311
|
+
|
|
312
|
+
Listeners and connections end when the VM stops or is disposed. They are not part of snapshots: after booting one, call `listen` again.
|
|
313
|
+
|
|
314
|
+
### `vm.serveHttp(port, handler, { maxBodyBytes? })` → `{ port, close() }`
|
|
315
|
+
|
|
316
|
+
An HTTP server on top of `vm.listen`. Each request from the VM becomes a standard `Request`, and the `Response` your handler returns goes back. A response body is streamed as it is produced, so server-sent events work. Each connection carries one request.
|
|
317
|
+
|
|
318
|
+
For example, a page that tells the VM the time and echoes what it posts:
|
|
319
|
+
|
|
320
|
+
```js
|
|
321
|
+
await vm.serveHttp(8080, async (req) => {
|
|
322
|
+
if (req.method === 'POST') {
|
|
323
|
+
return new Response(`you sent: ${await req.text()}\n`);
|
|
324
|
+
}
|
|
325
|
+
return new Response(`${new Date().toISOString()}\n`);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
await vm.exec('wget -qO- http://host.internal:8080/');
|
|
329
|
+
// → { output: '2026-10-16T19:03:11.123Z', exitCode: 0, … }
|
|
330
|
+
|
|
331
|
+
await vm.exec("wget -qO- --post-data='hello' http://host.internal:8080/");
|
|
332
|
+
// → { output: 'you sent: hello', exitCode: 0, … }
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Some details:
|
|
336
|
+
|
|
337
|
+
- The request body is read in full before the handler runs. Bodies over `maxBodyBytes` (default 64 MiB) get a 413 without being read.
|
|
338
|
+
- `request.url` is always `http://host.internal:<port>/<path>`. What the VM puts in the `Host` header is in `request.headers`. It does not change the URL, so passing the request on with `fetch(request)` cannot be pointed somewhere else by the VM.
|
|
339
|
+
- A response without `Content-Length` is sent chunked, so a body that fails halfway reaches the VM as an incomplete response rather than a short one. If the VM disconnects, the response body is cancelled, so whatever produces it can stop.
|
|
340
|
+
|
|
271
341
|
### `vm.dispose()`
|
|
272
342
|
|
|
273
343
|
Stops the VM and frees its workers.
|
|
@@ -298,7 +368,7 @@ Disposes every VM and, in frame mode, the frames.
|
|
|
298
368
|
|
|
299
369
|
### Errors
|
|
300
370
|
|
|
301
|
-
Everything rejects with an `Arm64JSError` carrying a `code`: `unsupported-browser`, `protocol-mismatch`, `engine-mismatch`, `image-not-found`, `boot-failed`, `exec-timeout`, `exec-failed`, `vm-exited`, `vm-lost`, `storage-unavailable`, `quota`, `snapshot-failed`, `snapshot-not-found`, `snapshot-in-use`, `snapshot-corrupt`, `invalid-input`, `share-unavailable`, `mount-failed`, `unmount-failed`, `write-failed`, `read-failed`.
|
|
371
|
+
Everything rejects with an `Arm64JSError` carrying a `code`: `unsupported-browser`, `protocol-mismatch`, `engine-mismatch`, `image-not-found`, `boot-failed`, `exec-timeout`, `exec-failed`, `vm-exited`, `vm-lost`, `storage-unavailable`, `quota`, `snapshot-failed`, `snapshot-not-found`, `snapshot-in-use`, `snapshot-corrupt`, `invalid-input`, `share-unavailable`, `mount-failed`, `unmount-failed`, `write-failed`, `read-failed`, `listen-failed`.
|
|
302
372
|
|
|
303
373
|
## Terminal (optional)
|
|
304
374
|
|
|
@@ -322,6 +392,8 @@ It shows the VM console, sends what is typed to the VM, and sizes itself to the
|
|
|
322
392
|
|
|
323
393
|
The VM has an emulated network card, DNS, and can install Alpine packages with `apk` through the CDN's package mirror. It has no general internet access.
|
|
324
394
|
|
|
395
|
+
The name `host.internal` is the page itself: see `vm.listen` and `vm.serveHttp`.
|
|
396
|
+
|
|
325
397
|
## Development
|
|
326
398
|
|
|
327
399
|
```sh
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** What `write` takes. Strings are sent as UTF-8. */
|
|
2
|
+
export type ConnectionData = ArrayBuffer | ArrayBufferView | string;
|
|
3
|
+
/** One TCP connection the guest opened to `host.internal:<port>`. */
|
|
4
|
+
export declare class Connection implements AsyncIterable<Uint8Array> {
|
|
5
|
+
private readonly port;
|
|
6
|
+
/** The port the guest dialed. */
|
|
7
|
+
readonly localPort: number;
|
|
8
|
+
/** The guest's bytes, in order. Ends when the guest closes its side. */
|
|
9
|
+
readonly readable: ReadableStream<Uint8Array>;
|
|
10
|
+
/** Writes go to the guest. Closing it is `end()`, aborting it is `close()`. */
|
|
11
|
+
readonly writable: WritableStream<Uint8Array>;
|
|
12
|
+
/** Resolves when the connection has ended both ways. */
|
|
13
|
+
readonly closed: Promise<void>;
|
|
14
|
+
private readonly inbox;
|
|
15
|
+
private remoteEnded;
|
|
16
|
+
private isClosed;
|
|
17
|
+
private localEnded;
|
|
18
|
+
private readCancelled;
|
|
19
|
+
private unacked;
|
|
20
|
+
private wake;
|
|
21
|
+
private resolveClosed;
|
|
22
|
+
/** Writes in call order. */
|
|
23
|
+
private writes;
|
|
24
|
+
/** Send `data` to the guest. Resolves once it fits the window. */
|
|
25
|
+
write(data: ConnectionData): Promise<void>;
|
|
26
|
+
/** Half close after pending writes: the guest reads end-of-file but can still send. */
|
|
27
|
+
end(): Promise<void>;
|
|
28
|
+
/** End both ways now. Bytes already sent still reach the guest; writes waiting
|
|
29
|
+
* for the window and later guest bytes are dropped. Idempotent. */
|
|
30
|
+
close(): void;
|
|
31
|
+
/** Leaving a `for await` loop early keeps the connection open. */
|
|
32
|
+
[Symbol.asyncIterator](): AsyncGenerator<Uint8Array, void, undefined>;
|
|
33
|
+
private onMessage;
|
|
34
|
+
private ackAll;
|
|
35
|
+
private finish;
|
|
36
|
+
private post;
|
|
37
|
+
private waitForChange;
|
|
38
|
+
private notify;
|
|
39
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// A guest connection to `host.internal` as a byte stream. Writes wait while
|
|
2
|
+
// `HOST_CONN_WINDOW` bytes are unacknowledged; guest bytes are acknowledged only
|
|
3
|
+
// when read, so a slow page slows the guest.
|
|
4
|
+
import { Arm64JSError, HOST_CONN_CHUNK, HOST_CONN_WINDOW } from '@arm64js/protocol';
|
|
5
|
+
/** One TCP connection the guest opened to `host.internal:<port>`. */
|
|
6
|
+
export class Connection {
|
|
7
|
+
port;
|
|
8
|
+
localPort;
|
|
9
|
+
/** The guest's bytes, in order. Ends when the guest closes its side. */
|
|
10
|
+
readable;
|
|
11
|
+
/** Writes go to the guest. Closing it is `end()`, aborting it is `close()`. */
|
|
12
|
+
writable;
|
|
13
|
+
/** Resolves when the connection has ended both ways. */
|
|
14
|
+
closed;
|
|
15
|
+
inbox = [];
|
|
16
|
+
remoteEnded = false;
|
|
17
|
+
isClosed = false;
|
|
18
|
+
localEnded = false;
|
|
19
|
+
readCancelled = false;
|
|
20
|
+
unacked = 0;
|
|
21
|
+
wake = null;
|
|
22
|
+
resolveClosed;
|
|
23
|
+
/** Writes in call order. */
|
|
24
|
+
writes = Promise.resolve();
|
|
25
|
+
/** @internal */
|
|
26
|
+
constructor(port,
|
|
27
|
+
/** The port the guest dialed. */
|
|
28
|
+
localPort) {
|
|
29
|
+
this.port = port;
|
|
30
|
+
this.localPort = localPort;
|
|
31
|
+
this.closed = new Promise((resolve) => {
|
|
32
|
+
this.resolveClosed = resolve;
|
|
33
|
+
});
|
|
34
|
+
port.onmessage = (ev) => {
|
|
35
|
+
this.onMessage(ev.data);
|
|
36
|
+
};
|
|
37
|
+
this.readable = new ReadableStream({
|
|
38
|
+
pull: async (controller) => {
|
|
39
|
+
for (;;) {
|
|
40
|
+
const chunk = this.inbox.shift();
|
|
41
|
+
if (chunk) {
|
|
42
|
+
this.post({ type: 'ack', bytes: chunk.length });
|
|
43
|
+
controller.enqueue(chunk);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (this.remoteEnded || this.isClosed) {
|
|
47
|
+
controller.close();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
await this.waitForChange();
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
// Writing goes on; guest bytes are dropped.
|
|
54
|
+
cancel: () => {
|
|
55
|
+
this.readCancelled = true;
|
|
56
|
+
this.ackAll();
|
|
57
|
+
},
|
|
58
|
+
}, { highWaterMark: 0 });
|
|
59
|
+
this.writable = new WritableStream({
|
|
60
|
+
abort: () => {
|
|
61
|
+
this.close();
|
|
62
|
+
},
|
|
63
|
+
close: async () => this.end(),
|
|
64
|
+
write: async (chunk) => this.write(chunk),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/** Send `data` to the guest. Resolves once it fits the window. */
|
|
68
|
+
async write(data) {
|
|
69
|
+
const bytes = toBytes(data);
|
|
70
|
+
const run = async () => {
|
|
71
|
+
for (let at = 0; at < bytes.length; at += HOST_CONN_CHUNK) {
|
|
72
|
+
const piece = bytes.slice(at, at + HOST_CONN_CHUNK);
|
|
73
|
+
while (!this.isClosed && this.unacked > 0 && this.unacked + piece.length > HOST_CONN_WINDOW) {
|
|
74
|
+
await this.waitForChange();
|
|
75
|
+
}
|
|
76
|
+
if (this.isClosed) {
|
|
77
|
+
throw new Arm64JSError('invalid-input', 'the connection is closed');
|
|
78
|
+
}
|
|
79
|
+
if (this.localEnded) {
|
|
80
|
+
throw new Arm64JSError('invalid-input', 'write after end()');
|
|
81
|
+
}
|
|
82
|
+
this.unacked += piece.length;
|
|
83
|
+
this.post({ type: 'data', data: piece }, [piece.buffer]);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const next = this.writes.then(run);
|
|
87
|
+
this.writes = next.catch(() => { });
|
|
88
|
+
return next;
|
|
89
|
+
}
|
|
90
|
+
/** Half close after pending writes: the guest reads end-of-file but can still send. */
|
|
91
|
+
async end() {
|
|
92
|
+
const run = () => {
|
|
93
|
+
if (this.localEnded || this.isClosed) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
this.localEnded = true;
|
|
97
|
+
this.post({ type: 'end' });
|
|
98
|
+
};
|
|
99
|
+
const next = this.writes.then(run);
|
|
100
|
+
this.writes = next.catch(() => { });
|
|
101
|
+
return next;
|
|
102
|
+
}
|
|
103
|
+
/** End both ways now. Bytes already sent still reach the guest; writes waiting
|
|
104
|
+
* for the window and later guest bytes are dropped. Idempotent. */
|
|
105
|
+
close() {
|
|
106
|
+
if (this.isClosed) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
this.post({ type: 'close' });
|
|
110
|
+
this.finish();
|
|
111
|
+
}
|
|
112
|
+
/** @internal The VM went away; nothing more will arrive or leave. */
|
|
113
|
+
lost() {
|
|
114
|
+
if (!this.isClosed) {
|
|
115
|
+
this.finish();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Leaving a `for await` loop early keeps the connection open. */
|
|
119
|
+
async *[Symbol.asyncIterator]() {
|
|
120
|
+
const reader = this.readable.getReader();
|
|
121
|
+
try {
|
|
122
|
+
for (;;) {
|
|
123
|
+
const r = await reader.read();
|
|
124
|
+
if (r.done) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
yield r.value;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
reader.releaseLock();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
onMessage(msg) {
|
|
135
|
+
if (this.isClosed) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
switch (msg?.type) {
|
|
139
|
+
case 'data':
|
|
140
|
+
if (msg.data instanceof Uint8Array && msg.data.length > 0 && !this.remoteEnded) {
|
|
141
|
+
this.inbox.push(msg.data);
|
|
142
|
+
if (this.readCancelled) {
|
|
143
|
+
this.ackAll();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
break;
|
|
147
|
+
case 'ack':
|
|
148
|
+
this.unacked = Math.max(0, this.unacked - (Number(msg.bytes) || 0));
|
|
149
|
+
break;
|
|
150
|
+
case 'end':
|
|
151
|
+
this.remoteEnded = true;
|
|
152
|
+
break;
|
|
153
|
+
case 'close':
|
|
154
|
+
this.finish();
|
|
155
|
+
return;
|
|
156
|
+
default:
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
this.notify();
|
|
160
|
+
}
|
|
161
|
+
ackAll() {
|
|
162
|
+
const bytes = this.inbox.reduce((n, c) => n + c.length, 0);
|
|
163
|
+
this.inbox.length = 0;
|
|
164
|
+
if (bytes > 0) {
|
|
165
|
+
this.post({ type: 'ack', bytes });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
finish() {
|
|
169
|
+
this.isClosed = true;
|
|
170
|
+
this.port.onmessage = null;
|
|
171
|
+
this.port.close();
|
|
172
|
+
this.notify();
|
|
173
|
+
this.resolveClosed();
|
|
174
|
+
}
|
|
175
|
+
post(msg, transfer = []) {
|
|
176
|
+
if (this.isClosed) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
this.port.postMessage(msg, transfer);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
this.finish();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async waitForChange() {
|
|
187
|
+
return new Promise((resolve) => {
|
|
188
|
+
const prev = this.wake;
|
|
189
|
+
this.wake = () => {
|
|
190
|
+
prev?.();
|
|
191
|
+
resolve();
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
notify() {
|
|
196
|
+
const wake = this.wake;
|
|
197
|
+
this.wake = null;
|
|
198
|
+
wake?.();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** A copy of `data`; it is transferred away. */
|
|
202
|
+
function toBytes(data) {
|
|
203
|
+
if (typeof data === 'string') {
|
|
204
|
+
return new TextEncoder().encode(data);
|
|
205
|
+
}
|
|
206
|
+
if (data instanceof ArrayBuffer) {
|
|
207
|
+
return new Uint8Array(data.slice(0));
|
|
208
|
+
}
|
|
209
|
+
if (ArrayBuffer.isView(data)) {
|
|
210
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice();
|
|
211
|
+
}
|
|
212
|
+
throw new Arm64JSError('invalid-input', 'write takes bytes or a string');
|
|
213
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Connection } from './connection.js';
|
|
2
|
+
/** Answers one guest request. */
|
|
3
|
+
export type HttpHandler = (request: Request) => Promise<Response> | Response;
|
|
4
|
+
/** Serve one request on `conn`, then close it. */
|
|
5
|
+
export declare function serveConnection(conn: Connection, handler: HttpHandler, opts: {
|
|
6
|
+
maxBodyBytes: number;
|
|
7
|
+
}): Promise<void>;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// HTTP/1.1 for `vm.serveHttp`: one request per connection, streamed responses.
|
|
2
|
+
// Bodies without `Content-Length` go chunked, so a cut-off body is detectable.
|
|
3
|
+
import { HOST_INTERNAL_NAME } from '@arm64js/protocol';
|
|
4
|
+
const HEAD_MAX = 64 * 1024;
|
|
5
|
+
const CRLF = new Uint8Array([13, 10]);
|
|
6
|
+
const HEAD_END = new Uint8Array([13, 10, 13, 10]);
|
|
7
|
+
/** Headers about the connection itself, which this server sets. */
|
|
8
|
+
const HOP_HEADERS = new Set(['connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'proxy-connection']);
|
|
9
|
+
const REASONS = {
|
|
10
|
+
200: 'OK',
|
|
11
|
+
201: 'Created',
|
|
12
|
+
204: 'No Content',
|
|
13
|
+
206: 'Partial Content',
|
|
14
|
+
301: 'Moved Permanently',
|
|
15
|
+
302: 'Found',
|
|
16
|
+
304: 'Not Modified',
|
|
17
|
+
400: 'Bad Request',
|
|
18
|
+
401: 'Unauthorized',
|
|
19
|
+
403: 'Forbidden',
|
|
20
|
+
404: 'Not Found',
|
|
21
|
+
405: 'Method Not Allowed',
|
|
22
|
+
413: 'Content Too Large',
|
|
23
|
+
429: 'Too Many Requests',
|
|
24
|
+
431: 'Request Header Fields Too Large',
|
|
25
|
+
500: 'Internal Server Error',
|
|
26
|
+
501: 'Not Implemented',
|
|
27
|
+
502: 'Bad Gateway',
|
|
28
|
+
503: 'Service Unavailable',
|
|
29
|
+
};
|
|
30
|
+
class HttpError extends Error {
|
|
31
|
+
status;
|
|
32
|
+
constructor(status, message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.status = status;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Serve one request on `conn`, then close it. */
|
|
38
|
+
export async function serveConnection(conn, handler, opts) {
|
|
39
|
+
const reader = new ByteReader(conn.readable.getReader());
|
|
40
|
+
let response;
|
|
41
|
+
let method = 'GET';
|
|
42
|
+
let http10 = false;
|
|
43
|
+
try {
|
|
44
|
+
const head = await reader.readUntil(HEAD_END, HEAD_MAX);
|
|
45
|
+
if (head === null) {
|
|
46
|
+
conn.close();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const req = parseHead(head);
|
|
50
|
+
method = req.method;
|
|
51
|
+
http10 = req.version === 'HTTP/1.0';
|
|
52
|
+
const body = await readBody(reader, req.headers, opts.maxBodyBytes, async () => {
|
|
53
|
+
if (req.headers.get('expect')?.toLowerCase() === '100-continue') {
|
|
54
|
+
await conn.write('HTTP/1.1 100 Continue\r\n\r\n');
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
// The guest must not choose the URL's origin (`Host`, absolute-form target).
|
|
58
|
+
const request = new Request(`http://${HOST_INTERNAL_NAME}:${conn.localPort}${pathOf(req.target)}`, {
|
|
59
|
+
body: method === 'GET' || method === 'HEAD' ? undefined : body,
|
|
60
|
+
headers: req.headers,
|
|
61
|
+
method,
|
|
62
|
+
});
|
|
63
|
+
try {
|
|
64
|
+
response = await handler(request);
|
|
65
|
+
if (!(response instanceof Response)) {
|
|
66
|
+
throw new Error('the handler must return a Response');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
console.error('[arm64js] serveHttp handler failed', e);
|
|
71
|
+
response = new Response(`handler failed: ${String(e?.message ?? e)}\n`, { status: 500 });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
const status = e instanceof HttpError ? e.status : 400;
|
|
76
|
+
response = new Response(`${String(e?.message ?? e)}\n`, { status });
|
|
77
|
+
}
|
|
78
|
+
// Drop unread guest bytes, or they fill the window and block the guest.
|
|
79
|
+
await reader.cancel();
|
|
80
|
+
try {
|
|
81
|
+
await writeResponse(conn, response, method === 'HEAD', http10);
|
|
82
|
+
await conn.end();
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
conn.close();
|
|
86
|
+
// The guest is gone: stop the body's producer.
|
|
87
|
+
await response.body?.cancel().catch(() => { });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** The path and query of a request target, whatever its form. */
|
|
91
|
+
function pathOf(target) {
|
|
92
|
+
if (target.startsWith('/')) {
|
|
93
|
+
return target;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const u = new URL(target);
|
|
97
|
+
return `${u.pathname}${u.search}`;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw new HttpError(400, 'bad request target');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function parseHead(bytes) {
|
|
104
|
+
const text = new TextDecoder('latin1').decode(bytes);
|
|
105
|
+
const lines = text.split('\r\n');
|
|
106
|
+
const [method, target, version] = (lines.shift() ?? '').split(' ');
|
|
107
|
+
if (!method || !target || !version?.startsWith('HTTP/1.')) {
|
|
108
|
+
throw new HttpError(400, 'bad request line');
|
|
109
|
+
}
|
|
110
|
+
const headers = new Headers();
|
|
111
|
+
for (const line of lines) {
|
|
112
|
+
if (line === '') {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const colon = line.indexOf(':');
|
|
116
|
+
if (colon <= 0) {
|
|
117
|
+
throw new HttpError(400, 'bad header line');
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
headers.append(line.slice(0, colon).trim(), line.slice(colon + 1).trim());
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
throw new HttpError(400, 'bad header line');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { headers, method: method.toUpperCase(), target, version };
|
|
127
|
+
}
|
|
128
|
+
async function readBody(reader, headers, max, beforeBody) {
|
|
129
|
+
const tooLarge = () => new HttpError(413, `the request body is over ${max} bytes`);
|
|
130
|
+
if (headers.get('transfer-encoding')?.toLowerCase().includes('chunked')) {
|
|
131
|
+
await beforeBody();
|
|
132
|
+
const parts = [];
|
|
133
|
+
let total = 0;
|
|
134
|
+
for (;;) {
|
|
135
|
+
const line = await reader.readUntil(CRLF, 1024);
|
|
136
|
+
if (line === null) {
|
|
137
|
+
throw new HttpError(400, 'the request body ended early');
|
|
138
|
+
}
|
|
139
|
+
const size = parseInt(new TextDecoder().decode(line).split(';')[0].trim(), 16);
|
|
140
|
+
if (!Number.isFinite(size) || size < 0) {
|
|
141
|
+
throw new HttpError(400, 'bad chunk size');
|
|
142
|
+
}
|
|
143
|
+
if (size === 0) {
|
|
144
|
+
// Trailers, up to the empty line.
|
|
145
|
+
while ((await reader.readUntil(CRLF, HEAD_MAX))?.length) {
|
|
146
|
+
// skip
|
|
147
|
+
}
|
|
148
|
+
return concat(parts);
|
|
149
|
+
}
|
|
150
|
+
total += size;
|
|
151
|
+
if (total > max) {
|
|
152
|
+
throw tooLarge();
|
|
153
|
+
}
|
|
154
|
+
parts.push(await reader.readExact(size));
|
|
155
|
+
await reader.readExact(2);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const length = headers.get('content-length');
|
|
159
|
+
if (length === null) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
if (!/^\d+$/.test(length.trim())) {
|
|
163
|
+
throw new HttpError(400, 'bad Content-Length');
|
|
164
|
+
}
|
|
165
|
+
const n = Number(length.trim());
|
|
166
|
+
// Refuse before `100 Continue` invites the body.
|
|
167
|
+
if (!Number.isSafeInteger(n) || n > max) {
|
|
168
|
+
throw tooLarge();
|
|
169
|
+
}
|
|
170
|
+
await beforeBody();
|
|
171
|
+
return reader.readExact(n);
|
|
172
|
+
}
|
|
173
|
+
async function writeResponse(conn, response, head, http10) {
|
|
174
|
+
const status = response.status;
|
|
175
|
+
const lines = [`HTTP/1.1 ${status} ${response.statusText || REASONS[status] || ''}`];
|
|
176
|
+
response.headers.forEach((value, name) => {
|
|
177
|
+
if (!HOP_HEADERS.has(name) && name !== 'set-cookie') {
|
|
178
|
+
lines.push(`${name}: ${value}`);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
for (const cookie of response.headers.getSetCookie?.() ?? []) {
|
|
182
|
+
lines.push(`set-cookie: ${cookie}`);
|
|
183
|
+
}
|
|
184
|
+
const noBody = head || status === 204 || status === 304 || (status >= 100 && status < 200);
|
|
185
|
+
const sized = response.headers.has('content-length');
|
|
186
|
+
// HTTP/1.0 has no chunked encoding: the connection's end marks the body's.
|
|
187
|
+
const chunked = !noBody && !!response.body && !sized && !http10;
|
|
188
|
+
if (!response.body && !noBody && !sized) {
|
|
189
|
+
lines.push('content-length: 0');
|
|
190
|
+
}
|
|
191
|
+
if (chunked) {
|
|
192
|
+
lines.push('transfer-encoding: chunked');
|
|
193
|
+
}
|
|
194
|
+
lines.push('connection: close', '', '');
|
|
195
|
+
await conn.write(lines.join('\r\n'));
|
|
196
|
+
if (noBody || !response.body) {
|
|
197
|
+
await response.body?.cancel();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
for await (const chunk of iterate(response.body)) {
|
|
201
|
+
if (chunk.length === 0) {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
await conn.write(chunked ? concat([ascii(`${chunk.length.toString(16)}\r\n`), chunk, CRLF]) : chunk);
|
|
205
|
+
}
|
|
206
|
+
if (chunked) {
|
|
207
|
+
await conn.write('0\r\n\r\n');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function ascii(text) {
|
|
211
|
+
return new TextEncoder().encode(text);
|
|
212
|
+
}
|
|
213
|
+
async function* iterate(body) {
|
|
214
|
+
const reader = body.getReader();
|
|
215
|
+
try {
|
|
216
|
+
for (;;) {
|
|
217
|
+
const { done, value } = await reader.read();
|
|
218
|
+
if (done) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
yield value;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
reader.releaseLock();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function concat(parts) {
|
|
229
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
230
|
+
let at = 0;
|
|
231
|
+
for (const p of parts) {
|
|
232
|
+
out.set(p, at);
|
|
233
|
+
at += p.length;
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
/** Buffered reads over a byte stream. */
|
|
238
|
+
class ByteReader {
|
|
239
|
+
reader;
|
|
240
|
+
buf = new Uint8Array(0);
|
|
241
|
+
done = false;
|
|
242
|
+
constructor(reader) {
|
|
243
|
+
this.reader = reader;
|
|
244
|
+
}
|
|
245
|
+
/** Bytes up to `delim` (dropped), or `null` at end of stream before any byte. */
|
|
246
|
+
async readUntil(delim, max) {
|
|
247
|
+
let from = 0;
|
|
248
|
+
for (;;) {
|
|
249
|
+
const at = indexOf(this.buf, delim, from);
|
|
250
|
+
if (at >= 0) {
|
|
251
|
+
const out = this.buf.slice(0, at);
|
|
252
|
+
this.buf = this.buf.subarray(at + delim.length);
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
if (this.buf.length > max) {
|
|
256
|
+
throw new HttpError(431, 'the request head is too large');
|
|
257
|
+
}
|
|
258
|
+
from = Math.max(0, this.buf.length - delim.length + 1);
|
|
259
|
+
const next = await this.next();
|
|
260
|
+
if (!next) {
|
|
261
|
+
if (this.buf.length === 0) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
throw new HttpError(400, 'the request ended early');
|
|
265
|
+
}
|
|
266
|
+
this.buf = this.buf.length === 0 ? next : concat([this.buf, next]);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/** Exactly `n` bytes, copied once. */
|
|
270
|
+
async readExact(n) {
|
|
271
|
+
const parts = [];
|
|
272
|
+
let have = 0;
|
|
273
|
+
while (have < n) {
|
|
274
|
+
if (this.buf.length === 0) {
|
|
275
|
+
const next = await this.next();
|
|
276
|
+
if (!next) {
|
|
277
|
+
throw new HttpError(400, 'the request body ended early');
|
|
278
|
+
}
|
|
279
|
+
this.buf = next;
|
|
280
|
+
}
|
|
281
|
+
const take = Math.min(n - have, this.buf.length);
|
|
282
|
+
parts.push(this.buf.subarray(0, take));
|
|
283
|
+
this.buf = this.buf.subarray(take);
|
|
284
|
+
have += take;
|
|
285
|
+
}
|
|
286
|
+
return concat(parts);
|
|
287
|
+
}
|
|
288
|
+
/** Stop reading: the rest of the stream is dropped. */
|
|
289
|
+
async cancel() {
|
|
290
|
+
await this.reader.cancel().catch(() => { });
|
|
291
|
+
}
|
|
292
|
+
async next() {
|
|
293
|
+
if (this.done) {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
const { done, value } = await this.reader.read();
|
|
297
|
+
if (done) {
|
|
298
|
+
this.done = true;
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
return value;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function indexOf(hay, needle, from) {
|
|
305
|
+
outer: for (let i = from; i <= hay.length - needle.length; i++) {
|
|
306
|
+
for (let j = 0; j < needle.length; j++) {
|
|
307
|
+
if (hay[i + j] !== needle[j]) {
|
|
308
|
+
continue outer;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return i;
|
|
312
|
+
}
|
|
313
|
+
return -1;
|
|
314
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,8 +3,10 @@ export type { Arm64JSApi } from './api.js';
|
|
|
3
3
|
export type { HostMode } from './hosts.js';
|
|
4
4
|
export type { MountSource } from './mount.js';
|
|
5
5
|
export { VERSION as version } from './version.js';
|
|
6
|
-
export {
|
|
6
|
+
export { Connection, type ConnectionData } from './connection.js';
|
|
7
|
+
export type { HttpHandler } from './http.js';
|
|
8
|
+
export { DEFAULT_MAX_BODY_BYTES, Vm, type FileData, type Listener, type Mount, type ServeHttpOptions } from './vm.js';
|
|
7
9
|
export type { Arm64JSErrorCode, BootOptions, BootProgress, ExecOptions, ExecResult, OutputInfo, ReadFileOptions, SnapshotInfo, SnapshotOptions, StorageStatus, VmExit, WriteFileOptions, } from '@arm64js/protocol';
|
|
8
|
-
export { Arm64JSError } from '@arm64js/protocol';
|
|
10
|
+
export { Arm64JSError, HOST_INTERNAL_NAME } from '@arm64js/protocol';
|
|
9
11
|
export declare const Arm64JS: Arm64JSApi;
|
|
10
12
|
export default Arm64JS;
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createArm64JS } from './api.js';
|
|
2
2
|
export { VERSION as version } from './version.js';
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
3
|
+
export { Connection } from './connection.js';
|
|
4
|
+
export { DEFAULT_MAX_BODY_BYTES, Vm } from './vm.js';
|
|
5
|
+
export { Arm64JSError, HOST_INTERNAL_NAME } from '@arm64js/protocol';
|
|
5
6
|
export const Arm64JS = createArm64JS();
|
|
6
7
|
export default Arm64JS;
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.7.0";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by scripts/stamp-version.mjs from package.json. Do not edit.
|
|
2
|
-
export const VERSION = "0.
|
|
2
|
+
export const VERSION = "0.7.0";
|
package/dist/vm.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { type ExecOptions, type ExecResult, type OutputInfo, type ReadFileOptions, type SnapshotInfo, type SnapshotOptions, type VmExit, type WriteFileOptions } from '@arm64js/protocol';
|
|
2
|
+
import { Connection } from './connection.js';
|
|
3
|
+
import { type HttpHandler } from './http.js';
|
|
2
4
|
import { type MountSource } from './mount.js';
|
|
3
5
|
/** What `writeFile` takes. */
|
|
4
6
|
export type FileData = Blob | BufferSource | string;
|
|
@@ -9,6 +11,19 @@ export interface Mount {
|
|
|
9
11
|
/** Remove the mount. Idempotent. */
|
|
10
12
|
unmount(): Promise<void>;
|
|
11
13
|
}
|
|
14
|
+
/** `serveHttp`'s default request body limit. */
|
|
15
|
+
export declare const DEFAULT_MAX_BODY_BYTES: number;
|
|
16
|
+
export interface ServeHttpOptions {
|
|
17
|
+
/** Larger request bodies are refused with 413 (default 64 MiB). */
|
|
18
|
+
maxBodyBytes?: number;
|
|
19
|
+
}
|
|
20
|
+
/** A live `listen`. */
|
|
21
|
+
export interface Listener {
|
|
22
|
+
/** The port the guest dials on `host.internal`. */
|
|
23
|
+
readonly port: number;
|
|
24
|
+
/** Take no new connections. Open ones keep going. Idempotent. */
|
|
25
|
+
close(): Promise<void>;
|
|
26
|
+
}
|
|
12
27
|
/** A running VM. */
|
|
13
28
|
export declare class Vm {
|
|
14
29
|
readonly id: string;
|
|
@@ -21,6 +36,12 @@ export declare class Vm {
|
|
|
21
36
|
private readonly exitListeners;
|
|
22
37
|
/** The latest `mount` at each path, so an old handle cannot undo a newer one. */
|
|
23
38
|
private readonly mounts;
|
|
39
|
+
/** Listener ports by guest port. */
|
|
40
|
+
private readonly listeners;
|
|
41
|
+
/** Open guest connections, ended when the VM goes away. */
|
|
42
|
+
private readonly connections;
|
|
43
|
+
private exitWatched;
|
|
44
|
+
private exited;
|
|
24
45
|
/** Run a shell command (one or more lines) in the guest and get its output and
|
|
25
46
|
* exit code. One at a time per VM. */
|
|
26
47
|
exec(command: string, opts?: ExecOptions): Promise<ExecResult>;
|
|
@@ -49,9 +70,21 @@ export declare class Vm {
|
|
|
49
70
|
/** Copy the guest's file `path` out, as a `Blob`. The copy is held in page
|
|
50
71
|
* memory. */
|
|
51
72
|
readFile(path: string, opts?: ReadFileOptions): Promise<Blob>;
|
|
73
|
+
/** Take guest connections to `host.internal:<port>`; `onConnection` gets each as
|
|
74
|
+
* a byte stream. Ports nobody listens on refuse connections. Not kept in
|
|
75
|
+
* snapshots. */
|
|
76
|
+
listen(port: number, onConnection: (conn: Connection) => unknown): Promise<Listener>;
|
|
77
|
+
/** Serve HTTP on `host.internal:<port>`: each guest request becomes a `Request`;
|
|
78
|
+
* the handler's `Response` streams back. */
|
|
79
|
+
serveHttp(port: number, handler: HttpHandler, opts?: ServeHttpOptions): Promise<Listener>;
|
|
52
80
|
/** Save the VM to this browser's storage. The VM keeps running. */
|
|
53
81
|
snapshot(opts?: SnapshotOptions): Promise<SnapshotInfo>;
|
|
54
82
|
/** Stop the VM and free its workers. Idempotent. */
|
|
55
83
|
dispose(): Promise<void>;
|
|
84
|
+
/** The VM is gone and its workers cannot say so: end everything here. */
|
|
85
|
+
private closeListeners;
|
|
86
|
+
/** The guest's workers stop without notice, so end everything on exit. */
|
|
87
|
+
private watchExit;
|
|
88
|
+
private gone;
|
|
56
89
|
private check;
|
|
57
90
|
}
|
package/dist/vm.js
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { Arm64JSError, } from '@arm64js/protocol';
|
|
2
|
+
import { Connection } from './connection.js';
|
|
2
3
|
import { withSdkErrors } from './errors.js';
|
|
4
|
+
import { serveConnection } from './http.js';
|
|
3
5
|
import { guestPath, mountFiles } from './mount.js';
|
|
4
6
|
/** The `onExit` a VM gets when its frame stops answering. */
|
|
5
7
|
const LOST_EXIT = { reason: 'lost' };
|
|
8
|
+
/** How long `Listener.close` waits for the engine to confirm. */
|
|
9
|
+
const UNLISTEN_WAIT_MS = 5000;
|
|
10
|
+
/** `serveHttp`'s default request body limit. */
|
|
11
|
+
export const DEFAULT_MAX_BODY_BYTES = 64 * 1024 * 1024;
|
|
12
|
+
async function delay(ms) {
|
|
13
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
}
|
|
6
15
|
/** A running VM. */
|
|
7
16
|
export class Vm {
|
|
8
17
|
id;
|
|
@@ -14,6 +23,12 @@ export class Vm {
|
|
|
14
23
|
exitListeners = new Set();
|
|
15
24
|
/** The latest `mount` at each path, so an old handle cannot undo a newer one. */
|
|
16
25
|
mounts = new Map();
|
|
26
|
+
/** Listener ports by guest port. */
|
|
27
|
+
listeners = new Map();
|
|
28
|
+
/** Open guest connections, ended when the VM goes away. */
|
|
29
|
+
connections = new Set();
|
|
30
|
+
exitWatched = false;
|
|
31
|
+
exited = false;
|
|
17
32
|
/** @internal */
|
|
18
33
|
constructor(id, host,
|
|
19
34
|
/** The engine this VM runs on (`'0.11'`). */
|
|
@@ -129,6 +144,115 @@ export class Vm {
|
|
|
129
144
|
this.check();
|
|
130
145
|
return withSdkErrors(this.host.readFile(this.id, path, opts));
|
|
131
146
|
}
|
|
147
|
+
/** Take guest connections to `host.internal:<port>`; `onConnection` gets each as
|
|
148
|
+
* a byte stream. Ports nobody listens on refuse connections. Not kept in
|
|
149
|
+
* snapshots. */
|
|
150
|
+
async listen(port, onConnection) {
|
|
151
|
+
this.check();
|
|
152
|
+
if (typeof onConnection !== 'function') {
|
|
153
|
+
throw new Arm64JSError('invalid-input', 'listen needs a function to call for each connection');
|
|
154
|
+
}
|
|
155
|
+
const host = this.host;
|
|
156
|
+
if (!host.listen || !host.unlisten) {
|
|
157
|
+
throw new Arm64JSError('listen-failed', `engine ${this.engine} cannot take connections from the guest; update the arm64js package`);
|
|
158
|
+
}
|
|
159
|
+
const unlisten = host.unlisten.bind(host);
|
|
160
|
+
let listener;
|
|
161
|
+
try {
|
|
162
|
+
listener = await withSdkErrors(host.listen(this.id, port));
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
// An engine from before protocol 1.2 does not know the op.
|
|
166
|
+
if (e.code === 'invalid-input' && /unknown op/.test(String(e.message))) {
|
|
167
|
+
throw new Arm64JSError('listen-failed', `engine ${this.engine} cannot take connections from the guest; update the arm64js package`);
|
|
168
|
+
}
|
|
169
|
+
throw e;
|
|
170
|
+
}
|
|
171
|
+
if (this.gone()) {
|
|
172
|
+
listener.close();
|
|
173
|
+
throw new Arm64JSError('vm-lost', 'the VM went away while listen ran');
|
|
174
|
+
}
|
|
175
|
+
this.watchExit();
|
|
176
|
+
let closing = false;
|
|
177
|
+
let engineDone = null;
|
|
178
|
+
this.listeners.set(port, listener);
|
|
179
|
+
listener.onmessage = (ev) => {
|
|
180
|
+
if (ev.data?.type === 'closed') {
|
|
181
|
+
engineDone?.();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (ev.data?.type !== 'connect' || !(ev.data.conn instanceof MessagePort)) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const conn = new Connection(ev.data.conn, ev.data.port);
|
|
188
|
+
// Refuse rather than leave it hanging.
|
|
189
|
+
if (closing || this.gone()) {
|
|
190
|
+
conn.close();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
this.connections.add(conn);
|
|
194
|
+
void conn.closed.then(() => this.connections.delete(conn));
|
|
195
|
+
const fail = (e) => {
|
|
196
|
+
console.error('[arm64js] listen callback failed', e);
|
|
197
|
+
conn.close();
|
|
198
|
+
};
|
|
199
|
+
try {
|
|
200
|
+
const r = onConnection(conn);
|
|
201
|
+
if (r && typeof r.then === 'function') {
|
|
202
|
+
r.then(undefined, fail);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
fail(e);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
return {
|
|
210
|
+
close: async () => {
|
|
211
|
+
if (closing || this.listeners.get(port) !== listener) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
closing = true;
|
|
215
|
+
try {
|
|
216
|
+
if (!this.gone()) {
|
|
217
|
+
// Wait for `closed` so no connection in flight is lost.
|
|
218
|
+
const done = new Promise((resolve) => {
|
|
219
|
+
engineDone = resolve;
|
|
220
|
+
});
|
|
221
|
+
await withSdkErrors(unlisten(this.id, port));
|
|
222
|
+
await Promise.race([done, delay(UNLISTEN_WAIT_MS)]);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch (e) {
|
|
226
|
+
// The VM stopped: nothing is listening anyway.
|
|
227
|
+
const code = e.code;
|
|
228
|
+
if (code !== 'vm-exited' && code !== 'vm-lost') {
|
|
229
|
+
throw e;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
if (this.listeners.get(port) === listener) {
|
|
234
|
+
this.listeners.delete(port);
|
|
235
|
+
}
|
|
236
|
+
listener.close();
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
port,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
/** Serve HTTP on `host.internal:<port>`: each guest request becomes a `Request`;
|
|
243
|
+
* the handler's `Response` streams back. */
|
|
244
|
+
async serveHttp(port, handler, opts = {}) {
|
|
245
|
+
if (typeof handler !== 'function') {
|
|
246
|
+
throw new Arm64JSError('invalid-input', 'serveHttp needs a function that answers a Request');
|
|
247
|
+
}
|
|
248
|
+
const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
249
|
+
if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes < 0) {
|
|
250
|
+
throw new Arm64JSError('invalid-input', 'maxBodyBytes must be a whole number of bytes');
|
|
251
|
+
}
|
|
252
|
+
return this.listen(port, (conn) => {
|
|
253
|
+
void serveConnection(conn, handler, { maxBodyBytes });
|
|
254
|
+
});
|
|
255
|
+
}
|
|
132
256
|
/** Save the VM to this browser's storage. The VM keeps running. */
|
|
133
257
|
async snapshot(opts) {
|
|
134
258
|
this.check();
|
|
@@ -140,6 +264,7 @@ export class Vm {
|
|
|
140
264
|
return;
|
|
141
265
|
}
|
|
142
266
|
this.disposed = true;
|
|
267
|
+
this.closeListeners();
|
|
143
268
|
this.onDisposed(this);
|
|
144
269
|
await withSdkErrors(this.host.dispose(this.id));
|
|
145
270
|
}
|
|
@@ -149,11 +274,42 @@ export class Vm {
|
|
|
149
274
|
return;
|
|
150
275
|
}
|
|
151
276
|
this.frameLost = true;
|
|
277
|
+
this.closeListeners();
|
|
152
278
|
for (const cb of [...this.exitListeners]) {
|
|
153
279
|
cb(LOST_EXIT);
|
|
154
280
|
}
|
|
155
281
|
this.exitListeners.clear();
|
|
156
282
|
}
|
|
283
|
+
/** The VM is gone and its workers cannot say so: end everything here. */
|
|
284
|
+
closeListeners() {
|
|
285
|
+
for (const listener of this.listeners.values()) {
|
|
286
|
+
listener.close();
|
|
287
|
+
}
|
|
288
|
+
this.listeners.clear();
|
|
289
|
+
for (const conn of this.connections) {
|
|
290
|
+
conn.lost();
|
|
291
|
+
}
|
|
292
|
+
this.connections.clear();
|
|
293
|
+
}
|
|
294
|
+
/** The guest's workers stop without notice, so end everything on exit. */
|
|
295
|
+
watchExit() {
|
|
296
|
+
if (this.exitWatched) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
this.exitWatched = true;
|
|
300
|
+
try {
|
|
301
|
+
this.host.onExit(this.id, () => {
|
|
302
|
+
this.exited = true;
|
|
303
|
+
this.closeListeners();
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
// Unknown VM: dispose cleans up.
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
gone() {
|
|
311
|
+
return this.disposed || this.frameLost || this.exited;
|
|
312
|
+
}
|
|
157
313
|
check() {
|
|
158
314
|
if (this.disposed) {
|
|
159
315
|
throw new Arm64JSError('vm-lost', 'this VM was disposed');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arm64js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Run a Linux VM inside your web page. Boots Alpine from the arm64js CDN, runs commands, keeps snapshots in the browser.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"prepare": "npm run stamp && npm run build:protocol && husky"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@arm64js/protocol": "^1.
|
|
37
|
+
"@arm64js/protocol": "^1.2.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@eslint/js": "^10.0.1",
|