1import * as Hetzner from "@distilled.cloud/hetzner";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Redacted from "effect/Redacted";
5import * as Bundle from "../Bundle/Bundle.ts";
6import { isResolved } from "../Diff.ts";
7import { createPhysicalName } from "../PhysicalName.ts";
8import { Platform, type Main, type PlatformProps } from "../Platform.ts";
9import * as Provider from "../Provider.ts";
10import { Resource, type ResourceBinding } from "../Resource.ts";
11import type { ServerHost } from "../Server/Process.ts";
12import { Stack } from "../Stack.ts";
13import type { ServiceBinding } from "./MountVolume.ts";
14import type { Providers } from "./Providers.ts";
15import type { Server } from "./Server.ts";
16import { SshError, sshClientForServer } from "./Ssh.ts";
17import {
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 */
29type Ref<T> = T | Effect.Effect<T, never, Providers>;
30
31export 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
78export 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
101export const isService = (value: unknown): value is Service =>
102 typeof value === "object" &&
103 value !== null &&
104 (value as { Type?: string }).Type === "Hetzner.Service";
105
106export type ServiceServices = ServerHost;
107
108export type ServiceShape = Main<ServiceServices>;
109
110export 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 */
153export 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
173export class ServiceError extends Data.TaggedError("Hetzner.ServiceError")<{
174 message: string;
175}> {}
176
177const DEFAULT_PORT = 3000;
178
179const 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
187const 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
193const 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
200const 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
207const 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
216const serviceUrl = (ipv4: string | undefined, port: number) =>
217 ipv4 !== undefined ? `http://${ipv4}:${port}` : undefined;
218
219export 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 );