1import * as NodeFs from "node:fs/promises";
2import * as NodeHttp from "node:http";
3import type { AddressInfo } from "node:net";
4import * as NodeOs from "node:os";
5import * as NodePath from "node:path";
6import * as vite from "vite";
7import {
8 afterAll,
9 afterEach,
10 beforeAll,
11 describe,
12 expect,
13 it,
14 vi,
15} from "vitest";
16import type { PreviewWorkerBuild } from "../preview-server.ts";
17import cloudflareVitePlugin from "../plugin.ts";
18
19// Stand-in for the workerd preview runtime: a plain HTTP server; every
20// proxied request responds with "worker".
21let runtimeAddress: string;
22let startedBuilds: Array<PreviewWorkerBuild> = [];
23let closed = 0;
24
25vi.mock("../preview-server.ts", () => ({
26 startPreviewServer: async (_options: unknown, build: PreviewWorkerBuild) => {
27 startedBuilds.push(build);
28 return {
29 address: new URL(runtimeAddress),
30 proxySharedSecret: "preview-secret",
31 close: async () => {
32 closed += 1;
33 },
34 };
35 },
36}));
37
38const listen = (server: NodeHttp.Server): Promise<string> =>
39 new Promise((resolve, reject) => {
40 server.once("error", reject);
41 server.listen(0, "127.0.0.1", () => {
42 const { port } = server.address() as AddressInfo;
43 resolve(`http://127.0.0.1:${port}`);
44 });
45 });
46
47describe("preview plugin", () => {
48 let runtimeServer: NodeHttp.Server;
49 let root: string;
50
51 beforeAll(async () => {
52 runtimeServer = NodeHttp.createServer((_req, res) => {
53 res.end("worker");
54 });
55 runtimeAddress = await listen(runtimeServer);
56 // A fake built project: the entry environment's outDir with the entry
57 // chunk, and a client outDir with a static asset.
58 root = await NodeFs.mkdtemp(
59 NodePath.join(NodeOs.tmpdir(), "distilled-preview-"),
60 );
61 await NodeFs.mkdir(NodePath.join(root, "dist", "ssr"), { recursive: true });
62 await NodeFs.mkdir(NodePath.join(root, "dist", "client"), {
63 recursive: true,
64 });
65 await NodeFs.writeFile(
66 NodePath.join(root, "dist", "ssr", "worker-entry.js"),
67 "export default { fetch: () => new Response('built worker') };",
68 );
69 await NodeFs.writeFile(
70 NodePath.join(root, "dist", "client", "index.html"),
71 "<html></html>",
72 );
73 });
74
75 afterAll(async () => {
76 runtimeServer.closeAllConnections();
77 await new Promise((resolve) => runtimeServer.close(resolve));
78 await NodeFs.rm(root, { recursive: true, force: true });
79 });
80
81 afterEach(() => {
82 startedBuilds = [];
83 closed = 0;
84 });
85
86 const createPreviewServer = (plugins: vite.PluginOption) =>
87 vite.preview({
88 configFile: false,
89 root,
90 logLevel: "silent",
91 plugins: [plugins],
92 preview: { port: 0, host: "127.0.0.1" },
93 });
94
95 const serverOrigin = (server: vite.PreviewServer): string => {
96 const { port } = server.httpServer.address() as AddressInfo;
97 return `http://127.0.0.1:${port}`;
98 };
99
100 it("serves the built worker through the runtime proxy", async () => {
101 const server = await createPreviewServer(
102 cloudflareVitePlugin({ main: "./worker-entry.ts" }),
103 );
104 try {
105 const response = await fetch(`${serverOrigin(server)}/anything`);
106 expect(response.status).toBe(200);
107 expect(await response.text()).toBe("worker");
108 } finally {
109 await server.close();
110 }
111 });
112
113 it("resolves the built worker output from the entry environment", async () => {
114 const server = await createPreviewServer(
115 cloudflareVitePlugin({ main: "./worker-entry.ts" }),
116 );
117 try {
118 expect(startedBuilds).toHaveLength(1);
119 const build = startedBuilds[0]!;
120 expect(build.directory).toBe(
121 await NodeFs.realpath(NodePath.join(root, "dist", "ssr")),
122 );
123 expect(build.entryModule).toBe("worker-entry.js");
124 expect(build.assetsDirectory).toBe(
125 await NodeFs.realpath(NodePath.join(root, "dist", "client")),
126 );
127 } finally {
128 await server.close();
129 }
130 });
131
132 it("closes the runtime when the preview server closes", async () => {
133 const server = await createPreviewServer(
134 cloudflareVitePlugin({ main: "./worker-entry.ts" }),
135 );
136 await server.close();
137 expect(closed).toBe(1);
138 });
139
140 it("stays out of the way in SPA mode (no worker entry)", async () => {
141 const server = await createPreviewServer(cloudflareVitePlugin({}));
142 try {
143 expect(startedBuilds).toHaveLength(0);
144 // Vite's own static serving handles the request.
145 const response = await fetch(`${serverOrigin(server)}/index.html`);
146 expect(response.status).toBe(200);
147 expect(await response.text()).toContain("<html>");
148 } finally {
149 await server.close();
150 }
151 });
152
153 it("fails fast when the built entry chunk is missing", async () => {
154 await expect(
155 createPreviewServer(cloudflareVitePlugin({ main: "./missing-entry.ts" })),
156 ).rejects.toThrow(/missing-entry\.js/);
157 });
158});