1import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
5import type * as Scope from "effect/Scope";
6import { pathToFileURL } from "node:url";
7import { createHash } from "node:crypto";
8import { describe, expect, it } from "vitest";
9import { runBuildChild } from "../../core/BuildChild.ts";
10import { buildCloudflare, clientPlugins } from "../CloudflareBuild.ts";
11
12const run = <A, E>(
13 effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Scope.Scope>,
14) =>
15 Effect.runPromise(
16 Effect.scoped(effect).pipe(Effect.provide(NodeServices.layer)),
17 );
18
19const fixture = Effect.gen(function* () {
20 const fs = yield* FileSystem.FileSystem;
21 const path = yield* Path.Path;
22 const packageDir = yield* path.fromFileUrl(
23 new URL("../../../", import.meta.url),
24 );
25 const source = yield* path.fromFileUrl(
26 new URL("./fixtures/cloudflare", import.meta.url),
27 );
28 const root = yield* fs.makeTempDirectoryScoped({
29 directory: packageDir,
30 prefix: ".octane-test-",
31 });
32 yield* fs.copy(source, root, { overwrite: true });
33 return root;
34});
35
36const build = (root: string) =>
37 runBuildChild({
38 module: new URL("../source.ts", import.meta.url).href,
39 rootDir: root,
40 framework: "octane",
41 config: {
42 rootDir: root,
43 compatibilityDate: "2026-08-31",
44 compatibilityFlags: [],
45 },
46 });
47
48describe("Octane Cloudflare build", () => {
49 it("preserves native plugins without mutating their server hooks", async () => {
50 const closeBundle = () => undefined;
51 const native = { name: "@octanejs/vite-plugin", closeBundle };
52 const custom = { name: "custom" };
53 const plugins = await run(
54 clientPlugins([Promise.resolve([native, false]), custom]),
55 );
56 expect(plugins.map((plugin) => plugin.name)).toEqual([
57 native.name,
58 custom.name,
59 ]);
60 expect(plugins[0]?.closeBundle).toBeUndefined();
61 expect(native.closeBundle).toBe(closeBundle);
62 expect(plugins[1]).toBe(custom);
63 });
64
65 it("rejects a missing native plugin instead of silently dropping SSR", async () => {
66 const error = await run(
67 clientPlugins([{ name: "unrelated" }]).pipe(Effect.flip),
68 );
69 expect(error.message).toContain("native octane()");
70 });
71
72 it("builds and serves an adapter-free app with native plugins, CSS, routes, and bindings", async () => {
73 await run(
74 Effect.gen(function* () {
75 const fs = yield* FileSystem.FileSystem;
76 const path = yield* Path.Path;
77 const root = yield* fixture;
78 const configPath = path.join(root, "octane.config.ts");
79 const vitePath = path.join(root, "vite.config.ts");
80 const config = yield* fs.readFileString(configPath);
81 const vite = yield* fs.readFileString(vitePath);
82 const output = yield* build(`${root}/`);
83 expect(output.serverModules?.[0]?.name).toBe("server/worker.js");
84 expect(output.clientDirectory).toBe(path.join(root, "output/client"));
85 expect(
86 yield* fs.exists(path.join(root, "output/client/index.html")),
87 ).toBe(false);
88 expect(yield* fs.exists(path.join(root, "output/client/.vite"))).toBe(
89 false,
90 );
91 expect(yield* fs.readFileString(configPath)).toBe(config);
92 expect(yield* fs.readFileString(vitePath)).toBe(vite);
93 expect(
94 (yield* fs.readDirectory(root)).filter((entry) =>
95 entry.startsWith(".alchemy-octane-worker-"),
96 ),
97 ).toEqual([]);
98 const modules = output.serverModules
99 ?.map((module) => Buffer.from(module.content).toString())
100 .join("\n");
101 expect(modules).not.toContain("node:fs");
102 expect(modules).not.toContain("node:http");
103 const worker = yield* Effect.tryPromise(
104 () =>
105 import(
106 /* @vite-ignore */ pathToFileURL(
107 path.join(root, "output/server/worker.js"),
108 ).href
109 ),
110 );
111 const fetch = (pathname: string, init?: RequestInit) =>
112 Effect.tryPromise(
113 () =>
114 worker.default.fetch(
115 new Request(`https://octane.example${pathname}`, init),
116 { MESSAGE: "hello" },
117 { waitUntil() {} },
118 ) as Promise<Response>,
119 );
120 const response = yield* fetch("/api/hello");
121 expect(yield* Effect.tryPromise(() => response.json())).toEqual({
122 message: "native-config:hello",
123 hasWaitUntil: true,
124 });
125 for (const route of ["/", "/other"]) {
126 const page = yield* fetch(route);
127 expect(page.status).toBe(200);
128 const html = yield* Effect.tryPromise(() => page.text());
129 expect(html).toContain("ALCHEMY_OCTANE_WORKER");
130 expect(html).toContain("native-plugin-preserved");
131 expect(html).toMatch(/stylesheet[^>]+\.css/);
132 }
133 const rpcId = yield* Effect.sync(() =>
134 createHash("sha256")
135 .update("/src/rpc.tsx#greet")
136 .digest("hex")
137 .slice(0, 8),
138 );
139 const rpc = yield* fetch(`/_$_ripple_rpc_$_/${rpcId}`, {
140 method: "POST",
141 headers: {
142 "Content-Type": "application/json",
143 Origin: "https://octane.example",
144 },
145 body: '[[1],"Worker"]',
146 });
147 expect(rpc.status).toBe(200);
148 expect(yield* Effect.tryPromise(() => rpc.text())).toContain(
149 '"hello Worker"',
150 );
151 expect((yield* fetch("/missing")).status).toBe(404);
152 }),
153 );
154 }, 120_000);
155
156 it("preserves Cloudflare module imports in the Worker bundle", async () => {
157 await run(
158 Effect.gen(function* () {
159 const fs = yield* FileSystem.FileSystem;
160 const path = yield* Path.Path;
161 const root = yield* fixture;
162 const file = path.join(root, "octane.config.ts");
163 const config = yield* fs.readFileString(file);
164 yield* fs.writeFileString(
165 file,
166 config
167 .replace(
168 "handler: (context) => {",
169 'handler: async (context) => { const { env } = await import("cloudflare:workers");',
170 )
171 .replace(
172 "message: `${prefix}:${platform.env.MESSAGE}`",
173 "message: `${prefix}:${env.MESSAGE}`",
174 ),
175 );
176 const output = yield* build(root);
177 expect(
178 output.serverModules
179 ?.map((module) => Buffer.from(module.content).toString())
180 .join("\n"),
181 ).toContain("cloudflare:workers");
182 }),
183 );
184 }, 120_000);
185
186 it("cleans generated files after a server-build failure without rewriting config", async () => {
187 await run(
188 Effect.gen(function* () {
189 const fs = yield* FileSystem.FileSystem;
190 const path = yield* Path.Path;
191 const root = yield* fixture;
192 const file = path.join(root, "vite.config.ts");
193 const original = yield* fs.readFileString(file);
194 const failing = original.replace(
195 'name: "native-app-plugin",',
196 'name: "native-app-plugin", configResolved(config) { if (config.build.ssr) throw new Error("intentional server-build failure"); },',
197 );
198 yield* fs.writeFileString(file, failing);
199 yield* build(root).pipe(Effect.flip);
200 expect(yield* fs.readFileString(file)).toBe(failing);
201 expect(
202 yield* fs.exists(path.join(root, "output/client/index.html")),
203 ).toBe(true);
204 expect(
205 (yield* fs.readDirectory(root)).filter((entry) =>
206 entry.startsWith(".alchemy-octane-worker-"),
207 ),
208 ).toEqual([]);
209 }),
210 );
211 }, 120_000);
212
213 it("rejects incompatible adapter declarations", async () => {
214 await run(
215 Effect.gen(function* () {
216 const fs = yield* FileSystem.FileSystem;
217 const path = yield* Path.Path;
218 const root = yield* fixture;
219 const configPath = path.join(root, "octane.config.ts");
220 const config = yield* fs.readFileString(configPath);
221 yield* fs.writeFileString(
222 configPath,
223 config.replace(
224 "defineConfig({",
225 'defineConfig({ adapter: { name: "vercel" },',
226 ),
227 );
228 const error = yield* buildCloudflare(root).pipe(Effect.flip);
229 expect(error.message).toContain(
230 '"vercel" is incompatible with Cloudflare',
231 );
232 }),
233 );
234 }, 30_000);
235});