@superhq/shuru 0.1.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/package.json +18 -0
- package/src/index.ts +2 -0
- package/src/process.ts +144 -0
- package/src/sandbox.ts +103 -0
- package/src/types.ts +28 -0
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@superhq/shuru",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"files": ["src"],
|
|
8
|
+
"scripts": {
|
|
9
|
+
"test": "bun test test/unit/",
|
|
10
|
+
"test:integration": "bun test test/integration/",
|
|
11
|
+
"check": "biome check src/",
|
|
12
|
+
"format": "biome check --write src/"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@biomejs/biome": "2.4.5",
|
|
16
|
+
"@types/bun": "^1.3.10"
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/index.ts
ADDED
package/src/process.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { Subprocess } from "bun";
|
|
2
|
+
import type { StdioResponse } from "./types";
|
|
3
|
+
|
|
4
|
+
interface PendingRequest {
|
|
5
|
+
resolve: (value: StdioResponse) => void;
|
|
6
|
+
reject: (reason: Error) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class ShuruProcess {
|
|
10
|
+
private proc: Subprocess<"pipe", "pipe", "inherit"> | null = null;
|
|
11
|
+
private pending = new Map<string, PendingRequest>();
|
|
12
|
+
private idCounter = 0;
|
|
13
|
+
|
|
14
|
+
async start(args: string[]): Promise<void> {
|
|
15
|
+
this.proc = Bun.spawn(args, {
|
|
16
|
+
stdin: "pipe",
|
|
17
|
+
stdout: "pipe",
|
|
18
|
+
stderr: "inherit",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
this.readLoop();
|
|
22
|
+
|
|
23
|
+
await new Promise<void>((resolve, reject) => {
|
|
24
|
+
const timeout = setTimeout(() => {
|
|
25
|
+
reject(new Error("shuru: timed out waiting for ready signal (30s)"));
|
|
26
|
+
}, 30_000);
|
|
27
|
+
|
|
28
|
+
this.pending.set("__ready__", {
|
|
29
|
+
resolve: () => {
|
|
30
|
+
clearTimeout(timeout);
|
|
31
|
+
resolve();
|
|
32
|
+
},
|
|
33
|
+
reject: (err: Error) => {
|
|
34
|
+
clearTimeout(timeout);
|
|
35
|
+
reject(err);
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async send(msg: Record<string, unknown>): Promise<StdioResponse> {
|
|
42
|
+
if (!this.proc) throw new Error("shuru process not started");
|
|
43
|
+
|
|
44
|
+
const id = String(++this.idCounter);
|
|
45
|
+
const line = `${JSON.stringify({ id, ...msg })}\n`;
|
|
46
|
+
const proc = this.proc;
|
|
47
|
+
|
|
48
|
+
return new Promise<StdioResponse>((resolve, reject) => {
|
|
49
|
+
this.pending.set(id, { resolve, reject });
|
|
50
|
+
proc.stdin.write(line);
|
|
51
|
+
proc.stdin.flush();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async stop(): Promise<void> {
|
|
56
|
+
if (!this.proc) return;
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
this.proc.stdin.end();
|
|
60
|
+
} catch {
|
|
61
|
+
// stdin may already be closed
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const exited = this.proc.exited;
|
|
65
|
+
const timeout = new Promise<never>((_, reject) =>
|
|
66
|
+
setTimeout(
|
|
67
|
+
() => reject(new Error("shuru: shutdown timed out (5s)")),
|
|
68
|
+
5_000,
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
await Promise.race([exited, timeout]);
|
|
74
|
+
} catch {
|
|
75
|
+
this.proc.kill();
|
|
76
|
+
await this.proc.exited;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const [, req] of this.pending) {
|
|
80
|
+
req.reject(new Error("shuru process stopped"));
|
|
81
|
+
}
|
|
82
|
+
this.pending.clear();
|
|
83
|
+
this.proc = null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private async readLoop(): Promise<void> {
|
|
87
|
+
if (!this.proc?.stdout) return;
|
|
88
|
+
|
|
89
|
+
const reader = this.proc.stdout.getReader();
|
|
90
|
+
const decoder = new TextDecoder();
|
|
91
|
+
let buffer = "";
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
while (true) {
|
|
95
|
+
const { done, value } = await reader.read();
|
|
96
|
+
if (done) break;
|
|
97
|
+
|
|
98
|
+
buffer += decoder.decode(value, { stream: true });
|
|
99
|
+
const lines = buffer.split("\n");
|
|
100
|
+
buffer = lines.pop() ?? "";
|
|
101
|
+
|
|
102
|
+
for (const line of lines) {
|
|
103
|
+
if (!line) continue;
|
|
104
|
+
try {
|
|
105
|
+
this.dispatch(JSON.parse(line) as StdioResponse);
|
|
106
|
+
} catch {
|
|
107
|
+
// malformed line
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// stream closed
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const [, req] of this.pending) {
|
|
116
|
+
req.reject(new Error("shuru process exited unexpectedly"));
|
|
117
|
+
}
|
|
118
|
+
this.pending.clear();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private dispatch(msg: StdioResponse): void {
|
|
122
|
+
if (msg.type === "ready") {
|
|
123
|
+
const req = this.pending.get("__ready__");
|
|
124
|
+
if (req) {
|
|
125
|
+
this.pending.delete("__ready__");
|
|
126
|
+
req.resolve(msg);
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const { id } = msg;
|
|
132
|
+
if (!id) return;
|
|
133
|
+
|
|
134
|
+
const req = this.pending.get(id);
|
|
135
|
+
if (!req) return;
|
|
136
|
+
this.pending.delete(id);
|
|
137
|
+
|
|
138
|
+
if (msg.type === "error") {
|
|
139
|
+
req.reject(new Error(msg.error));
|
|
140
|
+
} else {
|
|
141
|
+
req.resolve(msg);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
package/src/sandbox.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { ShuruProcess } from "./process";
|
|
2
|
+
import type { ExecResult, StartOptions } from "./types";
|
|
3
|
+
|
|
4
|
+
export class Sandbox {
|
|
5
|
+
private proc: ShuruProcess;
|
|
6
|
+
|
|
7
|
+
private constructor(proc: ShuruProcess) {
|
|
8
|
+
this.proc = proc;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
static async start(opts: StartOptions = {}): Promise<Sandbox> {
|
|
12
|
+
const bin = opts.shuruBin ?? "shuru";
|
|
13
|
+
const args = buildArgs(bin, opts);
|
|
14
|
+
|
|
15
|
+
const proc = new ShuruProcess();
|
|
16
|
+
await proc.start(args);
|
|
17
|
+
|
|
18
|
+
return new Sandbox(proc);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async exec(command: string): Promise<ExecResult> {
|
|
22
|
+
const resp = await this.proc.send({
|
|
23
|
+
type: "exec",
|
|
24
|
+
argv: ["sh", "-c", command],
|
|
25
|
+
});
|
|
26
|
+
if (resp.type !== "exec") {
|
|
27
|
+
throw new Error(`unexpected response type: ${resp.type}`);
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
stdout: resp.stdout,
|
|
31
|
+
stderr: resp.stderr,
|
|
32
|
+
exitCode: resp.exit_code,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async readFile(path: string): Promise<string> {
|
|
37
|
+
const resp = await this.proc.send({
|
|
38
|
+
type: "exec",
|
|
39
|
+
argv: ["cat", path],
|
|
40
|
+
});
|
|
41
|
+
if (resp.type !== "exec") {
|
|
42
|
+
throw new Error(`unexpected response type: ${resp.type}`);
|
|
43
|
+
}
|
|
44
|
+
if (resp.exit_code !== 0) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`readFile failed (exit ${resp.exit_code}): ${resp.stderr}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return resp.stdout;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async writeFile(path: string, content: string): Promise<void> {
|
|
53
|
+
const b64 = btoa(content);
|
|
54
|
+
const resp = await this.proc.send({
|
|
55
|
+
type: "exec",
|
|
56
|
+
argv: ["sh", "-c", `echo '${b64}' | base64 -d > "$1"`, "--", path],
|
|
57
|
+
});
|
|
58
|
+
if (resp.type !== "exec") {
|
|
59
|
+
throw new Error(`unexpected response type: ${resp.type}`);
|
|
60
|
+
}
|
|
61
|
+
if (resp.exit_code !== 0) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`writeFile failed (exit ${resp.exit_code}): ${resp.stderr}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async checkpoint(name: string): Promise<void> {
|
|
69
|
+
const resp = await this.proc.send({ type: "checkpoint", name });
|
|
70
|
+
if (resp.type !== "checkpoint") {
|
|
71
|
+
throw new Error(`unexpected response type: ${resp.type}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async stop(): Promise<void> {
|
|
76
|
+
await this.proc.stop();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @internal exported for testing */
|
|
81
|
+
export function buildArgs(bin: string, opts: StartOptions): string[] {
|
|
82
|
+
const args = [...bin.split(/\s+/), "run", "--stdio"];
|
|
83
|
+
|
|
84
|
+
if (opts.from) args.push("--from", opts.from);
|
|
85
|
+
if (opts.cpus) args.push("--cpus", String(opts.cpus));
|
|
86
|
+
if (opts.memory) args.push("--memory", String(opts.memory));
|
|
87
|
+
if (opts.diskSize) args.push("--disk-size", String(opts.diskSize));
|
|
88
|
+
if (opts.allowNet) args.push("--allow-net");
|
|
89
|
+
|
|
90
|
+
if (opts.ports) {
|
|
91
|
+
for (const p of opts.ports) {
|
|
92
|
+
args.push("-p", p);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (opts.mounts) {
|
|
97
|
+
for (const [host, guest] of Object.entries(opts.mounts)) {
|
|
98
|
+
args.push("--mount", `${host}:${guest}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return args;
|
|
103
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface StartOptions {
|
|
2
|
+
from?: string;
|
|
3
|
+
cpus?: number;
|
|
4
|
+
memory?: number;
|
|
5
|
+
diskSize?: number;
|
|
6
|
+
allowNet?: boolean;
|
|
7
|
+
ports?: string[];
|
|
8
|
+
mounts?: Record<string, string>;
|
|
9
|
+
shuruBin?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ExecResult {
|
|
13
|
+
stdout: string;
|
|
14
|
+
stderr: string;
|
|
15
|
+
exitCode: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type StdioResponse =
|
|
19
|
+
| { type: "ready" }
|
|
20
|
+
| {
|
|
21
|
+
type: "exec";
|
|
22
|
+
id: string;
|
|
23
|
+
stdout: string;
|
|
24
|
+
stderr: string;
|
|
25
|
+
exit_code: number;
|
|
26
|
+
}
|
|
27
|
+
| { type: "checkpoint"; id: string; ok: boolean }
|
|
28
|
+
| { type: "error"; id: string; error: string };
|