| 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 | */ |
| 29 | import * as Effect from "effect/Effect"; |
| 30 | import * as FileSystem from "effect/FileSystem"; |
| 31 | import * as Path from "effect/Path"; |
| 32 | import { fileURLToPath } from "node:url"; |
| 33 | import type { Config as WakuConfig } from "waku/config"; |
| 34 | import { runBuildChild } from "../core/BuildChild.ts"; |
| 35 | import { |
| 36 | DeployTargetError, |
| 37 | makeDeployTarget, |
| 38 | toOutputFile, |
| 39 | type BuildOutput, |
| 40 | } from "../core/index.ts"; |
| 41 | import { AWS_LAMBDA_HANDLE_GLOBAL } from "./aws-shared.ts"; |
| 42 | import { make, type WakuTarget, type WakuTargetBuildContext } from "./Waku.ts"; |
| 43 | |
| 44 | export type { WakuTarget, WakuTargetContext } from "./Waku.ts"; |
| 45 | |
| 46 | /** The serve entry the finishing pass writes into `dist/server`. */ |
| 47 | export const SERVE_ENTRY_NAME = "serve-aws-lambda.mjs"; |
| 48 | |
| 49 | /** Configuration accepted by the AWS deploy target. */ |
| 50 | export 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 | */ |
| 73 | export const makeServeEntrySource = (streaming: boolean): string => |
| 74 | `// Generated by @alchemy.run/frontend-frameworks/waku/aws — the Lambda entry. |
| 75 | import { INTERNAL_runFetch } from "./index.js"; |
| 76 | |
| 77 | const handle = globalThis.${AWS_LAMBDA_HANDLE_GLOBAL}; |
| 78 | if (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 | |
| 85 | export 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 | */ |
| 97 | const 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 | */ |
| 196 | export 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 | |
| 208 | export 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 | */ |
| 223 | export 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 | */ |
| 254 | export const target = makeWakuAwsTarget; |
| 255 | |
| 256 | export default makeWakuAwsTarget; |