| 1 | import type { InputProps } from "../../Input.ts"; |
| 2 | import * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import { AlchemyContext } from "../../AlchemyContext.ts"; |
| 5 | import type { MemoOptions } from "../../Command/Memo.ts"; |
| 6 | import type { Input } from "../../Input.ts"; |
| 7 | import * as Namespace from "../../Namespace.ts"; |
| 8 | import * as Output from "../../Output.ts"; |
| 9 | import { ProviderModePolicy } from "../../ProviderMode.ts"; |
| 10 | import { Table } from "../DynamoDB/Table.ts"; |
| 11 | import type { PolicyStatement } from "../IAM/Policy.ts"; |
| 12 | import { EventSourceMapping } from "../Lambda/EventSourceMapping.ts"; |
| 13 | import { |
| 14 | Function as LambdaFunction, |
| 15 | type FunctionProps, |
| 16 | } from "../Lambda/Function.ts"; |
| 17 | import { Bucket } from "../S3/Bucket.ts"; |
| 18 | import { Queue } from "../SQS/Queue.ts"; |
| 19 | import { AssetDeployment } from "./AssetDeployment.ts"; |
| 20 | import { asRouterDomain, registerDevRouterRoute } from "./DevRouterRoute.ts"; |
| 21 | import { Server, type ServerDevProps } from "../../Website/Server.ts"; |
| 22 | import { makeKvSite, type StaticSiteProps } from "./StaticSite.ts"; |
| 23 | import { |
| 24 | normalizeWebsiteDomain, |
| 25 | type WebsiteAssetsConfig, |
| 26 | type WebsiteDomainProps, |
| 27 | type WebsiteEdgeProps, |
| 28 | type WebsiteInvalidationProps, |
| 29 | } from "./shared.ts"; |
| 30 | |
| 31 | /** |
| 32 | * The framework-integration module that drives the `@opennextjs/aws` build |
| 33 | * (it is its own deploy target — the module IS the AWS pipeline). |
| 34 | */ |
| 35 | export const NEXTJS_AWS_FRAMEWORK_SPECIFIER = |
| 36 | "@alchemy.run/frontend-frameworks/nextjs/aws"; |
| 37 | |
| 38 | /** The S3 key prefix the OpenNext ISR/fetch cache seed is uploaded under. */ |
| 39 | export const NEXTJS_CACHE_PREFIX = "_cache"; |
| 40 | |
| 41 | export interface NextjsProps { |
| 42 | /** |
| 43 | * Project root directory (the directory containing `next.config.ts`). |
| 44 | * @default "." |
| 45 | */ |
| 46 | rootDir?: string; |
| 47 | /** |
| 48 | * Controls which files are hashed to decide whether the build re-runs. |
| 49 | * @default true |
| 50 | */ |
| 51 | memo?: MemoOptions | boolean; |
| 52 | /** |
| 53 | * Options for the local dev server that runs this site under |
| 54 | * `alchemy dev`. |
| 55 | */ |
| 56 | dev?: ServerDevProps; |
| 57 | /** |
| 58 | * SSR server (Lambda) configuration. |
| 59 | */ |
| 60 | /** |
| 61 | * Memory allocated to the server function, in MB. |
| 62 | * @default 1024 |
| 63 | */ |
| 64 | memorySize?: number; |
| 65 | /** |
| 66 | * Maximum server request duration. |
| 67 | * @default 30 seconds |
| 68 | */ |
| 69 | timeout?: Duration.Duration; |
| 70 | /** |
| 71 | * Environment variables for the SSR server (Lambda environment). |
| 72 | * Values accept `Output`s (e.g. `API_URL: api.url`). |
| 73 | */ |
| 74 | env?: Record<string, any>; |
| 75 | /** |
| 76 | * Server instruction set architecture. |
| 77 | * @default "x86_64" |
| 78 | */ |
| 79 | architecture?: "x86_64" | "arm64"; |
| 80 | /** |
| 81 | * Lambda runtime for the server function. |
| 82 | * @default "nodejs24.x" |
| 83 | */ |
| 84 | runtime?: FunctionProps["runtime"]; |
| 85 | /** |
| 86 | * Image optimization (Lambda) configuration. |
| 87 | */ |
| 88 | imageOptimization?: { |
| 89 | /** |
| 90 | * Memory allocated to the image optimization function, in MB. |
| 91 | * @default 1536 |
| 92 | */ |
| 93 | memorySize?: number; |
| 94 | }; |
| 95 | /** |
| 96 | * Static asset upload configuration. |
| 97 | */ |
| 98 | assets?: WebsiteAssetsConfig; |
| 99 | /** |
| 100 | * Optional custom domain. A string is shorthand for `{ name }`; `null` |
| 101 | * explicitly clears a previously set domain. Set `domain.router` to |
| 102 | * serve the site through an existing `AWS.Website.Router` instead of a |
| 103 | * standalone CloudFront distribution. |
| 104 | */ |
| 105 | domain?: string | WebsiteDomainProps | null; |
| 106 | /** |
| 107 | * Serve the site at its CloudFront default domain |
| 108 | * (`https://dxxxx.cloudfront.net`). `false` 301s default-domain requests |
| 109 | * to `https://<domain.name>` at the edge and excludes the default domain |
| 110 | * from the `urls` output. Requires `domain`; not applicable when |
| 111 | * `domain.router` is set. |
| 112 | * @default true |
| 113 | */ |
| 114 | cloudfrontUrl?: boolean; |
| 115 | /** |
| 116 | * Additional CloudFront Function customizations. |
| 117 | */ |
| 118 | edge?: WebsiteEdgeProps; |
| 119 | /** |
| 120 | * Optional deterministic S3 bucket name for the asset bucket. |
| 121 | */ |
| 122 | bucketName?: string; |
| 123 | /** |
| 124 | * Whether to delete uploaded objects when the bucket is destroyed. |
| 125 | * @default false |
| 126 | */ |
| 127 | forceDestroy?: boolean; |
| 128 | /** |
| 129 | * CloudFront invalidation behavior. |
| 130 | * @default { paths: "all", wait: false } |
| 131 | */ |
| 132 | invalidation?: false | WebsiteInvalidationProps; |
| 133 | /** |
| 134 | * User-defined tags applied to created resources. |
| 135 | */ |
| 136 | tags?: Record<string, string>; |
| 137 | } |
| 138 | |
| 139 | /** |
| 140 | * Deploy a Next.js application to AWS with the OpenNext |
| 141 | * (`@opennextjs/aws`) serverless topology: the SSR server on a streaming |
| 142 | * Lambda Function URL, static assets in S3 behind CloudFront's KV-manifest |
| 143 | * edge router, the ISR/fetch cache in a dedicated S3 bucket, a dedicated |
| 144 | * image optimization Lambda routed at `/_next/image`, and ISR revalidation |
| 145 | * through an SQS FIFO queue plus a DynamoDB tag-cache table. |
| 146 | * |
| 147 | * The build runs through `@alchemy.run/frontend-frameworks/nextjs/aws` (the |
| 148 | * `@opennextjs/aws` pipeline) — both it and `@opennextjs/aws` must be |
| 149 | * installed in your project. When the project has no `open-next.config.ts`, |
| 150 | * a minimal default with the streaming server wrapper is generated. |
| 151 | * |
| 152 | * During `alchemy dev` the site is Next's own dev server (`next dev`) and |
| 153 | * no cloud resources are declared; `Alchemy.remote()` opts back into the |
| 154 | * full live deployment. |
| 155 | * |
| 156 | * ### Creating Next.js Sites |
| 157 | * **Example:** Basic Next.js App |
| 158 | * ```typescript |
| 159 | * const site = yield* AWS.Website.Nextjs("Web", { |
| 160 | * rootDir: "./app", |
| 161 | * }); |
| 162 | * ``` |
| 163 | * |
| 164 | * **Example:** Custom Domain |
| 165 | * ```typescript |
| 166 | * const site = yield* AWS.Website.Nextjs("Web", { |
| 167 | * rootDir: "./app", |
| 168 | * domain: { |
| 169 | * name: "app.example.com", |
| 170 | * hostedZoneId: zone.hostedZoneId, |
| 171 | * }, |
| 172 | * }); |
| 173 | * ``` |
| 174 | * |
| 175 | * ### Server Configuration |
| 176 | * **Example:** Tune The Server Function |
| 177 | * ```typescript |
| 178 | * const site = yield* AWS.Website.Nextjs("Web", { |
| 179 | * rootDir: "./app", |
| 180 | * memorySize: 2048, |
| 181 | * env: { |
| 182 | * API_BASE: api.url, |
| 183 | * }, |
| 184 | * }); |
| 185 | * ``` |
| 186 | * |
| 187 | * @resource |
| 188 | */ |
| 189 | |
| 190 | export const Nextjs = Effect.fn("AWS.Website.Nextjs")( |
| 191 | function* (id: string, propsIn: InputProps<NextjsProps> = {}) { |
| 192 | const props = propsIn as NextjsProps; |
| 193 | const ctx = yield* AlchemyContext; |
| 194 | const remoted = yield* ProviderModePolicy; |
| 195 | // Mirrors the other Website composites: during `alchemy dev` the site |
| 196 | // is `next dev` (native HMR) and no cloud resources are declared; |
| 197 | // `Alchemy.remote()` opts back into the full live deployment. |
| 198 | const isLocal = ctx.dev && remoted !== true; |
| 199 | |
| 200 | const build = yield* Server("Build", { |
| 201 | framework: NEXTJS_AWS_FRAMEWORK_SPECIFIER, |
| 202 | target: NEXTJS_AWS_FRAMEWORK_SPECIFIER, |
| 203 | root: props.rootDir, |
| 204 | env: props.env, |
| 205 | memo: props.memo, |
| 206 | dev: props.dev, |
| 207 | }); |
| 208 | |
| 209 | if (isLocal) { |
| 210 | // Router-attached sites register with the Router in dev exactly as they |
| 211 | // do live — same resource types and ids — with `next dev` standing in |
| 212 | // for the S3 + Lambda origins. |
| 213 | const routerDomain = asRouterDomain(normalizeWebsiteDomain(props.domain)); |
| 214 | const kvNamespace = routerDomain |
| 215 | ? yield* registerDevRouterRoute(routerDomain, build.url) |
| 216 | : undefined; |
| 217 | return { |
| 218 | bucket: undefined, |
| 219 | build, |
| 220 | cacheBucket: undefined, |
| 221 | cacheFiles: undefined, |
| 222 | distribution: undefined, |
| 223 | files: undefined, |
| 224 | imageFunction: undefined, |
| 225 | imageUrl: undefined, |
| 226 | invalidation: undefined, |
| 227 | kvNamespace, |
| 228 | revalidationFunction: undefined, |
| 229 | revalidationQueue: undefined, |
| 230 | server: undefined, |
| 231 | serverUrl: undefined, |
| 232 | tagCacheTable: undefined, |
| 233 | url: build.url, |
| 234 | urls: [build.url], |
| 235 | }; |
| 236 | } |
| 237 | |
| 238 | // `.open-next/<relative>` derived from the build's dist directory. |
| 239 | const fromDist = (relative: string) => |
| 240 | Output.map((dir: string | undefined) => { |
| 241 | if (!dir) { |
| 242 | throw new Error( |
| 243 | "The Next.js build produced no .open-next directory.", |
| 244 | ); |
| 245 | } |
| 246 | return `${dir}/${relative}`; |
| 247 | })(build.distDir as any) as Input<string>; |
| 248 | |
| 249 | // The CDN-facing asset bucket (site assets + public files + originals |
| 250 | // for the image optimizer). |
| 251 | const bucket = |
| 252 | props.assets?.bucket ?? |
| 253 | (yield* Bucket("Bucket", { |
| 254 | bucketName: props.bucketName, |
| 255 | forceDestroy: props.forceDestroy, |
| 256 | tags: props.tags, |
| 257 | })); |
| 258 | |
| 259 | // The ISR/fetch cache lives in its OWN bucket, deliberately separate |
| 260 | // from the site bucket: the site bucket carries the CloudFront read |
| 261 | // policy (bound to the distribution's ARN), so a server -> site-bucket |
| 262 | // reference would close a dependency cycle |
| 263 | // (server -> bucket -> distribution -> server) and force the CloudFront |
| 264 | // origin to rendezvous on the Lambda's precreate stub, which has no |
| 265 | // Function URL yet. A dedicated cache bucket keeps the graph acyclic. |
| 266 | const cacheBucket = yield* Bucket("CacheBucket", { |
| 267 | forceDestroy: props.forceDestroy, |
| 268 | tags: props.tags, |
| 269 | }); |
| 270 | |
| 271 | // ISR revalidation queue: OpenNext's `sqs` queue override sends |
| 272 | // explicitly-deduplicated messages to a FIFO queue. |
| 273 | const revalidationQueue = yield* Queue("RevalidationQueue", { |
| 274 | fifo: true, |
| 275 | // Lambda requires the queue's visibility timeout to cover the |
| 276 | // consumer function's timeout (30s) with headroom. |
| 277 | visibilityTimeout: "2 minutes", |
| 278 | tags: props.tags, |
| 279 | }); |
| 280 | |
| 281 | // Tag cache (`revalidateTag` / `revalidatePath`): the schema OpenNext's |
| 282 | // `dynamodb` tag-cache override queries — `tag`/`path` primary key plus |
| 283 | // the `revalidate` GSI on `path`/`revalidatedAt`. |
| 284 | const tagCacheTable = yield* Table("TagCache", { |
| 285 | partitionKey: "tag", |
| 286 | sortKey: "path", |
| 287 | attributes: { tag: "S", path: "S", revalidatedAt: "N" }, |
| 288 | globalSecondaryIndexes: [ |
| 289 | { |
| 290 | indexName: "revalidate", |
| 291 | partitionKey: "path", |
| 292 | sortKey: "revalidatedAt", |
| 293 | projection: { ProjectionType: "ALL" }, |
| 294 | }, |
| 295 | ], |
| 296 | billingMode: "PAY_PER_REQUEST", |
| 297 | tags: props.tags, |
| 298 | }); |
| 299 | |
| 300 | const server = yield* LambdaFunction("Server", { |
| 301 | // The framework module derives the entry from open-next.output.json |
| 302 | // (origins.default.bundle + handler), so this tracks the manifest. |
| 303 | main: build.serverEntry as unknown as string, |
| 304 | handler: "handler", |
| 305 | isExternal: true, |
| 306 | // OpenNext's server-functions/default is a complete deployment unit |
| 307 | // (entry + traced .next output + its own node_modules) — ship as-is. |
| 308 | bundle: false, |
| 309 | runtime: props.runtime ?? "nodejs24.x", |
| 310 | architecture: props.architecture, |
| 311 | memorySize: props.memorySize ?? 1024, |
| 312 | timeout: props.timeout ?? Duration.seconds(30), |
| 313 | env: { |
| 314 | // The env names OpenNext's s3/sqs/dynamodb overrides read. Regions |
| 315 | // are omitted: the SDK falls back to the Lambda runtime's own |
| 316 | // AWS_REGION, and every resource here is same-region. |
| 317 | CACHE_BUCKET_NAME: cacheBucket.bucketName, |
| 318 | CACHE_BUCKET_KEY_PREFIX: NEXTJS_CACHE_PREFIX, |
| 319 | REVALIDATION_QUEUE_URL: revalidationQueue.queueUrl, |
| 320 | CACHE_DYNAMO_TABLE: tagCacheTable.tableName, |
| 321 | ...props.env, |
| 322 | }, |
| 323 | functionUrl: { |
| 324 | authType: "NONE", |
| 325 | // The default server is built with the aws-lambda-streaming |
| 326 | // wrapper (the framework module enforces it). |
| 327 | invokeMode: "RESPONSE_STREAM", |
| 328 | }, |
| 329 | }); |
| 330 | |
| 331 | yield* server.bind`Allow(${server}, AWS.Website.Nextjs.Cache(${cacheBucket}))`( |
| 332 | { |
| 333 | policyStatements: [ |
| 334 | { |
| 335 | Effect: "Allow", |
| 336 | Action: ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], |
| 337 | Resource: [Output.interpolate`${cacheBucket.bucketArn}/*` as any], |
| 338 | }, |
| 339 | { |
| 340 | Effect: "Allow", |
| 341 | Action: ["s3:ListBucket"], |
| 342 | Resource: [cacheBucket.bucketArn as any], |
| 343 | }, |
| 344 | { |
| 345 | Effect: "Allow", |
| 346 | Action: ["sqs:SendMessage"], |
| 347 | Resource: [revalidationQueue.queueArn as any], |
| 348 | }, |
| 349 | { |
| 350 | Effect: "Allow", |
| 351 | Action: [ |
| 352 | "dynamodb:GetItem", |
| 353 | "dynamodb:PutItem", |
| 354 | "dynamodb:DeleteItem", |
| 355 | "dynamodb:Query", |
| 356 | "dynamodb:Scan", |
| 357 | "dynamodb:BatchGetItem", |
| 358 | "dynamodb:BatchWriteItem", |
| 359 | "dynamodb:UpdateItem", |
| 360 | ], |
| 361 | Resource: [ |
| 362 | tagCacheTable.tableArn as any, |
| 363 | Output.interpolate`${tagCacheTable.tableArn}/index/*` as any, |
| 364 | ], |
| 365 | }, |
| 366 | ] satisfies PolicyStatement[], |
| 367 | }, |
| 368 | ); |
| 369 | |
| 370 | // ISR revalidation consumer: drains the FIFO queue and HEAD-requests |
| 371 | // stale pages with the prerender revalidate header. |
| 372 | const revalidationFunction = yield* LambdaFunction("Revalidation", { |
| 373 | main: fromDist("revalidation-function/index.mjs"), |
| 374 | handler: "handler", |
| 375 | isExternal: true, |
| 376 | bundle: false, |
| 377 | runtime: "nodejs24.x", |
| 378 | memorySize: 512, |
| 379 | timeout: Duration.seconds(30), |
| 380 | functionUrl: false, |
| 381 | }); |
| 382 | |
| 383 | yield* revalidationFunction.bind`Allow(${revalidationFunction}, AWS.SQS.Consume(${revalidationQueue}))`( |
| 384 | { |
| 385 | policyStatements: [ |
| 386 | { |
| 387 | Effect: "Allow", |
| 388 | Action: [ |
| 389 | "sqs:ReceiveMessage", |
| 390 | "sqs:DeleteMessage", |
| 391 | "sqs:GetQueueAttributes", |
| 392 | "sqs:GetQueueUrl", |
| 393 | "sqs:ChangeMessageVisibility", |
| 394 | ], |
| 395 | Resource: [revalidationQueue.queueArn as any], |
| 396 | }, |
| 397 | ] satisfies PolicyStatement[], |
| 398 | }, |
| 399 | ); |
| 400 | |
| 401 | yield* EventSourceMapping("RevalidationEventSource", { |
| 402 | functionName: revalidationFunction.functionName, |
| 403 | eventSourceArn: revalidationQueue.queueArn, |
| 404 | batchSize: 5, |
| 405 | }); |
| 406 | |
| 407 | // Image optimization: OpenNext installs sharp's linux-arm64 binaries |
| 408 | // into the bundle, so the function architecture is always arm64. Its |
| 409 | // s3 image loader reads originals from the asset bucket. |
| 410 | const imageFunction = yield* LambdaFunction("ImageOptimization", { |
| 411 | main: fromDist("image-optimization-function/index.mjs"), |
| 412 | handler: "handler", |
| 413 | isExternal: true, |
| 414 | bundle: false, |
| 415 | runtime: "nodejs24.x", |
| 416 | architecture: "arm64", |
| 417 | memorySize: props.imageOptimization?.memorySize ?? 1536, |
| 418 | timeout: Duration.seconds(25), |
| 419 | env: { |
| 420 | BUCKET_NAME: bucket.bucketName, |
| 421 | }, |
| 422 | functionUrl: { |
| 423 | authType: "NONE", |
| 424 | // The image optimizer is buffered (streaming: false in the |
| 425 | // OpenNext output manifest). |
| 426 | invokeMode: "BUFFERED", |
| 427 | }, |
| 428 | }); |
| 429 | |
| 430 | yield* imageFunction.bind`Allow(${imageFunction}, AWS.S3.GetObject(${bucket}))`( |
| 431 | { |
| 432 | policyStatements: [ |
| 433 | { |
| 434 | Effect: "Allow", |
| 435 | Action: ["s3:GetObject"], |
| 436 | Resource: [Output.interpolate`${bucket.bucketArn}/*` as any], |
| 437 | }, |
| 438 | ] satisfies PolicyStatement[], |
| 439 | }, |
| 440 | ); |
| 441 | |
| 442 | const urlHost = (url: string | undefined) => { |
| 443 | if (!url) { |
| 444 | throw new Error( |
| 445 | "A Next.js Lambda function did not produce a Function URL.", |
| 446 | ); |
| 447 | } |
| 448 | return new URL(url).hostname; |
| 449 | }; |
| 450 | const serverHost = Output.map(urlHost)( |
| 451 | server.functionUrl as any, |
| 452 | ) as Input<string>; |
| 453 | const imageHost = Output.map(urlHost)( |
| 454 | imageFunction.functionUrl as any, |
| 455 | ) as Input<string>; |
| 456 | |
| 457 | const siteProps: StaticSiteProps = { |
| 458 | path: build.clientDir as unknown as string, |
| 459 | assets: props.assets, |
| 460 | domain: props.domain, |
| 461 | cloudfrontUrl: props.cloudfrontUrl, |
| 462 | edge: props.edge, |
| 463 | bucketName: props.bucketName, |
| 464 | forceDestroy: props.forceDestroy, |
| 465 | invalidation: props.invalidation, |
| 466 | tags: props.tags, |
| 467 | }; |
| 468 | |
| 469 | const site = yield* makeKvSite(id, siteProps, { |
| 470 | serverHost, |
| 471 | image: { route: "/_next/image", host: imageHost }, |
| 472 | }); |
| 473 | |
| 474 | // Seed the ISR/fetch cache: `.open-next/cache/<buildId>/...` uploaded |
| 475 | // under `_cache/` (matching CACHE_BUCKET_KEY_PREFIX). Old builds' seeds |
| 476 | // are left in place so a rolling deploy never breaks in-flight ISR. |
| 477 | const cacheFiles = yield* AssetDeployment("CacheFiles", { |
| 478 | bucket: cacheBucket, |
| 479 | sourcePath: fromDist("cache") as unknown as string, |
| 480 | prefix: NEXTJS_CACHE_PREFIX, |
| 481 | purge: false, |
| 482 | }); |
| 483 | |
| 484 | return { |
| 485 | ...site, |
| 486 | build, |
| 487 | cacheBucket, |
| 488 | cacheFiles, |
| 489 | imageFunction, |
| 490 | imageUrl: imageFunction.functionUrl, |
| 491 | revalidationFunction, |
| 492 | revalidationQueue, |
| 493 | server, |
| 494 | serverUrl: server.functionUrl, |
| 495 | tagCacheTable, |
| 496 | }; |
| 497 | }, |
| 498 | (effect, id: string, _props?: NextjsProps) => effect.pipe(Namespace.push(id)), |
| 499 | ); |