1import * as AWS from "alchemy/AWS";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { HttpServerRequest } from "effect/http/HttpServerRequest";
5import * as HttpServerResponse from "effect/http/HttpServerResponse";
6import * as ChildProcess from "effect/process/ChildProcess";
7import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";
8
9/**
10 * The trailer the in-VM server appends after a command's output so the
11 * Durable Object can learn the exit code without it leaking into the
12 * terminal: `\n__EXIT__:<code>\n`.
13 */
14const EXIT_MARKER = "__EXIT__:";
15
16const encoder = new TextEncoder();
17
18/**
19 * The build role is created bare; the image grants it the trust + build
20 * permissions it needs through a binding.
21 */
22export const ShellBuildRole = AWS.IAM.Role("ShellMicrovmBuildRole");
23
24/**
25 * The shell MicroVM image — an **effectful** image: this module is both the
26 * image declaration and the in-VM server. The Effect program below is
27 * bundled into the image (bun runtime) and served by the alchemy MicroVM
28 * bootstrap; there is no Dockerfile and no hand-rolled HTTP server.
29 *
30 * `POST /exec { "command": "<sh command>" }` → a chunked text/plain response
31 * streaming the command's interleaved stdout+stderr as it is produced,
32 * terminated by the `__EXIT__` trailer. The MicroVM auth token is enforced
33 * by the endpoint proxy in front of this server, so the server itself
34 * trusts every request it receives.
35 */
36export class ShellMicrovm extends AWS.Lambda.MicrovmImage<ShellMicrovm>()(
37 "ShellMicrovm",
38) {}
39
40export default ShellMicrovm.make(
41 ShellBuildRole.pipe(
42 Effect.map((buildRole) => ({
43 main: import.meta.filename,
44 buildRole,
45 runtime: "bun" as const,
46 resources: [{ minimumMemoryInMiB: 512 }],
47 cpuConfigurations: [{ architecture: "ARM_64" }],
48 })),
49 ),
50 Effect.gen(function* () {
51 return {
52 fetch: Effect.gen(function* () {
53 const request = yield* HttpServerRequest;
54 const url = new URL(request.url, "http://microvm");
55
56 if (url.pathname === "/health") {
57 return HttpServerResponse.text("ok");
58 }
59
60 if (request.method === "POST" && url.pathname === "/exec") {
61 const body = (yield* request.json.pipe(
62 Effect.orElseSucceed(() => undefined),
63 )) as { command?: unknown } | undefined;
64 const command =
65 typeof body?.command === "string" ? body.command : "";
66 if (!command.trim()) {
67 return HttpServerResponse.text("empty command", { status: 400 });
68 }
69
70 const spawner = yield* ChildProcessSpawner;
71 // The spawned handle is scoped; `Stream.unwrap` folds that scope
72 // into the stream's lifetime, so the process is reaped exactly when
73 // the response stream completes (or the client goes away).
74 const output = Stream.unwrap(
75 spawner
76 .spawn(ChildProcess.make("sh", ["-c", command], { shell: false }))
77 .pipe(
78 Effect.map((handle) =>
79 Stream.merge(handle.stdout, handle.stderr).pipe(
80 Stream.concat(
81 Stream.fromEffect(
82 handle.exitCode.pipe(
83 Effect.map((code) =>
84 encoder.encode(`\n${EXIT_MARKER}${code}\n`),
85 ),
86 ),
87 ),
88 ),
89 ),
90 ),
91 ),
92 ).pipe(
93 // Spawn/pipe failures still terminate the protocol: report them
94 // as a non-zero exit with the error as output.
95 Stream.catchCause((cause) =>
96 Stream.make(
97 encoder.encode(`\n${EXIT_MARKER}1\n${String(cause)}\n`),
98 ),
99 ),
100 );
101
102 return HttpServerResponse.stream(output, {
103 contentType: "text/plain; charset=utf-8",
104 headers: { "cache-control": "no-store" },
105 });
106 }
107
108 return HttpServerResponse.text("shell microvm: POST /exec", {
109 status: 404,
110 });
111 }),
112 };
113 }),
114);