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