1/**
2 * `@alchemy.run/frontend-frameworks/vinext/node` — vinext on a Node
3 * container (vinext's Vite build + vinext's production server).
4 *
5 * Unlike the Cloudflare integration (`./source`, Vite + the Alchemy
6 * Cloudflare plugin + `vinext/server/fetch-handler`), this module does
7 * **not** inject a Workers plugin. The container-optimal path is a
8 * long-running Node process:
9 *
10 * - **`build`** runs Vinext's Vite build in a disposable child
11 * (cwd = project root), then writes a serve entry that calls
12 * `startProdServer` from `vinext/server/prod-server`, answers
13 * `GET /health`, and listens on `PORT` (default 3000).
14 * - **`dev`** runs the real `vinext dev` CLI (plain Node), scoped.
15 *
16 * Composites should load this module as both the framework specifier and
17 * the deploy target (same shape as `nextjs/node`).
18 */
19import * as FrameworkCore from "../core/index.ts";
20import * as Effect from "effect/Effect";
21import * as FileSystem from "effect/FileSystem";
22import * as Path from "effect/Path";
23import type * as Scope from "effect/Scope";
24import { runBuildChild } from "../core/BuildChild.ts";
25import {
26 NODE_BUNDLE_CONDITIONS,
27 NODE_DEFAULT_PORT,
28 NODE_SERVE_ENTRY_FILE_NAME,
29} from "../core/NodeServe.ts";
30import {
31 DeployTargetError,
32 makeDeployTarget,
33 type DeployTarget,
34} from "../core/index.ts";
35import {
36 awaitVinextDevReady,
37 collectVinextDist,
38 failFramework,
39 pickEphemeralPort,
40 pinServeModule,
41 resolveVinextCli,
42 runVinextBuild,
43 spawnVinextDev,
44} from "./cli.ts";
45
46const failTarget = (message: string, cause?: unknown) =>
47 new DeployTargetError({ platform: "node", message, cause });
48
49/** Options for the vinext Node framework module / deploy target. */
50export interface VinextNodeOptions {
51 readonly root?: string | undefined;
52 /**
53 * The deploy target module specifier the caller resolved this module as.
54 * This module IS the Node target — accepted and ignored (parity with
55 * `nextjs/node`).
56 */
57 readonly target?: string | undefined;
58}
59
60export interface VinextNodeTargetConfig {
61 readonly root?: string | undefined;
62}
63
64export interface VinextNodeTarget extends DeployTarget<VinextNodeTargetConfig> {}
65
66/** Serve-entry module name relative to `dist/`. */
67export const SERVER_ENTRY_NAME = `server/${NODE_SERVE_ENTRY_FILE_NAME}`;
68
69/**
70 * Packages the Node image `npm install`s so `startProdServer` and the
71 * RSC externals vinext leaves unbundled resolve at runtime.
72 */
73export const VINEXT_NODE_INSTALL = [
74 "vinext",
75 "react",
76 "react-dom",
77 "react-server-dom-webpack",
78] as const;
79
80/**
81 * The generated vinext Node serve entry: starts vinext's official
82 * production server and answers `GET /health` on the same listener.
83 */
84export const makeVinextServeEntrySource = (): string =>
85 `// Generated by @alchemy.run/frontend-frameworks/vinext/node.
86import * as path from "node:path";
87import { fileURLToPath } from "node:url";
88import { startProdServer } from "vinext/server/prod-server";
89
90const dir = path.dirname(fileURLToPath(import.meta.url));
91const PORT = Number.parseInt(process.env.PORT ?? "${String(NODE_DEFAULT_PORT)}", 10);
92const HOST = process.env.HOST ?? "0.0.0.0";
93
94const { server } = await startProdServer({
95 port: PORT,
96 host: HOST,
97 outDir: path.resolve(dir, ".."),
98});
99
100const isHealth = (req) => {
101 const urlPath = (req.url ?? "/").split("?")[0];
102 return (req.method === "GET" || req.method === "HEAD") && urlPath === "/health";
103};
104
105const existing = server.listeners("request").slice();
106server.removeAllListeners("request");
107server.on("request", (req, res) => {
108 if (isHealth(req)) {
109 res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
110 res.end("ok");
111 return;
112 }
113 for (const listener of existing) listener.call(server, req, res);
114});
115`;
116
117export interface VinextNodeBuildChildConfig {
118 readonly rootDir: string;
119 readonly config: VinextNodeTargetConfig;
120}
121
122export const buildInChild = (config: VinextNodeBuildChildConfig) =>
123 Effect.gen(function* () {
124 const root = config.rootDir;
125 yield* runVinextBuild({ root, cache: "redis" });
126 const dist = yield* collectVinextDist(root);
127 return yield* pinServeModule(
128 dist,
129 SERVER_ENTRY_NAME,
130 makeVinextServeEntrySource(),
131 );
132 });
133
134const makeNodeChildTarget = (
135 config: VinextNodeTargetConfig = {},
136): VinextNodeTarget =>
137 makeDeployTarget({
138 platform: "node",
139 config,
140 bundle: {
141 conditions: [...NODE_BUNDLE_CONDITIONS],
142 },
143 });
144
145/**
146 * Create the Node {@link VinextNodeTarget}: run Vinext's Vite build in a
147 * child process, then vinext's production serve entry.
148 */
149export const makeNodeTarget = (
150 config: VinextNodeTargetConfig = {},
151): VinextNodeTarget => ({
152 ...makeNodeChildTarget(config),
153 build: (context) =>
154 runBuildChild({
155 runtime: "node",
156 module: import.meta.url,
157 rootDir: context.root,
158 env: context.env,
159 framework: "vinext",
160 config: {
161 rootDir: context.root,
162 config,
163 } satisfies VinextNodeBuildChildConfig,
164 }).pipe(Effect.mapError((error) => failTarget(error.message, error.cause))),
165});
166
167export const target = makeNodeTarget;
168
169export default makeNodeTarget;
170
171export interface VinextNodeService {
172 readonly build: (
173 options?: FrameworkCore.FrameworkBuildOptions,
174 ) => Effect.Effect<
175 {
176 readonly distDirectory: string;
177 readonly clientDirectory: string | undefined;
178 readonly serverModules: Array<{ readonly name: string }>;
179 },
180 FrameworkCore.FrameworkError
181 >;
182 readonly dev: (
183 options?: FrameworkCore.FrameworkDevOptions,
184 ) => Effect.Effect<
185 FrameworkCore.FrameworkDevServer,
186 FrameworkCore.FrameworkError,
187 Scope.Scope
188 >;
189}
190
191/**
192 * Build the vinext-on-Node framework service. See the module doc for the
193 * `build`/`dev` semantics.
194 */
195export const make: (
196 options?: VinextNodeOptions,
197) => Effect.Effect<
198 VinextNodeService,
199 never,
200 FileSystem.FileSystem | Path.Path
201> = Effect.fnUntraced(function* (options?: VinextNodeOptions) {
202 const fs = yield* FileSystem.FileSystem;
203 const path = yield* Path.Path;
204 const resolveRoot = (override: string | undefined) =>
205 Effect.sync(() => path.resolve(override ?? options?.root ?? process.cwd()));
206
207 const build: VinextNodeService["build"] = Effect.fn(function* (
208 buildOptions?: FrameworkCore.FrameworkBuildOptions,
209 ) {
210 const root = yield* resolveRoot(buildOptions?.root);
211 const nodeTarget = makeNodeTarget({ root });
212 const output = yield* nodeTarget.build!({
213 root,
214 framework: "vinext",
215 env: buildOptions?.env,
216 }).pipe(
217 Effect.provideService(FileSystem.FileSystem, fs),
218 Effect.provideService(Path.Path, path),
219 Effect.mapError((error) => failFramework(error.message)(error.cause)),
220 );
221 return {
222 distDirectory: output.distDirectory ?? path.join(root, "dist"),
223 clientDirectory: output.clientDirectory,
224 serverModules: (output.serverModules ?? []).map((module_) => ({
225 name: module_.name,
226 })),
227 };
228 });
229
230 const dev: VinextNodeService["dev"] = Effect.fn(function* (
231 devOptions?: FrameworkCore.FrameworkDevOptions,
232 ) {
233 const root = yield* resolveRoot(devOptions?.root);
234 const port = devOptions?.port ?? (yield* pickEphemeralPort);
235 const cli = yield* resolveVinextCli(root);
236 const host = devOptions?.host ?? "127.0.0.1";
237 const child = yield* spawnVinextDev({ root, cli, port, host });
238 const url = `http://${host}:${port}`;
239 yield* awaitVinextDevReady({ url, child });
240 return { url };
241 });
242
243 return { build, dev };
244});