1/**
2 * `@alchemy.run/frontend-frameworks/vinext/aws` — vinext on AWS Lambda.
3 *
4 * AWS uses Vinext's Vite build in Node (no Cloudflare Vite
5 * plugin) plus a finishing pass that wraps the App Router RSC entry
6 * (`dist/server/index.js`) as a streaming Lambda handler via
7 * `@alchemy.run/frontend-frameworks/aws-lambda`. Static assets in
8 * `dist/client` are uploaded to S3 by `AWS.Website.Vinext`.
9 *
10 * - **`build`** runs vinext's Vite build in a disposable child, then writes
11 * `dist/server/serve-aws-lambda.mjs` exporting `handler`.
12 * - **`dev`** runs the real `vinext dev` CLI (plain Node), scoped.
13 *
14 * This is **not** OpenNext and **not** `Cloudflare.Website.Vinext`.
15 * App Router only — Pages Router has no single fetch handler to wrap.
16 *
17 * Composites should load this module as both the framework specifier and
18 * the deploy target (same shape as `nextjs/aws`).
19 */
20import * as FrameworkCore from "../core/index.ts";
21import * as Effect from "effect/Effect";
22import * as FileSystem from "effect/FileSystem";
23import * as Path from "effect/Path";
24import type * as Scope from "effect/Scope";
25import { fileURLToPath } from "node:url";
26import { resolveProjectPackageDirectory } from "../core/Loader.ts";
27import { runBuildChild } from "../core/BuildChild.ts";
28import {
29 DeployTargetError,
30 makeDeployTarget,
31 type DeployTarget,
32} from "../core/index.ts";
33import {
34 awaitVinextDevReady,
35 collectVinextDist,
36 failFramework,
37 pickEphemeralPort,
38 pinServeModule,
39 resolveVinextCli,
40 runVinextBuild,
41 spawnVinextDev,
42 VINEXT_RSC_ENTRY,
43} from "./cli.ts";
44
45const failTarget = (message: string, cause?: unknown) =>
46 new DeployTargetError({ platform: "aws", message, cause });
47
48/** Options for the vinext AWS framework module / deploy target. */
49export interface VinextAwsOptions {
50 readonly root?: string | undefined;
51 /**
52 * The deploy target module specifier the caller resolved this module as.
53 * This module IS the AWS target — accepted and ignored.
54 */
55 readonly target?: string | undefined;
56 /**
57 * Whether the emitted Lambda handler streams its response
58 * (`awslambda.streamifyResponse`, requires a Function URL with
59 * `invokeMode: RESPONSE_STREAM`).
60 * @default true
61 */
62 readonly streaming?: boolean | undefined;
63}
64
65export interface VinextAwsTargetConfig {
66 readonly root?: string | undefined;
67 readonly streaming?: boolean | undefined;
68}
69
70export interface VinextAwsTarget extends DeployTarget<VinextAwsTargetConfig> {}
71
72/** The Lambda entry the finishing pass writes into `dist/server`. */
73export const SERVE_ENTRY_NAME = "server/serve-aws-lambda.mjs";
74
75/** The on-disk copy of this package's `aws-lambda` web adapter runtime. */
76export const LAMBDA_ADAPTER_FILE_NAME = "vinext-aws-lambda.mjs";
77
78/**
79 * The generated Lambda entry: imports vinext's App Router fetch handler
80 * and exports it wrapped for the Lambda Node.js runtime.
81 */
82export const makeLambdaEntrySource = (streaming: boolean): string => {
83 const wrap = streaming ? "toLambdaHandler" : "toBufferedLambdaHandler";
84 return `// Generated by @alchemy.run/frontend-frameworks/vinext/aws — do not edit.
85import rsc from "./index.js";
86import { ${wrap} } from "./${LAMBDA_ADAPTER_FILE_NAME}";
87
88const fetchHandler = (request) => {
89 const ctx = {
90 hostRuntime: "node",
91 waitUntil(promise) {
92 Promise.resolve(promise).catch(() => {});
93 },
94 passThroughOnException() {},
95 };
96 if (typeof rsc === "function") return rsc(request, ctx);
97 return rsc.fetch(request, process.env, ctx);
98};
99
100export const handler = ${wrap}(fetchHandler);
101`;
102};
103
104const resolveLambdaAdapterPath = Effect.try({
105 try: () =>
106 fileURLToPath(
107 import.meta.resolve("@alchemy.run/frontend-frameworks/aws-lambda"),
108 ),
109 catch: (cause) =>
110 failFramework(
111 'Failed to resolve "@alchemy.run/frontend-frameworks/aws-lambda" — is the package built (its exports map serves dist/)?',
112 )(cause),
113});
114
115export interface VinextAwsBuildChildConfig {
116 readonly rootDir: string;
117 readonly config: VinextAwsTargetConfig;
118}
119
120export const buildInChild = (config: VinextAwsBuildChildConfig) =>
121 Effect.gen(function* () {
122 const fs = yield* FileSystem.FileSystem;
123 const path = yield* Path.Path;
124 const root = config.rootDir;
125 yield* runVinextBuild({ root, cache: "s3" });
126 const dist = yield* collectVinextDist(root);
127 if (!dist.hasRsc) {
128 return yield* Effect.fail(
129 failFramework(
130 `The vinext AWS target requires an App Router build (${VINEXT_RSC_ENTRY}). Pages Router is supported on Node containers, not Lambda.`,
131 )(undefined),
132 );
133 }
134 const adapterPath = yield* resolveLambdaAdapterPath;
135 const adapterSource = yield* fs
136 .readFileString(adapterPath)
137 .pipe(
138 Effect.mapError(
139 failFramework("Failed to read the aws-lambda adapter module"),
140 ),
141 );
142 yield* fs
143 .writeFileString(
144 path.join(dist.serverDir, LAMBDA_ADAPTER_FILE_NAME),
145 adapterSource.replace(/^\/\/# sourceMappingURL=.*$/m, ""),
146 )
147 .pipe(
148 Effect.mapError(
149 failFramework(
150 "Failed to write the aws-lambda adapter into dist/server",
151 ),
152 ),
153 );
154 const output = yield* pinServeModule(
155 dist,
156 SERVE_ENTRY_NAME,
157 makeLambdaEntrySource(config.config.streaming !== false),
158 );
159 // Preserve vinext's separate RSC/SSR module graphs and package its externals.
160 const vinextRoot = yield* resolveProjectPackageDirectory(
161 root,
162 "vinext",
163 ).pipe(Effect.flatMap((directory) => fs.realPath(directory)));
164 const standaloneUrl = yield* path.toFileUrl(
165 path.join(vinextRoot, "dist/build/standalone.js"),
166 );
167 const standalone = yield* Effect.tryPromise({
168 try: () =>
169 import(/* @vite-ignore */ standaloneUrl.href) as Promise<{
170 emitStandaloneOutput(options: {
171 root: string;
172 outDir: string;
173 vinextPackageRoot: string;
174 }): { standaloneDir: string };
175 }>,
176 catch: failFramework(
177 "Failed to load vinext standalone packaging; install vinext 1.0.0-beta.10 or newer",
178 ),
179 });
180 const packaged = yield* Effect.try({
181 try: () =>
182 standalone.emitStandaloneOutput({
183 root,
184 outDir: dist.distDirectory!,
185 vinextPackageRoot: vinextRoot,
186 }),
187 catch: failFramework(
188 "Failed to package vinext Lambda runtime dependencies",
189 ),
190 });
191 yield* fs.rename(
192 path.join(packaged.standaloneDir, "node_modules"),
193 path.join(dist.serverDir, "node_modules"),
194 );
195 yield* fs.remove(packaged.standaloneDir, { recursive: true });
196 yield* fs.writeFileString(
197 path.join(dist.serverDir, "package.json"),
198 '{"type":"module"}',
199 );
200 return output;
201 });
202
203const makeAwsChildTarget = (
204 config: VinextAwsTargetConfig = {},
205): VinextAwsTarget =>
206 makeDeployTarget({
207 platform: "aws",
208 config,
209 bundle: {
210 conditions: ["node", "import", "module"],
211 external: ["@aws-sdk/"],
212 },
213 });
214
215/**
216 * Create the AWS Lambda {@link VinextAwsTarget}. See the module doc.
217 */
218export const makeAwsTarget = (
219 config: VinextAwsTargetConfig = {},
220): VinextAwsTarget => ({
221 ...makeAwsChildTarget(config),
222 build: (context) =>
223 runBuildChild({
224 runtime: "node",
225 module: import.meta.url,
226 rootDir: context.root,
227 env: context.env,
228 framework: "vinext",
229 config: {
230 rootDir: context.root,
231 config,
232 } satisfies VinextAwsBuildChildConfig,
233 }).pipe(Effect.mapError((error) => failTarget(error.message, error.cause))),
234});
235
236export const target = makeAwsTarget;
237
238export default makeAwsTarget;
239
240export interface VinextAwsService {
241 readonly build: (
242 options?: FrameworkCore.FrameworkBuildOptions,
243 ) => Effect.Effect<
244 {
245 readonly distDirectory: string;
246 readonly clientDirectory: string | undefined;
247 readonly serverModules: Array<{ readonly name: string }>;
248 },
249 FrameworkCore.FrameworkError
250 >;
251 readonly dev: (
252 options?: FrameworkCore.FrameworkDevOptions,
253 ) => Effect.Effect<
254 FrameworkCore.FrameworkDevServer,
255 FrameworkCore.FrameworkError,
256 Scope.Scope
257 >;
258}
259
260/**
261 * Build the vinext-on-AWS framework service. See the module doc for the
262 * `build`/`dev` semantics.
263 */
264export const make: (
265 options?: VinextAwsOptions,
266) => Effect.Effect<VinextAwsService, never, FileSystem.FileSystem | Path.Path> =
267 Effect.fnUntraced(function* (options?: VinextAwsOptions) {
268 const fs = yield* FileSystem.FileSystem;
269 const path = yield* Path.Path;
270 const resolveRoot = (override: string | undefined) =>
271 Effect.sync(() =>
272 path.resolve(override ?? options?.root ?? process.cwd()),
273 );
274
275 const build: VinextAwsService["build"] = Effect.fn(function* (
276 buildOptions?: FrameworkCore.FrameworkBuildOptions,
277 ) {
278 const root = yield* resolveRoot(buildOptions?.root);
279 const awsTarget = makeAwsTarget({
280 root,
281 streaming: options?.streaming,
282 });
283 const output = yield* awsTarget.build!({
284 root,
285 framework: "vinext",
286 env: buildOptions?.env,
287 }).pipe(
288 Effect.provideService(FileSystem.FileSystem, fs),
289 Effect.provideService(Path.Path, path),
290 Effect.mapError((error) => failFramework(error.message)(error.cause)),
291 );
292 return {
293 distDirectory: output.distDirectory ?? path.join(root, "dist"),
294 clientDirectory: output.clientDirectory,
295 serverModules: (output.serverModules ?? []).map((module_) => ({
296 name: module_.name,
297 })),
298 };
299 });
300
301 const dev: VinextAwsService["dev"] = Effect.fn(function* (
302 devOptions?: FrameworkCore.FrameworkDevOptions,
303 ) {
304 const root = yield* resolveRoot(devOptions?.root);
305 const port = devOptions?.port ?? (yield* pickEphemeralPort);
306 const cli = yield* resolveVinextCli(root);
307 const host = devOptions?.host ?? "127.0.0.1";
308 const child = yield* spawnVinextDev({ root, cli, port, host });
309 const url = `http://${host}:${port}`;
310 yield* awaitVinextDevReady({ url, child });
311 return { url };
312 });
313
314 return { build, dev };
315 });