1import * as AWS from "alchemy/AWS";
2import * as Cloudflare from "alchemy/Cloudflare";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Schedule from "effect/Schedule";
6import { HttpServerRequest } from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import ShellSession from "./shell-session.ts";
9import { ShellMicrovm } from "./shell-image.ts";
10import { TERMINAL_HTML } from "./terminal-html.ts";
11
12/**
13 * The cross-cloud shell Worker.
14 *
15 * - `GET /` serves the hand-rolled terminal SPA.
16 * - `GET /session/:id/ws` provisions (or reuses) the session's AWS Lambda
17 * MicroVM, hands its endpoint + auth headers to the {@link ShellSession}
18 * Durable Object, and forwards the WebSocket upgrade to it.
19 *
20 * The MicroVM control-plane ops (Run/Get/CreateAuthToken) are bound on this
21 * Worker — Alchemy mints an IAM User + AccessKey + assume-role Role once and
22 * the Worker assumes it at runtime (see `MicrovmBinding.ts`). Under
23 * `alchemy dev` the Worker runs in local workerd and these calls resolve to
24 * the Floci emulator via the dev endpoint + CA wiring.
25 */
26export default Cloudflare.Worker(
27 "MicrovmShellWorker",
28 { main: import.meta.url },
29 Effect.gen(function* () {
30 const sessions = yield* ShellSession;
31 const runMicrovm = yield* AWS.Lambda.RunMicrovm(ShellMicrovm);
32 const getMicrovm = yield* AWS.Lambda.GetMicrovm(ShellMicrovm);
33 const createAuthToken = yield* AWS.Lambda.CreateAuthToken(ShellMicrovm);
34
35 /** Boot a MicroVM, wait until RUNNING, and mint a data-plane auth token. */
36 const provision = Effect.gen(function* () {
37 const vm = yield* runMicrovm({
38 idlePolicy: {
39 maxIdleDurationSeconds: 900,
40 suspendedDurationSeconds: 300,
41 autoResumeEnabled: true,
42 },
43 });
44 yield* getMicrovm({ microvmIdentifier: vm.microvmId }).pipe(
45 Effect.flatMap((m) =>
46 m.state === "RUNNING"
47 ? Effect.void
48 : Effect.fail(new Error(`microvm ${m.state}`)),
49 ),
50 Effect.retry({ schedule: Schedule.spaced("1 second"), times: 60 }),
51 );
52 const { authToken } = yield* createAuthToken({
53 microvmIdentifier: vm.microvmId,
54 expirationInMinutes: 60,
55 allowedPorts: [{ port: 8080 }],
56 });
57 return {
58 endpoint: vm.endpoint,
59 headers: AWS.Lambda.microvmAuthHeaders(authToken),
60 };
61 });
62
63 return {
64 fetch: Effect.gen(function* () {
65 const request = yield* HttpServerRequest;
66 const url = new URL(request.url, "http://shell");
67
68 if (request.method === "GET" && url.pathname === "/") {
69 return HttpServerResponse.html(TERMINAL_HTML);
70 }
71
72 const wsMatch = url.pathname.match(/^\/session\/([^/]+)\/ws$/);
73 if (wsMatch) {
74 if (request.headers.upgrade !== "websocket") {
75 return HttpServerResponse.text("expected websocket upgrade", {
76 status: 426,
77 });
78 }
79 const sessionId = wsMatch[1]!;
80 const stub = sessions.getByName(sessionId);
81 const coords = yield* provision;
82 yield* stub.init(coords);
83 return yield* stub.fetch(request);
84 }
85
86 return HttpServerResponse.text("not found", { status: 404 });
87 }).pipe(Effect.orDie),
88 };
89 }).pipe(
90 Effect.provide(
91 Layer.mergeAll(
92 AWS.Lambda.RunMicrovmHttp,
93 AWS.Lambda.GetMicrovmHttp,
94 AWS.Lambda.CreateAuthTokenHttp,
95 ),
96 ),
97 ),
98);