| 1 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Stream from "effect/Stream"; |
| 4 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 5 | import * as HttpClient from "effect/http/HttpClient"; |
| 6 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 7 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 8 | |
| 9 | const 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 | */ |
| 17 | export 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 | */ |
| 32 | export 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 | ) {} |