| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Redacted from "effect/Redacted"; |
| 3 | import { AlchemyContext } from "../../AlchemyContext.ts"; |
| 4 | import type { MemoOptions } from "../../Command/Memo.ts"; |
| 5 | import * as Output from "../../Output.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 type { Branch } from "../Branch.ts"; |
| 10 | import type { BranchScope } from "../BranchScope.ts"; |
| 11 | import { CustomDomain } from "../CustomDomain.ts"; |
| 12 | import { Function, type FunctionCommonProps } from "../Function.ts"; |
| 13 | import { Project } from "../Project.ts"; |
| 14 | import type { Providers } from "../Providers.ts"; |
| 15 | import { WebsiteArtifact } from "./Artifact.ts"; |
| 16 | |
| 17 | /** Deployment controls; the composition owns code, environment, and runtime wiring. */ |
| 18 | export type WebsiteFunctionOptions = Pick<FunctionCommonProps, "slug" | "name">; |
| 19 | |
| 20 | /** Shared framework configuration, independent of backend scope. */ |
| 21 | export 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. */ |
| 42 | export 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. */ |
| 48 | export type FrameworkSiteProps = FrameworkSiteOptions & WebsiteScope; |
| 49 | |
| 50 | /** Outputs of a Neon framework or static website. */ |
| 51 | export 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. */ |
| 65 | export 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. */ |
| 81 | export 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. */ |
| 92 | export 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. */ |
| 126 | export 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 | }); |