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