1import * as Effect from "effect/Effect";
2import * as NodeFs from "node:fs";
3import * as NodeHttp from "node:http";
4import type * as NodeNet from "node:net";
5import { describe, expect, it, vi } from "vitest";
6import {
7 makeCloudflareDevPlatform,
8 resolveClientModulePath,
9 resolveDevPluginPath,
10 type OpenDevProxy,
11} from "../dev/host.ts";
12import { RUNTIME_CONFIG_KEY, type DevConnectInfo } from "../dev/shared.ts";
13import { fromHarnessOptions } from "../index.ts";
14
15// The plugin runs inside nitro's dev SSR worker thread; in the test we stand
16// in for nitro: `defineNitroPlugin` is identity and `useRuntimeConfig`
17// serves whatever the test put in the holder.
18const nitro = vi.hoisted(() => ({ config: {} as Record<string, unknown> }));
19vi.mock("nitropack/runtime", () => ({
20 defineNitroPlugin: (plugin: unknown) => plugin,
21 useRuntimeConfig: () => nitro.config,
22}));
23
24// The protocol constants the fake proxy server speaks (the same public
25// subpath the runtime-free client is built on).
26const loadProtocol = () =>
27 import("@alchemy.run/cloudflare-runtime/core/platform-proxy/PlatformProxyProtocol");
28
29const listen = (server: NodeHttp.Server): Promise<string> =>
30 new Promise((resolve) => {
31 server.listen(0, "127.0.0.1", () => {
32 const address = server.address() as NodeNet.AddressInfo;
33 resolve(`http://127.0.0.1:${address.port}/`);
34 });
35 });
36
37const close = (server: NodeHttp.Server): Promise<void> =>
38 new Promise((resolve) => server.close(() => resolve()));
39
40/** A fake platform-proxy: /env descriptor + a KV-ish /call implementation. */
41const makeFakeProxy = async (token: string) => {
42 const protocol = await loadProtocol();
43 const store = new Map<string, string>();
44 const server = NodeHttp.createServer((request, response) => {
45 if (request.headers[protocol.HEADER_TOKEN] !== token) {
46 response.statusCode = 401;
47 response.end("unauthorized");
48 return;
49 }
50 if (request.url === protocol.PATH_ENV) {
51 response.setHeader("content-type", "application/json");
52 response.end(
53 JSON.stringify({
54 bindings: [
55 {
56 name: "TEXT",
57 kind: "value",
58 value: { $: "string", value: "hello" },
59 },
60 {
61 name: "OVERRIDDEN",
62 kind: "value",
63 value: { $: "string", value: "proxied" },
64 },
65 { name: "KV", kind: "stub" },
66 ],
67 }),
68 );
69 return;
70 }
71 if (request.url === protocol.PATH_CALL) {
72 let body = "";
73 request.on("data", (chunk: Buffer) => (body += chunk.toString()));
74 request.on("end", () => {
75 const call = JSON.parse(body) as {
76 binding: string;
77 chain: Array<{
78 method: string;
79 args: Array<{ $: string; value?: unknown }>;
80 }>;
81 };
82 const [segment] = call.chain;
83 let result: unknown = null;
84 if (segment?.method === "put") {
85 store.set(
86 String(segment.args[0]?.value),
87 String(segment.args[1]?.value),
88 );
89 result = undefined;
90 } else if (segment?.method === "get") {
91 result = store.get(String(segment.args[0]?.value)) ?? null;
92 }
93 response.setHeader(protocol.HEADER_RESULT, "json");
94 response.setHeader("content-type", "application/json");
95 response.end(JSON.stringify({ value: protocol.encodeValue(result) }));
96 });
97 return;
98 }
99 response.statusCode = 404;
100 response.end();
101 });
102 const url = await listen(server);
103 return { url, close: () => close(server) };
104};
105
106/** Load the shipped dev plugin fresh and hand it a captured nitroApp. */
107const setupPlugin = async (config: Record<string, unknown>) => {
108 nitro.config = config;
109 vi.resetModules();
110 const { default: plugin } = (await import("../dev/plugin.ts")) as unknown as {
111 default: (app: unknown) => void;
112 };
113 const hooks = new Map<string, (event: unknown) => Promise<void>>();
114 plugin({
115 hooks: {
116 hook: (name: string, handler: (event: unknown) => Promise<void>) => {
117 hooks.set(name, handler);
118 },
119 },
120 });
121 return hooks;
122};
123
124const makeEvent = () => ({
125 context: {} as Record<string, unknown>,
126 node: {
127 req: {
128 url: "/api/test?x=1",
129 method: "GET",
130 headers: { host: "localhost:3000", accept: "application/json" },
131 },
132 },
133});
134
135describe("module resolution", () => {
136 it("resolves the shipped dev plugin next to the host module", () => {
137 const path = resolveDevPluginPath();
138 expect(path).toMatch(/dev[/\\]plugin\.(ts|js)$/);
139 expect(NodeFs.existsSync(path)).toBe(true);
140 });
141
142 it("resolves the runtime-free client module from this package's dependencies", () => {
143 const path = resolveClientModulePath();
144 expect(path).toMatch(/platform-proxy[/\\]connect\./);
145 expect(NodeFs.existsSync(path)).toBe(true);
146 });
147});
148
149describe("makeCloudflareDevPlatform", () => {
150 it("opens the proxy, injects the plugin + connect info, and disposes with the scope", async () => {
151 let disposed = 0;
152 let openedWith: Parameters<OpenDevProxy>[0] | undefined;
153 const openProxy: OpenDevProxy = async (options) => {
154 openedWith = options;
155 return {
156 url: "http://127.0.0.1:4321/",
157 token: "test-token",
158 dispose: async () => {
159 disposed += 1;
160 },
161 };
162 };
163 const bindings = [{ kind: "text" }];
164 const platform = await Effect.runPromise(
165 Effect.scoped(
166 Effect.gen(function* () {
167 const platform = yield* makeCloudflareDevPlatform({
168 openProxy,
169 compatibilityDate: "2026-03-10",
170 compatibilityFlags: ["nodejs_compat"],
171 })({
172 root: "/tmp/project",
173 env: { LITERAL: "value", SKIPPED: { not: "a string" } },
174 bindings,
175 });
176 // Still alive inside the scope.
177 expect(disposed).toBe(0);
178 return platform;
179 }),
180 ),
181 );
182 expect(disposed).toBe(1);
183 expect(openedWith?.name).toBe("nuxt-dev-platform-proxy");
184 expect(openedWith?.compatibilityDate).toBe("2026-03-10");
185 expect(openedWith?.bindings).toBe(bindings);
186 expect(platform.nitroPlugins).toEqual([resolveDevPluginPath()]);
187 const info = (platform.runtimeConfig as Record<string, DevConnectInfo>)[
188 RUNTIME_CONFIG_KEY
189 ];
190 expect(info).toBeDefined();
191 expect(info?.url).toBe("http://127.0.0.1:4321/");
192 expect(info?.token).toBe("test-token");
193 expect(info?.clientModule).toBe(resolveClientModulePath());
194 // Literal env: strings only.
195 expect(info?.env).toEqual({ LITERAL: "value" });
196 });
197
198 it("maps the open failure onto DeployTargetError", async () => {
199 const openProxy: OpenDevProxy = async () => {
200 throw new Error("workerd exploded");
201 };
202 const result = await Effect.runPromise(
203 Effect.result(
204 Effect.scoped(
205 makeCloudflareDevPlatform({ openProxy })({ root: "/tmp/project" }),
206 ),
207 ),
208 );
209 expect(result._tag).toBe("Failure");
210 if (result._tag === "Failure") {
211 const failure = result.failure as { _tag: string; message: string };
212 expect(failure._tag).toBe("DeployTargetError");
213 expect(failure.message).toContain(
214 "Failed to open the dev platform proxy",
215 );
216 }
217 });
218});
219
220describe("dev plugin (worker half)", () => {
221 it("serves the cloudflare dev context through the runtime-free client", async () => {
222 const token = "plugin-token";
223 const proxy = await makeFakeProxy(token);
224 try {
225 const info: DevConnectInfo = {
226 url: proxy.url,
227 token,
228 clientModule: resolveClientModulePath(),
229 env: { OVERRIDDEN: "literal-wins" },
230 };
231 const hooks = await setupPlugin({ [RUNTIME_CONFIG_KEY]: info });
232 const onRequest = hooks.get("request");
233 expect(onRequest).toBeDefined();
234
235 const event = makeEvent();
236 await onRequest!(event);
237
238 const cf = event.context.cf as Record<string, unknown>;
239 expect(cf.country).toBe("US");
240 // waitUntil is detachable (h3 consumers call it bare).
241 const waitUntil = event.context.waitUntil as (
242 promise: Promise<unknown>,
243 ) => void;
244 waitUntil(Promise.resolve());
245
246 const cloudflare = event.context.cloudflare as {
247 request: Request | undefined;
248 env: Record<string, unknown>;
249 context: { waitUntil: (promise: Promise<unknown>) => void };
250 };
251 expect(cloudflare.request?.url).toBe(
252 "http://localhost:3000/api/test?x=1",
253 );
254 expect((cloudflare.request as { cf?: unknown } | undefined)?.cf).toBe(cf);
255 expect(cloudflare.env.TEXT).toBe("hello");
256 // Literal env overlay: a same-named literal wins over the proxied value.
257 expect(cloudflare.env.OVERRIDDEN).toBe("literal-wins");
258 const kv = cloudflare.env.KV as {
259 put: (key: string, value: string) => Promise<void>;
260 get: (key: string) => Promise<string | null>;
261 };
262 await kv.put("k", "v");
263 expect(await kv.get("k")).toBe("v");
264 expect(await kv.get("missing")).toBeNull();
265 } finally {
266 await proxy.close();
267 }
268 });
269
270 it("stays inert without connect info", async () => {
271 const hooks = await setupPlugin({});
272 expect(hooks.size).toBe(0);
273 });
274
275 it("fails descriptively when the proxy rejects the token, then retries", async () => {
276 const proxy = await makeFakeProxy("right-token");
277 try {
278 const info: DevConnectInfo = {
279 url: proxy.url,
280 token: "wrong-token",
281 clientModule: resolveClientModulePath(),
282 };
283 const hooks = await setupPlugin({ [RUNTIME_CONFIG_KEY]: info });
284 const onRequest = hooks.get("request");
285 await expect(onRequest!(makeEvent())).rejects.toThrow(
286 /\/env request failed with status 401/,
287 );
288 // The failed connection was not cached: the next request retries.
289 await expect(onRequest!(makeEvent())).rejects.toThrow(
290 /\/env request failed with status 401/,
291 );
292 } finally {
293 await proxy.close();
294 }
295 });
296});
297
298describe("fromHarnessOptions (dev)", () => {
299 it("passes the harness worker's binding hooks through to dev.bindings", () => {
300 const bindings = [{ hook: true }];
301 const options = fromHarnessOptions({
302 target: { cloudflare: { worker: { worker: { bindings } } } },
303 });
304 expect(options.dev?.bindings).toBe(bindings);
305 });
306});