1/**
2 * `@alchemy.run/frontend-frameworks/waku/aws` — the AWS Lambda deploy target for
3 * the Waku integration.
4 *
5 * Waku's AWS story is a plain-Node waku build with this package's forked
6 * aws-lambda adapter selected via `unstable_adapter`: `dist/server` is the
7 * self-contained server bundle and `dist/public` holds the static assets
8 * (SSG pages included) for the CDN. What this target owns:
9 *
10 * - **`adapter`** — this package's fork of waku's aws-lambda adapter
11 * (`@alchemy.run/frontend-frameworks/waku/aws-adapter`): behavior-identical to
12 * upstream except the Lambda wrapper is this package's own streaming
13 * adapter (`@alchemy.run/frontend-frameworks/aws-lambda`) instead of
14 * `hono/aws-lambda`, and the serve entry is written by this target's
15 * finishing pass instead of upstream's build enhancer.
16 * - **`vitePlugins`** — none: a plain-Node waku build needs no bundler
17 * plugin (there is no workerd runtime to host dev/build environments in).
18 * - **`finish`** — writes the Lambda serve entry
19 * (`dist/server/serve-aws-lambda.mjs`, importing the built server chunk's
20 * `INTERNAL_runFetch` and wrapping it via the adapter's handler-wrapper
21 * global — no bare imports, so the shipped directory needs no
22 * `node_modules`) plus a `package.json` marking the directory ESM, and
23 * pins the serve entry as `serverModules[0]`.
24 * - **`entry`** — surfaces `config.main` as the generic user-entry carriage.
25 * - **`bundle`** — Node resolve conditions and the `@aws-sdk/` externals
26 * (the Lambda Node.js runtime ships SDK v3) for callers that post-process
27 * server code.
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 type { Config as WakuConfig } from "waku/config";
34import { runBuildChild } from "../core/BuildChild.ts";
35import {
36 DeployTargetError,
37 makeDeployTarget,
38 toOutputFile,
39 type BuildOutput,
40} from "../core/index.ts";
41import { AWS_LAMBDA_HANDLE_GLOBAL } from "./aws-shared.ts";
42import { make, type WakuTarget, type WakuTargetBuildContext } from "./Waku.ts";
43
44export type { WakuTarget, WakuTargetContext } from "./Waku.ts";
45
46/** The serve entry the finishing pass writes into `dist/server`. */
47export const SERVE_ENTRY_NAME = "serve-aws-lambda.mjs";
48
49/** Configuration accepted by the AWS deploy target. */
50export interface WakuAwsTargetConfig {
51 /**
52 * The user's own server entry module (the deploy-target user-entry seam):
53 * a module that wraps waku's handler (imported from
54 * `virtual:waku/server-entry`) and re-exports extras. Absolute, or
55 * relative to the project root.
56 */
57 readonly main?: string | undefined;
58 /**
59 * Whether the emitted Lambda handler streams its response
60 * (`awslambda.streamifyResponse`, requires a Function URL with
61 * `invokeMode: RESPONSE_STREAM`). Set `false` for the buffered
62 * payload-v2 handler (`invokeMode: BUFFERED` / API Gateway HTTP APIs).
63 * @default true
64 */
65 readonly streaming?: boolean | undefined;
66}
67
68/**
69 * The content of the generated Lambda serve entry: imports the built server
70 * chunk (whose module evaluation runs the adapter setup and publishes the
71 * handler-wrapper global), then exports the wrapped `handler`.
72 */
73export const makeServeEntrySource = (streaming: boolean): string =>
74 `// Generated by @alchemy.run/frontend-frameworks/waku/aws — the Lambda entry.
75import { INTERNAL_runFetch } from "./index.js";
76
77const handle = globalThis.${AWS_LAMBDA_HANDLE_GLOBAL};
78if (typeof handle !== "function") {
79 throw new Error(
80 "The waku AWS adapter did not register its Lambda handler wrapper — " +
81 "the server bundle was not built with @alchemy.run/frontend-frameworks/waku/aws-adapter.",
82 );
83}
84
85export const handler = handle(
86 (req, ...args) => INTERNAL_runFetch(process.env, req, ...args),
87 { streaming: ${streaming} },
88);
89`;
90
91/**
92 * The adapter-driven target — the shape the framework's regular
93 * build/finish pipeline consumes. Used directly in the build child (where
94 * `cwd === root` holds); {@link makeWakuAwsTarget} wraps it with the
95 * wholesale `build` hook that spawns the child.
96 */
97const makeAwsAdapterTarget = (
98 config?: WakuAwsTargetConfig,
99): WakuTarget<WakuAwsTargetConfig | undefined> =>
100 makeDeployTarget({
101 platform: "aws",
102 config,
103 entry: config?.main !== undefined ? { main: config.main } : undefined,
104 bundle: {
105 conditions: ["node", "import", "module"],
106 external: ["@aws-sdk/"],
107 },
108 adapter: () =>
109 Effect.try({
110 try: () =>
111 fileURLToPath(
112 import.meta
113 .resolve("@alchemy.run/frontend-frameworks/waku/aws-adapter"),
114 ),
115 catch: (cause) =>
116 new DeployTargetError({
117 platform: "aws",
118 message:
119 "Failed to resolve the @alchemy.run/frontend-frameworks/waku AWS adapter module",
120 cause,
121 }),
122 }),
123 vitePlugins: () => Effect.sync(() => []),
124 finish: (output, _context) =>
125 Effect.gen(function* () {
126 const fs = yield* FileSystem.FileSystem;
127 const path = yield* Path.Path;
128 const fail = (message: string) => (cause?: unknown) =>
129 new DeployTargetError({ platform: "aws", message, cause });
130
131 if (output.distDirectory === undefined) {
132 return yield* Effect.fail(
133 fail("The waku build produced no dist directory")(undefined),
134 );
135 }
136 const serverDir = path.join(output.distDirectory, "server");
137 const serverIndex = path.join(serverDir, "index.js");
138 const hasServerIndex = yield* fs
139 .exists(serverIndex)
140 .pipe(
141 Effect.mapError(fail("Failed to probe the built server entry")),
142 );
143 if (!hasServerIndex) {
144 return yield* Effect.fail(
145 fail(`The waku build produced no server entry at ${serverIndex}`)(
146 undefined,
147 ),
148 );
149 }
150
151 const serveSource = makeServeEntrySource(config?.streaming ?? true);
152 // `dist/server` ships to Lambda as-is (no project package.json in the
153 // archive), so the directory must carry its own ESM marker for
154 // `./index.js` to parse as a module.
155 yield* Effect.all(
156 [
157 fs.writeFileString(
158 path.join(serverDir, SERVE_ENTRY_NAME),
159 serveSource,
160 ),
161 fs.writeFileString(
162 path.join(serverDir, "package.json"),
163 `${JSON.stringify({ type: "module" }, null, 2)}\n`,
164 ),
165 ],
166 { concurrency: "unbounded" },
167 ).pipe(Effect.mapError(fail("Failed to write the Lambda serve entry")));
168
169 const serveModule = yield* toOutputFile(
170 path.join("server", SERVE_ENTRY_NAME),
171 serveSource,
172 );
173 // The serve entry becomes `serverModules[0]` — the module alchemy's
174 // Server resource deploys as the Lambda `main`.
175 const serverModules = [
176 serveModule,
177 ...(output.serverModules ?? []).filter(
178 (module_) => module_.name !== serveModule.name,
179 ),
180 ];
181 return { ...output, serverModules } satisfies BuildOutput;
182 }),
183 });
184
185/**
186 * Waku assumes `process.cwd()` is the project root in several places (the
187 * html-shell plugin's `index.html` input resolves against the cwd, and
188 * rolldown resolves relative inputs from the cwd), so the framework build
189 * chdirs to the root — which must never happen in the engine process (many
190 * deploys share one event loop). This target's wholesale `build` therefore
191 * runs the framework in a disposable child process whose working directory
192 * IS the project root (see `core/BuildChild.ts`); the framework's own
193 * chdir is a no-op there. The shared `core/BuildChildRunner` entry imports
194 * this module in the child and calls the exported {@link buildInChild}.
195 */
196export interface WakuAwsBuildChildConfig {
197 readonly rootDir: string;
198 /** The (JSON-serializable) target config the parent was created with. */
199 readonly config: WakuAwsTargetConfig | undefined;
200 /**
201 * The inline waku options the framework was constructed with
202 * (`WakuFrameworkOptions.waku`, carried through the build context). Only
203 * JSON-serializable fields cross the process boundary.
204 */
205 readonly waku: WakuConfig | undefined;
206}
207
208export const buildInChild = (config: WakuAwsBuildChildConfig) =>
209 Effect.gen(function* () {
210 const framework = yield* make({
211 root: config.rootDir,
212 // The adapter-only target: no wholesale `build` hook, so the child
213 // runs the regular waku build + finish pipeline (no recursion).
214 target: makeAwsAdapterTarget(config.config),
215 waku: config.waku,
216 });
217 return yield* framework.build({ root: config.rootDir });
218 });
219
220/**
221 * Build the AWS Lambda {@link WakuTarget}. See the module doc for the seams.
222 */
223export const makeWakuAwsTarget = (
224 config?: WakuAwsTargetConfig,
225): WakuTarget<WakuAwsTargetConfig | undefined> => ({
226 ...makeAwsAdapterTarget(config),
227 build: (context: WakuTargetBuildContext) =>
228 runBuildChild({
229 runtime: "node",
230 module: import.meta.url,
231 rootDir: context.root,
232 framework: "waku",
233 config: {
234 rootDir: context.root,
235 config,
236 waku: context.waku,
237 } satisfies WakuAwsBuildChildConfig,
238 }).pipe(
239 Effect.mapError(
240 (error) =>
241 new DeployTargetError({
242 platform: "aws",
243 message: error.message,
244 cause: error.cause,
245 }),
246 ),
247 ),
248});
249
250/**
251 * The deploy-target module contract (`resolveDeployTarget` accepts the
252 * default export — or the named `target` export — as a value or factory).
253 */
254export const target = makeWakuAwsTarget;
255
256export default makeWakuAwsTarget;