1/**
2 * `@alchemy.run/frontend-frameworks/octane/node` — the Node container deploy
3 * target for the Octane integration.
4 *
5 * Node-hosted `Website.Octane` resources select this target, which
6 * automatically wraps Octane's default native Node output. No adapter is
7 * required in `octane.config.ts`: the project's own `vite build` emits
8 * `dist/server/entry.js`, a self-contained Node ESM bundle exporting a
9 * web-standard fetch `handler`.
10 * The finishing pass writes a Node HTTP program that serves
11 * `clientDirectory` first, then falls through to that handler on `PORT`
12 * (default 3000), and answers `GET /health`. It re-reads `dist/server`
13 * so runtime inputs — notably the SSR `index.html` the entry reads via
14 * `join(__dirname, "./index.html")` — stay in the output. Container
15 * FrameworkSites extraFile that template to dest `"index.html"` next to
16 * the bundled `index.mjs` (not into `dist/`, or NodeServe would serve
17 * the unrendered shell at `GET /`). The finish pass also disables
18 * Octane's `isMainModule` auto-listen so rolldown flattening `entry.js`
19 * into `index.mjs` cannot bind `PORT` before the generated serve entry.
20 *
21 * - **`adapterName` / `adapterPackage`** — identify the optional legacy Node
22 * marker adapter, still accepted for existing projects.
23 * - **`serverEntryFileName`** — `entry.js`, Octane's emitted node entry.
24 * - **`bundle`** — Node resolve conditions (no `workerd`, no `@aws-sdk/`).
25 */
26import * as Effect from "effect/Effect";
27import * as FileSystem from "effect/FileSystem";
28import * as Path from "effect/Path";
29import { runBuildChild } from "../core/BuildChild.ts";
30import {
31 NODE_BUNDLE_CONDITIONS,
32 NODE_SERVE_ENTRY_FILE_NAME,
33 relativeClientDirExpression,
34 writeNodeServeEntry,
35} from "../core/NodeServe.ts";
36import {
37 DeployTargetError,
38 makeDeployTarget,
39 readServerModulesFromDisk,
40 sortServerModules,
41} from "../core/index.ts";
42import { make, type OctaneTarget, type OctaneTargetConfig } from "./Octane.ts";
43
44/** The `adapter.name` the Node marker adapter declares. */
45export const ADAPTER_NAME = "node";
46
47/** The optional legacy Node marker adapter module for existing configs. */
48export const ADAPTER_PACKAGE =
49 "@alchemy.run/frontend-frameworks/octane/node-adapter";
50
51/** Octane's emitted node server entry within the server output directory. */
52export const SERVER_ENTRY_FILE_NAME = "entry.js";
53
54export interface OctaneNodeTargetConfig extends OctaneTargetConfig {}
55
56const fail = (message: string, cause?: unknown) =>
57 new DeployTargetError({ platform: "node", message, cause });
58
59/**
60 * Octane's node `entry.js` auto-listens when `import.meta.url` is the
61 * process entry. Container hosts rolldown that file into `index.mjs`,
62 * which would start a second HTTP server on `PORT` (EADDRINUSE) and
63 * crash. Replace the guard with `false` so only the generated serve
64 * entry binds the port.
65 */
66const neutralizeAutoListen = (source: string): string =>
67 source
68 .replaceAll(
69 "fileURLToPath(import.meta.url) === resolve(process.argv[1])",
70 "false",
71 )
72 .replaceAll(
73 "fileURLToPath(import.meta.url)===resolve(process.argv[1])",
74 "false",
75 );
76
77const makeNodeAdapterTarget = (
78 config: OctaneNodeTargetConfig = {},
79): OctaneTarget =>
80 makeDeployTarget({
81 platform: "node",
82 config,
83 bundle: {
84 conditions: [...NODE_BUNDLE_CONDITIONS],
85 },
86 adapterName: ADAPTER_NAME,
87 adapterPackage: ADAPTER_PACKAGE,
88 serverEntryFileName: SERVER_ENTRY_FILE_NAME,
89 finish: (output, context) =>
90 Effect.gen(function* () {
91 const fs = yield* FileSystem.FileSystem;
92 const path = yield* Path.Path;
93 if (context.entry === undefined) {
94 return yield* Effect.fail(
95 fail("The Octane build produced no on-disk server entry to finish"),
96 );
97 }
98 if (output.clientDirectory === undefined) {
99 return yield* Effect.fail(
100 fail(
101 "The Octane build produced no client directory for the Node serve entry",
102 ),
103 );
104 }
105 const serverDir = path.dirname(context.entry);
106 const entrySource = yield* fs
107 .readFileString(context.entry)
108 .pipe(
109 Effect.mapError((error) =>
110 fail("Failed to read dist/server/entry.js", error),
111 ),
112 );
113 const neutralized = neutralizeAutoListen(entrySource);
114 if (neutralized !== entrySource) {
115 yield* fs
116 .writeFileString(context.entry, neutralized)
117 .pipe(
118 Effect.mapError((error) =>
119 fail(
120 "Failed to disable Octane auto-listen in dist/server/entry.js",
121 error,
122 ),
123 ),
124 );
125 }
126 yield* fs
127 .writeFileString(
128 path.join(serverDir, "package.json"),
129 '{"type":"module"}\n',
130 )
131 .pipe(
132 Effect.mapError((error) =>
133 fail("Failed to write dist/server/package.json", error),
134 ),
135 );
136 const servePath = path.join(serverDir, NODE_SERVE_ENTRY_FILE_NAME);
137 const serveModuleName = path
138 .join("server", NODE_SERVE_ENTRY_FILE_NAME)
139 .replaceAll("\\", "/");
140 const withServe = yield* writeNodeServeEntry({
141 output,
142 servePath,
143 serveModuleName,
144 clientDirExpression: relativeClientDirExpression(
145 servePath,
146 output.clientDirectory,
147 ),
148 handler: {
149 kind: "fetch",
150 imports: `import { handler } from ${JSON.stringify(`./${SERVER_ENTRY_FILE_NAME}`)};`,
151 expr: "handler",
152 },
153 platform: "node",
154 });
155 // Re-read so the SSR `index.html` the entry reads beside itself is
156 // part of the output (same as the AWS finishing pass).
157 const modules = yield* readServerModulesFromDisk({
158 directory: serverDir,
159 prefix: "server",
160 }).pipe(Effect.mapError((error) => fail(error.message, error.cause)));
161 return {
162 ...withServe,
163 serverModules: sortServerModules(modules, serveModuleName),
164 };
165 }),
166 });
167
168export interface OctaneNodeBuildChildConfig {
169 readonly rootDir: string;
170 readonly config: OctaneNodeTargetConfig;
171}
172
173export const buildInChild = (config: OctaneNodeBuildChildConfig) =>
174 Effect.gen(function* () {
175 const framework = yield* make({
176 root: config.rootDir,
177 target: makeNodeAdapterTarget(config.config),
178 compatibilityDate: config.config.compatibilityDate,
179 compatibilityFlags: config.config.compatibilityFlags,
180 });
181 return yield* framework.build({ root: config.rootDir });
182 });
183
184export const makeNodeTarget = (
185 config: OctaneNodeTargetConfig = {},
186): OctaneTarget => ({
187 ...makeNodeAdapterTarget(config),
188 build: (context) =>
189 runBuildChild({
190 module: import.meta.url,
191 rootDir: context.root,
192 env: context.env,
193 framework: "octane",
194 config: {
195 rootDir: context.root,
196 config,
197 } satisfies OctaneNodeBuildChildConfig,
198 }).pipe(Effect.mapError((error) => fail(error.message, error.cause))),
199});
200
201export const target = makeNodeTarget;
202
203export default makeNodeTarget;