1import * as Effect from "effect/Effect";
2import * as Path from "effect/Path";
3import * as Redacted from "effect/Redacted";
4import { AlchemyContext } from "../../AlchemyContext.ts";
5import * as Command from "../../Command/index.ts";
6import * as Namespace from "../../Namespace.ts";
7import * as Output from "../../Output.ts";
8import { ProviderModePolicy } from "../../ProviderMode.ts";
9import { initialCwd } from "../../Util/Node.ts";
10import { WebsiteArtifact } from "./Artifact.ts";
11import {
12 deployWebsite,
13 type FrameworkSiteProps,
14 type Website,
15} from "./FrameworkSite.ts";
16import { loadFrontendCore } from "../../Website/FrontendCore.ts";
17
18/** A command-built website with the same build vocabulary as Fly and Railway. */
19export interface StaticSiteProps
20 extends
21 Omit<Command.BuildProps, "env">,
22 Omit<FrameworkSiteProps, "dev" | "memo"> {
23 /** Local command; skips the production build. Without a command, a local static server serves the built output. */
24 dev?: {
25 /** Shell command that starts the native dev server. */
26 command: string;
27 /** Dev command directory; defaults to cwd or rootDir. */
28 cwd?: string;
29 /** Additional development environment variables. */
30 env?: Record<string, string | Redacted.Redacted<string>>;
31 /** Explicit dev URL when the command does not print one. */
32 url?: string;
33 };
34 /** Return index.html on unmatched paths. Mutually exclusive with errorPage. */
35 spa?: boolean;
36 /** HTML file returned with HTTP 404; relative to outdir. Mutually exclusive with spa. */
37 errorPage?: string;
38}
39
40const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
41
42/**
43 * Build a static website with a shell command and serve it on Prisma Compute.
44 * Only the output directory is packaged; the repository is never uploaded.
45 * Native development declares no Prisma Project, Compute, or custom domain.
46 * `Alchemy.remote()` opts into the live deployment during development.
47 *
48 * ### Creating a Static Website
49 * **Example:** Hugo website
50 * ```typescript
51 * const site = yield* Prisma.Website.StaticSite("Blog", {
52 * command: "hugo --minify",
53 * outdir: "public",
54 * });
55 * ```
56 *
57 * ### Monorepos and Development
58 * **Example:** A custom frontend build
59 * ```typescript
60 * const site = yield* Prisma.Website.StaticSite("Web", {
61 * cwd: "apps/web",
62 * command: "npm run build",
63 * outdir: "dist",
64 * spa: true,
65 * dev: { command: "npm run dev" },
66 * });
67 * ```
68 *
69 * @resource
70 * @product Website
71 */
72export const StaticSite = (id: string, props: StaticSiteProps) =>
73 Effect.gen(function* () {
74 const context = yield* AlchemyContext;
75 const remote = yield* ProviderModePolicy;
76 const local = context.dev && remote !== true;
77 const cwd = props.cwd ?? props.rootDir;
78 if (local && props.dev !== undefined) {
79 const dev = yield* Command.Dev("Dev", {
80 command: props.dev.command,
81 cwd: props.dev.cwd ?? cwd,
82 env: { ...props.env, ...props.dev.env },
83 });
84 return {
85 url: props.dev.url ?? dev.url,
86 compute: undefined,
87 project: undefined,
88 domain: undefined,
89 } satisfies Website;
90 }
91 if (props.spa && props.errorPage !== undefined) {
92 return yield* Effect.die(
93 new Error("StaticSite spa and errorPage are mutually exclusive."),
94 );
95 }
96 const build = yield* Command.Build("Build", {
97 command: props.command,
98 cwd,
99 env: props.env,
100 outdir: props.outdir,
101 memo: props.memo,
102 shell: props.shell,
103 timeout: props.timeout,
104 });
105 const handling = props.assets?.notFoundHandling;
106 const options = {
107 notFoundHandling:
108 props.errorPage !== undefined
109 ? ("404-page" as const)
110 : props.spa
111 ? ("spa" as const)
112 : handling === "single-page-application"
113 ? ("spa" as const)
114 : (handling ?? ("none" as const)),
115 htmlHandling: props.assets?.htmlHandling,
116 errorPage: props.errorPage,
117 };
118 if (local) {
119 const { makeNodeServeEntrySource } = yield* loadFrontendCore;
120 const path = yield* Path.Path;
121 const runtime = yield* Effect.sync(() => process.execPath);
122 const command = Output.map(build.outdir, (outdir) => {
123 const source = makeNodeServeEntrySource({
124 ...options,
125 clientDirExpression: JSON.stringify(path.resolve(initialCwd, outdir)),
126 printUrl: true,
127 });
128 return `${quote(runtime)} --eval ${quote(source)}`;
129 });
130 const dev = yield* Command.Dev("Dev", {
131 command,
132 cwd,
133 shell: true,
134 env: { ...props.env, HOST: "127.0.0.1", PORT: "0" },
135 });
136 return {
137 url: dev.url,
138 compute: undefined,
139 project: undefined,
140 domain: undefined,
141 } satisfies Website;
142 }
143 const artifact = yield* WebsiteArtifact("Artifact", {
144 root: cwd ?? ".",
145 distDir: build.outdir,
146 buildHash: build.hash.output,
147 static: options,
148 });
149 return yield* deployWebsite(props, artifact);
150 }).pipe(Namespace.push(id));