1import type { InputProps } from "../../Input.ts";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import { AlchemyContext } from "../../AlchemyContext.ts";
5import type { MemoOptions } from "../../Command/Memo.ts";
6import type { Input } from "../../Input.ts";
7import * as Namespace from "../../Namespace.ts";
8import * as Output from "../../Output.ts";
9import { ProviderModePolicy } from "../../ProviderMode.ts";
10import { Table } from "../DynamoDB/Table.ts";
11import type { PolicyStatement } from "../IAM/Policy.ts";
12import { EventSourceMapping } from "../Lambda/EventSourceMapping.ts";
13import {
14 Function as LambdaFunction,
15 type FunctionProps,
16} from "../Lambda/Function.ts";
17import { Bucket } from "../S3/Bucket.ts";
18import { Queue } from "../SQS/Queue.ts";
19import { AssetDeployment } from "./AssetDeployment.ts";
20import { asRouterDomain, registerDevRouterRoute } from "./DevRouterRoute.ts";
21import { Server, type ServerDevProps } from "../../Website/Server.ts";
22import { makeKvSite, type StaticSiteProps } from "./StaticSite.ts";
23import {
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 */
35export 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. */
39export const NEXTJS_CACHE_PREFIX = "_cache";
40
41export 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
190export 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);