1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
5import * as Redacted from "effect/Redacted";
6import { AlchemyContext } from "../../AlchemyContext.ts";
7import type { MemoOptions } from "../../Command/Memo.ts";
8import * as Output from "../../Output.ts";
9import { ProviderModePolicy } from "../../ProviderMode.ts";
10import { initialCwd } from "../../Util/Node.ts";
11import {
12 staticConfigFromAssets,
13 type WebsiteAssetsProps,
14 type WebsiteNotFoundHandling,
15} from "../../Website/assets.ts";
16import { packSiteExtraFiles } from "../../Website/packExtraFiles.ts";
17import {
18 Server as FrameworkServer,
19 type ServerDevProps,
20} from "../../Website/Server.ts";
21import type { Providers } from "../Providers.ts";
22import { RecordSet } from "../RecordSet.ts";
23import { Server } from "../Server.ts";
24import { Service } from "../Service.ts";
25import type { Zone } from "../Zone.ts";
26
27/**
28 * A resource-valued prop: the resource itself, or an Effect that produces
29 * it (so `yield* Server(...)` and `Server(...)` both type-check).
30 */
31export type Ref<T> = T | Effect.Effect<T, never, Providers>;
32
33const resolveRef = <T>(ref: Ref<T>): Effect.Effect<T, never, Providers> =>
34 Effect.isEffect(ref) ? ref : Effect.succeed(ref);
35
36/** Default listen port. Hetzner `deployUnit` curls `/health` whenever `PORT` is set. */
37export const DEFAULT_WEBSITE_PORT = 3000;
38
39export type { ServerDevProps, WebsiteAssetsProps, WebsiteNotFoundHandling };
40export { staticConfigFromAssets };
41
42/**
43 * Props shared by every Hetzner framework website composite.
44 */
45export interface FrameworkSiteProps {
46 /**
47 * Project root directory (the directory containing `package.json`).
48 * @default "."
49 */
50 rootDir?: string;
51 /**
52 * Controls which files are hashed to decide whether the build re-runs.
53 * @default true
54 */
55 memo?: MemoOptions | boolean;
56 /**
57 * Options for the local dev server that runs this site under
58 * `alchemy dev`.
59 */
60 dev?: ServerDevProps;
61 /**
62 * Process environment for the hosted unit (and the local framework
63 * dev server). Not Cloudflare Worker bindings. Accepts `Output`s
64 * (e.g. `VITE_API_URL: api.url`).
65 */
66 env?: Record<
67 string,
68 string | Redacted.Redacted<string> | Output.Output<string | undefined>
69 >;
70 /**
71 * Static-asset routing (`notFoundHandling`, `htmlHandling`).
72 */
73 assets?: WebsiteAssetsProps;
74 /**
75 * Optional custom domain. Creates an A {@link RecordSet} on
76 * {@link zone} pointing at the Server's public IPv4. The site `url`
77 * becomes `http://{domain}:{port}` (no TLS on Service).
78 */
79 domain?: string;
80 /**
81 * Existing Hetzner DNS Zone `domain` is created in. Required when
82 * {@link domain} is set — v1 does not provision a Zone.
83 */
84 zone?: Ref<Zone>;
85 /**
86 * Server the site's Service runs on. Accepts a `Hetzner.Server` or an
87 * Effect that produces one. When omitted, a `cpx12` / `ubuntu-24.04`
88 * Server is created in `fsn1` (public IPv4 is the Server default).
89 */
90 server?: Ref<Server>;
91 /**
92 * User-defined labels applied to auto-created Server / RecordSet.
93 */
94 tags?: Record<string, string>;
95}
96
97/** Per-framework wiring for {@link makeFrameworkSite}. */
98export interface FrameworkSiteConfig {
99 /** Display name used in error messages (e.g. `"SvelteKit"`). */
100 name: string;
101 /** Framework-integration module specifier. */
102 framework: string;
103 /** Node container deploy-target module specifier. */
104 target: string;
105 /**
106 * Framework-specific build options forwarded to the integration (e.g.
107 * `{ kit }`, `{ nuxt }`, `{ astro }`). Must be JSON-serializable.
108 */
109 options?: Record<string, unknown> | undefined;
110 /**
111 * Assets-only mode: no server modules (or every page prerendered). The
112 * composite generates a static-file server as `main`.
113 */
114 static?:
115 | {
116 spa?: boolean | undefined;
117 errorPage?: string | undefined;
118 htmlHandling?: "none" | "drop-trailing-slash" | undefined;
119 }
120 | undefined;
121 /**
122 * Vocs/Waku: serve `about/index.html` at `/about`.
123 * @default "none"
124 */
125 htmlHandling?: "none" | "drop-trailing-slash";
126 /**
127 * Skip baking `clientDirectory` at `dist/`. Next.js serves `.next`
128 * from the unit root instead.
129 */
130 skipClientAssets?: boolean | undefined;
131 /**
132 * Native packages to `npm install` into the unit instead of bundling
133 * (Next.js needs `next` / `react` / `react-dom`).
134 */
135 install?: string[] | undefined;
136}
137
138export interface Website {
139 /**
140 * Local framework URL under `alchemy dev`, or the live Service URL
141 * (`http://{ipv4}:{port}` / `http://{domain}:{port}`).
142 */
143 readonly url: string | Output.Output<string | undefined> | undefined;
144 /** Server the unit runs on. `undefined` during `alchemy dev`. */
145 readonly server: Server | undefined;
146 /** Hosted systemd unit. `undefined` during `alchemy dev`. */
147 readonly service: Service | undefined;
148}
149
150export class FrameworkSiteError extends Data.TaggedError(
151 "Hetzner.Website.FrameworkSiteError",
152)<{
153 readonly framework: string;
154 readonly message: string;
155 readonly cause?: unknown;
156}> {}
157
158export const unwrapEnv = (
159 env:
160 | Record<
161 string,
162 string | Redacted.Redacted<string> | Output.Output<string | undefined>
163 >
164 | undefined,
165): Record<string, string | Output.Output<string | undefined>> | undefined => {
166 if (env === undefined) return undefined;
167 return Object.fromEntries(
168 Object.entries(env).map(([key, value]) => [
169 key,
170 Redacted.isRedacted(value) ? Redacted.value(value) : value,
171 ]),
172 );
173};
174
175export const resolveWebsiteServer = Effect.fn(function* (props: {
176 readonly server?: Ref<Server> | undefined;
177 readonly tags?: Record<string, string> | undefined;
178}) {
179 if (props.server !== undefined) {
180 return yield* resolveRef(props.server);
181 }
182 return yield* Server("Server", {
183 serverType: "cpx12",
184 image: "ubuntu-24.04",
185 location: "fsn1",
186 labels: props.tags,
187 });
188});
189
190export const bindWebsiteDomain = Effect.fn(function* (props: {
191 readonly domain: string;
192 readonly zone: Ref<Zone>;
193 readonly server: Server;
194 readonly tags?: Record<string, string> | undefined;
195}) {
196 const zone = yield* resolveRef(props.zone);
197 const name = Output.map((apex: string | undefined) => {
198 const domain = props.domain;
199 if (apex === undefined || domain === apex) return "@";
200 const suffix = `.${apex}`;
201 if (domain.endsWith(suffix)) return domain.slice(0, -suffix.length);
202 throw new Error(
203 `Hetzner.Website domain "${domain}" is not inside zone "${apex}"`,
204 );
205 })(zone.name as never);
206 yield* RecordSet("Domain", {
207 zone,
208 name: name as never,
209 type: "A",
210 records: [{ value: props.server.ipv4 as never }],
211 labels: props.tags,
212 });
213});
214
215export const websiteUrl = (args: {
216 readonly domain?: string | undefined;
217 readonly service: Service;
218 readonly port: number;
219}) =>
220 args.domain !== undefined
221 ? `http://${args.domain}:${String(args.port)}`
222 : args.service.url;
223
224/**
225 * Shared implementation behind the Hetzner framework website composites:
226 * build through the Node deploy target, then host one Service on a
227 * Hetzner Server (auto-created `cpx12` in `fsn1` when `server` is omitted).
228 *
229 * During `alchemy dev` the site is the framework's own dev server and no
230 * cloud resources are declared; `Alchemy.remote()` opts back into the
231 * live Service path.
232 */
233const runFrameworkSite = Effect.fn("Hetzner.Website.FrameworkSite")(function* (
234 id: string,
235 props: FrameworkSiteProps,
236 config: FrameworkSiteConfig,
237) {
238 const ctx = yield* AlchemyContext;
239 const remoted = yield* ProviderModePolicy;
240 const isLocal = ctx.dev && remoted !== true;
241 const path = yield* Path.Path;
242 const fs = yield* FileSystem.FileSystem;
243 const port = DEFAULT_WEBSITE_PORT;
244
245 if (config.static?.spa && config.static.errorPage) {
246 return yield* Effect.die(
247 `Cannot provide both "spa" and "errorPage". A SPA answers misses with the index page (200); "errorPage" answers them with a real 404.`,
248 );
249 }
250 if (props.domain !== undefined && props.zone === undefined) {
251 return yield* Effect.die(
252 `Hetzner.Website "${config.name}": "domain" requires "zone" (an existing Hetzner.Zone).`,
253 );
254 }
255
256 const build = yield* FrameworkServer("Build", {
257 framework: config.framework,
258 target: config.target,
259 root: props.rootDir,
260 env: unwrapEnv(props.env),
261 options: config.options,
262 memo: props.memo,
263 dev: props.dev,
264 });
265
266 if (isLocal) {
267 return {
268 url: build.url,
269 server: undefined,
270 service: undefined,
271 };
272 }
273
274 // The build runs at APPLY time (`Website.Server` is a resource), so its
275 // attributes are Outputs here — derive every deploy input lazily and let
276 // the engine resolve them once the build has produced real paths. Reading
277 // `build.distDir` eagerly in the composite body would observe an Output
278 // proxy, not a string.
279 const buildOut = Output.mapEffect(
280 ([serverEntry, distDir]: [string | undefined, string | undefined]) =>
281 Effect.gen(function* () {
282 if (serverEntry === undefined || distDir === undefined) {
283 return yield* Effect.die(
284 new FrameworkSiteError({
285 framework: config.framework,
286 message: `The ${config.name} build produced no Node serve entry (serverModules[0]). The Node deploy target should write serve-node.mjs.`,
287 }),
288 );
289 }
290 const main = path.resolve(initialCwd, serverEntry);
291 if (!(yield* fs.exists(main).pipe(Effect.orElseSucceed(() => false)))) {
292 return yield* Effect.die(
293 new FrameworkSiteError({
294 framework: config.framework,
295 message: `The ${config.name} build produced no server entry at ${main}`,
296 }),
297 );
298 }
299 return { distDir: path.resolve(initialCwd, distDir), main };
300 }),
301 )(
302 Output.all(
303 build.serverEntry as unknown as Output.Output<string | undefined>,
304 build.distDir as unknown as Output.Output<string | undefined>,
305 ) as unknown as Output.Output<[string | undefined, string | undefined]>,
306 );
307 const main = Output.map(buildOut, (out) => out.main);
308
309 const server = yield* resolveWebsiteServer(props);
310 const env = {
311 ...unwrapEnv(props.env),
312 PORT: String(port),
313 };
314
315 const extraFiles = Output.mapEffect(
316 (out: { distDir: string; main: string }) =>
317 packSiteExtraFiles(
318 out.distDir,
319 config.skipClientAssets === true ? "next" : "client",
320 ).pipe(
321 Effect.map((files) =>
322 files?.map((file) => ({
323 source: file.source,
324 destination: file.dest,
325 })),
326 ),
327 ),
328 )(buildOut);
329
330 const service = yield* Service("Service", {
331 server,
332 main: main as unknown as string,
333 extraFiles: extraFiles as unknown as Array<{
334 source: string;
335 destination: string;
336 }>,
337 port,
338 env,
339 isExternal: true,
340 build:
341 config.install !== undefined && config.install.length > 0
342 ? { install: config.install }
343 : undefined,
344 });
345
346 if (props.domain !== undefined && props.zone !== undefined) {
347 yield* bindWebsiteDomain({
348 domain: props.domain,
349 zone: props.zone,
350 server,
351 tags: props.tags,
352 });
353 }
354
355 return {
356 url: websiteUrl({ domain: props.domain, service, port }),
357 server,
358 service,
359 };
360});
361
362/**
363 * Composite-level tagged errors (`FrameworkSiteError`, filesystem) are
364 * defects — `Alchemy.Stack` only admits `ConfigError` on the user effect.
365 */
366export const makeFrameworkSite = (
367 id: string,
368 props: FrameworkSiteProps,
369 config: FrameworkSiteConfig,
370) => runFrameworkSite(id, props, config).pipe(Effect.orDie);