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 Namespace from "../../Namespace.ts";
9import * as Output from "../../Output.ts";
10import { ProviderModePolicy } from "../../ProviderMode.ts";
11import type { ExtraFile } from "../../Util/extraFiles.ts";
12import { initialCwd } from "../../Util/Node.ts";
13import {
14 staticConfigFromAssets,
15 type WebsiteAssetsProps,
16 type WebsiteNotFoundHandling,
17} from "../../Website/assets.ts";
18import { packSiteExtraFiles } from "../../Website/packExtraFiles.ts";
19import {
20 Server as FrameworkServer,
21 type ServerDevProps,
22} from "../../Website/Server.ts";
23import {
24 Service,
25 type ResourceRequirements,
26 type RevisionScaling,
27 type ServiceProps,
28} from "../Run/Service.ts";
29
30export type { ServerDevProps, WebsiteAssetsProps, WebsiteNotFoundHandling };
31export { staticConfigFromAssets };
32
33/** Port the Node serve entry listens on (Cloud Run injects `PORT`). */
34export const WEBSITE_PORT = 8080;
35
36/** Process environment value for a hosted website. */
37export type WebsiteEnvValue =
38 | string
39 | Redacted.Redacted<string>
40 | Output.Output<string | undefined>;
41
42/**
43 * Cloud Run knobs shared by every GCP website composite (framework sites
44 * and `StaticSite`).
45 */
46export interface WebsiteServiceProps {
47 /**
48 * Allow unauthenticated requests. `true` disables Cloud Run's invoker IAM
49 * check so anyone can load the site; `false` requires a Google-signed
50 * identity token on every request.
51 * @default true
52 */
53 public?: boolean;
54 /**
55 * Ingress (`INGRESS_TRAFFIC_ALL`, `INGRESS_TRAFFIC_INTERNAL_ONLY`,
56 * `INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER`).
57 * @default "INGRESS_TRAFFIC_ALL"
58 */
59 ingress?: ServiceProps["ingress"];
60 /**
61 * Revision scaling. Set `minInstanceCount: 1` to avoid cold starts.
62 */
63 scaling?: RevisionScaling;
64 /**
65 * CPU / memory for the server container, e.g.
66 * `{ limits: { cpu: "1", memory: "1Gi" } }`.
67 * @default Cloud Run's default (1 vCPU, 512Mi)
68 */
69 resources?: ResourceRequirements;
70 /**
71 * Request timeout (e.g. `"60s"`).
72 * @default "300s"
73 */
74 timeout?: string;
75 /**
76 * Max concurrent requests per instance.
77 * @default 80
78 */
79 maxInstanceRequestConcurrency?: number;
80 /**
81 * Runtime service account email. When omitted, Alchemy mints a
82 * dedicated service account with no roles (deleted on destroy).
83 */
84 serviceAccount?: string;
85 /**
86 * User labels applied to the Cloud Run service. Alchemy ownership labels
87 * are merged in automatically.
88 */
89 tags?: Record<string, string>;
90}
91
92/**
93 * Props shared by every GCP framework website composite.
94 */
95export interface FrameworkSiteProps extends WebsiteServiceProps {
96 /**
97 * Project root directory (the directory containing `package.json`).
98 * @default "."
99 */
100 rootDir?: string;
101 /**
102 * Controls which files are hashed to decide whether the build re-runs.
103 * @default true
104 */
105 memo?: MemoOptions | boolean;
106 /**
107 * Process environment for the hosted server (Cloud Run container env
108 * vars). Accepts `Output`s (e.g. `VITE_API_URL: api.uri`). `Redacted`
109 * values are unwrapped into plain container env vars.
110 */
111 env?: Record<string, WebsiteEnvValue>;
112 /**
113 * Static-asset routing (`notFoundHandling`, `htmlHandling`). Client
114 * files are baked into the container image and served by the Node
115 * server.
116 */
117 assets?: WebsiteAssetsProps;
118 /**
119 * Options for the local dev server that runs this site under
120 * `alchemy dev`.
121 */
122 dev?: ServerDevProps;
123}
124
125/** Per-framework wiring for {@link makeFrameworkSite}. */
126export interface FrameworkSiteConfig {
127 /** Display name used in error messages (e.g. `"SvelteKit"`). */
128 name: string;
129 /** Framework-integration module specifier. */
130 framework: string;
131 /** Node deploy-target module specifier. */
132 target: string;
133 /**
134 * Framework-specific build options forwarded to the integration (e.g.
135 * `{ kit }`, `{ nuxt }`, `{ astro }`). Must be JSON-serializable.
136 */
137 options?: Record<string, unknown> | undefined;
138 /**
139 * Assets-only routing hints (Vite SPA fallback). Accepted for parity
140 * with the other providers; the Node serve entry handles routing.
141 */
142 static?: { spa?: boolean; errorPage?: string } | undefined;
143 /**
144 * Vocs/Waku: serve `about/index.html` at `/about`.
145 * @default "none"
146 */
147 htmlHandling?: "none" | "drop-trailing-slash";
148 /**
149 * Packages installed into the image with `npm install` instead of
150 * bundling (Next.js needs `next`).
151 */
152 install?: string[] | undefined;
153 /**
154 * Ship `.next`, `public/`, and `next.config.*` from the app root instead
155 * of the build's dist directory (Next.js).
156 */
157 skipClientAssets?: boolean | undefined;
158}
159
160export interface FrameworkSite {
161 /**
162 * Public site URL. Local framework URL under `alchemy dev`; the Cloud
163 * Run service URL (`https://{service}-{hash}.{region}.run.app`) on
164 * deploy.
165 */
166 url: string | Output.Output<string | undefined> | undefined;
167 /** Hosting Cloud Run service. `undefined` during `alchemy dev`. */
168 service: Service | undefined;
169}
170
171export class FrameworkSiteError extends Data.TaggedError("FrameworkSiteError")<{
172 readonly framework: string;
173 readonly message: string;
174 readonly cause?: unknown;
175}> {}
176
177/** Unwrap `Redacted` values into plain container env vars. */
178export const websiteEnv = (
179 env: Record<string, WebsiteEnvValue> | undefined,
180): Record<string, string | Output.Output<string | undefined>> | undefined => {
181 if (env === undefined) return undefined;
182 return Object.fromEntries(
183 Object.entries(env).map(([key, value]) => [
184 key,
185 Redacted.isRedacted(value) ? Redacted.value(value) : value,
186 ]),
187 );
188};
189
190/**
191 * Cloud Run service props for a website whose image is the unbundled Node
192 * program `main` plus `extraFiles`.
193 */
194export const websiteServiceProps = (
195 props: WebsiteServiceProps & {
196 env?: Record<string, WebsiteEnvValue>;
197 },
198 program: {
199 main: string;
200 extraFiles: ReadonlyArray<ExtraFile>;
201 install?: string[] | undefined;
202 },
203): ServiceProps => ({
204 main: program.main,
205 // The Node serve entry is a complete program, not an Effect-native one.
206 isExternal: true,
207 extraFiles: program.extraFiles,
208 port: WEBSITE_PORT,
209 env: websiteEnv(props.env),
210 labels: props.tags,
211 ingress: props.ingress,
212 invokerIamDisabled: props.public ?? true,
213 template: {
214 timeout: props.timeout,
215 maxInstanceRequestConcurrency: props.maxInstanceRequestConcurrency,
216 scaling: props.scaling,
217 serviceAccount: props.serviceAccount,
218 containers:
219 props.resources !== undefined
220 ? [{ resources: props.resources }]
221 : undefined,
222 },
223 build:
224 program.install !== undefined && program.install.length > 0
225 ? { install: program.install }
226 : undefined,
227});
228
229const runFrameworkSite = Effect.fn("GCP.Website.FrameworkSite")(function* (
230 _id: string,
231 props: FrameworkSiteProps,
232 config: FrameworkSiteConfig,
233) {
234 const ctx = yield* AlchemyContext;
235 const remoted = yield* ProviderModePolicy;
236 const isLocal = ctx.dev && remoted !== true;
237 const path = yield* Path.Path;
238 const fs = yield* FileSystem.FileSystem;
239
240 const build = yield* FrameworkServer("Build", {
241 framework: config.framework,
242 target: config.target,
243 root: props.rootDir,
244 env: websiteEnv(props.env),
245 options: config.options,
246 memo: props.memo,
247 dev: props.dev,
248 });
249
250 if (isLocal) {
251 return { url: build.url, service: undefined } satisfies FrameworkSite;
252 }
253
254 // The build runs at apply time (`Website.Server` is a resource), so its
255 // attributes are Outputs here — derive every deploy input lazily.
256 const buildOut = Output.mapEffect(
257 ([serverEntry, distDir]: [string | undefined, string | undefined]) =>
258 Effect.gen(function* () {
259 if (serverEntry === undefined || distDir === undefined) {
260 return yield* Effect.die(
261 new FrameworkSiteError({
262 framework: config.framework,
263 message: `The ${config.name} build produced no Node serve entry (serverModules[0]). The Node deploy target should write serve-node.mjs.`,
264 }),
265 );
266 }
267 const main = path.resolve(initialCwd, serverEntry);
268 if (!(yield* fs.exists(main).pipe(Effect.orElseSucceed(() => false)))) {
269 return yield* Effect.die(
270 new FrameworkSiteError({
271 framework: config.framework,
272 message: `The ${config.name} build produced no server entry at ${main}`,
273 }),
274 );
275 }
276 return { distDir: path.resolve(initialCwd, distDir), main };
277 }),
278 )(
279 Output.all(
280 build.serverEntry as unknown as Output.Output<string | undefined>,
281 build.distDir as unknown as Output.Output<string | undefined>,
282 ) as unknown as Output.Output<[string | undefined, string | undefined]>,
283 );
284 const main = Output.map(buildOut, (out) => out.main);
285
286 // Always an array: `extraFiles` selects the unbundled Node image mode.
287 const extraFiles = Output.mapEffect(
288 (out: { distDir: string; main: string }) =>
289 packSiteExtraFiles(
290 out.distDir,
291 config.skipClientAssets === true ? "next" : "client",
292 ).pipe(Effect.map((files) => files ?? [])),
293 )(buildOut);
294
295 const service = yield* Service(
296 "Service",
297 websiteServiceProps(props, {
298 main: main as unknown as string,
299 extraFiles: extraFiles as unknown as ExtraFile[],
300 install: config.install,
301 }),
302 );
303
304 return {
305 url: service.uri,
306 service,
307 } satisfies FrameworkSite;
308});
309
310/**
311 * Shared implementation behind the GCP framework website composites:
312 * `Website.Server` runs the framework toolchain (dev server / production
313 * build), then a live deploy bakes `serve-node.mjs` plus the build output
314 * into a Node image in Artifact Registry and runs it on a Cloud Run
315 * service.
316 *
317 * Callers pipe `Namespace.push(id)` themselves (the composites do).
318 *
319 * Composite-level tagged errors (`FrameworkSiteError`, filesystem) are
320 * defects — `Alchemy.Stack` only admits `ConfigError` on the user effect.
321 */
322export const makeFrameworkSite = (
323 id: string,
324 props: FrameworkSiteProps,
325 config: FrameworkSiteConfig,
326) => runFrameworkSite(id, props, config).pipe(Effect.orDie);
327
328/** Push {@link id} then run {@link makeFrameworkSite}. */
329export const frameworkSite = (
330 id: string,
331 props: FrameworkSiteProps,
332 config: FrameworkSiteConfig,
333) => makeFrameworkSite(id, props, config).pipe(Namespace.push(id));