1/*!
2 * Client metadata collection adapted from @octanejs/vite-plugin 0.1.22.
3 * MIT License
4 * Copyright (c) 2026 Dominic Gannaway
5 *
6 * Permission is hereby granted, free of charge, to any person obtaining a copy
7 * of this software and associated documentation files (the "Software"), to deal
8 * in the Software without restriction, including without limitation the rights
9 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 * copies of the Software, and to permit persons to whom the Software is
11 * furnished to do so, subject to the following conditions:
12 *
13 * The above copyright notice and this permission notice shall be included in all
14 * copies or substantial portions of the Software.
15 *
16 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 * SOFTWARE.
23 */
24import * as Effect from "effect/Effect";
25import * as FileSystem from "effect/FileSystem";
26import * as Path from "effect/Path";
27import type { InlineConfig, Plugin, PluginOption } from "vite";
28import { createRequire } from "node:module";
29import {
30 FrameworkError,
31 loadProjectModule,
32 resolveProjectPackageDirectory,
33} from "../core/index.ts";
34import { readOctaneOutput } from "./Octane.ts";
35
36interface OctaneConfig {
37 readonly adapter?: { readonly name?: string };
38 readonly router: {
39 readonly routes: ReadonlyArray<{
40 readonly type: string;
41 readonly entry?: unknown;
42 readonly layout?: string;
43 }>;
44 readonly preHydrate?: string;
45 };
46 readonly rootBoundary: {
47 readonly pending?: unknown;
48 readonly catch?: unknown;
49 };
50 readonly build: {
51 readonly outDir: string;
52 readonly minify?: boolean;
53 readonly target?: string;
54 };
55}
56
57interface OctanePluginModule {
58 readonly loadOctaneConfig: (root: string) => Promise<OctaneConfig | null>;
59 readonly getOctaneConfigPath: (root: string) => string;
60 readonly get_route_entry_path: (entry: unknown) => string | undefined;
61}
62
63interface ManifestEntry {
64 readonly file: string;
65 readonly src?: string;
66 readonly css?: string[];
67 readonly imports?: string[];
68 readonly dynamicImports?: string[];
69}
70
71type ClientAssets = Record<string, { js: string; css: string[] }>;
72
73const fail = (message: string, cause?: unknown) =>
74 new FrameworkError({ framework: "octane", message, cause });
75
76/** Keep Octane's compiler and client hooks; Alchemy builds the server separately. */
77export const clientPlugins = (
78 options: ReadonlyArray<PluginOption>,
79): Effect.Effect<Plugin[], FrameworkError> =>
80 Effect.gen(function* () {
81 const plugins: Plugin[] = [];
82 const visit = (option: PluginOption): Effect.Effect<void, FrameworkError> =>
83 Effect.gen(function* () {
84 const resolved = yield* Effect.tryPromise({
85 try: () => Promise.resolve(option),
86 catch: (cause) => fail("Failed to resolve a Vite plugin", cause),
87 });
88 if (Array.isArray(resolved)) {
89 for (const child of resolved) yield* visit(child);
90 } else if (resolved) {
91 plugins.push(resolved);
92 }
93 });
94 for (const option of options) yield* visit(option);
95 const octane = plugins.filter(
96 (plugin) => plugin.name === "@octanejs/vite-plugin",
97 );
98 if (octane.length !== 1 || octane[0]?.closeBundle === undefined) {
99 return yield* Effect.fail(
100 fail(
101 "Expected one native octane() Vite plugin with a server build hook",
102 ),
103 );
104 }
105 return plugins.map((plugin) =>
106 plugin === octane[0] ? { ...plugin, closeBundle: undefined } : plugin,
107 );
108 });
109
110/** Generate the Worker entry without importing build-time adapter code. */
111export const workerEntry = (options: {
112 readonly manifest: string;
113 readonly production: string;
114 readonly htmlTemplate: string;
115}) => `import { manifest, rendererDeps } from ${JSON.stringify(options.manifest)};
116import { createHandler } from ${JSON.stringify(options.production)};
117
118let handler;
119export default {
120 fetch(request, env, ctx) {
121 handler ??= createHandler(manifest, {
122 ...rendererDeps,
123 htmlTemplate: ${JSON.stringify(options.htmlTemplate)},
124 });
125 return handler(request, { env, ctx });
126 },
127};
128`;
129
130export const buildCloudflare = (rootDirectory: string) =>
131 Effect.scoped(
132 Effect.gen(function* () {
133 const fs = yield* FileSystem.FileSystem;
134 const path = yield* Path.Path;
135 const root = path.resolve(rootDirectory);
136 const vite = yield* loadProjectModule<typeof import("vite")>(
137 root,
138 "vite",
139 );
140 const pluginDirectory = yield* resolveProjectPackageDirectory(
141 root,
142 "@octanejs/vite-plugin",
143 ).pipe(Effect.flatMap((directory) => fs.realPath(directory)));
144 const resolveModule = yield* Effect.sync(
145 () => createRequire(path.join(pluginDirectory, "package.json")).resolve,
146 );
147 const octane = yield* loadProjectModule<OctanePluginModule>(
148 root,
149 "@octanejs/vite-plugin",
150 );
151 const codegen = yield* loadProjectModule<{
152 generateServerEntry: (options: Record<string, unknown>) => string;
153 }>(pluginDirectory, "@octanejs/app-core/codegen");
154 const assetHelper = yield* loadProjectModule<{
155 createClientAssetMap: (
156 manifest: Record<string, ManifestEntry>,
157 entries: string[],
158 files: Record<string, string>,
159 ) => ClientAssets;
160 }>(pluginDirectory, path.join(pluginDirectory, "src/client-assets.js"));
161 const config = yield* Effect.tryPromise({
162 try: () => octane.loadOctaneConfig(root),
163 catch: (cause) => fail("Failed to load octane.config.ts", cause),
164 });
165 if (config === null || config.router.routes.length === 0) {
166 return yield* Effect.fail(
167 fail(
168 "A fullstack Octane app with octane.config.ts routes is required",
169 ),
170 );
171 }
172 if (
173 config.adapter !== undefined &&
174 config.adapter.name !== "cloudflare"
175 ) {
176 return yield* Effect.fail(
177 fail(
178 `The Octane adapter "${config.adapter.name}" is incompatible with Cloudflare`,
179 ),
180 );
181 }
182 const entries = yield* Effect.sync(() => [
183 ...new Set(
184 [
185 ...config.router.routes
186 .filter((route) => route.type === "render")
187 .flatMap((route) => [
188 octane.get_route_entry_path(route.entry),
189 route.layout,
190 ]),
191 config.router.preHydrate,
192 octane.get_route_entry_path(config.rootBoundary.pending),
193 octane.get_route_entry_path(config.rootBoundary.catch),
194 ].filter((entry): entry is string => typeof entry === "string"),
195 ),
196 ]);
197 const loadConfig = (ssr: boolean) =>
198 Effect.tryPromise({
199 try: () =>
200 vite.loadConfigFromFile(
201 { command: "build", mode: "production", isSsrBuild: ssr },
202 undefined,
203 root,
204 "warn",
205 ),
206 catch: (cause) => fail("Failed to load vite.config", cause),
207 }).pipe(Effect.map((loaded) => loaded?.config ?? {}));
208 const clientConfig = yield* loadConfig(false);
209 const plugins = yield* clientPlugins(clientConfig.plugins ?? []);
210 if (
211 clientConfig.root !== undefined &&
212 path.resolve(root, clientConfig.root) !== root
213 ) {
214 return yield* Effect.fail(
215 fail(
216 "Set rootDir to the Octane app root instead of overriding Vite root",
217 ),
218 );
219 }
220 let clientDir = path.resolve(root, config.build.outDir, "client");
221 const rpcModules = new Set<string>();
222 const entryFiles: Record<string, string> = {};
223 const collect: Plugin = {
224 name: "alchemy:octane-client-metadata",
225 configResolved(config) {
226 clientDir = path.resolve(config.root, config.build.outDir);
227 },
228 transform(code, id, options) {
229 const file = id.split("?")[0]!;
230 if (
231 options?.ssr ||
232 !/\.(tsrx|tsx)$/.test(file) ||
233 !code.includes("_$__serverRpc(")
234 )
235 return;
236 const relative = path.relative(root, file);
237 rpcModules.add(
238 relative === ".." ||
239 relative.startsWith(`..${path.sep}`) ||
240 path.isAbsolute(relative)
241 ? file
242 : `/${relative.replaceAll("\\", "/")}`,
243 );
244 },
245 generateBundle(_options, bundle) {
246 for (const output of Object.values(bundle)) {
247 if (output.type !== "chunk") continue;
248 for (const entry of entries) {
249 const id = path
250 .resolve(root, entry.startsWith("/") ? `.${entry}` : entry)
251 .replaceAll("\\", "/");
252 if (
253 output.moduleIds.some(
254 (module) => module.replaceAll("\\", "/") === id,
255 )
256 ) {
257 entryFiles[entry] ??= output.fileName;
258 }
259 }
260 }
261 },
262 };
263 const serverDir = path.resolve(root, config.build.outDir, "server");
264 yield* Effect.tryPromise({
265 try: () =>
266 vite.build({
267 ...clientConfig,
268 root,
269 configFile: false,
270 plugins: plugins.flatMap((plugin) =>
271 plugin.name === "@octanejs/vite-plugin"
272 ? [plugin, collect]
273 : [plugin],
274 ),
275 logLevel: "warn",
276 }),
277 catch: (cause) => fail("Failed to build the Octane client", cause),
278 });
279 const manifest = yield* fs
280 .readFileString(path.join(clientDir, ".vite/manifest.json"))
281 .pipe(
282 Effect.flatMap((source) =>
283 Effect.try({
284 try: () => JSON.parse(source) as Record<string, ManifestEntry>,
285 catch: (cause) => fail("Invalid Octane client manifest", cause),
286 }),
287 ),
288 );
289 const clientAssets = yield* Effect.sync(() =>
290 assetHelper.createClientAssetMap(manifest, entries, entryFiles),
291 );
292 const htmlTemplate = yield* fs.readFileString(
293 path.join(clientDir, "index.html"),
294 );
295 const directory = yield* fs.makeTempDirectoryScoped({
296 directory: root,
297 prefix: ".alchemy-octane-worker-",
298 });
299 const manifestFile = path.join(directory, "manifest.mjs");
300 const workerFile = path.join(directory, "worker.mjs");
301 const production = yield* Effect.try(() =>
302 resolveModule("@octanejs/app-core/production"),
303 );
304 const source = yield* Effect.try({
305 try: () =>
306 codegen.generateServerEntry({
307 routes: config.router.routes,
308 rootBoundary: config.rootBoundary,
309 octaneConfigPath: octane.getOctaneConfigPath(root),
310 rpcModulePaths: [...rpcModules],
311 clientAssetMap: clientAssets,
312 mode: "manifest",
313 configModuleId: resolveModule("@octanejs/app-core/config"),
314 serverRuntimeModuleId: resolveModule("octane/server"),
315 staticRuntimeModuleId: resolveModule("octane/static"),
316 generatedBy: "@alchemy.run/frontend-frameworks",
317 }),
318 catch: (cause) =>
319 fail("Failed to generate the Octane server manifest", cause),
320 });
321 yield* fs.writeFileString(manifestFile, source);
322 const workerSource = yield* Effect.sync(() =>
323 workerEntry({ manifest: manifestFile, production, htmlTemplate }),
324 );
325 yield* fs.writeFileString(workerFile, workerSource);
326 const serverConfig = yield* loadConfig(true);
327 const serverOptions: InlineConfig = yield* Effect.sync(() =>
328 vite.mergeConfig(serverConfig, {
329 root,
330 configFile: false,
331 appType: "custom",
332 logLevel: "warn",
333 define: { "process.env.NODE_ENV": JSON.stringify("production") },
334 resolve: {
335 conditions: [
336 "workerd",
337 "worker",
338 "module",
339 "browser",
340 "production",
341 ],
342 alias: [
343 {
344 find: /^@octanejs\/vite-plugin$/,
345 replacement: path.join(pluginDirectory, "src/config-entry.js"),
346 },
347 ],
348 },
349 build: {
350 outDir: serverDir,
351 emptyOutDir: true,
352 ssr: true,
353 target: config.build.target,
354 minify: config.build.minify ?? false,
355 rollupOptions: {
356 input: workerFile,
357 external: [/^(node|cloudflare):/],
358 output: { entryFileNames: "worker.js", format: "esm" },
359 },
360 },
361 ssr: {
362 target: "webworker",
363 noExternal: true,
364 external: ["vite"],
365 resolve: {
366 conditions: [
367 "workerd",
368 "worker",
369 "module",
370 "browser",
371 "production",
372 ],
373 },
374 },
375 } satisfies InlineConfig),
376 );
377 yield* Effect.tryPromise({
378 try: () => vite.build(serverOptions),
379 catch: (cause) => fail("Failed to build the Octane Worker", cause),
380 });
381 yield* fs.remove(path.join(clientDir, "index.html"));
382 yield* fs.remove(path.join(clientDir, ".vite"), { recursive: true });
383 return yield* readOctaneOutput({
384 dir: path.resolve(root, config.build.outDir),
385 clientDir,
386 serverDir,
387 serverEntryFileName: "worker.js",
388 });
389 }).pipe(
390 Effect.mapError((cause) =>
391 cause instanceof FrameworkError
392 ? cause
393 : fail("Failed to build Octane for Cloudflare", cause),
394 ),
395 ),
396 );