1import * as Effect from "effect/Effect";
2import * as Output from "../../Output.ts";
3import * as Redacted from "effect/Redacted";
4import { AlchemyContext } from "../../AlchemyContext.ts";
5import type { MemoOptions } from "../../Command/Memo.ts";
6import { ProviderModePolicy } from "../../ProviderMode.ts";
7import type { WebsiteAssetsProps } from "../../Website/assets.ts";
8import { Server, type ServerDevProps } from "../../Website/Server.ts";
9import { Compute, type ComputeProps } from "../Compute.ts";
10import { CustomDomain } from "../CustomDomain.ts";
11import { Project } from "../Project.ts";
12import type { Providers } from "../Providers.ts";
13import type { PrismaRegionId } from "../Types.ts";
14import { WebsiteArtifact } from "./Artifact.ts";
15
16/** A Prisma Project resource, ID, or lightweight ID reference. */
17export type WebsiteProjectReference =
18 | Project
19 | string
20 | {
21 /** Existing Prisma project ID; no project is created or owned by the site. */
22 readonly projectId: string | Output.Output<string>;
23 };
24
25/** Deployment controls that do not replace the framework build or artifact. */
26export type WebsiteComputeOptions = Pick<
27 ComputeProps,
28 | "appName"
29 | "port"
30 | "envClass"
31 | "start"
32 | "skipPromote"
33 | "destroyOldDeployment"
34 | "timeoutSeconds"
35 | "pollIntervalMs"
36 | "verifyUrl"
37 | "healthCheck"
38 | "urlReadinessTimeoutSeconds"
39>;
40
41/** Shared contract for Prisma's framework website composites. */
42export interface FrameworkSiteProps {
43 /** Existing Project or ID reference, optionally produced by an Effect. Omission creates a project without a database, only on live deployments. */
44 project?:
45 | WebsiteProjectReference
46 | Effect.Effect<WebsiteProjectReference, never, Providers>;
47 /** Application directory containing package.json and framework configuration. @default "." */
48 rootDir?: string;
49 /** Build input hashing options. Set false to rebuild on every deployment. @default true */
50 memo?: MemoOptions | boolean;
51 /** Build, development, and runtime environment. Wrap secrets in Redacted; public framework variables can be compiled into browser assets. */
52 env?: Record<
53 string,
54 string | Redacted.Redacted<string> | Output.Output<string | undefined>
55 >;
56 /** Origin static-file routing, using the same vocabulary as Fly and Railway websites. */
57 assets?: WebsiteAssetsProps;
58 /** Native framework development server options, including external-server mode. */
59 dev?: ServerDevProps;
60 /** Custom hostname attached using Prisma.CustomDomain. Only apps on the default branch support custom domains; configure the returned DNS records before routing traffic. */
61 domain?: string;
62 /** Compute region. Defaults to the project's default region, then us-east-1. */
63 regionId?: PrismaRegionId;
64 /** Existing branch ID. Mutually exclusive with branchGitName. */
65 branchId?: string;
66 /** Existing branch git name. Defaults to the project's default branch. */
67 branchGitName?: string;
68 /** Compute deployment, readiness, and rollout options. The website owns its build, artifact, and runtime environment. */
69 compute?: WebsiteComputeOptions;
70 /** Accepted for website API parity; Prisma Compute does not persist resource tags. */
71 tags?: Record<string, string>;
72}
73
74/** Outputs of a Prisma framework or static website. */
75export interface Website {
76 /** Native dev-server URL locally; Compute URL or configured custom-domain URL on live deployments. */
77 url: string | Output.Output<string | undefined> | undefined;
78 /** Compute app serving the artifact. Undefined during native development. */
79 compute: Compute | undefined;
80 /** Project resource or existing project reference. Undefined during native development. */
81 project: WebsiteProjectReference | undefined;
82 /** Custom-domain resource, including DNS records and certificate status. Undefined without domain or during native development. */
83 domain: CustomDomain | undefined;
84}
85
86/** Internal framework-to-Node-target wiring. */
87export interface FrameworkSiteConfig {
88 /** Framework integration module. */
89 framework: string;
90 /** Existing Node deploy-target module. */
91 target: string;
92 /** Serializable framework-specific overrides. */
93 options?: Record<string, unknown>;
94 /** Artifact layout; Next.js serves from the application root. */
95 layout?: "output" | "next";
96 /** Default not-found behavior, overridden by assets.notFoundHandling. */
97 notFoundHandling?: "none" | "spa" | "404-page";
98 /** Default extensionless HTML behavior, overridden by assets.htmlHandling. */
99 htmlHandling?: "none" | "drop-trailing-slash";
100}
101
102/** Resolve build-time secrets without changing the redacted runtime env props. */
103export const websiteBuildEnv = (env: FrameworkSiteProps["env"]) =>
104 env === undefined
105 ? undefined
106 : Object.fromEntries(
107 Object.entries(env).map(([key, value]) => [
108 key,
109 Redacted.isRedacted(value) ? Redacted.value(value) : value,
110 ]),
111 );
112
113/** Shared live half, called only after a build resource has been declared. */
114export const deployWebsite = Effect.fn(function* (
115 props: Omit<FrameworkSiteProps, "dev">,
116 artifact: WebsiteArtifact,
117): Effect.fn.Return<Website, never, Providers> {
118 const project = Effect.isEffect(props.project)
119 ? yield* props.project
120 : (props.project ??
121 (yield* Project("Project", {
122 createDatabase: false,
123 region: props.regionId,
124 })));
125 const port = props.compute?.port ?? 3000;
126 const compute = yield* Compute("Compute", {
127 ...props.compute,
128 project: typeof project === "string" ? project : project.projectId,
129 artifactPath: artifact.artifactPath,
130 isExternal: true,
131 port,
132 regionId: props.regionId,
133 branchId: props.branchId,
134 branchGitName: props.branchGitName,
135 env: {
136 ...props.env,
137 PORT: String(port),
138 HOST: "0.0.0.0",
139 NODE_ENV: "production",
140 },
141 });
142 const domain = props.domain
143 ? yield* CustomDomain("Domain", { app: compute, hostname: props.domain })
144 : undefined;
145 return {
146 url: domain
147 ? Output.map(domain.hostname, (hostname) => `https://${hostname}`)
148 : compute.url,
149 compute,
150 project,
151 domain,
152 };
153});
154
155/** Compose a real framework build/dev server with a traced Prisma artifact. */
156export const makeFrameworkSite = Effect.fn(function* (
157 _id: string,
158 props: FrameworkSiteProps,
159 config: FrameworkSiteConfig,
160) {
161 const context = yield* AlchemyContext;
162 const remote = yield* ProviderModePolicy;
163 const handling = props.assets?.notFoundHandling;
164 const targetConfig = {
165 notFoundHandling:
166 handling === "single-page-application"
167 ? "spa"
168 : (handling ?? config.notFoundHandling),
169 htmlHandling: props.assets?.htmlHandling ?? config.htmlHandling,
170 };
171 const build = yield* Server("Build", {
172 framework: config.framework,
173 target: config.target,
174 root: props.rootDir,
175 env: websiteBuildEnv(props.env),
176 options: { ...config.options, ...targetConfig, targetConfig },
177 memo: props.memo,
178 dev: props.dev,
179 });
180 if (context.dev && remote !== true) {
181 return {
182 url: build.url,
183 compute: undefined,
184 project: undefined,
185 domain: undefined,
186 } satisfies Website;
187 }
188 const requiredPath = (value: string | undefined) =>
189 value === undefined
190 ? Effect.die(
191 new Error(
192 `The ${config.framework} Node build produced no deployable server output.`,
193 ),
194 )
195 : Effect.succeed(value);
196 const artifact = yield* WebsiteArtifact("Artifact", {
197 root: props.rootDir ?? ".",
198 distDir: Output.mapEffect(requiredPath)(build.distDir),
199 serverEntry: Output.mapEffect(requiredPath)(build.serverEntry),
200 layout: config.layout,
201 buildHash: build.hash.output,
202 });
203 return yield* deployWebsite(props, artifact);
204});