arm64js 0.7.0 → 0.9.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 CHANGED
@@ -30,7 +30,7 @@ const { output, exitCode } = await vm.exec('apk add --no-cache curl');
30
30
 
31
31
  It returns raw `output` and `exitCode` so that you can verify the result.
32
32
 
33
- The VM _does not_ have access to the Internet except for the apk registry. To give it your own data, you can use one of the two options:
33
+ The VM _does not_ have access to the Internet except for the apk registry, unless you run an internet gateway for it (see [Networking](#networking)). To give it your own data, you can use one of the two options:
34
34
 
35
35
  - `vm.writeFile`: copies the specified file into the VM. Changing or deleting the file happens only inside the VM. The file is saved and loaded with the snapshot. Uses VM memory to copy the file, thus is intended mainly for smaller (up to 100 MiB) files.
36
36
  - `vm.mount`: read-only, direct mount of the data blob. Doesn't use any VM memory. Not stored in the snapshot. To unmount use `vm.unmount()`.
@@ -114,7 +114,7 @@ npm install arm64js
114
114
 
115
115
  ## API
116
116
 
117
- ### `Arm64JS.boot(image | snapshotId, { vcpus?, onProgress? })` → `Vm`
117
+ ### `Arm64JS.boot(image | snapshotId, { vcpus?, gateway?, onProgress? })` → `Vm`
118
118
 
119
119
  A VM can be created either from a base image or your own local snapshot.
120
120
 
@@ -123,6 +123,8 @@ A VM can be created either from a base image or your own local snapshot.
123
123
 
124
124
  Additionally, you can specify an `onProgress` callback to track VM loading phases and the number of virtual CPUs the VM will have (more is not always better).
125
125
 
126
+ `gateway: { url, token? }` sends the VM's internet traffic through a gateway you run, see [Internet gateway](#internet-gateway).
127
+
126
128
  onProgress gets one argument, a BootProgress object:
127
129
 
128
130
  ```ts
@@ -390,10 +392,36 @@ It shows the VM console, sends what is typed to the VM, and sizes itself to the
390
392
 
391
393
  ## Networking
392
394
 
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.
395
+ 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 unless you give it a gateway.
394
396
 
395
397
  The name `host.internal` is the page itself: see `vm.listen` and `vm.serveHttp`.
396
398
 
399
+ ### Internet gateway
400
+
401
+ A browser cannot open TCP connections, so a VM reaches the internet through a gateway: a small program that takes the VM's connections over a WebSocket and opens them from the machine it runs on. Download `arm64js-gateway` for Linux or macOS from [arm64js.com](https://arm64js.com/#internet-gateway), start it, and pass its address when booting:
402
+
403
+ ```sh
404
+ ./arm64js-gateway --ws 127.0.0.1:4434
405
+ ```
406
+
407
+ ```js
408
+ const vm = await Arm64JS.boot('alpine', { gateway: { url: 'ws://127.0.0.1:4434' } });
409
+ await vm.exec('wget -qO- https://example.com');
410
+ ```
411
+
412
+ By default the gateway relays every connection from any page that reaches it, to any address, including the machine it runs on and its local network. To narrow that down:
413
+
414
+ - `--allow-host registry.npmjs.org` (repeatable, `*.npmjs.org` for a whole domain) relays only TCP connections to those names. A name that resolves to a private or local address is still refused; give the IP itself to allow an address. UDP and ping are then not relayed. Names that share an address with an allowed name on a large CDN can still be reached, because the gateway sees addresses, not the site inside the encrypted connection.
415
+ - `--allow-origin https://app.example.com` (repeatable) serves only web pages from that origin. It does not stop other programs, which can send any origin, so a gateway other machines can reach needs `--token`. In frame mode the VM runs in the CDN's frame, so the origin the gateway sees is `https://cdn.arm64js.com`: use `--token` instead.
416
+ - `--token <secret>` serves only clients that pass the same `token` in the boot option: `{ gateway: { url, token } }`. Anyone who can read your page can read the token too. A client the gateway refuses gets no connection at all, so the VM boots without internet access.
417
+
418
+ Things to know:
419
+
420
+ - Chrome asks the user to allow access to apps on their device the first time a page on another site connects to a gateway on `127.0.0.1`. A page served from `localhost` is not asked.
421
+ - A gateway on another machine needs `wss://`: browsers block a plain `ws://` connection from an `https` page to anything but the local machine. Put it behind a TLS proxy such as Caddy.
422
+ - DNS does not go through the gateway: it is resolved over HTTPS by the browser (Cloudflare's resolver). `apk` packages come from the CDN's mirror and use the gateway only when that download fails.
423
+ - Needs engine 0.9 or newer. A snapshot saved on an older engine resumes on that engine, and booting it with `gateway` fails with `engine-mismatch` rather than starting a VM without internet access.
424
+
397
425
  ## Development
398
426
 
399
427
  ```sh
@@ -1,6 +1,7 @@
1
1
  // A guest connection to `host.internal` as a byte stream. Writes wait while
2
2
  // `HOST_CONN_WINDOW` bytes are unacknowledged; guest bytes are acknowledged only
3
- // when read, so a slow page slows the guest.
3
+ // when read, so a slow page slows the guest. The page's side ends by itself once
4
+ // the page has read to the guest's end, as a Node socket's does.
4
5
  import { Arm64JSError, HOST_CONN_CHUNK, HOST_CONN_WINDOW } from '@arm64js/protocol';
5
6
  /** One TCP connection the guest opened to `host.internal:<port>`. */
6
7
  export class Connection {
@@ -45,6 +46,10 @@ export class Connection {
45
46
  }
46
47
  if (this.remoteEnded || this.isClosed) {
47
48
  controller.close();
49
+ // The guest has ended and the page has seen it: end the page's side
50
+ // too, after the writes already made. A page that forgot `end()`
51
+ // would otherwise keep the connection, and its buffers in the VM.
52
+ void this.end();
48
53
  return;
49
54
  }
50
55
  await this.waitForChange();
@@ -27,6 +27,12 @@ export async function mountFrameHost(url, deps = {}) {
27
27
  iframe.src = url;
28
28
  iframe.setAttribute('title', 'arm64js');
29
29
  iframe.setAttribute('aria-hidden', 'true');
30
+ // The VM's network worker runs in this frame. To reach a gateway on the user's
31
+ // own machine (`gateway: { url: 'ws://127.0.0.1:…' }`) Chrome asks the user for
32
+ // local network access, and a cross-origin frame may only ask when the page
33
+ // delegates it. All three names are Chrome's (it split the permission in 145);
34
+ // a browser that knows none of them ignores the attribute.
35
+ iframe.setAttribute('allow', 'local-network-access; local-network; loopback-network');
30
36
  // In the layout and on screen, never display:none: a hidden frame's timers are
31
37
  // throttled by the browser. The VM steps in workers, but the frame's main
32
38
  // thread still relays.
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export { VERSION as version } from './version.js';
6
6
  export { Connection, type ConnectionData } from './connection.js';
7
7
  export type { HttpHandler } from './http.js';
8
8
  export { DEFAULT_MAX_BODY_BYTES, Vm, type FileData, type Listener, type Mount, type ServeHttpOptions } from './vm.js';
9
- export type { Arm64JSErrorCode, BootOptions, BootProgress, ExecOptions, ExecResult, OutputInfo, ReadFileOptions, SnapshotInfo, SnapshotOptions, StorageStatus, VmExit, WriteFileOptions, } from '@arm64js/protocol';
9
+ export type { Arm64JSErrorCode, BootOptions, BootProgress, ExecOptions, ExecResult, GatewayOptions, OutputInfo, ReadFileOptions, SnapshotInfo, SnapshotOptions, StorageStatus, VmExit, WriteFileOptions, } from '@arm64js/protocol';
10
10
  export { Arm64JSError, HOST_INTERNAL_NAME } from '@arm64js/protocol';
11
11
  export declare const Arm64JS: Arm64JSApi;
12
12
  export default Arm64JS;
package/dist/loader.d.ts CHANGED
@@ -4,6 +4,13 @@ export declare function packageEngine(): string;
4
4
  export declare function engineParts(tag: string): [number, number] | null;
5
5
  /** Whether engine `a` is newer than engine `b`. False unless both are exact tags. */
6
6
  export declare function isNewerEngine(a: string, b: string): boolean;
7
+ /** The first engine whose runtime understands `BootOptions.gateway`. */
8
+ export declare const GATEWAY_MIN_ENGINE = "0.9";
9
+ /**
10
+ * Whether `engine` is known to predate `gateway`. Only an exact tag can say so;
11
+ * a development build (`'dev'`) is assumed current.
12
+ */
13
+ export declare function predatesGateway(engine: string): boolean;
7
14
  /** The immutable runtime directory for an exact `X.Y`, with its trailing slash. */
8
15
  export declare function runtimeDir(version: string): string;
9
16
  export declare function runtimeModuleUrl(version: string): string;
package/dist/loader.js CHANGED
@@ -24,6 +24,15 @@ export function isNewerEngine(a, b) {
24
24
  }
25
25
  return x[0] !== y[0] ? x[0] > y[0] : x[1] > y[1];
26
26
  }
27
+ /** The first engine whose runtime understands `BootOptions.gateway`. */
28
+ export const GATEWAY_MIN_ENGINE = '0.9';
29
+ /**
30
+ * Whether `engine` is known to predate `gateway`. Only an exact tag can say so;
31
+ * a development build (`'dev'`) is assumed current.
32
+ */
33
+ export function predatesGateway(engine) {
34
+ return engineParts(engine) !== null && isNewerEngine(GATEWAY_MIN_ENGINE, engine);
35
+ }
27
36
  /** The immutable runtime directory for an exact `X.Y`, with its trailing slash. */
28
37
  export function runtimeDir(version) {
29
38
  return `${CDN_BASE}/sdk-v${version}/`;
package/dist/runtime.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Arm64JSError } from '@arm64js/protocol';
2
2
  import { withSdkErrors } from './errors.js';
3
3
  import { loadHost } from './hosts.js';
4
- import { engineParts, isNewerEngine, packageEngine } from './loader.js';
4
+ import { engineParts, GATEWAY_MIN_ENGINE, isNewerEngine, packageEngine, predatesGateway } from './loader.js';
5
5
  import { Vm } from './vm.js';
6
6
  /**
7
7
  * The runtimes for this page: its own engine, loaded on the first call, and
@@ -71,6 +71,12 @@ export class Runtime {
71
71
  }
72
72
  }
73
73
  async bootOn(loaded, id, opts) {
74
+ // An older runtime drops the option it does not know, and the VM would boot
75
+ // with no internet and no error. A snapshot saved on an older engine resumes
76
+ // on that engine, so this can happen with a current SDK too.
77
+ if (opts?.gateway !== undefined && predatesGateway(loaded.engine)) {
78
+ throw new Arm64JSError('engine-mismatch', `the gateway option needs engine ${GATEWAY_MIN_ENGINE} or newer; this VM runs on ${loaded.engine}`);
79
+ }
74
80
  const { vmId } = await withSdkErrors(loaded.host.boot(id, opts));
75
81
  const vm = new Vm(vmId, loaded.host, loaded.engine, (v) => this.vms.delete(v));
76
82
  this.vms.add(vm);
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.7.0";
1
+ export declare const VERSION = "0.9.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.7.0";
2
+ export const VERSION = "0.9.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arm64js",
3
- "version": "0.7.0",
3
+ "version": "0.9.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.2.0"
37
+ "@arm64js/protocol": "^1.3.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@eslint/js": "^10.0.1",