1/**
2 * `@alchemy.run/frontend-frameworks/react-router/aws` — the AWS Lambda
3 * deploy target for the React Router v7 integration.
4 *
5 * React Router builds through Vite: `reactRouter()` in the project's
6 * `vite.config.ts` builds a `client` environment into
7 * `<buildDirectory>/client` and a server environment into
8 * `<buildDirectory>/server`. Its server output is a `ServerBuild` MANIFEST
9 * (`{ entry, routes, assets, ... }`) with no default export and nothing to
10 * call, so the framework half makes the server build's rollup input a
11 * wrapper that turns the manifest into a fetch handler with
12 * `createRequestHandler` from `react-router` (see `serverEntrySource` in
13 * `ReactRouter.ts` — it runs at build time because `react-router` itself
14 * has to end up inside the emitted bundle). What this target owns is the
15 * Lambda packaging on top of that fetch handler:
16 *
17 * - **`serverEntryFileName`** — `index.js`, the chunk the server pass emits
18 * (React Router's `serverBuildFile`); the finishing pass wraps it.
19 * - **`finish`** — emits the Lambda deployment surface next to the entry:
20 * a `package.json` (`type: "module"` — the Lambda Node.js runtime
21 * classifies `.js` by the nearest manifest, and `<buildDirectory>/server`
22 * ships without one), a copy of this package's self-contained
23 * `aws-lambda` web adapter, and `lambda.mjs` exporting `handler` — the
24 * fetch handler wrapped with `awslambda.streamifyResponse` (or the
25 * buffered payload-v2 wrapper with `streaming: false`). The rewritten
26 * `BuildOutput` puts `server/lambda.mjs` first, so it becomes the
27 * deployed Lambda entry. (`lambda.mjs`, not `index.mjs`: React Router's
28 * server build already occupies `index.js`, and the Lambda runtime
29 * resolves a handler module extensionless.)
30 * - **`bundle`** — Node resolve conditions and the `@aws-sdk/` externals
31 * (the Lambda Node.js runtime ships SDK v3) for callers that post-process
32 * server code.
33 *
34 * No `devPlatform`: `dev` runs React Router's own Vite dev server, which is
35 * already the AWS Lambda programming model (plain Node).
36 */
37import * as Effect from "effect/Effect";
38import * as FileSystem from "effect/FileSystem";
39import * as Path from "effect/Path";
40import { fileURLToPath } from "node:url";
41import { runBuildChild } from "../core/BuildChild.ts";
42import {
43 DeployTargetError,
44 makeDeployTarget,
45 readServerModulesFromDisk,
46 sortServerModules,
47 type BuildOutput,
48 type DeployTargetFinishContext,
49} from "../core/index.ts";
50import {
51 DEFAULT_SERVER_BUILD_FILE,
52 make,
53 type ReactRouterTarget,
54 type ReactRouterTargetConfig,
55} from "./ReactRouter.ts";
56
57/** The server entry file name React Router's server pass emits. */
58export const SERVER_ENTRY_FILE_NAME = DEFAULT_SERVER_BUILD_FILE;
59
60/**
61 * The Lambda entry module the finishing pass emits beside the server entry.
62 * Deliberately NOT `index.mjs`: the server build is `index.js`, and the
63 * Lambda Node.js runtime resolves the handler module without an extension.
64 */
65export const LAMBDA_ENTRY_FILE_NAME = "lambda.mjs";
66
67/** The on-disk copy of this package's `aws-lambda` web adapter runtime. */
68export const LAMBDA_ADAPTER_FILE_NAME = "react-router-aws-lambda.mjs";
69
70/** AWS-specific knobs carried on the shared {@link ReactRouterTargetConfig}. */
71export interface ReactRouterAwsTargetConfig extends ReactRouterTargetConfig {
72 /**
73 * Whether the emitted Lambda handler streams its response
74 * (`awslambda.streamifyResponse`, requires a Function URL with
75 * `invokeMode: RESPONSE_STREAM`). The buffered handler answers
76 * APIGW/Function-URL events with a complete response object.
77 * @default true
78 */
79 readonly streaming?: boolean | undefined;
80}
81
82/** The AWS {@link ReactRouterTarget}, carrying the AWS-specific config. */
83export interface ReactRouterAwsTarget extends ReactRouterTarget {
84 readonly config: ReactRouterAwsTargetConfig;
85}
86
87const fail = (message: string, cause?: unknown) =>
88 new DeployTargetError({ platform: "aws", message, cause });
89
90/**
91 * The generated Lambda entry: imports the server build's fetch handler and
92 * exports it wrapped for the Lambda Node.js runtime.
93 *
94 * The entry module default-exports `{ fetch }` (the shape the integration's
95 * virtual server entry produces). A project whose own entry default-exports
96 * the bare fetch function is accepted too.
97 */
98export const generateLambdaEntry = (options: {
99 readonly streaming: boolean;
100 readonly serverEntryFileName: string;
101}): string => {
102 const wrap = options.streaming
103 ? "toLambdaHandler"
104 : "toBufferedLambdaHandler";
105 return `// Auto-generated by @alchemy.run/frontend-frameworks/react-router/aws — do not edit.
106import * as serverEntry from "./${options.serverEntryFileName}";
107import { ${wrap} } from "./${LAMBDA_ADAPTER_FILE_NAME}";
108
109const entry = serverEntry.default ?? serverEntry;
110const fetchHandler =
111 typeof entry === "function"
112 ? entry
113 : (request) => entry.fetch(request);
114
115export const handler = ${wrap}(fetchHandler);
116`;
117};
118
119/**
120 * Resolve this package's built, self-contained `aws-lambda` adapter module
121 * on disk (it has no imports of its own, so a byte copy deploys as-is).
122 */
123const resolveLambdaAdapterPath = Effect.try({
124 try: () =>
125 fileURLToPath(
126 import.meta.resolve("@alchemy.run/frontend-frameworks/aws-lambda"),
127 ),
128 catch: (cause) =>
129 fail(
130 'Failed to resolve "@alchemy.run/frontend-frameworks/aws-lambda" — is the package built (its exports map serves dist/)?',
131 cause,
132 ),
133});
134
135const finish = (
136 config: ReactRouterAwsTargetConfig,
137 output: BuildOutput,
138 context: DeployTargetFinishContext,
139): Effect.Effect<
140 BuildOutput,
141 DeployTargetError,
142 FileSystem.FileSystem | Path.Path
143> =>
144 Effect.gen(function* () {
145 const fs = yield* FileSystem.FileSystem;
146 const path = yield* Path.Path;
147 if (context.entry === undefined) {
148 return yield* Effect.fail(
149 fail(
150 "The React Router build produced no on-disk server entry to finish",
151 ),
152 );
153 }
154 const serverDir = path.dirname(context.entry);
155 const serverEntryFileName = path.basename(context.entry);
156
157 // The Lambda Node.js runtime classifies `.js` modules by the nearest
158 // package.json, and the server directory ships as the deployment root
159 // without one — the ESM server build and its chunks need the marker.
160 yield* fs.writeFileString(
161 path.join(serverDir, "package.json"),
162 '{"type":"module"}\n',
163 );
164
165 const adapterPath = yield* resolveLambdaAdapterPath;
166 const adapterSource = yield* fs.readFileString(adapterPath);
167 yield* fs.writeFileString(
168 path.join(serverDir, LAMBDA_ADAPTER_FILE_NAME),
169 // Drop the sourcemap pointer — the map is not copied into the bundle.
170 adapterSource.replace(/^\/\/# sourceMappingURL=.*$/m, ""),
171 );
172
173 yield* fs.writeFileString(
174 path.join(serverDir, LAMBDA_ENTRY_FILE_NAME),
175 generateLambdaEntry({
176 streaming: config.streaming !== false,
177 serverEntryFileName,
178 }),
179 );
180
181 // Re-read the server directory so the emitted files are part of the
182 // output, with the Lambda entry first.
183 const modules = yield* readServerModulesFromDisk({
184 directory: serverDir,
185 prefix: "server",
186 }).pipe(Effect.mapError((error) => fail(error.message, error.cause)));
187 return {
188 ...output,
189 serverModules: sortServerModules(
190 modules,
191 `server/${LAMBDA_ENTRY_FILE_NAME}`,
192 ),
193 };
194 }).pipe(
195 Effect.catchTag("PlatformError", (error) =>
196 Effect.fail(fail("Failed to emit the Lambda entry", error)),
197 ),
198 );
199
200/**
201 * The finish-only target — the shape the framework's regular build/finish
202 * pipeline consumes. Used directly in the build child (where `cwd === root`
203 * holds); {@link makeAwsTarget} wraps it with the wholesale `build` hook
204 * that spawns the child.
205 */
206const makeAwsFinishTarget = (
207 config: ReactRouterAwsTargetConfig = {},
208): ReactRouterAwsTarget =>
209 makeDeployTarget({
210 platform: "aws",
211 config,
212 bundle: {
213 conditions: ["node", "import", "module"],
214 external: ["@aws-sdk/"],
215 },
216 serverEntryFileName: SERVER_ENTRY_FILE_NAME,
217 finish: (output, context) => finish(config, output, context),
218 });
219
220/**
221 * A React Router build loads and executes the project's `vite.config.*` and
222 * `react-router.config.ts` (user plugins may mutate `process.env` or
223 * `process.chdir`, and React Router's config loader spins its own vite-node
224 * server) — none of which may touch the engine process, where many deploys
225 * share one event loop. This target's wholesale `build` therefore runs the
226 * framework in a disposable child process whose working directory IS the
227 * project root (see `core/BuildChild.ts`). The shared
228 * `core/BuildChildRunner` entry imports this module in the child and calls
229 * the exported {@link buildInChild}.
230 */
231export interface ReactRouterAwsBuildChildConfig {
232 readonly rootDir: string;
233 /** The (JSON-serializable) target config the parent was created with. */
234 readonly config: ReactRouterAwsTargetConfig;
235}
236
237export const buildInChild = (config: ReactRouterAwsBuildChildConfig) =>
238 Effect.gen(function* () {
239 const framework = yield* make({
240 root: config.rootDir,
241 // The finish-only target: no wholesale `build` hook, so the child
242 // runs the regular vite build + finish pipeline (no recursion).
243 target: makeAwsFinishTarget(config.config),
244 buildDirectory: config.config.buildDirectory,
245 });
246 return yield* framework.build({ root: config.rootDir });
247 });
248
249/**
250 * Create the AWS Lambda {@link ReactRouterTarget}. See the module doc for
251 * the seams.
252 */
253export const makeAwsTarget = (
254 config: ReactRouterAwsTargetConfig = {},
255): ReactRouterAwsTarget => ({
256 ...makeAwsFinishTarget(config),
257 build: (context) =>
258 runBuildChild({
259 module: import.meta.url,
260 rootDir: context.root,
261 framework: "react-router",
262 config: {
263 rootDir: context.root,
264 config,
265 } satisfies ReactRouterAwsBuildChildConfig,
266 }).pipe(Effect.mapError((error) => fail(error.message, error.cause))),
267});
268
269/**
270 * The deploy-target module contract (`resolveDeployTarget` accepts the
271 * default export — or the named `target` export — as a value or factory).
272 */
273export const target = makeAwsTarget;
274
275export default makeAwsTarget;