1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Layer from "effect/Layer";
4import * as Path from "effect/Path";
5import { finishNeonOutput, makeNeonTarget } from "../core/NeonServe.ts";
6import type { BuildOutput } from "../core/BuildOutput.ts";
7import { DeployTargetError } from "../core/DeployTarget.ts";
8import { FrameworkError, type FrameworkDevOptions } from "../core/Framework.ts";
9import { createRequire } from "node:module";
10import { pathToFileURL } from "node:url";
11import { isInsideDevChild, runDevChild } from "../core/DevChild.ts";
12import { runBuildChild } from "../core/BuildChild.ts";
13import { Framework } from "../core/Framework.ts";
14import { make as makeVocsLayer } from "./Vocs.ts";
15import type { Plugin } from "vite";
16import {
17 make as makeNode,
18 makeNodeTarget,
19 type VocsNodeFrameworkOptions,
20} from "./node.ts";
21
22// Preserve config imports and closures; only the dev-only Vite loader is removed.
23const runtimeConfigPlugin = (): Plugin => ({
24 name: "alchemy:vocs-neon-runtime-config",
25 enforce: "post",
26 transform(code, id) {
27 const file = id.replaceAll("\\", "/").split("?")[0];
28 if (file?.endsWith("/vocs/dist/internal/mdx.js")) {
29 const logger =
30 /const logger = createLogger\(undefined, \{ allowClearScreen: false, prefix: '\[vocs\]' \}\);/;
31 if (
32 !logger.test(code) ||
33 !code.includes("import { createLogger } from 'vite';")
34 )
35 throw new Error(
36 "Vocs MDX logger changed; update the Neon runtime bridge.",
37 );
38 return code
39 .replace("import { createLogger } from 'vite';", "")
40 .replace(
41 logger,
42 'const logger = { warn: (message) => console.warn("[vocs]", message), error: (message) => console.error("[vocs]", message) };',
43 );
44 }
45 if (!file?.endsWith("/vocs/dist/internal/config.js")) return;
46 const pattern =
47 /export async function resolve\(options = \{\}\) \{[\s\S]*?\n\}\n(?=export let global;)/;
48 if (!pattern.test(code))
49 throw new Error(
50 "Vocs runtime config shape changed; update the Neon config bridge.",
51 );
52 return code.replace(
53 pattern,
54 "export async function resolve() {\n" +
55 ' const resolved = (await import("virtual:alchemy-vocs/user-config")).default;\n' +
56 " return define(resolved);\n}\n",
57 );
58 },
59});
60
61export const buildInChild = (config: { root: string }) =>
62 Effect.gen(function* () {
63 const node = makeNodeTarget();
64 const framework = yield* Framework.pipe(
65 Effect.provide(
66 makeVocsLayer({
67 root: config.root,
68 target: {
69 ...node,
70 build: undefined,
71 vitePlugins: (context) =>
72 node
73 .vitePlugins(context)
74 .pipe(
75 Effect.map((plugins) => [...plugins, runtimeConfigPlugin()]),
76 ),
77 },
78 }),
79 ),
80 );
81 return yield* framework
82 .build({ root: config.root })
83 .pipe(Effect.flatMap(finishNeonOutput));
84 });
85
86/** Vocs production output served by a Neon Fetch handler. */
87export const target = (config?: Parameters<typeof makeNodeTarget>[0]) => ({
88 ...makeNeonTarget(makeNodeTarget(config)),
89 build: (
90 context: Parameters<
91 NonNullable<ReturnType<typeof makeNodeTarget>["build"]>
92 >[0],
93 ) =>
94 runBuildChild({
95 runtime: "node",
96 module: import.meta.url,
97 rootDir: context.root,
98 config: { root: context.root },
99 framework: "vocs",
100 env: context.env,
101 }).pipe(
102 Effect.mapError(
103 (cause) =>
104 new DeployTargetError({
105 platform: "neon",
106 message: cause.message,
107 cause,
108 }),
109 ),
110 ),
111});
112export default target;
113
114/** Native Vocs development with Neon-compatible production output. */
115export const make = Effect.fn(function* (
116 options: VocsNodeFrameworkOptions = {},
117) {
118 const fs = yield* FileSystem.FileSystem;
119 const path = yield* Path.Path;
120 const node = yield* makeNode(options);
121 const services = Layer.mergeAll(
122 Layer.succeed(FileSystem.FileSystem)(fs),
123 Layer.succeed(Path.Path)(path),
124 );
125 return {
126 dev: Effect.fn(function* (devOptions?: FrameworkDevOptions) {
127 const root = yield* Effect.sync(() =>
128 path.resolve(devOptions?.root ?? options.root ?? process.cwd()),
129 );
130 if (isInsideDevChild()) {
131 const fail = (cause: unknown) =>
132 new FrameworkError({
133 framework: "vocs",
134 message: "Failed to start the native Vocs dev server",
135 cause,
136 });
137 const modules = yield* Effect.try({
138 try: () => {
139 const project = createRequire(path.join(root, "package.json"));
140 const vocs = project.resolve("vocs/vite");
141 const require = createRequire(vocs);
142 return {
143 vocs: pathToFileURL(vocs).href,
144 vite: pathToFileURL(require.resolve("vite")).href,
145 react: pathToFileURL(require.resolve("@vitejs/plugin-react"))
146 .href,
147 };
148 },
149 catch: fail,
150 });
151 // All plugins must share Vocs's Vite instance for runnable SSR environments.
152 const vite = yield* Effect.tryPromise({
153 try: () => import(modules.vite) as Promise<typeof import("vite")>,
154 catch: fail,
155 });
156 const react = yield* Effect.tryPromise({
157 try: () =>
158 import(modules.react) as Promise<
159 typeof import("@vitejs/plugin-react")
160 >,
161 catch: fail,
162 });
163 const vocs = yield* Effect.tryPromise({
164 try: () =>
165 import(modules.vocs) as Promise<typeof import("vocs/vite")>,
166 catch: fail,
167 });
168 const server = yield* Effect.acquireRelease(
169 Effect.tryPromise({
170 try: () =>
171 vite.createServer({
172 root,
173 configFile: false,
174 plugins: [react.default(), vocs.vocs()],
175 server: {
176 host: devOptions?.host,
177 port: devOptions?.port,
178 strictPort: devOptions?.port !== undefined,
179 },
180 }),
181 catch: fail,
182 }),
183 (server) => Effect.promise(() => server.close()),
184 );
185 yield* Effect.tryPromise({ try: () => server.listen(), catch: fail });
186 const url =
187 server.resolvedUrls?.local[0] ?? server.resolvedUrls?.network[0];
188 if (!url)
189 return yield* Effect.fail(
190 fail(new Error("Vocs did not report a dev URL")),
191 );
192 return { url };
193 }
194 return yield* runDevChild({
195 // Vocs's Vite dev server (like its build) exhausts memory under
196 // Bun; run the dev child under Node from PATH.
197 runtime: "node",
198 framework: "vocs",
199 module: import.meta.url,
200 callerUrl: import.meta.url,
201 rootDir: root,
202 makeOptions: { ...options, root },
203 devOptions: { ...devOptions, root },
204 });
205 }),
206 build: (buildOptions?: {
207 readonly root?: string;
208 }): Effect.Effect<BuildOutput, FrameworkError | DeployTargetError> =>
209 Effect.gen(function* () {
210 const root = yield* Effect.sync(() =>
211 path.resolve(buildOptions?.root ?? options.root ?? process.cwd()),
212 );
213 return yield* target().build({ root, framework: "vocs" });
214 }).pipe(Effect.provide(services)),
215 };
216});