| 1 | import * as NodeFs from "node:fs/promises"; |
| 2 | import * as NodeHttp from "node:http"; |
| 3 | import type { AddressInfo } from "node:net"; |
| 4 | import * as NodeOs from "node:os"; |
| 5 | import * as NodePath from "node:path"; |
| 6 | import * as vite from "vite"; |
| 7 | import { |
| 8 | afterAll, |
| 9 | afterEach, |
| 10 | beforeAll, |
| 11 | describe, |
| 12 | expect, |
| 13 | it, |
| 14 | vi, |
| 15 | } from "vitest"; |
| 16 | import type { PreviewWorkerBuild } from "../preview-server.ts"; |
| 17 | import cloudflareVitePlugin from "../plugin.ts"; |
| 18 | |
| 19 | // Stand-in for the workerd preview runtime: a plain HTTP server; every |
| 20 | // proxied request responds with "worker". |
| 21 | let runtimeAddress: string; |
| 22 | let startedBuilds: Array<PreviewWorkerBuild> = []; |
| 23 | let closed = 0; |
| 24 | |
| 25 | vi.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 | |
| 38 | const 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 | |
| 47 | describe("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 | }); |