1/**
2 * `@alchemy.run/frontend-frameworks/vocs/node` — the Node container deploy
3 * target for Vocs.
4 *
5 * Vocs is Waku-based RSC. The target runs the vocs build in a disposable
6 * child process (cwd = project root) with waku's node adapter, then writes
7 * a Node HTTP program that serves `clientDirectory` first (extensionless
8 * HTML: `/about` → `about/index.html`) and falls through to
9 * `INTERNAL_runFetch`. Vocs' default `renderStrategy` is `"dynamic"`, so
10 * dropping server modules would deploy an empty html-shell (no pages).
11 */
12import * as Effect from "effect/Effect";
13import * as FileSystem from "effect/FileSystem";
14import * as Path from "effect/Path";
15import * as NodePath from "node:path";
16import { runBuildChild } from "../core/BuildChild.ts";
17import { isInsideDevChild, runDevChild } from "../core/DevChild.ts";
18import {
19 NODE_BUNDLE_CONDITIONS,
20 NODE_SERVE_ENTRY_FILE_NAME,
21 relativeClientDirExpression,
22 writeNodeServeEntry,
23} from "../core/NodeServe.ts";
24import {
25 DeployTargetError,
26 Framework,
27 makeDeployTarget,
28 type Framework as FrameworkService,
29} from "../core/index.ts";
30import type { VocsTarget } from "./Target.ts";
31import { make as makeVocsLayer } from "./Vocs.ts";
32
33export interface VocsNodeTargetConfig {}
34
35const fail = (message: string, cause?: unknown) =>
36 new DeployTargetError({ platform: "node", message, cause });
37
38/**
39 * The in-child target: adapter + empty vite plugins, no wholesale `build`
40 * (the child runs the regular vocs pipeline). `finish` writes the Node
41 * serve entry so dynamic pages SSR instead of serving the empty html-shell.
42 */
43const makeNodeAdapterTarget = (
44 config: VocsNodeTargetConfig = {},
45): VocsTarget<VocsNodeTargetConfig> =>
46 makeDeployTarget({
47 platform: "node",
48 config,
49 bundle: {
50 conditions: [...NODE_BUNDLE_CONDITIONS],
51 },
52 adapter: (context) =>
53 Effect.succeed(
54 NodePath.join(context.wakuDirectory, "dist/adapters/node.js"),
55 ),
56 vitePlugins: () => Effect.sync(() => []),
57 finish: (output) =>
58 Effect.gen(function* () {
59 const fs = yield* FileSystem.FileSystem;
60 const path = yield* Path.Path;
61 if (output.distDirectory === undefined) {
62 return yield* Effect.fail(
63 fail("The vocs build produced no dist directory"),
64 );
65 }
66 if (output.clientDirectory === undefined) {
67 return yield* Effect.fail(
68 fail(
69 "The vocs build produced no client directory for the Node serve entry",
70 ),
71 );
72 }
73 const serverDir = path.join(output.distDirectory, "server");
74 const serverIndex = path.join(serverDir, "index.js");
75 const hasServerIndex = yield* fs
76 .exists(serverIndex)
77 .pipe(
78 Effect.mapError((error) =>
79 fail("Failed to probe the built server entry", error),
80 ),
81 );
82 if (!hasServerIndex) {
83 return yield* Effect.fail(
84 fail(`The vocs build produced no server entry at ${serverIndex}`),
85 );
86 }
87 yield* fs
88 .writeFileString(
89 path.join(serverDir, "package.json"),
90 `${JSON.stringify({ type: "module" }, null, 2)}\n`,
91 )
92 .pipe(
93 Effect.mapError((error) =>
94 fail("Failed to write dist/server/package.json", error),
95 ),
96 );
97 const servePath = path.join(serverDir, NODE_SERVE_ENTRY_FILE_NAME);
98 return yield* writeNodeServeEntry({
99 output,
100 servePath,
101 serveModuleName: path
102 .join("server", NODE_SERVE_ENTRY_FILE_NAME)
103 .replaceAll("\\", "/"),
104 clientDirExpression: relativeClientDirExpression(
105 servePath,
106 output.clientDirectory,
107 ),
108 handler: {
109 kind: "fetch",
110 imports: `import { INTERNAL_runFetch } from "./index.js";`,
111 expr: "(req, ...args) => INTERNAL_runFetch(process.env, req, ...args)",
112 },
113 htmlHandling: "drop-trailing-slash",
114 platform: "node",
115 });
116 }),
117 });
118
119export interface VocsNodeBuildChildConfig {
120 readonly rootDir: string;
121 readonly config: VocsNodeTargetConfig;
122}
123
124export const buildInChild = (config: VocsNodeBuildChildConfig) =>
125 Effect.gen(function* () {
126 const framework = yield* Framework.pipe(
127 Effect.provide(
128 makeVocsLayer({
129 root: config.rootDir,
130 target: makeNodeAdapterTarget(config.config),
131 }),
132 ),
133 );
134 return yield* framework.build({ root: config.rootDir });
135 });
136
137/**
138 * Create the Node {@link VocsTarget}: the wholesale `build` hook runs
139 * the vocs pipeline in a child process (cwd = project root).
140 */
141export const makeNodeTarget = (
142 config: VocsNodeTargetConfig = {},
143): VocsTarget<VocsNodeTargetConfig> => ({
144 ...makeNodeAdapterTarget(config),
145 build: (context) =>
146 runBuildChild({
147 module: import.meta.url,
148 rootDir: context.root,
149 env: context.env,
150 framework: "vocs",
151 config: {
152 rootDir: context.root,
153 config,
154 } satisfies VocsNodeBuildChildConfig,
155 }).pipe(Effect.mapError((error) => fail(error.message, error.cause))),
156});
157
158export const target = makeNodeTarget;
159
160export default makeNodeTarget;
161
162export interface VocsNodeFrameworkOptions {
163 readonly root?: string | undefined;
164 readonly target?: string | undefined;
165 readonly outDir?: string | undefined;
166}
167
168/**
169 * Framework-module contract used by container Website composites
170 * (`module.make(...)` returns `{ build, dev }`, not a Layer).
171 */
172export const make = (options: VocsNodeFrameworkOptions = {}) =>
173 Effect.gen(function* () {
174 const path = yield* Path.Path;
175 const resolveRoot = (override: string | undefined) =>
176 path.resolve(override ?? options.root ?? process.cwd());
177
178 const withFramework = <A, E, R>(
179 root: string,
180 use: (framework: FrameworkService["Service"]) => Effect.Effect<A, E, R>,
181 ) =>
182 Effect.gen(function* () {
183 const framework = yield* Framework.pipe(
184 Effect.provide(
185 makeVocsLayer({
186 root,
187 // Parent path: child-process target so Vocs Config.resolve()
188 // and group-icons glob src/pages with cwd = project root.
189 target: makeNodeTarget({}),
190 }),
191 ),
192 );
193 return yield* use(framework);
194 });
195
196 return {
197 build: (buildOptions?: { readonly root?: string }) => {
198 const root = resolveRoot(buildOptions?.root);
199 return withFramework(root, (framework) => framework.build({ root }));
200 },
201 dev: (devOptions?: {
202 readonly root?: string;
203 readonly port?: number;
204 readonly host?: string;
205 }) => {
206 const root = resolveRoot(devOptions?.root);
207 if (!isInsideDevChild()) {
208 return runDevChild({
209 // Vocs's Vite dev server (like its build) exhausts memory under
210 // Bun; run the dev child under Node from PATH.
211 runtime: "node",
212 framework: "vocs",
213 module: "@alchemy.run/frontend-frameworks/vocs/node",
214 callerUrl: import.meta.url,
215 rootDir: root,
216 makeOptions: { ...options, root },
217 devOptions: { ...devOptions, root },
218 });
219 }
220 return withFramework(root, (framework) =>
221 framework.dev({
222 root,
223 port: devOptions?.port,
224 host: devOptions?.host,
225 }),
226 );
227 },
228 };
229 });