1import * as Cloudflare from "alchemy/Cloudflare";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import * as FetchHttpClient from "effect/http/FetchHttpClient";
5import * as HttpClient from "effect/http/HttpClient";
6import * as HttpClientRequest from "effect/http/HttpClientRequest";
7import * as HttpClientResponse from "effect/http/HttpClientResponse";
8
9const EXIT_MARKER = "__EXIT__:";
10
11/**
12 * The session's MicroVM coordinates: its endpoint host and the already-resolved
13 * auth header map (from `AWS.Lambda.microvmAuthHeaders`). The DO speaks only
14 * the VM's data plane — it never touches AWS credentials, so these plain
15 * strings are all it needs.
16 */
17export interface MicrovmCoords {
18 readonly endpoint: string;
19 readonly headers: Record<string, string>;
20}
21
22/**
23 * One terminal session. The hosting Worker provisions the session's MicroVM
24 * (assume-role control plane) and hands the coordinates here via the `init`
25 * RPC; the same VM is reused for every command in the session (no re-boot per
26 * command).
27 *
28 * Each command received on the WebSocket is POSTed to the VM's streaming
29 * `/exec` route; the combined stdout/stderr is forwarded back to the browser
30 * chunk-by-chunk as the process produces it.
31 */
32export default class ShellSession extends Cloudflare.DurableObject<ShellSession>()(
33 "ShellSession",
34 Effect.gen(function* () {
35 // The session's VM coordinates, set by the `init` RPC and reused for every
36 // command in the session. The closure copy is just a cache: workerd's
37 // hibernatable WebSockets SURVIVE DO eviction, so an idle session's next
38 // message wakes a fresh instance (empty closure) on the SAME socket — the
39 // browser never reconnects. The authoritative copy lives in DO storage and
40 // is rehydrated lazily.
41 let coords: MicrovmCoords | undefined;
42
43 return Effect.gen(function* () {
44 const send = (socket: Cloudflare.WebSocket, text: string) =>
45 socket.send(text).pipe(Effect.ignore);
46
47 const loadCoords = Effect.gen(function* () {
48 if (coords) return coords;
49 const state = yield* Cloudflare.DurableObjectState;
50 coords = yield* state.storage.get<MicrovmCoords>("coords");
51 return coords;
52 });
53
54 const runCommand = (socket: Cloudflare.WebSocket, command: string) =>
55 Effect.gen(function* () {
56 const current = yield* loadCoords;
57 if (!current) {
58 yield* send(socket, "[session has no microvm]\n");
59 return;
60 }
61 const { endpoint, headers } = current;
62 const client = yield* HttpClient.HttpClient;
63 const request = HttpClientRequest.post(
64 `https://${endpoint}/exec`,
65 ).pipe(
66 HttpClientRequest.setHeaders(headers),
67 HttpClientRequest.bodyJsonUnsafe({ command }),
68 );
69
70 // Render like a real shell: forward output verbatim, swallow the
71 // VM's `\n__EXIT__:<code>` trailer (including the separator newline
72 // the server adds before it), and only make sure the next prompt
73 // starts on a fresh line when the command's output didn't end with
74 // one — no exit-status chatter.
75 let endedWithNewline = true;
76 yield* HttpClientResponse.stream(client.execute(request)).pipe(
77 Stream.decodeText(),
78 Stream.runForEach((chunk) =>
79 Effect.gen(function* () {
80 const marker = chunk.indexOf(EXIT_MARKER);
81 if (marker === -1) {
82 if (chunk) {
83 endedWithNewline = chunk.endsWith("\n");
84 yield* send(socket, chunk);
85 }
86 return;
87 }
88 let before = chunk.slice(0, marker);
89 if (before.endsWith("\n")) {
90 before = before.slice(0, -1);
91 }
92 if (before) {
93 endedWithNewline = before.endsWith("\n");
94 yield* send(socket, before);
95 }
96 }),
97 ),
98 );
99 if (!endedWithNewline) {
100 yield* send(socket, "\n");
101 }
102 }).pipe(
103 Effect.catch((error) => send(socket, `[error] ${String(error)}\n`)),
104 Effect.provide(FetchHttpClient.layer),
105 );
106
107 return {
108 /** Pin this session to a provisioned MicroVM (called by the Worker). */
109 init: (next: MicrovmCoords) =>
110 Effect.gen(function* () {
111 coords = next;
112 // Persist so a hibernation wake (same socket, fresh instance)
113 // finds its VM again.
114 const state = yield* Cloudflare.DurableObjectState;
115 yield* state.storage.put("coords", next);
116 }),
117 fetch: Effect.gen(function* () {
118 const [response, socket] = yield* Cloudflare.upgrade();
119 yield* send(
120 socket,
121 "connected to microvm — type a command and press enter\n",
122 );
123 return response;
124 }),
125 webSocketMessage: Effect.fn(function* (
126 socket: Cloudflare.WebSocket,
127 message: string | ArrayBuffer,
128 ) {
129 const command =
130 typeof message === "string"
131 ? message
132 : new TextDecoder().decode(message);
133 if (!command.trim()) return;
134 yield* runCommand(socket, command.trim());
135 }),
136 webSocketClose: Effect.fn(function* (
137 socket: Cloudflare.WebSocket,
138 code: number,
139 reason: string,
140 ) {
141 yield* socket.close(code, reason).pipe(Effect.ignore);
142 }),
143 };
144 });
145 }),
146) {}