| 1 | import * as Hetzner from "@distilled.cloud/hetzner"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Redacted from "effect/Redacted"; |
| 5 | import * as Bundle from "../Bundle/Bundle.ts"; |
| 6 | import { isResolved } from "../Diff.ts"; |
| 7 | import { createPhysicalName } from "../PhysicalName.ts"; |
| 8 | import { Platform, type Main, type PlatformProps } from "../Platform.ts"; |
| 9 | import * as Provider from "../Provider.ts"; |
| 10 | import { Resource, type ResourceBinding } from "../Resource.ts"; |
| 11 | import type { ServerHost } from "../Server/Process.ts"; |
| 12 | import { Stack } from "../Stack.ts"; |
| 13 | import type { ServiceBinding } from "./MountVolume.ts"; |
| 14 | import type { Providers } from "./Providers.ts"; |
| 15 | import type { Server } from "./Server.ts"; |
| 16 | import { SshError, sshClientForServer } from "./Ssh.ts"; |
| 17 | import { |
| 18 | collectBindingState, |
| 19 | createHetznerHostedSupport, |
| 20 | createHetznerHostRuntimeContext, |
| 21 | type HetznerBuildOptions, |
| 22 | type HetznerHostRuntimeContext, |
| 23 | } from "./hosted.ts"; |
| 24 | |
| 25 | /** |
| 26 | * A resource-valued prop: the resource itself, or an Effect that produces |
| 27 | * it (so `yield* Server(...)` and `Server(...)` both type-check). |
| 28 | */ |
| 29 | type Ref<T> = T | Effect.Effect<T, never, Providers>; |
| 30 | |
| 31 | export interface ServiceProps extends PlatformProps { |
| 32 | /** |
| 33 | * Server this Service runs on. Accepts a `Hetzner.Server` or an Effect |
| 34 | * that produces one. Changing the Server replaces the Service. |
| 35 | */ |
| 36 | server: Ref<Server>; |
| 37 | /** |
| 38 | * Module entrypoint bundled with rolldown and copied onto the Server |
| 39 | * over SSH as a systemd unit. |
| 40 | */ |
| 41 | main: string; |
| 42 | /** |
| 43 | * Port the hosted HTTP server listens on. Written to `PORT` and used |
| 44 | * to build {@link Service} `url`. |
| 45 | * |
| 46 | * @default 3000 |
| 47 | */ |
| 48 | port?: number; |
| 49 | /** |
| 50 | * Named export to load from `main`. |
| 51 | * |
| 52 | * @default "default" |
| 53 | */ |
| 54 | handler?: string; |
| 55 | /** |
| 56 | * Additional environment variables for the hosted process. Merged after |
| 57 | * binding-injected `env`. |
| 58 | */ |
| 59 | env?: Record<string, any>; |
| 60 | /** |
| 61 | * Bundler configuration for `main`: rolldown `input`/`output` overrides |
| 62 | * plus pure-annotation options (`pure`) and `install` for packages that |
| 63 | * must ship as real `node_modules` (see {@link HetznerBuildOptions}). |
| 64 | */ |
| 65 | build?: HetznerBuildOptions; |
| 66 | /** |
| 67 | * Extra host directories packed into the unit archive next to the |
| 68 | * bundled entry (e.g. a website `clientDirectory` at `dist/`). Hashed |
| 69 | * into `code.hash` so asset changes update the unit. Destination is |
| 70 | * relative to the unit root (`/opt/<unit>/`). |
| 71 | */ |
| 72 | extraFiles?: ReadonlyArray<{ |
| 73 | source: string; |
| 74 | destination: string; |
| 75 | }>; |
| 76 | } |
| 77 | |
| 78 | export type Service = Resource< |
| 79 | "Hetzner.Service", |
| 80 | ServiceProps, |
| 81 | { |
| 82 | /** Numeric Hetzner Server ID the unit runs on. */ |
| 83 | serverId: number; |
| 84 | /** Public IPv4 of the Server, if attached. */ |
| 85 | ipv4: string | undefined; |
| 86 | /** Port the process listens on. */ |
| 87 | port: number; |
| 88 | /** `http://<ipv4>:<port>` when the Server has a public IPv4. */ |
| 89 | url: string | undefined; |
| 90 | /** systemd unit name (`<name>.service`). */ |
| 91 | unitName: string; |
| 92 | /** Content hash of the bundled program. */ |
| 93 | code: { |
| 94 | hash: string; |
| 95 | }; |
| 96 | }, |
| 97 | ServiceBinding, |
| 98 | Providers |
| 99 | >; |
| 100 | |
| 101 | export const isService = (value: unknown): value is Service => |
| 102 | typeof value === "object" && |
| 103 | value !== null && |
| 104 | (value as { Type?: string }).Type === "Hetzner.Service"; |
| 105 | |
| 106 | export type ServiceServices = ServerHost; |
| 107 | |
| 108 | export type ServiceShape = Main<ServiceServices>; |
| 109 | |
| 110 | export type ServiceRuntimeContext = HetznerHostRuntimeContext; |
| 111 | |
| 112 | /** |
| 113 | * An Effect program hosted on a Hetzner Cloud Server as a systemd unit. |
| 114 | * |
| 115 | * N Services can share one Server. Each Service is bundled with rolldown, |
| 116 | * copied over SSH, and restarted as its own unit. Bind |
| 117 | * `Hetzner.MountVolume` inside the impl to attach a Volume and mount it |
| 118 | * at a path — the same `(volume, server, path)` from two Services is one |
| 119 | * attach and one mount. |
| 120 | * |
| 121 | * @see https://docs.hetzner.cloud/reference/cloud#servers |
| 122 | * |
| 123 | * ### Hosting a Service |
| 124 | * **Example:** HTTP server on a Server |
| 125 | * ```typescript |
| 126 | * const server = yield* Hetzner.Server("box", { |
| 127 | * serverType: "cpx12", |
| 128 | * image: "ubuntu-24.04", |
| 129 | * location: "nbg1", |
| 130 | * }); |
| 131 | * |
| 132 | * export default class Api extends Hetzner.Service<Api>()( |
| 133 | * "Api", |
| 134 | * { server, main: import.meta.url, port: 3000 }, |
| 135 | * Effect.gen(function* () { |
| 136 | * return { |
| 137 | * fetch: Effect.succeed(HttpServerResponse.json({ ok: true })), |
| 138 | * }; |
| 139 | * }), |
| 140 | * ) {} |
| 141 | * ``` |
| 142 | * |
| 143 | * ### Volumes |
| 144 | * **Example:** Mount a Volume |
| 145 | * ```typescript |
| 146 | * const mount = yield* Hetzner.MountVolume(volume, { path: "/data" }); |
| 147 | * // write/read files under mount.path inside the hosted process |
| 148 | * ``` |
| 149 | * |
| 150 | * @resource |
| 151 | * @product Service |
| 152 | */ |
| 153 | export const Service: Platform< |
| 154 | Service, |
| 155 | ServiceServices, |
| 156 | ServiceShape, |
| 157 | ServiceRuntimeContext |
| 158 | > = Platform("Hetzner.Service", { |
| 159 | createRuntimeContext: createHetznerHostRuntimeContext("Hetzner.Service"), |
| 160 | // `{ server: Box }` at module scope is an Effect. Yield it here so the |
| 161 | // Server is registered and `news.server` is resolved attributes at |
| 162 | // reconcile (same DX as `yield* Server(...)` inside Effect.gen). |
| 163 | transformProps: (_id, props) => |
| 164 | Effect.gen(function* () { |
| 165 | if (globalThis.__ALCHEMY_RUNTIME__) return props; |
| 166 | const server = Effect.isEffect(props.server) |
| 167 | ? yield* props.server as Effect.Effect<Server, never, Providers> |
| 168 | : props.server; |
| 169 | return { ...props, server }; |
| 170 | }), |
| 171 | }); |
| 172 | |
| 173 | export class ServiceError extends Data.TaggedError("Hetzner.ServiceError")<{ |
| 174 | message: string; |
| 175 | }> {} |
| 176 | |
| 177 | const DEFAULT_PORT = 3000; |
| 178 | |
| 179 | const serverIdOf = (value: unknown): number | undefined => { |
| 180 | if (value === null || typeof value !== "object") return undefined; |
| 181 | const rec = value as { serverId?: unknown; id?: unknown }; |
| 182 | if (typeof rec.serverId === "number") return rec.serverId; |
| 183 | if (typeof rec.id === "number") return rec.id; |
| 184 | return undefined; |
| 185 | }; |
| 186 | |
| 187 | const ipv4Of = (value: unknown): string | undefined => { |
| 188 | if (value === null || typeof value !== "object") return undefined; |
| 189 | const rec = value as { ipv4?: unknown }; |
| 190 | return typeof rec.ipv4 === "string" ? rec.ipv4 : undefined; |
| 191 | }; |
| 192 | |
| 193 | const unwrapKey = ( |
| 194 | value: Redacted.Redacted<string> | string | undefined, |
| 195 | ): string | undefined => { |
| 196 | if (value === undefined) return undefined; |
| 197 | return typeof value === "string" ? value : Redacted.value(value); |
| 198 | }; |
| 199 | |
| 200 | const privateKeyOf = (value: unknown): string | undefined => { |
| 201 | if (value === null || typeof value !== "object") return undefined; |
| 202 | return unwrapKey( |
| 203 | (value as { privateKey?: Redacted.Redacted<string> | string }).privateKey, |
| 204 | ); |
| 205 | }; |
| 206 | |
| 207 | const createUnitName = (id: string, existing?: string) => |
| 208 | existing !== undefined |
| 209 | ? Effect.succeed(existing) |
| 210 | : createPhysicalName({ |
| 211 | id, |
| 212 | maxLength: 64, |
| 213 | lowercase: true, |
| 214 | }).pipe(Effect.map((name) => name.replaceAll(/[^a-z0-9-]/g, "-"))); |
| 215 | |
| 216 | const serviceUrl = (ipv4: string | undefined, port: number) => |
| 217 | ipv4 !== undefined ? `http://${ipv4}:${port}` : undefined; |
| 218 | |
| 219 | export const ServiceProvider = () => |
| 220 | Provider.effect( |
| 221 | Service, |
| 222 | Effect.gen(function* () { |
| 223 | const stack = yield* Stack; |
| 224 | const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin; |
| 225 | const hosted = createHetznerHostedSupport({ |
| 226 | stackName: stack.name, |
| 227 | stage: stack.stage, |
| 228 | virtualEntryPlugin, |
| 229 | }); |
| 230 | |
| 231 | const openSession = (news: ServiceProps) => |
| 232 | sshClientForServer(news.server as Server, { |
| 233 | privateKey: privateKeyOf(news.server), |
| 234 | }); |
| 235 | |
| 236 | return Service.Provider.of({ |
| 237 | stables: ["serverId", "unitName"], |
| 238 | // systemd units have no Hetzner list API. Nuke deletes the |
| 239 | // Server they live on; empty list is the documented shape for |
| 240 | // parent-keyed resources (Provider.list). |
| 241 | nuke: { dependsOn: ["Hetzner.Server"] }, |
| 242 | list: () => Effect.succeed([]), |
| 243 | diff: Effect.fn(function* ({ id, news, output }) { |
| 244 | if (!isResolved(news)) return undefined; |
| 245 | const nextServer = serverIdOf(news.server); |
| 246 | if ( |
| 247 | output !== undefined && |
| 248 | nextServer !== undefined && |
| 249 | output.serverId !== nextServer |
| 250 | ) { |
| 251 | return { action: "replace" } as const; |
| 252 | } |
| 253 | if (output !== undefined && news.main) { |
| 254 | const { hash } = yield* hosted.bundleProgram(id, news); |
| 255 | if (hash !== output.code.hash) { |
| 256 | return { action: "update" } as const; |
| 257 | } |
| 258 | } |
| 259 | return undefined; |
| 260 | }), |
| 261 | read: Effect.fn(function* ({ olds, output }) { |
| 262 | if (output === undefined) return undefined; |
| 263 | const serverId = |
| 264 | output.serverId ?? |
| 265 | (olds !== undefined ? serverIdOf(olds.server) : undefined); |
| 266 | if (serverId === undefined) return undefined; |
| 267 | const live = yield* Hetzner.servers.getServer({ id: serverId }).pipe( |
| 268 | Effect.map(({ server }) => server), |
| 269 | Effect.catchTag("NotFound", () => Effect.succeed(undefined)), |
| 270 | ); |
| 271 | if (live === undefined) return undefined; |
| 272 | return { |
| 273 | ...output, |
| 274 | serverId: live.id, |
| 275 | ipv4: live.public_net.ipv4?.ip, |
| 276 | url: serviceUrl(live.public_net.ipv4?.ip, output.port), |
| 277 | }; |
| 278 | }), |
| 279 | reconcile: Effect.fn(function* ({ id, news, output, bindings }) { |
| 280 | const serverId = serverIdOf(news.server); |
| 281 | if (serverId === undefined) { |
| 282 | return yield* new ServiceError({ |
| 283 | message: "Service requires a resolved Server", |
| 284 | }); |
| 285 | } |
| 286 | const ipv4 = ipv4Of(news.server); |
| 287 | if (ipv4 === undefined) { |
| 288 | return yield* new ServiceError({ |
| 289 | message: "Service requires the Server to have a public IPv4", |
| 290 | }); |
| 291 | } |
| 292 | const port = news.port ?? DEFAULT_PORT; |
| 293 | const unitName = yield* createUnitName(id, output?.unitName); |
| 294 | const { env: bindingEnv, volumes } = collectBindingState( |
| 295 | bindings as ResourceBinding<ServiceBinding>[], |
| 296 | ); |
| 297 | const env = { |
| 298 | ...bindingEnv, |
| 299 | ...hosted.alchemyEnv, |
| 300 | ...(news.port !== undefined ? { PORT: String(news.port) } : {}), |
| 301 | ...news.env, |
| 302 | }; |
| 303 | |
| 304 | const { archive, hash, entryRel } = yield* hosted.bundleProgram( |
| 305 | id, |
| 306 | news, |
| 307 | ); |
| 308 | |
| 309 | const ssh = yield* openSession(news); |
| 310 | yield* Effect.ensuring( |
| 311 | Effect.gen(function* () { |
| 312 | yield* hosted.waitForSsh(ssh); |
| 313 | // Attach (no automount) then mkdir+mount+fstab. The same |
| 314 | // (volume, server, path) from two Services is one attach |
| 315 | // and one mount — both steps are independently idempotent. |
| 316 | yield* hosted.attachAndMount({ |
| 317 | ssh, |
| 318 | serverId, |
| 319 | volumes, |
| 320 | }); |
| 321 | yield* hosted.deployUnit({ |
| 322 | ssh, |
| 323 | unitName, |
| 324 | archive, |
| 325 | env, |
| 326 | entryRel, |
| 327 | }); |
| 328 | }), |
| 329 | ssh.close, |
| 330 | ); |
| 331 | |
| 332 | return { |
| 333 | serverId, |
| 334 | ipv4, |
| 335 | port, |
| 336 | url: serviceUrl(ipv4, port), |
| 337 | unitName, |
| 338 | code: { hash }, |
| 339 | }; |
| 340 | }), |
| 341 | delete: Effect.fn(function* ({ olds, output }) { |
| 342 | const ssh = yield* openSession(olds).pipe( |
| 343 | Effect.catchTag("Hetzner.SshError", () => |
| 344 | Effect.succeed(undefined), |
| 345 | ), |
| 346 | ); |
| 347 | if (ssh === undefined) return; |
| 348 | yield* Effect.ensuring( |
| 349 | hosted.removeUnit({ ssh, unitName: output.unitName }), |
| 350 | ssh.close, |
| 351 | ); |
| 352 | }), |
| 353 | }); |
| 354 | }), |
| 355 | ); |