arm64js 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +286 -0
- package/dist/api.d.ts +32 -0
- package/dist/api.js +32 -0
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +24 -0
- package/dist/frame-host.d.ts +30 -0
- package/dist/frame-host.js +89 -0
- package/dist/hosts.d.ts +27 -0
- package/dist/hosts.js +46 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +6 -0
- package/dist/loader.d.ts +10 -0
- package/dist/loader.js +36 -0
- package/dist/mount.d.ts +12 -0
- package/dist/mount.js +62 -0
- package/dist/runtime.d.ts +33 -0
- package/dist/runtime.js +121 -0
- package/dist/terminal.d.ts +19 -0
- package/dist/terminal.js +88 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +2 -0
- package/dist/vm.d.ts +56 -0
- package/dist/vm.js +161 -0
- package/package.json +79 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Konstantin Goncharuk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# ARM64JS SDK
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/arm64js)
|
|
4
|
+
[](https://github.com/kooler/arm64js-sdk/actions/workflows/ci.yml)
|
|
5
|
+
|
|
6
|
+
Arm64js is a WebAssembly-based arm64 emulator running in a web browser and allowing you to boot and control a real Linux (Alpine).
|
|
7
|
+
|
|
8
|
+
This SDK helps to: create a VM, set it up and interact with it.
|
|
9
|
+
|
|
10
|
+
The SDK is distributed as an npm package and integrated with the arm64js CDN.
|
|
11
|
+
|
|
12
|
+
## VM creation lifecycle
|
|
13
|
+
|
|
14
|
+
### 1. Boot a base image
|
|
15
|
+
|
|
16
|
+
Similarly to Docker, you need to select a base image to boot the initial state of your VM. At the moment, only base images provided by arm64js can be used as they require some customization to work "quicker" when emulated in a web browser. The list of images and their versions is at [arm64js.com/images](https://arm64js.com/images/). Booting the image creates a VM instance:
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
import { Arm64JS } from 'arm64js';
|
|
20
|
+
const vm = await Arm64JS.boot('alpine');
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### 2. Set up the VM
|
|
24
|
+
|
|
25
|
+
You would likely want to install some extra packages or copy files into your VM so that it can do something useful. For managing packages, use `apk` as you would in the native Alpine. To execute a command (any command pretty much), the `vm.exec` method is used:
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
const { output, exitCode } = await vm.exec('apk add --no-cache curl');
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
It returns raw `output` and `exitCode` so that you can verify the result.
|
|
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:
|
|
34
|
+
|
|
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
|
+
- `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()`.
|
|
37
|
+
|
|
38
|
+
See example below:
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
await vm.writeFile('/root/data.csv', file); // creates a physical file in the VM
|
|
42
|
+
await vm.mount({ 'model.bin': bigFile }, '/data'); // read-only, directly mounted blob, does not copy anything into the VM so it can be a large file
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
To read any file from the VM, use `readFile`:
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
const result = await vm.readFile('/root/result.txt'); // a copy out, as a Blob
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### 3. Make a snapshot and save it locally
|
|
52
|
+
|
|
53
|
+
Once you are done with the VM setup, you would likely want to save it so that you don't need to do the same setup again. For that, make a snapshot of the VM. Snapshots are stored in the [OPFS](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system).
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
const snap = await vm.snapshot({ name: 'alpine with curl' });
|
|
57
|
+
console.log(snap.id); // this is the id we'd need to store to load the snapshot later
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
To load from the snapshot, specify its ID when creating the VM:
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
const again = await Arm64JS.boot(snap.id);
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Where the VM runs
|
|
67
|
+
|
|
68
|
+
The VM needs `SharedArrayBuffer` which browsers only give to cross-origin-isolated pages. If the `Cross-Origin-Opener-Policy` (COOP) header is not specified or not sufficient, the SDK will load the VM inside an iframe on the `cdn.arm64js.com` domain.
|
|
69
|
+
|
|
70
|
+
The following decision process is being used:
|
|
71
|
+
|
|
72
|
+
| Your page | What happens |
|
|
73
|
+
| ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- |
|
|
74
|
+
| sends `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp` | the VM runs **inline**, in your page's own workers. Works in all modern browsers. |
|
|
75
|
+
| sends no COOP headers (likely) | the VM runs in a hidden **frame** served from the arm64js CDN. Works in Chrome and Edge 137+. |
|
|
76
|
+
| no COOP header and a browser that doesn't support the iframe fallback (Firefox, Safari) | `boot()` rejects with `unsupported-browser`. |
|
|
77
|
+
|
|
78
|
+
Call `Arm64JS.mode()` to see which mode is being used.
|
|
79
|
+
|
|
80
|
+
### Setting the headers
|
|
81
|
+
|
|
82
|
+
To run the VM inline, send these two headers with the page that runs the VM:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
Cross-Origin-Opener-Policy: same-origin
|
|
86
|
+
Cross-Origin-Embedder-Policy: require-corp
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
We recommend setting them only on the pages that run the VM, not on the whole site, because they change how the browser treats a page:
|
|
90
|
+
|
|
91
|
+
- files from other domains (scripts, images, fonts, iframes) load only if their server allows it with a `Cross-Origin-Resource-Policy` or CORS header (arm64js CDN already does).
|
|
92
|
+
- popups the page opens lose their link to it, which can break for example sign-in or payment popups.
|
|
93
|
+
|
|
94
|
+
Here's an example of how to send them with nginx:
|
|
95
|
+
|
|
96
|
+
```nginx
|
|
97
|
+
location = /playground.html {
|
|
98
|
+
add_header Cross-Origin-Opener-Policy same-origin;
|
|
99
|
+
add_header Cross-Origin-Embedder-Policy require-corp;
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
To check the setup, run `await Arm64JS.mode()`, it should return `'inline'`.
|
|
104
|
+
|
|
105
|
+
### Content-Security-Policy
|
|
106
|
+
|
|
107
|
+
If your page sends a Content-Security-Policy and you would like to use the iframe-based solution, you need to allow the arm64js CDN as an origin: `script-src https://cdn.arm64js.com`, `connect-src https://cdn.arm64js.com`, `worker-src blob:` (the VM's workers are started through a blob), and `frame-src https://cdn.arm64js.com` for the iframe itself.
|
|
108
|
+
|
|
109
|
+
## Install
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
npm install arm64js
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## API
|
|
116
|
+
|
|
117
|
+
### `Arm64JS.boot(image | snapshotId, { vcpus?, onProgress? })` → `Vm`
|
|
118
|
+
|
|
119
|
+
A VM can be created either from a base image or your own local snapshot.
|
|
120
|
+
|
|
121
|
+
- `image`: name of an image on the CDN ([all images](https://arm64js.com/images/)): `'alpine'` is the newest version of it, `'alpine:2'` a fixed one, `'alpine@sha256:<hash>'` an exact snapshot.
|
|
122
|
+
- `snapshotId`: a snapshot id (`snap.id`, 64 hex characters) loads one of your own snapshots made previously.
|
|
123
|
+
|
|
124
|
+
Additionally, you can specify an `onProgress` callback to track VM loading phases (`resolve → manifest → start → memory (repeats, with counts) → run → done`) and the number of virtual CPUs the VM will have (more is not always better).
|
|
125
|
+
|
|
126
|
+
### Listener: `vm.onOutput(cb)` → unsubscribe
|
|
127
|
+
|
|
128
|
+
The raw bytes the guest prints on its console, including escape sequences.
|
|
129
|
+
|
|
130
|
+
```js
|
|
131
|
+
const decoder = new TextDecoder(); // The output comes as bytes (Uint8Array), TextDecoder turns them into text.
|
|
132
|
+
const stopOutput = vm.onOutput((bytes, info) => {
|
|
133
|
+
if (info.exec) return; // skip commands that we call ourselves
|
|
134
|
+
console.log(decoder.decode(bytes, { stream: true }));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// to stop listening:
|
|
138
|
+
stopOutput();
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### `vm.onExit(cb)` → unsubscribe
|
|
142
|
+
|
|
143
|
+
The VM stopped (halted or faulted). Every later call on the VM rejects with `vm-exited`. If the hidden frame running the VM stops answering, `reason` is `'lost'` and later calls reject with `vm-lost`.
|
|
144
|
+
|
|
145
|
+
If the VM has already stopped, the callback is called right away. It is not called for `vm.dispose()`.
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
const stopExit = vm.onExit(({ reason }) => console.log(`VM stopped: ${reason}`));
|
|
149
|
+
|
|
150
|
+
// later, to stop listening:
|
|
151
|
+
stopExit();
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### `vm.exec(command, { timeoutMs? })` → `{ output, exitCode, truncated }`
|
|
155
|
+
|
|
156
|
+
Runs `command` in the guest's shell (`sh`), and returns what it printed and its exit status. `command` can be one command or several, one per line. The default timeout is two minutes.
|
|
157
|
+
|
|
158
|
+
It works by typing the command into the guest's serial console and reading the bytes back. There are a few limits:
|
|
159
|
+
|
|
160
|
+
- stdout and stderr are merged into one stream.
|
|
161
|
+
- Each line of the command must be under 4000 bytes.
|
|
162
|
+
- **`output` is capped at 1 MiB**, and past that the head is dropped and `truncated` is `true`. Write to a file and read it back if you need more.
|
|
163
|
+
- A timeout does its best to terminate the command (sends Ctrl-C to the VM's console), but that does not guarantee that the command will stop -- a program that ignores Ctrl-C (like vim) will keep running.
|
|
164
|
+
|
|
165
|
+
Whatever the command does runs as **root** in the guest.
|
|
166
|
+
|
|
167
|
+
### `vm.write(data)`
|
|
168
|
+
|
|
169
|
+
Send data directly into the VM console, lower level than `vm.exec`, allows sending any keyboard sequences including key presses like Ctrl-C ('\x03') or Enter ('\r').
|
|
170
|
+
|
|
171
|
+
### `vm.resize(cols, rows)`
|
|
172
|
+
|
|
173
|
+
Set the terminal console size. Mainly useful if you are writing your own terminal implementation.
|
|
174
|
+
|
|
175
|
+
If you want to use a terminal with your VM, it's easier to connect xterm (see "Terminal" section below).
|
|
176
|
+
|
|
177
|
+
### `vm.writeFile(path, data, { mode?, timeoutMs? })`
|
|
178
|
+
|
|
179
|
+
Copies `data` (a `Blob` or `File`, bytes, or a string) into the VM as the file `path` (an absolute path). An existing file is replaced, a missing folder is created.
|
|
180
|
+
|
|
181
|
+
- `mode` sets the permission bits, e.g. `0o755` for a script.
|
|
182
|
+
- `timeoutMs` will terminate the operation if it doesn't complete within the specified time (for example, if the VM is very busy). Default is two minutes plus one second per MiB.
|
|
183
|
+
|
|
184
|
+
The copy is kept in VM memory, so it has to fit there (`alpine` for example has 1 GiB). If you need to use a larger file, use `vm.mount` instead.
|
|
185
|
+
|
|
186
|
+
**Files become part of the VM, so when a snapshot is taken those files will be present in the snapshot.**
|
|
187
|
+
|
|
188
|
+
### `vm.readFile(path, { maxBytes?, timeoutMs? })` → `Blob`
|
|
189
|
+
|
|
190
|
+
Copies a file out of the VM. The copy is kept in VM memory, so the file should fit there (default 1 GiB). If it doesn't, or the path is not a file but a directory, or doesn't exist, the read is refused with `read-failed`.
|
|
191
|
+
|
|
192
|
+
Here's an example of how a file can be downloaded from the VM:
|
|
193
|
+
|
|
194
|
+
```js
|
|
195
|
+
const blob = await vm.readFile('/root/report.pdf');
|
|
196
|
+
const a = document.createElement('a');
|
|
197
|
+
a.href = URL.createObjectURL(blob);
|
|
198
|
+
a.download = 'report.pdf';
|
|
199
|
+
a.click();
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### `vm.mount(files, path)` → `{ path, unmount() }`
|
|
203
|
+
|
|
204
|
+
Mounts files in read-only mode into the VM. Files are not copied, so they do not consume memory. `path` is created if missing. `files` is a record of names to Blobs (`{ 'data.bin': blob }`), or a list of Files (`input.files` or `dataTransfer.files`).
|
|
205
|
+
|
|
206
|
+
A path that is already mounted is refused with `invalid-input`: `unmount` it first, and wait for that to finish.
|
|
207
|
+
|
|
208
|
+
**Mounts are not part of the VM and are not saved in snapshots.** After you boot a snapshot, mount again.
|
|
209
|
+
|
|
210
|
+
### `vm.unmount(path)`
|
|
211
|
+
|
|
212
|
+
Undoes a `mount`. If nothing is mounted at `path` it does nothing. While a program in the VM still uses the files, it rejects with `unmount-failed`.
|
|
213
|
+
|
|
214
|
+
### `vm.snapshot({ name?, meta? })` → `SnapshotInfo`
|
|
215
|
+
|
|
216
|
+
Saves the VM in browser storage (OPFS) and keeps it running. Use `meta` to store any extra info you'd like to add to the snapshot (any JSON up to 16 KiB); it is returned in `snapshots.list()`.
|
|
217
|
+
|
|
218
|
+
### `vm.dispose()`
|
|
219
|
+
|
|
220
|
+
Stops the VM and frees its workers.
|
|
221
|
+
|
|
222
|
+
### `Arm64JS.snapshots.list()` / `.get(id)` / `.remove(id, { force? })`
|
|
223
|
+
|
|
224
|
+
Lists stored snapshots, newest first: `{ id, name, created, base, engine, vcpus, sizeBytes, meta }`. `remove` is refused while a VM runs from that snapshot, unless `force` is specified.
|
|
225
|
+
|
|
226
|
+
### `Arm64JS.storage.status()`
|
|
227
|
+
|
|
228
|
+
Returns an object describing the current storage situation: `{ available, reason?, persistent?, quota?, usage?, snapshots, poolBytes }`.
|
|
229
|
+
|
|
230
|
+
Storage can be unavailable when:
|
|
231
|
+
|
|
232
|
+
1. a page is not a secure context
|
|
233
|
+
2. a browser without OPFS
|
|
234
|
+
3. in **frame mode when the browser blocks third-party storage** (for example in a private window).
|
|
235
|
+
|
|
236
|
+
When storage is not available, `snapshot()` rejects with `storage-unavailable`.
|
|
237
|
+
|
|
238
|
+
### `Arm64JS.engine()` → `'0.4'`
|
|
239
|
+
|
|
240
|
+
The engine version the SDK has been built with. A VM booted from a snapshot can run on another version (based on the version that was running when the snapshot was made). Use `vm.engine` to check which.
|
|
241
|
+
|
|
242
|
+
### `Arm64JS.shutdown()`
|
|
243
|
+
|
|
244
|
+
Disposes every VM and, in frame mode, the frames.
|
|
245
|
+
|
|
246
|
+
### Errors
|
|
247
|
+
|
|
248
|
+
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`.
|
|
249
|
+
|
|
250
|
+
## Terminal (optional)
|
|
251
|
+
|
|
252
|
+
A terminal is based on [xterm.js](https://xtermjs.org) and is not included by default; you need to install and wire it separately.
|
|
253
|
+
|
|
254
|
+
```sh
|
|
255
|
+
npm install @xterm/xterm @xterm/addon-fit
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
```js
|
|
259
|
+
import { attachTerminal } from 'arm64js/terminal';
|
|
260
|
+
import '@xterm/xterm/css/xterm.css';
|
|
261
|
+
|
|
262
|
+
const term = attachTerminal(vm, document.getElementById('term'));
|
|
263
|
+
// term.xterm is the xterm instance; term.dispose() removes it and leaves the VM running.
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
It shows the VM console, sends what is typed to the VM, and sizes itself to the element (pass `{ fit: false }` to keep a fixed size, and `{ xterm: { … } }` for xterm's own options). `vm.exec` runs on the same console but stays out of the terminal; keys pressed while a `vm.exec` command is running are sent once it finishes.
|
|
267
|
+
|
|
268
|
+
## Networking
|
|
269
|
+
|
|
270
|
+
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.
|
|
271
|
+
|
|
272
|
+
## Development
|
|
273
|
+
|
|
274
|
+
```sh
|
|
275
|
+
npm install
|
|
276
|
+
npm test
|
|
277
|
+
npm run build
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
`src/version.ts` and `protocol/dist/` are generated. `npm install` creates them; run `npm run stamp` again after changing the version in `package.json`.
|
|
281
|
+
|
|
282
|
+
`protocol/` is the `@arm64js/protocol` package, which the engine uses too. To change it, bump its version and publish it (tag `protocol-v<version>`), update it in the engine and release the engine, then release this package.
|
|
283
|
+
|
|
284
|
+
## License
|
|
285
|
+
|
|
286
|
+
MIT
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { BootOptions } from '@arm64js/protocol';
|
|
2
|
+
import { type Arm64JSDeps } from './runtime.js';
|
|
3
|
+
export type { Arm64JSDeps } from './runtime.js';
|
|
4
|
+
/**
|
|
5
|
+
* Build the API over a runtime. Tests pass `deps`; the package's own
|
|
6
|
+
* `Arm64JS` is one of these over the real page.
|
|
7
|
+
*/
|
|
8
|
+
export declare function createArm64JS(deps?: Arm64JSDeps): {
|
|
9
|
+
/** Boot a CDN image (`'alpine'`, `'alpine:2'`) or one of this browser's snapshots by id.
|
|
10
|
+
* A snapshot boots on the engine it was saved on when this page's own cannot resume it. */
|
|
11
|
+
boot: (target: string, opts?: BootOptions) => Promise<import("./vm.js").Vm>;
|
|
12
|
+
/** `'inline'` when the VM runs in this page, `'frame'` when in the CDN's frame. */
|
|
13
|
+
mode: () => Promise<import("./hosts.js").HostMode>;
|
|
14
|
+
/** This page's engine, e.g. `'0.11'`. A VM booted from a snapshot may run on
|
|
15
|
+
* another: see `vm.engine`. */
|
|
16
|
+
engine: () => Promise<string>;
|
|
17
|
+
snapshots: {
|
|
18
|
+
list: () => Promise<import("@arm64js/protocol").SnapshotInfo[]>;
|
|
19
|
+
get: (id: string) => Promise<import("@arm64js/protocol").SnapshotInfo | null>;
|
|
20
|
+
/** Delete a snapshot. Refused while a VM runs from it, unless `force`. */
|
|
21
|
+
remove: (id: string, opts?: {
|
|
22
|
+
force?: boolean;
|
|
23
|
+
}) => Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
storage: {
|
|
26
|
+
status: () => Promise<import("@arm64js/protocol").StorageStatus>;
|
|
27
|
+
};
|
|
28
|
+
/** Dispose every VM and, in frame mode, the frame. */
|
|
29
|
+
shutdown: () => Promise<void>;
|
|
30
|
+
version: string;
|
|
31
|
+
};
|
|
32
|
+
export type Arm64JSApi = ReturnType<typeof createArm64JS>;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The object a page calls: `Arm64JS.boot(...)`, `Arm64JS.snapshots.list()`.
|
|
2
|
+
import { Runtime } from './runtime.js';
|
|
3
|
+
import { VERSION } from './version.js';
|
|
4
|
+
/**
|
|
5
|
+
* Build the API over a runtime. Tests pass `deps`; the package's own
|
|
6
|
+
* `Arm64JS` is one of these over the real page.
|
|
7
|
+
*/
|
|
8
|
+
export function createArm64JS(deps = {}) {
|
|
9
|
+
const rt = new Runtime(deps);
|
|
10
|
+
return {
|
|
11
|
+
/** Boot a CDN image (`'alpine'`, `'alpine:2'`) or one of this browser's snapshots by id.
|
|
12
|
+
* A snapshot boots on the engine it was saved on when this page's own cannot resume it. */
|
|
13
|
+
boot: async (target, opts) => rt.boot(target, opts),
|
|
14
|
+
/** `'inline'` when the VM runs in this page, `'frame'` when in the CDN's frame. */
|
|
15
|
+
mode: async () => rt.mode(),
|
|
16
|
+
/** This page's engine, e.g. `'0.11'`. A VM booted from a snapshot may run on
|
|
17
|
+
* another: see `vm.engine`. */
|
|
18
|
+
engine: async () => rt.engine(),
|
|
19
|
+
snapshots: {
|
|
20
|
+
list: async () => rt.listSnapshots(),
|
|
21
|
+
get: async (id) => rt.getSnapshot(id),
|
|
22
|
+
/** Delete a snapshot. Refused while a VM runs from it, unless `force`. */
|
|
23
|
+
remove: async (id, opts) => rt.removeSnapshot(id, opts),
|
|
24
|
+
},
|
|
25
|
+
storage: {
|
|
26
|
+
status: async () => rt.storageStatus(),
|
|
27
|
+
},
|
|
28
|
+
/** Dispose every VM and, in frame mode, the frame. */
|
|
29
|
+
shutdown: async () => rt.shutdown(),
|
|
30
|
+
version: VERSION,
|
|
31
|
+
};
|
|
32
|
+
}
|
package/dist/errors.d.ts
ADDED
package/dist/errors.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Errors raised by this package. Every host call a page can reach goes through
|
|
2
|
+
// `withSdkErrors`, so a caller sees one `Arm64JSError` class whichever way the VM runs.
|
|
3
|
+
import { Arm64JSError } from '@arm64js/protocol';
|
|
4
|
+
/** Await a host call, re-raising its failure as this package's error class. */
|
|
5
|
+
export async function withSdkErrors(p) {
|
|
6
|
+
return p.catch(rethrow);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Re-raise a host failure as this package's `Arm64JSError`.
|
|
10
|
+
*
|
|
11
|
+
* Inline, the rejection carries the CDN runtime's own copy of the class, so an
|
|
12
|
+
* `instanceof` against the copy this package exports would be false — while in
|
|
13
|
+
* frame mode, where `RpcClient` rebuilds it locally, it would be true.
|
|
14
|
+
*/
|
|
15
|
+
function rethrow(e) {
|
|
16
|
+
const code = e?.code;
|
|
17
|
+
if (e instanceof Arm64JSError) {
|
|
18
|
+
throw e;
|
|
19
|
+
}
|
|
20
|
+
if (code) {
|
|
21
|
+
throw new Arm64JSError(code, e.message, e.data);
|
|
22
|
+
}
|
|
23
|
+
throw new Arm64JSError('boot-failed', String(e?.message ?? e));
|
|
24
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type Host } from '@arm64js/protocol';
|
|
2
|
+
import { RpcClient } from '@arm64js/protocol/rpc-client';
|
|
3
|
+
/**
|
|
4
|
+
* How long the frame gets to say hello before the page gives up on it: a frame
|
|
5
|
+
* that never runs a script (blocked third-party frames, a network failure).
|
|
6
|
+
*/
|
|
7
|
+
export declare const FRAME_HANDSHAKE_TIMEOUT_MS = 8000;
|
|
8
|
+
/** The message the developer sees when the browser cannot isolate the frame. */
|
|
9
|
+
export declare const UNSUPPORTED_MESSAGE: string;
|
|
10
|
+
/** Seams for tests: how the iframe is made and how messages arrive. */
|
|
11
|
+
export interface FrameDeps {
|
|
12
|
+
document?: Pick<Document, 'createElement' | 'body'>;
|
|
13
|
+
view?: Pick<Window, 'addEventListener' | 'removeEventListener'>;
|
|
14
|
+
setTimeout?: (cb: () => void, ms: number) => unknown;
|
|
15
|
+
clearTimeout?: (h: unknown) => void;
|
|
16
|
+
makeChannel?: () => {
|
|
17
|
+
port1: MessagePort;
|
|
18
|
+
port2: MessagePort;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export interface FrameHost {
|
|
22
|
+
host: Host;
|
|
23
|
+
client: RpcClient;
|
|
24
|
+
iframe: HTMLIFrameElement;
|
|
25
|
+
/** Fires once when the frame stops answering. */
|
|
26
|
+
onLost(cb: () => void): () => void;
|
|
27
|
+
dispose(): void;
|
|
28
|
+
}
|
|
29
|
+
/** Mount the frame for `frameUrl`, wait for its handshake, hand it a port. */
|
|
30
|
+
export declare function mountFrameHost(url: string, deps?: FrameDeps): Promise<FrameHost>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Frame mode: the runtime in a hidden iframe on the CDN, over a MessagePort.
|
|
2
|
+
// The frame document carries Document-Isolation-Policy, so it is cross-origin
|
|
3
|
+
// isolated whatever this page's headers say — the only way a page that cannot
|
|
4
|
+
// set COOP/COEP gets a VM. Its handshake says whether that worked; a browser
|
|
5
|
+
// without the header comes up unisolated and this reports
|
|
6
|
+
// `unsupported-browser` rather than a VM that would never boot.
|
|
7
|
+
import { Arm64JSError, FRAME_MESSAGE_KIND, FRAME_OP_CONNECT, PROTOCOL_VERSION, } from '@arm64js/protocol';
|
|
8
|
+
import { RpcClient } from '@arm64js/protocol/rpc-client';
|
|
9
|
+
/**
|
|
10
|
+
* How long the frame gets to say hello before the page gives up on it: a frame
|
|
11
|
+
* that never runs a script (blocked third-party frames, a network failure).
|
|
12
|
+
*/
|
|
13
|
+
export const FRAME_HANDSHAKE_TIMEOUT_MS = 8_000;
|
|
14
|
+
/** The message the developer sees when the browser cannot isolate the frame. */
|
|
15
|
+
export const UNSUPPORTED_MESSAGE = 'arm64js needs SharedArrayBuffer, and this browser cannot isolate the VM frame on its own ' +
|
|
16
|
+
'(Document-Isolation-Policy is Chrome/Edge 137+). To run here, serve your page with ' +
|
|
17
|
+
'Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp ' +
|
|
18
|
+
'so that crossOriginIsolated is true; the SDK then runs inline in every browser.';
|
|
19
|
+
/** Mount the frame for `frameUrl`, wait for its handshake, hand it a port. */
|
|
20
|
+
export async function mountFrameHost(url, deps = {}) {
|
|
21
|
+
const doc = deps.document ?? document;
|
|
22
|
+
const view = deps.view ?? window;
|
|
23
|
+
const later = deps.setTimeout ?? ((cb, ms) => setTimeout(cb, ms));
|
|
24
|
+
const cancel = deps.clearTimeout ?? ((h) => clearTimeout(h));
|
|
25
|
+
const origin = new URL(url).origin;
|
|
26
|
+
const iframe = doc.createElement('iframe');
|
|
27
|
+
iframe.src = url;
|
|
28
|
+
iframe.setAttribute('title', 'arm64js');
|
|
29
|
+
iframe.setAttribute('aria-hidden', 'true');
|
|
30
|
+
// In the layout and on screen, never display:none: a hidden frame's timers are
|
|
31
|
+
// throttled by the browser. The VM steps in workers, but the frame's main
|
|
32
|
+
// thread still relays.
|
|
33
|
+
iframe.style.cssText = 'position:fixed;bottom:0;right:0;width:1px;height:1px;border:0;opacity:0;pointer-events:none';
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
let settled = false;
|
|
36
|
+
const finish = (err, value) => {
|
|
37
|
+
if (settled) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
settled = true;
|
|
41
|
+
cancel(timer);
|
|
42
|
+
view.removeEventListener('message', onMessage);
|
|
43
|
+
if (err) {
|
|
44
|
+
iframe.remove();
|
|
45
|
+
reject(err);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
resolve(value);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const timer = later(() => finish(new Arm64JSError('unsupported-browser', `the VM frame did not answer within ${FRAME_HANDSHAKE_TIMEOUT_MS / 1000}s. ${UNSUPPORTED_MESSAGE}`)), FRAME_HANDSHAKE_TIMEOUT_MS);
|
|
52
|
+
const onMessage = (e) => {
|
|
53
|
+
// Only this frame, from the origin we loaded it from, saying the one thing
|
|
54
|
+
// we expect. Anything else on the page can post to this window.
|
|
55
|
+
if (e.source !== iframe.contentWindow) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (e.origin !== origin) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const d = e.data;
|
|
62
|
+
if (!d || d.kind !== FRAME_MESSAGE_KIND || d.v !== 1 || typeof d.isolated !== 'boolean') {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!d.isolated) {
|
|
66
|
+
return finish(new Arm64JSError('unsupported-browser', UNSUPPORTED_MESSAGE));
|
|
67
|
+
}
|
|
68
|
+
if (d.protocol !== PROTOCOL_VERSION) {
|
|
69
|
+
return finish(new Arm64JSError('protocol-mismatch', `this SDK speaks protocol ${PROTOCOL_VERSION} but engine ${String(d.engine)} speaks ${String(d.protocol)}; update the arm64js package`));
|
|
70
|
+
}
|
|
71
|
+
const { port1, port2 } = (deps.makeChannel ?? (() => new MessageChannel()))();
|
|
72
|
+
const connect = { kind: FRAME_MESSAGE_KIND, v: 1, op: FRAME_OP_CONNECT };
|
|
73
|
+
iframe.contentWindow?.postMessage(connect, origin, [port2]);
|
|
74
|
+
const client = new RpcClient(port1);
|
|
75
|
+
finish(null, {
|
|
76
|
+
host: client,
|
|
77
|
+
client,
|
|
78
|
+
iframe,
|
|
79
|
+
onLost: (cb) => client.onLost(cb),
|
|
80
|
+
dispose() {
|
|
81
|
+
client.close();
|
|
82
|
+
iframe.remove();
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
view.addEventListener('message', onMessage);
|
|
87
|
+
doc.body.appendChild(iframe);
|
|
88
|
+
});
|
|
89
|
+
}
|
package/dist/hosts.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Host, type RuntimeModule } from '@arm64js/protocol';
|
|
2
|
+
import { type FrameDeps } from './frame-host.js';
|
|
3
|
+
/** `'inline'` when the VM runs in this page, `'frame'` when in the CDN's frame. */
|
|
4
|
+
export type HostMode = 'inline' | 'frame';
|
|
5
|
+
/** A runtime ready to boot VMs. */
|
|
6
|
+
export interface LoadedHost {
|
|
7
|
+
host: Host;
|
|
8
|
+
mode: HostMode;
|
|
9
|
+
engine: string;
|
|
10
|
+
dispose(): void;
|
|
11
|
+
}
|
|
12
|
+
/** Seams for tests; a page never passes these. */
|
|
13
|
+
export interface HostDeps {
|
|
14
|
+
isolated?: boolean;
|
|
15
|
+
importRuntime?: (url: string) => Promise<RuntimeModule>;
|
|
16
|
+
mountFrame?: (url: string) => Promise<{
|
|
17
|
+
host: Host;
|
|
18
|
+
onLost(cb: () => void): () => void;
|
|
19
|
+
dispose(): void;
|
|
20
|
+
}>;
|
|
21
|
+
frameDeps?: FrameDeps;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Load `engine` inline when this page is cross-origin isolated, in a frame
|
|
25
|
+
* otherwise. `onLost` fires if a frame stops answering.
|
|
26
|
+
*/
|
|
27
|
+
export declare function loadHost(engine: string, deps: HostDeps, onLost: () => void): Promise<LoadedHost>;
|
package/dist/hosts.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Loads the runtime for one engine, in one of two ways. A cross-origin isolated
|
|
2
|
+
// page imports it from the CDN and runs the VM itself. Any other page gets a
|
|
3
|
+
// hidden CDN frame that runs it.
|
|
4
|
+
import { Arm64JSError, PROTOCOL_VERSION } from '@arm64js/protocol';
|
|
5
|
+
import { mountFrameHost } from './frame-host.js';
|
|
6
|
+
import { frameUrl, runtimeModuleUrl } from './loader.js';
|
|
7
|
+
/**
|
|
8
|
+
* Load `engine` inline when this page is cross-origin isolated, in a frame
|
|
9
|
+
* otherwise. `onLost` fires if a frame stops answering.
|
|
10
|
+
*/
|
|
11
|
+
export async function loadHost(engine, deps, onLost) {
|
|
12
|
+
const isolated = deps.isolated ?? Boolean(globalThis.crossOriginIsolated);
|
|
13
|
+
return isolated ? loadInlineHost(engine, deps) : loadFrameHost(engine, deps, onLost);
|
|
14
|
+
}
|
|
15
|
+
async function loadInlineHost(engine, deps) {
|
|
16
|
+
const importer = deps.importRuntime ??
|
|
17
|
+
(async (url) => import(/* @vite-ignore */ /* webpackIgnore: true */ url));
|
|
18
|
+
let mod;
|
|
19
|
+
try {
|
|
20
|
+
mod = await importer(runtimeModuleUrl(engine));
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
throw new Arm64JSError('boot-failed', `could not load engine ${engine} from the CDN: ${String(e)}`);
|
|
24
|
+
}
|
|
25
|
+
if (mod.protocol !== PROTOCOL_VERSION) {
|
|
26
|
+
throw new Arm64JSError('protocol-mismatch', `this SDK speaks protocol ${PROTOCOL_VERSION} but engine ${engine} speaks ${String(mod.protocol)}; update the arm64js package`);
|
|
27
|
+
}
|
|
28
|
+
return { host: mod.createHost(), mode: 'inline', engine, dispose() { } };
|
|
29
|
+
}
|
|
30
|
+
async function loadFrameHost(engine, deps, onLost) {
|
|
31
|
+
const mounter = deps.mountFrame ?? (async (url) => mountFrameHost(url, deps.frameDeps));
|
|
32
|
+
const frame = await mounter(frameUrl(engine));
|
|
33
|
+
frame.onLost(() => {
|
|
34
|
+
// The next call builds a new frame. Tear this one down first: only its
|
|
35
|
+
// main thread stopped answering, and the workers behind it keep stepping
|
|
36
|
+
// a guest with no handle left.
|
|
37
|
+
onLost();
|
|
38
|
+
try {
|
|
39
|
+
frame.dispose();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// A frame already gone from the document has nothing to remove.
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
return { host: frame.host, mode: 'frame', engine, dispose: () => frame.dispose() };
|
|
46
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Arm64JSApi } from './api.js';
|
|
2
|
+
export type { Arm64JSApi } from './api.js';
|
|
3
|
+
export type { HostMode } from './hosts.js';
|
|
4
|
+
export type { MountSource } from './mount.js';
|
|
5
|
+
export { VERSION as version } from './version.js';
|
|
6
|
+
export { Vm, type FileData, type Mount } from './vm.js';
|
|
7
|
+
export type { Arm64JSErrorCode, BootOptions, BootProgress, ExecOptions, ExecResult, OutputInfo, ReadFileOptions, SnapshotInfo, SnapshotOptions, StorageStatus, VmExit, WriteFileOptions, } from '@arm64js/protocol';
|
|
8
|
+
export { Arm64JSError } from '@arm64js/protocol';
|
|
9
|
+
export declare const Arm64JS: Arm64JSApi;
|
|
10
|
+
export default Arm64JS;
|
package/dist/index.js
ADDED
package/dist/loader.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** The engine this package runs: its own major.minor. */
|
|
2
|
+
export declare function packageEngine(): string;
|
|
3
|
+
/** `'0.11'` as `[0, 11]`, or `null` for anything that is not an exact engine tag. */
|
|
4
|
+
export declare function engineParts(tag: string): [number, number] | null;
|
|
5
|
+
/** Whether engine `a` is newer than engine `b`. False unless both are exact tags. */
|
|
6
|
+
export declare function isNewerEngine(a: string, b: string): boolean;
|
|
7
|
+
/** The immutable runtime directory for an exact `X.Y`, with its trailing slash. */
|
|
8
|
+
export declare function runtimeDir(version: string): string;
|
|
9
|
+
export declare function runtimeModuleUrl(version: string): string;
|
|
10
|
+
export declare function frameUrl(version: string): string;
|