1import * as Effect from "effect/Effect";
2import * as Redacted from "effect/Redacted";
3import { AlchemyContext } from "../../AlchemyContext.ts";
4import type { MemoOptions } from "../../Command/Memo.ts";
5import * as Output from "../../Output.ts";
6import { ProviderModePolicy } from "../../ProviderMode.ts";
7import type { WebsiteAssetsProps } from "../../Website/assets.ts";
8import { Server, type ServerDevProps } from "../../Website/Server.ts";
9import type { Branch } from "../Branch.ts";
10import type { BranchScope } from "../BranchScope.ts";
11import { CustomDomain } from "../CustomDomain.ts";
12import { Function, type FunctionCommonProps } from "../Function.ts";
13import { Project } from "../Project.ts";
14import type { Providers } from "../Providers.ts";
15import { WebsiteArtifact } from "./Artifact.ts";
16
17/** Deployment controls; the composition owns code, environment, and runtime wiring. */
18export type WebsiteFunctionOptions = Pick<FunctionCommonProps, "slug" | "name">;
19
20/** Shared framework configuration, independent of backend scope. */
21export interface FrameworkSiteOptions {
22 /** Application directory containing package.json and framework configuration. @default "." */
23 rootDir?: string;
24 /** Build input hashing. Set false to rebuild every deployment. @default true */
25 memo?: MemoOptions | boolean;
26 /** Build, development, and runtime values. Public framework prefixes are browser-visible; Redacted does not prevent build tools from embedding a value. */
27 env?: Record<
28 string,
29 string | Redacted.Redacted<string> | Output.Output<string | undefined>
30 >;
31 /** Static-file routing within the Function, not a separate CDN. */
32 assets?: WebsiteAssetsProps;
33 /** Native framework dev server, including external-server mode. */
34 dev?: ServerDevProps;
35 /** Custom hostname. Publish domain.cnameTarget as a DNS-only CNAME and verify HTTPS separately. */
36 domain?: string;
37 /** Supported Function controls; memory, listening ports, and Docker options are not available. */
38 function?: WebsiteFunctionOptions;
39}
40
41/** Scope references are forwarded to the composed resources without resolution. */
42export type WebsiteScope =
43 | { branch: Branch | NonNullable<BranchScope["branch"]>; project?: never }
44 | { project: Project | NonNullable<BranchScope["project"]>; branch?: never }
45 | { project?: never; branch?: never };
46
47/** Explicit branch or project, or an Ohio project owned only on live deployments. */
48export type FrameworkSiteProps = FrameworkSiteOptions & WebsiteScope;
49
50/** Outputs of a Neon framework or static website. */
51export interface Website {
52 /** Native dev URL locally, otherwise the Function or configured custom-domain URL. */
53 url: string | Output.Output<string | undefined> | undefined;
54 /** Function serving the artifact; absent during native development. */
55 function: Function | undefined;
56 /** Explicit or owned project reference; explicit references are preserved locally. */
57 project: WebsiteScope["project"];
58 /** Explicit branch reference, preserved locally without transferring ownership. */
59 branch: WebsiteScope["branch"];
60 /** Custom-domain registration and DNS target; absent during native development. */
61 domain: CustomDomain | undefined;
62}
63
64/** Internal framework-to-Fetch target wiring. */
65export interface FrameworkSiteConfig {
66 /** Framework integration module. */
67 framework: string;
68 /** Neon Fetch deploy-target module. */
69 target: string;
70 /** Serializable native framework overrides. */
71 options?: Record<string, unknown>;
72 /** Next.js keeps output and configuration beside its entrypoint. */
73 layout?: "output" | "next";
74 /** Default unmatched-path behavior. */
75 notFoundHandling?: "none" | "spa" | "404-page";
76 /** Default extensionless HTML behavior. */
77 htmlHandling?: "none" | "drop-trailing-slash";
78}
79
80/** Unwrap build values without changing the runtime's redacted props. */
81export const websiteBuildEnv = (env: FrameworkSiteProps["env"]) =>
82 env === undefined
83 ? undefined
84 : Object.fromEntries(
85 Object.entries(env).map(([key, value]) => [
86 key,
87 Redacted.isRedacted(value) ? Redacted.value(value) : value,
88 ]),
89 );
90
91/** Live composition; explicit projects and branches remain independently owned. */
92export const deployWebsite = Effect.fn(function* (
93 props: Omit<FrameworkSiteOptions, "dev"> & WebsiteScope,
94 artifact: WebsiteArtifact,
95): Effect.fn.Return<Website, never, Providers> {
96 const project = props.branch
97 ? undefined
98 : (props.project ??
99 (yield* Project("Project", {
100 // Validate the artifact before provisioning an implicit backend.
101 region: Output.map(artifact.hash, () => "aws-us-east-2" as const),
102 })));
103 const scope =
104 props.branch !== undefined
105 ? { branch: props.branch }
106 : { project: project! };
107 const fn = yield* Function("Function", {
108 ...props.function,
109 ...scope,
110 artifact: { zip: artifact.artifactPath },
111 env: { ...props.env, NODE_ENV: "production" },
112 });
113 const domain = props.domain
114 ? yield* CustomDomain("Domain", { function: fn, hostname: props.domain })
115 : undefined;
116 return {
117 url: domain ? domain.url : fn.url,
118 function: fn,
119 project,
120 branch: props.branch,
121 domain,
122 };
123});
124
125/** Compose native development or a production Fetch build with a traced artifact. */
126export const makeFrameworkSite = Effect.fn(function* (
127 _id: string,
128 props: FrameworkSiteProps,
129 config: FrameworkSiteConfig,
130) {
131 const context = yield* AlchemyContext;
132 const remote = yield* ProviderModePolicy;
133 if (props.project !== undefined && props.branch !== undefined) {
134 return yield* Effect.die(
135 new Error("Specify branch or project, never both."),
136 );
137 }
138 const handling = props.assets?.notFoundHandling;
139 const targetConfig = {
140 notFoundHandling:
141 handling === "single-page-application"
142 ? "spa"
143 : (handling ?? config.notFoundHandling),
144 htmlHandling: props.assets?.htmlHandling ?? config.htmlHandling,
145 };
146 const build = yield* Server("Build", {
147 framework: config.framework,
148 target: config.target,
149 root: props.rootDir,
150 env: websiteBuildEnv(props.env),
151 options: { ...config.options, ...targetConfig, targetConfig },
152 memo: props.memo,
153 dev: props.dev,
154 });
155 if (context.dev && remote !== true) {
156 return {
157 url: build.url,
158 function: undefined,
159 project: props.project,
160 branch: props.branch,
161 domain: undefined,
162 } satisfies Website;
163 }
164 const requiredPath = (value: string | undefined) =>
165 value === undefined
166 ? Effect.die(
167 new Error(
168 `The ${config.framework} build produced no Neon Fetch output.`,
169 ),
170 )
171 : Effect.succeed(value);
172 const artifact = yield* WebsiteArtifact("Artifact", {
173 root: props.rootDir ?? ".",
174 distDir: Output.mapEffect(requiredPath)(build.distDir),
175 serverEntry: Output.mapEffect(requiredPath)(build.serverEntry),
176 layout: config.layout,
177 buildHash: build.hash.output,
178 });
179 return yield* deployWebsite(props, artifact);
180});