| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Output from "../../Output.ts"; |
| 3 | import * as Redacted from "effect/Redacted"; |
| 4 | import { AlchemyContext } from "../../AlchemyContext.ts"; |
| 5 | import type { MemoOptions } from "../../Command/Memo.ts"; |
| 6 | import { ProviderModePolicy } from "../../ProviderMode.ts"; |
| 7 | import type { WebsiteAssetsProps } from "../../Website/assets.ts"; |
| 8 | import { Server, type ServerDevProps } from "../../Website/Server.ts"; |
| 9 | import { Compute, type ComputeProps } from "../Compute.ts"; |
| 10 | import { CustomDomain } from "../CustomDomain.ts"; |
| 11 | import { Project } from "../Project.ts"; |
| 12 | import type { Providers } from "../Providers.ts"; |
| 13 | import type { PrismaRegionId } from "../Types.ts"; |
| 14 | import { WebsiteArtifact } from "./Artifact.ts"; |
| 15 | |
| 16 | /** A Prisma Project resource, ID, or lightweight ID reference. */ |
| 17 | export 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. */ |
| 26 | export 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. */ |
| 42 | export 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. */ |
| 75 | export 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. */ |
| 87 | export 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. */ |
| 103 | export 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. */ |
| 114 | export 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. */ |
| 156 | export 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 | }); |