1import * as NodeHttp from "node:http";
2import path from "node:path";
3import type { AddressInfo } from "node:net";
4import * as vite from "vite";
5import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
6import { WebSocketServer } from "ws";
7import { DistilledDevEnvironment } from "../dev-environment.ts";
8import cloudflareVitePlugin from "../plugin.ts";
9
10// Stand-in for the workerd dev runtime: a plain HTTP server (every proxied
11// request responds with "worker") that also accepts the module-runner
12// WebSocket connections `DistilledDevEnvironment.connect` opens.
13let runtimeAddress: string;
14
15vi.mock("../dev-server.ts", () => ({
16 createDefaultContext: async () => ({}),
17 startServer: async () => ({
18 address: runtimeAddress,
19 proxySharedSecret: "dev-secret",
20 close: async () => {},
21 }),
22}));
23
24const root = path.join(import.meta.dirname, "fixtures");
25
26const listen = (server: NodeHttp.Server): Promise<string> =>
27 new Promise((resolve, reject) => {
28 server.once("error", reject);
29 server.listen(0, "127.0.0.1", () => {
30 const { port } = server.address() as AddressInfo;
31 resolve(`http://127.0.0.1:${port}`);
32 });
33 });
34
35describe("dev plugin", () => {
36 let runtimeServer: NodeHttp.Server;
37 let webSocketServer: WebSocketServer;
38
39 beforeAll(async () => {
40 runtimeServer = NodeHttp.createServer((_req, res) => {
41 res.end("worker");
42 });
43 webSocketServer = new WebSocketServer({ server: runtimeServer });
44 runtimeAddress = await listen(runtimeServer);
45 });
46
47 afterAll(async () => {
48 for (const client of webSocketServer.clients) {
49 client.terminate();
50 }
51 webSocketServer.close();
52 runtimeServer.closeAllConnections();
53 await new Promise((resolve) => runtimeServer.close(resolve));
54 });
55
56 const createDevServer = (
57 plugins: vite.PluginOption,
58 config: vite.UserConfig = {},
59 ) =>
60 vite.createServer({
61 ...config,
62 configFile: false,
63 root,
64 logLevel: "silent",
65 plugins: [plugins],
66 server: { middlewareMode: true, hmr: false },
67 });
68
69 const request = async (
70 server: vite.ViteDevServer,
71 url: string,
72 ): Promise<{ status: number; text: string }> => {
73 const httpServer = NodeHttp.createServer(server.middlewares);
74 const address = await listen(httpServer);
75 try {
76 const response = await fetch(`${address}${url}`);
77 return { status: response.status, text: await response.text() };
78 } finally {
79 httpServer.closeAllConnections();
80 await new Promise((resolve) => httpServer.close(resolve));
81 }
82 };
83
84 const frameworkPlugin = (): vite.Plugin => ({
85 name: "fake-framework",
86 configureServer(server) {
87 return () => {
88 // Mimics a framework's Node request bridge (e.g. Waku's RSC bridge):
89 // a catch-all post middleware registered by a plugin that precedes
90 // ours in the plugin order.
91 server.middlewares.use(function frameworkCatchAll(_req, res) {
92 res.statusCode = 200;
93 res.end("framework");
94 });
95 };
96 },
97 });
98
99 it("degrades to a runnable environment when configureServer is stripped (framework typegen servers)", async () => {
100 // Frameworks strip `configureServer` off the plugin instances when they
101 // create throwaway dev servers (e.g. Astro's type generation during
102 // `build`/`sync`) so no workerd runtime boots mid-build.
103 const plugins = cloudflareVitePlugin({
104 main: "./worker-entry.ts",
105 }) as Array<vite.Plugin>;
106 for (const plugin of plugins) {
107 plugin.configureServer = undefined;
108 }
109 const server = await createDevServer(plugins);
110 try {
111 const environment = server.environments["ssr"];
112 expect(environment).toBeDefined();
113 expect(environment).not.toBeInstanceOf(DistilledDevEnvironment);
114 expect(vite.isRunnableDevEnvironment(environment!)).toBe(true);
115 } finally {
116 await server.close();
117 }
118 });
119
120 it("creates the Distilled environment when configureServer is intact", async () => {
121 const server = await createDevServer(
122 cloudflareVitePlugin({ main: "./worker-entry.ts" }),
123 );
124 try {
125 expect(server.environments["ssr"]).toBeInstanceOf(
126 DistilledDevEnvironment,
127 );
128 } finally {
129 await server.close();
130 }
131 });
132
133 it("accepts multiple aliases for the same Worker entry", async () => {
134 const server = await createDevServer([
135 {
136 name: "framework-entry-aliases",
137 config: () => ({
138 environments: {
139 ssr: {
140 build: {
141 rollupOptions: {
142 input: {
143 worker: "./worker-entry.ts",
144 "entry.worker": path.join(root, "worker-entry.ts"),
145 },
146 },
147 },
148 },
149 },
150 }),
151 },
152 cloudflareVitePlugin({}),
153 ]);
154 try {
155 expect(server.environments["ssr"]).toBeInstanceOf(
156 DistilledDevEnvironment,
157 );
158 expect((await request(server, "/")).text).toBe("worker");
159 } finally {
160 await server.close();
161 }
162 });
163
164 it("rejects distinct Worker entries", async () => {
165 await expect(
166 createDevServer(cloudflareVitePlugin({}), {
167 environments: {
168 ssr: {
169 build: {
170 rollupOptions: {
171 input: {
172 first: "./worker-entry.ts",
173 second: "./other-entry.ts",
174 },
175 },
176 },
177 },
178 },
179 }),
180 ).rejects.toThrow("Expected exactly one entry in the input, got 2 entries");
181 });
182
183 it("registers the proxy middleware after other plugins' post middlewares by default", async () => {
184 const server = await createDevServer([
185 frameworkPlugin(),
186 cloudflareVitePlugin({ main: "./worker-entry.ts" }),
187 ]);
188 try {
189 const response = await request(server, "/");
190 expect(response.text).toBe("framework");
191 } finally {
192 await server.close();
193 }
194 });
195
196 it('registers the proxy middleware ahead of other plugins\' post middlewares with middlewareOrder: "pre"', async () => {
197 const server = await createDevServer([
198 frameworkPlugin(),
199 cloudflareVitePlugin({
200 main: "./worker-entry.ts",
201 dev: { middlewareOrder: "pre" },
202 }),
203 ]);
204 try {
205 // Appended after the framework's plugins, the proxy still sees requests
206 // first, so the framework's Node bridge never fires.
207 const response = await request(server, "/");
208 expect(response.text).toBe("worker");
209 // ...but it must not shadow Vite's own internal middlewares.
210 const viteClient = await request(server, "/@vite/client");
211 expect(viteClient.status).toBe(200);
212 expect(viteClient.text).not.toBe("worker");
213 expect(viteClient.text).not.toBe("framework");
214 } finally {
215 await server.close();
216 }
217 });
218});