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 type WebsiteScope,
16} from "./FrameworkSite.ts";
17import { loadFrontendCore } from "../../Website/FrontendCore.ts";
18
19/** Configuration for a command-built Neon website. */
20export type StaticSiteProps = Omit<Command.BuildProps, "env"> &
21 Omit<FrameworkSiteProps, "dev" | "memo" | "project" | "branch"> &
22 WebsiteScope & {
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 Neon Functions.
44 * Only the output directory is packaged; the repository is never uploaded.
45 * Native development declares no implicit Neon Project, Function, 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* Neon.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* Neon.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 (props.project !== undefined && props.branch !== undefined) {
79 return yield* Effect.die(
80 new Error("Specify branch or project, never both."),
81 );
82 }
83 if (props.spa && props.errorPage !== undefined) {
84 return yield* Effect.die(
85 new Error("StaticSite spa and errorPage are mutually exclusive."),
86 );
87 }
88 if (local && props.dev !== undefined) {
89 const dev = yield* Command.Dev("Dev", {
90 command: props.dev.command,
91 cwd: props.dev.cwd ?? cwd,
92 env: { ...props.env, ...props.dev.env },
93 });
94 return {
95 url: props.dev.url ?? dev.url,
96 function: undefined,
97 project: props.project,
98 branch: props.branch,
99 domain: undefined,
100 } satisfies Website;
101 }
102 const build = yield* Command.Build("Build", {
103 command: props.command,
104 cwd,
105 env: props.env,
106 outdir: props.outdir,
107 memo: props.memo,
108 shell: props.shell,
109 timeout: props.timeout,
110 });
111 const handling = props.assets?.notFoundHandling;
112 const options = {
113 notFoundHandling:
114 props.errorPage !== undefined
115 ? ("404-page" as const)
116 : props.spa
117 ? ("spa" as const)
118 : handling === "single-page-application"
119 ? ("spa" as const)
120 : (handling ?? ("none" as const)),
121 htmlHandling: props.assets?.htmlHandling,
122 errorPage: props.errorPage,
123 };
124 if (local) {
125 const { makeNodeServeEntrySource } = yield* loadFrontendCore;
126 const path = yield* Path.Path;
127 const runtime = yield* Effect.sync(() => process.execPath);
128 const command = Output.map(build.outdir, (outdir) => {
129 let source = makeNodeServeEntrySource({
130 ...options,
131 clientDirExpression: JSON.stringify(path.resolve(initialCwd, outdir)),
132 printUrl: true,
133 });
134 if (props.errorPage)
135 source = source.replace(
136 'lookupStatic("/404.html")',
137 `lookupStatic(${JSON.stringify(`/${props.errorPage}`)})`,
138 );
139 return `${quote(runtime)} --eval ${quote(source)}`;
140 });
141 const dev = yield* Command.Dev("Dev", {
142 command,
143 cwd,
144 shell: true,
145 env: { ...props.env, HOST: "127.0.0.1", PORT: "0" },
146 });
147 return {
148 url: dev.url,
149 function: undefined,
150 project: props.project,
151 branch: props.branch,
152 domain: undefined,
153 } satisfies Website;
154 }
155 const artifact = yield* WebsiteArtifact("Artifact", {
156 root: cwd ?? ".",
157 distDir: build.outdir,
158 buildHash: build.hash.output,
159 static: options,
160 });
161 return yield* deployWebsite(props, artifact);
162 }).pipe(Namespace.push(id));