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/dist/loader.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Which runtime to load, and from where. `sdk-v<X.Y>/` on the CDN holds an
|
|
2
|
+
// exact, never-rewritten runtime.
|
|
3
|
+
//
|
|
4
|
+
// A page runs the runtime this package was released with, its own `X.Y`. A
|
|
5
|
+
// snapshot saved on another engine can bring that engine's runtime in too.
|
|
6
|
+
import { CDN_BASE } from '@arm64js/protocol';
|
|
7
|
+
import { VERSION } from './version.js';
|
|
8
|
+
/** The engine this package runs: its own major.minor. */
|
|
9
|
+
export function packageEngine() {
|
|
10
|
+
const [major, minor] = VERSION.split('.');
|
|
11
|
+
return `${major}.${minor}`;
|
|
12
|
+
}
|
|
13
|
+
/** `'0.11'` as `[0, 11]`, or `null` for anything that is not an exact engine tag. */
|
|
14
|
+
export function engineParts(tag) {
|
|
15
|
+
const m = /^(\d+)\.(\d+)$/.exec(tag);
|
|
16
|
+
return m ? [Number(m[1]), Number(m[2])] : null;
|
|
17
|
+
}
|
|
18
|
+
/** Whether engine `a` is newer than engine `b`. False unless both are exact tags. */
|
|
19
|
+
export function isNewerEngine(a, b) {
|
|
20
|
+
const x = engineParts(a);
|
|
21
|
+
const y = engineParts(b);
|
|
22
|
+
if (!x || !y) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return x[0] !== y[0] ? x[0] > y[0] : x[1] > y[1];
|
|
26
|
+
}
|
|
27
|
+
/** The immutable runtime directory for an exact `X.Y`, with its trailing slash. */
|
|
28
|
+
export function runtimeDir(version) {
|
|
29
|
+
return `${CDN_BASE}/sdk-v${version}/`;
|
|
30
|
+
}
|
|
31
|
+
export function runtimeModuleUrl(version) {
|
|
32
|
+
return `${runtimeDir(version)}arm64js-sdk-lib.js`;
|
|
33
|
+
}
|
|
34
|
+
export function frameUrl(version) {
|
|
35
|
+
return `${runtimeDir(version)}frame.html`;
|
|
36
|
+
}
|
package/dist/mount.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `mount` takes: a record of file name → Blob, or a list of Files (an
|
|
3
|
+
* `<input type=file>`'s `files`, a drop's).
|
|
4
|
+
*/
|
|
5
|
+
export type MountSource = Record<string, Blob> | Iterable<File> | ArrayLike<File>;
|
|
6
|
+
/**
|
|
7
|
+
* `path` with repeated and trailing slashes dropped, so one folder has one key.
|
|
8
|
+
* The runtime checks the rest.
|
|
9
|
+
*/
|
|
10
|
+
export declare function guestPath(path: string): string;
|
|
11
|
+
/** The record of Blobs the runtime takes. */
|
|
12
|
+
export declare function mountFiles(source: MountSource): Record<string, Blob>;
|
package/dist/mount.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Checks and normalizes what a page passes to `vm.mount`, before anything is
|
|
2
|
+
// sent to the runtime.
|
|
3
|
+
import { Arm64JSError } from '@arm64js/protocol';
|
|
4
|
+
/**
|
|
5
|
+
* `path` with repeated and trailing slashes dropped, so one folder has one key.
|
|
6
|
+
* The runtime checks the rest.
|
|
7
|
+
*/
|
|
8
|
+
export function guestPath(path) {
|
|
9
|
+
if (typeof path !== 'string' || !path.startsWith('/')) {
|
|
10
|
+
throw new Arm64JSError('invalid-input', `the path must be an absolute guest path, not ${JSON.stringify(path)}`);
|
|
11
|
+
}
|
|
12
|
+
return `/${path
|
|
13
|
+
.split('/')
|
|
14
|
+
.filter((p) => p !== '' && p !== '.')
|
|
15
|
+
.join('/')}`;
|
|
16
|
+
}
|
|
17
|
+
/** The record of Blobs the runtime takes. */
|
|
18
|
+
export function mountFiles(source) {
|
|
19
|
+
const files = recordOf(source);
|
|
20
|
+
if (Object.keys(files).length === 0) {
|
|
21
|
+
throw new Arm64JSError('invalid-input', 'mount: no files to share');
|
|
22
|
+
}
|
|
23
|
+
return files;
|
|
24
|
+
}
|
|
25
|
+
function recordOf(source) {
|
|
26
|
+
if (!source || typeof source !== 'object') {
|
|
27
|
+
throw new Arm64JSError('invalid-input', 'mount takes a record of Blobs or a list of Files');
|
|
28
|
+
}
|
|
29
|
+
if (source instanceof Blob) {
|
|
30
|
+
throw new Arm64JSError('invalid-input', 'mount: name the Blob, e.g. { "data.bin": blob }');
|
|
31
|
+
}
|
|
32
|
+
const list = source;
|
|
33
|
+
if (typeof list[Symbol.iterator] === 'function' || typeof list.length === 'number') {
|
|
34
|
+
return filesRecord(source);
|
|
35
|
+
}
|
|
36
|
+
// Checked here: something else (a folder handle) may not even reach the VM's
|
|
37
|
+
// frame, and then no answer would ever come back.
|
|
38
|
+
const proto = Object.getPrototypeOf(source);
|
|
39
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
40
|
+
throw new Arm64JSError('invalid-input', 'mount takes a record of Blobs or a list of Files');
|
|
41
|
+
}
|
|
42
|
+
for (const [name, blob] of Object.entries(source)) {
|
|
43
|
+
if (!(blob instanceof Blob)) {
|
|
44
|
+
throw new Arm64JSError('invalid-input', `mount: ${name} is not a Blob`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return source;
|
|
48
|
+
}
|
|
49
|
+
/** A list of Files as the record the runtime takes, named by `File.name`. */
|
|
50
|
+
function filesRecord(source) {
|
|
51
|
+
const record = Object.create(null);
|
|
52
|
+
for (const file of Array.from(source)) {
|
|
53
|
+
if (!(file instanceof Blob) || typeof file.name !== 'string') {
|
|
54
|
+
throw new Arm64JSError('invalid-input', 'mount: every item in a list must be a File');
|
|
55
|
+
}
|
|
56
|
+
if (file.name in record) {
|
|
57
|
+
throw new Arm64JSError('invalid-input', `mount: two files are named ${file.name}`);
|
|
58
|
+
}
|
|
59
|
+
record[file.name] = file;
|
|
60
|
+
}
|
|
61
|
+
return record;
|
|
62
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type BootOptions, type SnapshotInfo, type StorageStatus } from '@arm64js/protocol';
|
|
2
|
+
import { type HostDeps, type HostMode, type LoadedHost } from './hosts.js';
|
|
3
|
+
import { Vm } from './vm.js';
|
|
4
|
+
/** Seams for tests; a page never passes these. */
|
|
5
|
+
export interface Arm64JSDeps extends HostDeps {
|
|
6
|
+
/** The page's own engine (default: this package's). */
|
|
7
|
+
engine?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The runtimes for this page: its own engine, loaded on the first call, and
|
|
11
|
+
* any other a snapshot needs.
|
|
12
|
+
*/
|
|
13
|
+
export declare class Runtime {
|
|
14
|
+
private readonly deps;
|
|
15
|
+
private readonly defaultEngine;
|
|
16
|
+
private readonly engines;
|
|
17
|
+
private readonly vms;
|
|
18
|
+
constructor(deps?: Arm64JSDeps);
|
|
19
|
+
load(engine?: string): Promise<LoadedHost>;
|
|
20
|
+
/** Forget a frame that stopped answering, and end the VMs that ran in it. */
|
|
21
|
+
private lost;
|
|
22
|
+
boot(id: string, opts?: BootOptions): Promise<Vm>;
|
|
23
|
+
private bootOn;
|
|
24
|
+
mode(): Promise<HostMode>;
|
|
25
|
+
engine(): Promise<string>;
|
|
26
|
+
listSnapshots(): Promise<SnapshotInfo[]>;
|
|
27
|
+
getSnapshot(id: string): Promise<SnapshotInfo | null>;
|
|
28
|
+
removeSnapshot(id: string, opts?: {
|
|
29
|
+
force?: boolean;
|
|
30
|
+
}): Promise<void>;
|
|
31
|
+
storageStatus(): Promise<StorageStatus>;
|
|
32
|
+
shutdown(): Promise<void>;
|
|
33
|
+
}
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Arm64JSError } from '@arm64js/protocol';
|
|
2
|
+
import { withSdkErrors } from './errors.js';
|
|
3
|
+
import { loadHost } from './hosts.js';
|
|
4
|
+
import { engineParts, isNewerEngine, packageEngine } from './loader.js';
|
|
5
|
+
import { Vm } from './vm.js';
|
|
6
|
+
/**
|
|
7
|
+
* The runtimes for this page: its own engine, loaded on the first call, and
|
|
8
|
+
* any other a snapshot needs.
|
|
9
|
+
*/
|
|
10
|
+
export class Runtime {
|
|
11
|
+
deps;
|
|
12
|
+
defaultEngine;
|
|
13
|
+
engines = new Map();
|
|
14
|
+
vms = new Set();
|
|
15
|
+
constructor(deps = {}) {
|
|
16
|
+
this.deps = deps;
|
|
17
|
+
this.defaultEngine = deps.engine ?? packageEngine();
|
|
18
|
+
}
|
|
19
|
+
async load(engine = this.defaultEngine) {
|
|
20
|
+
const loading = this.engines.get(engine);
|
|
21
|
+
if (loading) {
|
|
22
|
+
return loading;
|
|
23
|
+
}
|
|
24
|
+
const next = loadHost(engine, this.deps, () => this.lost(engine, next)).catch((e) => {
|
|
25
|
+
if (this.engines.get(engine) === next) {
|
|
26
|
+
this.engines.delete(engine);
|
|
27
|
+
}
|
|
28
|
+
throw e;
|
|
29
|
+
});
|
|
30
|
+
this.engines.set(engine, next);
|
|
31
|
+
return next;
|
|
32
|
+
}
|
|
33
|
+
/** Forget a frame that stopped answering, and end the VMs that ran in it. */
|
|
34
|
+
lost(engine, loading) {
|
|
35
|
+
if (this.engines.get(engine) !== loading) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
this.engines.delete(engine);
|
|
39
|
+
for (const vm of this.vms) {
|
|
40
|
+
if (vm.engine === engine) {
|
|
41
|
+
this.vms.delete(vm);
|
|
42
|
+
vm.lost();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async boot(id, opts) {
|
|
47
|
+
if (typeof id !== 'string') {
|
|
48
|
+
throw new Arm64JSError('invalid-input', 'boot takes an image name or a snapshot id');
|
|
49
|
+
}
|
|
50
|
+
const defaultHost = await this.load();
|
|
51
|
+
if (!isSnapshotId(id)) {
|
|
52
|
+
return this.bootOn(defaultHost, id, opts);
|
|
53
|
+
}
|
|
54
|
+
// The engine the snapshot was saved on. An unreadable record is left to the
|
|
55
|
+
// boot below to report.
|
|
56
|
+
const saved = (await defaultHost.host.getSnapshot(id).catch(() => null))?.engine;
|
|
57
|
+
const other = saved && saved !== defaultHost.engine && engineParts(saved) ? saved : null;
|
|
58
|
+
// Saved on a newer engine: use it. Saved on an older one: try ours first.
|
|
59
|
+
if (other && isNewerEngine(other, defaultHost.engine)) {
|
|
60
|
+
return this.bootOn(await this.load(other), id, opts);
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
return await this.bootOn(defaultHost, id, opts);
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
// A changed snapshot format: only the engine it was saved on can resume it.
|
|
67
|
+
if (!other || e.code !== 'engine-mismatch') {
|
|
68
|
+
throw e;
|
|
69
|
+
}
|
|
70
|
+
return this.bootOn(await this.load(other), id, opts);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async bootOn(loaded, id, opts) {
|
|
74
|
+
const { vmId } = await withSdkErrors(loaded.host.boot(id, opts));
|
|
75
|
+
const vm = new Vm(vmId, loaded.host, loaded.engine, (v) => this.vms.delete(v));
|
|
76
|
+
this.vms.add(vm);
|
|
77
|
+
return vm;
|
|
78
|
+
}
|
|
79
|
+
async mode() {
|
|
80
|
+
return (await this.load()).mode;
|
|
81
|
+
}
|
|
82
|
+
async engine() {
|
|
83
|
+
return (await this.load()).engine;
|
|
84
|
+
}
|
|
85
|
+
async listSnapshots() {
|
|
86
|
+
return withSdkErrors((await this.load()).host.listSnapshots());
|
|
87
|
+
}
|
|
88
|
+
async getSnapshot(id) {
|
|
89
|
+
return withSdkErrors((await this.load()).host.getSnapshot(id));
|
|
90
|
+
}
|
|
91
|
+
async removeSnapshot(id, opts) {
|
|
92
|
+
return withSdkErrors((await this.load()).host.removeSnapshot(id, opts));
|
|
93
|
+
}
|
|
94
|
+
async storageStatus() {
|
|
95
|
+
return withSdkErrors((await this.load()).host.storageStatus());
|
|
96
|
+
}
|
|
97
|
+
async shutdown() {
|
|
98
|
+
const loads = [...this.engines];
|
|
99
|
+
if (loads.length === 0) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// Wait for loads in flight, so no frame appears after shutdown.
|
|
103
|
+
const loaded = await Promise.all(loads.map(async ([, p]) => p.catch(() => null)));
|
|
104
|
+
for (const vm of [...this.vms]) {
|
|
105
|
+
await vm.dispose().catch(() => { });
|
|
106
|
+
}
|
|
107
|
+
this.vms.clear();
|
|
108
|
+
for (const l of loaded) {
|
|
109
|
+
l?.dispose();
|
|
110
|
+
}
|
|
111
|
+
for (const [engine, p] of loads) {
|
|
112
|
+
if (this.engines.get(engine) === p) {
|
|
113
|
+
this.engines.delete(engine);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** A local snapshot id, as the runtime spells it. */
|
|
119
|
+
function isSnapshotId(s) {
|
|
120
|
+
return /^[0-9a-f]{64}$/.test(s);
|
|
121
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Terminal, type ITerminalOptions } from '@xterm/xterm';
|
|
2
|
+
import type { Vm } from './vm.js';
|
|
3
|
+
export interface TerminalOptions {
|
|
4
|
+
/** Size the terminal to its element, now and whenever the element resizes
|
|
5
|
+
* (default true). Off, the terminal keeps `xterm.cols`/`xterm.rows`. */
|
|
6
|
+
fit?: boolean;
|
|
7
|
+
/** Passed to xterm's `Terminal`. */
|
|
8
|
+
xterm?: ITerminalOptions;
|
|
9
|
+
}
|
|
10
|
+
export interface AttachedTerminal {
|
|
11
|
+
/** The xterm instance, for themes, focus and addons. */
|
|
12
|
+
readonly xterm: Terminal;
|
|
13
|
+
/** Refit to the element now. */
|
|
14
|
+
fit(): void;
|
|
15
|
+
/** Detach from the VM and remove the terminal. The VM keeps running. */
|
|
16
|
+
dispose(): void;
|
|
17
|
+
}
|
|
18
|
+
/** Show a VM's console in `element` and send what is typed there to the guest. */
|
|
19
|
+
export declare function attachTerminal(vm: Vm, element: HTMLElement, opts?: TerminalOptions): AttachedTerminal;
|
package/dist/terminal.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// An optional terminal for a VM, on xterm.js:
|
|
2
|
+
//
|
|
3
|
+
// import { attachTerminal } from 'arm64js/terminal';
|
|
4
|
+
// import '@xterm/xterm/css/xterm.css';
|
|
5
|
+
// const term = attachTerminal(vm, document.getElementById('term')!);
|
|
6
|
+
//
|
|
7
|
+
// A separate entry point, so a page that never imports it carries no xterm.
|
|
8
|
+
// `@xterm/xterm` and `@xterm/addon-fit` are optional peer dependencies.
|
|
9
|
+
import { FitAddon } from '@xterm/addon-fit';
|
|
10
|
+
import { Terminal } from '@xterm/xterm';
|
|
11
|
+
/**
|
|
12
|
+
* How long the element must stay one size before the guest hears it, so a
|
|
13
|
+
* drag repaints a full-screen program once rather than per frame.
|
|
14
|
+
*/
|
|
15
|
+
const RESIZE_SETTLE_MS = 100;
|
|
16
|
+
/** Show a VM's console in `element` and send what is typed there to the guest. */
|
|
17
|
+
export function attachTerminal(vm, element, opts = {}) {
|
|
18
|
+
const xterm = new Terminal({ convertEol: true, cursorBlink: true, ...opts.xterm });
|
|
19
|
+
const fitAddon = new FitAddon();
|
|
20
|
+
const fitting = opts.fit ?? true;
|
|
21
|
+
if (fitting) {
|
|
22
|
+
xterm.loadAddon(fitAddon);
|
|
23
|
+
}
|
|
24
|
+
xterm.open(element);
|
|
25
|
+
const report = (e) => globalThis.console?.warn?.('[arm64js] terminal', e);
|
|
26
|
+
// What an `exec` prints is its caller's, not the person's at the keyboard.
|
|
27
|
+
const unsubscribeOutput = vm.onOutput((bytes, info) => {
|
|
28
|
+
if (!info.exec) {
|
|
29
|
+
xterm.write(bytes);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
const unsubscribeExit = vm.onExit(({ reason }) => {
|
|
33
|
+
xterm.options.disableStdin = true;
|
|
34
|
+
xterm.write(`\r\n[the VM stopped: ${reason}]\r\n`);
|
|
35
|
+
});
|
|
36
|
+
const inputSubscription = xterm.onData((data) => void vm.write(data).catch(report));
|
|
37
|
+
let resizeTimer = null;
|
|
38
|
+
let lastSentSize = '';
|
|
39
|
+
const sendSizeToGuest = () => {
|
|
40
|
+
const size = `${xterm.cols}x${xterm.rows}`;
|
|
41
|
+
if (size === lastSentSize) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
lastSentSize = size;
|
|
45
|
+
void vm.resize(xterm.cols, xterm.rows).catch(report);
|
|
46
|
+
};
|
|
47
|
+
const resizeSubscription = xterm.onResize(() => {
|
|
48
|
+
if (resizeTimer !== null) {
|
|
49
|
+
clearTimeout(resizeTimer);
|
|
50
|
+
}
|
|
51
|
+
resizeTimer = setTimeout(() => {
|
|
52
|
+
resizeTimer = null;
|
|
53
|
+
sendSizeToGuest();
|
|
54
|
+
}, RESIZE_SETTLE_MS);
|
|
55
|
+
});
|
|
56
|
+
const fit = () => {
|
|
57
|
+
if (fitting) {
|
|
58
|
+
fitAddon.fit();
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const observer = fitting && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => fit()) : null;
|
|
62
|
+
observer?.observe(element);
|
|
63
|
+
fit();
|
|
64
|
+
sendSizeToGuest();
|
|
65
|
+
// The guest's prompt was printed before this terminal existed; an empty line
|
|
66
|
+
// brings up a fresh one.
|
|
67
|
+
void vm.write('\r').catch(report);
|
|
68
|
+
let disposed = false;
|
|
69
|
+
return {
|
|
70
|
+
xterm,
|
|
71
|
+
fit,
|
|
72
|
+
dispose() {
|
|
73
|
+
if (disposed) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
disposed = true;
|
|
77
|
+
if (resizeTimer !== null) {
|
|
78
|
+
clearTimeout(resizeTimer);
|
|
79
|
+
}
|
|
80
|
+
observer?.disconnect();
|
|
81
|
+
unsubscribeOutput();
|
|
82
|
+
unsubscribeExit();
|
|
83
|
+
inputSubscription.dispose();
|
|
84
|
+
resizeSubscription.dispose();
|
|
85
|
+
xterm.dispose();
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const VERSION = "0.4.0";
|
package/dist/version.js
ADDED
package/dist/vm.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type ExecOptions, type ExecResult, type OutputInfo, type ReadFileOptions, type SnapshotInfo, type SnapshotOptions, type VmExit, type WriteFileOptions } from '@arm64js/protocol';
|
|
2
|
+
import { type MountSource } from './mount.js';
|
|
3
|
+
/** What `writeFile` takes. */
|
|
4
|
+
export type FileData = Blob | BufferSource | string;
|
|
5
|
+
/** A live `mount`. */
|
|
6
|
+
export interface Mount {
|
|
7
|
+
/** Where the files are in the guest. */
|
|
8
|
+
readonly path: string;
|
|
9
|
+
/** Remove the mount. Idempotent. */
|
|
10
|
+
unmount(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
/** A running VM. */
|
|
13
|
+
export declare class Vm {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
private readonly host;
|
|
16
|
+
/** The engine this VM runs on (`'0.11'`). */
|
|
17
|
+
readonly engine: string;
|
|
18
|
+
private readonly onDisposed;
|
|
19
|
+
private disposed;
|
|
20
|
+
private frameLost;
|
|
21
|
+
private readonly exitListeners;
|
|
22
|
+
/** The latest `mount` at each path, so an old handle cannot undo a newer one. */
|
|
23
|
+
private readonly mounts;
|
|
24
|
+
/** Run a shell command (one or more lines) in the guest and get its output and
|
|
25
|
+
* exit code. One at a time per VM. */
|
|
26
|
+
exec(command: string, opts?: ExecOptions): Promise<ExecResult>;
|
|
27
|
+
/** The raw bytes the guest prints on its console. `info.exec` marks what an
|
|
28
|
+
* `exec` printed (its command, output and prompts). Returns the unsubscribe. */
|
|
29
|
+
onOutput(cb: (bytes: Uint8Array, info: OutputInfo) => void): () => void;
|
|
30
|
+
/** Type into the guest's console, as a person at a terminal would. Input
|
|
31
|
+
* sent while an `exec` or `snapshot` runs is held and delivered after it. */
|
|
32
|
+
write(data: string): Promise<void>;
|
|
33
|
+
/** Set the guest's terminal size, so full-screen programs lay out for it. */
|
|
34
|
+
resize(cols: number, rows: number): Promise<void>;
|
|
35
|
+
/** The guest's run ending (a halt, a fault). Returns the unsubscribe. */
|
|
36
|
+
onExit(cb: (exit: VmExit) => void): () => void;
|
|
37
|
+
/** Show files to the guest, read-only, in the folder `path` (an absolute
|
|
38
|
+
* guest path, created if missing). A file is read only as the guest asks, so
|
|
39
|
+
* its size does not matter. Mounts are not kept in snapshots: mount again
|
|
40
|
+
* after booting one. */
|
|
41
|
+
mount(source: MountSource, path: string): Promise<Mount>;
|
|
42
|
+
/** Undo the `mount` at `path`. Idempotent. */
|
|
43
|
+
unmount(path: string): Promise<void>;
|
|
44
|
+
/** Copy `data` into the guest as the file `path`, replacing it if it exists and
|
|
45
|
+
* creating its folder if missing. The file lives in guest memory: for a large
|
|
46
|
+
* one, `mount` it instead. */
|
|
47
|
+
writeFile(path: string, data: FileData, opts?: WriteFileOptions): Promise<void>;
|
|
48
|
+
/** Copy the guest's file `path` out, as a `Blob`. The copy is held in page
|
|
49
|
+
* memory. */
|
|
50
|
+
readFile(path: string, opts?: ReadFileOptions): Promise<Blob>;
|
|
51
|
+
/** Save the VM to this browser's storage. The VM keeps running. */
|
|
52
|
+
snapshot(opts?: SnapshotOptions): Promise<SnapshotInfo>;
|
|
53
|
+
/** Stop the VM and free its workers. Idempotent. */
|
|
54
|
+
dispose(): Promise<void>;
|
|
55
|
+
private check;
|
|
56
|
+
}
|
package/dist/vm.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { Arm64JSError, } from '@arm64js/protocol';
|
|
2
|
+
import { withSdkErrors } from './errors.js';
|
|
3
|
+
import { guestPath, mountFiles } from './mount.js';
|
|
4
|
+
/** The `onExit` a VM gets when its frame stops answering. */
|
|
5
|
+
const LOST_EXIT = { reason: 'lost' };
|
|
6
|
+
/** A running VM. */
|
|
7
|
+
export class Vm {
|
|
8
|
+
id;
|
|
9
|
+
host;
|
|
10
|
+
engine;
|
|
11
|
+
onDisposed;
|
|
12
|
+
disposed = false;
|
|
13
|
+
frameLost = false;
|
|
14
|
+
exitListeners = new Set();
|
|
15
|
+
/** The latest `mount` at each path, so an old handle cannot undo a newer one. */
|
|
16
|
+
mounts = new Map();
|
|
17
|
+
/** @internal */
|
|
18
|
+
constructor(id, host,
|
|
19
|
+
/** The engine this VM runs on (`'0.11'`). */
|
|
20
|
+
engine, onDisposed) {
|
|
21
|
+
this.id = id;
|
|
22
|
+
this.host = host;
|
|
23
|
+
this.engine = engine;
|
|
24
|
+
this.onDisposed = onDisposed;
|
|
25
|
+
}
|
|
26
|
+
/** Run a shell command (one or more lines) in the guest and get its output and
|
|
27
|
+
* exit code. One at a time per VM. */
|
|
28
|
+
async exec(command, opts) {
|
|
29
|
+
this.check();
|
|
30
|
+
return withSdkErrors(this.host.exec(this.id, command, opts));
|
|
31
|
+
}
|
|
32
|
+
/** The raw bytes the guest prints on its console. `info.exec` marks what an
|
|
33
|
+
* `exec` printed (its command, output and prompts). Returns the unsubscribe. */
|
|
34
|
+
onOutput(cb) {
|
|
35
|
+
this.check();
|
|
36
|
+
return this.host.onOutput(this.id, cb);
|
|
37
|
+
}
|
|
38
|
+
/** Type into the guest's console, as a person at a terminal would. Input
|
|
39
|
+
* sent while an `exec` or `snapshot` runs is held and delivered after it. */
|
|
40
|
+
async write(data) {
|
|
41
|
+
this.check();
|
|
42
|
+
return withSdkErrors(this.host.write(this.id, data));
|
|
43
|
+
}
|
|
44
|
+
/** Set the guest's terminal size, so full-screen programs lay out for it. */
|
|
45
|
+
async resize(cols, rows) {
|
|
46
|
+
this.check();
|
|
47
|
+
return withSdkErrors(this.host.resize(this.id, cols, rows));
|
|
48
|
+
}
|
|
49
|
+
/** The guest's run ending (a halt, a fault). Returns the unsubscribe. */
|
|
50
|
+
onExit(cb) {
|
|
51
|
+
this.check();
|
|
52
|
+
if (this.frameLost) {
|
|
53
|
+
queueMicrotask(() => cb(LOST_EXIT));
|
|
54
|
+
return () => { };
|
|
55
|
+
}
|
|
56
|
+
const unsubscribe = this.host.onExit(this.id, cb);
|
|
57
|
+
this.exitListeners.add(cb);
|
|
58
|
+
return () => {
|
|
59
|
+
unsubscribe();
|
|
60
|
+
this.exitListeners.delete(cb);
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Show files to the guest, read-only, in the folder `path` (an absolute
|
|
64
|
+
* guest path, created if missing). A file is read only as the guest asks, so
|
|
65
|
+
* its size does not matter. Mounts are not kept in snapshots: mount again
|
|
66
|
+
* after booting one. */
|
|
67
|
+
async mount(source, path) {
|
|
68
|
+
this.check();
|
|
69
|
+
const guestDir = guestPath(path);
|
|
70
|
+
const files = mountFiles(source);
|
|
71
|
+
if (this.mounts.has(guestDir)) {
|
|
72
|
+
throw new Arm64JSError('invalid-input', `mount: ${guestDir} is already mounted, unmount it first`);
|
|
73
|
+
}
|
|
74
|
+
// Taken before the call, so a second mount here is refused while this one runs.
|
|
75
|
+
const token = {};
|
|
76
|
+
this.mounts.set(guestDir, token);
|
|
77
|
+
const handle = {
|
|
78
|
+
path: guestDir,
|
|
79
|
+
unmount: async () => {
|
|
80
|
+
if (this.mounts.get(guestDir) === token) {
|
|
81
|
+
await this.unmount(guestDir);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
try {
|
|
86
|
+
await withSdkErrors(this.host.mount(this.id, files, guestDir));
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
// An unmount meanwhile may have let a newer mount take the path.
|
|
90
|
+
if (this.mounts.get(guestDir) === token) {
|
|
91
|
+
this.mounts.delete(guestDir);
|
|
92
|
+
}
|
|
93
|
+
throw e;
|
|
94
|
+
}
|
|
95
|
+
return handle;
|
|
96
|
+
}
|
|
97
|
+
/** Undo the `mount` at `path`. Idempotent. */
|
|
98
|
+
async unmount(path) {
|
|
99
|
+
this.check();
|
|
100
|
+
const guestDir = guestPath(path);
|
|
101
|
+
const token = this.mounts.get(guestDir);
|
|
102
|
+
await withSdkErrors(this.host.unmount(this.id, guestDir));
|
|
103
|
+
// A mount started meanwhile keeps its token.
|
|
104
|
+
if (this.mounts.get(guestDir) === token) {
|
|
105
|
+
this.mounts.delete(guestDir);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Copy `data` into the guest as the file `path`, replacing it if it exists and
|
|
109
|
+
* creating its folder if missing. The file lives in guest memory: for a large
|
|
110
|
+
* one, `mount` it instead. */
|
|
111
|
+
async writeFile(path, data, opts) {
|
|
112
|
+
this.check();
|
|
113
|
+
let blob;
|
|
114
|
+
if (data instanceof Blob) {
|
|
115
|
+
blob = data;
|
|
116
|
+
}
|
|
117
|
+
else if (typeof data === 'string' || data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
|
|
118
|
+
blob = new Blob([data]);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
throw new Arm64JSError('invalid-input', 'writeFile takes a Blob, bytes or a string');
|
|
122
|
+
}
|
|
123
|
+
return withSdkErrors(this.host.writeFile(this.id, path, blob, opts));
|
|
124
|
+
}
|
|
125
|
+
/** Copy the guest's file `path` out, as a `Blob`. The copy is held in page
|
|
126
|
+
* memory. */
|
|
127
|
+
async readFile(path, opts) {
|
|
128
|
+
this.check();
|
|
129
|
+
return withSdkErrors(this.host.readFile(this.id, path, opts));
|
|
130
|
+
}
|
|
131
|
+
/** Save the VM to this browser's storage. The VM keeps running. */
|
|
132
|
+
async snapshot(opts) {
|
|
133
|
+
this.check();
|
|
134
|
+
return withSdkErrors(this.host.snapshot(this.id, opts));
|
|
135
|
+
}
|
|
136
|
+
/** Stop the VM and free its workers. Idempotent. */
|
|
137
|
+
async dispose() {
|
|
138
|
+
if (this.disposed) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
this.disposed = true;
|
|
142
|
+
this.onDisposed(this);
|
|
143
|
+
await withSdkErrors(this.host.dispose(this.id));
|
|
144
|
+
}
|
|
145
|
+
/** @internal The frame this VM ran in stopped answering. */
|
|
146
|
+
lost() {
|
|
147
|
+
if (this.frameLost) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
this.frameLost = true;
|
|
151
|
+
for (const cb of [...this.exitListeners]) {
|
|
152
|
+
cb(LOST_EXIT);
|
|
153
|
+
}
|
|
154
|
+
this.exitListeners.clear();
|
|
155
|
+
}
|
|
156
|
+
check() {
|
|
157
|
+
if (this.disposed) {
|
|
158
|
+
throw new Arm64JSError('vm-lost', 'this VM was disposed');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "arm64js",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Run a Linux VM inside your web page. Boots Alpine from the arm64js CDN, runs commands, keeps snapshots in the browser.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": "github:kooler/arm64js-sdk",
|
|
8
|
+
"homepage": "https://arm64js.com",
|
|
9
|
+
"sideEffects": false,
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./terminal": {
|
|
16
|
+
"types": "./dist/terminal.d.ts",
|
|
17
|
+
"default": "./dist/terminal.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"stamp": "node scripts/stamp-version.mjs",
|
|
26
|
+
"build:protocol": "npm run build -w @arm64js/protocol",
|
|
27
|
+
"build": "npm run stamp && npm run build:protocol && node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json",
|
|
28
|
+
"typecheck": "npm run stamp && npm run build:protocol && tsc --noEmit",
|
|
29
|
+
"test": "npm run stamp && npm run build:protocol && vitest run",
|
|
30
|
+
"format": "prettier --write .",
|
|
31
|
+
"format:check": "prettier --check .",
|
|
32
|
+
"prepack": "npm run build",
|
|
33
|
+
"lint": "npm run stamp && npm run build:protocol && eslint .",
|
|
34
|
+
"prepare": "npm run stamp && npm run build:protocol && husky"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@arm64js/protocol": "^1.1.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@eslint/js": "^10.0.1",
|
|
41
|
+
"@types/node": "^26.6.0",
|
|
42
|
+
"@xterm/addon-fit": "^0.11.0",
|
|
43
|
+
"@xterm/xterm": "^6.0.0",
|
|
44
|
+
"eslint": "^10.10.0",
|
|
45
|
+
"eslint-config-prettier": "^10.1.8",
|
|
46
|
+
"eslint-plugin-perfectionist": "^5.11.1",
|
|
47
|
+
"globals": "^17.12.0",
|
|
48
|
+
"husky": "^9.1.7",
|
|
49
|
+
"lint-staged": "^17.5.1",
|
|
50
|
+
"prettier": "3.9.6",
|
|
51
|
+
"typescript": "^5.6.0",
|
|
52
|
+
"typescript-eslint": "^8.70.0",
|
|
53
|
+
"vitest": "^2.1.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"@xterm/addon-fit": "^0.10.0 || ^0.11.0",
|
|
57
|
+
"@xterm/xterm": "^5.5.0 || ^6.0.0"
|
|
58
|
+
},
|
|
59
|
+
"peerDependenciesMeta": {
|
|
60
|
+
"@xterm/addon-fit": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
63
|
+
"@xterm/xterm": {
|
|
64
|
+
"optional": true
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"workspaces": [
|
|
68
|
+
"protocol"
|
|
69
|
+
],
|
|
70
|
+
"lint-staged": {
|
|
71
|
+
"*.{ts,js,mjs}": [
|
|
72
|
+
"eslint --fix",
|
|
73
|
+
"prettier --write"
|
|
74
|
+
],
|
|
75
|
+
"!(*.{ts,js,mjs})": [
|
|
76
|
+
"prettier --write --ignore-unknown"
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
}
|