| 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 | */ |
| 26 | import * as Effect from "effect/Effect"; |
| 27 | import * as FileSystem from "effect/FileSystem"; |
| 28 | import * as Path from "effect/Path"; |
| 29 | import { runBuildChild } from "../core/BuildChild.ts"; |
| 30 | import { |
| 31 | NODE_BUNDLE_CONDITIONS, |
| 32 | NODE_SERVE_ENTRY_FILE_NAME, |
| 33 | relativeClientDirExpression, |
| 34 | writeNodeServeEntry, |
| 35 | } from "../core/NodeServe.ts"; |
| 36 | import { |
| 37 | DeployTargetError, |
| 38 | makeDeployTarget, |
| 39 | readServerModulesFromDisk, |
| 40 | sortServerModules, |
| 41 | } from "../core/index.ts"; |
| 42 | import { make, type OctaneTarget, type OctaneTargetConfig } from "./Octane.ts"; |
| 43 | |
| 44 | /** The `adapter.name` the Node marker adapter declares. */ |
| 45 | export const ADAPTER_NAME = "node"; |
| 46 | |
| 47 | /** The optional legacy Node marker adapter module for existing configs. */ |
| 48 | export const ADAPTER_PACKAGE = |
| 49 | "@alchemy.run/frontend-frameworks/octane/node-adapter"; |
| 50 | |
| 51 | /** Octane's emitted node server entry within the server output directory. */ |
| 52 | export const SERVER_ENTRY_FILE_NAME = "entry.js"; |
| 53 | |
| 54 | export interface OctaneNodeTargetConfig extends OctaneTargetConfig {} |
| 55 | |
| 56 | const 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 | */ |
| 66 | const 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 | |
| 77 | const 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 | |
| 168 | export interface OctaneNodeBuildChildConfig { |
| 169 | readonly rootDir: string; |
| 170 | readonly config: OctaneNodeTargetConfig; |
| 171 | } |
| 172 | |
| 173 | export 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 | |
| 184 | export 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 | |
| 201 | export const target = makeNodeTarget; |
| 202 | |
| 203 | export default makeNodeTarget; |