@supermachine/core 0.4.25
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 +202 -0
- package/README.md +407 -0
- package/examples/01-hello.mjs +32 -0
- package/examples/02-pool.mjs +54 -0
- package/examples/03-snapshot-then-restore.mjs +54 -0
- package/examples/04-expose-tcp.mjs +80 -0
- package/examples/05-spawn-streaming.mjs +59 -0
- package/examples/README.md +43 -0
- package/index.d.ts +532 -0
- package/index.js +290 -0
- package/package.json +59 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// 02-pool.mjs — keep a warm pool, dispatch N concurrent commands.
|
|
2
|
+
//
|
|
3
|
+
// Demonstrates:
|
|
4
|
+
// - `min`/`max` pool sizing (eager pre-spawn + auto-grow)
|
|
5
|
+
// - Concurrent acquire(): pool serializes correctly under load
|
|
6
|
+
// - `pool.stats()` for observability / autoscaling decisions
|
|
7
|
+
//
|
|
8
|
+
// Run:
|
|
9
|
+
// node examples/02-pool.mjs
|
|
10
|
+
|
|
11
|
+
import { Image } from "@supermachine/core";
|
|
12
|
+
|
|
13
|
+
const img = await Image.build({
|
|
14
|
+
ref: "alpine:latest",
|
|
15
|
+
name: "pool-demo",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Pre-spawn 4 VMs; grow up to 8 under load.
|
|
19
|
+
const pool = await img.pool({
|
|
20
|
+
min: 4,
|
|
21
|
+
max: 8,
|
|
22
|
+
// Per-cycle clean state on release. Trade ~3 ms cycle-restore
|
|
23
|
+
// for guaranteed fresh /tmp etc. across acquires.
|
|
24
|
+
restoreOnRelease: true,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
console.log("pool stats (idle):", pool.stats());
|
|
28
|
+
// → { alive: 4, inUse: 0, idle: 4, waiting: 0, min: 4, max: 8 }
|
|
29
|
+
|
|
30
|
+
// Launch 16 concurrent commands; pool will serve from 4 idle
|
|
31
|
+
// slots, grow to 8, then queue the remaining 8 fairly.
|
|
32
|
+
const work = Array.from({ length: 16 }, async (_, i) => {
|
|
33
|
+
const vm = await pool.acquire();
|
|
34
|
+
try {
|
|
35
|
+
const { stdout } = await vm.exec({
|
|
36
|
+
argv: ["sh", "-c", `echo "task ${i} on $(hostname)"`],
|
|
37
|
+
timeoutMs: 5_000,
|
|
38
|
+
});
|
|
39
|
+
return stdout.toString().trim();
|
|
40
|
+
} finally {
|
|
41
|
+
await vm.release();
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Snapshot stats mid-flight so the user can see the queue depth.
|
|
46
|
+
setTimeout(() => {
|
|
47
|
+
console.log("pool stats (mid-flight):", pool.stats());
|
|
48
|
+
}, 50);
|
|
49
|
+
|
|
50
|
+
const outputs = await Promise.all(work);
|
|
51
|
+
console.log("\nall results:");
|
|
52
|
+
for (const line of outputs) console.log(" ", line);
|
|
53
|
+
|
|
54
|
+
console.log("\npool stats (drained):", pool.stats());
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// 03-snapshot-then-restore.mjs — capture mid-flight VM state,
|
|
2
|
+
// reload it later for instant warm starts.
|
|
3
|
+
//
|
|
4
|
+
// The story: you have a slow setup phase (compile a seed program,
|
|
5
|
+
// warm a page cache, fetch a model). Do it ONCE, snapshot, then
|
|
6
|
+
// restore the post-warmup state on every subsequent run in <50 ms.
|
|
7
|
+
//
|
|
8
|
+
// Run:
|
|
9
|
+
// node examples/03-snapshot-then-restore.mjs
|
|
10
|
+
|
|
11
|
+
import { Image } from "@supermachine/core";
|
|
12
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
const img = await Image.build({
|
|
17
|
+
ref: "alpine:latest",
|
|
18
|
+
name: "snapshot-demo",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Phase 1: do expensive setup in a regular VM.
|
|
22
|
+
const pool = await img.pool({ min: 1, max: 1 });
|
|
23
|
+
const vm = await pool.acquire();
|
|
24
|
+
|
|
25
|
+
// Simulate setup: drop a "computed" file into the guest. In a real
|
|
26
|
+
// workload this might be `apk add gcc && rustc -O example.rs`.
|
|
27
|
+
await vm.writeFile(
|
|
28
|
+
"/etc/seed.txt",
|
|
29
|
+
Buffer.from("expensive setup result: 0xCAFEBABE\n"),
|
|
30
|
+
);
|
|
31
|
+
console.log("phase 1: setup complete, file written to guest");
|
|
32
|
+
|
|
33
|
+
// Capture the post-setup state. Returns a new Image pointing at
|
|
34
|
+
// a freshly-written restore.snap.
|
|
35
|
+
const captureDir = mkdtempSync(join(tmpdir(), "sm-snap-"));
|
|
36
|
+
const captured = await vm.snapshot(captureDir);
|
|
37
|
+
console.log(`phase 2: snapshot captured (${captured.memoryMib} MiB)`);
|
|
38
|
+
console.log(` path: ${captured.snapshotPath}`);
|
|
39
|
+
|
|
40
|
+
await vm.release();
|
|
41
|
+
|
|
42
|
+
// Phase 3: a brand-new VM from the captured state has the file
|
|
43
|
+
// already there — no need to rerun setup.
|
|
44
|
+
const restored = await Image.fromSnapshot(captured.snapshotPath);
|
|
45
|
+
const pool2 = await restored.pool({ min: 1, max: 1 });
|
|
46
|
+
const vm2 = await pool2.acquire();
|
|
47
|
+
|
|
48
|
+
const seed = await vm2.readFile("/etc/seed.txt");
|
|
49
|
+
console.log("phase 3: restored VM reads back seed →", seed.toString().trim());
|
|
50
|
+
|
|
51
|
+
await vm2.release();
|
|
52
|
+
|
|
53
|
+
// Tidy up the captured snapshot.
|
|
54
|
+
rmSync(captureDir, { recursive: true, force: true });
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// 04-expose-tcp.mjs — bake a service image, expose it as a local
|
|
2
|
+
// TCP port, connect from the host.
|
|
3
|
+
//
|
|
4
|
+
// Demonstrates the `TcpForwarder` API surface:
|
|
5
|
+
// - `vm.exposeTcp(hostPort, guestPort)` — TCP→vsock proxy
|
|
6
|
+
// - `TcpForwarder.localAddr` getter
|
|
7
|
+
// - `TcpForwarder.stop()` (idempotent; in-flight connections survive)
|
|
8
|
+
//
|
|
9
|
+
// The example bakes an alpine "echo server": `nc -l -p 8080` in a
|
|
10
|
+
// loop. We then connect from the host and verify the forwarder
|
|
11
|
+
// landed at a listening port. Getting bidirectional data through
|
|
12
|
+
// busybox `nc` is finicky across alpine releases — for a real
|
|
13
|
+
// service workload (nginx, redis, your own app), the
|
|
14
|
+
// TcpForwarder API stays identical and the data path works
|
|
15
|
+
// without any of the BusyBox-nc gymnastics here.
|
|
16
|
+
//
|
|
17
|
+
// Run:
|
|
18
|
+
// node examples/04-expose-tcp.mjs
|
|
19
|
+
|
|
20
|
+
import { Image } from "@supermachine/core";
|
|
21
|
+
import { createConnection } from "node:net";
|
|
22
|
+
|
|
23
|
+
const img = await Image.build({
|
|
24
|
+
ref: "alpine:latest",
|
|
25
|
+
name: "expose-tcp-demo",
|
|
26
|
+
cmd: ["sh", "-c", "while true; do nc -l -p 8080; done"],
|
|
27
|
+
guestPort: 8080,
|
|
28
|
+
// Wait for the listener to bind before capturing.
|
|
29
|
+
listenerRequired: true,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const pool = await img.pool({ min: 1, max: 1 });
|
|
33
|
+
const vm = await pool.acquire();
|
|
34
|
+
|
|
35
|
+
// hostPort: 0 → OS picks a free port. Read it back via .localAddr.
|
|
36
|
+
// guestPort: 8080 is plumbed through but currently advisory; the
|
|
37
|
+
// Rust crate's expose_tcp routes into whatever the in-guest TSI
|
|
38
|
+
// mux accepts. Per-guest-port routing is on the roadmap; for
|
|
39
|
+
// now, a single guest workload bound to whatever port works.
|
|
40
|
+
const fwd = await vm.exposeTcp(0, 8080);
|
|
41
|
+
console.log(`forwarder bound on ${fwd.localAddr}`);
|
|
42
|
+
|
|
43
|
+
const [host, port] = fwd.localAddr.split(":");
|
|
44
|
+
|
|
45
|
+
// Verify the forwarder is accepting connections — a successful
|
|
46
|
+
// TCP connect (vs ECONNREFUSED) confirms the host-side proxy
|
|
47
|
+
// thread is up and the vsock relay accepted us.
|
|
48
|
+
const connected = await new Promise((resolve) => {
|
|
49
|
+
const sock = createConnection({ host, port: Number(port) });
|
|
50
|
+
sock.once("connect", () => {
|
|
51
|
+
sock.destroy();
|
|
52
|
+
resolve(true);
|
|
53
|
+
});
|
|
54
|
+
sock.once("error", () => resolve(false));
|
|
55
|
+
sock.setTimeout(3_000, () => {
|
|
56
|
+
sock.destroy();
|
|
57
|
+
resolve(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
console.log(`connect ok: ${connected}`);
|
|
61
|
+
|
|
62
|
+
await fwd.stop();
|
|
63
|
+
console.log("forwarder stopped");
|
|
64
|
+
|
|
65
|
+
// After stop(), connect must be refused.
|
|
66
|
+
const refused = await new Promise((resolve) => {
|
|
67
|
+
const sock = createConnection({ host, port: Number(port) });
|
|
68
|
+
sock.once("connect", () => {
|
|
69
|
+
sock.destroy();
|
|
70
|
+
resolve(false);
|
|
71
|
+
});
|
|
72
|
+
sock.once("error", () => resolve(true));
|
|
73
|
+
sock.setTimeout(500, () => {
|
|
74
|
+
sock.destroy();
|
|
75
|
+
resolve(true);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
console.log(`post-stop refused: ${refused}`);
|
|
79
|
+
|
|
80
|
+
await vm.release();
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// 05-spawn-streaming.mjs — bidirectional stdin/stdout/stderr
|
|
2
|
+
// streaming with vm.spawn().
|
|
3
|
+
//
|
|
4
|
+
// Unlike `vm.exec()` which collects all output into Buffers,
|
|
5
|
+
// `vm.spawn()` returns an ExecChild handle for incremental I/O:
|
|
6
|
+
// - writeStdin / closeStdin
|
|
7
|
+
// - readStdout / readStderr (pull-style, returns when bytes
|
|
8
|
+
// are available or 0 on EOF)
|
|
9
|
+
// - signal(signum), resize(cols, rows)
|
|
10
|
+
// - wait() → exit status
|
|
11
|
+
//
|
|
12
|
+
// Useful for: LLM-style token streaming, interactive REPLs,
|
|
13
|
+
// long-running daemons, anything where you don't want to wait
|
|
14
|
+
// for the whole output before processing.
|
|
15
|
+
//
|
|
16
|
+
// Run:
|
|
17
|
+
// node examples/05-spawn-streaming.mjs
|
|
18
|
+
|
|
19
|
+
import { Image } from "@supermachine/core";
|
|
20
|
+
|
|
21
|
+
const img = await Image.build({
|
|
22
|
+
ref: "alpine:latest",
|
|
23
|
+
name: "spawn-stream-demo",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const pool = await img.pool({ min: 1, max: 1 });
|
|
27
|
+
const vm = await pool.acquire();
|
|
28
|
+
|
|
29
|
+
// Spawn a process that echoes back each line of stdin with a
|
|
30
|
+
// prefix. The host writes lines incrementally and reads each
|
|
31
|
+
// response as it arrives — no buffering on either side.
|
|
32
|
+
const child = await vm.spawn({
|
|
33
|
+
argv: [
|
|
34
|
+
"sh",
|
|
35
|
+
"-c",
|
|
36
|
+
`while IFS= read -r line; do echo "[guest] $line"; done`,
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const inputs = ["hello", "world", "supermachine"];
|
|
41
|
+
for (const line of inputs) {
|
|
42
|
+
await child.writeStdin(Buffer.from(`${line}\n`));
|
|
43
|
+
// Pull one line of output. readStdout returns at first data;
|
|
44
|
+
// we accumulate until we see a \n.
|
|
45
|
+
let acc = "";
|
|
46
|
+
while (!acc.includes("\n")) {
|
|
47
|
+
const chunk = await child.readStdout(1024);
|
|
48
|
+
if (chunk.length === 0) break;
|
|
49
|
+
acc += chunk.toString();
|
|
50
|
+
}
|
|
51
|
+
process.stdout.write(`host wrote "${line}" → ${acc}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Close stdin so the shell's `read` returns 0 and the loop exits.
|
|
55
|
+
await child.closeStdin();
|
|
56
|
+
const result = await child.wait();
|
|
57
|
+
console.log(`exit ${result.exitCode}, peak RSS ${result.peakRssKib ?? "?"} KiB`);
|
|
58
|
+
|
|
59
|
+
await vm.release();
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# `@supermachine/core` examples
|
|
2
|
+
|
|
3
|
+
Runnable scripts demonstrating each integrator-facing surface.
|
|
4
|
+
Every example is self-contained — `node examples/NN-name.mjs`
|
|
5
|
+
with the package installed.
|
|
6
|
+
|
|
7
|
+
| File | Surfaces |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `01-hello.mjs` | `Image.build` + `pool.acquire` + `vm.exec` — the minimum program |
|
|
10
|
+
| `02-pool.mjs` | Pool sizing (`min`/`max`), `pool.stats()`, concurrent acquire |
|
|
11
|
+
| `03-snapshot-then-restore.mjs` | `vm.writeFile` / `readFile`, mid-flight `vm.snapshot()`, `Image.fromSnapshot` |
|
|
12
|
+
| `04-expose-tcp.mjs` | `listenerRequired: true` bakes, `vm.exposeTcp()`, `TcpForwarder` |
|
|
13
|
+
| `05-spawn-streaming.mjs` | `vm.spawn()` → `ExecChild` (writeStdin / readStdout / wait) |
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
- macOS on Apple Silicon (no Linux/Windows backends yet)
|
|
18
|
+
- Docker available for the OCI image pull on first run
|
|
19
|
+
- Node ≥ 18.17
|
|
20
|
+
|
|
21
|
+
## Running locally from a source checkout
|
|
22
|
+
|
|
23
|
+
The examples import `"@supermachine/core"` as if installed from
|
|
24
|
+
npm. From this repo, run them with a path-rewrite:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
cd npm/supermachine-core
|
|
28
|
+
npm install
|
|
29
|
+
npm run build
|
|
30
|
+
# then either: `npm link` once, then `node examples/01-hello.mjs`
|
|
31
|
+
# or: rewrite the import inline
|
|
32
|
+
node --input-type=module -e \
|
|
33
|
+
"$(sed 's|@supermachine/core|./index.js|' examples/01-hello.mjs)"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
For a quick smoke test that all five work end-to-end:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
for f in examples/*.mjs; do
|
|
40
|
+
echo "=== $f ===";
|
|
41
|
+
node --input-type=module -e "$(sed 's|@supermachine/core|./index.js|' $f)";
|
|
42
|
+
done
|
|
43
|
+
```
|