From b62e0919fcfb37e4720aadb9ade11e16d370df91 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 19 Oct 2026 15:26:28 +0100 Subject: [PATCH 1/5] [rsbuild-plugin] Prototype an Rsbuild plugin that runs Workers in workerd Adds a private `@cloudflare/rsbuild-plugin` prototype that builds the Worker from `cloudflare.config.ts` with rspack, runs it in Miniflare during `rsbuild dev` and `rsbuild preview`, and writes the Build Output Specification on `rsbuild build`. Logic the Vite plugin already had is moved into a private `@cloudflare/workers-dev-shared` package so both plugins use it: config loading, local bindings, Node.js compat, Miniflare exports, persistence and Build Output manifests. --- packages/rsbuild-plugin-cloudflare/.gitignore | 1 + .../rsbuild-plugin-cloudflare/package.json | 70 +++ .../fixtures/spa-with-api/.gitignore | 3 + .../fixtures/spa-with-api/api/index.ts | 20 + .../spa-with-api/cloudflare.config.ts | 12 + .../fixtures/spa-with-api/rsbuild.config.ts | 7 + .../fixtures/spa-with-api/src/index.ts | 5 + .../fixtures/spa-with-api/tsconfig.json | 7 + .../src/__tests__/fixtures/worker/.gitignore | 3 + .../fixtures/worker/cloudflare.config.ts | 16 + .../fixtures/worker/rsbuild.config.ts | 6 + .../__tests__/fixtures/worker/src/index.ts | 36 ++ .../src/__tests__/fixtures/worker/src/lazy.ts | 1 + .../__tests__/fixtures/worker/tsconfig.json | 7 + .../src/__tests__/helpers.ts | 98 ++++ .../src/__tests__/spa-with-api.spec.ts | 97 ++++ .../src/__tests__/worker.spec.ts | 104 ++++ .../rsbuild-plugin-cloudflare/src/index.ts | 398 +++++++++++++++ .../src/worker-environment.ts | 152 ++++++ .../rsbuild-plugin-cloudflare/tsconfig.json | 8 + .../tsdown.config.ts | 11 + packages/rsbuild-plugin-cloudflare/turbo.json | 12 + .../vitest.config.ts | 19 + packages/vite-plugin-cloudflare/package.json | 1 + .../src/build-output-preview.ts | 8 +- .../vite-plugin-cloudflare/src/context.ts | 2 +- .../src/miniflare-options.ts | 148 +----- .../src/nodejs-compat.ts | 215 +------- .../src/plugin-config.ts | 163 +----- .../src/plugins/virtual-modules.ts | 2 +- packages/workers-dev-shared/package.json | 51 ++ .../__tests__/get-miniflare-manifest.spec.ts | 4 +- .../src/__tests__/local-env.spec.ts | 0 .../__tests__/miniflare-containers.spec.ts | 2 +- .../src/cloudflare-config.ts | 153 ++++++ packages/workers-dev-shared/src/index.ts | 20 + .../src/local-env.ts | 40 ++ .../src/miniflare-exports.ts | 43 ++ .../src/miniflare-manifest.ts | 50 ++ .../workers-dev-shared/src/nodejs-compat.ts | 220 ++++++++ packages/workers-dev-shared/src/persist.ts | 21 + packages/workers-dev-shared/tsconfig.json | 7 + packages/workers-dev-shared/tsdown.config.ts | 14 + packages/workers-dev-shared/turbo.json | 9 + packages/workers-dev-shared/vitest.config.ts | 11 + pnpm-lock.yaml | 473 ++++++++++++++---- 46 files changed, 2142 insertions(+), 608 deletions(-) create mode 100644 packages/rsbuild-plugin-cloudflare/.gitignore create mode 100644 packages/rsbuild-plugin-cloudflare/package.json create mode 100644 packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/spa-with-api/.gitignore create mode 100644 packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/spa-with-api/api/index.ts create mode 100644 packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/spa-with-api/cloudflare.config.ts create mode 100644 packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/spa-with-api/rsbuild.config.ts create mode 100644 packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/spa-with-api/src/index.ts create mode 100644 packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/spa-with-api/tsconfig.json create mode 100644 packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/.gitignore create mode 100644 packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/cloudflare.config.ts create mode 100644 packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/rsbuild.config.ts create mode 100644 packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/src/index.ts create mode 100644 packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/src/lazy.ts create mode 100644 packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/tsconfig.json create mode 100644 packages/rsbuild-plugin-cloudflare/src/__tests__/helpers.ts create mode 100644 packages/rsbuild-plugin-cloudflare/src/__tests__/spa-with-api.spec.ts create mode 100644 packages/rsbuild-plugin-cloudflare/src/__tests__/worker.spec.ts create mode 100644 packages/rsbuild-plugin-cloudflare/src/index.ts create mode 100644 packages/rsbuild-plugin-cloudflare/src/worker-environment.ts create mode 100644 packages/rsbuild-plugin-cloudflare/tsconfig.json create mode 100644 packages/rsbuild-plugin-cloudflare/tsdown.config.ts create mode 100644 packages/rsbuild-plugin-cloudflare/turbo.json create mode 100644 packages/rsbuild-plugin-cloudflare/vitest.config.ts create mode 100644 packages/workers-dev-shared/package.json rename packages/{vite-plugin-cloudflare => workers-dev-shared}/src/__tests__/get-miniflare-manifest.spec.ts (92%) rename packages/{vite-plugin-cloudflare => workers-dev-shared}/src/__tests__/local-env.spec.ts (100%) rename packages/{vite-plugin-cloudflare => workers-dev-shared}/src/__tests__/miniflare-containers.spec.ts (95%) create mode 100644 packages/workers-dev-shared/src/cloudflare-config.ts create mode 100644 packages/workers-dev-shared/src/index.ts rename packages/{vite-plugin-cloudflare => workers-dev-shared}/src/local-env.ts (61%) create mode 100644 packages/workers-dev-shared/src/miniflare-exports.ts create mode 100644 packages/workers-dev-shared/src/miniflare-manifest.ts create mode 100644 packages/workers-dev-shared/src/nodejs-compat.ts create mode 100644 packages/workers-dev-shared/src/persist.ts create mode 100644 packages/workers-dev-shared/tsconfig.json create mode 100644 packages/workers-dev-shared/tsdown.config.ts create mode 100644 packages/workers-dev-shared/turbo.json create mode 100644 packages/workers-dev-shared/vitest.config.ts diff --git a/packages/rsbuild-plugin-cloudflare/.gitignore b/packages/rsbuild-plugin-cloudflare/.gitignore new file mode 100644 index 00000000000..d36977dc476 --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/.gitignore @@ -0,0 +1 @@ +.tmp diff --git a/packages/rsbuild-plugin-cloudflare/package.json b/packages/rsbuild-plugin-cloudflare/package.json new file mode 100644 index 00000000000..58f69121c5a --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/package.json @@ -0,0 +1,70 @@ +{ + "name": "@cloudflare/rsbuild-plugin", + "version": "0.0.0", + "private": true, + "description": "Cloudflare plugin for Rsbuild (experimental prototype)", + "keywords": [ + "cloudflare", + "cloudflare-workers", + "rsbuild", + "rsbuild-plugin", + "rspack", + "workers" + ], + "homepage": "https://github.com/cloudflare/workers-sdk/tree/main/packages/rsbuild-plugin-cloudflare#readme", + "bugs": { + "url": "https://github.com/cloudflare/workers-sdk/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/cloudflare/workers-sdk.git", + "directory": "packages/rsbuild-plugin-cloudflare" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "scripts": { + "build": "tsdown", + "check:type": "tsc", + "dev": "tsdown --watch", + "test:ci": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@cloudflare/config": "workspace:*", + "@cloudflare/runtime-types": "workspace:*", + "@cloudflare/unenv-preset": "workspace:*", + "miniflare": "workspace:*", + "unenv": "2.0.0-rc.24" + }, + "devDependencies": { + "@cloudflare/build-output-utils": "workspace:*", + "@cloudflare/workers-dev-shared": "workspace:*", + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", + "@cloudflare/workers-utils": "workspace:*", + "@remix-run/node-fetch-server": "^0.8.0", + "@rsbuild/core": "^2.2.13", + "@types/node": "catalog:default", + "cf": "catalog:default", + "get-port": "^7.1.0", + "mlly": "^1.7.4", + "tsdown": "0.16.3", + "typescript": "catalog:default", + "vitest": "catalog:default" + }, + "peerDependencies": { + "@rsbuild/core": "^2.0.0" + } +} diff --git a/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/spa-with-api/.gitignore b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/spa-with-api/.gitignore new file mode 100644 index 00000000000..50bdc84b940 --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/spa-with-api/.gitignore @@ -0,0 +1,3 @@ +.cloudflare +dist +.wrangler diff --git a/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/spa-with-api/api/index.ts b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/spa-with-api/api/index.ts new file mode 100644 index 00000000000..a78d491e289 --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/spa-with-api/api/index.ts @@ -0,0 +1,20 @@ +interface Env { + ASSETS: Fetcher; +} + +export default { + async fetch(request, env) { + const { pathname } = new URL(request.url); + + if (pathname === "/api/hello") { + return Response.json({ name: "Cloudflare" }); + } + + const response = await env.ASSETS.fetch( + new URL("/index.html", request.url) + ); + return new Response(response.body, { + headers: { "content-type": "text/html", "x-from-worker": "true" }, + }); + }, +} satisfies ExportedHandler; diff --git a/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/spa-with-api/cloudflare.config.ts b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/spa-with-api/cloudflare.config.ts new file mode 100644 index 00000000000..b741df767f9 --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/spa-with-api/cloudflare.config.ts @@ -0,0 +1,12 @@ +import { bindings, defineConfig } from "cf/config"; +import * as entrypoint from "./api/index.ts" with { type: "cf-worker" }; + +export default defineConfig({ + worker: { + name: "rsbuild-spa-with-api", + entrypoint, + compatibilityDate: "2025-06-01", + assets: { notFoundHandling: "single-page-application" }, + env: { ASSETS: bindings.assets() }, + }, +}); diff --git a/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/spa-with-api/rsbuild.config.ts b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/spa-with-api/rsbuild.config.ts new file mode 100644 index 00000000000..564d828ae16 --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/spa-with-api/rsbuild.config.ts @@ -0,0 +1,7 @@ +import { pluginCloudflare } from "@cloudflare/rsbuild-plugin"; +import { defineConfig } from "@rsbuild/core"; + +export default defineConfig({ + source: { entry: { index: "./src/index.ts" } }, + plugins: [pluginCloudflare({ types: { includeRuntime: false } })], +}); diff --git a/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/spa-with-api/src/index.ts b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/spa-with-api/src/index.ts new file mode 100644 index 00000000000..b253128f12c --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/spa-with-api/src/index.ts @@ -0,0 +1,5 @@ +void fetch("/api/hello") + .then((response) => response.json() as Promise<{ name: string }>) + .then(({ name }) => { + document.body.textContent = `Hello, ${name}!`; + }); diff --git a/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/spa-with-api/tsconfig.json b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/spa-with-api/tsconfig.json new file mode 100644 index 00000000000..c80bf12891a --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/spa-with-api/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/worker.json", + "compilerOptions": { + "types": ["@cloudflare/workers-types"] + }, + "include": ["api", "src", "cloudflare.config.ts"] +} diff --git a/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/.gitignore b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/.gitignore new file mode 100644 index 00000000000..50bdc84b940 --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/.gitignore @@ -0,0 +1,3 @@ +.cloudflare +dist +.wrangler diff --git a/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/cloudflare.config.ts b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/cloudflare.config.ts new file mode 100644 index 00000000000..111d9824888 --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/cloudflare.config.ts @@ -0,0 +1,16 @@ +import { bindings, defineConfig } from "cf/config"; +import * as entrypoint from "./src/index.ts" with { type: "cf-worker" }; + +export default defineConfig({ + worker: { + name: "rsbuild-worker", + entrypoint, + compatibilityDate: "2025-06-01", + compatibilityFlags: ["nodejs_compat"], + env: { + KV: bindings.kv(), + GREETING: bindings.text("Hello"), + SECRET: bindings.secret(), + }, + }, +}); diff --git a/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/rsbuild.config.ts b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/rsbuild.config.ts new file mode 100644 index 00000000000..1ec4c6768b4 --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/rsbuild.config.ts @@ -0,0 +1,6 @@ +import { pluginCloudflare } from "@cloudflare/rsbuild-plugin"; +import { defineConfig } from "@rsbuild/core"; + +export default defineConfig({ + plugins: [pluginCloudflare({ types: { includeRuntime: false } })], +}); diff --git a/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/src/index.ts b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/src/index.ts new file mode 100644 index 00000000000..30a79beab20 --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/src/index.ts @@ -0,0 +1,36 @@ +import { Buffer } from "node:buffer"; +import { env } from "cloudflare:workers"; + +interface Env { + KV: KVNamespace; + GREETING: string; + SECRET: string; +} + +export default { + async fetch(request, workerEnv) { + const { pathname } = new URL(request.url); + + switch (pathname) { + case "/kv": { + await workerEnv.KV.put("key", "value"); + return new Response(await workerEnv.KV.get("key")); + } + case "/node": { + return new Response(Buffer.from("aGVsbG8=", "base64").toString()); + } + case "/process": { + return new Response(typeof process.env); + } + case "/dynamic-import": { + const { message } = await import("./lazy"); + return new Response(message); + } + case "/secret": { + return new Response(workerEnv.SECRET); + } + } + + return new Response(`${(env as Env).GREETING} from rsbuild + workerd`); + }, +} satisfies ExportedHandler; diff --git a/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/src/lazy.ts b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/src/lazy.ts new file mode 100644 index 00000000000..e6c8a15e5db --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/src/lazy.ts @@ -0,0 +1 @@ +export const message = "lazy chunk"; diff --git a/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/tsconfig.json b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/tsconfig.json new file mode 100644 index 00000000000..1393d7d3d90 --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/worker.json", + "compilerOptions": { + "types": ["@cloudflare/workers-types", "@types/node"] + }, + "include": ["src", "cloudflare.config.ts"] +} diff --git a/packages/rsbuild-plugin-cloudflare/src/__tests__/helpers.ts b/packages/rsbuild-plugin-cloudflare/src/__tests__/helpers.ts new file mode 100644 index 00000000000..6ae0cb8bdac --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/src/__tests__/helpers.ts @@ -0,0 +1,98 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { removeDirSync } from "@cloudflare/workers-utils"; +import { createRsbuild } from "@rsbuild/core"; +import getPort from "get-port"; +import { onTestFinished } from "vitest"; +import { pluginCloudflare } from "../index"; +import type { RsbuildConfig } from "@rsbuild/core"; + +/** + * Copies a fixture so that tests can modify its files. The copy stays inside + * this package so that the fixture's imports (such as `cf/config`) resolve. + * + * @param name The fixture directory name. + * @returns The path of the copy, which is removed when the test finishes. + */ +export function copyFixture(name: string): string { + const tmpRoot = path.join(import.meta.dirname, "..", "..", ".tmp"); + fs.mkdirSync(tmpRoot, { recursive: true }); + const root = fs.mkdtempSync(path.join(tmpRoot, `${name}-`)); + fs.cpSync(path.join(import.meta.dirname, "fixtures", name), root, { + recursive: true, + filter: (source) => !/[/\\](\.cloudflare|dist)$/.test(source), + }); + onTestFinished(() => removeDirSync(root)); + return root; +} + +function createFixtureRsbuild(root: string, config: RsbuildConfig) { + return createRsbuild({ + cwd: root, + config: { + ...config, + plugins: [ + pluginCloudflare({ persistState: false, types: { generate: false } }), + ], + logLevel: "warn", + }, + }); +} + +/** + * Starts an Rsbuild dev server for a fixture copy with the Cloudflare plugin. + * + * @param root The fixture root. + * @param config Additional Rsbuild config from the fixture's `rsbuild.config.ts`. + * @returns The dev server's URL, closed when the test finishes. + */ +export async function startDevServer( + root: string, + config: RsbuildConfig = {} +): Promise { + const rsbuild = await createFixtureRsbuild(root, { + ...config, + server: { port: await getPort(), strictPort: true }, + }); + const { server, urls } = await rsbuild.startDevServer(); + onTestFinished(() => server.close()); + const [url] = urls; + if (url === undefined) { + throw new Error("The dev server didn't report a URL."); + } + return url; +} + +/** + * Runs a production build for a fixture copy with the Cloudflare plugin. + * + * @param root The fixture root. + * @param config Additional Rsbuild config from the fixture's `rsbuild.config.ts`. + */ +export async function build( + root: string, + config: RsbuildConfig = {} +): Promise { + const rsbuild = await createFixtureRsbuild(root, config); + const { close } = await rsbuild.build(); + await close(); +} + +/** + * Starts an Rsbuild preview server for a fixture copy that has been built. + * + * @param root The fixture root. + * @returns The preview server's URL, closed when the test finishes. + */ +export async function startPreviewServer(root: string): Promise { + const rsbuild = await createFixtureRsbuild(root, { + server: { port: await getPort(), strictPort: true }, + }); + const { server, urls } = await rsbuild.preview(); + onTestFinished(() => server.close()); + const [url] = urls; + if (url === undefined) { + throw new Error("The preview server didn't report a URL."); + } + return url; +} diff --git a/packages/rsbuild-plugin-cloudflare/src/__tests__/spa-with-api.spec.ts b/packages/rsbuild-plugin-cloudflare/src/__tests__/spa-with-api.spec.ts new file mode 100644 index 00000000000..cd65dbc4911 --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/src/__tests__/spa-with-api.spec.ts @@ -0,0 +1,97 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { describe, test } from "vitest"; +import { + build, + copyFixture, + startDevServer, + startPreviewServer, +} from "./helpers"; +import type { RsbuildConfig } from "@rsbuild/core"; + +// Mirrors the fixture's `rsbuild.config.ts`. +const config: RsbuildConfig = { + source: { entry: { index: "./src/index.ts" } }, +}; + +describe("spa-with-api", () => { + test("serves client assets, the API, and the SPA fallback during dev", async ({ + expect, + }) => { + const url = await startDevServer(copyFixture("spa-with-api"), config); + + const index = await fetch(url); + expect(index.headers.get("x-from-worker")).toBeNull(); + expect(await index.text()).toContain('src="/static/js/index.js"'); + + const script = await fetch(new URL("/static/js/index.js", url)); + expect(script.headers.get("content-type")).toContain("javascript"); + expect(await script.text()).toContain("/api/hello"); + + const api = await fetch(new URL("/api/hello", url)); + expect(await api.json()).toEqual({ name: "Cloudflare" }); + + // Unmatched routes reach the Worker, which serves `index.html` through + // its assets binding. + const fallback = await fetch(new URL("/some/route", url)); + expect(fallback.headers.get("x-from-worker")).toBe("true"); + expect(await fallback.text()).toContain('src="/static/js/index.js"'); + }); + + test("writes the client to the Worker's assets directory", async ({ + expect, + }) => { + const root = copyFixture("spa-with-api"); + await build(root, config); + + const workerDir = path.join( + root, + ".cloudflare", + "output", + "v0", + "workers", + "default" + ); + + expect(fs.existsSync(path.join(workerDir, "assets", "index.html"))).toBe( + true + ); + expect(fs.existsSync(path.join(workerDir, "bundle", "index.js"))).toBe( + true + ); + expect(fs.existsSync(path.join(root, "dist"))).toBe(false); + expect( + JSON.parse( + fs.readFileSync(path.join(workerDir, "worker.config.json"), "utf8") + ) + ).toMatchObject({ + assets: { notFoundHandling: "single-page-application" }, + env: { ASSETS: { type: "assets" } }, + }); + }); + + test("previews the Build Output with assets served by workerd", async ({ + expect, + }) => { + const root = copyFixture("spa-with-api"); + await build(root, config); + const url = await startPreviewServer(root); + + const index = await fetch(url); + expect(index.headers.get("x-from-worker")).toBeNull(); + const html = await index.text(); + const scriptPath = html.match(/src="(\/static\/js\/index[^"]*\.js)"/)?.[1]; + expect(scriptPath).toBeDefined(); + + const script = await fetch(new URL(scriptPath ?? "", url)); + expect(script.headers.get("x-from-worker")).toBeNull(); + expect(await script.text()).toContain("/api/hello"); + + const api = await fetch(new URL("/api/hello", url)); + expect(await api.json()).toEqual({ name: "Cloudflare" }); + + const fallback = await fetch(new URL("/some/route", url)); + expect(fallback.headers.get("x-from-worker")).toBe("true"); + expect(await fallback.text()).toBe(html); + }); +}); diff --git a/packages/rsbuild-plugin-cloudflare/src/__tests__/worker.spec.ts b/packages/rsbuild-plugin-cloudflare/src/__tests__/worker.spec.ts new file mode 100644 index 00000000000..d1436ed3ff6 --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/src/__tests__/worker.spec.ts @@ -0,0 +1,104 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { readBuildOutput } from "@cloudflare/build-output-utils"; +import { describe, test, vi } from "vitest"; +import { + build, + copyFixture, + startDevServer, + startPreviewServer, +} from "./helpers"; + +describe("worker", () => { + test("runs the Worker in workerd during dev", async ({ expect }) => { + const url = await startDevServer(copyFixture("worker")); + + async function get(pathname: string): Promise { + return (await fetch(new URL(pathname, url))).text(); + } + + expect(await get("/")).toBe("Hello from rsbuild + workerd"); + expect(await get("/kv")).toBe("value"); + expect(await get("/node")).toBe("hello"); + expect(await get("/process")).toBe("object"); + expect(await get("/dynamic-import")).toBe("lazy chunk"); + expect(await get("/secret")).toBe("from-dev-vars"); + }); + + test("reloads the Worker when its source changes", async ({ expect }) => { + const root = copyFixture("worker"); + const url = await startDevServer(root); + const lazyPath = path.join(root, "src", "lazy.ts"); + const dynamicImportUrl = new URL("/dynamic-import", url); + + expect(await (await fetch(dynamicImportUrl)).text()).toBe("lazy chunk"); + + fs.writeFileSync( + lazyPath, + fs.readFileSync(lazyPath, "utf8").replace("lazy chunk", "updated chunk") + ); + + await vi.waitFor( + async () => { + expect(await (await fetch(dynamicImportUrl)).text()).toBe( + "updated chunk" + ); + }, + { timeout: 10_000, interval: 100 } + ); + }); + + test("writes the Build Output Specification", async ({ expect }) => { + const root = copyFixture("worker"); + const outputDir = path.join(root, ".cloudflare", "output", "v0"); + const workerDir = path.join(outputDir, "workers", "default"); + // Rsbuild copies `public/` into the Worker's output directory too. + fs.mkdirSync(path.join(root, "public")); + fs.writeFileSync(path.join(root, "public", "script.js"), "console.log();"); + // Output from a previous build. + fs.mkdirSync(path.join(outputDir, "workers", "stale"), { recursive: true }); + fs.writeFileSync( + path.join(outputDir, "workers", "stale", "worker.config.json"), + "{}" + ); + + await build(root); + + const { rootConfig, workers } = await readBuildOutput(root); + expect(rootConfig.buildContext).toMatchObject({ mode: "production" }); + expect(Object.keys(workers)).toEqual(["default"]); + expect(workers.default?.config).toMatchObject({ + name: "rsbuild-worker", + compatibilityFlags: ["nodejs_compat"], + manifest: { + mainModule: "index.js", + modules: { + "index.js": { type: "esm" }, + "index.js.map": { type: "sourcemap" }, + }, + }, + }); + expect( + Object.keys(workers.default?.config.manifest?.modules ?? {}) + ).toHaveLength(2); + expect( + fs.readFileSync(path.join(workerDir, "bundle", "index.js"), "utf8") + ).toContain("from rsbuild + workerd"); + }); + + test("previews the Build Output in workerd", async ({ expect }) => { + const root = copyFixture("worker"); + await build(root); + const url = await startPreviewServer(root); + + async function get(pathname: string): Promise { + return (await fetch(new URL(pathname, url))).text(); + } + + expect(await get("/")).toBe("Hello from rsbuild + workerd"); + expect(await get("/kv")).toBe("value"); + expect(await get("/node")).toBe("hello"); + expect(await get("/dynamic-import")).toBe("lazy chunk"); + expect(await get("/secret")).toBe("from-dev-vars"); + }); +}); diff --git a/packages/rsbuild-plugin-cloudflare/src/index.ts b/packages/rsbuild-plugin-cloudflare/src/index.ts new file mode 100644 index 00000000000..9a0cc03818a --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/src/index.ts @@ -0,0 +1,398 @@ +import * as path from "node:path"; +import { + BUILD_OUTPUT_ROOT, + cleanBuildOutputDir, + DEFAULT_WORKER_DIRECTORY_NAME, + getWorkerAssetsDir, + getWorkerBundleDir, + readBuildOutput, + writeRootConfig, + writeWorkerConfig, +} from "@cloudflare/build-output-utils"; +import { + createMiniflareExports, + getMiniflareManifest, + getPersistenceRoot, + loadCloudflareConfig, + resolveLocalBindingsByWorkerName, + warnForMissingSecrets, +} from "@cloudflare/workers-dev-shared"; +import { loadDevVars, loadEnv } from "@cloudflare/workers-utils/local-env"; +import { createRequest, sendResponse } from "@remix-run/node-fetch-server"; +import { logger } from "@rsbuild/core"; +import { + getDefaultDevRegistryPath, + Log, + Miniflare, + Request as MiniflareRequest, +} from "miniflare"; +import { + getManifestFromCompilation, + getWorkerEnvironmentConfig, + removeUnemittedFiles, + WORKER_ENTRY_NAME, +} from "./worker-environment"; +import type { + ParsedInputWorkerConfig, + ParsedOutputWorkerConfig, +} from "@cloudflare/config"; +import type { PersistState } from "@cloudflare/workers-dev-shared"; +import type { + RequestHandler, + RsbuildDevServer, + RsbuildPlugin, +} from "@rsbuild/core"; +import type { MiniflareOptions, WorkerOptions } from "miniflare"; +import type { IncomingMessage } from "node:http"; + +export interface PluginCloudflareOptions { + /** Whether to persist local binding state between dev sessions. Defaults to `true`. */ + persistState?: PersistState; + /** Options for generating `.cloudflare/types/index.d.ts`. */ + types?: { generate?: boolean; includeRuntime?: boolean }; +} + +/** The Rsbuild environment that builds the Worker. */ +const ENVIRONMENT_NAME = "cloudflare"; + +/** Marks requests that the Worker's assets binding forwarded to Rsbuild. */ +const kAssetRequest = Symbol("kAssetRequest"); + +type MiniflareWorkerConfig = WorkerOptions["config"]; +type MiniflareManifest = NonNullable; +type LoadedConfig = Awaited>; + +/** + * Rsbuild plugin that builds the Worker defined in `cloudflare.config.ts` with + * rspack and runs it in workerd (via Miniflare) during `rsbuild dev`. + * `rsbuild build` writes the Build Output Specification to `.cloudflare/output`, + * which `rsbuild preview` runs in workerd. + * + * @param options Plugin options. + * @returns An Rsbuild plugin. + */ +export function pluginCloudflare( + options: PluginCloudflareOptions = {} +): RsbuildPlugin { + return { + name: "rsbuild:cloudflare", + setup(api) { + const { action, rootPath: root } = api.context; + const isPreview = process.env.CLOUDFLARE_PREVIEW_BUILD === "true"; + let mode = "development"; + let loadedConfig: LoadedConfig; + let devServer: RsbuildDevServer | undefined; + let miniflare: Miniflare | undefined; + let resolveMiniflare: (miniflare: Miniflare) => void = () => {}; + const miniflareReady = new Promise((resolve) => { + resolveMiniflare = resolve; + }); + + function getWorkerConfig(): ParsedInputWorkerConfig & { + entrypoint: string; + } { + const worker = loadedConfig?.parsedConfig.worker; + if (worker?.entrypoint === undefined) { + throw new Error( + "The Cloudflare Rsbuild plugin requires a `cloudflare.config.ts` that defines `worker.entrypoint`." + ); + } + return { + ...worker, + entrypoint: path.resolve(root, worker.entrypoint), + }; + } + + api.modifyRsbuildConfig(async (userConfig, { mergeRsbuildConfig }) => { + if (action === "preview") { + // Preview runs the Build Output rather than the project's sources, + // and Rsbuild checks that this directory exists before starting. + return mergeRsbuildConfig(userConfig, { + output: { distPath: { root: BUILD_OUTPUT_ROOT } }, + }); + } + + mode = + userConfig.mode ?? (action === "dev" ? "development" : "production"); + loadedConfig = await loadCloudflareConfig({ + root, + mode, + isPreview, + types: { + generate: options.types?.generate ?? true, + includeRuntime: options.types?.includeRuntime ?? true, + }, + }); + + // Without explicit environments (Rsbuild defaults them to `{}`), Rsbuild + // builds a single `web` environment from the top-level config, so keep + // it when there is a client entry. + const hasEnvironments = + Object.keys(userConfig.environments ?? {}).length > 0; + const environments = hasEnvironments + ? userConfig.environments + : userConfig.source?.entry + ? { web: {} } + : {}; + + return mergeRsbuildConfig(userConfig, { + environments: { + ...environments, + [ENVIRONMENT_NAME]: getWorkerEnvironmentConfig( + getWorkerConfig(), + getWorkerBundleDir(root) + ), + }, + dev: { + watchFiles: { + paths: loadedConfig + ? [loadedConfig.configPath, ...loadedConfig.dependencies] + : [], + type: "reload-server", + }, + }, + }); + }); + + api.modifyEnvironmentConfig((config, { name }) => { + if (action === "preview") { + return; + } + if (name === ENVIRONMENT_NAME) { + // Top-level entries are merged into every environment, so replace + // them to keep client entries out of the Worker. + config.source.entry = getWorkerEnvironmentConfig( + getWorkerConfig(), + getWorkerBundleDir(root) + ).source?.entry; + } else if (action === "build" && config.output.target === "web") { + config.output.distPath.root = getWorkerAssetsDir(root); + } + }); + + api.onBeforeStartDevServer(({ server }) => { + devServer = server; + // Runs after Rsbuild's built-in middleware, so client assets are + // served first and every other request is handled by the Worker. + return () => { + server.middlewares.use(workerMiddleware); + }; + }); + + api.onBeforeBuild({ + // Runs before Rsbuild empties the output directories and copies + // `public/` into them, so that the copies aren't removed. + order: "pre", + async handler({ isFirstCompile }) { + if (isFirstCompile) { + // As in the Vite plugin, drop Workers and Containers from + // previous builds. + await cleanBuildOutputDir(root); + } + }, + }); + + api.onAfterEnvironmentCompile(async ({ environment, stats }) => { + if ( + environment.name !== ENVIRONMENT_NAME || + !stats || + stats.hasErrors() + ) { + return; + } + if (action === "build") { + await removeUnemittedFiles(environment.distPath, stats.compilation); + return; + } + if (action !== "dev") { + return; + } + + await startOrUpdateMiniflare( + await getDevMiniflareOptions({ + ...getManifestFromCompilation(stats.compilation), + modulesRoot: environment.distPath, + }) + ); + }); + + api.onCloseDevServer(async () => { + await miniflare?.dispose(); + }); + + api.onBeforeStartPreviewServer(async ({ server }) => { + await startOrUpdateMiniflare(await getPreviewMiniflareOptions()); + // Rsbuild has no preview close hook, so dispose with the HTTP server. + server.httpServer?.on("close", () => void miniflare?.dispose()); + // Added before Rsbuild's built-in middleware, so that Miniflare serves + // the assets as well, as it would when deployed. + server.middlewares.use(workerMiddleware); + }); + + api.onAfterBuild(async () => { + await writeWorkerConfig({ + root, + config: getWorkerConfig(), + manifest: { + type: "partial", + mainModule: `${WORKER_ENTRY_NAME}.js`, + modules: {}, + }, + }); + await writeRootConfig( + root, + { + accountId: loadedConfig?.parsedConfig.accountId, + complianceRegion: loadedConfig?.parsedConfig.complianceRegion, + }, + { isPreview, mode } + ); + }); + + async function startOrUpdateMiniflare( + miniflareOptions: MiniflareOptions + ): Promise { + if (miniflare) { + await miniflare.setOptions(miniflareOptions); + } else { + miniflare = new Miniflare(miniflareOptions); + await miniflare.ready; + resolveMiniflare(miniflare); + } + } + + /** Forwards a request to the Worker running in Miniflare. */ + const workerMiddleware: RequestHandler = async (req, res, next) => { + if (kAssetRequest in req) { + return next(); + } + + try { + const request = createRequest(req, res); + const response = await ( + await miniflareReady + ).dispatchFetch( + new MiniflareRequest(request.url, { + method: request.method, + // The body is streamed back as-is, so ask for it uncompressed. + headers: [["accept-encoding", "identity"], ...request.headers], + body: request.body, + duplex: "half", + signal: request.signal, + redirect: "manual", + }) + ); + await sendResponse(res, response as unknown as Response); + } catch (error) { + next(error); + } + }; + + async function getDevMiniflareOptions( + manifest: MiniflareManifest + ): Promise { + const { + entrypoint: _entrypoint, + assets: _assets, + ...config + } = getWorkerConfig(); + const env = await getLocalBindings(config); + // Rsbuild serves the client assets in dev, so the assets binding is + // forwarded to its middleware. + for (const [name, binding] of Object.entries(env)) { + if (binding.type === "assets") { + env[name] = { + type: "node-handler", + handler: (req: IncomingMessage, res) => { + Object.assign(req, { [kAssetRequest]: true }); + devServer?.middlewares(req, res); + }, + }; + } + } + return getMiniflareOptions({ ...config, env, manifest }); + } + + async function getPreviewMiniflareOptions(): Promise { + const { workers, rootConfig } = await readBuildOutput(root); + const worker = workers[DEFAULT_WORKER_DIRECTORY_NAME]; + const workerManifest = worker?.config.manifest; + if (!worker?.bundleDir || !workerManifest) { + throw new Error( + "No Worker found in the Build Output. Run `rsbuild build` before `rsbuild preview`." + ); + } + // Load the same `.env` files as the build. + mode = rootConfig.buildContext.mode ?? "production"; + const { manifest: _manifest, assets, ...config } = worker.config; + return getMiniflareOptions({ + ...config, + env: await getLocalBindings(config), + manifest: await getMiniflareManifest({ + ...workerManifest, + rootPath: worker.bundleDir, + }), + assets: worker.assetsDir + ? { ...assets, directory: worker.assetsDir, hasUserWorker: true } + : undefined, + }); + } + + /** Resolves the Worker's bindings, including values from `.env` and `.dev.vars` files. */ + async function getLocalBindings( + config: Pick< + ParsedInputWorkerConfig | ParsedOutputWorkerConfig, + "name" | "env" + > + ): Promise> { + const [localEnv, devVars] = await Promise.all([ + loadEnv(root, mode), + loadDevVars(root, mode), + ]); + const localBindings = resolveLocalBindingsByWorkerName( + [config], + localEnv, + devVars + ); + warnForMissingSecrets(localBindings, mode, logger); + return { ...localBindings.get(config.name)?.bindings }; + } + + function getMiniflareOptions({ + exports, + ...config + }: Omit & { + exports?: + | ParsedInputWorkerConfig["exports"] + | ParsedOutputWorkerConfig["exports"]; + }): MiniflareOptions { + return { + log: new RsbuildMiniflareLog(), + unsafeDevRegistryPath: getDefaultDevRegistryPath(), + resourcePersistencePath: getPersistenceRoot( + root, + options.persistState ?? true + ), + workers: [ + { + config: { + ...config, + // Containers aren't supported yet, so Container-backed exports throw. + exports: createMiniflareExports({ + exports, + containerRuntimeOptions: undefined, + }), + }, + }, + ], + }; + } + }, + }; +} + +class RsbuildMiniflareLog extends Log { + override logReady() { + // Rsbuild prints its own server URLs. + } +} diff --git a/packages/rsbuild-plugin-cloudflare/src/worker-environment.ts b/packages/rsbuild-plugin-cloudflare/src/worker-environment.ts new file mode 100644 index 00000000000..6d97cc823d7 --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/src/worker-environment.ts @@ -0,0 +1,152 @@ +import * as fsp from "node:fs/promises"; +import * as path from "node:path"; +import { hasNodeJsCompat, NodeJsCompat } from "@cloudflare/workers-dev-shared"; +import { rspack } from "@rsbuild/core"; +import type { ParsedInputWorkerConfig } from "@cloudflare/config"; +import type { EnvironmentConfig, Rspack } from "@rsbuild/core"; + +/** The Worker's entry chunk name, so its main module is `index.js`. */ +export const WORKER_ENTRY_NAME = "index"; + +/** + * Builds the Rsbuild environment config that bundles a Worker for workerd as + * a single ES module entry (plus any dynamically imported chunks). + * + * @param worker The Worker config with its resolved entrypoint. + * @param distPath The directory the bundle is written to. + * @returns An Rsbuild environment config. + */ +export function getWorkerEnvironmentConfig( + worker: ParsedInputWorkerConfig & { entrypoint: string }, + distPath: string +): EnvironmentConfig { + const nodeJsCompat = hasNodeJsCompat(worker) + ? new NodeJsCompat(worker) + : undefined; + + return { + source: { + entry: { + [WORKER_ENTRY_NAME]: { + import: nodeJsCompat + ? [toDataUri(nodeJsCompat.injectGlobalCode()), worker.entrypoint] + : worker.entrypoint, + html: false, + }, + }, + }, + resolve: { + conditionNames: ["workerd", "worker", "module", "browser", "import"], + }, + output: { + target: "web-worker", + module: true, + distPath: { root: distPath, js: "", jsAsync: "" }, + filename: { js: "[name].js" }, + filenameHash: false, + // As in the Vite plugin, source maps are written to the bundle rather + // than the public assets, so emit them for readable stack traces. + sourceMap: { js: "source-map" }, + // Like the Vite plugin and Wrangler, don't minify the Worker, which also + // keeps license comments in the deployed modules. + minify: false, + externals: [ + ({ request = "" }, callback) => { + const nodeRequest = request.startsWith("node:") + ? request + : `node:${request}`; + if (request.startsWith("cloudflare:")) { + return callback(undefined, `module ${request}`); + } + if (nodeJsCompat?.externals.has(nodeRequest)) { + return callback(undefined, `module ${nodeRequest}`); + } + return callback(); + }, + ], + }, + performance: { chunkSplit: { strategy: "all-in-one" } }, + dev: { hmr: false, liveReload: false }, + tools: { + rspack: { + // Keep the entry's exports, which are the Worker's handlers. + output: { library: { type: "module" } }, + plugins: nodeJsCompat ? [nodeJsCompatPlugin(nodeJsCompat)] : [], + }, + }, + }; +} + +/** + * Applies the shared Node.js compat aliases and global injections. Requests + * are rewritten before resolution because rspack rejects `node:` specifiers + * before `resolve.alias` is applied, and because the polyfills must be resolved + * from this package rather than the user's project. + */ +function nodeJsCompatPlugin( + nodeJsCompat: NodeJsCompat +): Rspack.RspackPluginInstance { + return new rspack.NormalModuleReplacementPlugin(/^[^./]/, (resource) => { + const virtualModule = nodeJsCompat.getGlobalVirtualModule(resource.request); + resource.request = + virtualModule !== undefined + ? toDataUri(virtualModule) + : (nodeJsCompat.resolveNodeJsImport(resource.request)?.resolved ?? + resource.request); + }); +} + +function toDataUri(code: string): string { + return `data:text/javascript,${encodeURIComponent(code)}`; +} + +/** + * Collects the JavaScript emitted for a Worker into a Miniflare module manifest. + * + * @param compilation The Worker environment's compilation. + * @returns The main module name and every emitted ES module. + */ +export function getManifestFromCompilation(compilation: Rspack.Compilation): { + mainModule: string; + modules: Record; +} { + const modules: Record = {}; + for (const asset of compilation.getAssets()) { + if (asset.name.endsWith(".js")) { + modules[asset.name] = { + type: "esm", + contents: asset.source.source().toString(), + }; + } + } + + return { mainModule: `${WORKER_ENTRY_NAME}.js`, modules }; +} + +/** + * Removes files from the Worker's output directory that the compilation didn't + * emit, so that the Build Output's inferred modules are exactly the Worker's. + * Rsbuild copies `public/` into every non-Node environment, and watch rebuilds + * leave stale chunks behind. + * + * @param distPath The Worker environment's output directory. + * @param compilation The Worker environment's compilation. + */ +export async function removeUnemittedFiles( + distPath: string, + compilation: Rspack.Compilation +): Promise { + const emitted = new Set( + compilation.getAssets().map(({ name }) => path.join(distPath, name)) + ); + const entries = await fsp.readdir(distPath, { + recursive: true, + withFileTypes: true, + }); + await Promise.all( + entries + .map((entry) => path.join(entry.parentPath, entry.name)) + .filter((file, index) => entries[index]?.isFile() && !emitted.has(file)) + .map((file) => fsp.rm(file)) + ); +} diff --git a/packages/rsbuild-plugin-cloudflare/tsconfig.json b/packages/rsbuild-plugin-cloudflare/tsconfig.json new file mode 100644 index 00000000000..fee2683b3cb --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@cloudflare/workers-tsconfig/base.json", + "compilerOptions": { + "types": ["@types/node"] + }, + "include": ["src"], + "exclude": ["src/__tests__/fixtures"] +} diff --git a/packages/rsbuild-plugin-cloudflare/tsdown.config.ts b/packages/rsbuild-plugin-cloudflare/tsdown.config.ts new file mode 100644 index 00000000000..0c9545f0894 --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/tsdown.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + }, + platform: "node", + outDir: "dist", + dts: true, + tsconfig: "tsconfig.json", +}); diff --git a/packages/rsbuild-plugin-cloudflare/turbo.json b/packages/rsbuild-plugin-cloudflare/turbo.json new file mode 100644 index 00000000000..081adaa7f9e --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/turbo.json @@ -0,0 +1,12 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"] + }, + "test:ci": { + "env": ["CLOUDFLARE_PREVIEW_BUILD"] + } + } +} diff --git a/packages/rsbuild-plugin-cloudflare/vitest.config.ts b/packages/rsbuild-plugin-cloudflare/vitest.config.ts new file mode 100644 index 00000000000..009e1e0e56b --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineConfig({ + test: { + include: ["**/__tests__/**/*.spec.ts"], + testTimeout: 60_000, + server: { + deps: { + // `cloudflare.config.ts` files are loaded through Node's module hooks, + // which Vitest's module runner would otherwise bypass. + external: [/[/\\]packages[/\\]config[/\\]dist[/\\]/], + }, + }, + }, + }) +); diff --git a/packages/vite-plugin-cloudflare/package.json b/packages/vite-plugin-cloudflare/package.json index 7425b59f2e6..478c18fc8be 100644 --- a/packages/vite-plugin-cloudflare/package.json +++ b/packages/vite-plugin-cloudflare/package.json @@ -63,6 +63,7 @@ "@cloudflare/mock-npm-registry": "workspace:*", "@cloudflare/remote-bindings": "workspace:*", "@cloudflare/workers-auth": "workspace:*", + "@cloudflare/workers-dev-shared": "workspace:*", "@cloudflare/workers-shared": "workspace:*", "@cloudflare/workers-tsconfig": "workspace:*", "@cloudflare/workers-types": "catalog:default", diff --git a/packages/vite-plugin-cloudflare/src/build-output-preview.ts b/packages/vite-plugin-cloudflare/src/build-output-preview.ts index d99f02a2637..aa866f8ee8f 100644 --- a/packages/vite-plugin-cloudflare/src/build-output-preview.ts +++ b/packages/vite-plugin-cloudflare/src/build-output-preview.ts @@ -5,17 +5,11 @@ import { import { PRERENDER_WORKER_DIRECTORY_NAME } from "./build-output"; import type { BuildOutputWorker } from "@cloudflare/build-output-utils"; import type { - ModuleType, ParsedOutputContainerConfig, ParsedOutputRootConfig, ParsedOutputWorkerConfig, } from "@cloudflare/config"; - -export interface Bundle { - rootPath: string; - mainModule: string; - modules: Record; -} +import type { Bundle } from "@cloudflare/workers-dev-shared"; export interface BuildOutputPreviewWorker { config: ParsedOutputWorkerConfig; diff --git a/packages/vite-plugin-cloudflare/src/context.ts b/packages/vite-plugin-cloudflare/src/context.ts index 2198b46b141..5ed20147ee6 100644 --- a/packages/vite-plugin-cloudflare/src/context.ts +++ b/packages/vite-plugin-cloudflare/src/context.ts @@ -4,7 +4,6 @@ import { getInitialWorkerNameToExportTypesMap } from "./export-types"; import { debuglog } from "./utils"; import type { ConfigChangeCoordinator } from "./config-change-coordinator"; import type { ExportTypes } from "./export-types"; -import type { NodeJsCompat } from "./nodejs-compat"; import type { AssetsOnlyResolvedConfig, PreviewResolvedConfig, @@ -18,6 +17,7 @@ import type { ParsedInputWorkerConfig, ParsedOutputWorkerConfig, } from "@cloudflare/config"; +import type { NodeJsCompat } from "@cloudflare/workers-dev-shared"; import type { MiniflareOptions } from "miniflare"; import type * as vite from "vite"; diff --git a/packages/vite-plugin-cloudflare/src/miniflare-options.ts b/packages/vite-plugin-cloudflare/src/miniflare-options.ts index 237ac662ec3..c0cb621576f 100644 --- a/packages/vite-plugin-cloudflare/src/miniflare-options.ts +++ b/packages/vite-plugin-cloudflare/src/miniflare-options.ts @@ -11,6 +11,13 @@ import { resolveDockerHost, } from "@cloudflare/containers-shared"; import { maybeStartOrUpdateRemoteProxySession } from "@cloudflare/remote-bindings"; +import { + createMiniflareExports, + getMiniflareManifest, + getPersistenceRoot, + resolveLocalBindingsByWorkerName, + warnForMissingSecrets, +} from "@cloudflare/workers-dev-shared"; import { getBrowserRenderingHeadfulFromEnv, getLocalExplorerEnabledFromEnv, @@ -36,7 +43,6 @@ import { VITE_PROXY_WORKER_NAME, } from "./constants"; import { getInputInspectorPort } from "./debug"; -import { resolveLocalBindings } from "./local-env"; import { additionalModuleRE } from "./plugins/additional-modules"; import { getRemoteBindings } from "./remote-bindings"; import { ENVIRONMENT_NAME_HEADER } from "./shared"; @@ -47,29 +53,21 @@ import { toApiComplianceRegion, withTrailingSlash, } from "./utils"; -import type { Bundle } from "./build-output-preview"; import type { CloudflareDevEnvironment } from "./cloudflare-environment"; import type { AssetsOnlyPluginContext, PreviewPluginContext, WorkersPluginContext, } from "./context"; -import type { ResolvedLocalBindings } from "./local-env"; -import type { PersistState } from "./plugin-config"; -import type { - ParsedInputWorkerConfig, - ParsedOutputWorkerConfig, -} from "@cloudflare/config"; +import type { ParsedInputWorkerConfig } from "@cloudflare/config"; import type { ContainerDevOptions, ContainerDevPlan, - ContainerDevRuntimeOptions, } from "@cloudflare/containers-shared"; import type { RemoteBindingsLogger, RemoteProxySessionData, } from "@cloudflare/remote-bindings"; -import type { LoadedEnv } from "@cloudflare/workers-utils/local-env"; import type { MiniflareOptions, WorkerdStructuredLog, @@ -81,24 +79,6 @@ const INTERNAL_WORKERS_COMPATIBILITY_DATE = "2024-10-04"; // Used to mark HTML assets as being in the public directory so that they can be resolved from their root relative paths const PUBLIC_DIR_PREFIX = "/__vite_public_dir__"; -function getPersistenceRoot( - root: string, - persistState: PersistState -): string | undefined { - if (persistState === false) { - return; - } - - const defaultPersistPath = ".cloudflare/state"; - const persistPath = path.resolve( - root, - typeof persistState === "object" ? persistState.path : defaultPersistPath, - "v3" - ); - - return persistPath; -} - // We want module names to be their absolute path without the leading slash // (i.e. the modules root should be the root directory). On Windows, we need // paths to include the drive letter (i.e. `C:/a/b/c/index.mjs`). @@ -117,7 +97,6 @@ const MODULE_RUNNER_LEGACY_PATH = const WRAPPER_PATH = "__VITE_WORKER_ENTRY__"; const ASSETS_ONLY_MODULE = "__VITE_ASSETS_ONLY__.js"; type MiniflareEnv = NonNullable; -type MiniflareExports = NonNullable; async function getInternalWorkerManifest( workerPath: string @@ -755,44 +734,6 @@ export async function getDevMiniflareOptions( }; } -/** - * Read a Build Output Specification manifest into Miniflare's native module - * manifest shape. - */ -export async function getMiniflareManifest( - bundle: Bundle -): Promise> { - const { mainModule } = bundle; - const mainEntry = bundle.modules[mainModule]; - assert( - mainEntry !== undefined, - `Build Output Specification: \`mainModule\` "${mainModule}" is missing from \`modules\`.` - ); - - const modules: NonNullable["modules"] = - {}; - await Promise.all( - Object.entries(bundle.modules).map(async ([modulePath, { type }]) => { - const contents = await fsp.readFile( - path.join(bundle.rootPath, modulePath) - ); - modules[modulePath] = { - type, - contents: - type === "wasm" || type === "data" - ? new Uint8Array(contents) - : contents.toString(), - }; - }) - ); - - return { - mainModule, - modulesRoot: bundle.rootPath, - modules, - }; -} - export async function getPreviewMiniflareOptions( ctx: PreviewPluginContext, vitePreviewServer: vite.PreviewServer @@ -950,79 +891,6 @@ export async function getPreviewMiniflareOptions( }; } -/** - * Replaces authored Container names on Durable Object exports with workerd's - * local Container runtime metadata. - * - * @param options - Worker exports and their planned local Container metadata. - * @returns Exports accepted by Miniflare's native Worker config schema. - */ -export function createMiniflareExports(options: { - exports: - | ParsedInputWorkerConfig["exports"] - | ParsedOutputWorkerConfig["exports"]; - containerRuntimeOptions: Map | undefined; -}): MiniflareExports { - const workerExports: MiniflareExports = {}; - for (const [name, workerExport] of Object.entries(options.exports ?? {})) { - if (workerExport.type === "durable-object" && "storage" in workerExport) { - const containerName = - "container" in workerExport ? workerExport.container : undefined; - const container = - containerName === undefined - ? undefined - : options.containerRuntimeOptions?.get(name); - assert( - containerName === undefined || container !== undefined, - `Expected runtime options for Container-backed Durable Object export "${name}".` - ); - workerExports[name] = { ...workerExport, container }; - } else { - workerExports[name] = workerExport; - } - } - return workerExports; -} - -function resolveLocalBindingsByWorkerName( - workerConfigs: Array, - localEnv: LoadedEnv, - devVars: Record | undefined -): Map { - const resolvedLocalBindingsByWorkerName = new Map< - string, - ResolvedLocalBindings - >(); - for (const config of workerConfigs) { - resolvedLocalBindingsByWorkerName.set( - config.name, - resolveLocalBindings(config.env, localEnv.values, devVars) - ); - } - return resolvedLocalBindingsByWorkerName; -} - -function warnForMissingSecrets( - resolvedLocalBindingsByWorkerName: Map, - mode: string, - logger: vite.Logger -): void { - for (const [ - workerName, - { missingSecrets }, - ] of resolvedLocalBindingsByWorkerName) { - if (missingSecrets.length === 0) { - continue; - } - logger.warn( - `Missing required secrets for Worker "${workerName}": ${missingSecrets.join(", ")}. ` + - `Add them to one of \`.dev.vars.${mode}\`, \`.dev.vars\`, ` + - `\`.env.${mode}.local\`, \`.env.${mode}\`, \`.env.local\`, or \`.env\`. ` + - "Alternatively, set them as environment variables." - ); - } -} - /** * A Miniflare logger that forwards messages onto a Vite logger. */ diff --git a/packages/vite-plugin-cloudflare/src/nodejs-compat.ts b/packages/vite-plugin-cloudflare/src/nodejs-compat.ts index 5acb510efa6..9cf882ae417 100644 --- a/packages/vite-plugin-cloudflare/src/nodejs-compat.ts +++ b/packages/vite-plugin-cloudflare/src/nodejs-compat.ts @@ -1,222 +1,11 @@ import assert from "node:assert"; import * as path from "node:path"; -import { - getCloudflarePreset, - nonPrefixedNodeModules, -} from "@cloudflare/unenv-preset"; +import { nonPrefixedNodeModules } from "@cloudflare/unenv-preset"; import { getNodeCompat } from "miniflare"; -import { resolvePathSync } from "mlly"; -import { defineEnv } from "unenv"; -import { VIRTUAL_NODEJS_GLOBAL_INJECT_PREFIX } from "./plugins/virtual-modules"; import type { ResolvedWorkerConfig } from "./plugin-config"; -import type { ResolvedEnvironment } from "unenv"; +import type { NodeJsCompat } from "@cloudflare/workers-dev-shared"; import type * as vite from "vite"; -type InjectsByModule = Map< - string, - Array<{ injectedName: string; exportName: string; importName: string }> ->; -type VirtualModulePathToSpecifier = Map; - -export class NodeJsCompat { - externals: Set; - entries: Set; - #env: ResolvedEnvironment; - /** - * Map of module identifiers to an array of: - * - `injectedName`: the name injected on `globalThis` - * - `exportName`: the export name from the module - * - `importName`: the imported name - */ - #injectsByModule: InjectsByModule; - /** - * Map of virtual module to injectable module ID, - * which then maps via `injectsByModule` to the global code to be injected. - */ - #virtualModulePathToSpecifier: VirtualModulePathToSpecifier; - - constructor(workerConfig: ResolvedWorkerConfig) { - const { env } = defineEnv({ - presets: [ - getCloudflarePreset({ - compatibilityDate: workerConfig.compatibilityDate, - compatibilityFlags: workerConfig.compatibilityFlags, - }), - ], - }); - - this.#env = env; - this.externals = new Set(env.external); - this.entries = this.#getEntries(); - const { injectsByModule, virtualModulePathToSpecifier } = - this.#getInjects(); - this.#injectsByModule = injectsByModule; - this.#virtualModulePathToSpecifier = virtualModulePathToSpecifier; - } - - /** - * Gets a set of module specifiers for all possible Node.js compat polyfill entry-points - */ - #getEntries(): Set { - // Include all the alias targets - const entries = new Set(Object.values(this.#env.alias)); - - // Include all the injection targets - for (const globalInject of Object.values(this.#env.inject)) { - if (typeof globalInject === "string") { - entries.add(globalInject); - } else { - assert( - globalInject[0] !== undefined, - "Expected first element of globalInject to be defined" - ); - entries.add(globalInject[0]); - } - } - - // Include all the polyfills - this.#env.polyfill.forEach((polyfill) => entries.add(polyfill)); - - // Exclude all the externals - this.externals.forEach((external) => entries.delete(external)); - - return entries; - } - - #getInjects(): { - injectsByModule: InjectsByModule; - virtualModulePathToSpecifier: VirtualModulePathToSpecifier; - } { - const injectsByModule = new Map< - string, - { injectedName: string; exportName: string; importName: string }[] - >(); - const virtualModulePathToSpecifier = new Map(); - - for (const [injectedName, moduleSpecifier] of Object.entries( - this.#env.inject - )) { - const [module, exportName, importName] = Array.isArray(moduleSpecifier) - ? [moduleSpecifier[0], moduleSpecifier[1], moduleSpecifier[1]] - : [moduleSpecifier, "default", "defaultExport"]; - - if (!injectsByModule.has(module)) { - injectsByModule.set(module, []); - virtualModulePathToSpecifier.set( - `${VIRTUAL_NODEJS_GLOBAL_INJECT_PREFIX}${module}`, - module - ); - } - - const injects = injectsByModule.get(module); - assert(injects, `expected injects for "${module}" to be defined`); - injects.push({ injectedName, exportName, importName }); - } - - return { injectsByModule, virtualModulePathToSpecifier }; - } - - /** - * Does the given module ID resolve to a virtual module corresponding to a global injection module? - */ - isGlobalVirtualModule(source: string): boolean { - return this.#virtualModulePathToSpecifier.has(source); - } - - /** - * Get the contents of the virtual module corresponding to a global injection module. - */ - getGlobalVirtualModule(source: string): string | undefined { - const module = this.#virtualModulePathToSpecifier.get(source); - - if (!module) { - return; - } - - const injects = this.#injectsByModule.get(module); - assert(injects, `expected injects for "${module}" to be defined`); - const imports = injects.map(({ exportName, importName }) => - importName === exportName ? exportName : `${exportName} as ${importName}` - ); - - return [ - `import { ${imports.join(", ")} } from "${module}";`, - ...injects.map( - ({ injectedName, importName }) => - `globalThis.${injectedName} = ${importName};` - ), - ].join("\n"); - } - - /** - * Gets the necessary global polyfills to inject into the entry-point of the user's code. - */ - injectGlobalCode(): string { - const injectedCode = Array.from(this.#virtualModulePathToSpecifier.keys()) - .map((moduleId) => `import "${moduleId}";`) - .join("\n"); - // Some globals are not injected using the approach above but are added to globalThis via side-effect imports of polyfills from the unenv-preset. - const polyfillCode = this.#env.polyfill - .map((polyfillPath) => `import "${polyfillPath}";`) - .join("\n"); - - return `${injectedCode}${polyfillCode}`; - } - - /** - * Resolves the `source` to a Node.js compat alias if possible. - * - * If there is an alias, the return value is an object with: - * - `unresolved`: a bare import path to the polyfill (e.g. `unenv/runtime/node/crypto`) - * - `resolved`: an absolute path to the polyfill (e.g. `/path/to/project/node_modules/unenv/runtime/node/child_process/index.mjs`) - */ - resolveNodeJsImport( - source: string - ): { unresolved: string; resolved: string } | undefined { - const alias = this.#env.alias[source]; - - // These aliases must be resolved from the context of this plugin since the alias will refer to one of the - // `@cloudflare/unenv-preset` or the `unenv` packages, which are direct dependencies of this package, - // and not the user's project. - // We exclude `externals` as these should be externalized rather than optimized. - if (alias && !this.externals.has(alias)) { - return { - unresolved: alias, - resolved: resolvePathSync(alias, { url: import.meta.url }), - }; - } - - if (this.entries.has(source)) { - return { - unresolved: source, - resolved: resolvePathSync(source, { url: import.meta.url }), - }; - } - } -} - -/** - * Returns `true` if the given combination of compat dates and flags enables Node.js compatibility. - */ -export function hasNodeJsCompat(workerConfig: ResolvedWorkerConfig) { - const nodeCompatMode = getNodeCompat( - workerConfig.compatibilityDate, - workerConfig.compatibilityFlags ?? [] - ).mode; - - if (nodeCompatMode === "v2") { - return true; - } - - if (nodeCompatMode === "v1") { - throw new Error( - `Unsupported Node.js compat mode (v1). Only the v2 mode is supported, either change your compat date to "2024-09-23" or later, or set the "nodejs_compat_v2" compatibility flag` - ); - } - - return false; -} - /** * Returns true if Node.js async local storage (ALS) is enabled (and not full Node.js compatibility mode). */ diff --git a/packages/vite-plugin-cloudflare/src/plugin-config.ts b/packages/vite-plugin-cloudflare/src/plugin-config.ts index bcc62b80e1b..e04188c2e6e 100644 --- a/packages/vite-plugin-cloudflare/src/plugin-config.ts +++ b/packages/vite-plugin-cloudflare/src/plugin-config.ts @@ -1,49 +1,38 @@ -import * as fs from "node:fs"; -import * as fsp from "node:fs/promises"; import * as path from "node:path"; import { DEFAULT_WORKER_DIRECTORY_NAME, normalizeDirectoryName, } from "@cloudflare/build-output-utils"; +import { InputWorkerSchema } from "@cloudflare/config"; import { - generateTypes, - InputWorkerSchema, - loadAndParseConfig, -} from "@cloudflare/config"; -import { - generateRuntimeTypes, - RUNTIME_TYPES_MARKER, -} from "@cloudflare/runtime-types"; + hasNodeJsCompat, + loadCloudflareConfig, + NodeJsCompat, +} from "@cloudflare/workers-dev-shared"; import { parseStaticRouting } from "@cloudflare/workers-shared/utils/configuration/parseStaticRouting"; import { DEFAULT_COMPAT_DATE, - formatZodError, getWorkerNameFromProject, } from "@cloudflare/workers-utils"; import { loadDevVars, loadEnv } from "@cloudflare/workers-utils/local-env"; import { defu } from "defu"; -import * as vite from "vite"; import { PRERENDER_WORKER_DIRECTORY_NAME } from "./build-output"; import { getCliDisplayName, isPreviewBuild } from "./build-output-env"; import { readBuildOutputPreview } from "./build-output-preview"; -import { hasNodeJsCompat, NodeJsCompat } from "./nodejs-compat"; import type { BuildOutputPreviewWorker } from "./build-output-preview"; import type { ParsedInputContainerConfig, InputWorkerConfig, - ParsedInputConfig, ParsedInputSettingsConfig, ParsedInputWorkerConfig, ParsedOutputContainerConfig, } from "@cloudflare/config"; +import type { PersistState } from "@cloudflare/workers-dev-shared"; import type { StaticRouting } from "@cloudflare/workers-shared/utils/types"; import type { LoadedEnv } from "@cloudflare/workers-utils/local-env"; +import type * as vite from "vite"; -type ParsedInputConfigWithWorker = Omit & { - worker: ParsedInputWorkerConfig; -}; - -export type PersistState = boolean | { path: string }; +export type { PersistState }; export type TunnelConfig = { autoStart?: boolean; name?: string; @@ -375,6 +364,7 @@ export async function resolvePluginConfig( const loadedConfig = await loadCloudflareConfig({ root, mode, + isPreview: isPreviewBuild(), types, }); const settings = { @@ -682,138 +672,3 @@ function resolveWorker( devOnly, }; } - -const CONFIG_FILENAME = "cloudflare.config.ts"; -const WRANGLER_CONFIG_FILENAMES = [ - "wrangler.json", - "wrangler.jsonc", - "wrangler.toml", -]; -const TYPES_OUTPUT_PATH = ".cloudflare/types/index.d.ts"; -const CONFIG_PACKAGE = "cf/config"; - -/** - * Load and validate `cloudflare.config.ts` via `@cloudflare/config`, if it - * exists. Returns the parsed default export, the absolute path of the loaded file, and - * the files imported while resolving the config (for watch-mode). - * - * When `types.generate` is true, also writes `.cloudflare/types/index.d.ts` - * when the generated content differs from what's already on disk. - */ -async function loadCloudflareConfig(options: { - root: string; - mode: string; - types: { generate: boolean; includeRuntime: boolean }; -}): Promise< - | { - parsedConfig: ParsedInputConfigWithWorker; - configPath: string; - dependencies: Set; - } - | undefined -> { - const configPath = path.resolve(options.root, CONFIG_FILENAME); - - if (!fs.existsSync(configPath)) { - const wranglerConfigFilename = WRANGLER_CONFIG_FILENAMES.find((filename) => - fs.existsSync(path.resolve(options.root, filename)) - ); - if (wranglerConfigFilename !== undefined) { - throw new Error( - `Found \`${wranglerConfigFilename}\` but no \`${CONFIG_FILENAME}\`. The Cloudflare Vite plugin no longer reads Wrangler configuration files, so this configuration would be ignored. Run \`npx cf migrate --bundler vite\` to generate \`${CONFIG_FILENAME}\`.` - ); - } - return; - } - - const { result, dependencies } = await loadAndParseConfig(configPath, { - isPreview: isPreviewBuild(), - mode: options.mode, - }); - - if (!result.success) { - throw new Error( - `Invalid \`${CONFIG_FILENAME}\`:\n${formatZodError(result.error)}` - ); - } - - const worker = result.data.worker; - if (worker === undefined) { - throw new Error( - `\`${CONFIG_FILENAME}\` must define a Worker using the \`worker\` property.` - ); - } - - if (options.types.generate) { - await writeCloudflareTypes({ - root: options.root, - configPath, - includeRuntime: options.types.includeRuntime, - compatibilityDate: worker.compatibilityDate, - compatibilityFlags: worker.compatibilityFlags ?? [], - }); - } - - return { - parsedConfig: { ...result.data, worker }, - configPath, - dependencies, - }; -} - -/** - * Write `.cloudflare/types/index.d.ts` using - * `@cloudflare/config`'s `generateTypes`, targeting the public `cf/config` - * package used by `cloudflare.config.ts`. - * - * When `includeRuntime` is true, appends the Workers runtime types (generated - * from the project's compatibility date/flags) after the inference block. The - * runtime-types generator caches against the existing file content, so it only - * spawns workerd when the compat date/flags/workerd version change. - * - * The existing file is read once and reused for both the runtime-types cache - * check and the diff-before-write (only writes if content differs, to avoid - * touching mtimes unnecessarily). - */ -async function writeCloudflareTypes(options: { - root: string; - configPath: string; - includeRuntime: boolean; - compatibilityDate: string; - compatibilityFlags: string[]; -}): Promise { - const outputPath = path.resolve(options.root, TYPES_OUTPUT_PATH); - const outputDir = path.dirname(outputPath); - const relativeConfigPath = vite.normalizePath( - path.relative(outputDir, options.configPath) - ); - const configImportPath = relativeConfigPath.startsWith(".") - ? relativeConfigPath - : `./${relativeConfigPath}`; - - let existingContent: string | undefined; - try { - existingContent = await fsp.readFile(outputPath, "utf8"); - } catch { - // File doesn't exist yet — we'll create it below. - } - - let content = generateTypes({ - configPath: configImportPath, - packageName: CONFIG_PACKAGE, - }); - - if (options.includeRuntime) { - const { runtimeHeader, runtimeTypes } = await generateRuntimeTypes({ - compatibilityDate: options.compatibilityDate, - compatibilityFlags: options.compatibilityFlags, - existingContent, - }); - content += `\n${runtimeHeader}\n${RUNTIME_TYPES_MARKER}\n${runtimeTypes}`; - } - - if (existingContent !== content) { - await fsp.mkdir(outputDir, { recursive: true }); - await fsp.writeFile(outputPath, content); - } -} diff --git a/packages/vite-plugin-cloudflare/src/plugins/virtual-modules.ts b/packages/vite-plugin-cloudflare/src/plugins/virtual-modules.ts index c49418f193f..36f5e06d788 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/virtual-modules.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/virtual-modules.ts @@ -1,5 +1,6 @@ import assert from "node:assert"; import * as path from "node:path"; +import { VIRTUAL_NODEJS_GLOBAL_INJECT_PREFIX } from "@cloudflare/workers-dev-shared"; import { assertHasNodeJsCompat } from "../nodejs-compat"; import { VIRTUAL_EXPORT_TYPES, @@ -10,7 +11,6 @@ import { createPlugin } from "../utils"; export const VIRTUAL_USER_ENTRY = `${virtualPrefix}user-entry`; export const VIRTUAL_CLIENT_FALLBACK_ENTRY = `${virtualPrefix}client-fallback-entry`; -export const VIRTUAL_NODEJS_GLOBAL_INJECT_PREFIX = `${virtualPrefix}nodejs-global-inject/`; const virtualCloudflareResolveRE = /^virtual:cloudflare\//; // eslint-disable-next-line no-control-regex -- Vite virtual module convention uses \0 prefix diff --git a/packages/workers-dev-shared/package.json b/packages/workers-dev-shared/package.json new file mode 100644 index 00000000000..70e41837d42 --- /dev/null +++ b/packages/workers-dev-shared/package.json @@ -0,0 +1,51 @@ +{ + "name": "@cloudflare/workers-dev-shared", + "version": "0.0.0", + "private": true, + "description": "Bundler-agnostic helpers shared by the Cloudflare Vite and Rsbuild plugins.", + "homepage": "https://github.com/cloudflare/workers-sdk/tree/main/packages/workers-dev-shared#readme", + "bugs": { + "url": "https://github.com/cloudflare/workers-sdk/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/cloudflare/workers-sdk.git", + "directory": "packages/workers-dev-shared" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "scripts": { + "build": "tsdown", + "check:type": "tsc", + "dev": "tsdown --watch", + "test:ci": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@cloudflare/config": "workspace:*", + "@cloudflare/runtime-types": "workspace:*", + "@cloudflare/unenv-preset": "workspace:*", + "miniflare": "workspace:*", + "mlly": "^1.7.4", + "unenv": "2.0.0-rc.24" + }, + "devDependencies": { + "@cloudflare/containers-shared": "workspace:*", + "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-utils": "workspace:*", + "@types/node": "catalog:default", + "tsdown": "0.16.3", + "typescript": "catalog:default", + "vitest": "catalog:default" + } +} diff --git a/packages/vite-plugin-cloudflare/src/__tests__/get-miniflare-manifest.spec.ts b/packages/workers-dev-shared/src/__tests__/get-miniflare-manifest.spec.ts similarity index 92% rename from packages/vite-plugin-cloudflare/src/__tests__/get-miniflare-manifest.spec.ts rename to packages/workers-dev-shared/src/__tests__/get-miniflare-manifest.spec.ts index cb47c68ee3e..ee27050d549 100644 --- a/packages/vite-plugin-cloudflare/src/__tests__/get-miniflare-manifest.spec.ts +++ b/packages/workers-dev-shared/src/__tests__/get-miniflare-manifest.spec.ts @@ -3,13 +3,13 @@ import { tmpdir } from "node:os"; import * as path from "node:path"; import { removeDirSync } from "@cloudflare/workers-utils"; import { afterEach, beforeEach, describe, test } from "vitest"; -import { getMiniflareManifest } from "../miniflare-options"; +import { getMiniflareManifest } from "../miniflare-manifest"; describe("getMiniflareManifest", () => { let rootPath: string; beforeEach(() => { - rootPath = mkdtempSync(path.join(tmpdir(), "vite-plugin-manifest-")); + rootPath = mkdtempSync(path.join(tmpdir(), "workers-dev-shared-manifest-")); mkdirSync(path.join(rootPath, "assets")); writeFileSync(path.join(rootPath, "index.js"), "export default {};"); writeFileSync(path.join(rootPath, "assets/data.bin"), Buffer.from([0, 1])); diff --git a/packages/vite-plugin-cloudflare/src/__tests__/local-env.spec.ts b/packages/workers-dev-shared/src/__tests__/local-env.spec.ts similarity index 100% rename from packages/vite-plugin-cloudflare/src/__tests__/local-env.spec.ts rename to packages/workers-dev-shared/src/__tests__/local-env.spec.ts diff --git a/packages/vite-plugin-cloudflare/src/__tests__/miniflare-containers.spec.ts b/packages/workers-dev-shared/src/__tests__/miniflare-containers.spec.ts similarity index 95% rename from packages/vite-plugin-cloudflare/src/__tests__/miniflare-containers.spec.ts rename to packages/workers-dev-shared/src/__tests__/miniflare-containers.spec.ts index 4d3bfb94599..535ce16a854 100644 --- a/packages/vite-plugin-cloudflare/src/__tests__/miniflare-containers.spec.ts +++ b/packages/workers-dev-shared/src/__tests__/miniflare-containers.spec.ts @@ -1,6 +1,6 @@ import { InputWorkerSchema } from "@cloudflare/config"; import { describe, test } from "vitest"; -import { createMiniflareExports } from "../miniflare-options"; +import { createMiniflareExports } from "../miniflare-exports"; describe("Container Miniflare exports", () => { test("replaces Container names with runtime image metadata", ({ expect }) => { diff --git a/packages/workers-dev-shared/src/cloudflare-config.ts b/packages/workers-dev-shared/src/cloudflare-config.ts new file mode 100644 index 00000000000..e57c1584c5b --- /dev/null +++ b/packages/workers-dev-shared/src/cloudflare-config.ts @@ -0,0 +1,153 @@ +import * as fs from "node:fs"; +import * as fsp from "node:fs/promises"; +import * as path from "node:path"; +import { generateTypes, loadAndParseConfig } from "@cloudflare/config"; +import { + generateRuntimeTypes, + RUNTIME_TYPES_MARKER, +} from "@cloudflare/runtime-types"; +import { formatZodError } from "@cloudflare/workers-utils"; +import type { + ParsedInputConfig, + ParsedInputWorkerConfig, +} from "@cloudflare/config"; + +type ParsedInputConfigWithWorker = Omit & { + worker: ParsedInputWorkerConfig; +}; + +export const CLOUDFLARE_CONFIG_FILENAME = "cloudflare.config.ts"; +const WRANGLER_CONFIG_FILENAMES = [ + "wrangler.json", + "wrangler.jsonc", + "wrangler.toml", +]; +const TYPES_OUTPUT_PATH = ".cloudflare/types/index.d.ts"; +const CONFIG_PACKAGE = "cf/config"; + +/** + * Load and validate `cloudflare.config.ts` via `@cloudflare/config`, if it + * exists. Returns the parsed default export, the absolute path of the loaded file, and + * the files imported while resolving the config (for watch-mode). + * + * When `types.generate` is true, also writes `.cloudflare/types/index.d.ts` + * when the generated content differs from what's already on disk. + */ +export async function loadCloudflareConfig(options: { + root: string; + mode: string; + isPreview: boolean; + types: { generate: boolean; includeRuntime: boolean }; +}): Promise< + | { + parsedConfig: ParsedInputConfigWithWorker; + configPath: string; + dependencies: Set; + } + | undefined +> { + const configPath = path.resolve(options.root, CLOUDFLARE_CONFIG_FILENAME); + + if (!fs.existsSync(configPath)) { + const wranglerConfigFilename = WRANGLER_CONFIG_FILENAMES.find((filename) => + fs.existsSync(path.resolve(options.root, filename)) + ); + if (wranglerConfigFilename !== undefined) { + throw new Error( + `Found \`${wranglerConfigFilename}\` but no \`${CLOUDFLARE_CONFIG_FILENAME}\`. The Cloudflare Vite plugin no longer reads Wrangler configuration files, so this configuration would be ignored. Run \`npx cf migrate --bundler vite\` to generate \`${CLOUDFLARE_CONFIG_FILENAME}\`.` + ); + } + return; + } + + const { result, dependencies } = await loadAndParseConfig(configPath, { + isPreview: options.isPreview, + mode: options.mode, + }); + + if (!result.success) { + throw new Error( + `Invalid \`${CLOUDFLARE_CONFIG_FILENAME}\`:\n${formatZodError(result.error)}` + ); + } + + const worker = result.data.worker; + if (worker === undefined) { + throw new Error( + `\`${CLOUDFLARE_CONFIG_FILENAME}\` must define a Worker using the \`worker\` property.` + ); + } + + if (options.types.generate) { + await writeCloudflareTypes({ + root: options.root, + configPath, + includeRuntime: options.types.includeRuntime, + compatibilityDate: worker.compatibilityDate, + compatibilityFlags: worker.compatibilityFlags ?? [], + }); + } + + return { + parsedConfig: { ...result.data, worker }, + configPath, + dependencies, + }; +} + +/** + * Write `.cloudflare/types/index.d.ts` using + * `@cloudflare/config`'s `generateTypes`, targeting the public `cf/config` + * package used by `cloudflare.config.ts`. + * + * When `includeRuntime` is true, appends the Workers runtime types (generated + * from the project's compatibility date/flags) after the inference block. The + * runtime-types generator caches against the existing file content, so it only + * spawns workerd when the compat date/flags/workerd version change. + * + * The existing file is read once and reused for both the runtime-types cache + * check and the diff-before-write (only writes if content differs, to avoid + * touching mtimes unnecessarily). + */ +async function writeCloudflareTypes(options: { + root: string; + configPath: string; + includeRuntime: boolean; + compatibilityDate: string; + compatibilityFlags: string[]; +}): Promise { + const outputPath = path.resolve(options.root, TYPES_OUTPUT_PATH); + const outputDir = path.dirname(outputPath); + const relativeConfigPath = path + .relative(outputDir, options.configPath) + .replaceAll("\\", "/"); + const configImportPath = relativeConfigPath.startsWith(".") + ? relativeConfigPath + : `./${relativeConfigPath}`; + + let existingContent: string | undefined; + try { + existingContent = await fsp.readFile(outputPath, "utf8"); + } catch { + // File doesn't exist yet — we'll create it below. + } + + let content = generateTypes({ + configPath: configImportPath, + packageName: CONFIG_PACKAGE, + }); + + if (options.includeRuntime) { + const { runtimeHeader, runtimeTypes } = await generateRuntimeTypes({ + compatibilityDate: options.compatibilityDate, + compatibilityFlags: options.compatibilityFlags, + existingContent, + }); + content += `\n${runtimeHeader}\n${RUNTIME_TYPES_MARKER}\n${runtimeTypes}`; + } + + if (existingContent !== content) { + await fsp.mkdir(outputDir, { recursive: true }); + await fsp.writeFile(outputPath, content); + } +} diff --git a/packages/workers-dev-shared/src/index.ts b/packages/workers-dev-shared/src/index.ts new file mode 100644 index 00000000000..9a2bc416f90 --- /dev/null +++ b/packages/workers-dev-shared/src/index.ts @@ -0,0 +1,20 @@ +export { + CLOUDFLARE_CONFIG_FILENAME, + loadCloudflareConfig, +} from "./cloudflare-config"; +export { + resolveLocalBindings, + resolveLocalBindingsByWorkerName, + warnForMissingSecrets, +} from "./local-env"; +export type { ResolvedLocalBindings } from "./local-env"; +export { createMiniflareExports } from "./miniflare-exports"; +export { getMiniflareManifest } from "./miniflare-manifest"; +export type { Bundle } from "./miniflare-manifest"; +export { + hasNodeJsCompat, + NodeJsCompat, + VIRTUAL_NODEJS_GLOBAL_INJECT_PREFIX, +} from "./nodejs-compat"; +export { getPersistenceRoot } from "./persist"; +export type { PersistState } from "./persist"; diff --git a/packages/vite-plugin-cloudflare/src/local-env.ts b/packages/workers-dev-shared/src/local-env.ts similarity index 61% rename from packages/vite-plugin-cloudflare/src/local-env.ts rename to packages/workers-dev-shared/src/local-env.ts index 4cd52c7de18..4ee517fda0a 100644 --- a/packages/vite-plugin-cloudflare/src/local-env.ts +++ b/packages/workers-dev-shared/src/local-env.ts @@ -1,4 +1,5 @@ import type { ParsedInputWorkerConfig } from "@cloudflare/config"; +import type { LoadedEnv } from "@cloudflare/workers-utils/local-env"; import type { WorkerOptions } from "miniflare"; type MiniflareEnv = NonNullable; @@ -60,3 +61,42 @@ export function resolveLocalBindings( return { bindings, missingSecrets }; } + +export function resolveLocalBindingsByWorkerName( + workerConfigs: Array>, + localEnv: LoadedEnv, + devVars: Record | undefined +): Map { + const resolvedLocalBindingsByWorkerName = new Map< + string, + ResolvedLocalBindings + >(); + for (const config of workerConfigs) { + resolvedLocalBindingsByWorkerName.set( + config.name, + resolveLocalBindings(config.env, localEnv.values, devVars) + ); + } + return resolvedLocalBindingsByWorkerName; +} + +export function warnForMissingSecrets( + resolvedLocalBindingsByWorkerName: Map, + mode: string, + logger: { warn(message: string): void } +): void { + for (const [ + workerName, + { missingSecrets }, + ] of resolvedLocalBindingsByWorkerName) { + if (missingSecrets.length === 0) { + continue; + } + logger.warn( + `Missing required secrets for Worker "${workerName}": ${missingSecrets.join(", ")}. ` + + `Add them to one of \`.dev.vars.${mode}\`, \`.dev.vars\`, ` + + `\`.env.${mode}.local\`, \`.env.${mode}\`, \`.env.local\`, or \`.env\`. ` + + "Alternatively, set them as environment variables." + ); + } +} diff --git a/packages/workers-dev-shared/src/miniflare-exports.ts b/packages/workers-dev-shared/src/miniflare-exports.ts new file mode 100644 index 00000000000..19fcb51e301 --- /dev/null +++ b/packages/workers-dev-shared/src/miniflare-exports.ts @@ -0,0 +1,43 @@ +import assert from "node:assert"; +import type { + ParsedInputWorkerConfig, + ParsedOutputWorkerConfig, +} from "@cloudflare/config"; +import type { ContainerDevRuntimeOptions } from "@cloudflare/containers-shared"; +import type { WorkerOptions } from "miniflare"; + +type MiniflareExports = NonNullable; + +/** + * Replaces authored Container names on Durable Object exports with workerd's + * local Container runtime metadata. + * + * @param options - Worker exports and their planned local Container metadata. + * @returns Exports accepted by Miniflare's native Worker config schema. + */ +export function createMiniflareExports(options: { + exports: + | ParsedInputWorkerConfig["exports"] + | ParsedOutputWorkerConfig["exports"]; + containerRuntimeOptions: Map | undefined; +}): MiniflareExports { + const workerExports: MiniflareExports = {}; + for (const [name, workerExport] of Object.entries(options.exports ?? {})) { + if (workerExport.type === "durable-object" && "storage" in workerExport) { + const containerName = + "container" in workerExport ? workerExport.container : undefined; + const container = + containerName === undefined + ? undefined + : options.containerRuntimeOptions?.get(name); + assert( + containerName === undefined || container !== undefined, + `Expected runtime options for Container-backed Durable Object export "${name}".` + ); + workerExports[name] = { ...workerExport, container }; + } else { + workerExports[name] = workerExport; + } + } + return workerExports; +} diff --git a/packages/workers-dev-shared/src/miniflare-manifest.ts b/packages/workers-dev-shared/src/miniflare-manifest.ts new file mode 100644 index 00000000000..86d124e4129 --- /dev/null +++ b/packages/workers-dev-shared/src/miniflare-manifest.ts @@ -0,0 +1,50 @@ +import assert from "node:assert"; +import * as fsp from "node:fs/promises"; +import * as path from "node:path"; +import type { ModuleType } from "@cloudflare/config"; +import type { WorkerOptions } from "miniflare"; + +/** A Worker bundle from the Build Output Specification. */ +export interface Bundle { + rootPath: string; + mainModule: string; + modules: Record; +} + +/** + * Read a Build Output Specification manifest into Miniflare's native module + * manifest shape. + */ +export async function getMiniflareManifest( + bundle: Bundle +): Promise> { + const { mainModule } = bundle; + const mainEntry = bundle.modules[mainModule]; + assert( + mainEntry !== undefined, + `Build Output Specification: \`mainModule\` "${mainModule}" is missing from \`modules\`.` + ); + + const modules: NonNullable["modules"] = + {}; + await Promise.all( + Object.entries(bundle.modules).map(async ([modulePath, { type }]) => { + const contents = await fsp.readFile( + path.join(bundle.rootPath, modulePath) + ); + modules[modulePath] = { + type, + contents: + type === "wasm" || type === "data" + ? new Uint8Array(contents) + : contents.toString(), + }; + }) + ); + + return { + mainModule, + modulesRoot: bundle.rootPath, + modules, + }; +} diff --git a/packages/workers-dev-shared/src/nodejs-compat.ts b/packages/workers-dev-shared/src/nodejs-compat.ts new file mode 100644 index 00000000000..3cec4ba34a2 --- /dev/null +++ b/packages/workers-dev-shared/src/nodejs-compat.ts @@ -0,0 +1,220 @@ +import assert from "node:assert"; +import { getCloudflarePreset } from "@cloudflare/unenv-preset"; +import { getNodeCompat } from "miniflare"; +import { resolvePathSync } from "mlly"; +import { defineEnv } from "unenv"; +import type { ParsedInputWorkerConfig } from "@cloudflare/config"; +import type { ResolvedEnvironment } from "unenv"; + +/** Prefix of the virtual modules that inject Node.js globals into a Worker. */ +export const VIRTUAL_NODEJS_GLOBAL_INJECT_PREFIX = + "virtual:cloudflare/nodejs-global-inject/"; + +type CompatConfig = Pick< + ParsedInputWorkerConfig, + "compatibilityDate" | "compatibilityFlags" +>; +type InjectsByModule = Map< + string, + Array<{ injectedName: string; exportName: string; importName: string }> +>; +type VirtualModulePathToSpecifier = Map; + +export class NodeJsCompat { + externals: Set; + entries: Set; + #env: ResolvedEnvironment; + /** + * Map of module identifiers to an array of: + * - `injectedName`: the name injected on `globalThis` + * - `exportName`: the export name from the module + * - `importName`: the imported name + */ + #injectsByModule: InjectsByModule; + /** + * Map of virtual module to injectable module ID, + * which then maps via `injectsByModule` to the global code to be injected. + */ + #virtualModulePathToSpecifier: VirtualModulePathToSpecifier; + + constructor(workerConfig: CompatConfig) { + const { env } = defineEnv({ + presets: [ + getCloudflarePreset({ + compatibilityDate: workerConfig.compatibilityDate, + compatibilityFlags: workerConfig.compatibilityFlags, + }), + ], + }); + + this.#env = env; + this.externals = new Set(env.external); + this.entries = this.#getEntries(); + const { injectsByModule, virtualModulePathToSpecifier } = + this.#getInjects(); + this.#injectsByModule = injectsByModule; + this.#virtualModulePathToSpecifier = virtualModulePathToSpecifier; + } + + /** + * Gets a set of module specifiers for all possible Node.js compat polyfill entry-points + */ + #getEntries(): Set { + // Include all the alias targets + const entries = new Set(Object.values(this.#env.alias)); + + // Include all the injection targets + for (const globalInject of Object.values(this.#env.inject)) { + if (typeof globalInject === "string") { + entries.add(globalInject); + } else { + assert( + globalInject[0] !== undefined, + "Expected first element of globalInject to be defined" + ); + entries.add(globalInject[0]); + } + } + + // Include all the polyfills + this.#env.polyfill.forEach((polyfill) => entries.add(polyfill)); + + // Exclude all the externals + this.externals.forEach((external) => entries.delete(external)); + + return entries; + } + + #getInjects(): { + injectsByModule: InjectsByModule; + virtualModulePathToSpecifier: VirtualModulePathToSpecifier; + } { + const injectsByModule = new Map< + string, + { injectedName: string; exportName: string; importName: string }[] + >(); + const virtualModulePathToSpecifier = new Map(); + + for (const [injectedName, moduleSpecifier] of Object.entries( + this.#env.inject + )) { + const [module, exportName, importName] = Array.isArray(moduleSpecifier) + ? [moduleSpecifier[0], moduleSpecifier[1], moduleSpecifier[1]] + : [moduleSpecifier, "default", "defaultExport"]; + + if (!injectsByModule.has(module)) { + injectsByModule.set(module, []); + virtualModulePathToSpecifier.set( + `${VIRTUAL_NODEJS_GLOBAL_INJECT_PREFIX}${module}`, + module + ); + } + + const injects = injectsByModule.get(module); + assert(injects, `expected injects for "${module}" to be defined`); + injects.push({ injectedName, exportName, importName }); + } + + return { injectsByModule, virtualModulePathToSpecifier }; + } + + /** + * Does the given module ID resolve to a virtual module corresponding to a global injection module? + */ + isGlobalVirtualModule(source: string): boolean { + return this.#virtualModulePathToSpecifier.has(source); + } + + /** + * Get the contents of the virtual module corresponding to a global injection module. + */ + getGlobalVirtualModule(source: string): string | undefined { + const module = this.#virtualModulePathToSpecifier.get(source); + + if (!module) { + return; + } + + const injects = this.#injectsByModule.get(module); + assert(injects, `expected injects for "${module}" to be defined`); + const imports = injects.map(({ exportName, importName }) => + importName === exportName ? exportName : `${exportName} as ${importName}` + ); + + return [ + `import { ${imports.join(", ")} } from "${module}";`, + ...injects.map( + ({ injectedName, importName }) => + `globalThis.${injectedName} = ${importName};` + ), + ].join("\n"); + } + + /** + * Gets the necessary global polyfills to inject into the entry-point of the user's code. + */ + injectGlobalCode(): string { + const injectedCode = Array.from(this.#virtualModulePathToSpecifier.keys()) + .map((moduleId) => `import "${moduleId}";`) + .join("\n"); + // Some globals are not injected using the approach above but are added to globalThis via side-effect imports of polyfills from the unenv-preset. + const polyfillCode = this.#env.polyfill + .map((polyfillPath) => `import "${polyfillPath}";`) + .join("\n"); + + return `${injectedCode}${polyfillCode}`; + } + + /** + * Resolves the `source` to a Node.js compat alias if possible. + * + * If there is an alias, the return value is an object with: + * - `unresolved`: a bare import path to the polyfill (e.g. `unenv/runtime/node/crypto`) + * - `resolved`: an absolute path to the polyfill (e.g. `/path/to/project/node_modules/unenv/runtime/node/child_process/index.mjs`) + */ + resolveNodeJsImport( + source: string + ): { unresolved: string; resolved: string } | undefined { + const alias = this.#env.alias[source]; + + // These aliases must be resolved from the context of this plugin since the alias will refer to one of the + // `@cloudflare/unenv-preset` or the `unenv` packages, which are direct dependencies of this package, + // and not the user's project. + // We exclude `externals` as these should be externalized rather than optimized. + if (alias && !this.externals.has(alias)) { + return { + unresolved: alias, + resolved: resolvePathSync(alias, { url: import.meta.url }), + }; + } + + if (this.entries.has(source)) { + return { + unresolved: source, + resolved: resolvePathSync(source, { url: import.meta.url }), + }; + } + } +} + +/** + * Returns `true` if the given combination of compat dates and flags enables Node.js compatibility. + */ +export function hasNodeJsCompat(workerConfig: CompatConfig) { + const nodeCompatMode = getNodeCompat( + workerConfig.compatibilityDate, + workerConfig.compatibilityFlags ?? [] + ).mode; + + if (nodeCompatMode === "v2") { + return true; + } + + if (nodeCompatMode === "v1") { + throw new Error( + `Unsupported Node.js compat mode (v1). Only the v2 mode is supported, either change your compat date to "2024-09-23" or later, or set the "nodejs_compat_v2" compatibility flag` + ); + } + + return false; +} diff --git a/packages/workers-dev-shared/src/persist.ts b/packages/workers-dev-shared/src/persist.ts new file mode 100644 index 00000000000..65614f170f6 --- /dev/null +++ b/packages/workers-dev-shared/src/persist.ts @@ -0,0 +1,21 @@ +import * as path from "node:path"; + +export type PersistState = boolean | { path: string }; + +export function getPersistenceRoot( + root: string, + persistState: PersistState +): string | undefined { + if (persistState === false) { + return; + } + + const defaultPersistPath = ".cloudflare/state"; + const persistPath = path.resolve( + root, + typeof persistState === "object" ? persistState.path : defaultPersistPath, + "v3" + ); + + return persistPath; +} diff --git a/packages/workers-dev-shared/tsconfig.json b/packages/workers-dev-shared/tsconfig.json new file mode 100644 index 00000000000..99e994d3b13 --- /dev/null +++ b/packages/workers-dev-shared/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/base.json", + "compilerOptions": { + "types": ["@types/node"] + }, + "include": ["src"] +} diff --git a/packages/workers-dev-shared/tsdown.config.ts b/packages/workers-dev-shared/tsdown.config.ts new file mode 100644 index 00000000000..c070361a8d1 --- /dev/null +++ b/packages/workers-dev-shared/tsdown.config.ts @@ -0,0 +1,14 @@ +import path from "node:path"; +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + }, + platform: "node", + outDir: "dist", + dts: true, + tsconfig: "tsconfig.json", + // Consumers bundle this package, so leave every bare import for them to resolve. + external: (id) => !id.startsWith(".") && !path.isAbsolute(id), +}); diff --git a/packages/workers-dev-shared/turbo.json b/packages/workers-dev-shared/turbo.json new file mode 100644 index 00000000000..6556dcf3e5e --- /dev/null +++ b/packages/workers-dev-shared/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "http://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"] + } + } +} diff --git a/packages/workers-dev-shared/vitest.config.ts b/packages/workers-dev-shared/vitest.config.ts new file mode 100644 index 00000000000..752f1b2d606 --- /dev/null +++ b/packages/workers-dev-shared/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import configShared from "../../vitest.shared"; + +export default mergeConfig( + configShared, + defineConfig({ + test: { + include: ["**/__tests__/**/*.spec.ts"], + }, + }) +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2b39834bfc..1040d82cf42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1739,7 +1739,7 @@ importers: version: link:../workers-utils tsdown: specifier: 0.16.3 - version: 0.16.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3) + version: 0.16.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3) typescript: specifier: catalog:default version: 5.8.3 @@ -1782,7 +1782,7 @@ importers: version: 6.0.2 tsdown: specifier: ^0.15.9 - version: 0.15.9(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(typescript@5.9.3) + version: 0.15.9(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(typescript@5.9.3) packages/codemods: devDependencies: @@ -1837,7 +1837,7 @@ importers: version: 2.2.0 tsdown: specifier: 0.16.3 - version: 0.16.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3) + version: 0.16.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3) typescript: specifier: catalog:default version: 5.8.3 @@ -2311,7 +2311,7 @@ importers: version: 1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.159.4)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/router-plugin': specifier: ^1.158.0 - version: 1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.1.5(patch_hash=6ff029cd40c16544867ce7328a4a4830d20f15b04843483e3452e50844a840e3)(@types/node@22.15.17)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0)) + version: 1.159.5(@rsbuild/core@2.2.13(core-js@3.37.1))(@tanstack/react-router@1.159.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.1.5(patch_hash=6ff029cd40c16544867ce7328a4a4830d20f15b04843483e3452e50844a840e3)(@types/node@22.15.17)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0)) '@types/react': specifier: ^19.2.0 version: 19.2.13 @@ -2574,7 +2574,7 @@ importers: version: 22.15.17 tsdown: specifier: 0.16.3 - version: 0.16.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3) + version: 0.16.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3) typescript: specifier: catalog:default version: 5.8.3 @@ -2737,7 +2737,68 @@ importers: version: 0.28.2 tsdown: specifier: 0.16.3 - version: 0.16.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3) + version: 0.16.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3) + typescript: + specifier: catalog:default + version: 5.8.3 + vitest: + specifier: catalog:default + version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.15.17)(@vitest/ui@4.1.0)(msw@2.14.6(@types/node@22.15.17)(typescript@5.8.3))(vite@8.2.0(@types/node@22.15.17)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0)) + + packages/rsbuild-plugin-cloudflare: + dependencies: + '@cloudflare/config': + specifier: workspace:* + version: link:../config + '@cloudflare/runtime-types': + specifier: workspace:* + version: link:../runtime-types + '@cloudflare/unenv-preset': + specifier: workspace:* + version: link:../unenv-preset + miniflare: + specifier: workspace:* + version: link:../miniflare + unenv: + specifier: 2.0.0-rc.24 + version: 2.0.0-rc.24 + devDependencies: + '@cloudflare/build-output-utils': + specifier: workspace:* + version: link:../build-output-utils + '@cloudflare/workers-dev-shared': + specifier: workspace:* + version: link:../workers-dev-shared + '@cloudflare/workers-tsconfig': + specifier: workspace:* + version: link:../workers-tsconfig + '@cloudflare/workers-types': + specifier: catalog:default + version: 5.20261019.1 + '@cloudflare/workers-utils': + specifier: workspace:* + version: link:../workers-utils + '@remix-run/node-fetch-server': + specifier: ^0.8.0 + version: 0.8.0 + '@rsbuild/core': + specifier: ^2.2.13 + version: 2.2.13(core-js@3.37.1) + '@types/node': + specifier: 22.15.17 + version: 22.15.17 + cf: + specifier: catalog:default + version: 1.0.0-beta.17(@types/node@22.15.17) + get-port: + specifier: ^7.1.0 + version: 7.1.0 + mlly: + specifier: ^1.7.4 + version: 1.7.4 + tsdown: + specifier: 0.16.3 + version: 0.16.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3) typescript: specifier: catalog:default version: 5.8.3 @@ -2765,7 +2826,7 @@ importers: version: 22.15.17 tsdown: specifier: 0.16.3 - version: 0.16.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3) + version: 0.16.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3) typescript: specifier: catalog:default version: 5.8.3 @@ -2885,6 +2946,9 @@ importers: '@cloudflare/workers-auth': specifier: workspace:* version: link:../workers-auth + '@cloudflare/workers-dev-shared': + specifier: workspace:* + version: link:../workers-dev-shared '@cloudflare/workers-shared': specifier: workspace:* version: link:../workers-shared @@ -2944,7 +3008,7 @@ importers: version: 1.2.2 tsdown: specifier: 0.16.3 - version: 0.16.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3) + version: 0.16.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3) typescript: specifier: catalog:default version: 5.8.3 @@ -4176,7 +4240,7 @@ importers: version: 2.2.0 tsdown: specifier: 0.16.3 - version: 0.16.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3) + version: 0.16.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3) typescript: specifier: catalog:default version: 5.8.3 @@ -4230,6 +4294,49 @@ importers: specifier: catalog:default version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.15.17)(@vitest/ui@4.1.0)(msw@2.14.6(@types/node@22.15.17)(typescript@5.8.3))(vite@8.2.0(@types/node@22.15.17)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0)) + packages/workers-dev-shared: + dependencies: + '@cloudflare/config': + specifier: workspace:* + version: link:../config + '@cloudflare/runtime-types': + specifier: workspace:* + version: link:../runtime-types + '@cloudflare/unenv-preset': + specifier: workspace:* + version: link:../unenv-preset + miniflare: + specifier: workspace:* + version: link:../miniflare + mlly: + specifier: ^1.7.4 + version: 1.7.4 + unenv: + specifier: 2.0.0-rc.24 + version: 2.0.0-rc.24 + devDependencies: + '@cloudflare/containers-shared': + specifier: workspace:* + version: link:../containers-shared + '@cloudflare/workers-tsconfig': + specifier: workspace:* + version: link:../workers-tsconfig + '@cloudflare/workers-utils': + specifier: workspace:* + version: link:../workers-utils + '@types/node': + specifier: 22.15.17 + version: 22.15.17 + tsdown: + specifier: 0.16.3 + version: 0.16.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3) + typescript: + specifier: catalog:default + version: 5.8.3 + vitest: + specifier: catalog:default + version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.15.17)(@vitest/ui@4.1.0)(msw@2.14.6(@types/node@22.15.17)(typescript@5.8.3))(vite@8.2.0(@types/node@22.15.17)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0)) + packages/workers-editor-shared: dependencies: react-split-pane: @@ -4494,7 +4601,7 @@ importers: version: 2.2.0 tsdown: specifier: ^0.15.9 - version: 0.15.9(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(typescript@5.8.3) + version: 0.15.9(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(typescript@5.8.3) tsup: specifier: 8.3.0 version: 8.3.0(@microsoft/api-extractor@7.52.8(@types/node@22.15.17))(jiti@2.6.1)(postcss@8.5.23)(tsx@4.21.0)(typescript@5.8.3)(yaml@2.9.0) @@ -4844,7 +4951,7 @@ importers: version: 1.5.0 tsdown: specifier: 0.16.3 - version: 0.16.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3) + version: 0.16.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3) tsup: specifier: 8.3.0 version: 8.3.0(@microsoft/api-extractor@7.52.8(@types/node@22.15.17))(jiti@2.6.1)(postcss@8.5.23)(supports-color@9.2.2)(tsx@4.21.0)(typescript@5.8.3)(yaml@2.8.1) @@ -6004,6 +6111,9 @@ packages: '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/core@1.11.3': + resolution: {integrity: sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==} + '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} @@ -6013,6 +6123,9 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@emnapi/wasi-threads@1.2.3': + resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==} + '@esbuild-kit/cjs-loader@2.4.4': resolution: {integrity: sha512-NfsJX4PdzhwSkfJukczyUiZGc7zNNWZcEAyqeISpDnn0PTfzMJR1aR8xAIPskBejIxBJbIgCCMzbaYa9SXepIg==} deprecated: 'Merged into tsx: https://tsx.hirok.io' @@ -9281,6 +9394,108 @@ packages: cpu: [x64] os: [win32] + '@rsbuild/core@2.2.13': + resolution: {integrity: sha512-UDSDrokgG70tmx+0bz9kuc64SkPQ5A1/Zhqsp8pD0L3Cko/xEnukAbrjouGihUTk55imhC4zx62DnQN/1VJ+GQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + core-js: '>= 3.0.0' + peerDependenciesMeta: + core-js: + optional: true + + '@rspack/binding-darwin-arm64@2.2.9': + resolution: {integrity: sha512-aIHkk/wngpwgWkwVQFz2AIkmG9WpWtWlkqy4BlYhs5a7l9z0BrB0kXnUb6VSbqZ7Tc3cRepKw7ipkGjKa8KKXw==} + cpu: [arm64] + os: [darwin] + + '@rspack/binding-darwin-x64@2.2.9': + resolution: {integrity: sha512-QtUS7lyOE5oxdMm7RH/Fw176jqwdHiTW2qEsmbdCYzPw3t7QkmI5ohoyiuxOp3czJfaayXP6bVyvm2FqqnWPAw==} + cpu: [x64] + os: [darwin] + + '@rspack/binding-linux-arm64-gnu@2.2.9': + resolution: {integrity: sha512-GMKxtBJAMoqBPqXZVi0QanDkneWwgiKRpTgHW/pbKPY/2XMfZJ98thm4YlATO18XZJ2KET+GfsNfaf1o5Y+aaA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rspack/binding-linux-arm64-musl@2.2.9': + resolution: {integrity: sha512-aW3fcM64R5mY2+ym6Q8okAo/pcVIjAliqxFOc9lf9hCyS7hHaabWo30smeMX3neEjsqw+iyNbzWMuH0G5AugTw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rspack/binding-linux-ppc64-gnu@2.2.9': + resolution: {integrity: sha512-dVEiyZdANq+g/qugI64vKnxO+PCVicidu/GQ6sO0QEWGVzdSUbxHztHviSnMwdvRs6OLrN7Lwhaw536hJ/cU8w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rspack/binding-linux-riscv64-gnu@2.2.9': + resolution: {integrity: sha512-uBHWmfMm502145le0EUOsBoV6bJ9XA5s3NQLL4smSMoSEuMbNrRhqDJ2+B1u8aXVfjfhxCPyNlMDmgbsdVaO0Q==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rspack/binding-linux-riscv64-musl@2.2.9': + resolution: {integrity: sha512-MR7e6PP7KcG3jHYWznk17npBBvjUXQg7qQbS+yrAu0R3p/27wLp+aP5m/zk4ev1Iq93ccIBIAGVRGA3v1fSKew==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rspack/binding-linux-s390x-gnu@2.2.9': + resolution: {integrity: sha512-Tc9Nuv7XvkrAG79Ps/6F7vT4bxZqiaiQ3Rd3BidKUjVYiwTPJogk1W3gPXp7Lw+AMoN/dY63W22bMJ7Oh0FeZw==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rspack/binding-linux-x64-gnu@2.2.9': + resolution: {integrity: sha512-xQYhQwC8q3mSCzHV+yi76Gt2Lt9BhnmeJnQgXGjuhyvjZWBB9jFNp9Uj6umun7tlMliMRhoqTesqvK4Vo/kkcQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rspack/binding-linux-x64-musl@2.2.9': + resolution: {integrity: sha512-FfEYdFhOd1nZL+U/up8nlbcRTTyWX4ohXUQcApQMYKIZoR1EJ0g/oBhS915+TSray0DchA+NT6yVL1sqWJOFaQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rspack/binding-wasm32-wasi@2.2.9': + resolution: {integrity: sha512-+29JEBiUi2TMDOGYmT3Tm3JGk3f19+CbjxNEt1bVreR8axuZZj+wOSwB/sx85lKQkL5fuNPh0BhOZ7cdkDxQpA==} + cpu: [wasm32] + + '@rspack/binding-win32-arm64-msvc@2.2.9': + resolution: {integrity: sha512-x/BDAwQCkR+ANWAAWV+15PFWuae2z9DzL+QcVeb+5taOri+tHa/iQdG8e3GJ60d0amsR+u9BJ2CBtJLK1fQkxw==} + cpu: [arm64] + os: [win32] + + '@rspack/binding-win32-ia32-msvc@2.2.9': + resolution: {integrity: sha512-i622t0Mom3STiDi8VHLaX62LKwhbT2kg37fKjzv3wQT+9bJvQ/+St6QQzFZg9C7fV53LXqAw8jFi5yIPIjg0Qw==} + cpu: [ia32] + os: [win32] + + '@rspack/binding-win32-x64-msvc@2.2.9': + resolution: {integrity: sha512-K98FPCAv0VpDPAz5ZqZ0XI1CmdJu9Kw1FmUDl4rN/zQIt9iqYn+Hgxq6Nk6Zx7g938jiG7ABLwITXSIKGbs6aw==} + cpu: [x64] + os: [win32] + + '@rspack/binding@2.2.9': + resolution: {integrity: sha512-Y8wDJoRo8n0KXDGq6J3KGVekkUQcOcUHSppMHoJv8JLbNBEsyaaKiDx4ljNTGEDtTON0CRbMEWbSiLRe+bJxOQ==} + + '@rspack/core@2.2.9': + resolution: {integrity: sha512-Tp6/ZdM5UrGB3BeLfvh69l1iX03PZ46IgXjKqubVEuJVAhnOjpshooH/YAxcxR0AymcBLKA997Z4v+LV2aXzCw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@module-federation/runtime-tools': ^0.24.1 || ^2.0.0 + '@swc/helpers': ^0.5.23 + peerDependenciesMeta: + '@module-federation/runtime-tools': + optional: true + '@swc/helpers': + optional: true + '@rushstack/node-core-library@5.13.1': resolution: {integrity: sha512-5yXhzPFGEkVc9Fu92wsNJ9jlvdwz4RNb2bMso+/+TH0nMm1jDDDsOIf4l8GAkPxGuwPw5DH24RliWVfSPhlW/Q==} peerDependencies: @@ -9767,6 +9982,9 @@ packages: peerDependencies: '@svgr/core': '*' + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + '@tabby_ai/hijri-converter@1.0.5': resolution: {integrity: sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==} engines: {node: '>=16.0.0'} @@ -10993,10 +11211,6 @@ packages: engines: {node: '>=22'} hasBin: true - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} - engines: {node: '>=18'} - chai@6.3.0: resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} engines: {node: '>=18'} @@ -12038,10 +12252,6 @@ packages: expect-type@0.15.0: resolution: {integrity: sha512-yWnriYB4e8G54M5/fAFj7rCIBiKs1HAACaY13kCz6Ku0dezjS9aMcfcdVK2X8Tv2tEV1BPz/wKfQ7WA4S/d8aA==} - expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} - engines: {node: '>=12.0.0'} - expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -15349,9 +15559,6 @@ packages: std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} - std-env@4.0.0: - resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} - std-env@4.3.0: resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} @@ -18316,6 +18523,12 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@1.11.3': + dependencies: + '@emnapi/wasi-threads': 1.2.3 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.11.1': dependencies: tslib: 2.8.1 @@ -18331,6 +18544,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.3': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild-kit/cjs-loader@2.4.4': dependencies: '@esbuild-kit/core-utils': 3.3.2 @@ -19586,9 +19804,9 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': dependencies: - '@emnapi/core': 1.11.1 + '@emnapi/core': 1.11.3 '@emnapi/runtime': 1.11.3 '@tybys/wasm-util': 0.10.3 optional: true @@ -20357,17 +20575,17 @@ snapshots: '@rolldown/binding-openharmony-arm64@1.2.3': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-beta.44(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': + '@rolldown/binding-wasm32-wasi@1.0.0-beta.44(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': dependencies: - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-beta.49(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)': + '@rolldown/binding-wasm32-wasi@1.0.0-beta.49(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': dependencies: - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -20613,6 +20831,84 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.57.1': optional: true + '@rsbuild/core@2.2.13(core-js@3.37.1)': + dependencies: + '@rspack/core': 2.2.9(@swc/helpers@0.5.23) + '@swc/helpers': 0.5.23 + optionalDependencies: + core-js: 3.37.1 + transitivePeerDependencies: + - '@module-federation/runtime-tools' + + '@rspack/binding-darwin-arm64@2.2.9': + optional: true + + '@rspack/binding-darwin-x64@2.2.9': + optional: true + + '@rspack/binding-linux-arm64-gnu@2.2.9': + optional: true + + '@rspack/binding-linux-arm64-musl@2.2.9': + optional: true + + '@rspack/binding-linux-ppc64-gnu@2.2.9': + optional: true + + '@rspack/binding-linux-riscv64-gnu@2.2.9': + optional: true + + '@rspack/binding-linux-riscv64-musl@2.2.9': + optional: true + + '@rspack/binding-linux-s390x-gnu@2.2.9': + optional: true + + '@rspack/binding-linux-x64-gnu@2.2.9': + optional: true + + '@rspack/binding-linux-x64-musl@2.2.9': + optional: true + + '@rspack/binding-wasm32-wasi@2.2.9': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + optional: true + + '@rspack/binding-win32-arm64-msvc@2.2.9': + optional: true + + '@rspack/binding-win32-ia32-msvc@2.2.9': + optional: true + + '@rspack/binding-win32-x64-msvc@2.2.9': + optional: true + + '@rspack/binding@2.2.9': + optionalDependencies: + '@rspack/binding-darwin-arm64': 2.2.9 + '@rspack/binding-darwin-x64': 2.2.9 + '@rspack/binding-linux-arm64-gnu': 2.2.9 + '@rspack/binding-linux-arm64-musl': 2.2.9 + '@rspack/binding-linux-ppc64-gnu': 2.2.9 + '@rspack/binding-linux-riscv64-gnu': 2.2.9 + '@rspack/binding-linux-riscv64-musl': 2.2.9 + '@rspack/binding-linux-s390x-gnu': 2.2.9 + '@rspack/binding-linux-x64-gnu': 2.2.9 + '@rspack/binding-linux-x64-musl': 2.2.9 + '@rspack/binding-wasm32-wasi': 2.2.9 + '@rspack/binding-win32-arm64-msvc': 2.2.9 + '@rspack/binding-win32-ia32-msvc': 2.2.9 + '@rspack/binding-win32-x64-msvc': 2.2.9 + + '@rspack/core@2.2.9(@swc/helpers@0.5.23)': + dependencies: + '@rspack/binding': 2.2.9 + optionalDependencies: + '@swc/helpers': 0.5.23 + '@rushstack/node-core-library@5.13.1(@types/node@22.15.17)': dependencies: ajv: 8.13.0 @@ -21263,6 +21559,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + '@tabby_ai/hijri-converter@1.0.5': {} '@tailwindcss/node@4.2.2': @@ -21396,7 +21696,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.1.5(patch_hash=6ff029cd40c16544867ce7328a4a4830d20f15b04843483e3452e50844a840e3)(@types/node@22.15.17)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0))': + '@tanstack/router-plugin@1.159.5(@rsbuild/core@2.2.13(core-js@3.37.1))(@tanstack/react-router@1.159.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.1.5(patch_hash=6ff029cd40c16544867ce7328a4a4830d20f15b04843483e3452e50844a840e3)(@types/node@22.15.17)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -21412,6 +21712,7 @@ snapshots: unplugin: 2.3.11 zod: 3.25.76 optionalDependencies: + '@rsbuild/core': 2.2.13(core-js@3.37.1) '@tanstack/react-router': 1.159.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) vite: 8.1.5(patch_hash=6ff029cd40c16544867ce7328a4a4830d20f15b04843483e3452e50844a840e3)(@types/node@22.15.17)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: @@ -21931,8 +22232,8 @@ snapshots: '@types/chai': 5.2.2 '@vitest/spy': 4.1.0 '@vitest/utils': 4.1.0 - chai: 6.2.2 - tinyrainbow: 3.0.3 + chai: 6.3.0 + tinyrainbow: 3.2.0 '@vitest/mocker@4.1.0(msw@2.14.6(@types/node@22.15.17)(typescript@5.8.3))(vite@8.1.5(patch_hash=6ff029cd40c16544867ce7328a4a4830d20f15b04843483e3452e50844a840e3)(@types/node@22.15.17)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0))': dependencies: @@ -22005,7 +22306,7 @@ snapshots: '@vitest/pretty-format@4.1.0': dependencies: - tinyrainbow: 3.0.3 + tinyrainbow: 3.2.0 '@vitest/pretty-format@5.0.4': dependencies: @@ -22058,7 +22359,7 @@ snapshots: dependencies: '@vitest/pretty-format': 4.1.0 convert-source-map: 2.0.0 - tinyrainbow: 3.0.3 + tinyrainbow: 3.2.0 '@vitest/utils@5.0.4': dependencies: @@ -22679,8 +22980,6 @@ snapshots: - bufferutil - utf-8-validate - chai@6.2.2: {} - chai@6.3.0: {} chalk@4.1.2: @@ -23801,8 +24100,6 @@ snapshots: expect-type@0.15.0: {} - expect-type@1.3.0: {} - expect-type@1.4.0: {} expect@29.7.0: @@ -25361,7 +25658,7 @@ snapshots: mlly@1.7.4: dependencies: acorn: 8.16.0 - pathe: 2.0.1 + pathe: 2.0.3 pkg-types: 1.3.1 ufo: 1.5.4 @@ -25579,7 +25876,7 @@ snapshots: dependencies: citty: 0.2.0 pathe: 2.0.3 - tinyexec: 1.2.4 + tinyexec: 1.3.1 object-assign@4.1.1: {} @@ -26722,7 +27019,7 @@ snapshots: reusify@1.0.4: {} - rolldown-plugin-dts@0.16.12(rolldown@1.0.0-beta.44(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3))(typescript@5.8.3): + rolldown-plugin-dts@0.16.12(rolldown@1.0.0-beta.44(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3))(typescript@5.8.3): dependencies: '@babel/generator': 7.29.1 '@babel/parser': 7.29.0 @@ -26733,14 +27030,14 @@ snapshots: dts-resolver: 2.1.3 get-tsconfig: 4.13.6 magic-string: 0.30.21 - rolldown: 1.0.0-beta.44(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + rolldown: 1.0.0-beta.44(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: - oxc-resolver - supports-color - rolldown-plugin-dts@0.16.12(rolldown@1.0.0-beta.44(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3))(typescript@5.9.3): + rolldown-plugin-dts@0.16.12(rolldown@1.0.0-beta.44(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3))(typescript@5.9.3): dependencies: '@babel/generator': 7.29.1 '@babel/parser': 7.29.0 @@ -26751,14 +27048,14 @@ snapshots: dts-resolver: 2.1.3 get-tsconfig: 4.13.6 magic-string: 0.30.21 - rolldown: 1.0.0-beta.44(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + rolldown: 1.0.0-beta.44(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - oxc-resolver - supports-color - rolldown-plugin-dts@0.17.6(ms@2.1.3)(rolldown@1.0.0-beta.49(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3))(typescript@5.8.3): + rolldown-plugin-dts@0.17.6(ms@2.1.3)(rolldown@1.0.0-beta.49(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3))(typescript@5.8.3): dependencies: '@babel/generator': 7.29.1 '@babel/parser': 7.29.0 @@ -26769,14 +27066,14 @@ snapshots: get-tsconfig: 4.13.6 magic-string: 0.30.21 obug: 0.1.3(ms@2.1.3) - rolldown: 1.0.0-beta.49(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + rolldown: 1.0.0-beta.49(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: - ms - oxc-resolver - rolldown@1.0.0-beta.44(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3): + rolldown@1.0.0-beta.44(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3): dependencies: '@oxc-project/types': 0.95.0 '@rolldown/pluginutils': 1.0.0-beta.44 @@ -26791,7 +27088,7 @@ snapshots: '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.44 '@rolldown/binding-linux-x64-musl': 1.0.0-beta.44 '@rolldown/binding-openharmony-arm64': 1.0.0-beta.44 - '@rolldown/binding-wasm32-wasi': 1.0.0-beta.44(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.44(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.44 '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.44 '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.44 @@ -26799,7 +27096,7 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - rolldown@1.0.0-beta.49(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3): + rolldown@1.0.0-beta.49(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3): dependencies: '@oxc-project/types': 0.96.0 '@rolldown/pluginutils': 1.0.0-beta.49 @@ -26814,7 +27111,7 @@ snapshots: '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.49 '@rolldown/binding-linux-x64-musl': 1.0.0-beta.49 '@rolldown/binding-openharmony-arm64': 1.0.0-beta.49 - '@rolldown/binding-wasm32-wasi': 1.0.0-beta.49(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.49(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.49 '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.49 '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.49 @@ -27411,8 +27708,6 @@ snapshots: std-env@3.9.0: {} - std-env@4.0.0: {} - std-env@4.3.0: {} steno@0.4.4: @@ -27791,7 +28086,7 @@ snapshots: strip-bom: 3.0.0 strip-json-comments: 2.0.1 - tsdown@0.15.9(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(typescript@5.8.3): + tsdown@0.15.9(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(typescript@5.8.3): dependencies: ansis: 4.2.0 cac: 6.7.14 @@ -27800,8 +28095,8 @@ snapshots: diff: 8.0.3 empathic: 2.0.1 hookable: 5.5.3 - rolldown: 1.0.0-beta.44(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) - rolldown-plugin-dts: 0.16.12(rolldown@1.0.0-beta.44(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3))(typescript@5.8.3) + rolldown: 1.0.0-beta.44(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + rolldown-plugin-dts: 0.16.12(rolldown@1.0.0-beta.44(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3))(typescript@5.8.3) semver: 7.7.3 tinyexec: 1.0.2 tinyglobby: 0.2.15 @@ -27818,7 +28113,7 @@ snapshots: - supports-color - vue-tsc - tsdown@0.15.9(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(typescript@5.9.3): + tsdown@0.15.9(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(typescript@5.9.3): dependencies: ansis: 4.2.0 cac: 6.7.14 @@ -27827,8 +28122,8 @@ snapshots: diff: 8.0.3 empathic: 2.0.1 hookable: 5.5.3 - rolldown: 1.0.0-beta.44(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) - rolldown-plugin-dts: 0.16.12(rolldown@1.0.0-beta.44(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3))(typescript@5.9.3) + rolldown: 1.0.0-beta.44(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + rolldown-plugin-dts: 0.16.12(rolldown@1.0.0-beta.44(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3))(typescript@5.9.3) semver: 7.7.3 tinyexec: 1.0.2 tinyglobby: 0.2.15 @@ -27845,7 +28140,7 @@ snapshots: - supports-color - vue-tsc - tsdown@0.16.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3): + tsdown@0.16.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3): dependencies: ansis: 4.3.1 cac: 6.7.14 @@ -27854,14 +28149,14 @@ snapshots: empathic: 2.0.1 hookable: 5.5.3 obug: 0.1.3(ms@2.1.3) - rolldown: 1.0.0-beta.49(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) - rolldown-plugin-dts: 0.17.6(ms@2.1.3)(rolldown@1.0.0-beta.49(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3))(typescript@5.8.3) + rolldown: 1.0.0-beta.49(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + rolldown-plugin-dts: 0.17.6(ms@2.1.3)(rolldown@1.0.0-beta.49(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3))(typescript@5.8.3) semver: 7.8.5 - tinyexec: 1.2.4 + tinyexec: 1.3.1 tinyglobby: 0.2.17 tree-kill: 1.2.2 unconfig-core: 7.5.0 - unrun: 0.2.8(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(synckit@0.11.12) + unrun: 0.2.8(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(synckit@0.11.12) optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: @@ -28260,10 +28555,10 @@ snapshots: picomatch: 4.0.7 webpack-virtual-modules: 0.6.2 - unrun@0.2.8(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(synckit@0.11.12): + unrun@0.2.8(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(synckit@0.11.12): dependencies: '@oxc-project/runtime': 0.96.0 - rolldown: 1.0.0-beta.49(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) + rolldown: 1.0.0-beta.49(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) optionalDependencies: synckit: 0.11.12 transitivePeerDependencies: @@ -28550,16 +28845,16 @@ snapshots: '@vitest/spy': 4.1.0 '@vitest/utils': 4.1.0 es-module-lexer: 2.3.1 - expect-type: 1.3.0 + expect-type: 1.4.0 magic-string: 0.30.21 obug: 2.1.4 pathe: 2.0.3 - picomatch: 4.0.5 - std-env: 4.0.0 + picomatch: 4.0.7 + std-env: 4.3.0 tinybench: 2.9.0 - tinyexec: 1.2.4 + tinyexec: 1.3.1 tinyglobby: 0.2.17 - tinyrainbow: 3.0.3 + tinyrainbow: 3.2.0 vite: 8.1.5(patch_hash=6ff029cd40c16544867ce7328a4a4830d20f15b04843483e3452e50844a840e3)(@types/node@22.15.17)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: @@ -28579,16 +28874,16 @@ snapshots: '@vitest/spy': 4.1.0 '@vitest/utils': 4.1.0 es-module-lexer: 2.3.1 - expect-type: 1.3.0 + expect-type: 1.4.0 magic-string: 0.30.21 obug: 2.1.4 pathe: 2.0.3 - picomatch: 4.0.5 - std-env: 4.0.0 + picomatch: 4.0.7 + std-env: 4.3.0 tinybench: 2.9.0 - tinyexec: 1.2.4 + tinyexec: 1.3.1 tinyglobby: 0.2.17 - tinyrainbow: 3.0.3 + tinyrainbow: 3.2.0 vite: 8.2.0(@types/node@22.15.17)(esbuild@0.28.2)(jiti@2.6.1)(tsx@3.12.10)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: @@ -28608,16 +28903,16 @@ snapshots: '@vitest/spy': 4.1.0 '@vitest/utils': 4.1.0 es-module-lexer: 2.3.1 - expect-type: 1.3.0 + expect-type: 1.4.0 magic-string: 0.30.21 obug: 2.1.4 pathe: 2.0.3 - picomatch: 4.0.5 - std-env: 4.0.0 + picomatch: 4.0.7 + std-env: 4.3.0 tinybench: 2.9.0 - tinyexec: 1.2.4 + tinyexec: 1.3.1 tinyglobby: 0.2.17 - tinyrainbow: 3.0.3 + tinyrainbow: 3.2.0 vite: 8.2.0(@types/node@22.15.17)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: @@ -28637,16 +28932,16 @@ snapshots: '@vitest/spy': 4.1.0 '@vitest/utils': 4.1.0 es-module-lexer: 2.3.1 - expect-type: 1.3.0 + expect-type: 1.4.0 magic-string: 0.30.21 obug: 2.1.4 pathe: 2.0.3 - picomatch: 4.0.5 - std-env: 4.0.0 + picomatch: 4.0.7 + std-env: 4.3.0 tinybench: 2.9.0 - tinyexec: 1.2.4 + tinyexec: 1.3.1 tinyglobby: 0.2.17 - tinyrainbow: 3.0.3 + tinyrainbow: 3.2.0 vite: 8.2.0(@types/node@22.15.17)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: @@ -28666,16 +28961,16 @@ snapshots: '@vitest/spy': 4.1.0 '@vitest/utils': 4.1.0 es-module-lexer: 2.3.1 - expect-type: 1.3.0 + expect-type: 1.4.0 magic-string: 0.30.21 obug: 2.1.4 pathe: 2.0.3 - picomatch: 4.0.5 - std-env: 4.0.0 + picomatch: 4.0.7 + std-env: 4.3.0 tinybench: 2.9.0 - tinyexec: 1.2.4 + tinyexec: 1.3.1 tinyglobby: 0.2.17 - tinyrainbow: 3.0.3 + tinyrainbow: 3.2.0 vite: 8.2.0(@types/node@22.15.17)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: From cb496a4ef8e973bbd1bf45c059aff34fe201ba6e Mon Sep 17 00:00:00 2001 From: James Date: Mon, 19 Oct 2026 20:17:48 +0100 Subject: [PATCH 2/5] [rsbuild-plugin] Close runtime gaps with the Vite plugin and run the playground e2es Move the rest of the Vite plugin's bundler-agnostic dev, preview and build logic into `@cloudflare/workers-dev-shared`, so the Rsbuild plugin gets the same Miniflare options, middlewares, asset handling, multi-Worker support, remote bindings, additional modules and Build Output as the Vite plugin. Run the Vite plugin's playground suite against the Rsbuild plugin without changing the specs: a harness translates each playground's Vite config, and Vite-specific playgrounds and tests are skipped with their reasons. --- .changeset/vite-plugin-api-plugin-config.md | 7 + .github/workflows/vite-plugin-playgrounds.yml | 56 ++ packages/rsbuild-plugin-cloudflare/README.md | 38 + .../src/__tests__/helpers.ts | 9 +- .../rsbuild-plugin-cloudflare/src/index.ts | 743 +++++++++++------ .../src/worker-environment.ts | 283 ++++++- .../tsdown.config.ts | 40 +- packages/vite-plugin-cloudflare/AGENTS.md | 7 + .../playground/__test-utils__/rsbuild.ts | 276 +++++++ .../playground/package.json | 8 +- .../playground/turbo.json | 14 + .../playground/vitest-setup.ts | 11 + .../playground/vitest.config.e2e.rsbuild.ts | 73 ++ .../__tests__/resolve-plugin-config.spec.ts | 6 +- .../src/__tests__/utils.spec.ts | 544 +------------ .../src/build-output.ts | 2 - packages/vite-plugin-cloudflare/src/build.ts | 25 +- .../vite-plugin-cloudflare/src/cf-vite.ts | 2 +- .../src/config-change-coordinator.ts | 4 +- .../vite-plugin-cloudflare/src/containers.ts | 2 +- packages/vite-plugin-cloudflare/src/debug.ts | 85 +- packages/vite-plugin-cloudflare/src/index.ts | 2 + .../src/miniflare-options.ts | 752 ++++-------------- .../src/nodejs-compat.ts | 19 - .../src/plugin-config.ts | 662 +-------------- .../src/plugins/additional-modules.ts | 56 +- .../src/plugins/build-output.ts | 2 +- .../src/plugins/debug.ts | 38 +- .../vite-plugin-cloudflare/src/plugins/dev.ts | 89 +-- .../src/plugins/nodejs-compat.ts | 3 +- .../src/plugins/preview.ts | 12 +- .../src/plugins/tunnel.ts | 3 +- .../src/plugins/wasm.ts | 10 +- packages/vite-plugin-cloudflare/src/utils.ts | 275 +------ .../vite-plugin-cloudflare/tsdown.config.ts | 15 +- packages/workers-dev-shared/package.json | 14 +- .../src/__tests__/asset-config.spec.ts | 6 +- .../src/__tests__/build-output-env.spec.ts | 0 .../__tests__/build-output-preview.spec.ts | 0 .../src/__tests__/remote-bindings.spec.ts | 0 .../src/__tests__/request-handler.spec.ts | 541 +++++++++++++ .../src/__tests__/websockets.spec.ts | 6 +- .../src/additional-modules.ts | 93 +++ .../src/asset-config.ts | 32 +- .../src/build-output-env.ts | 0 .../src/build-output-preview.ts | 2 +- .../workers-dev-shared/src/build-output.ts | 42 + .../src/constants.ts | 0 packages/workers-dev-shared/src/debug.ts | 121 +++ .../src/dev-asset-workers.ts | 178 +++++ .../workers-dev-shared/src/dev-middleware.ts | 101 +++ packages/workers-dev-shared/src/index.ts | 90 +++ packages/workers-dev-shared/src/logger.ts | 6 + .../src/miniflare-logger.ts | 113 +++ .../src/miniflare-options.ts | 217 +++++ .../workers-dev-shared/src/nodejs-compat.ts | 17 + .../workers-dev-shared/src/plugin-config.ts | 666 ++++++++++++++++ .../src/remote-bindings.ts | 122 ++- .../workers-dev-shared/src/request-handler.ts | 276 +++++++ .../src/websockets.ts | 34 +- .../src/workers/asset-worker/index.ts | 13 +- .../src/workers/router-worker/index.ts | 0 packages/workers-dev-shared/tsconfig.json | 5 +- .../workers-dev-shared/tsconfig.worker.json | 7 + pnpm-lock.yaml | 312 +++++++- 65 files changed, 4547 insertions(+), 2640 deletions(-) create mode 100644 .changeset/vite-plugin-api-plugin-config.md create mode 100644 packages/rsbuild-plugin-cloudflare/README.md create mode 100644 packages/vite-plugin-cloudflare/playground/__test-utils__/rsbuild.ts create mode 100644 packages/vite-plugin-cloudflare/playground/vitest.config.e2e.rsbuild.ts delete mode 100644 packages/vite-plugin-cloudflare/src/build-output.ts rename packages/{vite-plugin-cloudflare => workers-dev-shared}/src/__tests__/asset-config.spec.ts (81%) rename packages/{vite-plugin-cloudflare => workers-dev-shared}/src/__tests__/build-output-env.spec.ts (100%) rename packages/{vite-plugin-cloudflare => workers-dev-shared}/src/__tests__/build-output-preview.spec.ts (100%) rename packages/{vite-plugin-cloudflare => workers-dev-shared}/src/__tests__/remote-bindings.spec.ts (100%) create mode 100644 packages/workers-dev-shared/src/__tests__/request-handler.spec.ts rename packages/{vite-plugin-cloudflare => workers-dev-shared}/src/__tests__/websockets.spec.ts (98%) create mode 100644 packages/workers-dev-shared/src/additional-modules.ts rename packages/{vite-plugin-cloudflare => workers-dev-shared}/src/asset-config.ts (81%) rename packages/{vite-plugin-cloudflare => workers-dev-shared}/src/build-output-env.ts (100%) rename packages/{vite-plugin-cloudflare => workers-dev-shared}/src/build-output-preview.ts (97%) create mode 100644 packages/workers-dev-shared/src/build-output.ts rename packages/{vite-plugin-cloudflare => workers-dev-shared}/src/constants.ts (100%) create mode 100644 packages/workers-dev-shared/src/debug.ts create mode 100644 packages/workers-dev-shared/src/dev-asset-workers.ts create mode 100644 packages/workers-dev-shared/src/dev-middleware.ts create mode 100644 packages/workers-dev-shared/src/logger.ts create mode 100644 packages/workers-dev-shared/src/miniflare-logger.ts create mode 100644 packages/workers-dev-shared/src/miniflare-options.ts create mode 100644 packages/workers-dev-shared/src/plugin-config.ts rename packages/{vite-plugin-cloudflare => workers-dev-shared}/src/remote-bindings.ts (57%) create mode 100644 packages/workers-dev-shared/src/request-handler.ts rename packages/{vite-plugin-cloudflare => workers-dev-shared}/src/websockets.ts (82%) rename packages/{vite-plugin-cloudflare => workers-dev-shared}/src/workers/asset-worker/index.ts (86%) rename packages/{vite-plugin-cloudflare => workers-dev-shared}/src/workers/router-worker/index.ts (100%) create mode 100644 packages/workers-dev-shared/tsconfig.worker.json diff --git a/.changeset/vite-plugin-api-plugin-config.md b/.changeset/vite-plugin-api-plugin-config.md new file mode 100644 index 00000000000..d88941b7df6 --- /dev/null +++ b/.changeset/vite-plugin-api-plugin-config.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/vite-plugin": patch +--- + +Expose the options passed to `cloudflare()` as `api.pluginConfig` on the plugin + +Tools that load a project's Vite config can now read the Cloudflare plugin's options from the `vite-plugin-cloudflare` plugin's `api.pluginConfig`, without re-parsing the config file. This is used to run the Vite plugin's playground tests against `@cloudflare/rsbuild-plugin`. diff --git a/.github/workflows/vite-plugin-playgrounds.yml b/.github/workflows/vite-plugin-playgrounds.yml index e9cbc99c959..2976fc8b9ff 100644 --- a/.github/workflows/vite-plugin-playgrounds.yml +++ b/.github/workflows/vite-plugin-playgrounds.yml @@ -131,3 +131,59 @@ jobs: with: name: turbo-runs-${{ matrix.os }}-${{ matrix.vite }} path: .turbo/runs + + # The playground suite against `@cloudflare/rsbuild-plugin`, whose harness + # translates each playground's Vite config. These steps run without `--only`, + # so turbo builds the plugins and keys the cache on their dependency graph. + test-rsbuild: + timeout-minutes: 45 + name: Vite Plugin Playground (ubuntu-latest, rsbuild) + runs-on: ubuntu-latest + env: + CI_OS: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - name: Filter changed paths + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: changes + with: + filters: | + everything_but_markdown: + - '!**/*.md' + - name: Install Dependencies + if: steps.changes.outputs.everything_but_markdown == 'true' + uses: ./.github/actions/install-dependencies + with: + turbo-api: ${{ secrets.TURBO_API }} + turbo-team: ${{ secrets.TURBO_TEAM }} + turbo-token: ${{ secrets.TURBO_TOKEN }} + turbo-signature: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} + - name: Install playwright + if: steps.changes.outputs.everything_but_markdown == 'true' + run: | + pnpm --filter @vite-plugin-cloudflare/playground playwright:install + - name: Run dev playground tests + if: steps.changes.outputs.everything_but_markdown == 'true' + run: | + pnpm dotenv -- pnpm turbo test:ci:rsbuild:serve --filter @vite-plugin-cloudflare/playground + env: + NODE_OPTIONS: "--max_old_space_size=8192" + WRANGLER_LOG_PATH: ${{ runner.temp }}/wrangler-debug-logs/ + TEST_REPORT_PATH: ${{ runner.temp }}/test-report/index.html + - name: Run build/preview playground tests + if: steps.changes.outputs.everything_but_markdown == 'true' + run: | + pnpm dotenv -- pnpm turbo test:ci:rsbuild:build --filter @vite-plugin-cloudflare/playground + env: + NODE_OPTIONS: "--max_old_space_size=8192" + WRANGLER_LOG_PATH: ${{ runner.temp }}/wrangler-debug-logs/ + TEST_REPORT_PATH: ${{ runner.temp }}/test-report/index.html + - name: Upload turbo logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: turbo-runs-ubuntu-latest-rsbuild + path: .turbo/runs diff --git a/packages/rsbuild-plugin-cloudflare/README.md b/packages/rsbuild-plugin-cloudflare/README.md new file mode 100644 index 00000000000..37c54b1a1fa --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/README.md @@ -0,0 +1,38 @@ +# `@cloudflare/rsbuild-plugin` + +> [!WARNING] +> This is an experimental prototype. It is not published. + +Runs your Worker in workerd during `rsbuild dev` and `rsbuild preview`, and writes Cloudflare Build Output from `rsbuild build`. + +```ts +// rsbuild.config.ts +import { pluginCloudflare } from "@cloudflare/rsbuild-plugin"; +import { defineConfig } from "@rsbuild/core"; + +export default defineConfig({ + plugins: [pluginCloudflare()], +}); +``` + +The plugin takes the same options as `cloudflare()` from `@cloudflare/vite-plugin`, except `tunnel` and `viteEnvironment`, plus `envDir` and `envMode` for loading `.env` files. It shares its configuration, bindings, assets, Node.js compatibility and Build Output handling with the Vite plugin through `@cloudflare/workers-dev-shared`, and keeps only the Rsbuild glue itself. + +Each Worker is bundled in its own Rsbuild environment (`cloudflare` for the entry Worker), and the user's environments are the client. + +## Not supported + +- Hot module replacement inside the Worker. Changes to the Worker rebuild it and update workerd. +- Containers. +- Prerendering. +- The tunnel, CLI shortcuts and `cf` CLI integration. + +## Tests + +Besides this package's unit tests, the Vite plugin's playground suite runs against this plugin: + +```sh +pnpm --filter @vite-plugin-cloudflare/playground test:ci:rsbuild:serve +pnpm --filter @vite-plugin-cloudflare/playground test:ci:rsbuild:build +``` + +See `packages/vite-plugin-cloudflare/playground/vitest.config.e2e.rsbuild.ts` for the playgrounds and tests that are skipped, and why. diff --git a/packages/rsbuild-plugin-cloudflare/src/__tests__/helpers.ts b/packages/rsbuild-plugin-cloudflare/src/__tests__/helpers.ts index 6ae0cb8bdac..74e3c140823 100644 --- a/packages/rsbuild-plugin-cloudflare/src/__tests__/helpers.ts +++ b/packages/rsbuild-plugin-cloudflare/src/__tests__/helpers.ts @@ -1,10 +1,11 @@ import * as fs from "node:fs"; import * as path from "node:path"; +// The built plugin, which reads the internal Workers bundled alongside it. +import { pluginCloudflare } from "@cloudflare/rsbuild-plugin"; import { removeDirSync } from "@cloudflare/workers-utils"; import { createRsbuild } from "@rsbuild/core"; import getPort from "get-port"; import { onTestFinished } from "vitest"; -import { pluginCloudflare } from "../index"; import type { RsbuildConfig } from "@rsbuild/core"; /** @@ -32,7 +33,11 @@ function createFixtureRsbuild(root: string, config: RsbuildConfig) { config: { ...config, plugins: [ - pluginCloudflare({ persistState: false, types: { generate: false } }), + pluginCloudflare({ + persistState: false, + inspectorPort: false, + types: { generate: false }, + }), ], logLevel: "warn", }, diff --git a/packages/rsbuild-plugin-cloudflare/src/index.ts b/packages/rsbuild-plugin-cloudflare/src/index.ts index 9a0cc03818a..2acc301ecfc 100644 --- a/packages/rsbuild-plugin-cloudflare/src/index.ts +++ b/packages/rsbuild-plugin-cloudflare/src/index.ts @@ -1,72 +1,86 @@ +import assert from "node:assert"; +import * as fs from "node:fs"; +import * as fsp from "node:fs/promises"; import * as path from "node:path"; import { BUILD_OUTPUT_ROOT, cleanBuildOutputDir, DEFAULT_WORKER_DIRECTORY_NAME, getWorkerAssetsDir, - getWorkerBundleDir, - readBuildOutput, writeRootConfig, writeWorkerConfig, } from "@cloudflare/build-output-utils"; import { + createDebugMiddleware, createMiniflareExports, - getMiniflareManifest, - getPersistenceRoot, - loadCloudflareConfig, + createRequestHandler, + createStaticRoutingMiddleware, + DEBUG_PATH, + DevMiniflareLogger, + dispatchDevRequest, + getAssetsConfig, + getCommonMiniflareOptions, + getDevAssetWorkerOptions, + getPreviewMiniflareOptions, + getRemoteProxySessionData, + getWorkerZone, + handleWebSocket, + isPreviewBuild, + kRequestType, + moveImportedAssets, + readBuildOutputPreview, + resolveDevOnly, + resolveInspectorPort, resolveLocalBindingsByWorkerName, + resolvePluginConfig, + resolveStaticRouting, warnForMissingSecrets, } from "@cloudflare/workers-dev-shared"; -import { loadDevVars, loadEnv } from "@cloudflare/workers-utils/local-env"; -import { createRequest, sendResponse } from "@remix-run/node-fetch-server"; -import { logger } from "@rsbuild/core"; import { - getDefaultDevRegistryPath, - Log, - Miniflare, - Request as MiniflareRequest, -} from "miniflare"; + getDevVarsCandidatePaths, + getEnvPaths, +} from "@cloudflare/workers-utils/local-env"; +import { CoreHeaders, Miniflare } from "miniflare"; import { + getAdditionalModuleTypes, + getImportedAssetPaths, getManifestFromCompilation, getWorkerEnvironmentConfig, removeUnemittedFiles, - WORKER_ENTRY_NAME, } from "./worker-environment"; import type { - ParsedInputWorkerConfig, - ParsedOutputWorkerConfig, -} from "@cloudflare/config"; -import type { PersistState } from "@cloudflare/workers-dev-shared"; + DevHtmlHandlers, + IncomingMessage, + PluginConfig, + ResolvedPluginConfig, + Worker, +} from "@cloudflare/workers-dev-shared"; import type { - RequestHandler, + EnvironmentContext, + PublicDir, RsbuildDevServer, RsbuildPlugin, } from "@rsbuild/core"; import type { MiniflareOptions, WorkerOptions } from "miniflare"; -import type { IncomingMessage } from "node:http"; -export interface PluginCloudflareOptions { - /** Whether to persist local binding state between dev sessions. Defaults to `true`. */ - persistState?: PersistState; - /** Options for generating `.cloudflare/types/index.d.ts`. */ - types?: { generate?: boolean; includeRuntime?: boolean }; +/** The same options as the Vite plugin's `cloudflare()`, apart from the tunnel. */ +export interface PluginCloudflareOptions extends Omit { + /** The directory to load `.env` and `.dev.vars` files from, or `false` to skip them. Defaults to the project root. */ + envDir?: string | false; + /** The mode used to load `cloudflare.config.ts`, `.env.[mode]` and `.dev.vars.[mode]`. Defaults to Rsbuild's mode. */ + envMode?: string; } -/** The Rsbuild environment that builds the Worker. */ +/** The Rsbuild environment that builds the entry Worker. */ const ENVIRONMENT_NAME = "cloudflare"; -/** Marks requests that the Worker's assets binding forwarded to Rsbuild. */ -const kAssetRequest = Symbol("kAssetRequest"); - -type MiniflareWorkerConfig = WorkerOptions["config"]; -type MiniflareManifest = NonNullable; -type LoadedConfig = Awaited>; +type MiniflareManifest = NonNullable; /** - * Rsbuild plugin that builds the Worker defined in `cloudflare.config.ts` with - * rspack and runs it in workerd (via Miniflare) during `rsbuild dev`. - * `rsbuild build` writes the Build Output Specification to `.cloudflare/output`, - * which `rsbuild preview` runs in workerd. + * Rsbuild plugin that builds the Workers defined in `cloudflare.config.ts` and + * the plugin options with rspack and runs them in workerd (via Miniflare) + * during `rsbuild dev`. `rsbuild build` writes the Build Output Specification + * to `.cloudflare/output`, which `rsbuild preview` runs in workerd. * * @param options Plugin options. * @returns An Rsbuild plugin. @@ -78,33 +92,58 @@ export function pluginCloudflare( name: "rsbuild:cloudflare", setup(api) { const { action, rootPath: root } = api.context; - const isPreview = process.env.CLOUDFLARE_PREVIEW_BUILD === "true"; let mode = "development"; - let loadedConfig: LoadedConfig; + let resolvedPluginConfig: ResolvedPluginConfig; + /** The Workers to bundle, keyed by Rsbuild environment name, with the entry Worker first. */ + const workerEnvironments = new Map(); + /** The latest bundles, keyed by Rsbuild environment name. */ + const manifests = new Map(); + let manifestsChanged = false; + /** The assets that the entry Worker imports, which a build moves to the client output. */ + let importedAssets: { distPath: string; paths: string[] } | undefined; + /** The Worker environments whose latest dev build failed. */ + const failedEnvironments = new Set(); let devServer: RsbuildDevServer | undefined; + let environmentContexts: Record = {}; let miniflare: Miniflare | undefined; - let resolveMiniflare: (miniflare: Miniflare) => void = () => {}; - const miniflareReady = new Promise((resolve) => { - resolveMiniflare = resolve; + let inspectorPort: number | false = false; + /** Why Miniflare last failed to start or update, which requests report. */ + let miniflareError: unknown; + let settleFirstStart: () => void = () => {}; + /** Settles once Miniflare has first started, or failed to. */ + const firstStart = new Promise((resolve) => { + settleFirstStart = resolve; }); - - function getWorkerConfig(): ParsedInputWorkerConfig & { - entrypoint: string; - } { - const worker = loadedConfig?.parsedConfig.worker; - if (worker?.entrypoint === undefined) { - throw new Error( - "The Cloudflare Rsbuild plugin requires a `cloudflare.config.ts` that defines `worker.entrypoint`." - ); - } - return { - ...worker, - entrypoint: path.resolve(root, worker.entrypoint), - }; - } + let miniflareLogger: DevMiniflareLogger; api.modifyRsbuildConfig(async (userConfig, { mergeRsbuildConfig }) => { - if (action === "preview") { + miniflareLogger = new DevMiniflareLogger( + api.logger, + userConfig.logLevel + ); + mode = + options.envMode ?? + userConfig.mode ?? + (action === "dev" ? "development" : "production"); + const envDir = + options.envDir === false + ? false + : path.resolve(root, options.envDir ?? "."); + const preview = + action === "preview" + ? await readBuildOutputPreview(root, false) + : undefined; + // Load the same `.env` files as the build. + mode = preview?.rootConfig.buildContext.mode ?? mode; + resolvedPluginConfig = await resolvePluginConfig(options, { + root, + mode, + envDir, + command: action === "build" ? "build" : "serve", + preview, + }); + + if (resolvedPluginConfig.type === "preview") { // Preview runs the Build Output rather than the project's sources, // and Rsbuild checks that this directory exists before starting. return mergeRsbuildConfig(userConfig, { @@ -112,68 +151,133 @@ export function pluginCloudflare( }); } - mode = - userConfig.mode ?? (action === "dev" ? "development" : "production"); - loadedConfig = await loadCloudflareConfig({ - root, - mode, - isPreview, - types: { - generate: options.types?.generate ?? true, - includeRuntime: options.types?.includeRuntime ?? true, - }, - }); + for (const worker of resolvedPluginConfig.environmentNameToWorkerMap.values()) { + if (action !== "build" || !resolveDevOnly(worker.devOnly)) { + workerEnvironments.set(getEnvironmentName(worker), worker); + } + } // Without explicit environments (Rsbuild defaults them to `{}`), Rsbuild // builds a single `web` environment from the top-level config, so keep // it when there is a client entry. - const hasEnvironments = - Object.keys(userConfig.environments ?? {}).length > 0; - const environments = hasEnvironments - ? userConfig.environments - : userConfig.source?.entry - ? { web: {} } - : {}; + const clientEnvironments = Object.fromEntries( + Object.entries(userConfig.environments ?? {}).filter( + ([name]) => !workerEnvironments.has(name) + ) + ); + const environments = + Object.keys(clientEnvironments).length > 0 + ? clientEnvironments + : userConfig.source?.entry + ? { web: {} } + : {}; return mergeRsbuildConfig(userConfig, { environments: { ...environments, - [ENVIRONMENT_NAME]: getWorkerEnvironmentConfig( - getWorkerConfig(), - getWorkerBundleDir(root) + ...Object.fromEntries( + [...workerEnvironments].map(([name, worker]) => [ + name, + // The user's environment config overrides the defaults. + mergeRsbuildConfig( + getWorkerEnvironmentConfig(worker, root), + userConfig.environments?.[name] ?? {} + ), + ]) ), }, dev: { watchFiles: { - paths: loadedConfig - ? [loadedConfig.configPath, ...loadedConfig.dependencies] - : [], + paths: [ + ...resolvedPluginConfig.configPaths, + ...getEnvPaths(envDir, mode), + ...getDevVarsCandidatePaths(envDir, mode), + ...(resolvedPluginConfig.experimental + .headersAndRedirectsDevModeSupport + ? getPublicDirs(userConfig.server?.publicDir, root).flatMap( + (dir) => [ + path.join(dir, "_headers"), + path.join(dir, "_redirects"), + ] + ) + : []), + ], type: "reload-server", }, }, + // The asset Worker handles HTML requests, as when deployed. + server: { htmlFallback: false }, }); }); api.modifyEnvironmentConfig((config, { name }) => { - if (action === "preview") { - return; - } - if (name === ENVIRONMENT_NAME) { + const worker = workerEnvironments.get(name); + if (worker) { // Top-level entries are merged into every environment, so replace // them to keep client entries out of the Worker. config.source.entry = getWorkerEnvironmentConfig( - getWorkerConfig(), - getWorkerBundleDir(root) + worker, + root ).source?.entry; - } else if (action === "build" && config.output.target === "web") { + } else if ( + action === "build" && + resolvedPluginConfig.type !== "preview" && + config.output.target === "web" + ) { config.output.distPath.root = getWorkerAssetsDir(root); } }); - api.onBeforeStartDevServer(({ server }) => { + api.onBeforeStartDevServer(({ server, environments }) => { devServer = server; + environmentContexts = environments; + addServerHandlers( + server, + [...workerEnvironments.values()].map(({ config }) => config.name) + ); + const entryWorker = workerEnvironments.get(ENVIRONMENT_NAME); + const staticRouting = + entryWorker && resolvedPluginConfig.type === "workers" + ? resolveStaticRouting( + entryWorker.config, + resolvedPluginConfig.staticRouting + ) + : undefined; + if (entryWorker) { + const userWorkerHandler = createRequestHandler(async (request) => { + request.headers.set( + CoreHeaders.ROUTE_OVERRIDE, + entryWorker.config.name + ); + return (await getMiniflare()).dispatchFetch(request, { + redirect: "manual", + }); + }); + // Local trigger routes (e.g. `/cdn-cgi/local/scheduled`) must reach + // the entry Worker rather than the router Worker. + server.middlewares.use( + "/cdn-cgi/local/", + (req, res, next) => void userWorkerHandler(req, res, next) + ); + if (staticRouting) { + server.middlewares.use( + createStaticRoutingMiddleware( + staticRouting, + (req, res, next) => void userWorkerHandler(req, res, next) + ) + ); + } + } + // Rsbuild would serve its HTML pages itself, so send page requests to + // the router Worker first. + const hmrPath = api.getNormalizedConfig().dev.client.path; + server.middlewares.use((req, res, next) => + isPageRequest(req, hmrPath) + ? void workerMiddleware(req, res, next) + : next() + ); // Runs after Rsbuild's built-in middleware, so client assets are - // served first and every other request is handled by the Worker. + // served first and every other request is handled by the router Worker. return () => { server.middlewares.use(workerMiddleware); }; @@ -193,27 +297,56 @@ export function pluginCloudflare( }); api.onAfterEnvironmentCompile(async ({ environment, stats }) => { - if ( - environment.name !== ENVIRONMENT_NAME || - !stats || - stats.hasErrors() - ) { + if (!workerEnvironments.has(environment.name) || !stats) { + return; + } + if (stats.hasErrors()) { + failedEnvironments.add(environment.name); return; } + failedEnvironments.delete(environment.name); + manifests.set(environment.name, { + ...getManifestFromCompilation(stats.compilation), + modulesRoot: environment.distPath, + }); + manifestsChanged = true; if (action === "build") { await removeUnemittedFiles(environment.distPath, stats.compilation); + if (environment.name === ENVIRONMENT_NAME) { + importedAssets = { + distPath: environment.distPath, + paths: getImportedAssetPaths(stats.compilation), + }; + } + } + }); + + api.onAfterDevCompile(async () => { + if (failedEnvironments.size > 0) { + miniflareError = new Error( + "The Worker failed to build. See the dev server output for details." + ); + settleFirstStart(); return; } - if (action !== "dev") { + // Miniflare runs every Worker, so wait until they have all built, and + // don't restart it when only client code changed. + if ( + (miniflare && !manifestsChanged) || + manifests.size < workerEnvironments.size + ) { return; } - - await startOrUpdateMiniflare( - await getDevMiniflareOptions({ - ...getManifestFromCompilation(stats.compilation), - modulesRoot: environment.distPath, - }) - ); + manifestsChanged = false; + try { + await startOrUpdateMiniflare(await getDevMiniflareOptions()); + miniflareError = undefined; + } catch (error) { + // Keep the dev server running, so that fixing the Worker retries. + miniflareError = error; + api.logger.error(error); + } + settleFirstStart(); }); api.onCloseDevServer(async () => { @@ -221,32 +354,88 @@ export function pluginCloudflare( }); api.onBeforeStartPreviewServer(async ({ server }) => { - await startOrUpdateMiniflare(await getPreviewMiniflareOptions()); + if (resolvedPluginConfig.type !== "preview") { + return; + } + inspectorPort = await getInspectorPort(); + const { miniflareOptions } = await getPreviewMiniflareOptions({ + resolvedPluginConfig, + root, + mode, + logger: api.logger, + miniflareLogger, + inspectorPort, + publicUrl: undefined, + }); + await startOrUpdateMiniflare(miniflareOptions); + settleFirstStart(); // Rsbuild has no preview close hook, so dispose with the HTTP server. server.httpServer?.on("close", () => void miniflare?.dispose()); + addServerHandlers( + server, + miniflareOptions.workers.map(({ config }) => config.name) + ); // Added before Rsbuild's built-in middleware, so that Miniflare serves // the assets as well, as it would when deployed. server.middlewares.use(workerMiddleware); }); - api.onAfterBuild(async () => { - await writeWorkerConfig({ - root, - config: getWorkerConfig(), - manifest: { - type: "partial", - mainModule: `${WORKER_ENTRY_NAME}.js`, - modules: {}, - }, + api.onAfterBuild(async ({ stats }) => { + if (resolvedPluginConfig.type === "preview" || stats?.hasErrors()) { + return; + } + const assetsDir = getWorkerAssetsDir(root); + if (!fs.existsSync(assetsDir)) { + // Without a client entry there's no web environment to copy + // `public/` into the assets directory, as Vite's client build does. + for (const { name, copyOnBuild } of api.getNormalizedConfig().server + .publicDir) { + const dir = path.resolve(root, name); + if (copyOnBuild && fs.existsSync(dir)) { + await fsp.cp(dir, assetsDir, { recursive: true }); + } + } + } + if (importedAssets) { + // As in the Vite plugin, serve assets that the entry Worker imports. + moveImportedAssets( + importedAssets.paths, + importedAssets.distPath, + assetsDir + ); + } + if (resolvedPluginConfig.type === "assets-only") { + await writeWorkerConfig({ + root, + config: resolvedPluginConfig.config, + }); + } + for (const worker of resolvedPluginConfig.environmentNameToWorkerMap.values()) { + if (!resolveDevOnly(worker.devOnly)) { + const manifest = manifests.get(getEnvironmentName(worker)); + assert( + manifest, + `Expected Worker "${worker.config.name}" to be built` + ); + await writeWorkerConfig({ + root, + config: worker.config, + manifest: { + type: "partial", + mainModule: manifest.mainModule, + modules: getAdditionalModuleTypes(manifest.modules), + }, + directoryName: worker.directoryName, + }); + } else if (worker.directoryName === DEFAULT_WORKER_DIRECTORY_NAME) { + // `assetsOnly`: the entry Worker runs in dev but deploys as assets. + await writeWorkerConfig({ root, config: worker.config }); + } + } + await writeRootConfig(root, resolvedPluginConfig.settings, { + isPreview: isPreviewBuild(), + mode, }); - await writeRootConfig( - root, - { - accountId: loadedConfig?.parsedConfig.accountId, - complianceRegion: loadedConfig?.parsedConfig.complianceRegion, - }, - { isPreview, mode } - ); }); async function startOrUpdateMiniflare( @@ -257,142 +446,238 @@ export function pluginCloudflare( } else { miniflare = new Miniflare(miniflareOptions); await miniflare.ready; - resolveMiniflare(miniflare); } } - /** Forwards a request to the Worker running in Miniflare. */ - const workerMiddleware: RequestHandler = async (req, res, next) => { - if (kAssetRequest in req) { - return next(); + async function getMiniflare(): Promise { + await firstStart; + if (miniflareError || !miniflare) { + throw miniflareError; } + return miniflare; + } - try { - const request = createRequest(req, res); - const response = await ( - await miniflareReady - ).dispatchFetch( - new MiniflareRequest(request.url, { - method: request.method, - // The body is streamed back as-is, so ask for it uncompressed. - headers: [["accept-encoding", "identity"], ...request.headers], - body: request.body, - duplex: "half", - signal: request.signal, + /** Forwards a request to the router Worker, or to the asset Worker for asset requests. */ + const workerMiddleware = createRequestHandler(async (request, req) => + action === "dev" + ? dispatchDevRequest(await getMiniflare(), request, req) + : (await getMiniflare()).dispatchFetch(request, { redirect: "manual", }) - ); - await sendResponse(res, response as unknown as Response); - } catch (error) { - next(error); - } + ); + + /** Reads HTML pages from Rsbuild for the asset Worker. */ + const htmlHandlers: DevHtmlHandlers = { + async htmlExists(pathname) { + return findHtmlEntry(pathname) || findPublicFile(pathname) + ? pathname + : null; + }, + async fetchHtml(pathname) { + const entry = findHtmlEntry(pathname); + const environment = + entry && devServer?.environments[entry.environmentName]; + if (entry && environment) { + return environment.getTransformedHtml(entry.entryName); + } + const publicFile = findPublicFile(pathname); + if (!publicFile) { + throw new Error(`No HTML found for "${pathname}".`); + } + return fsp.readFile(publicFile, "utf8"); + }, }; - async function getDevMiniflareOptions( - manifest: MiniflareManifest - ): Promise { - const { - entrypoint: _entrypoint, - assets: _assets, - ...config - } = getWorkerConfig(); - const env = await getLocalBindings(config); - // Rsbuild serves the client assets in dev, so the assets binding is - // forwarded to its middleware. - for (const [name, binding] of Object.entries(env)) { - if (binding.type === "assets") { - env[name] = { - type: "node-handler", - handler: (req: IncomingMessage, res) => { - Object.assign(req, { [kAssetRequest]: true }); - devServer?.middlewares(req, res); - }, - }; + function findHtmlEntry( + pathname: string + ): { environmentName: string; entryName: string } | undefined { + const { base } = api.getNormalizedConfig().server; + for (const [environmentName, { htmlPaths }] of Object.entries( + environmentContexts + )) { + for (const [entryName, htmlPath] of Object.entries(htmlPaths)) { + if (path.posix.join(base, htmlPath) === pathname) { + return { environmentName, entryName }; + } } } - return getMiniflareOptions({ ...config, env, manifest }); } - async function getPreviewMiniflareOptions(): Promise { - const { workers, rootConfig } = await readBuildOutput(root); - const worker = workers[DEFAULT_WORKER_DIRECTORY_NAME]; - const workerManifest = worker?.config.manifest; - if (!worker?.bundleDir || !workerManifest) { - throw new Error( - "No Worker found in the Build Output. Run `rsbuild build` before `rsbuild preview`." + function findPublicFile(pathname: string): string | undefined { + return getPublicDirs(api.getNormalizedConfig().server.publicDir, root) + .map((dir) => path.join(dir, pathname)) + .find((file) => + fs.statSync(file, { throwIfNoEntry: false })?.isFile() + ); + } + + /** Adds the `/__debug` page and forwards the Workers' WebSockets. */ + function addServerHandlers( + server: Pick, + workerNames: string[] + ): void { + server.middlewares.use( + DEBUG_PATH, + createDebugMiddleware(workerNames, async () => + inspectorPort === false || !miniflare + ? null + : Number((await miniflare.getInspectorURL()).port) + ) + ); + const { httpServer } = server; + if (httpServer) { + // Rsbuild handles its own HMR upgrades on `dev.client.path`. + const hmrPath = api.getNormalizedConfig().dev.client.path; + void firstStart.then( + () => + miniflare && + handleWebSocket(httpServer, miniflare, { + isBundlerUpgrade: (_request, url) => url.pathname === hmrPath, + }) ); } - // Load the same `.env` files as the build. - mode = rootConfig.buildContext.mode ?? "production"; - const { manifest: _manifest, assets, ...config } = worker.config; - return getMiniflareOptions({ - ...config, - env: await getLocalBindings(config), - manifest: await getMiniflareManifest({ - ...workerManifest, - rootPath: worker.bundleDir, - }), - assets: worker.assetsDir - ? { ...assets, directory: worker.assetsDir, hasUserWorker: true } - : undefined, + } + + function getInspectorPort(): Promise { + return resolveInspectorPort({ + inspectorPort: resolvedPluginConfig.inspectorPort, + getPreviousPort: async () => + miniflare ? Number((await miniflare.getInspectorURL()).port) : null, + logger: api.logger, }); } - /** Resolves the Worker's bindings, including values from `.env` and `.dev.vars` files. */ - async function getLocalBindings( - config: Pick< - ParsedInputWorkerConfig | ParsedOutputWorkerConfig, - "name" | "env" - > - ): Promise> { - const [localEnv, devVars] = await Promise.all([ - loadEnv(root, mode), - loadDevVars(root, mode), - ]); + async function getDevMiniflareOptions(): Promise { + if (resolvedPluginConfig.type === "preview") { + throw new Error("Unexpected preview config in dev."); + } + inspectorPort = await getInspectorPort(); + const entryWorkerConfig = + workerEnvironments.get(ENVIRONMENT_NAME)?.config; + const { headers } = api.getNormalizedConfig().server; + const assetWorkers = await getDevAssetWorkerOptions({ + baseUrl: import.meta.url, + entryWorkerConfig, + hasUserWorker: resolvedPluginConfig.type === "workers", + assetsConfig: getAssetsConfig( + resolvedPluginConfig, + entryWorkerConfig, + { + publicDir: + getPublicDirs( + api.getNormalizedConfig().server.publicDir, + root + )[0] ?? path.join(root, "public"), + logger: api.logger, + } + ), + headers, + html: htmlHandlers, + }); const localBindings = resolveLocalBindingsByWorkerName( - [config], - localEnv, - devVars + [...workerEnvironments.values()].map(({ config }) => config), + resolvedPluginConfig.localEnv, + resolvedPluginConfig.devVars + ); + warnForMissingSecrets(localBindings, mode, api.logger); + + const workers = await Promise.all( + [...workerEnvironments].map( + async ([name, { config: workerConfig }]) => { + const { + entrypoint: _entrypoint, + assets: _assets, + exports, + ...config + } = workerConfig; + const env = { ...localBindings.get(config.name)?.bindings }; + // Rsbuild serves the client assets in dev, so the assets binding is + // forwarded to its middleware. + for (const [bindingName, binding] of Object.entries(env)) { + if (binding.type === "assets") { + env[bindingName] = { + type: "node-handler", + handler: (req, res) => { + req[kRequestType] = "asset"; + devServer?.middlewares(req, res); + }, + }; + } + } + const remoteProxySessionData = await getRemoteProxySessionData( + workerConfig, + { ...resolvedPluginConfig, root, logger: api.logger } + ); + + return { + config: { + ...config, + env, + manifest: manifests.get(name), + // Containers aren't supported yet, so Container-backed exports throw. + exports: createMiniflareExports({ + exports, + containerRuntimeOptions: undefined, + }), + }, + dev: { + zone: getWorkerZone(config), + remoteProxyConnectionString: + remoteProxySessionData?.session + ?.remoteProxyConnectionString, + unsafeInspectorProxy: inspectorPort !== false, + }, + } satisfies WorkerOptions; + } + ) ); - warnForMissingSecrets(localBindings, mode, logger); - return { ...localBindings.get(config.name)?.bindings }; - } - function getMiniflareOptions({ - exports, - ...config - }: Omit & { - exports?: - | ParsedInputWorkerConfig["exports"] - | ParsedOutputWorkerConfig["exports"]; - }): MiniflareOptions { return { - log: new RsbuildMiniflareLog(), - unsafeDevRegistryPath: getDefaultDevRegistryPath(), - resourcePersistencePath: getPersistenceRoot( + ...getCommonMiniflareOptions({ root, - options.persistState ?? true - ), - workers: [ - { - config: { - ...config, - // Containers aren't supported yet, so Container-backed exports throw. - exports: createMiniflareExports({ - exports, - containerRuntimeOptions: undefined, - }), - }, - }, - ], + persistState: resolvedPluginConfig.persistState, + logger: miniflareLogger, + inspectorPort, + publicUrl: undefined, + }), + workers: [...assetWorkers, ...workers], }; } }, }; } -class RsbuildMiniflareLog extends Log { - override logReady() { - // Rsbuild prints its own server URLs. +/** Resolves Rsbuild's `server.publicDir` option to absolute directories. */ +function getPublicDirs( + publicDir: PublicDir | undefined, + root: string +): string[] { + if (publicDir === false) { + return []; } + return [publicDir ?? {}] + .flat() + .map(({ name = "public" }) => path.resolve(root, name)); +} + +/** + * Whether a request is for a page, which Rsbuild would otherwise serve itself: + * a `GET` or `HEAD` for an HTML file or an extension-less path, other than + * Rsbuild's own routes. + */ +function isPageRequest(req: IncomingMessage, hmrPath: string): boolean { + const { pathname } = new URL(req.url ?? "/", "http://localhost"); + return ( + (req.method === "GET" || req.method === "HEAD") && + pathname !== hmrPath && + !/^\/(__|rsbuild-)/.test(pathname) && + (pathname.endsWith(".html") || path.posix.extname(pathname) === "") + ); +} + +/** The entry Worker builds in `cloudflare`, and every other Worker in `cloudflare_`. */ +function getEnvironmentName({ directoryName }: Worker): string { + return directoryName === DEFAULT_WORKER_DIRECTORY_NAME + ? ENVIRONMENT_NAME + : `${ENVIRONMENT_NAME}_${directoryName}`; } diff --git a/packages/rsbuild-plugin-cloudflare/src/worker-environment.ts b/packages/rsbuild-plugin-cloudflare/src/worker-environment.ts index 6d97cc823d7..b5ecfe0cfb9 100644 --- a/packages/rsbuild-plugin-cloudflare/src/worker-environment.ts +++ b/packages/rsbuild-plugin-cloudflare/src/worker-environment.ts @@ -1,9 +1,25 @@ +import { createHash } from "node:crypto"; +import * as fs from "node:fs"; import * as fsp from "node:fs/promises"; import * as path from "node:path"; -import { hasNodeJsCompat, NodeJsCompat } from "@cloudflare/workers-dev-shared"; +import { getWorkerBundleDir } from "@cloudflare/build-output-utils"; +import { + getWasmInitModule, + hasNodeJsAls, + isNodeAlsModule, + matchAdditionalModule, + moduleRuleFilters, + toModuleType, + wasmInitRE, +} from "@cloudflare/workers-dev-shared"; import { rspack } from "@rsbuild/core"; -import type { ParsedInputWorkerConfig } from "@cloudflare/config"; +import type { ModuleType } from "@cloudflare/config"; +import type { NodeJsCompat, Worker } from "@cloudflare/workers-dev-shared"; import type { EnvironmentConfig, Rspack } from "@rsbuild/core"; +import type { WorkerOptions } from "miniflare"; + +/** The package export conditions that the Vite plugin uses for Workers. */ +const conditionNames = ["workerd", "worker", "module", "browser"]; /** The Worker's entry chunk name, so its main module is `index.js`. */ export const WORKER_ENTRY_NAME = "index"; @@ -12,36 +28,50 @@ export const WORKER_ENTRY_NAME = "index"; * Builds the Rsbuild environment config that bundles a Worker for workerd as * a single ES module entry (plus any dynamically imported chunks). * - * @param worker The Worker config with its resolved entrypoint. - * @param distPath The directory the bundle is written to. + * @param worker The resolved Worker. + * @param root The project root. * @returns An Rsbuild environment config. */ export function getWorkerEnvironmentConfig( - worker: ParsedInputWorkerConfig & { entrypoint: string }, - distPath: string + { config, directoryName, nodeJsCompat }: Worker, + root: string ): EnvironmentConfig { - const nodeJsCompat = hasNodeJsCompat(worker) - ? new NodeJsCompat(worker) - : undefined; + // As in Vite, the entrypoint is a path relative to the root, or else a + // module specifier (e.g. a package export). + const entrypointPath = path.resolve(root, config.entrypoint); + const entrypoint = fs.existsSync(entrypointPath) + ? entrypointPath + : config.entrypoint; + /** The additional modules to emit, keyed by output file name. */ + const additionalModules = new Map< + string, + { filePath: string; type: ModuleType } + >(); return { source: { entry: { [WORKER_ENTRY_NAME]: { import: nodeJsCompat - ? [toDataUri(nodeJsCompat.injectGlobalCode()), worker.entrypoint] - : worker.entrypoint, + ? [toDataUri(nodeJsCompat.injectGlobalCode()), entrypoint] + : entrypoint, html: false, }, }, }, resolve: { - conditionNames: ["workerd", "worker", "module", "browser", "import"], + conditionNames, + // As in the Vite plugin, don't prefer packages' browser builds. + mainFields: ["module", "jsnext:main", "jsnext", "main"], }, output: { target: "web-worker", module: true, - distPath: { root: distPath, js: "", jsAsync: "" }, + distPath: { + root: getWorkerBundleDir(root, directoryName), + js: "", + jsAsync: "", + }, filename: { js: "[name].js" }, filenameHash: false, // As in the Vite plugin, source maps are written to the bundle rather @@ -51,27 +81,72 @@ export function getWorkerEnvironmentConfig( // keeps license comments in the deployed modules. minify: false, externals: [ - ({ request = "" }, callback) => { + async ({ request = "", context = "", dependencyType, getResolve }) => { const nodeRequest = request.startsWith("node:") ? request : `node:${request}`; if (request.startsWith("cloudflare:")) { - return callback(undefined, `module ${request}`); + return `module ${request}`; + } + if ( + nodeJsCompat?.externals.has(nodeRequest) || + (hasNodeJsAls(config) && isNodeAlsModule(request)) + ) { + // `require()` returns a built-in module's default export, as in + // Node.js, rather than its namespace. + return dependencyType === "commonjs" + ? [`module ${nodeRequest}`, "default"] + : `module ${nodeRequest}`; } - if (nodeJsCompat?.externals.has(nodeRequest)) { - return callback(undefined, `module ${nodeRequest}`); + // As in the Vite plugin, imports that resolve to `.wasm`, `.bin`, + // `.txt`, `.html` or `.sql` files become Worker modules. + if (getResolve && moduleRuleFilters.some((re) => re.test(request))) { + const resolved = await getResolve()(context, request); + const type = resolved && matchAdditionalModule(resolved); + if (resolved && type) { + const filePath = resolved.replace(/\?.*$/, ""); + const { name, ext } = path.parse(filePath); + const hash = createHash("sha256") + .update(await fsp.readFile(filePath)) + .digest("hex") + .slice(0, 8); + const fileName = `${name}-${hash}${ext}`; + additionalModules.set(fileName, { + filePath, + type: toModuleType(type), + }); + return `module ${createModuleReference(fileName)}`; + } } - return callback(); }, ], }, performance: { chunkSplit: { strategy: "all-in-one" } }, dev: { hmr: false, liveReload: false }, tools: { + // workerd runs the latest syntax, so don't lower it for the browser + // targets. Rsbuild drops its browserslist `env` when `jsc.target` is set. + swc: { jsc: { target: "esnext" } }, rspack: { // Keep the entry's exports, which are the Worker's handlers. output: { library: { type: "module" } }, - plugins: nodeJsCompat ? [nodeJsCompatPlugin(nodeJsCompat)] : [], + resolve: { + aliasFields: [], + // As in the Vite plugin, `import` or `require` depends on how the + // module is imported. + byDependency: { + esm: { conditionNames: [...conditionNames, "import"] }, + commonjs: { conditionNames: [...conditionNames, "require"] }, + }, + }, + // Like Vite, allow ES modules to import names that a CommonJS module + // doesn't export (they are `undefined`), as some packages do. + module: { parser: { javascript: { exportsPresence: "warn" } } }, + plugins: [ + ...(nodeJsCompat ? [nodeJsCompatPlugin(nodeJsCompat)] : []), + wasmInitPlugin(), + additionalModulesPlugin(additionalModules), + ], }, }, }; @@ -96,31 +171,185 @@ function nodeJsCompatPlugin( }); } +/** Supports `.wasm?init` imports, as in the Vite plugin. */ +function wasmInitPlugin(): Rspack.RspackPluginInstance { + return new rspack.NormalModuleReplacementPlugin(wasmInitRE, (resource) => { + const wasmPath = resource.request.replace(/\?init$/, ""); + resource.request = toDataUri( + getWasmInitModule( + // The data URI module can't resolve paths relative to the importer. + wasmPath.startsWith(".") + ? path.resolve(resource.context, wasmPath).replaceAll("\\", "/") + : wasmPath + ) + ); + }); +} + +/** + * Emits the additional modules that were externalized, and points the chunks' + * imports of them at their paths relative to each chunk. + */ +function additionalModulesPlugin( + additionalModules: Map +): Rspack.RspackPluginInstance { + const name = "rsbuild:cloudflare-additional-modules"; + return { + apply(compiler) { + compiler.hooks.thisCompilation.tap(name, (compilation) => { + compilation.hooks.processAssets.tapPromise( + { + name, + stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL, + }, + async () => { + for (const [fileName, { filePath, type }] of additionalModules) { + compilation.emitAsset( + fileName, + new compiler.webpack.sources.RawSource( + await fsp.readFile(filePath) + ), + { [MODULE_TYPE_KEY]: type } + ); + compilation.fileDependencies.add(filePath); + } + for (const { name: chunkName, source } of compilation.getAssets()) { + const code = source.source().toString(); + if (!/\.m?js$/.test(chunkName) || !moduleReferenceRE.test(code)) { + continue; + } + // Keeps the chunk's source map, which is generated later. + const replaced = new compiler.webpack.sources.ReplaceSource( + source + ); + for (const match of code.matchAll(moduleReferenceGlobalRE)) { + const [reference, fileName = ""] = match; + const relativePath = path.posix.relative( + path.posix.dirname(chunkName), + fileName + ); + replaced.replace( + match.index, + match.index + reference.length - 1, + relativePath.startsWith(".") + ? relativePath + : `./${relativePath}` + ); + } + compilation.updateAsset(chunkName, replaced); + } + } + ); + }); + }, + }; +} + +const MODULE_REFERENCE_PATTERN = + "__CLOUDFLARE_MODULE__(.*?)__CLOUDFLARE_MODULE__"; +const moduleReferenceRE = new RegExp(MODULE_REFERENCE_PATTERN); +const moduleReferenceGlobalRE = new RegExp(MODULE_REFERENCE_PATTERN, "g"); + +/** A placeholder for an additional module's path, as chunk paths aren't known yet. */ +function createModuleReference(fileName: string): string { + return `__CLOUDFLARE_MODULE__${fileName}__CLOUDFLARE_MODULE__`; +} + function toDataUri(code: string): string { return `data:text/javascript,${encodeURIComponent(code)}`; } +type ManifestModules = NonNullable< + WorkerOptions["config"]["manifest"] +>["modules"]; + /** - * Collects the JavaScript emitted for a Worker into a Miniflare module manifest. + * Collects the modules emitted for a Worker into a Miniflare module manifest. + * Source maps are included, so that Miniflare can source-map stack traces + * without reading them from disk. * * @param compilation The Worker environment's compilation. - * @returns The main module name and every emitted ES module. + * @returns The main module name and every emitted module. */ export function getManifestFromCompilation(compilation: Rspack.Compilation): { mainModule: string; - modules: Record; + modules: ManifestModules; } { - const modules: Record = {}; + const modules: ManifestModules = {}; for (const asset of compilation.getAssets()) { - if (asset.name.endsWith(".js")) { + const type = getModuleType(asset); + if (type) { + const contents = asset.source.buffer(); modules[asset.name] = { - type: "esm", - contents: asset.source.source().toString(), + type, + contents: + type === "wasm" || type === "data" + ? new Uint8Array(contents) + : contents.toString(), }; } } - return { mainModule: `${WORKER_ENTRY_NAME}.js`, modules }; + const entryChunk = compilation.entrypoints + .get(WORKER_ENTRY_NAME) + ?.getEntrypointChunk(); + const mainModule = [...(entryChunk?.files ?? [])].find( + (file) => getModuleType({ name: file, info: {} }) === "esm" + ); + if (!mainModule) { + throw new Error("The Worker's entry chunk wasn't emitted."); + } + + return { mainModule, modules }; +} + +/** + * Selects the modules that a partial Build Output manifest must list, which + * are those whose type can't be inferred from their extension. + * + * @param modules The Worker's Miniflare manifest modules. + * @returns The additional modules and their types. + */ +export function getAdditionalModuleTypes( + modules: ManifestModules = {} +): Record { + return Object.fromEntries( + Object.entries(modules) + .filter(([, { type }]) => type !== "esm" && type !== "sourcemap") + .map(([name, { type }]) => [name, { type }]) + ); +} + +/** + * Lists the other assets that the Worker imports (e.g. images), which are + * served as static assets rather than loaded as modules. + * + * @param compilation The Worker environment's compilation. + * @returns The assets' paths relative to the output directory. + */ +export function getImportedAssetPaths( + compilation: Rspack.Compilation +): string[] { + return compilation + .getAssets() + .filter((asset) => getModuleType(asset) === undefined) + .map(({ name }) => name); +} + +/** Records an emitted additional module's type in its asset info. */ +const MODULE_TYPE_KEY = "cloudflareModuleType"; + +function getModuleType({ + name, + info, +}: Pick): ModuleType | undefined { + if (/\.m?js$/.test(name)) { + return "esm"; + } + if (name.endsWith(".map")) { + return "sourcemap"; + } + return info[MODULE_TYPE_KEY]; } /** diff --git a/packages/rsbuild-plugin-cloudflare/tsdown.config.ts b/packages/rsbuild-plugin-cloudflare/tsdown.config.ts index 0c9545f0894..e9e80dd314c 100644 --- a/packages/rsbuild-plugin-cloudflare/tsdown.config.ts +++ b/packages/rsbuild-plugin-cloudflare/tsdown.config.ts @@ -1,11 +1,35 @@ import { defineConfig } from "tsdown"; +import type { UserConfig } from "tsdown"; -export default defineConfig({ - entry: { - index: "src/index.ts", +export default defineConfig([ + { + entry: { + index: "src/index.ts", + }, + platform: "node", + outDir: "dist", + // Dependencies, whose types the declarations import rather than inline. + external: ["miniflare", "@cloudflare/config"], + dts: { + // `@cloudflare/workers-dev-shared` is private, so inline its types. + resolve: ["@cloudflare/workers-dev-shared"], + }, + tsconfig: "tsconfig.json", }, - platform: "node", - outDir: "dist", - dts: true, - tsconfig: "tsconfig.json", -}); + // The router and asset Workers that serve assets in dev (see + // `getDevAssetWorkerOptions()`), shared with `@cloudflare/vite-plugin`. + internalWorker("asset-worker"), + internalWorker("router-worker"), +]); + +function internalWorker(name: string): UserConfig { + return { + entry: { index: `../workers-dev-shared/src/workers/${name}/index.ts` }, + outDir: `dist/workers/${name}`, + platform: "neutral", + inputOptions: { resolve: { mainFields: ["module", "main"] } }, + dts: false, + external: ["cloudflare:workers"], + tsconfig: "../workers-dev-shared/tsconfig.worker.json", + }; +} diff --git a/packages/vite-plugin-cloudflare/AGENTS.md b/packages/vite-plugin-cloudflare/AGENTS.md index 8809e740e7a..b9ef073e117 100644 --- a/packages/vite-plugin-cloudflare/AGENTS.md +++ b/packages/vite-plugin-cloudflare/AGENTS.md @@ -113,3 +113,10 @@ contract so the parent can drive either impl interchangeably. `retry: 1` from the root `vitest.shared.ts`. It spreads `configShared.test` rather than using `mergeConfig()`, which concatenates arrays and would silently enable the shared `default` reporter alongside `dot`. +- `test:ci:rsbuild:serve` / `test:ci:rsbuild:build` run the same specs against + `@cloudflare/rsbuild-plugin`. `playground/__test-utils__/rsbuild.ts` loads + each playground's Vite config, reads the `cloudflare()` options from the + plugin's `api.pluginConfig` and translates a small set of Vite options; it + throws on Vite plugins it can't translate. Playgrounds and tests that depend + on Vite-specific behaviour are skipped in `playground/vitest.config.e2e.rsbuild.ts`, + each with the reason. Don't edit specs to make them pass under Rsbuild. diff --git a/packages/vite-plugin-cloudflare/playground/__test-utils__/rsbuild.ts b/packages/vite-plugin-cloudflare/playground/__test-utils__/rsbuild.ts new file mode 100644 index 00000000000..6458acdddfe --- /dev/null +++ b/packages/vite-plugin-cloudflare/playground/__test-utils__/rsbuild.ts @@ -0,0 +1,276 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pluginCloudflare } from "@cloudflare/rsbuild-plugin"; +import { createLogger, createRsbuild, mergeRsbuildConfig } from "@rsbuild/core"; +import { pluginBasicSsl } from "@rsbuild/plugin-basic-ssl"; +import { pluginReact } from "@rsbuild/plugin-react"; +import type { PluginConfig } from "@cloudflare/vite-plugin"; +import type { + EnvironmentConfig, + RsbuildConfig, + RsbuildPlugin, +} from "@rsbuild/core"; +import type { + InlineConfig, + Logger as ViteLogger, + Plugin, + PluginOption, +} from "vite"; + +/** + * Runs the playground suite against `@cloudflare/rsbuild-plugin` instead of + * the Vite plugin when `CLOUDFLARE_TEST_BUNDLER=rsbuild`. + */ +export const isRsbuild = process.env.CLOUDFLARE_TEST_BUNDLER === "rsbuild"; + +/** + * Builds (when `isBuild`) and serves a playground with Rsbuild, using the + * options passed to `cloudflare()` in the playground's Vite config. + * + * @param viteConfig The playground's Vite config, as loaded by `loadConfig()` + * @param options.isBuild Whether to build and preview instead of running the dev server + * @returns The server's URL and a function that closes it + */ +export async function startRsbuildServe( + viteConfig: InlineConfig, + options: { isBuild: boolean } +): Promise<{ url: string; close: () => Promise }> { + const root = viteConfig.root ?? process.cwd(); + const config = await translateViteConfig(viteConfig, root); + + if (!options.isBuild) { + // As the Rsbuild CLI does, recreate the server when a watched config + // file changes. The port is inherited, so the URL stays the same. + let server: { close: () => Promise }; + const createDevRsbuild = async () => + createRsbuild({ + cwd: root, + config: await translateViteConfig(viteConfig, root), + async restart(context) { + const rsbuild = await createDevRsbuild(); + ({ server } = await rsbuild.startDevServer( + context.action === "dev" ? context.options : undefined + )); + return true; + }, + }); + const devServer = await (await createDevRsbuild()).startDevServer(); + server = devServer.server; + return { + url: getUrl(devServer.urls, config), + close: () => server.close(), + }; + } + + const builder = await createRsbuild({ + cwd: root, + config: { ...config, mode: "production" }, + }); + await (await builder.build()).close(); + // As in the Vite harness, preview runs in the same process after the build. + delete process.env.CLOUDFLARE_VITE_BUILD; + process.chdir(root); + const rsbuild = await createRsbuild({ cwd: root, config }); + const { server, urls } = await rsbuild.preview(); + return { url: getUrl(urls, config), close: () => server.close() }; +} + +function getUrl(urls: string[], config: RsbuildConfig): string { + const [url] = urls; + if (url === undefined) { + throw new Error("Rsbuild didn't report a server URL."); + } + const base = config.server?.base; + // Unlike Vite's resolved URLs, Rsbuild's don't include the raw base. + return base && base !== "/" + ? new URL(base, url).href + : url.replace(/\/$/, ""); +} + +/** + * Plugins that the harness knows how to replace with Rsbuild equivalents. + * Playgrounds that use any other Vite plugin can't run with Rsbuild and are + * excluded in `vitest.config.e2e.rsbuild.ts`. + */ +const portablePlugins: Array<{ + test: (name: string) => boolean; + create?: () => RsbuildPlugin; +}> = [ + { test: (name) => name.startsWith("vite-plugin-cloudflare") }, + { test: (name) => name === "vite:react-refresh", create: pluginReact }, + { test: (name) => name.startsWith("vite:react") }, + { test: (name) => name === "vite:basic-ssl", create: pluginBasicSsl }, +]; + +async function translateViteConfig( + viteConfig: InlineConfig, + root: string +): Promise { + let pluginConfig: PluginConfig | undefined; + const plugins: RsbuildPlugin[] = []; + + for (const plugin of await flattenPlugins(viteConfig.plugins)) { + if (plugin.name === "vite-plugin-cloudflare") { + pluginConfig = (plugin.api as { pluginConfig: PluginConfig }) + .pluginConfig; + } + const portable = portablePlugins.find(({ test }) => test(plugin.name)); + if (!portable) { + throw new Error( + `The Vite plugin "${plugin.name}" has no Rsbuild equivalent in the test harness.` + ); + } + const rsbuildPlugin = portable.create?.(); + if (rsbuildPlugin) { + plugins.push(rsbuildPlugin); + } + } + + if (!pluginConfig) { + throw new Error("The playground's Vite config doesn't use `cloudflare()`."); + } + + const config: RsbuildConfig = { + root, + mode: "development", + plugins: [ + pluginCloudflare({ + ...pluginConfig, + envDir: viteConfig.envDir, + envMode: viteConfig.mode, + }), + ...plugins, + ], + server: { + port: 0, + base: viteConfig.base, + headers: viteConfig.server?.headers as Record, + // Vite's dev server serves files from the root as well as `public/`. + publicDir: [{ name: "public" }, { name: root, copyOnBuild: false }], + }, + resolve: { alias: viteConfig.resolve?.alias as Record }, + // Vite resolves root-relative imports (e.g. `/vite.svg`) from `publicDir`, + // then the root. + tools: { + rspack: { + resolve: { roots: [path.join(root, "public"), root] }, + // Vite supports `?raw` and `?url` for every file, Rsbuild only for + // its asset types. + module: { + rules: [ + { test: /\.html$/, resourceQuery: /^\?raw$/, type: "asset/source" }, + { + test: /\.(json|txt)$/, + resourceQuery: /^\?url$/, + type: "asset/resource", + }, + ], + }, + }, + }, + environments: { + cloudflare: getWorkerEnvironment(viteConfig.environments?.ssr?.build), + }, + customLogger: createForwardingLogger(viteConfig.customLogger), + }; + return mergeRsbuildConfig(config, getHtmlEntries(viteConfig, root)); +} + +/** Maps the entry Worker's Vite build options that playgrounds set. */ +function getWorkerEnvironment( + build: NonNullable | undefined +): EnvironmentConfig { + const { output } = build?.rolldownOptions ?? {}; + const entryFileNames = + output && !Array.isArray(output) ? output.entryFileNames : undefined; + return { + output: { + ...(build?.sourcemap === false && { sourceMap: { js: false } }), + ...(typeof entryFileNames === "string" && { + filename: { js: entryFileNames.replace("[hash]", "[contenthash:8]") }, + }), + }, + }; +} + +/** + * Vite uses HTML files (by default `index.html`) as the client entries, + * whereas Rsbuild needs a script entry per page and injects it into the HTML. + */ +function getHtmlEntries(viteConfig: InlineConfig, root: string): RsbuildConfig { + const input = + viteConfig.environments?.client?.build?.rolldownOptions?.input ?? + path.join(root, "index.html"); + const htmlPaths = (typeof input === "string" ? [input] : Object.values(input)) + .map((file) => path.resolve(root, file)) + .filter((file) => file.endsWith(".html") && fs.existsSync(file)); + if (htmlPaths.length === 0) { + return {}; + } + const scriptRE = /\s* - `; -} diff --git a/packages/vite-plugin-cloudflare/src/index.ts b/packages/vite-plugin-cloudflare/src/index.ts index 5438a9033a2..41531ed7c8f 100644 --- a/packages/vite-plugin-cloudflare/src/index.ts +++ b/packages/vite-plugin-cloudflare/src/index.ts @@ -71,6 +71,8 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin[] { { name: "vite-plugin-cloudflare", sharedDuringBuild: true, + // Exposes the options so that tools can read them from a resolved Vite config. + api: { pluginConfig }, async config(userConfig, env) { ctx.setResolvedPluginConfig( await resolvePluginConfig(pluginConfig, userConfig, env) diff --git a/packages/vite-plugin-cloudflare/src/miniflare-options.ts b/packages/vite-plugin-cloudflare/src/miniflare-options.ts index c0cb621576f..ee4e5b43715 100644 --- a/packages/vite-plugin-cloudflare/src/miniflare-options.ts +++ b/packages/vite-plugin-cloudflare/src/miniflare-options.ts @@ -3,54 +3,43 @@ import * as fsp from "node:fs/promises"; import * as path from "node:path"; import * as timers from "node:timers/promises"; import { fileURLToPath } from "node:url"; -import { format } from "node:util"; import { createContainerDevPlanForCfConfig, - createContainerPreviewPlanForCfConfig, generateContainerBuildId, resolveDockerHost, } from "@cloudflare/containers-shared"; -import { maybeStartOrUpdateRemoteProxySession } from "@cloudflare/remote-bindings"; import { createMiniflareExports, - getMiniflareManifest, - getPersistenceRoot, + DevMiniflareLogger, + getPreviewMiniflareOptions as getSharedPreviewMiniflareOptions, + getAssetsConfig, + getCommonMiniflareOptions, + getDevAssetWorkerOptions, + getInternalWorkerManifest, + getRemoteProxySessionData, + getWorkerZone, + INTERNAL_WORKERS_COMPATIBILITY_DATE, + kRequestType, + miniflareModulesRoot, resolveLocalBindingsByWorkerName, + VITE_PROXY_WORKER_NAME, warnForMissingSecrets, } from "@cloudflare/workers-dev-shared"; -import { - getBrowserRenderingHeadfulFromEnv, - getLocalExplorerEnabledFromEnv, - getLocalObservabilityEnabledFromEnv, - getZoneFromRoute, -} from "@cloudflare/workers-utils"; +import { getBrowserRenderingHeadfulFromEnv } from "@cloudflare/workers-utils"; import { getDockerPath } from "@cloudflare/workers-utils/docker-path"; import { buildPublicUrl, - getDefaultDevRegistryPath, kUnsafeEphemeralUniqueKey, - Log, - LogLevel, parseModuleFallbackRequest, Response as MiniflareResponse, } from "miniflare"; -import { getAssetsConfig } from "./asset-config"; -import { - ASSET_WORKER_NAME, - kRequestType, - PROXY_SHARED_SECRET, - ROUTER_WORKER_NAME, - VITE_PROXY_WORKER_NAME, -} from "./constants"; import { getInputInspectorPort } from "./debug"; import { additionalModuleRE } from "./plugins/additional-modules"; -import { getRemoteBindings } from "./remote-bindings"; import { ENVIRONMENT_NAME_HEADER } from "./shared"; import { checkForNpmUpdate } from "./update-check"; import { debuglog, satisfiesMinimumViteVersion, - toApiComplianceRegion, withTrailingSlash, } from "./utils"; import type { CloudflareDevEnvironment } from "./cloudflare-environment"; @@ -59,63 +48,24 @@ import type { PreviewPluginContext, WorkersPluginContext, } from "./context"; -import type { ParsedInputWorkerConfig } from "@cloudflare/config"; import type { ContainerDevOptions, ContainerDevPlan, } from "@cloudflare/containers-shared"; -import type { - RemoteBindingsLogger, - RemoteProxySessionData, -} from "@cloudflare/remote-bindings"; -import type { - MiniflareOptions, - WorkerdStructuredLog, - WorkerOptions, -} from "miniflare"; +import type { MiniflareOptions, WorkerOptions } from "miniflare"; import type * as vite from "vite"; -const INTERNAL_WORKERS_COMPATIBILITY_DATE = "2024-10-04"; // Used to mark HTML assets as being in the public directory so that they can be resolved from their root relative paths const PUBLIC_DIR_PREFIX = "/__vite_public_dir__"; -// We want module names to be their absolute path without the leading slash -// (i.e. the modules root should be the root directory). On Windows, we need -// paths to include the drive letter (i.e. `C:/a/b/c/index.mjs`). -// Internally, Miniflare uses `path.relative(modulesRoot, path)` to compute -// module names. Setting `modulesRoot` to a drive letter and prepending this -// to paths ensures correct names. This requires us to specify `contents` in -// the miniflare module definitions though, as the new paths don't exist. -const miniflareModulesRoot = process.platform === "win32" ? "Z:\\" : "/"; -const ROUTER_WORKER_PATH = "./workers/router-worker/index.js"; -const ASSET_WORKER_PATH = "./workers/asset-worker/index.js"; const VITE_PROXY_WORKER_PATH = "./workers/vite-proxy-worker/index.js"; const RUNNER_PATH = "./workers/runner-worker/index.js"; const MODULE_RUNNER_PATH = "./workers/runner-worker/module-runner.js"; const MODULE_RUNNER_LEGACY_PATH = "./workers/runner-worker/module-runner-legacy.js"; const WRAPPER_PATH = "__VITE_WORKER_ENTRY__"; -const ASSETS_ONLY_MODULE = "__VITE_ASSETS_ONLY__.js"; type MiniflareEnv = NonNullable; -async function getInternalWorkerManifest( - workerPath: string -): Promise> { - return { - mainModule: workerPath, - modulesRoot: miniflareModulesRoot, - modules: { - [workerPath]: { - type: "esm", - contents: await fsp.readFile( - fileURLToPath(new URL(workerPath, import.meta.url)), - "utf8" - ), - }, - }, - }; -} - async function getUserWorkerManifest( wrapperContents: string ): Promise> { @@ -148,78 +98,6 @@ async function getUserWorkerManifest( }; } -function getAssetsOnlyManifest( - rootPath: string -): NonNullable { - return { - mainModule: ASSETS_ONLY_MODULE, - modulesRoot: rootPath, - modules: { - [ASSETS_ONLY_MODULE]: { - type: "esm", - contents: "export default {};", - }, - }, - }; -} - -/** Existing remote proxy sessions keyed by Worker name. */ -const remoteProxySessionsDataMap = new Map< - string, - RemoteProxySessionData | null ->(); - -function createRemoteBindingsLogger(logger: vite.Logger): RemoteBindingsLogger { - const write = ( - level: "info" | "warn" | "error", - args: Parameters - ) => logger[level](format(...args)); - - return { - loggerLevel: "log", - debug() {}, - log: (...args) => write("info", args), - info: (...args) => write("info", args), - warn: (...args) => write("warn", args), - error: (...args) => write("error", args), - console(method, ...args) { - if (method === "error") { - write("error", args); - } else if (method === "warn") { - write("warn", args); - } else if (method !== "debug" && method !== "trace") { - write("info", args); - } - }, - }; -} - -function getWorkerZone( - config: Pick -): string | undefined { - // Domains are converted to routes before fetch triggers, so this mirrors the - // first-route selection used by Wrangler's Miniflare options. - const domain = config.domains?.[0]; - if (domain !== undefined) { - return getZoneFromRoute( - typeof domain === "string" ? domain : domain.hostname - ); - } - - const fetchTrigger = config.triggers?.find( - (trigger) => trigger.type === "fetch" - ); - if (fetchTrigger?.type !== "fetch") { - return undefined; - } - - return getZoneFromRoute( - fetchTrigger.zone?.includes(".") - ? { pattern: fetchTrigger.pattern, zone_name: fetchTrigger.zone } - : fetchTrigger.pattern - ); -} - export async function getDevMiniflareOptions( ctx: AssetsOnlyPluginContext | WorkersPluginContext, viteDevServer: vite.ViteDevServer @@ -233,7 +111,10 @@ export async function getDevMiniflareOptions( const assetsConfig = getAssetsConfig( resolvedPluginConfig, entryWorkerConfig, - resolvedViteConfig + { + publicDir: resolvedViteConfig.publicDir, + logger: resolvedViteConfig.logger, + } ); const resolvedLocalBindingsByWorkerName = resolveLocalBindingsByWorkerName( ctx.allWorkerConfigs, @@ -245,191 +126,101 @@ export async function getDevMiniflareOptions( resolvedViteConfig.mode, viteDevServer.config.logger ); - const [routerWorkerManifest, assetWorkerManifest, viteProxyWorkerManifest] = - await Promise.all([ - getInternalWorkerManifest(ROUTER_WORKER_PATH), - getInternalWorkerManifest(ASSET_WORKER_PATH), - getInternalWorkerManifest(VITE_PROXY_WORKER_PATH), - ]); + const viteProxyWorkerManifest = await getInternalWorkerManifest( + VITE_PROXY_WORKER_PATH, + import.meta.url + ); const assetWorkers: WorkerOptions[] = [ - { - config: { - name: ROUTER_WORKER_NAME, - // TODO: Move this handling into Miniflare so Access is applied to the - // user Worker invocation without propagating through Worker boundaries. - access: entryWorkerConfig?.access, - compatibilityDate: INTERNAL_WORKERS_COMPATIBILITY_DATE, - compatibilityFlags: ["enable_ctx_exports"], - manifest: routerWorkerManifest, - env: { - CONFIG: { - type: "json", - value: { - has_user_worker: resolvedPluginConfig.type === "workers", - }, - }, - ASSET_WORKER: { - type: "worker", - worker: ASSET_WORKER_NAME, - }, - ...(entryWorkerConfig - ? { - USER_WORKER: { - type: "worker", - worker: entryWorkerConfig.name, - } satisfies MiniflareEnv[string], + ...(await getDevAssetWorkerOptions({ + baseUrl: import.meta.url, + entryWorkerConfig, + hasUserWorker: resolvedPluginConfig.type === "workers", + assetsConfig, + headers: viteDevServer.config.server.headers, + html: { + async htmlExists(pathname) { + const { root, publicDir } = resolvedViteConfig; + const publicDirInRoot = publicDir.startsWith(withTrailingSlash(root)); + const publicPath = withTrailingSlash(publicDir.slice(root.length)); + + // Assets in the public directory should be served at the root path + if (publicDirInRoot && pathname.startsWith(publicPath)) { + return null; + } + + const publicDirFilePath = path.join(publicDir, pathname); + const rootDirFilePath = path.join(root, pathname); + + for (const resolvedPath of [publicDirFilePath, rootDirFilePath]) { + try { + const stats = await fsp.stat(resolvedPath); + + if (stats.isFile()) { + return resolvedPath === publicDirFilePath + ? `${PUBLIC_DIR_PREFIX}${pathname}` + : pathname; } - : {}), - }, - }, - dev: { - unsafeRegisterWorker: false, - }, - }, - { - config: { - name: ASSET_WORKER_NAME, - compatibilityDate: INTERNAL_WORKERS_COMPATIBILITY_DATE, - manifest: assetWorkerManifest, - env: { - CONFIG: { type: "json", value: assetsConfig }, - __VITE_HEADERS__: { - type: "text", - value: JSON.stringify(viteDevServer.config.server.headers), - }, - __VITE_HTML_EXISTS__: { - type: "fetcher", - handler: async (request) => { - const { pathname } = new URL(request.url); - - if (pathname.endsWith(".html")) { - const { root, publicDir } = resolvedViteConfig; - const publicDirInRoot = publicDir.startsWith( - withTrailingSlash(root) - ); - const publicPath = withTrailingSlash( - publicDir.slice(root.length) - ); - - // Assets in the public directory should be served at the root path - if (publicDirInRoot && pathname.startsWith(publicPath)) { - return MiniflareResponse.json(null); - } + } catch {} + } - const publicDirFilePath = path.join(publicDir, pathname); - const rootDirFilePath = path.join(root, pathname); - - for (const resolvedPath of [ - publicDirFilePath, - rootDirFilePath, - ]) { - try { - const stats = await fsp.stat(resolvedPath); - - if (stats.isFile()) { - return MiniflareResponse.json( - resolvedPath === publicDirFilePath - ? `${PUBLIC_DIR_PREFIX}${pathname}` - : pathname - ); - } - } catch {} - } - } - - return MiniflareResponse.json(null); - }, - }, - __VITE_FETCH_HTML__: { - type: "fetcher", - handler: async (request) => { - const { pathname } = new URL(request.url); - - try { - const { root, publicDir } = resolvedViteConfig; - const isInPublicDir = pathname.startsWith(PUBLIC_DIR_PREFIX); - - // HTML files in the public directory are served as-is (no - // transform) from disk in both bundled and non-bundled dev. - if (isInPublicDir) { - const resolvedPath = path.join( - publicDir, - pathname.slice(PUBLIC_DIR_PREFIX.length) - ); - const html = await fsp.readFile(resolvedPath, "utf-8"); - - return new MiniflareResponse(html, { - headers: { "Content-Type": "text/html" }, - }); - } + return null; + }, + async fetchHtml(pathname) { + const { root, publicDir } = resolvedViteConfig; + const isInPublicDir = pathname.startsWith(PUBLIC_DIR_PREFIX); + + // HTML files in the public directory are served as-is (no + // transform) from disk in both bundled and non-bundled dev. + if (isInPublicDir) { + const resolvedPath = path.join( + publicDir, + pathname.slice(PUBLIC_DIR_PREFIX.length) + ); + return fsp.readFile(resolvedPath, "utf-8"); + } + + const bundledDev = viteDevServer.environments.client.bundledDev; + + if (bundledDev) { + // When the client environment runs in bundled dev mode + // (`experimental.bundledDev`), the transformed HTML lives in + // Vite's in-memory files rather than on disk. Serving it here + // ensures the returned HTML references the bundled client + // chunks (which Vite serves from `memoryFiles`) instead of the + // raw source entry. + // + // Unlike Vite's `indexHtmlMiddleware`, which serves a fallback + // loading page and relies on an HMR reload, we intentionally + // block briefly and serve the real HTML. + await bundledDev.triggerBundleRegenerationIfStale(); + + const key = pathname.slice(1); + let file = bundledDev.memoryFiles.get(key); + const deadline = Date.now() + 10_000; + while (!file && Date.now() < deadline) { + await timers.setTimeout(10); + file = bundledDev.memoryFiles.get(key); + } - const bundledDev = viteDevServer.environments.client.bundledDev; - - if (bundledDev) { - // When the client environment runs in bundled dev mode - // (`experimental.bundledDev`), the transformed HTML lives in - // Vite's in-memory files rather than on disk. Serving it here - // ensures the returned HTML references the bundled client - // chunks (which Vite serves from `memoryFiles`) instead of the - // raw source entry. - // - // Unlike Vite's `indexHtmlMiddleware`, which serves a fallback - // loading page and relies on an HMR reload, we intentionally - // block briefly and serve the real HTML. - await bundledDev.triggerBundleRegenerationIfStale(); - - const key = pathname.slice(1); - let file = bundledDev.memoryFiles.get(key); - const deadline = Date.now() + 10_000; - while (!file && Date.now() < deadline) { - await timers.setTimeout(10); - file = bundledDev.memoryFiles.get(key); - } - - if (!file) { - throw new Error( - `No bundled file for "${pathname}" after waiting for bundle regeneration.` - ); - } - - const html = - typeof file.source === "string" - ? file.source - : Buffer.from(file.source); - - return new MiniflareResponse(html, { - headers: { "Content-Type": "text/html" }, - }); - } + if (!file) { + throw new Error( + `No bundled file for "${pathname}" after waiting for bundle regeneration.` + ); + } - // Non-bundled dev: read root HTML from disk and transform via Vite. - const resolvedPath = path.join(root, pathname); - let html = await fsp.readFile(resolvedPath, "utf-8"); - html = await viteDevServer.transformIndexHtml( - resolvedPath, - html - ); + return typeof file.source === "string" + ? file.source + : Buffer.from(file.source); + } - return new MiniflareResponse(html, { - headers: { "Content-Type": "text/html" }, - }); - } catch (error) { - throw new Error( - `Unexpected error. Failed to load "${pathname}".`, - { - cause: error, - } - ); - } - }, - }, + // Non-bundled dev: read root HTML from disk and transform via Vite. + const resolvedPath = path.join(root, pathname); + const html = await fsp.readFile(resolvedPath, "utf-8"); + return viteDevServer.transformIndexHtml(resolvedPath, html); }, }, - dev: { - unsafeRegisterWorker: false, - }, - }, + })), { config: { name: VITE_PROXY_WORKER_NAME, @@ -484,42 +275,16 @@ export async function getDevMiniflareOptions( ? await Promise.all( [...resolvedPluginConfig.environmentNameToWorkerMap].map( async ([environmentName, worker]) => { - const bindings = getRemoteBindings(worker.config); - - const preExistingRemoteProxySession = - remoteProxySessionsDataMap.get(worker.config.name); - const settings = resolvedPluginConfig.settings; - - const remoteProxySessionData = - !resolvedPluginConfig.remoteBindings - ? // if remote bindings are not enabled then the proxy session can simply be null - null - : await maybeStartOrUpdateRemoteProxySession( - { - name: worker.config.name, - bindings: bindings ?? {}, - complianceRegion: toApiComplianceRegion( - settings.complianceRegion - ), - account_id: settings.accountId, - profileDir: resolvedViteConfig.root, - }, - preExistingRemoteProxySession ?? null, - undefined, - { - cliDisplayName: resolvedPluginConfig.cliDisplayName, - logger: createRemoteBindingsLogger( - viteDevServer.config.logger - ), - } - ); - - if (remoteProxySessionData) { - remoteProxySessionsDataMap.set( - worker.config.name, - remoteProxySessionData - ); - } + const remoteProxySessionData = await getRemoteProxySessionData( + worker.config, + { + remoteBindings: resolvedPluginConfig.remoteBindings, + settings: resolvedPluginConfig.settings, + root: resolvedViteConfig.root, + cliDisplayName: resolvedPluginConfig.cliDisplayName, + logger: viteDevServer.config.logger, + } + ); const wrappers = [ `import { createWorkerEntrypointWrapper, createDurableObjectWrapper, createWorkflowEntrypointWrapper } from "${RUNNER_PATH}";`, @@ -625,7 +390,7 @@ export async function getDevMiniflareOptions( ) : []; - const logger = new ViteMiniflareLogger(resolvedViteConfig); + const logger = getMiniflareLogger(resolvedViteConfig); const serverConfig = viteDevServer.config.server; const publicUrl = buildPublicUrl({ @@ -636,22 +401,15 @@ export async function getDevMiniflareOptions( }); const miniflareOptions: MiniflareOptions = { - log: logger, - publicUrl, - unsafeProxySharedSecret: PROXY_SHARED_SECRET, + ...getCommonMiniflareOptions({ + root: resolvedViteConfig.root, + persistState: resolvedPluginConfig.persistState, + logger, + inspectorPort: inputInspectorPort, + publicUrl, + containerEngine, + }), logRequests: false, - inspectorPort: - inputInspectorPort === false ? undefined : inputInspectorPort, - unsafeDevRegistryPath: getDefaultDevRegistryPath(), - unsafeTriggerHandlers: true, - unsafeLocalExplorer: getLocalExplorerEnabledFromEnv(), - // The switch for local observability capture: tells Miniflare core to - // attach the trace collector to each user worker. Opt-in via the - // `X_LOCAL_OBSERVABILITY` env var (defaults off); enabling it requires - // restarting the dev server. - unsafeObservability: getLocalObservabilityEnabledFromEnv(), - telemetry: { enabled: false }, - handleStructuredLogs: getStructuredLogsLogger(logger), async unsafeHandleRuntimeRestart() { // Miniflare has restarted `workerd` after a crash, but the // module runners created over our separate bootstrap channel @@ -664,12 +422,6 @@ export async function getDevMiniflareOptions( ); await viteDevServer.restart(); }, - resourcePersistencePath: getPersistenceRoot( - resolvedViteConfig.root, - resolvedPluginConfig.persistState - ), - resourceTmpPath: path.resolve(resolvedViteConfig.root, ".cloudflare/tmp"), - containerEngine, workers: [...assetWorkers, ...userWorkers], async unsafeModuleFallbackService(request) { const parsed = await parseModuleFallbackRequest(request); @@ -737,247 +489,29 @@ export async function getDevMiniflareOptions( export async function getPreviewMiniflareOptions( ctx: PreviewPluginContext, vitePreviewServer: vite.PreviewServer -): Promise<{ - miniflareOptions: Extract; - containerOptions: ContainerDevOptions[] | undefined; -}> { - const inputInspectorPort = await getInputInspectorPort( - ctx, - vitePreviewServer - ); +): ReturnType { const { resolvedPluginConfig, resolvedViteConfig } = ctx; - const resolvedLocalBindingsByWorkerName = resolveLocalBindingsByWorkerName( - ctx.allWorkerConfigs, - resolvedPluginConfig.localEnv, - resolvedPluginConfig.devVars - ); - warnForMissingSecrets( - resolvedLocalBindingsByWorkerName, - resolvedViteConfig.mode, - vitePreviewServer.config.logger - ); - const containerPlansByWorkerName = new Map(); - const containerOptions: ContainerDevOptions[] = []; - for (const previewWorker of resolvedPluginConfig.workers) { - const plan = createContainerPreviewPlanForCfConfig({ - containers: resolvedPluginConfig.containers, - exports: previewWorker.config.exports, - }); - if (plan !== undefined) { - containerPlansByWorkerName.set(previewWorker.config.name, plan); - containerOptions.push(...plan.containerOptions); - } - } - const containerEngine = - containerPlansByWorkerName.size === 0 - ? undefined - : resolveDockerHost(getDockerPath()); - const workers: WorkerOptions[] = await Promise.all( - resolvedPluginConfig.workers.map(async (previewWorker) => { - const workerConfig = previewWorker.config; - const bindings = getRemoteBindings(workerConfig); - const preExistingRemoteProxySessionData = remoteProxySessionsDataMap.get( - workerConfig.name - ); - const remoteProxySessionData = !resolvedPluginConfig.remoteBindings - ? null - : await maybeStartOrUpdateRemoteProxySession( - { - name: workerConfig.name, - bindings: bindings ?? {}, - complianceRegion: toApiComplianceRegion( - resolvedPluginConfig.settings.complianceRegion - ), - account_id: resolvedPluginConfig.settings.accountId, - profileDir: resolvedViteConfig.root, - }, - preExistingRemoteProxySessionData ?? null, - undefined, - { - cliDisplayName: resolvedPluginConfig.cliDisplayName, - logger: createRemoteBindingsLogger( - vitePreviewServer.config.logger - ), - } - ); - - if (remoteProxySessionData) { - remoteProxySessionsDataMap.set( - workerConfig.name, - remoteProxySessionData - ); - } - - const { manifest: _manifest, assets, exports, ...config } = workerConfig; - const resolvedLocalBindings = resolvedLocalBindingsByWorkerName.get( - workerConfig.name - ); - assert(resolvedLocalBindings); - const env = { ...resolvedLocalBindings.bindings }; - const workerExports = createMiniflareExports({ - exports, - containerRuntimeOptions: containerPlansByWorkerName.get( - workerConfig.name - )?.containerRuntimeOptions, - }); - return { - config: { - ...config, - exports: workerExports, - env, - manifest: previewWorker.bundle - ? await getMiniflareManifest(previewWorker.bundle) - : previewWorker.assetsDir - ? getAssetsOnlyManifest(resolvedViteConfig.root) - : undefined, - assets: previewWorker.assetsDir - ? { - ...assets, - directory: previewWorker.assetsDir, - hasUserWorker: previewWorker.bundle !== undefined, - } - : undefined, - }, - dev: { - zone: getWorkerZone(config), - remoteProxyConnectionString: - remoteProxySessionData?.session?.remoteProxyConnectionString, - unsafeInspectorProxy: inputInspectorPort !== false, - }, - } satisfies WorkerOptions; - }) - ); - - const logger = new ViteMiniflareLogger(resolvedViteConfig); - const serverConfig = vitePreviewServer.config.preview; - const publicUrl = buildPublicUrl({ - hostname: - typeof serverConfig.host === "string" ? serverConfig.host : undefined, - port: serverConfig.port, - secure: !!serverConfig.https, - }); - - const miniflareOptions: MiniflareOptions = { - log: logger, - publicUrl, - unsafeProxySharedSecret: PROXY_SHARED_SECRET, - inspectorPort: - inputInspectorPort === false ? undefined : inputInspectorPort, - unsafeDevRegistryPath: getDefaultDevRegistryPath(), - unsafeTriggerHandlers: true, - unsafeLocalExplorer: getLocalExplorerEnabledFromEnv(), - // The one switch for local observability: this env var tells Miniflare - // core to attach the trace collector to each user worker. - unsafeObservability: getLocalObservabilityEnabledFromEnv(), - telemetry: { enabled: false }, - handleStructuredLogs: getStructuredLogsLogger(logger), - resourcePersistencePath: getPersistenceRoot( - resolvedViteConfig.root, - resolvedPluginConfig.persistState - ), - resourceTmpPath: path.resolve(resolvedViteConfig.root, ".cloudflare/tmp"), - containerEngine, - workers, - }; - return { - miniflareOptions: miniflareOptions as Extract< - MiniflareOptions, - { workers: WorkerOptions[] } - >, - containerOptions: - containerPlansByWorkerName.size === 0 ? undefined : containerOptions, - }; -} - -/** - * A Miniflare logger that forwards messages onto a Vite logger. - */ -class ViteMiniflareLogger extends Log { - #warnedCompatibilityDateFallback = false; - - private logger: vite.Logger; - - constructor(config: vite.ResolvedConfig) { - super(miniflareLogLevelFromViteLogLevel(config.logLevel)); - this.logger = config.logger; - } - - override logWithLevel(level: LogLevel, message: string) { - switch (level) { - case LogLevel.ERROR: - return this.logger.error(message); - case LogLevel.WARN: - return this.logger.warn(message); - case LogLevel.INFO: - return this.logger.info(message); - } - } - - override warn(message: string): void { - // workerd emits a warning when the requested compatibility date is newer - // than the binary supports. We intercept it here so we only show it once - // and only when a newer version of the plugin is actually available on - // npm — otherwise the warning is just noise the user cannot act on. - if (!message.startsWith("The latest compatibility date supported by")) { - this.logger.warn(message); - return; - } - - if (this.#warnedCompatibilityDateFallback) { - return; - } - this.#warnedCompatibilityDateFallback = true; - - return void checkForNpmUpdate().then((result) => { - if (result.status !== "update-available") { - return; - } - this.logger.warn( - `${message}\nFeatures enabled by your requested compatibility date may not be available.` + - `\nUpgrade to \`@cloudflare/vite-plugin@${result.latest}\` to remove this warning.` - ); - return; - }); - } - - override logReady() { - // Noop so that Miniflare server start messages are not logged - } -} - -function miniflareLogLevelFromViteLogLevel( - level: vite.LogLevel = "info" -): LogLevel { - switch (level) { - case "error": - return LogLevel.ERROR; - case "warn": - return LogLevel.WARN; - case "info": - return LogLevel.INFO; - case "silent": - return LogLevel.NONE; - } + return getSharedPreviewMiniflareOptions({ + resolvedPluginConfig, + root: resolvedViteConfig.root, + mode: resolvedViteConfig.mode, + logger: vitePreviewServer.config.logger, + miniflareLogger: getMiniflareLogger(resolvedViteConfig), + inspectorPort: await getInputInspectorPort(ctx, vitePreviewServer), + publicUrl: buildPublicUrl({ + hostname: + typeof serverConfig.host === "string" ? serverConfig.host : undefined, + port: serverConfig.port, + secure: !!serverConfig.https, + }), + }); } -/** - * Generates a log handler to be passed as the `handleStructuredLogs` option to miniflare - * - * @param logger the vite logger to use - * @returns the log handler to pass to miniflare - */ -function getStructuredLogsLogger(logger: Log) { - return ({ level, message }: WorkerdStructuredLog) => { - if (level === "warn") { - return logger.warn(message); - } - - if (level === "error") { - return logger.logWithLevel(LogLevel.ERROR, message); - } - - return logger.info(message); - }; +function getMiniflareLogger(config: vite.ResolvedConfig): DevMiniflareLogger { + return new DevMiniflareLogger(config.logger, config.logLevel, { + packageName: "@cloudflare/vite-plugin", + checkForNpmUpdate, + }); } diff --git a/packages/vite-plugin-cloudflare/src/nodejs-compat.ts b/packages/vite-plugin-cloudflare/src/nodejs-compat.ts index 9cf882ae417..aa529666139 100644 --- a/packages/vite-plugin-cloudflare/src/nodejs-compat.ts +++ b/packages/vite-plugin-cloudflare/src/nodejs-compat.ts @@ -1,28 +1,9 @@ import assert from "node:assert"; import * as path from "node:path"; import { nonPrefixedNodeModules } from "@cloudflare/unenv-preset"; -import { getNodeCompat } from "miniflare"; -import type { ResolvedWorkerConfig } from "./plugin-config"; import type { NodeJsCompat } from "@cloudflare/workers-dev-shared"; import type * as vite from "vite"; -/** - * Returns true if Node.js async local storage (ALS) is enabled (and not full Node.js compatibility mode). - */ -export function hasNodeJsAls(workerConfig: ResolvedWorkerConfig | undefined) { - return ( - workerConfig !== undefined && - getNodeCompat( - workerConfig.compatibilityDate, - workerConfig.compatibilityFlags ?? [] - ).mode === "als" - ); -} - -export function isNodeAlsModule(modulePath: string) { - return /^(?:node:)?async_hooks$/.test(modulePath); -} - export const nodeBuiltinsRE = new RegExp( `^(${nonPrefixedNodeModules.join("|")}|node:.+)$` ); diff --git a/packages/vite-plugin-cloudflare/src/plugin-config.ts b/packages/vite-plugin-cloudflare/src/plugin-config.ts index e04188c2e6e..5e1391110a6 100644 --- a/packages/vite-plugin-cloudflare/src/plugin-config.ts +++ b/packages/vite-plugin-cloudflare/src/plugin-config.ts @@ -1,568 +1,48 @@ import * as path from "node:path"; import { - DEFAULT_WORKER_DIRECTORY_NAME, - normalizeDirectoryName, -} from "@cloudflare/build-output-utils"; -import { InputWorkerSchema } from "@cloudflare/config"; -import { - hasNodeJsCompat, - loadCloudflareConfig, - NodeJsCompat, + readBuildOutputPreview, + resolvePluginConfig as resolveSharedPluginConfig, } from "@cloudflare/workers-dev-shared"; -import { parseStaticRouting } from "@cloudflare/workers-shared/utils/configuration/parseStaticRouting"; -import { - DEFAULT_COMPAT_DATE, - getWorkerNameFromProject, -} from "@cloudflare/workers-utils"; -import { loadDevVars, loadEnv } from "@cloudflare/workers-utils/local-env"; -import { defu } from "defu"; -import { PRERENDER_WORKER_DIRECTORY_NAME } from "./build-output"; -import { getCliDisplayName, isPreviewBuild } from "./build-output-env"; -import { readBuildOutputPreview } from "./build-output-preview"; -import type { BuildOutputPreviewWorker } from "./build-output-preview"; import type { - ParsedInputContainerConfig, - InputWorkerConfig, - ParsedInputSettingsConfig, - ParsedInputWorkerConfig, - ParsedOutputContainerConfig, -} from "@cloudflare/config"; -import type { PersistState } from "@cloudflare/workers-dev-shared"; -import type { StaticRouting } from "@cloudflare/workers-shared/utils/types"; -import type { LoadedEnv } from "@cloudflare/workers-utils/local-env"; + PluginConfig, + ResolvedPluginConfig, +} from "@cloudflare/workers-dev-shared"; import type * as vite from "vite"; -export type { PersistState }; -export type TunnelConfig = { - autoStart?: boolean; - name?: string; - /** Email addresses or domain patterns allowed to authenticate to a Quick Tunnel. */ - allowedMail?: string[]; -}; - -interface BaseWorkerOptions { - viteEnvironment?: { name?: string; childEnvironments?: string[] }; -} - -/** - * Whether this Worker is only used during development and should not be built for production. - * Can be a boolean or a function that returns a boolean. The function is evaluated lazily - * at build time, allowing frameworks to provide the value after initialization. - */ -type DevOnly = boolean | (() => boolean); - -interface EntryWorkerOptions extends BaseWorkerOptions { - config?: EntryWorkerConfigCustomizer; - /** - * Whether the entry Worker should be omitted from the production build. - * Can be a boolean or a function that returns a boolean. The function is - * evaluated lazily at build time, allowing frameworks to provide the value - * after initialization. - * - * When set, an assets-only Build Output config is emitted. This enables using - * server-side code in development but producing a fully static app for - * deployment. - */ - assetsOnly?: DevOnly; -} - -interface AuxiliaryWorkerOptions extends BaseWorkerOptions { - /** Configure an auxiliary Worker. */ - config: WorkerConfigProvider; - devOnly?: DevOnly; -} - -interface PrerenderWorkerOptions extends BaseWorkerOptions { - config: WorkerConfigProvider; -} - -interface TypeGenerationOptions { - /** - * Whether to auto-generate `.cloudflare/types/index.d.ts`. Defaults to - * `true`. - */ - generate?: boolean; - /** - * Whether to include the Worker's runtime types (generated from the - * project's compatibility date and flags) in the generated - * `.cloudflare/types/index.d.ts`. Defaults to `true`. - */ - includeRuntime?: boolean; -} - -interface ResolvedTypeGenerationOptions { - generate: boolean; - includeRuntime: boolean; -} - -interface Experimental { - /** Experimental support for handling the _headers and _redirects files during Vite dev mode. */ - headersAndRedirectsDevModeSupport?: boolean; -} - -function normalizeTypes( - option: TypeGenerationOptions | undefined -): ResolvedTypeGenerationOptions { - return { - generate: option?.generate ?? true, - includeRuntime: option?.includeRuntime ?? true, - }; -} - -type EntryWorkerConfigCustomizer = - | Partial - | (( - config: ParsedInputWorkerConfig - ) => Partial | void); - -type WorkerConfigProvider = - | InputWorkerConfig - | ((options: { - entryWorkerConfig: ParsedInputWorkerConfig; - }) => InputWorkerConfig); - -export interface PluginConfig extends EntryWorkerOptions { - auxiliaryWorkers?: AuxiliaryWorkerOptions[]; - /** Configuration for a dedicated prerender Worker. */ - prerenderWorker?: PrerenderWorkerOptions; - persistState?: PersistState; - inspectorPort?: number | false; - remoteBindings?: boolean; - tunnel?: boolean | TunnelConfig; - /** - * Options for generating Worker types from `cloudflare.config.ts`. Type - * generation is skipped when the file does not exist. - */ - types?: TypeGenerationOptions; - experimental?: Experimental; -} - -export type ResolvedAssetsOnlyConfig = Omit< - ParsedInputWorkerConfig, - "entrypoint" ->; - -export type ResolvedWorkerConfig = Omit< - ParsedInputWorkerConfig, - "entrypoint" -> & { - entrypoint: string; -}; - -export interface Worker { - config: ResolvedWorkerConfig; - directoryName: string; - nodeJsCompat: NodeJsCompat | undefined; - devOnly: DevOnly | undefined; -} - -interface BaseResolvedConfig { - cliDisplayName: string; - persistState: PersistState; - inspectorPort: number | false | undefined; - types: ResolvedTypeGenerationOptions; - experimental: Pick; - remoteBindings: boolean; - tunnel: TunnelConfig; - localEnv: LoadedEnv; - devVars: Record | undefined; - settings: ParsedInputSettingsConfig; -} - -interface NonPreviewResolvedConfig extends BaseResolvedConfig { - containers: ParsedInputContainerConfig[]; - configPaths: Set; - environmentNameToWorkerMap: Map; - environmentNameToChildEnvironmentNamesMap: Map; - prerenderWorkerEnvironmentName: string | undefined; -} - -export interface AssetsOnlyResolvedConfig extends NonPreviewResolvedConfig { - type: "assets-only"; - config: ResolvedAssetsOnlyConfig; -} - -export interface WorkersResolvedConfig extends NonPreviewResolvedConfig { - type: "workers"; - entryWorkerEnvironmentName: string; - staticRouting: StaticRouting | undefined; -} - -export interface PreviewResolvedConfig extends BaseResolvedConfig { - type: "preview"; - containers: ParsedOutputContainerConfig[]; - workers: BuildOutputPreviewWorker[]; -} - -export type ResolvedPluginConfig = - | AssetsOnlyResolvedConfig - | WorkersResolvedConfig - | PreviewResolvedConfig; - -type ResolvedWorker = - | { type: "assets-only"; config: ResolvedAssetsOnlyConfig } - | { type: "worker"; config: ResolvedWorkerConfig }; - -function createDefaultWorkerConfig(name: string): ParsedInputWorkerConfig { - return { - name, - compatibilityDate: DEFAULT_COMPAT_DATE, - }; -} - -/** Resolves the entry Worker config. */ -function resolveEntryWorkerConfig(options: { - workerConfig: ParsedInputWorkerConfig; - configCustomizer: EntryWorkerConfigCustomizer | undefined; -}): ResolvedWorker { - const configResult = - typeof options.configCustomizer === "function" - ? options.configCustomizer(options.workerConfig) - : options.configCustomizer; - const config = InputWorkerSchema.parse( - configResult - ? defu(configResult, options.workerConfig) - : options.workerConfig - ); - - return resolveWorkerType({ config, isEntryWorker: true }); -} - -/** Resolves an auxiliary or prerender Worker config. */ -function resolveNonEntryWorkerConfig(options: { - config: WorkerConfigProvider; - entryWorkerConfig: ParsedInputWorkerConfig; -}): Extract { - const inputConfig = - typeof options.config === "function" - ? options.config({ entryWorkerConfig: options.entryWorkerConfig }) - : options.config; - const config = InputWorkerSchema.parse(inputConfig); - - return resolveWorkerType({ config, isEntryWorker: false }); -} - -function resolveWorkerType(options: { - config: ParsedInputWorkerConfig; - isEntryWorker: false; -}): Extract; -function resolveWorkerType(options: { - config: ParsedInputWorkerConfig; - isEntryWorker: true; -}): ResolvedWorker; -function resolveWorkerType(options: { - config: ParsedInputWorkerConfig; - isEntryWorker: boolean; -}): ResolvedWorker { - const { config, isEntryWorker } = options; - - if (!isEntryWorker && config.assets) { - throw new Error("assets are only supported by the default Worker"); - } - - if (config.entrypoint === undefined) { - if (!isEntryWorker) { - throw new Error( - "Auxiliary and prerender Workers must configure an `entrypoint`." - ); - } - const { entrypoint: _entrypoint, ...assetsOnlyConfig } = config; - return { type: "assets-only", config: assetsOnlyConfig }; - } - - return { - type: "worker", - config: { ...config, entrypoint: config.entrypoint }, - }; -} +export { resolveDevOnly } from "@cloudflare/workers-dev-shared"; +export type { + AssetsOnlyResolvedConfig, + PersistState, + PluginConfig, + PreviewResolvedConfig, + ResolvedAssetsOnlyConfig, + ResolvedPluginConfig, + ResolvedWorkerConfig, + TunnelConfig, + Worker, + WorkersResolvedConfig, +} from "@cloudflare/workers-dev-shared"; export async function resolvePluginConfig( pluginConfig: PluginConfig, userConfig: vite.UserConfig, viteEnv: vite.ConfigEnv ): Promise { - if ( - typeof pluginConfig.tunnel === "object" && - pluginConfig.tunnel.name !== undefined && - (pluginConfig.tunnel.allowedMail?.length ?? 0) > 0 - ) { - throw new Error( - "`tunnel.allowedMail` is only supported for Quick Tunnels and cannot be used together with `tunnel.name`." - ); - } const mode = "mode" in userConfig && typeof userConfig.mode === "string" ? userConfig.mode : viteEnv.mode; const root = userConfig.root ? path.resolve(userConfig.root) : process.cwd(); - const envDir = resolveEnvDir(root, userConfig.envDir); - const preview = viteEnv.isPreview - ? await readBuildOutputPreview(root, !!process.env.CLOUDFLARE_VITE_BUILD) - : undefined; - const localEnvMode = preview?.rootConfig.buildContext.mode ?? mode; - const [localEnv, devVars] = await Promise.all([ - loadEnv(envDir, localEnvMode), - loadDevVars(envDir, localEnvMode), - ]); - // TODO: Replace this process-global compatibility bridge by explicitly - // passing typed Cloudflare tool settings to the services that consume them. - Object.assign( - process.env, - Object.fromEntries( - Object.entries(localEnv.values).filter(([name]) => - name.startsWith("CLOUDFLARE_") - ) - ) - ); - const types = normalizeTypes(pluginConfig.types); - const shared = { - cliDisplayName: getCliDisplayName(), - persistState: pluginConfig.persistState ?? true, - inspectorPort: pluginConfig.inspectorPort, - types, - tunnel: - typeof pluginConfig.tunnel === "boolean" - ? { autoStart: pluginConfig.tunnel } - : { - autoStart: pluginConfig.tunnel?.autoStart ?? false, - name: pluginConfig.tunnel?.name, - allowedMail: pluginConfig.tunnel?.allowedMail?.length - ? pluginConfig.tunnel.allowedMail - : undefined, - }, - experimental: { - headersAndRedirectsDevModeSupport: - pluginConfig.experimental?.headersAndRedirectsDevModeSupport, - }, - localEnv, - devVars, - }; - - // The `cf-vite` delegate binary's `--local` flag sets this env var to - // force remote bindings off, overriding any `remoteBindings` value in the - // plugin config (mirrors `wrangler dev --local`). - const remoteBindings = - localEnv.values.CLOUDFLARE_VITE_FORCE_LOCAL === "true" - ? false - : (pluginConfig.remoteBindings ?? true); - if (preview !== undefined) { - const { accountId, complianceRegion } = preview.rootConfig; - return { - ...shared, - remoteBindings, - type: "preview", - settings: { accountId, complianceRegion }, - containers: preview.containers, - workers: preview.workers, - }; - } - - const configPaths = new Set(); - const validateAndAddEnvironmentName = createEnvironmentNameValidator(); - - const loadedConfig = await loadCloudflareConfig({ + return resolveSharedPluginConfig(pluginConfig, { root, mode, - isPreview: isPreviewBuild(), - types, - }); - const settings = { - accountId: loadedConfig?.parsedConfig.accountId, - complianceRegion: loadedConfig?.parsedConfig.complianceRegion, - }; - const containers = loadedConfig?.parsedConfig.containers ?? []; - if (loadedConfig) { - configPaths.add(loadedConfig.configPath); - for (const dep of loadedConfig.dependencies) { - configPaths.add(dep); - } - } - - // Type generation happens while loading the file above. The plugin-level - // customizer is intentionally applied afterwards so generated declarations - // only describe `cloudflare.config.ts`. - const entryWorkerResolvedConfig = resolveEntryWorkerConfig({ - workerConfig: - loadedConfig === undefined - ? createDefaultWorkerConfig(getWorkerNameFromProject(root)) - : loadedConfig.parsedConfig.worker, - configCustomizer: pluginConfig.config, - }); - - const environmentNameToWorkerMap = new Map(); - const environmentNameToChildEnvironmentNamesMap = new Map(); - const validateAndAddWorkerName = createWorkerNameValidator(); - validateAndAddWorkerName(entryWorkerResolvedConfig.config.name); - - const prerenderWorkerConfig = pluginConfig.prerenderWorker; - let prerenderWorkerEnvironmentName: string | undefined; - - if (prerenderWorkerConfig && viteEnv.command === "build") { - const workerResolvedConfig = resolveNonEntryWorkerConfig({ - config: prerenderWorkerConfig.config, - entryWorkerConfig: entryWorkerResolvedConfig.config, - }); - validateAndAddWorkerName(workerResolvedConfig.config.name); - - prerenderWorkerEnvironmentName = - prerenderWorkerConfig.viteEnvironment?.name ?? - workerNameToEnvironmentName(workerResolvedConfig.config.name); - - validateAndAddEnvironmentName(prerenderWorkerEnvironmentName); - - environmentNameToWorkerMap.set( - prerenderWorkerEnvironmentName, - resolveWorker(workerResolvedConfig.config, undefined, "prerender") - ); - - const prerenderWorkerChildEnvironments = - prerenderWorkerConfig.viteEnvironment?.childEnvironments; - - if (prerenderWorkerChildEnvironments) { - for (const childName of prerenderWorkerChildEnvironments) { - validateAndAddEnvironmentName(childName); - } - - environmentNameToChildEnvironmentNamesMap.set( - prerenderWorkerEnvironmentName, - prerenderWorkerChildEnvironments - ); - } - } - - if (entryWorkerResolvedConfig.type === "assets-only") { - addAuxiliaryWorkers({ - auxiliaryWorkers: pluginConfig.auxiliaryWorkers, - entryWorkerConfig: entryWorkerResolvedConfig.config, - environmentNameToWorkerMap, - environmentNameToChildEnvironmentNamesMap, - validateAndAddEnvironmentName, - validateAndAddWorkerName, - }); - return { - ...shared, - type: "assets-only", - config: entryWorkerResolvedConfig.config, - settings, - containers, - environmentNameToWorkerMap, - environmentNameToChildEnvironmentNamesMap, - prerenderWorkerEnvironmentName, - configPaths, - remoteBindings, - }; - } - - let staticRouting: StaticRouting | undefined; - - if (Array.isArray(entryWorkerResolvedConfig.config.assets?.runWorkerFirst)) { - staticRouting = parseStaticRouting( - entryWorkerResolvedConfig.config.assets.runWorkerFirst - ); - } - - const entryWorkerEnvironmentName = - pluginConfig.viteEnvironment?.name ?? "ssr"; - - validateAndAddEnvironmentName(entryWorkerEnvironmentName); - - environmentNameToWorkerMap.set( - entryWorkerEnvironmentName, - resolveWorker( - entryWorkerResolvedConfig.config, - pluginConfig.assetsOnly, - "default" - ) - ); - - const entryWorkerChildEnvironments = - pluginConfig.viteEnvironment?.childEnvironments; - - if (entryWorkerChildEnvironments) { - for (const childName of entryWorkerChildEnvironments) { - validateAndAddEnvironmentName(childName); - } - - environmentNameToChildEnvironmentNamesMap.set( - entryWorkerEnvironmentName, - entryWorkerChildEnvironments - ); - } - - addAuxiliaryWorkers({ - auxiliaryWorkers: pluginConfig.auxiliaryWorkers, - entryWorkerConfig: entryWorkerResolvedConfig.config, - environmentNameToWorkerMap, - environmentNameToChildEnvironmentNamesMap, - validateAndAddEnvironmentName, - validateAndAddWorkerName, + envDir: resolveEnvDir(root, userConfig.envDir), + command: viteEnv.command, + preview: viteEnv.isPreview + ? await readBuildOutputPreview(root, !!process.env.CLOUDFLARE_VITE_BUILD) + : undefined, }); - - return { - ...shared, - type: "workers", - configPaths, - environmentNameToWorkerMap, - environmentNameToChildEnvironmentNamesMap, - prerenderWorkerEnvironmentName, - settings, - containers, - entryWorkerEnvironmentName, - staticRouting, - remoteBindings, - }; -} - -function addAuxiliaryWorkers(options: { - auxiliaryWorkers: AuxiliaryWorkerOptions[] | undefined; - entryWorkerConfig: ParsedInputWorkerConfig; - environmentNameToWorkerMap: Map; - environmentNameToChildEnvironmentNamesMap: Map; - validateAndAddEnvironmentName: (name: string) => void; - validateAndAddWorkerName: (name: string) => void; -}): void { - const usedDirectoryNames = new Map(); - for (const auxiliaryWorker of options.auxiliaryWorkers ?? []) { - const workerResolvedConfig = resolveNonEntryWorkerConfig({ - config: auxiliaryWorker.config, - entryWorkerConfig: options.entryWorkerConfig, - }); - const workerName = workerResolvedConfig.config.name; - options.validateAndAddWorkerName(workerName); - - const workerDirectoryName = getAuxiliaryWorkerDirectoryName(workerName); - validateAuxiliaryWorkerDirectoryName({ - workerName, - workerDirectoryName, - usedDirectoryNames, - }); - const workerEnvironmentName = - auxiliaryWorker.viteEnvironment?.name ?? - workerNameToEnvironmentName(workerName); - options.validateAndAddEnvironmentName(workerEnvironmentName); - - options.environmentNameToWorkerMap.set( - workerEnvironmentName, - resolveWorker( - workerResolvedConfig.config, - auxiliaryWorker.devOnly, - workerDirectoryName - ) - ); - - const childEnvironmentNames = - auxiliaryWorker.viteEnvironment?.childEnvironments; - if (childEnvironmentNames) { - for (const childName of childEnvironmentNames) { - options.validateAndAddEnvironmentName(childName); - } - options.environmentNameToChildEnvironmentNamesMap.set( - workerEnvironmentName, - childEnvironmentNames - ); - } - } } /** @@ -578,97 +58,3 @@ export function resolveEnvDir( ): string | false { return envDir === false ? false : path.resolve(root, envDir ?? "."); } - -const RESERVED_WORKER_DIRECTORY_NAMES = new Set([ - DEFAULT_WORKER_DIRECTORY_NAME, - PRERENDER_WORKER_DIRECTORY_NAME, -]); - -function getAuxiliaryWorkerDirectoryName(workerName: string): string { - const directoryName = normalizeDirectoryName(workerName); - - return RESERVED_WORKER_DIRECTORY_NAMES.has(directoryName) - ? `_${directoryName}` - : directoryName; -} - -function validateAuxiliaryWorkerDirectoryName(options: { - workerName: string; - workerDirectoryName: string; - usedDirectoryNames: Map; -}): void { - const existingWorkerName = options.usedDirectoryNames.get( - options.workerDirectoryName - ); - if (existingWorkerName) { - throw new Error( - `The \`${options.workerName}\` and \`${existingWorkerName}\` auxiliary Worker names both produce the Build Output directory name \`${options.workerDirectoryName}\`.` - ); - } - - options.usedDirectoryNames.set( - options.workerDirectoryName, - options.workerName - ); -} - -// Worker names can only contain alphanumeric characters and '-' whereas -// environment names can only contain alphanumeric characters and '$', '_'. -function workerNameToEnvironmentName(workerName: string): string { - return workerName.replaceAll("-", "_"); -} - -function createWorkerNameValidator() { - const usedNames = new Set(); - - return (name: string): void => { - if (usedNames.has(name)) { - throw new Error(`Duplicate Worker name: "${name}"`); - } - - usedNames.add(name); - }; -} - -function createEnvironmentNameValidator() { - const usedNames = new Set(); - - return (name: string): void => { - if (name === "client") { - throw new Error(`"client" is a reserved Vite environment name`); - } - - if (usedNames.has(name)) { - throw new Error(`Duplicate Vite environment name: "${name}"`); - } - - usedNames.add(name); - }; -} - -/** - * Evaluates the `devOnly` value. Should be called lazily at build time - * to allow frameworks to provide the value after initialization. - */ -export function resolveDevOnly(devOnly: DevOnly | undefined): boolean { - if (typeof devOnly === "function") { - return devOnly(); - } - - return devOnly ?? false; -} - -function resolveWorker( - workerConfig: ResolvedWorkerConfig, - devOnly: DevOnly | undefined, - directoryName: string -): Worker { - return { - config: workerConfig, - directoryName, - nodeJsCompat: hasNodeJsCompat(workerConfig) - ? new NodeJsCompat(workerConfig) - : undefined, - devOnly, - }; -} diff --git a/packages/vite-plugin-cloudflare/src/plugins/additional-modules.ts b/packages/vite-plugin-cloudflare/src/plugins/additional-modules.ts index 77bc42d55fa..cce7e965ceb 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/additional-modules.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/additional-modules.ts @@ -1,10 +1,17 @@ import assert from "node:assert"; import * as fsp from "node:fs/promises"; import * as path from "node:path"; +import { + ADDITIONAL_MODULE_TYPES, + isAdditionalModuleType, + matchAdditionalModule, + moduleRuleFilters, + toModuleType, +} from "@cloudflare/workers-dev-shared"; import MagicString from "magic-string"; import * as vite from "vite"; import { cleanUrl, createPlugin } from "../utils"; -import type { ModuleType } from "@cloudflare/config"; +import type { AdditionalModuleType } from "@cloudflare/workers-dev-shared"; /** * Plugin to support additional module types (`CompiledWasm`, `Data` and `Text`) @@ -129,57 +136,10 @@ export const additionalModulesPlugin = createPlugin( } ); -const ADDITIONAL_MODULE_TYPES = ["CompiledWasm", "Data", "Text"] as const; -type AdditionalModuleType = (typeof ADDITIONAL_MODULE_TYPES)[number]; - -function isAdditionalModuleType( - type: string | undefined -): type is AdditionalModuleType { - return ADDITIONAL_MODULE_TYPES.some((moduleType) => moduleType === type); -} - -function toModuleType(type: AdditionalModuleType): ModuleType { - switch (type) { - case "CompiledWasm": - return "wasm"; - case "Data": - return "data"; - case "Text": - return "text"; - } -} - const ADDITIONAL_MODULE_PATTERN = `__CLOUDFLARE_MODULE__(${ADDITIONAL_MODULE_TYPES.join("|")})__(.*?)__CLOUDFLARE_MODULE__`; export const additionalModuleRE = new RegExp(ADDITIONAL_MODULE_PATTERN); const additionalModuleGlobalRE = new RegExp(ADDITIONAL_MODULE_PATTERN, "g"); -type ModuleRules = Array<{ - type: AdditionalModuleType; - pattern: RegExp; -}>; - -const moduleRules: ModuleRules = [ - { type: "CompiledWasm", pattern: /\.wasm(\?module)?$/ }, - { type: "Data", pattern: /\.bin$/ }, - { type: "Text", pattern: /\.(txt|html|sql)$/ }, -]; - -const subpathImportRE = /^#/; -const moduleRuleFilters = [ - ...moduleRules.map((rule) => rule.pattern), - subpathImportRE, -]; - -function matchAdditionalModule(source: string) { - for (const rule of moduleRules) { - if (rule.pattern.test(source)) { - return rule.type; - } - } - - return null; -} - function createModuleReference(type: AdditionalModuleType, id: string) { return `__CLOUDFLARE_MODULE__${type}__${id}__CLOUDFLARE_MODULE__`; } diff --git a/packages/vite-plugin-cloudflare/src/plugins/build-output.ts b/packages/vite-plugin-cloudflare/src/plugins/build-output.ts index 3ac4da2243e..69108c299e9 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/build-output.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/build-output.ts @@ -7,10 +7,10 @@ import { writeWorkerConfig as writeBuildOutputWorkerConfig, } from "@cloudflare/build-output-utils"; import { buildAndWriteContainerOutput } from "@cloudflare/containers-shared"; +import { isPreviewBuild } from "@cloudflare/workers-dev-shared"; import { getDockerPath } from "@cloudflare/workers-utils/docker-path"; import * as vite from "vite"; import { loadViteManifest } from "../build"; -import { isPreviewBuild } from "../build-output-env"; import { MAIN_ENTRY_NAME } from "../cloudflare-environment"; import { assertIsNotPreview } from "../context"; import { resolveDevOnly } from "../plugin-config"; diff --git a/packages/vite-plugin-cloudflare/src/plugins/debug.ts b/packages/vite-plugin-cloudflare/src/plugins/debug.ts index 74a8113089a..8539a1dee0c 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/debug.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/debug.ts @@ -1,10 +1,10 @@ import assert from "node:assert"; -import { assertIsNotPreview, assertIsPreview } from "../context"; import { - addDebugToVitePrintUrls, + createDebugMiddleware, DEBUG_PATH, - getDebugPathHtml, -} from "../debug"; +} from "@cloudflare/workers-dev-shared"; +import { assertIsNotPreview, assertIsPreview } from "../context"; +import { addDebugToVitePrintUrls } from "../debug"; import { createPlugin } from "../utils"; /** @@ -38,17 +38,10 @@ export const debugPlugin = createPlugin("debug", (ctx) => { ].map((worker) => worker.config.name) : []; - viteDevServer.middlewares.use(DEBUG_PATH, async (_, res, next) => { - const resolvedInspectorPort = await ctx.getResolvedInspectorPort(); - - if (resolvedInspectorPort) { - const html = getDebugPathHtml(workerNames, resolvedInspectorPort); - res.setHeader("Content-Type", "text/html"); - res.end(html); - } else { - next(); - } - }); + viteDevServer.middlewares.use( + DEBUG_PATH, + createDebugMiddleware(workerNames, () => ctx.getResolvedInspectorPort()) + ); }, async configurePreviewServer(vitePreviewServer) { assertIsPreview(ctx); @@ -73,17 +66,10 @@ export const debugPlugin = createPlugin("debug", (ctx) => { return worker.config.name; }); - vitePreviewServer.middlewares.use(DEBUG_PATH, async (_, res, next) => { - const resolvedInspectorPort = await ctx.getResolvedInspectorPort(); - - if (resolvedInspectorPort) { - const html = getDebugPathHtml(workerNames, resolvedInspectorPort); - res.setHeader("Content-Type", "text/html"); - res.end(html); - } else { - next(); - } - }); + vitePreviewServer.middlewares.use( + DEBUG_PATH, + createDebugMiddleware(workerNames, () => ctx.getResolvedInspectorPort()) + ); }, }; }); diff --git a/packages/vite-plugin-cloudflare/src/plugins/dev.ts b/packages/vite-plugin-cloudflare/src/plugins/dev.ts index 2f0ad6bafd7..953fe92c5bc 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/dev.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/dev.ts @@ -1,14 +1,14 @@ import assert from "node:assert"; -import { generateStaticRoutingRuleMatcher } from "@cloudflare/workers-shared/asset-worker/src/utils/rules-engine"; +import { + createStaticRoutingMiddleware, + dispatchDevRequest, + handleWebSocket, + resolveStaticRouting, +} from "@cloudflare/workers-dev-shared"; import { getDockerPath } from "@cloudflare/workers-utils/docker-path"; import { buildPublicUrl, CoreHeaders } from "miniflare"; import colors from "picocolors"; import { initRunners } from "../cloudflare-environment"; -import { - ASSET_WORKER_NAME, - kRequestType, - ROUTER_WORKER_NAME, -} from "../constants"; import { getDevContainerCleanup } from "../container-cleanup"; import { prepareContainerImagesForVite } from "../containers"; import { assertIsNotPreview } from "../context"; @@ -18,10 +18,12 @@ import { getCurrentWorkerNameToExportTypesMap, } from "../export-types"; import { getDevMiniflareOptions } from "../miniflare-options"; -import { UNKNOWN_HOST } from "../shared"; -import { createPlugin, createRequestHandler, debuglog } from "../utils"; -import { handleWebSocket } from "../websockets"; -import type { StaticRouting } from "@cloudflare/workers-shared/utils/types"; +import { + createPlugin, + createRequestHandler, + debuglog, + isViteWebSocketUpgrade, +} from "../utils"; /** * Plugin to provide core development functionality @@ -155,25 +157,18 @@ export const devPlugin = createPlugin("dev", (ctx) => { // The HTTP server is not available in middleware mode if (viteDevServer.httpServer) { - handleWebSocket( - viteDevServer.httpServer, - ctx.miniflare, - entryWorkerName - ); + handleWebSocket(viteDevServer.httpServer, ctx.miniflare, { + entryWorkerName, + isBundlerUpgrade: isViteWebSocketUpgrade, + }); } - const staticRouting: StaticRouting | undefined = - entryWorkerConfig.assets?.runWorkerFirst === true - ? { user_worker: ["/*"] } - : ctx.resolvedPluginConfig.staticRouting; + const staticRouting = resolveStaticRouting( + entryWorkerConfig, + ctx.resolvedPluginConfig.staticRouting + ); if (staticRouting) { - const excludeRulesMatcher = generateStaticRoutingRuleMatcher( - staticRouting.asset_worker ?? [] - ); - const includeRulesMatcher = generateStaticRoutingRuleMatcher( - staticRouting.user_worker - ); const userWorkerHandler = createRequestHandler(async (request) => { request.headers.set(CoreHeaders.ROUTE_OVERRIDE, entryWorkerName); @@ -183,22 +178,10 @@ export const devPlugin = createPlugin("dev", (ctx) => { }); viteDevServer.middlewares.use( - async function cloudflarePreMiddleware(req, res, next) { - assert(req.url, `req.url not defined`); - // Only the URL pathname is used to match rules - const request = new Request(new URL(req.url, UNKNOWN_HOST)); - - if (req[kRequestType] === "asset") { - next(); - } else if (excludeRulesMatcher({ request })) { - req[kRequestType] = "asset"; - next(); - } else if (includeRulesMatcher({ request })) { - void userWorkerHandler(req, res, next); - } else { - next(); - } - } + createStaticRoutingMiddleware( + staticRouting, + (req, res, next) => void userWorkerHandler(req, res, next) + ) ); } @@ -237,27 +220,9 @@ export const devPlugin = createPlugin("dev", (ctx) => { return () => { // post middleware viteDevServer.middlewares.use( - createRequestHandler(async (request, req) => { - if (req[kRequestType] === "asset") { - request.headers.set( - CoreHeaders.ROUTE_OVERRIDE, - ASSET_WORKER_NAME - ); - - return ctx.miniflare.dispatchFetch(request, { - redirect: "manual", - }); - } else { - request.headers.set( - CoreHeaders.ROUTE_OVERRIDE, - ROUTER_WORKER_NAME - ); - - return ctx.miniflare.dispatchFetch(request, { - redirect: "manual", - }); - } - }) + createRequestHandler((request, req) => + dispatchDevRequest(ctx.miniflare, request, req) + ) ); }; }, diff --git a/packages/vite-plugin-cloudflare/src/plugins/nodejs-compat.ts b/packages/vite-plugin-cloudflare/src/plugins/nodejs-compat.ts index bc8d6d5642b..79f3e94cae2 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/nodejs-compat.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/nodejs-compat.ts @@ -1,9 +1,8 @@ import assert from "node:assert"; +import { hasNodeJsAls, isNodeAlsModule } from "@cloudflare/workers-dev-shared"; import * as vite from "vite"; import { assertHasNodeJsCompat, - hasNodeJsAls, - isNodeAlsModule, nodeBuiltinsRE, NodeJsCompatWarnings, } from "../nodejs-compat"; diff --git a/packages/vite-plugin-cloudflare/src/plugins/preview.ts b/packages/vite-plugin-cloudflare/src/plugins/preview.ts index b31ccc196dc..2c18b054cb9 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/preview.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/preview.ts @@ -1,11 +1,15 @@ +import { handleWebSocket } from "@cloudflare/workers-dev-shared"; import { getDockerPath } from "@cloudflare/workers-utils/docker-path"; import { buildPublicUrl } from "miniflare"; import colors from "picocolors"; import { prepareContainerImagesForVite } from "../containers"; import { assertIsPreview } from "../context"; import { getPreviewMiniflareOptions } from "../miniflare-options"; -import { createPlugin, createRequestHandler } from "../utils"; -import { handleWebSocket } from "../websockets"; +import { + createPlugin, + createRequestHandler, + isViteWebSocketUpgrade, +} from "../utils"; /** * Plugin to provide core preview functionality @@ -64,7 +68,9 @@ export const previewPlugin = createPlugin("preview", (ctx) => { } }); } - handleWebSocket(vitePreviewServer.httpServer, ctx.miniflare); + handleWebSocket(vitePreviewServer.httpServer, ctx.miniflare, { + isBundlerUpgrade: isViteWebSocketUpgrade, + }); // In preview mode we put our middleware at the front of the chain so that all assets are handled in Miniflare vitePreviewServer.middlewares.use( diff --git a/packages/vite-plugin-cloudflare/src/plugins/tunnel.ts b/packages/vite-plugin-cloudflare/src/plugins/tunnel.ts index 24850e77b47..d09e8235c7e 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/tunnel.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/tunnel.ts @@ -1,4 +1,5 @@ import { isDeepStrictEqual } from "node:util"; +import { toApiComplianceRegion } from "@cloudflare/workers-dev-shared"; import { resolveNamedTunnel, startTunnel } from "@cloudflare/workers-utils"; import getPort from "get-port"; import { buildPublicUrl } from "miniflare"; @@ -6,7 +7,7 @@ import colors from "picocolors"; import encodeQR from "qr"; import { createAuth, createLogger, USER_AGENT } from "../auth"; import { assertIsNotPreview, assertIsPreview } from "../context"; -import { createPlugin, debuglog, toApiComplianceRegion } from "../utils"; +import { createPlugin, debuglog } from "../utils"; import type { PluginContext } from "../context"; import type { Config, Tunnel } from "@cloudflare/workers-utils"; import type * as vite from "vite"; diff --git a/packages/vite-plugin-cloudflare/src/plugins/wasm.ts b/packages/vite-plugin-cloudflare/src/plugins/wasm.ts index df2d8485a6c..c7018f2eb2c 100644 --- a/packages/vite-plugin-cloudflare/src/plugins/wasm.ts +++ b/packages/vite-plugin-cloudflare/src/plugins/wasm.ts @@ -1,7 +1,6 @@ +import { getWasmInitModule, wasmInitRE } from "@cloudflare/workers-dev-shared"; import { cleanUrl, createPlugin } from "../utils"; -const wasmInitRE = /\.wasm\?init$/; - /** * Plugin to support the `.wasm?init` extension */ @@ -14,12 +13,7 @@ export const wasmHelperPlugin = createPlugin("wasm-helper", (ctx) => { load: { filter: { id: wasmInitRE }, handler(id) { - return ` - import wasm from "${cleanUrl(id)}"; - export default function(opts = {}) { - return WebAssembly.instantiate(wasm, opts); - } - `; + return getWasmInitModule(cleanUrl(id)); }, }, }; diff --git a/packages/vite-plugin-cloudflare/src/utils.ts b/packages/vite-plugin-cloudflare/src/utils.ts index da2e382f1d1..d7a6bb601bc 100644 --- a/packages/vite-plugin-cloudflare/src/utils.ts +++ b/packages/vite-plugin-cloudflare/src/utils.ts @@ -1,20 +1,11 @@ import * as nodePath from "node:path"; import * as util from "node:util"; -import { createHeaders, sendResponse } from "@remix-run/node-fetch-server"; -import { - CoreHeaders, - parseForwardedProto, - Request as MiniflareRequest, - Response as MiniflareResponse, -} from "miniflare"; +import { createRequestHandler as createSharedRequestHandler } from "@cloudflare/workers-dev-shared"; import semverGte from "semver/functions/gte"; import { version as viteVersion } from "vite"; import * as vite from "vite"; -import { PROXY_SHARED_SECRET } from "./constants"; import { warnIfQuickTunnelSseResponse } from "./plugins/tunnel"; import type { PluginContext } from "./context"; -import type { ParsedInputSettingsConfig } from "@cloudflare/config"; -import type { ComplianceConfig } from "@cloudflare/workers-utils"; import type * as http from "node:http"; export const debuglog = util.debuglog("@cloudflare:vite-plugin"); @@ -59,270 +50,28 @@ export type MaybePromise = Promise | T; export type Defined = Exclude; -export function toApiComplianceRegion( - region: ParsedInputSettingsConfig["complianceRegion"] -): ComplianceConfig["compliance_region"] { - return region === "fedramp-high" ? "fedramp_high" : region; -} - export function withTrailingSlash(path: string): string { return path.endsWith("/") ? path : `${path}/`; } export function createRequestHandler( - handler: ( - request: MiniflareRequest, - req: vite.Connect.IncomingMessage - ) => Promise -): ( - req: vite.Connect.IncomingMessage, - res: http.ServerResponse, - next: vite.Connect.NextFunction -) => Promise { - return async (req, res, next) => { - let request: Request | undefined; - - try { - // Built in vite middleware trims out the base path when passing in the request - // We can restore it by using the `originalUrl` property - // This makes sure the worker receives the correct url in both dev using vite and production - if (req.originalUrl) { - req.url = req.originalUrl; - } - // Honor the `X-Forwarded-Proto` header on the incoming request so that - // `request.url` reflects the protocol the original client used, not the - // HTTP connection between the proxy and the dev server. This is needed - // when the Vite dev server sits behind a TLS-terminating reverse proxy - // or tunnel (cloudflared, ngrok, etc.) so that frameworks performing - // Origin/Host checks (e.g. CSRF protection) see a consistent origin. - // - // We trust this unconditionally because the Vite dev server is a - // local-only dev tool and this header is the de-facto standard set by - // reverse proxies. Browser-mediated CSRF attacks cannot reach here: - // `X-Forwarded-Proto` is not a CORS-safelisted header, so a `fetch()` - // that sets it triggers a CORS preflight, and simple form-submitted - // POSTs cannot include custom headers at all. - // - // If the header is absent or invalid, `createRequest` falls back to the - // connection protocol (`req.socket.encrypted`). - const protocol = getForwardedProto(req); - request = createRequestForIncomingMessage( - req, - res, - protocol ? { protocol } : undefined - ); - - let response = await handler(toMiniflareRequest(request), req); - - // Vite uses HTTP/2 when `server.https` or `preview.https` is enabled - if (req.httpVersionMajor === 2) { - response = new MiniflareResponse(response.body, response); - // HTTP/2 disallows use of the `transfer-encoding` header - response.headers.delete("transfer-encoding"); - } - - warnIfQuickTunnelSseResponse(response.headers.get("content-type")); - - await sendResponse(res, response as unknown as Response); - } catch (error) { - if (request?.signal.aborted) { - // If the request was aborted, ignore the error - return; - } - - next(error); - } - }; -} - -export function satisfiesMinimumViteVersion(minVersion: string): boolean { - return semverGte(viteVersion, minVersion); -} - -export function createRequestForIncomingMessage( - req: vite.Connect.IncomingMessage, - res: http.ServerResponse, - options?: { protocol?: "http:" | "https:"; host?: string } -): Request { - const controller = new AbortController(); - res.on("close", () => { - controller.abort(); + handler: Parameters[0] +): ReturnType { + return createSharedRequestHandler(handler, { + onResponse: (response) => + warnIfQuickTunnelSseResponse(response.headers.get("content-type")), }); - - const method = req.method ?? "GET"; - const headers = createHeaders(req); - const protocol = - options?.protocol ?? - getScheme(req) ?? - ("encrypted" in req.socket && req.socket.encrypted ? "https:" : "http:"); - const host = - options?.host ?? getAuthority(req) ?? headers.get("Host") ?? "localhost"; - if (headers.get("Host") !== host) { - headers.set("Host", host); - } - const url = new URL(req.url ?? "/", `${protocol}//${host}`); - const init: RequestInit & { duplex?: "half" } = { - method, - headers, - signal: controller.signal, - }; - - if (method !== "GET" && method !== "HEAD") { - init.body = createCancellableRequestBody(req); - init.duplex = "half"; - } - - return new Request(url, init); } -function createCancellableRequestBody( - req: vite.Connect.IncomingMessage -): ReadableStream { - let cleanup: (() => void) | undefined; - - return new ReadableStream({ - start(controller) { - const onData = (chunk: Buffer) => { - try { - controller.enqueue( - new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength) - ); - } catch (error) { - if ( - error instanceof TypeError && - error.message.includes("Controller is already closed") - ) { - cleanup?.(); - req.resume(); - return; - } - throw error; - } - }; - const onEnd = () => { - cleanup?.(); - controller.close(); - }; - const onError = (error: Error) => { - cleanup?.(); - controller.error(error); - }; - - cleanup = () => { - req.off("data", onData); - req.off("end", onEnd); - req.off("error", onError); - cleanup = undefined; - }; - - req.on("data", onData); - req.on("end", onEnd); - req.on("error", onError); - }, - cancel() { - cleanup?.(); - req.resume(); - }, - }); -} - -export function toMiniflareRequest(request: Request): MiniflareRequest { - const host = request.headers.get("Host") ?? new URL(request.url).host; - const xForwardedHost = request.headers.get("X-Forwarded-Host"); - - if (host && !xForwardedHost) { - // Set the `x-forwarded-host` header to the host of the Vite server if it is not already set - // Note that the `host` header inside the Worker will contain the workerd host - // TODO: reconsider this when adopting `miniflare.dispatchFetch` as it may be possible to provide the Vite server host in the `host` header - request.headers.set("X-Forwarded-Host", host); - } - - // Add the proxy shared secret to the request headers - // so the proxy worker can trust it and add host headers back - request.headers.set(CoreHeaders.PROXY_SHARED_SECRET, PROXY_SHARED_SECRET); - - // Undici sets the `Sec-Fetch-Mode` header to `cors` so we capture it in a custom header to be converted back later. - const secFetchMode = request.headers.get("Sec-Fetch-Mode"); - if (secFetchMode) { - request.headers.set(CoreHeaders.SEC_FETCH_MODE, secFetchMode); - } - return new MiniflareRequest(request.url, { - method: request.method, - headers: [["accept-encoding", "identity"], ...request.headers], - body: request.body, - duplex: "half", - signal: request.signal, - }); -} - -export const isRolldown = "rolldownVersion" in vite; - /** - * Parses the `X-Forwarded-Proto` header from an incoming Node.js request. - * - * If multiple proxies are in the chain, the header may be a comma-separated - * list — the left-most value is the original client-facing protocol. - * Returns `undefined` if the header is missing or holds an unsupported value. + * Identifies Vite's own WebSockets (e.g. HMR), which are left to Vite. */ -export function getForwardedProto(req: { - headers: http.IncomingHttpHeaders; -}): "http:" | "https:" | undefined { - const raw = req.headers["x-forwarded-proto"]; - return parseForwardedProto(Array.isArray(raw) ? raw[0] : raw); +export function isViteWebSocketUpgrade(request: http.IncomingMessage): boolean { + return request.headers["sec-websocket-protocol"]?.startsWith("vite") ?? false; } -/** - * Extracts the authority (host and port) from an incoming Node.js request. - * - * In HTTP/2, browsers pass the host and port in the `:authority` pseudo-header - * rather than the HTTP/1.1 `Host` header. This helper inspects `:authority` - * (or the `authority` property on `http2.Http2ServerRequest`) so that ports - * are preserved when converting Node requests to standard Fetch requests. - * - * @param req Incoming request containing HTTP headers and optional authority property - * @returns The authority string (e.g. "localhost:5173"), or undefined if not present - */ -export function getAuthority(req: { - headers: http.IncomingHttpHeaders; - authority?: string; -}): string | undefined { - const raw = req.headers[":authority"]; - const value = Array.isArray(raw) ? raw[0] : raw; - if (value) { - return value; - } - if (typeof req.authority === "string" && req.authority) { - return req.authority; - } - return undefined; +export function satisfiesMinimumViteVersion(minVersion: string): boolean { + return semverGte(viteVersion, minVersion); } -/** - * Extracts the scheme from an incoming Node.js request. - * - * Checks the `:scheme` pseudo-header in HTTP/2 requests or the `scheme` - * property on `http2.Http2ServerRequest`. - * - * @param req Incoming request containing HTTP headers and optional scheme property - * @returns The normalized scheme ("http:" or "https:"), or undefined if not present or unsupported - */ -export function getScheme(req: { - headers: http.IncomingHttpHeaders; - scheme?: string; -}): "http:" | "https:" | undefined { - const raw = req.headers[":scheme"]; - const value = Array.isArray(raw) ? raw[0] : raw; - if (value) { - const lower = value.toLowerCase(); - if (lower === "http" || lower === "https") { - return `${lower}:`; - } - } - if (typeof req.scheme === "string" && req.scheme) { - const lower = req.scheme.toLowerCase(); - if (lower === "http" || lower === "https") { - return `${lower}:`; - } - } - return undefined; -} +export const isRolldown = "rolldownVersion" in vite; diff --git a/packages/vite-plugin-cloudflare/tsdown.config.ts b/packages/vite-plugin-cloudflare/tsdown.config.ts index 9c65b80f1f8..d93ee01b503 100644 --- a/packages/vite-plugin-cloudflare/tsdown.config.ts +++ b/packages/vite-plugin-cloudflare/tsdown.config.ts @@ -13,8 +13,12 @@ export default defineConfig([ sourcemap, outDir: "dist", tsconfig: "tsconfig.plugin.json", + // Dependencies, whose types the declarations import rather than inline. + external: ["miniflare", "@cloudflare/config"], dts: { sourcemap, + // `@cloudflare/workers-dev-shared` is private, so inline its types. + resolve: ["@cloudflare/workers-dev-shared"], compilerOptions: { // workaround for https://github.com/rolldown/tsdown/issues/345 paths: { @@ -41,8 +45,15 @@ export default defineConfig([ dts: false, ignoreWatch, }, - worker("asset-worker"), - worker("router-worker"), + // Shared with `@cloudflare/rsbuild-plugin` (see `getDevAssetWorkerOptions()`). + worker("asset-worker", { + entry: { index: "../workers-dev-shared/src/workers/asset-worker/index.ts" }, + }), + worker("router-worker", { + entry: { + index: "../workers-dev-shared/src/workers/router-worker/index.ts", + }, + }), worker("runner-worker", { entry: { index: "src/workers/runner-worker/index.ts", diff --git a/packages/workers-dev-shared/package.json b/packages/workers-dev-shared/package.json index 70e41837d42..331c8a6d1a2 100644 --- a/packages/workers-dev-shared/package.json +++ b/packages/workers-dev-shared/package.json @@ -26,7 +26,7 @@ }, "scripts": { "build": "tsdown", - "check:type": "tsc", + "check:type": "tsc && tsc -p tsconfig.worker.json", "dev": "tsdown --watch", "test:ci": "vitest run", "test:watch": "vitest" @@ -40,12 +40,22 @@ "unenv": "2.0.0-rc.24" }, "devDependencies": { + "@cloudflare/build-output-utils": "workspace:*", "@cloudflare/containers-shared": "workspace:*", + "@cloudflare/remote-bindings": "workspace:*", + "@cloudflare/workers-shared": "workspace:*", "@cloudflare/workers-tsconfig": "workspace:*", + "@cloudflare/workers-types": "catalog:default", "@cloudflare/workers-utils": "workspace:*", + "@remix-run/node-fetch-server": "^0.8.0", "@types/node": "catalog:default", + "@types/ws": "^8.5.13", + "defu": "^6.1.4", + "get-port": "^7.1.0", + "picocolors": "^1.1.1", "tsdown": "0.16.3", "typescript": "catalog:default", - "vitest": "catalog:default" + "vitest": "catalog:default", + "ws": "catalog:default" } } diff --git a/packages/vite-plugin-cloudflare/src/__tests__/asset-config.spec.ts b/packages/workers-dev-shared/src/__tests__/asset-config.spec.ts similarity index 81% rename from packages/vite-plugin-cloudflare/src/__tests__/asset-config.spec.ts rename to packages/workers-dev-shared/src/__tests__/asset-config.spec.ts index de486ed5c65..ad9e730ddf1 100644 --- a/packages/vite-plugin-cloudflare/src/__tests__/asset-config.spec.ts +++ b/packages/workers-dev-shared/src/__tests__/asset-config.spec.ts @@ -1,7 +1,6 @@ import { test } from "vitest"; import { getAssetsConfig } from "../asset-config"; import type { AssetsOnlyResolvedConfig } from "../plugin-config"; -import type * as vite from "vite"; test("maps the native asset base path to the Asset Worker config", ({ expect, @@ -13,7 +12,10 @@ test("maps the native asset base path to the Asset Worker config", ({ } as unknown as AssetsOnlyResolvedConfig; expect( - getAssetsConfig(resolvedPluginConfig, undefined, {} as vite.ResolvedConfig) + getAssetsConfig(resolvedPluginConfig, undefined, { + publicDir: "public", + logger: console, + }) ).toEqual({ base_path: "/docs", has_static_routing: false, diff --git a/packages/vite-plugin-cloudflare/src/__tests__/build-output-env.spec.ts b/packages/workers-dev-shared/src/__tests__/build-output-env.spec.ts similarity index 100% rename from packages/vite-plugin-cloudflare/src/__tests__/build-output-env.spec.ts rename to packages/workers-dev-shared/src/__tests__/build-output-env.spec.ts diff --git a/packages/vite-plugin-cloudflare/src/__tests__/build-output-preview.spec.ts b/packages/workers-dev-shared/src/__tests__/build-output-preview.spec.ts similarity index 100% rename from packages/vite-plugin-cloudflare/src/__tests__/build-output-preview.spec.ts rename to packages/workers-dev-shared/src/__tests__/build-output-preview.spec.ts diff --git a/packages/vite-plugin-cloudflare/src/__tests__/remote-bindings.spec.ts b/packages/workers-dev-shared/src/__tests__/remote-bindings.spec.ts similarity index 100% rename from packages/vite-plugin-cloudflare/src/__tests__/remote-bindings.spec.ts rename to packages/workers-dev-shared/src/__tests__/remote-bindings.spec.ts diff --git a/packages/workers-dev-shared/src/__tests__/request-handler.spec.ts b/packages/workers-dev-shared/src/__tests__/request-handler.spec.ts new file mode 100644 index 00000000000..0c0c86819f4 --- /dev/null +++ b/packages/workers-dev-shared/src/__tests__/request-handler.spec.ts @@ -0,0 +1,541 @@ +import { EventEmitter } from "node:events"; +import http from "node:http"; +import net from "node:net"; +import { Response as MiniflareResponse } from "miniflare"; +import { afterEach, beforeEach, describe, test } from "vitest"; +import { + createRequestForIncomingMessage, + createRequestHandler, + getAuthority, + getForwardedProto, + getScheme, + toMiniflareRequest, +} from "../request-handler"; +import type { IncomingMessage } from "../request-handler"; +import type { AddressInfo } from "node:net"; + +describe("getForwardedProto", () => { + test("returns undefined when the header is missing", ({ expect }) => { + expect(getForwardedProto({ headers: {} })).toBeUndefined(); + }); + + test("returns https: when the header is `https`", ({ expect }) => { + expect( + getForwardedProto({ headers: { "x-forwarded-proto": "https" } }) + ).toBe("https:"); + }); + + test("returns http: when the header is `http`", ({ expect }) => { + expect( + getForwardedProto({ headers: { "x-forwarded-proto": "http" } }) + ).toBe("http:"); + }); + + test("is case-insensitive", ({ expect }) => { + expect( + getForwardedProto({ headers: { "x-forwarded-proto": "HTTPS" } }) + ).toBe("https:"); + }); + + test("trims surrounding whitespace", ({ expect }) => { + expect( + getForwardedProto({ headers: { "x-forwarded-proto": " https " } }) + ).toBe("https:"); + }); + + test("uses the left-most value when the header is a comma-separated proxy chain", ({ + expect, + }) => { + expect( + getForwardedProto({ headers: { "x-forwarded-proto": "https, http" } }) + ).toBe("https:"); + }); + + test("returns undefined when the header is an unsupported value", ({ + expect, + }) => { + expect( + getForwardedProto({ headers: { "x-forwarded-proto": "ws" } }) + ).toBeUndefined(); + }); + + test("returns undefined when the header is an empty string", ({ expect }) => { + expect( + getForwardedProto({ headers: { "x-forwarded-proto": "" } }) + ).toBeUndefined(); + }); +}); + +describe("getAuthority", () => { + test("returns undefined when header and authority property are missing", ({ + expect, + }) => { + expect(getAuthority({ headers: {} })).toBeUndefined(); + }); + + test("extracts authority from `:authority` header", ({ expect }) => { + expect(getAuthority({ headers: { ":authority": "localhost:5173" } })).toBe( + "localhost:5173" + ); + }); + + test("handles string array for `:authority` header", ({ expect }) => { + expect( + getAuthority({ headers: { ":authority": ["localhost:5173"] } }) + ).toBe("localhost:5173"); + }); + + test("extracts authority from `authority` property on request", ({ + expect, + }) => { + expect(getAuthority({ headers: {}, authority: "localhost:5173" })).toBe( + "localhost:5173" + ); + }); + + test("prioritizes `:authority` header over `authority` property", ({ + expect, + }) => { + expect( + getAuthority({ + headers: { ":authority": "header-host:5173" }, + authority: "prop-host:5173", + }) + ).toBe("header-host:5173"); + }); + + test("returns undefined when `:authority` is an empty string", ({ + expect, + }) => { + expect(getAuthority({ headers: { ":authority": "" } })).toBeUndefined(); + }); +}); + +describe("getScheme", () => { + test("returns undefined when header and scheme property are missing", ({ + expect, + }) => { + expect(getScheme({ headers: {} })).toBeUndefined(); + }); + + test("returns https: when `:scheme` header is `https`", ({ expect }) => { + expect(getScheme({ headers: { ":scheme": "https" } })).toBe("https:"); + }); + + test("returns http: when `:scheme` header is `http`", ({ expect }) => { + expect(getScheme({ headers: { ":scheme": "http" } })).toBe("http:"); + }); + + test("is case-insensitive", ({ expect }) => { + expect(getScheme({ headers: { ":scheme": "HTTPS" } })).toBe("https:"); + }); + + test("handles string array for `:scheme` header", ({ expect }) => { + expect(getScheme({ headers: { ":scheme": ["https"] } })).toBe("https:"); + }); + + test("extracts scheme from `scheme` property on request", ({ expect }) => { + expect(getScheme({ headers: {}, scheme: "https" })).toBe("https:"); + }); + + test("returns undefined for unsupported schemes", ({ expect }) => { + expect(getScheme({ headers: { ":scheme": "ws" } })).toBeUndefined(); + expect(getScheme({ headers: { ":scheme": "" } })).toBeUndefined(); + }); +}); + +describe("createRequestForIncomingMessage and toMiniflareRequest", () => { + test("preserves non-default port and hostname from `:authority` in request.url", ({ + expect, + }) => { + const res = new EventEmitter() as unknown as http.ServerResponse; + const req = { + method: "GET", + url: "/path?query=1", + headers: { + ":authority": "localhost:5173", + ":scheme": "https", + }, + rawHeaders: [":authority", "localhost:5173", ":scheme", "https"], + socket: {}, + } as unknown as IncomingMessage; + + const request = createRequestForIncomingMessage(req, res); + expect(request.url).toBe("https://localhost:5173/path?query=1"); + expect(request.headers.get("Host")).toBe("localhost:5173"); + }); + + test("preserves non-default port with IPv6 authority", ({ expect }) => { + const res = new EventEmitter() as unknown as http.ServerResponse; + const req = { + method: "GET", + url: "/path", + headers: { + ":authority": "[::1]:5173", + ":scheme": "https", + }, + rawHeaders: [":authority", "[::1]:5173", ":scheme", "https"], + socket: {}, + } as unknown as IncomingMessage; + + const request = createRequestForIncomingMessage(req, res); + expect(request.url).toBe("https://[::1]:5173/path"); + expect(request.headers.get("Host")).toBe("[::1]:5173"); + }); + + test("toMiniflareRequest sets X-Forwarded-Host including port when missing", ({ + expect, + }) => { + const res = new EventEmitter() as unknown as http.ServerResponse; + const req = { + method: "GET", + url: "/", + headers: { + ":authority": "localhost:5173", + ":scheme": "https", + }, + rawHeaders: [":authority", "localhost:5173", ":scheme", "https"], + socket: {}, + } as unknown as IncomingMessage; + + const request = createRequestForIncomingMessage(req, res); + const miniflareRequest = toMiniflareRequest(request); + + expect(miniflareRequest.headers.get("X-Forwarded-Host")).toBe( + "localhost:5173" + ); + }); + + test("toMiniflareRequest preserves existing X-Forwarded-Host if already set", ({ + expect, + }) => { + const res = new EventEmitter() as unknown as http.ServerResponse; + const req = { + method: "GET", + url: "/", + headers: { + ":authority": "localhost:5173", + ":scheme": "https", + "x-forwarded-host": "external-proxy.com:8443", + }, + rawHeaders: [ + ":authority", + "localhost:5173", + ":scheme", + "https", + "x-forwarded-host", + "external-proxy.com:8443", + ], + socket: {}, + } as unknown as IncomingMessage; + + const request = createRequestForIncomingMessage(req, res); + const miniflareRequest = toMiniflareRequest(request); + + expect(miniflareRequest.headers.get("X-Forwarded-Host")).toBe( + "external-proxy.com:8443" + ); + }); + + test("prioritizes `:authority` over a stale or port-less `Host` header", ({ + expect, + }) => { + const res = new EventEmitter() as unknown as http.ServerResponse; + const req = { + method: "GET", + url: "/path", + headers: { + ":authority": "localhost:5173", + ":scheme": "https", + host: "localhost", + }, + rawHeaders: [ + ":authority", + "localhost:5173", + ":scheme", + "https", + "host", + "localhost", + ], + socket: {}, + } as unknown as IncomingMessage; + + const request = createRequestForIncomingMessage(req, res); + expect(request.url).toBe("https://localhost:5173/path"); + expect(request.headers.get("Host")).toBe("localhost:5173"); + + const miniflareRequest = toMiniflareRequest(request); + expect(miniflareRequest.headers.get("X-Forwarded-Host")).toBe( + "localhost:5173" + ); + }); +}); + +describe("createRequestHandler", () => { + // Use a real HTTP server so that `req`, `res`, and `req.socket` look like + // what `createRequest` from `@remix-run/node-fetch-server` expects in + // production. This mirrors `websockets.spec.ts`. + let httpServer: http.Server; + let port: number; + let capturedUrls: string[]; + + beforeEach(async () => { + capturedUrls = []; + }); + + afterEach(async () => { + await new Promise((resolve, reject) => + httpServer?.close((e) => (e ? reject(e) : resolve())) + ); + }); + + function startServer() { + const handler = createRequestHandler(async (request) => { + capturedUrls.push(request.url); + return new MiniflareResponse("OK"); + }); + + httpServer = http.createServer((req, res) => { + void handler( + req as unknown as Parameters[0], + res, + (error: unknown) => { + res.statusCode = 500; + res.end(error instanceof Error ? error.message : String(error)); + } + ); + }); + return new Promise((r) => + httpServer.listen(0, "127.0.0.1", () => { + port = (httpServer.address() as AddressInfo).port; + r(); + }) + ); + } + + test("falls back to `http://` when no `X-Forwarded-Proto` header is set", async ({ + expect, + }) => { + await startServer(); + await fetch(`http://127.0.0.1:${port}/path`); + expect(capturedUrls[0]).toBe(`http://127.0.0.1:${port}/path`); + }); + + test("honors the `X-Forwarded-Proto` header set to `https`", async ({ + expect, + }) => { + await startServer(); + await fetch(`http://127.0.0.1:${port}/path`, { + headers: { "x-forwarded-proto": "https" }, + }); + expect(capturedUrls[0]).toBe(`https://127.0.0.1:${port}/path`); + }); + + test("honors the `X-Forwarded-Proto` header when it is case-insensitive", async ({ + expect, + }) => { + await startServer(); + await fetch(`http://127.0.0.1:${port}/path`, { + headers: { "x-forwarded-proto": "HTTPS" }, + }); + expect(capturedUrls[0]).toBe(`https://127.0.0.1:${port}/path`); + }); + + test("uses the left-most value when `X-Forwarded-Proto` is a proxy chain", async ({ + expect, + }) => { + await startServer(); + await fetch(`http://127.0.0.1:${port}/path`, { + headers: { "x-forwarded-proto": "https, http" }, + }); + expect(capturedUrls[0]).toBe(`https://127.0.0.1:${port}/path`); + }); + + test("ignores `X-Forwarded-Proto` when it holds an unsupported value", async ({ + expect, + }) => { + await startServer(); + await fetch(`http://127.0.0.1:${port}/path`, { + headers: { "x-forwarded-proto": "ws" }, + }); + expect(capturedUrls[0]).toBe(`http://127.0.0.1:${port}/path`); + }); + + test("returns the Worker response when the Worker cancels an oversized body", async ({ + expect, + }) => { + const handler = createRequestHandler(async (request) => { + const reader = request.body?.getReader(); + let total = 0; + + if (!reader) { + return new MiniflareResponse(null, { status: 204 }); + } + + try { + while (true) { + const result = await reader.read(); + if (result.done) { + break; + } + total += result.value.byteLength; + + if (total > 64 * 1024) { + await reader.cancel(); + return MiniflareResponse.json( + { error: "The request body is too large." }, + { status: 413 } + ); + } + } + } finally { + reader.releaseLock(); + } + + return new MiniflareResponse(null, { status: 204 }); + }); + + httpServer = http.createServer((req, res) => { + void handler( + req as unknown as Parameters[0], + res, + (error: unknown) => { + res.statusCode = 500; + res.setHeader("content-type", "text/plain"); + res.end(error instanceof Error ? error.message : String(error)); + } + ); + }); + + await new Promise((r) => + httpServer.listen(0, "127.0.0.1", () => { + port = (httpServer.address() as AddressInfo).port; + r(); + }) + ); + + for (let i = 0; i < 300; i++) { + const response = await fetch(`http://127.0.0.1:${port}/upload`, { + method: "POST", + body: new ReadableStream({ + start(controller) { + for (let j = 0; j < 8; j++) { + controller.enqueue(new Uint8Array(16 * 1024)); + } + controller.close(); + }, + }), + duplex: "half", + } as RequestInit & { duplex: "half" }); + + expect(response.status).toBe(413); + expect(await response.json()).toEqual({ + error: "The request body is too large.", + }); + } + }); + + test("drains cancelled request bodies so keep-alive connections can be reused", async ({ + expect, + }) => { + const handler = createRequestHandler(async (request) => { + if (request.method === "GET") { + return new MiniflareResponse("OK"); + } + + const reader = request.body?.getReader(); + let total = 0; + + if (!reader) { + return new MiniflareResponse(null, { status: 204 }); + } + + try { + while (true) { + const result = await reader.read(); + if (result.done) { + break; + } + total += result.value.byteLength; + + if (total > 64 * 1024) { + await reader.cancel(); + return new MiniflareResponse("Too large", { status: 413 }); + } + } + } finally { + reader.releaseLock(); + } + + return new MiniflareResponse(null, { status: 204 }); + }); + + httpServer = http.createServer((req, res) => { + void handler( + req as unknown as Parameters[0], + res, + (error: unknown) => { + res.statusCode = 500; + res.setHeader("content-type", "text/plain"); + res.end(error instanceof Error ? error.message : String(error)); + } + ); + }); + + await new Promise((r) => + httpServer.listen(0, "127.0.0.1", () => { + port = (httpServer.address() as AddressInfo).port; + r(); + }) + ); + + const socket = net.connect(port, "127.0.0.1"); + try { + const responses = await new Promise((resolve, reject) => { + let data = ""; + const timeout = setTimeout(() => { + reject(new Error(`Timed out waiting for reused connection: ${data}`)); + }, 2000); + + socket.on("data", (chunk) => { + data += chunk.toString("utf8"); + if (data.includes("HTTP/1.1 413") && data.includes("HTTP/1.1 200")) { + clearTimeout(timeout); + resolve(data); + } + }); + socket.on("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + + socket.write( + [ + "POST /upload HTTP/1.1", + `Host: 127.0.0.1:${port}`, + "Connection: keep-alive", + "Content-Length: 131072", + "", + "", + ].join("\r\n") + ); + socket.write(Buffer.alloc(131072)); + socket.write( + [ + "GET /after-cancel HTTP/1.1", + `Host: 127.0.0.1:${port}`, + "Connection: close", + "", + "", + ].join("\r\n") + ); + }); + + expect(responses).toContain("HTTP/1.1 413"); + expect(responses).toContain("HTTP/1.1 200"); + expect(responses).toContain("OK"); + } finally { + socket.destroy(); + } + }); +}); diff --git a/packages/vite-plugin-cloudflare/src/__tests__/websockets.spec.ts b/packages/workers-dev-shared/src/__tests__/websockets.spec.ts similarity index 98% rename from packages/vite-plugin-cloudflare/src/__tests__/websockets.spec.ts rename to packages/workers-dev-shared/src/__tests__/websockets.spec.ts index d322be027f8..58a8aaf6c3c 100644 --- a/packages/vite-plugin-cloudflare/src/__tests__/websockets.spec.ts +++ b/packages/workers-dev-shared/src/__tests__/websockets.spec.ts @@ -74,7 +74,11 @@ describe("handleWebSocket", () => { async function listen(entryWorkerName?: string) { const mf = miniflare ?? startMiniflare(); - handleWebSocket(httpServer, mf, entryWorkerName); + handleWebSocket(httpServer, mf, { + entryWorkerName, + isBundlerUpgrade: (request) => + request.headers["sec-websocket-protocol"]?.startsWith("vite") ?? false, + }); await new Promise((r) => httpServer.listen(0, "127.0.0.1", r)); port = (httpServer.address() as AddressInfo).port; // Wait for `workerd` to boot here (under the generous `testTimeout`) diff --git a/packages/workers-dev-shared/src/additional-modules.ts b/packages/workers-dev-shared/src/additional-modules.ts new file mode 100644 index 00000000000..c6f6cdd8c28 --- /dev/null +++ b/packages/workers-dev-shared/src/additional-modules.ts @@ -0,0 +1,93 @@ +import type { ModuleType } from "@cloudflare/config"; + +export const ADDITIONAL_MODULE_TYPES = [ + "CompiledWasm", + "Data", + "Text", +] as const; +export type AdditionalModuleType = (typeof ADDITIONAL_MODULE_TYPES)[number]; + +/** + * Checks whether a string is an additional module type. + * + * @param type The string to check + * @returns Whether `type` is an additional module type + */ +export function isAdditionalModuleType( + type: string | undefined +): type is AdditionalModuleType { + return ADDITIONAL_MODULE_TYPES.some((moduleType) => moduleType === type); +} + +/** + * Maps an additional module type to its Miniflare and Build Output module type. + * + * @param type The additional module type + * @returns The module type + */ +export function toModuleType(type: AdditionalModuleType): ModuleType { + switch (type) { + case "CompiledWasm": + return "wasm"; + case "Data": + return "data"; + case "Text": + return "text"; + } +} + +type ModuleRules = Array<{ + type: AdditionalModuleType; + pattern: RegExp; +}>; + +const moduleRules: ModuleRules = [ + { type: "CompiledWasm", pattern: /\.wasm(\?module)?$/ }, + { type: "Data", pattern: /\.bin$/ }, + { type: "Text", pattern: /\.(txt|html|sql)$/ }, +]; + +const subpathImportRE = /^#/; + +/** Import specifiers that may resolve to an additional module. */ +export const moduleRuleFilters = [ + ...moduleRules.map((rule) => rule.pattern), + subpathImportRE, +]; + +/** + * Matches a resolved module ID against the additional module rules. + * + * @param source The resolved module ID, including any query + * @returns The additional module type, or `null` if it isn't an additional module + */ +export function matchAdditionalModule( + source: string +): AdditionalModuleType | null { + for (const rule of moduleRules) { + if (rule.pattern.test(source)) { + return rule.type; + } + } + + return null; +} + +/** Matches imports of a `.wasm` file that return an instantiation helper. */ +export const wasmInitRE = /\.wasm\?init$/; + +/** + * Generates the module for a `.wasm?init` import, which default exports a + * function that instantiates the Wasm module. + * + * @param wasmPath The import specifier of the `.wasm` file, without the query + * @returns The module's code + */ +export function getWasmInitModule(wasmPath: string): string { + return ` + import wasm from "${wasmPath}"; + export default function(opts = {}) { + return WebAssembly.instantiate(wasm, opts); + } + `; +} diff --git a/packages/vite-plugin-cloudflare/src/asset-config.ts b/packages/workers-dev-shared/src/asset-config.ts similarity index 81% rename from packages/vite-plugin-cloudflare/src/asset-config.ts rename to packages/workers-dev-shared/src/asset-config.ts index 0b25784c1cb..87bb2ce8198 100644 --- a/packages/vite-plugin-cloudflare/src/asset-config.ts +++ b/packages/workers-dev-shared/src/asset-config.ts @@ -14,6 +14,7 @@ import { HeadersSchema, RedirectsSchema, } from "@cloudflare/workers-shared/utils/types"; +import type { DevLogger } from "./logger"; import type { AssetsOnlyResolvedConfig, ResolvedWorkerConfig, @@ -21,7 +22,6 @@ import type { } from "./plugin-config"; import type { Logger } from "@cloudflare/workers-shared/utils/configuration/types"; import type { AssetConfig } from "@cloudflare/workers-shared/utils/types"; -import type * as vite from "vite"; /** * Returns true if the `changedFile` matches one of the _headers or _redirects files, @@ -29,15 +29,15 @@ import type * as vite from "vite"; */ export function hasAssetsConfigChanged( resolvedPluginConfig: AssetsOnlyResolvedConfig | WorkersResolvedConfig, - resolvedViteConfig: vite.ResolvedConfig, + publicDir: string, changedFilePath: string ) { if (!resolvedPluginConfig.experimental?.headersAndRedirectsDevModeSupport) { return false; } return [ - getRedirectsConfigPath(resolvedViteConfig), - getHeadersConfigPath(resolvedViteConfig), + getRedirectsConfigPath(publicDir), + getHeadersConfigPath(publicDir), ].includes(changedFilePath); } @@ -48,7 +48,7 @@ export function hasAssetsConfigChanged( export function getAssetsConfig( resolvedPluginConfig: AssetsOnlyResolvedConfig | WorkersResolvedConfig, entryWorkerConfig: ResolvedWorkerConfig | undefined, - resolvedConfig: vite.ResolvedConfig + options: { publicDir: string; logger: DevLogger } ): AssetConfig { const assetsConfig = resolvedPluginConfig.type === "assets-only" @@ -89,23 +89,23 @@ export function getAssetsConfig( const logger: Logger = { debug() { - /* No debug log in Vite. */ + /* No debug log in dev. */ }, log(message: string) { - resolvedConfig.logger.info(message); + options.logger.info(message); }, info(message: string) { - resolvedConfig.logger.info(message); + options.logger.info(message); }, warn(message: string) { - resolvedConfig.logger.warn(message); + options.logger.warn(message); }, error(error: Error) { - resolvedConfig.logger.error(error.message, { error }); + options.logger.error(error.message, { error }); }, }; - const redirectsFile = getRedirectsConfigPath(resolvedConfig); + const redirectsFile = getRedirectsConfigPath(options.publicDir); const redirectsContents = maybeGetFile(redirectsFile); const redirects = redirectsContents && @@ -119,7 +119,7 @@ export function getAssetsConfig( }).redirects ); - const headersFile = getHeadersConfigPath(resolvedConfig); + const headersFile = getHeadersConfigPath(options.publicDir); const headersContents = maybeGetFile(headersFile); const headers = headersContents && @@ -138,10 +138,10 @@ export function getAssetsConfig( }; } -function getRedirectsConfigPath(config: vite.ResolvedConfig): string { - return path.join(config.publicDir, REDIRECTS_FILENAME); +function getRedirectsConfigPath(publicDir: string): string { + return path.join(publicDir, REDIRECTS_FILENAME); } -function getHeadersConfigPath(config: vite.ResolvedConfig): string { - return path.join(config.publicDir, HEADERS_FILENAME); +function getHeadersConfigPath(publicDir: string): string { + return path.join(publicDir, HEADERS_FILENAME); } diff --git a/packages/vite-plugin-cloudflare/src/build-output-env.ts b/packages/workers-dev-shared/src/build-output-env.ts similarity index 100% rename from packages/vite-plugin-cloudflare/src/build-output-env.ts rename to packages/workers-dev-shared/src/build-output-env.ts diff --git a/packages/vite-plugin-cloudflare/src/build-output-preview.ts b/packages/workers-dev-shared/src/build-output-preview.ts similarity index 97% rename from packages/vite-plugin-cloudflare/src/build-output-preview.ts rename to packages/workers-dev-shared/src/build-output-preview.ts index aa866f8ee8f..41e357ab4f9 100644 --- a/packages/vite-plugin-cloudflare/src/build-output-preview.ts +++ b/packages/workers-dev-shared/src/build-output-preview.ts @@ -3,13 +3,13 @@ import { readBuildOutput, } from "@cloudflare/build-output-utils"; import { PRERENDER_WORKER_DIRECTORY_NAME } from "./build-output"; +import type { Bundle } from "./miniflare-manifest"; import type { BuildOutputWorker } from "@cloudflare/build-output-utils"; import type { ParsedOutputContainerConfig, ParsedOutputRootConfig, ParsedOutputWorkerConfig, } from "@cloudflare/config"; -import type { Bundle } from "@cloudflare/workers-dev-shared"; export interface BuildOutputPreviewWorker { config: ParsedOutputWorkerConfig; diff --git a/packages/workers-dev-shared/src/build-output.ts b/packages/workers-dev-shared/src/build-output.ts new file mode 100644 index 00000000000..debc47d2522 --- /dev/null +++ b/packages/workers-dev-shared/src/build-output.ts @@ -0,0 +1,42 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +/** Build Output directory used for the dedicated prerender Worker. */ +export const PRERENDER_WORKER_DIRECTORY_NAME = "prerender"; + +/** + * Moves assets imported by the entry Worker from its build output to the + * client build output, so that they are served as static assets. + * + * @param importedAssetPaths The assets' paths relative to both directories + * @param entryWorkerBuildDirectory The entry Worker's build output directory + * @param clientBuildDirectory The client build output directory + * @returns The absolute paths of the moved assets + */ +export function moveImportedAssets( + importedAssetPaths: Iterable, + entryWorkerBuildDirectory: string, + clientBuildDirectory: string +): string[] { + const movedAssetPaths: string[] = []; + + for (const assetPath of importedAssetPaths) { + const src = path.join(entryWorkerBuildDirectory, assetPath); + const dest = path.join(clientBuildDirectory, assetPath); + + if (!fs.existsSync(src)) { + continue; + } + + if (fs.existsSync(dest)) { + fs.unlinkSync(src); + } else { + const destDir = path.dirname(dest); + fs.mkdirSync(destDir, { recursive: true }); + fs.renameSync(src, dest); + movedAssetPaths.push(dest); + } + } + + return movedAssetPaths; +} diff --git a/packages/vite-plugin-cloudflare/src/constants.ts b/packages/workers-dev-shared/src/constants.ts similarity index 100% rename from packages/vite-plugin-cloudflare/src/constants.ts rename to packages/workers-dev-shared/src/constants.ts diff --git a/packages/workers-dev-shared/src/debug.ts b/packages/workers-dev-shared/src/debug.ts new file mode 100644 index 00000000000..6a878aaa8db --- /dev/null +++ b/packages/workers-dev-shared/src/debug.ts @@ -0,0 +1,121 @@ +import assert from "node:assert"; +import getPort, { portNumbers } from "get-port"; +import colors from "picocolors"; +import type { DevLogger } from "./logger"; +import type { IncomingMessage, NextFunction } from "./request-handler"; +import type { ServerResponse } from "node:http"; + +export const DEBUG_PATH = "/__debug"; +const DEFAULT_INSPECTOR_PORT = 9229; + +/** + * Gets the inspector port option that should be passed to Miniflare based on the user's plugin config + * + * @param options.inspectorPort The user's `inspectorPort` option + * @param options.getPreviousPort Gets the port used before a dev server restart, if any + * @param options.logger Logs a warning when the default port is unavailable + * @returns The port, or `false` if the inspector is disabled + */ +export async function resolveInspectorPort(options: { + inspectorPort: number | false | undefined; + getPreviousPort: () => Promise; + logger: Pick; +}): Promise { + const { inspectorPort } = options; + + if (inspectorPort === undefined || inspectorPort === 0) { + const resolvedInspectorPort = await options.getPreviousPort(); + + if (resolvedInspectorPort !== null) { + // the user is not specifying an inspector port to use and we're already + // using one (this is a server restart) so let's just reuse that + return resolvedInspectorPort; + } + } + + const inputInspectorPort = + inspectorPort ?? (await getFirstAvailablePort(DEFAULT_INSPECTOR_PORT)); + + if ( + inspectorPort === undefined && + inputInspectorPort !== DEFAULT_INSPECTOR_PORT + ) { + options.logger.warn( + colors.dim( + `Default inspector port ${DEFAULT_INSPECTOR_PORT} not available, using ${inputInspectorPort} instead\n` + ) + ); + } + + return inputInspectorPort; +} + +function getFirstAvailablePort(start: number) { + return getPort({ port: portNumbers(start, 65535) }); +} + +/** + * Generate HTML that comprises a single script that: + * - redirects the page to the DevTools for debugging the first available Worker + * - opens tabs to the DevTools for all the remaining workers if any + * + * Note: this works based on the Miniflare inspector proxy logic (where Workers are available via + * their names) + */ +export function getDebugPathHtml(workerNames: string[], inspectorPort: number) { + // this function should always be called only when there is at least one worker to debug + assert(workerNames.length >= 1, "no workers present to debug"); + + const workerDevtoolsUrls = workerNames.map((workerName) => { + const localhost = `localhost:${inspectorPort}/${workerName}`; + const searchParams = new URLSearchParams({ + theme: "systemPreferred", + debugger: "true", + ws: localhost, + domain: workerName, + }); + const devtoolsFrontendUrl = `https://devtools.devprod.cloudflare.dev/js_app?${searchParams}`; + return devtoolsFrontendUrl; + }); + + return ` + + `; +} + +/** + * Creates middleware for the {@link DEBUG_PATH} page, which opens DevTools for each Worker. + * + * @param workerNames The names of the Workers to debug + * @param getInspectorPort Gets the inspector port that Miniflare is using, or `null` if there isn't one + * @returns The middleware + */ +export function createDebugMiddleware( + workerNames: string[], + getInspectorPort: () => Promise +) { + return async ( + _req: IncomingMessage, + res: ServerResponse, + next: NextFunction + ): Promise => { + const resolvedInspectorPort = await getInspectorPort(); + + if (resolvedInspectorPort) { + const html = getDebugPathHtml(workerNames, resolvedInspectorPort); + res.setHeader("Content-Type", "text/html"); + res.end(html); + } else { + next(); + } + }; +} diff --git a/packages/workers-dev-shared/src/dev-asset-workers.ts b/packages/workers-dev-shared/src/dev-asset-workers.ts new file mode 100644 index 00000000000..d6865d65ac9 --- /dev/null +++ b/packages/workers-dev-shared/src/dev-asset-workers.ts @@ -0,0 +1,178 @@ +import * as fsp from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { Response as MiniflareResponse } from "miniflare"; +import { ASSET_WORKER_NAME, ROUTER_WORKER_NAME } from "./constants"; +import type { ResolvedWorkerConfig } from "./plugin-config"; +import type { AssetConfig } from "@cloudflare/workers-shared/utils/types"; +import type { WorkerOptions } from "miniflare"; +import type { OutgoingHttpHeaders } from "node:http"; + +export const INTERNAL_WORKERS_COMPATIBILITY_DATE = "2024-10-04"; + +// We want module names to be their absolute path without the leading slash +// (i.e. the modules root should be the root directory). On Windows, we need +// paths to include the drive letter (i.e. `C:/a/b/c/index.mjs`). +// Internally, Miniflare uses `path.relative(modulesRoot, path)` to compute +// module names. Setting `modulesRoot` to a drive letter and prepending this +// to paths ensures correct names. This requires us to specify `contents` in +// the miniflare module definitions though, as the new paths don't exist. +export const miniflareModulesRoot = process.platform === "win32" ? "Z:\\" : "/"; + +type MiniflareEnv = NonNullable; + +/** + * Reads a bundled internal Worker into a Miniflare manifest. + * + * @param workerPath The Worker's path relative to `baseUrl` + * @param baseUrl The URL that the consumer's bundled internal Workers are relative to + * @returns The Worker's manifest + */ +export async function getInternalWorkerManifest( + workerPath: string, + baseUrl: string | URL +): Promise> { + return { + mainModule: workerPath, + modulesRoot: miniflareModulesRoot, + modules: { + [workerPath]: { + type: "esm", + contents: await fsp.readFile( + fileURLToPath(new URL(workerPath, baseUrl)), + "utf8" + ), + }, + }, + }; +} + +/** How the asset Worker reads HTML from the dev server. */ +export interface DevHtmlHandlers { + /** + * Resolves the pathname of a `.html` request to the pathname that + * `fetchHtml` accepts, or `null` when there is no such HTML file. + */ + htmlExists(pathname: string): Promise; + /** Returns the HTML for a pathname returned by `htmlExists`. */ + fetchHtml(pathname: string): Promise; +} + +/** + * Builds the Miniflare options for the router and asset Workers, which serve + * assets in dev as they are served when deployed, reading HTML from the dev + * server. Consumers bundle `router-worker` and `asset-worker` from + * `src/workers` to `./workers//index.js` relative to `baseUrl`. + * + * @param options.baseUrl The URL that the consumer's bundled internal Workers are relative to + * @param options.entryWorkerConfig The entry Worker, if there is one + * @param options.hasUserWorker Whether the entry Worker runs code rather than only serving assets + * @param options.assetsConfig The assets config, from `getAssetsConfig()` + * @param options.headers The headers that the dev server adds to responses + * @param options.html Reads HTML from the dev server + * @returns The router and asset Workers' options + */ +export async function getDevAssetWorkerOptions(options: { + baseUrl: string | URL; + entryWorkerConfig: Pick | undefined; + hasUserWorker: boolean; + assetsConfig: AssetConfig; + headers: OutgoingHttpHeaders | undefined; + html: DevHtmlHandlers; +}): Promise { + const { entryWorkerConfig, html } = options; + const [routerWorkerManifest, assetWorkerManifest] = await Promise.all([ + getInternalWorkerManifest( + "./workers/router-worker/index.js", + options.baseUrl + ), + getInternalWorkerManifest( + "./workers/asset-worker/index.js", + options.baseUrl + ), + ]); + + return [ + { + config: { + name: ROUTER_WORKER_NAME, + // TODO: Move this handling into Miniflare so Access is applied to the + // user Worker invocation without propagating through Worker boundaries. + access: entryWorkerConfig?.access, + compatibilityDate: INTERNAL_WORKERS_COMPATIBILITY_DATE, + compatibilityFlags: ["enable_ctx_exports"], + manifest: routerWorkerManifest, + env: { + CONFIG: { + type: "json", + value: { + has_user_worker: options.hasUserWorker, + }, + }, + ASSET_WORKER: { + type: "worker", + worker: ASSET_WORKER_NAME, + }, + ...(entryWorkerConfig + ? { + USER_WORKER: { + type: "worker", + worker: entryWorkerConfig.name, + } satisfies MiniflareEnv[string], + } + : {}), + }, + }, + dev: { + unsafeRegisterWorker: false, + }, + }, + { + config: { + name: ASSET_WORKER_NAME, + compatibilityDate: INTERNAL_WORKERS_COMPATIBILITY_DATE, + manifest: assetWorkerManifest, + env: { + CONFIG: { type: "json", value: options.assetsConfig }, + __VITE_HEADERS__: { + type: "text", + value: JSON.stringify(options.headers ?? {}), + }, + __VITE_HTML_EXISTS__: { + type: "fetcher", + handler: async (request) => { + const { pathname } = new URL(request.url); + + return MiniflareResponse.json( + pathname.endsWith(".html") + ? await html.htmlExists(pathname) + : null + ); + }, + }, + __VITE_FETCH_HTML__: { + type: "fetcher", + handler: async (request) => { + const { pathname } = new URL(request.url); + + try { + return new MiniflareResponse(await html.fetchHtml(pathname), { + headers: { "Content-Type": "text/html" }, + }); + } catch (error) { + throw new Error( + `Unexpected error. Failed to load "${pathname}".`, + { + cause: error, + } + ); + } + }, + }, + }, + }, + dev: { + unsafeRegisterWorker: false, + }, + }, + ]; +} diff --git a/packages/workers-dev-shared/src/dev-middleware.ts b/packages/workers-dev-shared/src/dev-middleware.ts new file mode 100644 index 00000000000..55946f6a9a5 --- /dev/null +++ b/packages/workers-dev-shared/src/dev-middleware.ts @@ -0,0 +1,101 @@ +import assert from "node:assert"; +import { generateStaticRoutingRuleMatcher } from "@cloudflare/workers-shared/asset-worker/src/utils/rules-engine"; +import { CoreHeaders } from "miniflare"; +import { + ASSET_WORKER_NAME, + kRequestType, + ROUTER_WORKER_NAME, +} from "./constants"; +import type { ResolvedWorkerConfig } from "./plugin-config"; +import type { IncomingMessage, NextFunction } from "./request-handler"; +import type { StaticRouting } from "@cloudflare/workers-shared/utils/types"; +import type { + Miniflare, + Request as MiniflareRequest, + Response as MiniflareResponse, +} from "miniflare"; +import type { ServerResponse } from "node:http"; + +const UNKNOWN_HOST = "http://localhost"; + +type Middleware = ( + req: IncomingMessage, + res: ServerResponse, + next: NextFunction +) => void; + +/** + * Dispatches a dev server request to the router Worker, or to the asset Worker + * when the request came from the entry Worker's assets binding. + * + * @param miniflare The Miniflare instance + * @param request The request to dispatch + * @param req The incoming request, which records whether it is an asset request + * @returns The Worker's response + */ +export function dispatchDevRequest( + miniflare: Miniflare, + request: MiniflareRequest, + req: IncomingMessage +): Promise { + request.headers.set( + CoreHeaders.ROUTE_OVERRIDE, + req[kRequestType] === "asset" ? ASSET_WORKER_NAME : ROUTER_WORKER_NAME + ); + + return miniflare.dispatchFetch(request, { redirect: "manual" }); +} + +/** + * Resolves the entry Worker's `run_worker_first` rules. + * + * @param entryWorkerConfig The entry Worker + * @param staticRouting The parsed `run_worker_first` rules, when it is an array + * @returns The rules, or `undefined` when assets are served first + */ +export function resolveStaticRouting( + entryWorkerConfig: ResolvedWorkerConfig, + staticRouting: StaticRouting | undefined +): StaticRouting | undefined { + return entryWorkerConfig.assets?.runWorkerFirst === true + ? { user_worker: ["/*"] } + : staticRouting; +} + +/** + * Creates a middleware that applies `run_worker_first` rules before the dev + * server's own middleware: excluded paths are marked as asset requests, and + * included paths are sent straight to the entry Worker. + * + * @param staticRouting The `run_worker_first` rules + * @param userWorkerHandler Sends a request to the entry Worker + * @returns The middleware + */ +export function createStaticRoutingMiddleware( + staticRouting: StaticRouting, + userWorkerHandler: Middleware +): Middleware { + const excludeRulesMatcher = generateStaticRoutingRuleMatcher( + staticRouting.asset_worker ?? [] + ); + const includeRulesMatcher = generateStaticRoutingRuleMatcher( + staticRouting.user_worker + ); + + return function cloudflarePreMiddleware(req, res, next) { + assert(req.url, `req.url not defined`); + // Only the URL pathname is used to match rules + const request = new Request(new URL(req.url, UNKNOWN_HOST)); + + if (req[kRequestType] === "asset") { + next(); + } else if (excludeRulesMatcher({ request })) { + req[kRequestType] = "asset"; + next(); + } else if (includeRulesMatcher({ request })) { + userWorkerHandler(req, res, next); + } else { + next(); + } + }; +} diff --git a/packages/workers-dev-shared/src/index.ts b/packages/workers-dev-shared/src/index.ts index 9a2bc416f90..0c954f9cf35 100644 --- a/packages/workers-dev-shared/src/index.ts +++ b/packages/workers-dev-shared/src/index.ts @@ -1,3 +1,36 @@ +export { + ADDITIONAL_MODULE_TYPES, + getWasmInitModule, + isAdditionalModuleType, + matchAdditionalModule, + moduleRuleFilters, + toModuleType, + wasmInitRE, +} from "./additional-modules"; +export type { AdditionalModuleType } from "./additional-modules"; +export { + ASSET_WORKER_NAME, + kRequestType, + PROXY_SHARED_SECRET, + ROUTER_WORKER_NAME, + VITE_PROXY_WORKER_NAME, +} from "./constants"; +export { getAssetsConfig, hasAssetsConfigChanged } from "./asset-config"; +export { + moveImportedAssets, + PRERENDER_WORKER_DIRECTORY_NAME, +} from "./build-output"; +export { + CLI_DISPLAY_NAME_ENV_VAR, + getCliDisplayName, + isPreviewBuild, + PREVIEW_BUILD_ENV_VAR, +} from "./build-output-env"; +export { readBuildOutputPreview } from "./build-output-preview"; +export type { + BuildOutputPreview, + BuildOutputPreviewWorker, +} from "./build-output-preview"; export { CLOUDFLARE_CONFIG_FILENAME, loadCloudflareConfig, @@ -8,13 +41,70 @@ export { warnForMissingSecrets, } from "./local-env"; export type { ResolvedLocalBindings } from "./local-env"; +export { + createDebugMiddleware, + DEBUG_PATH, + getDebugPathHtml, + resolveInspectorPort, +} from "./debug"; +export { + getDevAssetWorkerOptions, + getInternalWorkerManifest, + INTERNAL_WORKERS_COMPATIBILITY_DATE, + miniflareModulesRoot, +} from "./dev-asset-workers"; +export type { DevHtmlHandlers } from "./dev-asset-workers"; +export { + createStaticRoutingMiddleware, + dispatchDevRequest, + resolveStaticRouting, +} from "./dev-middleware"; +export type { DevLogger } from "./logger"; export { createMiniflareExports } from "./miniflare-exports"; +export { + getCommonMiniflareOptions, + getPreviewMiniflareOptions, +} from "./miniflare-options"; +export { + DevMiniflareLogger, + getStructuredLogsLogger, +} from "./miniflare-logger"; export { getMiniflareManifest } from "./miniflare-manifest"; export type { Bundle } from "./miniflare-manifest"; export { + hasNodeJsAls, hasNodeJsCompat, + isNodeAlsModule, NodeJsCompat, VIRTUAL_NODEJS_GLOBAL_INJECT_PREFIX, } from "./nodejs-compat"; export { getPersistenceRoot } from "./persist"; +export { resolveDevOnly, resolvePluginConfig } from "./plugin-config"; +export type { + AssetsOnlyResolvedConfig, + PluginConfig, + PreviewResolvedConfig, + ResolvedAssetsOnlyConfig, + ResolvedPluginConfig, + ResolvedWorkerConfig, + TunnelConfig, + Worker, + WorkersResolvedConfig, +} from "./plugin-config"; export type { PersistState } from "./persist"; +export { + getRemoteBindings, + getRemoteProxySessionData, + getWorkerZone, + toApiComplianceRegion, +} from "./remote-bindings"; +export { + createRequestForIncomingMessage, + createRequestHandler, + getAuthority, + getForwardedProto, + getScheme, + toMiniflareRequest, +} from "./request-handler"; +export type { IncomingMessage, NextFunction } from "./request-handler"; +export { handleWebSocket } from "./websockets"; diff --git a/packages/workers-dev-shared/src/logger.ts b/packages/workers-dev-shared/src/logger.ts new file mode 100644 index 00000000000..b5951815c9d --- /dev/null +++ b/packages/workers-dev-shared/src/logger.ts @@ -0,0 +1,6 @@ +/** The subset of a bundler's logger that shared dev code writes to. */ +export interface DevLogger { + info(message: string): void; + warn(message: string): void; + error(message: string, options?: { error?: Error | null }): void; +} diff --git a/packages/workers-dev-shared/src/miniflare-logger.ts b/packages/workers-dev-shared/src/miniflare-logger.ts new file mode 100644 index 00000000000..6d8990ff4bb --- /dev/null +++ b/packages/workers-dev-shared/src/miniflare-logger.ts @@ -0,0 +1,113 @@ +import { Log, LogLevel } from "miniflare"; +import type { DevLogger } from "./logger"; +import type { NpmVersionCheckResult } from "@cloudflare/workers-utils"; +import type { WorkerdStructuredLog } from "miniflare"; + +/** The log levels that both Vite and Rsbuild support. */ +type BundlerLogLevel = "error" | "warn" | "info" | "silent"; + +interface UpdateCheck { + packageName: string; + checkForNpmUpdate: () => Promise; +} + +/** + * A Miniflare logger that forwards messages onto a bundler's logger. + */ +export class DevMiniflareLogger extends Log { + #warnedCompatibilityDateFallback = false; + + private logger: DevLogger; + private updateCheck: UpdateCheck | undefined; + + /** + * @param logger The bundler's logger + * @param logLevel The bundler's log level + * @param updateCheck Used to suggest upgrading the plugin when workerd doesn't support the requested compatibility date + */ + constructor( + logger: DevLogger, + logLevel: BundlerLogLevel | undefined, + updateCheck?: UpdateCheck + ) { + super(getMiniflareLogLevel(logLevel)); + this.logger = logger; + this.updateCheck = updateCheck; + } + + override logWithLevel(level: LogLevel, message: string) { + switch (level) { + case LogLevel.ERROR: + return this.logger.error(message); + case LogLevel.WARN: + return this.logger.warn(message); + case LogLevel.INFO: + return this.logger.info(message); + } + } + + override warn(message: string): void { + // workerd emits a warning when the requested compatibility date is newer + // than the binary supports. We intercept it here so we only show it once + // and only when a newer version of the plugin is actually available on + // npm — otherwise the warning is just noise the user cannot act on. + if (!message.startsWith("The latest compatibility date supported by")) { + this.logger.warn(message); + return; + } + + if (this.#warnedCompatibilityDateFallback || !this.updateCheck) { + return; + } + this.#warnedCompatibilityDateFallback = true; + + const { packageName, checkForNpmUpdate } = this.updateCheck; + return void checkForNpmUpdate().then((result) => { + if (result.status !== "update-available") { + return; + } + this.logger.warn( + `${message}\nFeatures enabled by your requested compatibility date may not be available.` + + `\nUpgrade to \`${packageName}@${result.latest}\` to remove this warning.` + ); + return; + }); + } + + override logReady() { + // Noop so that Miniflare server start messages are not logged + } +} + +function getMiniflareLogLevel(level: BundlerLogLevel = "info"): LogLevel { + switch (level) { + case "error": + return LogLevel.ERROR; + case "warn": + return LogLevel.WARN; + case "info": + return LogLevel.INFO; + case "silent": + return LogLevel.NONE; + } +} + +/** + * Generates a log handler to be passed as the `handleStructuredLogs` option to miniflare + * + * @param logger the Miniflare logger to use + * @returns the log handler to pass to miniflare + */ +export function getStructuredLogsLogger(logger: Log) { + return ({ level, message }: WorkerdStructuredLog) => { + if (level === "warn") { + return logger.warn(message); + } + + if (level === "error") { + return logger.logWithLevel(LogLevel.ERROR, message); + } + + return logger.info(message); + }; +} diff --git a/packages/workers-dev-shared/src/miniflare-options.ts b/packages/workers-dev-shared/src/miniflare-options.ts new file mode 100644 index 00000000000..4e3cdfb34ea --- /dev/null +++ b/packages/workers-dev-shared/src/miniflare-options.ts @@ -0,0 +1,217 @@ +import assert from "node:assert"; +import * as path from "node:path"; +import { + createContainerPreviewPlanForCfConfig, + resolveDockerHost, +} from "@cloudflare/containers-shared"; +import { + getLocalExplorerEnabledFromEnv, + getLocalObservabilityEnabledFromEnv, +} from "@cloudflare/workers-utils"; +import { getDockerPath } from "@cloudflare/workers-utils/docker-path"; +import { getDefaultDevRegistryPath } from "miniflare"; +import { PROXY_SHARED_SECRET } from "./constants"; +import { + resolveLocalBindingsByWorkerName, + warnForMissingSecrets, +} from "./local-env"; +import { createMiniflareExports } from "./miniflare-exports"; +import { getStructuredLogsLogger } from "./miniflare-logger"; +import { getMiniflareManifest } from "./miniflare-manifest"; +import { getPersistenceRoot } from "./persist"; +import { getRemoteProxySessionData, getWorkerZone } from "./remote-bindings"; +import type { DevLogger } from "./logger"; +import type { PersistState } from "./persist"; +import type { PreviewResolvedConfig } from "./plugin-config"; +import type { + ContainerDevOptions, + ContainerDevPlan, +} from "@cloudflare/containers-shared"; +import type { Log, MiniflareOptions, WorkerOptions } from "miniflare"; + +const ASSETS_ONLY_MODULE = "__VITE_ASSETS_ONLY__.js"; + +/** + * Gets the Miniflare instance options that are the same in dev and preview for every bundler. + * + * @param options.root The project root + * @param options.persistState The user's `persistState` option + * @param options.logger The Miniflare logger + * @param options.inspectorPort The inspector port, or `false` if the inspector is disabled + * @param options.publicUrl The dev server's public URL + * @param options.containerEngine The container engine, if any Containers are configured + * @returns The instance options + */ +export function getCommonMiniflareOptions(options: { + root: string; + persistState: PersistState; + logger: Log; + inspectorPort: number | false; + publicUrl: string | undefined; + containerEngine?: MiniflareOptions["containerEngine"]; +}) { + const { root, logger, inspectorPort } = options; + + return { + log: logger, + publicUrl: options.publicUrl, + unsafeProxySharedSecret: PROXY_SHARED_SECRET, + inspectorPort: inspectorPort === false ? undefined : inspectorPort, + unsafeDevRegistryPath: getDefaultDevRegistryPath(), + unsafeTriggerHandlers: true, + unsafeLocalExplorer: getLocalExplorerEnabledFromEnv(), + // The switch for local observability capture: tells Miniflare core to + // attach the trace collector to each user worker. Opt-in via the + // `X_LOCAL_OBSERVABILITY` env var (defaults off); enabling it requires + // restarting the dev server. + unsafeObservability: getLocalObservabilityEnabledFromEnv(), + telemetry: { enabled: false }, + handleStructuredLogs: getStructuredLogsLogger(logger), + resourcePersistencePath: getPersistenceRoot(root, options.persistState), + resourceTmpPath: path.resolve(root, ".cloudflare/tmp"), + containerEngine: options.containerEngine, + } satisfies Partial; +} + +/** + * Gets the Miniflare options for previewing the Build Output. + * + * @param options.resolvedPluginConfig The resolved plugin config for the Build Output + * @param options.root The project root + * @param options.mode The mode used to load env files + * @param options.logger The bundler's logger + * @param options.miniflareLogger The Miniflare logger + * @param options.inspectorPort The inspector port, or `false` if the inspector is disabled + * @param options.publicUrl The preview server's public URL + * @returns The Miniflare options and the Containers to prepare + */ +export async function getPreviewMiniflareOptions(options: { + resolvedPluginConfig: PreviewResolvedConfig; + root: string; + mode: string; + logger: DevLogger; + miniflareLogger: Log; + inspectorPort: number | false; + publicUrl: string | undefined; +}): Promise<{ + miniflareOptions: Extract; + containerOptions: ContainerDevOptions[] | undefined; +}> { + const { resolvedPluginConfig, root, logger } = options; + const inputInspectorPort = options.inspectorPort; + const resolvedLocalBindingsByWorkerName = resolveLocalBindingsByWorkerName( + resolvedPluginConfig.workers.map((worker) => worker.config), + resolvedPluginConfig.localEnv, + resolvedPluginConfig.devVars + ); + warnForMissingSecrets( + resolvedLocalBindingsByWorkerName, + options.mode, + logger + ); + const containerPlansByWorkerName = new Map(); + const containerOptions: ContainerDevOptions[] = []; + for (const previewWorker of resolvedPluginConfig.workers) { + const plan = createContainerPreviewPlanForCfConfig({ + containers: resolvedPluginConfig.containers, + exports: previewWorker.config.exports, + }); + if (plan !== undefined) { + containerPlansByWorkerName.set(previewWorker.config.name, plan); + containerOptions.push(...plan.containerOptions); + } + } + const containerEngine = + containerPlansByWorkerName.size === 0 + ? undefined + : resolveDockerHost(getDockerPath()); + const workers: WorkerOptions[] = await Promise.all( + resolvedPluginConfig.workers.map(async (previewWorker) => { + const workerConfig = previewWorker.config; + const remoteProxySessionData = await getRemoteProxySessionData( + workerConfig, + { + remoteBindings: resolvedPluginConfig.remoteBindings, + settings: resolvedPluginConfig.settings, + root, + cliDisplayName: resolvedPluginConfig.cliDisplayName, + logger, + } + ); + + const { manifest: _manifest, assets, exports, ...config } = workerConfig; + const resolvedLocalBindings = resolvedLocalBindingsByWorkerName.get( + workerConfig.name + ); + assert(resolvedLocalBindings); + const env = { ...resolvedLocalBindings.bindings }; + const workerExports = createMiniflareExports({ + exports, + containerRuntimeOptions: containerPlansByWorkerName.get( + workerConfig.name + )?.containerRuntimeOptions, + }); + return { + config: { + ...config, + exports: workerExports, + env, + manifest: previewWorker.bundle + ? await getMiniflareManifest(previewWorker.bundle) + : previewWorker.assetsDir + ? getAssetsOnlyManifest(root) + : undefined, + assets: previewWorker.assetsDir + ? { + ...assets, + directory: previewWorker.assetsDir, + hasUserWorker: previewWorker.bundle !== undefined, + } + : undefined, + }, + dev: { + zone: getWorkerZone(config), + remoteProxyConnectionString: + remoteProxySessionData?.session?.remoteProxyConnectionString, + unsafeInspectorProxy: inputInspectorPort !== false, + }, + } satisfies WorkerOptions; + }) + ); + + const miniflareOptions: MiniflareOptions = { + ...getCommonMiniflareOptions({ + root, + persistState: resolvedPluginConfig.persistState, + logger: options.miniflareLogger, + inspectorPort: inputInspectorPort, + publicUrl: options.publicUrl, + containerEngine, + }), + workers, + }; + + return { + miniflareOptions: miniflareOptions as Extract< + MiniflareOptions, + { workers: WorkerOptions[] } + >, + containerOptions: + containerPlansByWorkerName.size === 0 ? undefined : containerOptions, + }; +} + +function getAssetsOnlyManifest( + rootPath: string +): NonNullable { + return { + mainModule: ASSETS_ONLY_MODULE, + modulesRoot: rootPath, + modules: { + [ASSETS_ONLY_MODULE]: { + type: "esm", + contents: "export default {};", + }, + }, + }; +} diff --git a/packages/workers-dev-shared/src/nodejs-compat.ts b/packages/workers-dev-shared/src/nodejs-compat.ts index 3cec4ba34a2..153b59386d4 100644 --- a/packages/workers-dev-shared/src/nodejs-compat.ts +++ b/packages/workers-dev-shared/src/nodejs-compat.ts @@ -197,6 +197,23 @@ export class NodeJsCompat { } } +/** + * Returns true if Node.js async local storage (ALS) is enabled (and not full Node.js compatibility mode). + */ +export function hasNodeJsAls(workerConfig: CompatConfig | undefined) { + return ( + workerConfig !== undefined && + getNodeCompat( + workerConfig.compatibilityDate, + workerConfig.compatibilityFlags ?? [] + ).mode === "als" + ); +} + +export function isNodeAlsModule(modulePath: string) { + return /^(?:node:)?async_hooks$/.test(modulePath); +} + /** * Returns `true` if the given combination of compat dates and flags enables Node.js compatibility. */ diff --git a/packages/workers-dev-shared/src/plugin-config.ts b/packages/workers-dev-shared/src/plugin-config.ts new file mode 100644 index 00000000000..bd26cabba9a --- /dev/null +++ b/packages/workers-dev-shared/src/plugin-config.ts @@ -0,0 +1,666 @@ +import { + DEFAULT_WORKER_DIRECTORY_NAME, + normalizeDirectoryName, +} from "@cloudflare/build-output-utils"; +import { InputWorkerSchema } from "@cloudflare/config"; +import { parseStaticRouting } from "@cloudflare/workers-shared/utils/configuration/parseStaticRouting"; +import { + DEFAULT_COMPAT_DATE, + getWorkerNameFromProject, +} from "@cloudflare/workers-utils"; +import { loadDevVars, loadEnv } from "@cloudflare/workers-utils/local-env"; +import { defu } from "defu"; +import { PRERENDER_WORKER_DIRECTORY_NAME } from "./build-output"; +import { getCliDisplayName, isPreviewBuild } from "./build-output-env"; +import { loadCloudflareConfig } from "./cloudflare-config"; +import { hasNodeJsCompat, NodeJsCompat } from "./nodejs-compat"; +import type { + BuildOutputPreview, + BuildOutputPreviewWorker, +} from "./build-output-preview"; +import type { PersistState } from "./persist"; +import type { + ParsedInputContainerConfig, + InputWorkerConfig, + ParsedInputSettingsConfig, + ParsedInputWorkerConfig, + ParsedOutputContainerConfig, +} from "@cloudflare/config"; +import type { StaticRouting } from "@cloudflare/workers-shared/utils/types"; +import type { LoadedEnv } from "@cloudflare/workers-utils/local-env"; + +export type { PersistState }; +export type TunnelConfig = { + autoStart?: boolean; + name?: string; + /** Email addresses or domain patterns allowed to authenticate to a Quick Tunnel. */ + allowedMail?: string[]; +}; + +interface BaseWorkerOptions { + viteEnvironment?: { name?: string; childEnvironments?: string[] }; +} + +/** + * Whether this Worker is only used during development and should not be built for production. + * Can be a boolean or a function that returns a boolean. The function is evaluated lazily + * at build time, allowing frameworks to provide the value after initialization. + */ +type DevOnly = boolean | (() => boolean); + +interface EntryWorkerOptions extends BaseWorkerOptions { + config?: EntryWorkerConfigCustomizer; + /** + * Whether the entry Worker should be omitted from the production build. + * Can be a boolean or a function that returns a boolean. The function is + * evaluated lazily at build time, allowing frameworks to provide the value + * after initialization. + * + * When set, an assets-only Build Output config is emitted. This enables using + * server-side code in development but producing a fully static app for + * deployment. + */ + assetsOnly?: DevOnly; +} + +interface AuxiliaryWorkerOptions extends BaseWorkerOptions { + /** Configure an auxiliary Worker. */ + config: WorkerConfigProvider; + devOnly?: DevOnly; +} + +interface PrerenderWorkerOptions extends BaseWorkerOptions { + config: WorkerConfigProvider; +} + +interface TypeGenerationOptions { + /** + * Whether to auto-generate `.cloudflare/types/index.d.ts`. Defaults to + * `true`. + */ + generate?: boolean; + /** + * Whether to include the Worker's runtime types (generated from the + * project's compatibility date and flags) in the generated + * `.cloudflare/types/index.d.ts`. Defaults to `true`. + */ + includeRuntime?: boolean; +} + +interface ResolvedTypeGenerationOptions { + generate: boolean; + includeRuntime: boolean; +} + +interface Experimental { + /** Experimental support for handling the _headers and _redirects files during Vite dev mode. */ + headersAndRedirectsDevModeSupport?: boolean; +} + +function normalizeTypes( + option: TypeGenerationOptions | undefined +): ResolvedTypeGenerationOptions { + return { + generate: option?.generate ?? true, + includeRuntime: option?.includeRuntime ?? true, + }; +} + +type EntryWorkerConfigCustomizer = + | Partial + | (( + config: ParsedInputWorkerConfig + ) => Partial | void); + +type WorkerConfigProvider = + | InputWorkerConfig + | ((options: { + entryWorkerConfig: ParsedInputWorkerConfig; + }) => InputWorkerConfig); + +export interface PluginConfig extends EntryWorkerOptions { + auxiliaryWorkers?: AuxiliaryWorkerOptions[]; + /** Configuration for a dedicated prerender Worker. */ + prerenderWorker?: PrerenderWorkerOptions; + persistState?: PersistState; + inspectorPort?: number | false; + remoteBindings?: boolean; + tunnel?: boolean | TunnelConfig; + /** + * Options for generating Worker types from `cloudflare.config.ts`. Type + * generation is skipped when the file does not exist. + */ + types?: TypeGenerationOptions; + experimental?: Experimental; +} + +export type ResolvedAssetsOnlyConfig = Omit< + ParsedInputWorkerConfig, + "entrypoint" +>; + +export type ResolvedWorkerConfig = Omit< + ParsedInputWorkerConfig, + "entrypoint" +> & { + entrypoint: string; +}; + +export interface Worker { + config: ResolvedWorkerConfig; + directoryName: string; + nodeJsCompat: NodeJsCompat | undefined; + devOnly: DevOnly | undefined; +} + +interface BaseResolvedConfig { + cliDisplayName: string; + persistState: PersistState; + inspectorPort: number | false | undefined; + types: ResolvedTypeGenerationOptions; + experimental: Pick; + remoteBindings: boolean; + tunnel: TunnelConfig; + localEnv: LoadedEnv; + devVars: Record | undefined; + settings: ParsedInputSettingsConfig; +} + +interface NonPreviewResolvedConfig extends BaseResolvedConfig { + containers: ParsedInputContainerConfig[]; + configPaths: Set; + environmentNameToWorkerMap: Map; + environmentNameToChildEnvironmentNamesMap: Map; + prerenderWorkerEnvironmentName: string | undefined; +} + +export interface AssetsOnlyResolvedConfig extends NonPreviewResolvedConfig { + type: "assets-only"; + config: ResolvedAssetsOnlyConfig; +} + +export interface WorkersResolvedConfig extends NonPreviewResolvedConfig { + type: "workers"; + entryWorkerEnvironmentName: string; + staticRouting: StaticRouting | undefined; +} + +export interface PreviewResolvedConfig extends BaseResolvedConfig { + type: "preview"; + containers: ParsedOutputContainerConfig[]; + workers: BuildOutputPreviewWorker[]; +} + +export type ResolvedPluginConfig = + | AssetsOnlyResolvedConfig + | WorkersResolvedConfig + | PreviewResolvedConfig; + +type ResolvedWorker = + | { type: "assets-only"; config: ResolvedAssetsOnlyConfig } + | { type: "worker"; config: ResolvedWorkerConfig }; + +function createDefaultWorkerConfig(name: string): ParsedInputWorkerConfig { + return { + name, + compatibilityDate: DEFAULT_COMPAT_DATE, + }; +} + +/** Resolves the entry Worker config. */ +function resolveEntryWorkerConfig(options: { + workerConfig: ParsedInputWorkerConfig; + configCustomizer: EntryWorkerConfigCustomizer | undefined; +}): ResolvedWorker { + const configResult = + typeof options.configCustomizer === "function" + ? options.configCustomizer(options.workerConfig) + : options.configCustomizer; + const config = InputWorkerSchema.parse( + configResult + ? defu(configResult, options.workerConfig) + : options.workerConfig + ); + + return resolveWorkerType({ config, isEntryWorker: true }); +} + +/** Resolves an auxiliary or prerender Worker config. */ +function resolveNonEntryWorkerConfig(options: { + config: WorkerConfigProvider; + entryWorkerConfig: ParsedInputWorkerConfig; +}): Extract { + const inputConfig = + typeof options.config === "function" + ? options.config({ entryWorkerConfig: options.entryWorkerConfig }) + : options.config; + const config = InputWorkerSchema.parse(inputConfig); + + return resolveWorkerType({ config, isEntryWorker: false }); +} + +function resolveWorkerType(options: { + config: ParsedInputWorkerConfig; + isEntryWorker: false; +}): Extract; +function resolveWorkerType(options: { + config: ParsedInputWorkerConfig; + isEntryWorker: true; +}): ResolvedWorker; +function resolveWorkerType(options: { + config: ParsedInputWorkerConfig; + isEntryWorker: boolean; +}): ResolvedWorker { + const { config, isEntryWorker } = options; + + if (!isEntryWorker && config.assets) { + throw new Error("assets are only supported by the default Worker"); + } + + if (config.entrypoint === undefined) { + if (!isEntryWorker) { + throw new Error( + "Auxiliary and prerender Workers must configure an `entrypoint`." + ); + } + const { entrypoint: _entrypoint, ...assetsOnlyConfig } = config; + return { type: "assets-only", config: assetsOnlyConfig }; + } + + return { + type: "worker", + config: { ...config, entrypoint: config.entrypoint }, + }; +} + +/** + * Resolves the plugin options and `cloudflare.config.ts` (or, in preview, the + * Build Output) into the Workers to run. + * + * @param pluginConfig The plugin options + * @param options.root The project root + * @param options.mode The mode used to load `cloudflare.config.ts` and env files + * @param options.envDir The directory to load env files from, or `false` to skip them + * @param options.command Whether the bundler is serving or building + * @param options.preview The Build Output to preview, when previewing + * @returns The resolved config + */ +export async function resolvePluginConfig( + pluginConfig: PluginConfig, + options: { + root: string; + mode: string; + envDir: string | false; + command: "serve" | "build"; + preview: BuildOutputPreview | undefined; + } +): Promise { + if ( + typeof pluginConfig.tunnel === "object" && + pluginConfig.tunnel.name !== undefined && + (pluginConfig.tunnel.allowedMail?.length ?? 0) > 0 + ) { + throw new Error( + "`tunnel.allowedMail` is only supported for Quick Tunnels and cannot be used together with `tunnel.name`." + ); + } + const { root, mode, envDir, preview } = options; + const localEnvMode = preview?.rootConfig.buildContext.mode ?? mode; + const [localEnv, devVars] = await Promise.all([ + loadEnv(envDir, localEnvMode), + loadDevVars(envDir, localEnvMode), + ]); + // TODO: Replace this process-global compatibility bridge by explicitly + // passing typed Cloudflare tool settings to the services that consume them. + Object.assign( + process.env, + Object.fromEntries( + Object.entries(localEnv.values).filter(([name]) => + name.startsWith("CLOUDFLARE_") + ) + ) + ); + const types = normalizeTypes(pluginConfig.types); + const shared = { + cliDisplayName: getCliDisplayName(), + persistState: pluginConfig.persistState ?? true, + inspectorPort: pluginConfig.inspectorPort, + types, + tunnel: + typeof pluginConfig.tunnel === "boolean" + ? { autoStart: pluginConfig.tunnel } + : { + autoStart: pluginConfig.tunnel?.autoStart ?? false, + name: pluginConfig.tunnel?.name, + allowedMail: pluginConfig.tunnel?.allowedMail?.length + ? pluginConfig.tunnel.allowedMail + : undefined, + }, + experimental: { + headersAndRedirectsDevModeSupport: + pluginConfig.experimental?.headersAndRedirectsDevModeSupport, + }, + localEnv, + devVars, + }; + + // The `cf-vite` delegate binary's `--local` flag sets this env var to + // force remote bindings off, overriding any `remoteBindings` value in the + // plugin config (mirrors `wrangler dev --local`). + const remoteBindings = + localEnv.values.CLOUDFLARE_VITE_FORCE_LOCAL === "true" + ? false + : (pluginConfig.remoteBindings ?? true); + + if (preview !== undefined) { + const { accountId, complianceRegion } = preview.rootConfig; + return { + ...shared, + remoteBindings, + type: "preview", + settings: { accountId, complianceRegion }, + containers: preview.containers, + workers: preview.workers, + }; + } + + const configPaths = new Set(); + const validateAndAddEnvironmentName = createEnvironmentNameValidator(); + + const loadedConfig = await loadCloudflareConfig({ + root, + mode, + isPreview: isPreviewBuild(), + types, + }); + const settings = { + accountId: loadedConfig?.parsedConfig.accountId, + complianceRegion: loadedConfig?.parsedConfig.complianceRegion, + }; + const containers = loadedConfig?.parsedConfig.containers ?? []; + if (loadedConfig) { + configPaths.add(loadedConfig.configPath); + for (const dep of loadedConfig.dependencies) { + configPaths.add(dep); + } + } + + // Type generation happens while loading the file above. The plugin-level + // customizer is intentionally applied afterwards so generated declarations + // only describe `cloudflare.config.ts`. + const entryWorkerResolvedConfig = resolveEntryWorkerConfig({ + workerConfig: + loadedConfig === undefined + ? createDefaultWorkerConfig(getWorkerNameFromProject(root)) + : loadedConfig.parsedConfig.worker, + configCustomizer: pluginConfig.config, + }); + + const environmentNameToWorkerMap = new Map(); + const environmentNameToChildEnvironmentNamesMap = new Map(); + const validateAndAddWorkerName = createWorkerNameValidator(); + validateAndAddWorkerName(entryWorkerResolvedConfig.config.name); + + const prerenderWorkerConfig = pluginConfig.prerenderWorker; + let prerenderWorkerEnvironmentName: string | undefined; + + if (prerenderWorkerConfig && options.command === "build") { + const workerResolvedConfig = resolveNonEntryWorkerConfig({ + config: prerenderWorkerConfig.config, + entryWorkerConfig: entryWorkerResolvedConfig.config, + }); + validateAndAddWorkerName(workerResolvedConfig.config.name); + + prerenderWorkerEnvironmentName = + prerenderWorkerConfig.viteEnvironment?.name ?? + workerNameToEnvironmentName(workerResolvedConfig.config.name); + + validateAndAddEnvironmentName(prerenderWorkerEnvironmentName); + + environmentNameToWorkerMap.set( + prerenderWorkerEnvironmentName, + resolveWorker(workerResolvedConfig.config, undefined, "prerender") + ); + + const prerenderWorkerChildEnvironments = + prerenderWorkerConfig.viteEnvironment?.childEnvironments; + + if (prerenderWorkerChildEnvironments) { + for (const childName of prerenderWorkerChildEnvironments) { + validateAndAddEnvironmentName(childName); + } + + environmentNameToChildEnvironmentNamesMap.set( + prerenderWorkerEnvironmentName, + prerenderWorkerChildEnvironments + ); + } + } + + if (entryWorkerResolvedConfig.type === "assets-only") { + addAuxiliaryWorkers({ + auxiliaryWorkers: pluginConfig.auxiliaryWorkers, + entryWorkerConfig: entryWorkerResolvedConfig.config, + environmentNameToWorkerMap, + environmentNameToChildEnvironmentNamesMap, + validateAndAddEnvironmentName, + validateAndAddWorkerName, + }); + return { + ...shared, + type: "assets-only", + config: entryWorkerResolvedConfig.config, + settings, + containers, + environmentNameToWorkerMap, + environmentNameToChildEnvironmentNamesMap, + prerenderWorkerEnvironmentName, + configPaths, + remoteBindings, + }; + } + + let staticRouting: StaticRouting | undefined; + + if (Array.isArray(entryWorkerResolvedConfig.config.assets?.runWorkerFirst)) { + staticRouting = parseStaticRouting( + entryWorkerResolvedConfig.config.assets.runWorkerFirst + ); + } + + const entryWorkerEnvironmentName = + pluginConfig.viteEnvironment?.name ?? "ssr"; + + validateAndAddEnvironmentName(entryWorkerEnvironmentName); + + environmentNameToWorkerMap.set( + entryWorkerEnvironmentName, + resolveWorker( + entryWorkerResolvedConfig.config, + pluginConfig.assetsOnly, + "default" + ) + ); + + const entryWorkerChildEnvironments = + pluginConfig.viteEnvironment?.childEnvironments; + + if (entryWorkerChildEnvironments) { + for (const childName of entryWorkerChildEnvironments) { + validateAndAddEnvironmentName(childName); + } + + environmentNameToChildEnvironmentNamesMap.set( + entryWorkerEnvironmentName, + entryWorkerChildEnvironments + ); + } + + addAuxiliaryWorkers({ + auxiliaryWorkers: pluginConfig.auxiliaryWorkers, + entryWorkerConfig: entryWorkerResolvedConfig.config, + environmentNameToWorkerMap, + environmentNameToChildEnvironmentNamesMap, + validateAndAddEnvironmentName, + validateAndAddWorkerName, + }); + + return { + ...shared, + type: "workers", + configPaths, + environmentNameToWorkerMap, + environmentNameToChildEnvironmentNamesMap, + prerenderWorkerEnvironmentName, + settings, + containers, + entryWorkerEnvironmentName, + staticRouting, + remoteBindings, + }; +} + +function addAuxiliaryWorkers(options: { + auxiliaryWorkers: AuxiliaryWorkerOptions[] | undefined; + entryWorkerConfig: ParsedInputWorkerConfig; + environmentNameToWorkerMap: Map; + environmentNameToChildEnvironmentNamesMap: Map; + validateAndAddEnvironmentName: (name: string) => void; + validateAndAddWorkerName: (name: string) => void; +}): void { + const usedDirectoryNames = new Map(); + for (const auxiliaryWorker of options.auxiliaryWorkers ?? []) { + const workerResolvedConfig = resolveNonEntryWorkerConfig({ + config: auxiliaryWorker.config, + entryWorkerConfig: options.entryWorkerConfig, + }); + const workerName = workerResolvedConfig.config.name; + options.validateAndAddWorkerName(workerName); + + const workerDirectoryName = getAuxiliaryWorkerDirectoryName(workerName); + validateAuxiliaryWorkerDirectoryName({ + workerName, + workerDirectoryName, + usedDirectoryNames, + }); + const workerEnvironmentName = + auxiliaryWorker.viteEnvironment?.name ?? + workerNameToEnvironmentName(workerName); + options.validateAndAddEnvironmentName(workerEnvironmentName); + + options.environmentNameToWorkerMap.set( + workerEnvironmentName, + resolveWorker( + workerResolvedConfig.config, + auxiliaryWorker.devOnly, + workerDirectoryName + ) + ); + + const childEnvironmentNames = + auxiliaryWorker.viteEnvironment?.childEnvironments; + if (childEnvironmentNames) { + for (const childName of childEnvironmentNames) { + options.validateAndAddEnvironmentName(childName); + } + options.environmentNameToChildEnvironmentNamesMap.set( + workerEnvironmentName, + childEnvironmentNames + ); + } + } +} + +const RESERVED_WORKER_DIRECTORY_NAMES = new Set([ + DEFAULT_WORKER_DIRECTORY_NAME, + PRERENDER_WORKER_DIRECTORY_NAME, +]); + +function getAuxiliaryWorkerDirectoryName(workerName: string): string { + const directoryName = normalizeDirectoryName(workerName); + + return RESERVED_WORKER_DIRECTORY_NAMES.has(directoryName) + ? `_${directoryName}` + : directoryName; +} + +function validateAuxiliaryWorkerDirectoryName(options: { + workerName: string; + workerDirectoryName: string; + usedDirectoryNames: Map; +}): void { + const existingWorkerName = options.usedDirectoryNames.get( + options.workerDirectoryName + ); + if (existingWorkerName) { + throw new Error( + `The \`${options.workerName}\` and \`${existingWorkerName}\` auxiliary Worker names both produce the Build Output directory name \`${options.workerDirectoryName}\`.` + ); + } + + options.usedDirectoryNames.set( + options.workerDirectoryName, + options.workerName + ); +} + +// Worker names can only contain alphanumeric characters and '-' whereas +// environment names can only contain alphanumeric characters and '$', '_'. +function workerNameToEnvironmentName(workerName: string): string { + return workerName.replaceAll("-", "_"); +} + +function createWorkerNameValidator() { + const usedNames = new Set(); + + return (name: string): void => { + if (usedNames.has(name)) { + throw new Error(`Duplicate Worker name: "${name}"`); + } + + usedNames.add(name); + }; +} + +function createEnvironmentNameValidator() { + const usedNames = new Set(); + + return (name: string): void => { + if (name === "client") { + throw new Error(`"client" is a reserved Vite environment name`); + } + + if (usedNames.has(name)) { + throw new Error(`Duplicate Vite environment name: "${name}"`); + } + + usedNames.add(name); + }; +} + +/** + * Evaluates the `devOnly` value. Should be called lazily at build time + * to allow frameworks to provide the value after initialization. + */ +export function resolveDevOnly(devOnly: DevOnly | undefined): boolean { + if (typeof devOnly === "function") { + return devOnly(); + } + + return devOnly ?? false; +} + +function resolveWorker( + workerConfig: ResolvedWorkerConfig, + devOnly: DevOnly | undefined, + directoryName: string +): Worker { + return { + config: workerConfig, + directoryName, + nodeJsCompat: hasNodeJsCompat(workerConfig) + ? new NodeJsCompat(workerConfig) + : undefined, + devOnly, + }; +} diff --git a/packages/vite-plugin-cloudflare/src/remote-bindings.ts b/packages/workers-dev-shared/src/remote-bindings.ts similarity index 57% rename from packages/vite-plugin-cloudflare/src/remote-bindings.ts rename to packages/workers-dev-shared/src/remote-bindings.ts index 6a656abaeb8..cfb1c9b9af4 100644 --- a/packages/vite-plugin-cloudflare/src/remote-bindings.ts +++ b/packages/workers-dev-shared/src/remote-bindings.ts @@ -1,8 +1,17 @@ +import { format } from "node:util"; +import { maybeStartOrUpdateRemoteProxySession } from "@cloudflare/remote-bindings"; +import { getZoneFromRoute } from "@cloudflare/workers-utils"; +import type { DevLogger } from "./logger"; import type { + ParsedInputSettingsConfig, ParsedInputWorkerConfig, ParsedOutputWorkerConfig, } from "@cloudflare/config"; -import type { Binding } from "@cloudflare/workers-utils"; +import type { + RemoteBindingsLogger, + RemoteProxySessionData, +} from "@cloudflare/remote-bindings"; +import type { Binding, ComplianceConfig } from "@cloudflare/workers-utils"; type WorkerConfig = ParsedInputWorkerConfig | ParsedOutputWorkerConfig; @@ -196,3 +205,114 @@ export function getRemoteBindings( return bindings; } + +export function toApiComplianceRegion( + region: ParsedInputSettingsConfig["complianceRegion"] +): ComplianceConfig["compliance_region"] { + return region === "fedramp-high" ? "fedramp_high" : region; +} + +/** Existing remote proxy sessions keyed by Worker name. */ +const remoteProxySessionsDataMap = new Map< + string, + RemoteProxySessionData | null +>(); + +/** + * Starts a remote proxy session for the Worker's remote bindings, or updates + * the existing one, so that it is reused across Miniflare updates. + * + * @returns The session data, or `null` when remote bindings are disabled + */ +export async function getRemoteProxySessionData( + config: Pick, + options: { + remoteBindings: boolean; + settings: Pick; + root: string; + cliDisplayName: string; + logger: DevLogger; + } +): Promise { + if (!options.remoteBindings) { + return null; + } + + const remoteProxySessionData = await maybeStartOrUpdateRemoteProxySession( + { + name: config.name, + bindings: getRemoteBindings(config), + complianceRegion: toApiComplianceRegion( + options.settings.complianceRegion + ), + account_id: options.settings.accountId, + profileDir: options.root, + }, + remoteProxySessionsDataMap.get(config.name) ?? null, + undefined, + { + cliDisplayName: options.cliDisplayName, + logger: createRemoteBindingsLogger(options.logger), + } + ); + + if (remoteProxySessionData) { + remoteProxySessionsDataMap.set(config.name, remoteProxySessionData); + } + + return remoteProxySessionData; +} + +function createRemoteBindingsLogger(logger: DevLogger): RemoteBindingsLogger { + const write = ( + level: "info" | "warn" | "error", + args: Parameters + ) => logger[level](format(...args)); + + return { + loggerLevel: "log", + debug() {}, + log: (...args) => write("info", args), + info: (...args) => write("info", args), + warn: (...args) => write("warn", args), + error: (...args) => write("error", args), + console(method, ...args) { + if (method === "error") { + write("error", args); + } else if (method === "warn") { + write("warn", args); + } else if (method !== "debug" && method !== "trace") { + write("info", args); + } + }, + }; +} + +/** + * Gets the zone that local requests to the Worker should appear to come from. + */ +export function getWorkerZone( + config: Pick +): string | undefined { + // Domains are converted to routes before fetch triggers, so this mirrors the + // first-route selection used by Wrangler's Miniflare options. + const domain = config.domains?.[0]; + if (domain !== undefined) { + return getZoneFromRoute( + typeof domain === "string" ? domain : domain.hostname + ); + } + + const fetchTrigger = config.triggers?.find( + (trigger) => trigger.type === "fetch" + ); + if (fetchTrigger?.type !== "fetch") { + return undefined; + } + + return getZoneFromRoute( + fetchTrigger.zone?.includes(".") + ? { pattern: fetchTrigger.pattern, zone_name: fetchTrigger.zone } + : fetchTrigger.pattern + ); +} diff --git a/packages/workers-dev-shared/src/request-handler.ts b/packages/workers-dev-shared/src/request-handler.ts new file mode 100644 index 00000000000..636407b3e57 --- /dev/null +++ b/packages/workers-dev-shared/src/request-handler.ts @@ -0,0 +1,276 @@ +import { createHeaders, sendResponse } from "@remix-run/node-fetch-server"; +import { + CoreHeaders, + parseForwardedProto, + Request as MiniflareRequest, + Response as MiniflareResponse, +} from "miniflare"; +import { PROXY_SHARED_SECRET } from "./constants"; +import type * as http from "node:http"; + +/** A Node.js request as passed to Connect-style middleware. */ +export type IncomingMessage = http.IncomingMessage & { originalUrl?: string }; + +/** The `next` callback passed to Connect-style middleware. */ +export type NextFunction = (error?: unknown) => void; + +/** + * Creates Connect-style middleware that converts the incoming Node.js request + * to a Miniflare request, passes it to `handler`, and writes back the response. + * + * @param handler Dispatches the request (usually to Miniflare) + * @param options.onResponse Called with each response before it is sent + * @returns The middleware + */ +export function createRequestHandler( + handler: ( + request: MiniflareRequest, + req: IncomingMessage + ) => Promise, + options: { onResponse?: (response: MiniflareResponse) => void } = {} +): ( + req: IncomingMessage, + res: http.ServerResponse, + next: NextFunction +) => Promise { + return async (req, res, next) => { + let request: Request | undefined; + + try { + // Built in vite middleware trims out the base path when passing in the request + // We can restore it by using the `originalUrl` property + // This makes sure the worker receives the correct url in both dev using vite and production + if (req.originalUrl) { + req.url = req.originalUrl; + } + // Honor the `X-Forwarded-Proto` header on the incoming request so that + // `request.url` reflects the protocol the original client used, not the + // HTTP connection between the proxy and the dev server. This is needed + // when the dev server sits behind a TLS-terminating reverse proxy + // or tunnel (cloudflared, ngrok, etc.) so that frameworks performing + // Origin/Host checks (e.g. CSRF protection) see a consistent origin. + // + // We trust this unconditionally because the dev server is a + // local-only dev tool and this header is the de-facto standard set by + // reverse proxies. Browser-mediated CSRF attacks cannot reach here: + // `X-Forwarded-Proto` is not a CORS-safelisted header, so a `fetch()` + // that sets it triggers a CORS preflight, and simple form-submitted + // POSTs cannot include custom headers at all. + // + // If the header is absent or invalid, `createRequest` falls back to the + // connection protocol (`req.socket.encrypted`). + const protocol = getForwardedProto(req); + request = createRequestForIncomingMessage( + req, + res, + protocol ? { protocol } : undefined + ); + + let response = await handler(toMiniflareRequest(request), req); + + // Vite uses HTTP/2 when `server.https` or `preview.https` is enabled + if (req.httpVersionMajor === 2) { + response = new MiniflareResponse(response.body, response); + // HTTP/2 disallows use of the `transfer-encoding` header + response.headers.delete("transfer-encoding"); + } + + options.onResponse?.(response); + + await sendResponse(res, response as unknown as Response); + } catch (error) { + if (request?.signal.aborted) { + // If the request was aborted, ignore the error + return; + } + + next(error); + } + }; +} + +export function createRequestForIncomingMessage( + req: IncomingMessage, + res: http.ServerResponse, + options?: { protocol?: "http:" | "https:"; host?: string } +): Request { + const controller = new AbortController(); + res.on("close", () => { + controller.abort(); + }); + + const method = req.method ?? "GET"; + const headers = createHeaders(req); + const protocol = + options?.protocol ?? + getScheme(req) ?? + ("encrypted" in req.socket && req.socket.encrypted ? "https:" : "http:"); + const host = + options?.host ?? getAuthority(req) ?? headers.get("Host") ?? "localhost"; + if (headers.get("Host") !== host) { + headers.set("Host", host); + } + const url = new URL(req.url ?? "/", `${protocol}//${host}`); + const init: RequestInit & { duplex?: "half" } = { + method, + headers, + signal: controller.signal, + }; + + if (method !== "GET" && method !== "HEAD") { + init.body = createCancellableRequestBody(req); + init.duplex = "half"; + } + + return new Request(url, init); +} + +function createCancellableRequestBody( + req: IncomingMessage +): ReadableStream { + let cleanup: (() => void) | undefined; + + return new ReadableStream({ + start(controller) { + const onData = (chunk: Buffer) => { + try { + controller.enqueue( + new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength) + ); + } catch (error) { + if ( + error instanceof TypeError && + error.message.includes("Controller is already closed") + ) { + cleanup?.(); + req.resume(); + return; + } + throw error; + } + }; + const onEnd = () => { + cleanup?.(); + controller.close(); + }; + const onError = (error: Error) => { + cleanup?.(); + controller.error(error); + }; + + cleanup = () => { + req.off("data", onData); + req.off("end", onEnd); + req.off("error", onError); + cleanup = undefined; + }; + + req.on("data", onData); + req.on("end", onEnd); + req.on("error", onError); + }, + cancel() { + cleanup?.(); + req.resume(); + }, + }); +} + +export function toMiniflareRequest(request: Request): MiniflareRequest { + const host = request.headers.get("Host") ?? new URL(request.url).host; + const xForwardedHost = request.headers.get("X-Forwarded-Host"); + + if (host && !xForwardedHost) { + // Set the `x-forwarded-host` header to the host of the dev server if it is not already set + // Note that the `host` header inside the Worker will contain the workerd host + // TODO: reconsider this when adopting `miniflare.dispatchFetch` as it may be possible to provide the dev server host in the `host` header + request.headers.set("X-Forwarded-Host", host); + } + + // Add the proxy shared secret to the request headers + // so the proxy worker can trust it and add host headers back + request.headers.set(CoreHeaders.PROXY_SHARED_SECRET, PROXY_SHARED_SECRET); + + // Undici sets the `Sec-Fetch-Mode` header to `cors` so we capture it in a custom header to be converted back later. + const secFetchMode = request.headers.get("Sec-Fetch-Mode"); + if (secFetchMode) { + request.headers.set(CoreHeaders.SEC_FETCH_MODE, secFetchMode); + } + return new MiniflareRequest(request.url, { + method: request.method, + headers: [["accept-encoding", "identity"], ...request.headers], + body: request.body, + duplex: "half", + signal: request.signal, + }); +} + +/** + * Parses the `X-Forwarded-Proto` header from an incoming Node.js request. + * + * If multiple proxies are in the chain, the header may be a comma-separated + * list — the left-most value is the original client-facing protocol. + * Returns `undefined` if the header is missing or holds an unsupported value. + */ +export function getForwardedProto(req: { + headers: http.IncomingHttpHeaders; +}): "http:" | "https:" | undefined { + const raw = req.headers["x-forwarded-proto"]; + return parseForwardedProto(Array.isArray(raw) ? raw[0] : raw); +} + +/** + * Extracts the authority (host and port) from an incoming Node.js request. + * + * In HTTP/2, browsers pass the host and port in the `:authority` pseudo-header + * rather than the HTTP/1.1 `Host` header. This helper inspects `:authority` + * (or the `authority` property on `http2.Http2ServerRequest`) so that ports + * are preserved when converting Node requests to standard Fetch requests. + * + * @param req Incoming request containing HTTP headers and optional authority property + * @returns The authority string (e.g. "localhost:5173"), or undefined if not present + */ +export function getAuthority(req: { + headers: http.IncomingHttpHeaders; + authority?: string; +}): string | undefined { + const raw = req.headers[":authority"]; + const value = Array.isArray(raw) ? raw[0] : raw; + if (value) { + return value; + } + if (typeof req.authority === "string" && req.authority) { + return req.authority; + } + return undefined; +} + +/** + * Extracts the scheme from an incoming Node.js request. + * + * Checks the `:scheme` pseudo-header in HTTP/2 requests or the `scheme` + * property on `http2.Http2ServerRequest`. + * + * @param req Incoming request containing HTTP headers and optional scheme property + * @returns The normalized scheme ("http:" or "https:"), or undefined if not present or unsupported + */ +export function getScheme(req: { + headers: http.IncomingHttpHeaders; + scheme?: string; +}): "http:" | "https:" | undefined { + const raw = req.headers[":scheme"]; + const value = Array.isArray(raw) ? raw[0] : raw; + if (value) { + const lower = value.toLowerCase(); + if (lower === "http" || lower === "https") { + return `${lower}:`; + } + } + if (typeof req.scheme === "string" && req.scheme) { + const lower = req.scheme.toLowerCase(); + if (lower === "http" || lower === "https") { + return `${lower}:`; + } + } + return undefined; +} diff --git a/packages/vite-plugin-cloudflare/src/websockets.ts b/packages/workers-dev-shared/src/websockets.ts similarity index 82% rename from packages/vite-plugin-cloudflare/src/websockets.ts rename to packages/workers-dev-shared/src/websockets.ts index 8b9521b819c..91084861c5c 100644 --- a/packages/vite-plugin-cloudflare/src/websockets.ts +++ b/packages/workers-dev-shared/src/websockets.ts @@ -1,21 +1,31 @@ import { createHeaders } from "@remix-run/node-fetch-server"; import { CoreHeaders, coupleWebSocket } from "miniflare"; import { WebSocketServer } from "ws"; -import { UNKNOWN_HOST } from "./shared"; -import { getForwardedProto } from "./utils"; +import { getForwardedProto } from "./request-handler"; import type { Headers, Miniflare } from "miniflare"; -import type { IncomingMessage } from "node:http"; +import type { IncomingMessage, Server } from "node:http"; +import type { Http2SecureServer } from "node:http2"; import type { Duplex } from "node:stream"; -import type * as vite from "vite"; + +const UNKNOWN_HOST = "http://localhost"; /** - * Handles 'upgrade' requests to the Vite HTTP server and forwards WebSocket events between the client and Worker environments. + * Handles 'upgrade' requests to the dev server and forwards WebSocket events between the client and Worker environments. + * + * @param httpServer The dev server's HTTP server + * @param miniflare The Miniflare instance to dispatch upgrades to + * @param options.entryWorkerName Routes upgrades to this Worker when set + * @param options.isBundlerUpgrade Identifies the bundler's own WebSockets (e.g. HMR), which are left to the bundler */ export function handleWebSocket( - httpServer: vite.HttpServer, + httpServer: Server | Http2SecureServer, miniflare: Miniflare, - entryWorkerName?: string + options: { + entryWorkerName?: string; + isBundlerUpgrade: (request: IncomingMessage, url: URL) => boolean; + } ) { + const { entryWorkerName, isBundlerUpgrade } = options; const nodeWebSocket = new WebSocketServer({ noServer: true }); // Stash Worker 101-response headers keyed by the upgrade request so a single @@ -49,19 +59,17 @@ export function handleWebSocket( const rawHost = request.headers.host ?? UNKNOWN_HOST; // Honor `X-Forwarded-Proto` so that the upgrade URL reflects the // protocol the original client used (e.g. behind a TLS-terminating - // reverse proxy or tunnel). Matches `createRequestHandler` in utils.ts. + // reverse proxy or tunnel). Matches `createRequestHandler`. const protocol = getForwardedProto(request) ?? "http:"; const base = /^https?:\/\//i.test(rawHost) ? rawHost : `${protocol}//${rawHost}`; const url = new URL(request.url ?? "", base); - const isViteRequest = - request.headers["sec-websocket-protocol"]?.startsWith("vite"); const isSandboxRequest = hasSandboxOrigin(url.origin); - // Ignore Vite HMR WebSockets but forward on all sandbox requests. - if (isViteRequest && !isSandboxRequest) { + // Ignore the bundler's WebSockets but forward on all sandbox requests. + if (isBundlerUpgrade(request, url) && !isSandboxRequest) { return; } @@ -85,7 +93,7 @@ export function handleWebSocket( // Forward response headers (e.g. Set-Cookie, custom auth headers) from // the Worker's 101 response onto the upgrade response sent to the // client. Without this, headers set on a `new Response(null, { status: - // 101, webSocket, headers })` are silently dropped during `vite dev`, + // 101, webSocket, headers })` are silently dropped during dev, // even though they are delivered correctly by `wrangler dev`. // See cloudflare/workers-sdk#10390. workerResponseHeaders.set(request, response.headers); diff --git a/packages/vite-plugin-cloudflare/src/workers/asset-worker/index.ts b/packages/workers-dev-shared/src/workers/asset-worker/index.ts similarity index 86% rename from packages/vite-plugin-cloudflare/src/workers/asset-worker/index.ts rename to packages/workers-dev-shared/src/workers/asset-worker/index.ts index 3c2ff24ed70..53c1c5374a7 100644 --- a/packages/vite-plugin-cloudflare/src/workers/asset-worker/index.ts +++ b/packages/workers-dev-shared/src/workers/asset-worker/index.ts @@ -1,7 +1,7 @@ import { AssetWorkerInner } from "@cloudflare/workers-shared/asset-worker"; -import { UNKNOWN_HOST } from "../../shared"; import type { Env as _Env } from "@cloudflare/workers-shared/asset-worker"; -import type { ResolvedConfig } from "vite"; + +const UNKNOWN_HOST = "http://localhost"; interface Env extends _Env { __VITE_HTML_EXISTS__: Fetcher; @@ -15,10 +15,11 @@ export default class CustomAssetWorker extends AssetWorkerInner { const modifiedResponse = new Response(response.body, response); modifiedResponse.headers.delete("ETag"); modifiedResponse.headers.delete("Cache-Control"); - // Add headers set via `server.headers` in the Vite config - const viteHeaders = JSON.parse( - this.env.__VITE_HEADERS__ - ) as ResolvedConfig["server"]["headers"]; + // Add headers set via the dev server's `server.headers` config + const viteHeaders = JSON.parse(this.env.__VITE_HEADERS__) as Record< + string, + string | number | string[] | undefined + >; for (const [key, value] of Object.entries(viteHeaders)) { if (Array.isArray(value)) { diff --git a/packages/vite-plugin-cloudflare/src/workers/router-worker/index.ts b/packages/workers-dev-shared/src/workers/router-worker/index.ts similarity index 100% rename from packages/vite-plugin-cloudflare/src/workers/router-worker/index.ts rename to packages/workers-dev-shared/src/workers/router-worker/index.ts diff --git a/packages/workers-dev-shared/tsconfig.json b/packages/workers-dev-shared/tsconfig.json index 99e994d3b13..0066e22b4c8 100644 --- a/packages/workers-dev-shared/tsconfig.json +++ b/packages/workers-dev-shared/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "@cloudflare/workers-tsconfig/base.json", "compilerOptions": { - "types": ["@types/node"] + "types": ["@types/node", "@cloudflare/workers-types/experimental"] }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/workers"] } diff --git a/packages/workers-dev-shared/tsconfig.worker.json b/packages/workers-dev-shared/tsconfig.worker.json new file mode 100644 index 00000000000..9b76d4218a9 --- /dev/null +++ b/packages/workers-dev-shared/tsconfig.worker.json @@ -0,0 +1,7 @@ +{ + "extends": "@cloudflare/workers-tsconfig/base.json", + "compilerOptions": { + "types": ["@cloudflare/workers-types/experimental"] + }, + "include": ["src/workers"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1040d82cf42..bde5838bb54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3030,12 +3030,24 @@ importers: '@aws-sdk/s3-request-presigner': specifier: ^3.721.0 version: 3.721.0 + '@cloudflare/rsbuild-plugin': + specifier: workspace:* + version: link:../../rsbuild-plugin-cloudflare '@cloudflare/vite-plugin': specifier: workspace:* version: link:.. '@cloudflare/workers-tsconfig': specifier: workspace:* version: link:../../workers-tsconfig + '@rsbuild/core': + specifier: ^2.2.13 + version: 2.2.13(core-js@3.37.1) + '@rsbuild/plugin-basic-ssl': + specifier: ^2.0.0 + version: 2.0.0(@rsbuild/core@2.2.13(core-js@3.37.1)) + '@rsbuild/plugin-react': + specifier: ^2.1.1 + version: 2.1.1(@rsbuild/core@2.2.13(core-js@3.37.1))(@rspack/core@2.2.9(@swc/helpers@0.5.23)) playwright-chromium: specifier: catalog:default version: 1.60.0 @@ -4315,18 +4327,45 @@ importers: specifier: 2.0.0-rc.24 version: 2.0.0-rc.24 devDependencies: + '@cloudflare/build-output-utils': + specifier: workspace:* + version: link:../build-output-utils '@cloudflare/containers-shared': specifier: workspace:* version: link:../containers-shared + '@cloudflare/remote-bindings': + specifier: workspace:* + version: link:../remote-bindings + '@cloudflare/workers-shared': + specifier: workspace:* + version: link:../workers-shared '@cloudflare/workers-tsconfig': specifier: workspace:* version: link:../workers-tsconfig + '@cloudflare/workers-types': + specifier: catalog:default + version: 5.20261019.1 '@cloudflare/workers-utils': specifier: workspace:* version: link:../workers-utils + '@remix-run/node-fetch-server': + specifier: ^0.8.0 + version: 0.8.0 '@types/node': specifier: 22.15.17 version: 22.15.17 + '@types/ws': + specifier: ^8.5.13 + version: 8.5.13 + defu: + specifier: ^6.1.4 + version: 6.1.4 + get-port: + specifier: ^7.1.0 + version: 7.1.0 + picocolors: + specifier: ^1.1.1 + version: 1.1.1 tsdown: specifier: 0.16.3 version: 0.16.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(ms@2.1.3)(synckit@0.11.12)(typescript@5.8.3) @@ -4336,6 +4375,9 @@ importers: vitest: specifier: catalog:default version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.15.17)(@vitest/ui@4.1.0)(msw@2.14.6(@types/node@22.15.17)(typescript@5.8.3))(vite@8.2.0(@types/node@22.15.17)(esbuild@0.28.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0)) + ws: + specifier: catalog:default + version: 8.21.0 packages/workers-editor-shared: dependencies: @@ -7970,6 +8012,10 @@ packages: resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} engines: {node: '>= 20.19.0'} + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.0.1': resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} engines: {node: '>= 20.19.0'} @@ -8426,6 +8472,53 @@ packages: cpu: [x64] os: [win32] + '@peculiar/asn1-cms@2.11.0': + resolution: {integrity: sha512-SlAiDoT/Cd8qaNmbTt78gqyaEvu2fQd6ML6KWJkdjXGrgDKnHHrusVkfQUelkfinL2p665IIADb0JBixtT8W1g==} + engines: {node: '>=14'} + + '@peculiar/asn1-csr@2.11.0': + resolution: {integrity: sha512-M/ED0yJ05qMK+UADPKnIa8vhAeewOJALSxTAZB7RQ/batRHhIJZkwznCcWAbFO5saHJ1yEQ+m4lncEcG9RnQSg==} + engines: {node: '>=14'} + + '@peculiar/asn1-ecc@2.11.0': + resolution: {integrity: sha512-jj1alGEKev7/un+cI+exyVK9m31lmP7cCBMCZuCb0wkFQH2zmKmJzNiG4wqXFNzlcP8timHLnzf3lV3OxBpb/Q==} + engines: {node: '>=14'} + + '@peculiar/asn1-pfx@2.11.0': + resolution: {integrity: sha512-ykSAWRu8KkmzU8LRpk9ZQC3zuLmBEtoSMjIv9XHVamD5W7amqreV5wttKqnvtjWOzzSJQxA/HVT8/tHwpEEGSQ==} + engines: {node: '>=14'} + + '@peculiar/asn1-pkcs8@2.11.0': + resolution: {integrity: sha512-lragjkjQGnQ5luxC+sxoAfAO/jBiQ7hOEGlDKJUk7MC5zY3AzjB2DU0GyPUH1Sswdv10t7DurY0zylOYMLb88w==} + engines: {node: '>=14'} + + '@peculiar/asn1-pkcs9@2.11.0': + resolution: {integrity: sha512-gDdFjpqV0weJjgNUvueUVf4D8uVbpq+VbG0TsxLAY2QGljAkJyL9qwQ7avol9j0jaYSJ7Ey7v7RZp0M3GTcQSA==} + engines: {node: '>=14'} + + '@peculiar/asn1-rsa@2.11.0': + resolution: {integrity: sha512-S2sBJmbfI09Kzdt9cEhE6pDNjHpVTkURs+811+PxS8AvsTLxC4woMNRhxHYaIbClkkNfAVt0tZxJdINVfUmzuA==} + engines: {node: '>=14'} + + '@peculiar/asn1-schema@2.11.0': + resolution: {integrity: sha512-8xh4avo53/vXO6UEv9yaoJ83hY581geaLTygf61NPIXP5AeIZCPjJhpTaYU23IZkG+NpLN/C9FYzWp7hphIT9A==} + engines: {node: '>=14'} + + '@peculiar/asn1-x509-attr@2.11.0': + resolution: {integrity: sha512-Fgvnd9REmLxEj/4cbDP54dNUNUXkzUksXLqEHqs1mHBuSRjSTCUSPM/WQIZYSvrdG7ND9CX1AQtPoIBekhgQSA==} + engines: {node: '>=14'} + + '@peculiar/asn1-x509@2.11.0': + resolution: {integrity: sha512-J1sHAwGNlk+8J+FeR1eLVc6Gq0T9f9ITc3HWyPYgJraoW6U8AJuQiWVJ3SCEpltfTdpOY7fHdHT5oKAsbK/jww==} + engines: {node: '>=14'} + + '@peculiar/utils@2.0.3': + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} + + '@peculiar/x509@1.14.3': + resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} + engines: {node: '>=20.0.0'} + '@phosphor-icons/react@2.1.10': resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==} engines: {node: '>=10'} @@ -9404,6 +9497,22 @@ packages: core-js: optional: true + '@rsbuild/plugin-basic-ssl@2.0.0': + resolution: {integrity: sha512-QpJFX5dsJks2zilixefmF85HLGL55K7yBbS16UtSFXBWmMIbhcnGh+wqNt6IatsJKTHtHZ/KVINUjaiUALGSGw==} + peerDependencies: + '@rsbuild/core': ^2.0.0 + peerDependenciesMeta: + '@rsbuild/core': + optional: true + + '@rsbuild/plugin-react@2.1.1': + resolution: {integrity: sha512-tLtUedsRtTLS6M4VtwzI6aakFLOFdgl9Sh1ZNBWvxb0Te3EnHrppaE7UiYEmDUXQDJ50gfr6yyirtxY5N6moyg==} + peerDependencies: + '@rsbuild/core': ^2.0.0 + peerDependenciesMeta: + '@rsbuild/core': + optional: true + '@rspack/binding-darwin-arm64@2.2.9': resolution: {integrity: sha512-aIHkk/wngpwgWkwVQFz2AIkmG9WpWtWlkqy4BlYhs5a7l9z0BrB0kXnUb6VSbqZ7Tc3cRepKw7ipkGjKa8KKXw==} cpu: [arm64] @@ -9496,6 +9605,15 @@ packages: '@swc/helpers': optional: true + '@rspack/plugin-react-refresh@2.0.2': + resolution: {integrity: sha512-dGNZiCxQxgAUI9sah7gd8u+O7OJZRCmqtEJNDOd8xW5RqcieC86F7p5qcShyw6onH5pKf57evpr2VjGbaFGkZg==} + peerDependencies: + '@rspack/core': ^2.0.0 + react-refresh: '>=0.10.0 <1.0.0' + peerDependenciesMeta: + '@rspack/core': + optional: true + '@rushstack/node-core-library@5.13.1': resolution: {integrity: sha512-5yXhzPFGEkVc9Fu92wsNJ9jlvdwz4RNb2bMso+/+TH0nMm1jDDDsOIf4l8GAkPxGuwPw5DH24RliWVfSPhlW/Q==} peerDependencies: @@ -10853,6 +10971,10 @@ packages: asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + asn1js@3.1.0: + resolution: {integrity: sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA==} + engines: {node: '>=12.0.0'} + assert-plus@1.0.0: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} @@ -11135,6 +11257,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + bytestreamjs@2.0.1: + resolution: {integrity: sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==} + engines: {node: '>=6.0.0'} + c12@3.1.0: resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} peerDependencies: @@ -14407,6 +14533,10 @@ packages: resolution: {integrity: sha512-8xCNE/aT/EXKenuMDZ+xTVwkT8gsoHN2z/Q29l80u0ppGEXVvsKRzNMbtKhg8LS8k1tJLAHHylf6p4VFmP6XUQ==} engines: {node: '>= 0.4.0'} + pkijs@3.4.1: + resolution: {integrity: sha512-Oo/NZcSWccq8KyoG7gLE9fnltgHns+pNCjCAp/WmjsUySi+sX7y4z4Xqu4fVb42CDHzRPl33fjzT15V1wvcyhA==} + engines: {node: '>=16.0.0'} + playwright-chromium@1.60.0: resolution: {integrity: sha512-xxz9pc2HIxQW/Qg9ijG2fZOHRT//KhLo0KfvJRa45YYRrcA7ZONoilgJR40SW5pmecb6HkuROaeViXoCaXTZyQ==} engines: {node: '>=18'} @@ -14823,6 +14953,13 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.2.0: + resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==} + engines: {node: '>=16.0.0'} + qr@0.6.0: resolution: {integrity: sha512-P23VoX7SipHALdiIYG+D+LT/6n22dNKwV92FAb3d+Nlki/5WisSsfLt0UDFz2XEBtuwrECTznvu+chKKFCSYhA==} engines: {node: '>= 20.19.0'} @@ -14933,6 +15070,10 @@ packages: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} + react-refresh@0.19.0: + resolution: {integrity: sha512-5HdstYgoJBbBofjapO5qCPcovECJCBfvjLb+iTcsmqDFVMRdqy0qs7iiInBJTUXmIFc2bjoc5b2HVLp2zRtYhQ==} + engines: {node: '>=0.10.0'} + react-required-if@1.0.3: resolution: {integrity: sha512-MdbG9mGJVA12lV+jvsU/U6D50NrGfkL6Yvy132dLCUioOvDqemtbYAMTn2gIxS6cTVqqC50mL/37t0UosJkgiQ==} @@ -15021,6 +15162,9 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -15247,6 +15391,10 @@ packages: scule@1.3.0: resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + selfsigned@5.5.0: + resolution: {integrity: sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==} + engines: {node: '>=18'} + sembear@0.7.0: resolution: {integrity: sha512-XyLTEich2D02FODCkfdto3mB9DetWPLuTzr4tvoofe9SvyM27h4nQSbV3+iVcYQz94AFyKtqBv5pcZbj3k2hdA==} @@ -15969,6 +16117,9 @@ packages: unplugin-unused: optional: true + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.3.0: resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} @@ -16003,6 +16154,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -19502,7 +19657,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.11.1 + '@emnapi/runtime': 1.11.3 optional: true '@img/sharp-wasm32@0.35.4': @@ -19825,6 +19980,8 @@ snapshots: '@noble/ciphers@2.1.1': {} + '@noble/hashes@1.8.0': {} + '@noble/hashes@2.0.1': {} '@nodelib/fs.scandir@2.1.5': @@ -20140,6 +20297,100 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.56.0': optional: true + '@peculiar/asn1-cms@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + '@peculiar/asn1-x509-attr': 2.11.0 + asn1js: 3.1.0 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + asn1js: 3.1.0 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + asn1js: 3.1.0 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.11.0': + dependencies: + '@peculiar/asn1-cms': 2.11.0 + '@peculiar/asn1-pkcs8': 2.11.0 + '@peculiar/asn1-rsa': 2.11.0 + '@peculiar/asn1-schema': 2.11.0 + asn1js: 3.1.0 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + asn1js: 3.1.0 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.11.0': + dependencies: + '@peculiar/asn1-cms': 2.11.0 + '@peculiar/asn1-pfx': 2.11.0 + '@peculiar/asn1-pkcs8': 2.11.0 + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + '@peculiar/asn1-x509-attr': 2.11.0 + asn1js: 3.1.0 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + asn1js: 3.1.0 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.11.0': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.1.0 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + asn1js: 3.1.0 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/utils': 2.0.3 + asn1js: 3.1.0 + tslib: 2.8.1 + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 + + '@peculiar/x509@1.14.3': + dependencies: + '@peculiar/asn1-cms': 2.11.0 + '@peculiar/asn1-csr': 2.11.0 + '@peculiar/asn1-ecc': 2.11.0 + '@peculiar/asn1-pkcs9': 2.11.0 + '@peculiar/asn1-rsa': 2.11.0 + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + pvtsutils: 1.3.6 + reflect-metadata: 0.2.2 + tslib: 2.8.1 + tsyringe: 4.10.0 + '@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: react: 19.2.4 @@ -20840,6 +21091,21 @@ snapshots: transitivePeerDependencies: - '@module-federation/runtime-tools' + '@rsbuild/plugin-basic-ssl@2.0.0(@rsbuild/core@2.2.13(core-js@3.37.1))': + dependencies: + selfsigned: 5.5.0 + optionalDependencies: + '@rsbuild/core': 2.2.13(core-js@3.37.1) + + '@rsbuild/plugin-react@2.1.1(@rsbuild/core@2.2.13(core-js@3.37.1))(@rspack/core@2.2.9(@swc/helpers@0.5.23))': + dependencies: + '@rspack/plugin-react-refresh': 2.0.2(@rspack/core@2.2.9(@swc/helpers@0.5.23))(react-refresh@0.19.0) + react-refresh: 0.19.0 + optionalDependencies: + '@rsbuild/core': 2.2.13(core-js@3.37.1) + transitivePeerDependencies: + - '@rspack/core' + '@rspack/binding-darwin-arm64@2.2.9': optional: true @@ -20909,6 +21175,12 @@ snapshots: optionalDependencies: '@swc/helpers': 0.5.23 + '@rspack/plugin-react-refresh@2.0.2(@rspack/core@2.2.9(@swc/helpers@0.5.23))(react-refresh@0.19.0)': + dependencies: + react-refresh: 0.19.0 + optionalDependencies: + '@rspack/core': 2.2.9(@swc/helpers@0.5.23) + '@rushstack/node-core-library@5.13.1(@types/node@22.15.17)': dependencies: ajv: 8.13.0 @@ -22630,6 +22902,12 @@ snapshots: dependencies: safer-buffer: 2.1.2 + asn1js@3.1.0: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.2.0 + tslib: 2.8.1 + assert-plus@1.0.0: {} ast-kit@2.2.0: @@ -22889,6 +23167,8 @@ snapshots: bytes@3.1.2: {} + bytestreamjs@2.0.1: {} + c12@3.1.0: dependencies: chokidar: 4.0.3 @@ -26283,6 +26563,15 @@ snapshots: pkginfo@0.4.1: {} + pkijs@3.4.1: + dependencies: + '@noble/hashes': 1.8.0 + asn1js: 3.1.0 + bytestreamjs: 2.0.1 + pvtsutils: 1.3.6 + pvutils: 1.2.0 + tslib: 2.8.1 + playwright-chromium@1.60.0: dependencies: playwright-core: 1.60.0 @@ -26691,6 +26980,12 @@ snapshots: pure-rand@6.1.0: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.2.0: {} + qr@0.6.0: {} qs@6.10.3: @@ -26811,6 +27106,8 @@ snapshots: react-refresh@0.18.0: {} + react-refresh@0.19.0: {} + react-required-if@1.0.3: {} react-shallow-renderer@16.15.0(react@18.3.1): @@ -26927,6 +27224,8 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + reflect-metadata@0.2.2: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -27292,6 +27591,11 @@ snapshots: scule@1.3.0: {} + selfsigned@5.5.0: + dependencies: + '@peculiar/x509': 1.14.3 + pkijs: 3.4.1 + sembear@0.7.0: dependencies: semver: 7.8.5 @@ -28169,6 +28473,8 @@ snapshots: - synckit - vue-tsc + tslib@1.14.1: {} + tslib@2.3.0: {} tslib@2.8.1: {} @@ -28300,6 +28606,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 From b0b408340fa0a6c18c09982a4e479f2475dedac8 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 19 Oct 2026 20:47:38 +0100 Subject: [PATCH 3/5] [rsbuild-plugin] Fix CI: load the Rsbuild harness lazily and commit the .dev.vars fixture The Vite playground jobs build only the Vite plugin, so importing the Rsbuild harness eagerly failed every spec. The root .gitignore excludes .dev.vars, so the Rsbuild fixture's secret was missing in CI. --- .../src/__tests__/fixtures/worker/.dev.vars | 1 + .../playground/__test-utils__/rsbuild.ts | 6 ------ packages/vite-plugin-cloudflare/playground/vitest-setup.ts | 5 +++-- 3 files changed, 4 insertions(+), 8 deletions(-) create mode 100644 packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/.dev.vars diff --git a/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/.dev.vars b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/.dev.vars new file mode 100644 index 00000000000..f37c733e73c --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/src/__tests__/fixtures/worker/.dev.vars @@ -0,0 +1 @@ +SECRET=from-dev-vars diff --git a/packages/vite-plugin-cloudflare/playground/__test-utils__/rsbuild.ts b/packages/vite-plugin-cloudflare/playground/__test-utils__/rsbuild.ts index 6458acdddfe..35d937194a8 100644 --- a/packages/vite-plugin-cloudflare/playground/__test-utils__/rsbuild.ts +++ b/packages/vite-plugin-cloudflare/playground/__test-utils__/rsbuild.ts @@ -17,12 +17,6 @@ import type { PluginOption, } from "vite"; -/** - * Runs the playground suite against `@cloudflare/rsbuild-plugin` instead of - * the Vite plugin when `CLOUDFLARE_TEST_BUNDLER=rsbuild`. - */ -export const isRsbuild = process.env.CLOUDFLARE_TEST_BUNDLER === "rsbuild"; - /** * Builds (when `isBuild`) and serves a playground with Rsbuild, using the * options passed to `cloudflare()` in the playground's Vite config. diff --git a/packages/vite-plugin-cloudflare/playground/vitest-setup.ts b/packages/vite-plugin-cloudflare/playground/vitest-setup.ts index 0260650c443..673741a071f 100644 --- a/packages/vite-plugin-cloudflare/playground/vitest-setup.ts +++ b/packages/vite-plugin-cloudflare/playground/vitest-setup.ts @@ -9,7 +9,6 @@ import { preview, } from "vite"; import { assert, beforeAll, inject } from "vitest"; -import { isRsbuild, startRsbuildServe } from "./__test-utils__/rsbuild"; import type { Browser, Page } from "playwright-chromium"; import type { ConfigEnv, @@ -287,7 +286,9 @@ export async function startDefaultServe(): Promise< // Vitest 4 sets NODE_ENV=test — override so Vite uses the correct mode process.env.NODE_ENV = isBuild ? "production" : "development"; - if (isRsbuild) { + if (process.env.CLOUDFLARE_TEST_BUNDLER === "rsbuild") { + // Imported lazily because the Vite runs don't build the Rsbuild plugin. + const { startRsbuildServe } = await import("./__test-utils__/rsbuild"); const server = await startRsbuildServe( await loadConfig({ command: isBuild ? "build" : "serve", mode: "" }), { isBuild } From dbdb4f78dab27c0662360fe934ab49fade811949 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 19 Oct 2026 21:45:59 +0100 Subject: [PATCH 4/5] [rsbuild-plugin] Share the per-Worker dev options with the Vite plugin Move the per-Worker part of the Vite plugin's dev Miniflare options into `getDevUserWorkerOptions` in `@cloudflare/workers-dev-shared`. The Vite plugin extends it with its module-runner bindings. The Rsbuild plugin uses it as-is, so it now also enables headful browser rendering when requested, and like the Vite plugin it turns off Miniflare's request logging. Also stop rebuilding the whole Worker environment config just to read its entry, and tidy the entry Worker middleware setup. --- .../rsbuild-plugin-cloudflare/src/index.ts | 117 +++++++----------- .../src/worker-environment.ts | 49 +++++--- .../src/miniflare-options.ts | 71 ++++------- packages/workers-dev-shared/src/index.ts | 1 + .../src/miniflare-options.ts | 96 +++++++++++++- 5 files changed, 191 insertions(+), 143 deletions(-) diff --git a/packages/rsbuild-plugin-cloudflare/src/index.ts b/packages/rsbuild-plugin-cloudflare/src/index.ts index 2acc301ecfc..ee68fd4b025 100644 --- a/packages/rsbuild-plugin-cloudflare/src/index.ts +++ b/packages/rsbuild-plugin-cloudflare/src/index.ts @@ -12,7 +12,6 @@ import { } from "@cloudflare/build-output-utils"; import { createDebugMiddleware, - createMiniflareExports, createRequestHandler, createStaticRoutingMiddleware, DEBUG_PATH, @@ -21,12 +20,10 @@ import { getAssetsConfig, getCommonMiniflareOptions, getDevAssetWorkerOptions, + getDevUserWorkerOptions, getPreviewMiniflareOptions, - getRemoteProxySessionData, - getWorkerZone, handleWebSocket, isPreviewBuild, - kRequestType, moveImportedAssets, readBuildOutputPreview, resolveDevOnly, @@ -45,6 +42,7 @@ import { getAdditionalModuleTypes, getImportedAssetPaths, getManifestFromCompilation, + getWorkerEntry, getWorkerEnvironmentConfig, removeUnemittedFiles, } from "./worker-environment"; @@ -215,10 +213,7 @@ export function pluginCloudflare( if (worker) { // Top-level entries are merged into every environment, so replace // them to keep client entries out of the Worker. - config.source.entry = getWorkerEnvironmentConfig( - worker, - root - ).source?.entry; + config.source.entry = getWorkerEntry(worker, root); } else if ( action === "build" && resolvedPluginConfig.type !== "preview" && @@ -236,13 +231,6 @@ export function pluginCloudflare( [...workerEnvironments.values()].map(({ config }) => config.name) ); const entryWorker = workerEnvironments.get(ENVIRONMENT_NAME); - const staticRouting = - entryWorker && resolvedPluginConfig.type === "workers" - ? resolveStaticRouting( - entryWorker.config, - resolvedPluginConfig.staticRouting - ) - : undefined; if (entryWorker) { const userWorkerHandler = createRequestHandler(async (request) => { request.headers.set( @@ -253,18 +241,22 @@ export function pluginCloudflare( redirect: "manual", }); }); + const toUserWorker: Parameters< + typeof createStaticRoutingMiddleware + >[1] = (req, res, next) => void userWorkerHandler(req, res, next); // Local trigger routes (e.g. `/cdn-cgi/local/scheduled`) must reach // the entry Worker rather than the router Worker. - server.middlewares.use( - "/cdn-cgi/local/", - (req, res, next) => void userWorkerHandler(req, res, next) - ); + server.middlewares.use("/cdn-cgi/local/", toUserWorker); + const staticRouting = + resolvedPluginConfig.type === "workers" + ? resolveStaticRouting( + entryWorker.config, + resolvedPluginConfig.staticRouting + ) + : undefined; if (staticRouting) { server.middlewares.use( - createStaticRoutingMiddleware( - staticRouting, - (req, res, next) => void userWorkerHandler(req, res, next) - ) + createStaticRoutingMiddleware(staticRouting, toUserWorker) ); } } @@ -390,9 +382,9 @@ export function pluginCloudflare( // `public/` into the assets directory, as Vite's client build does. for (const { name, copyOnBuild } of api.getNormalizedConfig().server .publicDir) { - const dir = path.resolve(root, name); - if (copyOnBuild && fs.existsSync(dir)) { - await fsp.cp(dir, assetsDir, { recursive: true }); + // Rsbuild normalizes `name` to an absolute path. + if (copyOnBuild && fs.existsSync(name)) { + await fsp.cp(name, assetsDir, { recursive: true }); } } } @@ -582,54 +574,29 @@ export function pluginCloudflare( warnForMissingSecrets(localBindings, mode, api.logger); const workers = await Promise.all( - [...workerEnvironments].map( - async ([name, { config: workerConfig }]) => { - const { - entrypoint: _entrypoint, - assets: _assets, - exports, - ...config - } = workerConfig; - const env = { ...localBindings.get(config.name)?.bindings }; - // Rsbuild serves the client assets in dev, so the assets binding is - // forwarded to its middleware. - for (const [bindingName, binding] of Object.entries(env)) { - if (binding.type === "assets") { - env[bindingName] = { - type: "node-handler", - handler: (req, res) => { - req[kRequestType] = "asset"; - devServer?.middlewares(req, res); - }, - }; - } - } - const remoteProxySessionData = await getRemoteProxySessionData( - workerConfig, - { ...resolvedPluginConfig, root, logger: api.logger } - ); - - return { - config: { - ...config, - env, - manifest: manifests.get(name), - // Containers aren't supported yet, so Container-backed exports throw. - exports: createMiniflareExports({ - exports, - containerRuntimeOptions: undefined, - }), - }, - dev: { - zone: getWorkerZone(config), - remoteProxyConnectionString: - remoteProxySessionData?.session - ?.remoteProxyConnectionString, - unsafeInspectorProxy: inspectorPort !== false, - }, - } satisfies WorkerOptions; - } - ) + [...workerEnvironments].map(async ([name, { config }]) => { + const workerOptions = await getDevUserWorkerOptions(config, { + resolvedLocalBindings: localBindings.get(config.name), + remoteProxy: { + ...resolvedPluginConfig, + root, + logger: api.logger, + }, + // Rsbuild serves the client assets in dev. + handleAssetRequest: (req, res) => + devServer?.middlewares(req, res), + inspectorPort, + // Containers aren't supported yet, so Container-backed exports throw. + containerRuntimeOptions: undefined, + }); + return { + ...workerOptions, + config: { + ...workerOptions.config, + manifest: manifests.get(name), + }, + } satisfies WorkerOptions; + }) ); return { @@ -640,6 +607,8 @@ export function pluginCloudflare( inspectorPort, publicUrl: undefined, }), + // As in the Vite plugin, don't log every request. + logRequests: false, workers: [...assetWorkers, ...workers], }; } diff --git a/packages/rsbuild-plugin-cloudflare/src/worker-environment.ts b/packages/rsbuild-plugin-cloudflare/src/worker-environment.ts index b5ecfe0cfb9..741a9c4c30f 100644 --- a/packages/rsbuild-plugin-cloudflare/src/worker-environment.ts +++ b/packages/rsbuild-plugin-cloudflare/src/worker-environment.ts @@ -33,15 +33,10 @@ export const WORKER_ENTRY_NAME = "index"; * @returns An Rsbuild environment config. */ export function getWorkerEnvironmentConfig( - { config, directoryName, nodeJsCompat }: Worker, + worker: Worker, root: string ): EnvironmentConfig { - // As in Vite, the entrypoint is a path relative to the root, or else a - // module specifier (e.g. a package export). - const entrypointPath = path.resolve(root, config.entrypoint); - const entrypoint = fs.existsSync(entrypointPath) - ? entrypointPath - : config.entrypoint; + const { config, directoryName, nodeJsCompat } = worker; /** The additional modules to emit, keyed by output file name. */ const additionalModules = new Map< string, @@ -49,16 +44,7 @@ export function getWorkerEnvironmentConfig( >(); return { - source: { - entry: { - [WORKER_ENTRY_NAME]: { - import: nodeJsCompat - ? [toDataUri(nodeJsCompat.injectGlobalCode()), entrypoint] - : entrypoint, - html: false, - }, - }, - }, + source: { entry: getWorkerEntry(worker, root) }, resolve: { conditionNames, // As in the Vite plugin, don't prefer packages' browser builds. @@ -152,6 +138,35 @@ export function getWorkerEnvironmentConfig( }; } +/** + * Gets the Worker environment's entry, which imports the Node.js compat globals + * before the Worker when they're needed. + * + * @param worker The resolved Worker. + * @param root The project root. + * @returns The Rsbuild entry config. + */ +export function getWorkerEntry( + { config, nodeJsCompat }: Worker, + root: string +): NonNullable["entry"]> { + // As in Vite, the entrypoint is a path relative to the root, or else a + // module specifier (e.g. a package export). + const entrypointPath = path.resolve(root, config.entrypoint); + const entrypoint = fs.existsSync(entrypointPath) + ? entrypointPath + : config.entrypoint; + + return { + [WORKER_ENTRY_NAME]: { + import: nodeJsCompat + ? [toDataUri(nodeJsCompat.injectGlobalCode()), entrypoint] + : entrypoint, + html: false, + }, + }; +} + /** * Applies the shared Node.js compat aliases and global injections. Requests * are rewritten before resolution because rspack rejects `node:` specifiers diff --git a/packages/vite-plugin-cloudflare/src/miniflare-options.ts b/packages/vite-plugin-cloudflare/src/miniflare-options.ts index ee4e5b43715..3cdf73dcba1 100644 --- a/packages/vite-plugin-cloudflare/src/miniflare-options.ts +++ b/packages/vite-plugin-cloudflare/src/miniflare-options.ts @@ -9,23 +9,19 @@ import { resolveDockerHost, } from "@cloudflare/containers-shared"; import { - createMiniflareExports, DevMiniflareLogger, getPreviewMiniflareOptions as getSharedPreviewMiniflareOptions, getAssetsConfig, getCommonMiniflareOptions, getDevAssetWorkerOptions, + getDevUserWorkerOptions, getInternalWorkerManifest, - getRemoteProxySessionData, - getWorkerZone, INTERNAL_WORKERS_COMPATIBILITY_DATE, - kRequestType, miniflareModulesRoot, resolveLocalBindingsByWorkerName, VITE_PROXY_WORKER_NAME, warnForMissingSecrets, } from "@cloudflare/workers-dev-shared"; -import { getBrowserRenderingHeadfulFromEnv } from "@cloudflare/workers-utils"; import { getDockerPath } from "@cloudflare/workers-utils/docker-path"; import { buildPublicUrl, @@ -275,14 +271,25 @@ export async function getDevMiniflareOptions( ? await Promise.all( [...resolvedPluginConfig.environmentNameToWorkerMap].map( async ([environmentName, worker]) => { - const remoteProxySessionData = await getRemoteProxySessionData( + const workerOptions = await getDevUserWorkerOptions( worker.config, { - remoteBindings: resolvedPluginConfig.remoteBindings, - settings: resolvedPluginConfig.settings, - root: resolvedViteConfig.root, - cliDisplayName: resolvedPluginConfig.cliDisplayName, - logger: viteDevServer.config.logger, + resolvedLocalBindings: resolvedLocalBindingsByWorkerName.get( + worker.config.name + ), + remoteProxy: { + remoteBindings: resolvedPluginConfig.remoteBindings, + settings: resolvedPluginConfig.settings, + root: resolvedViteConfig.root, + cliDisplayName: resolvedPluginConfig.cliDisplayName, + logger: viteDevServer.config.logger, + }, + handleAssetRequest: (req, res) => + viteDevServer.middlewares(req, res), + inspectorPort: inputInspectorPort, + containerRuntimeOptions: containerPlansByWorkerName.get( + worker.config.name + )?.containerRuntimeOptions, } ); @@ -303,31 +310,7 @@ export async function getDevMiniflareOptions( ); } - const { - entrypoint: _entrypoint, - assets: _assets, - ...config - } = worker.config; - const resolvedLocalBindings = - resolvedLocalBindingsByWorkerName.get(worker.config.name); - assert(resolvedLocalBindings); - const env = { ...resolvedLocalBindings.bindings }; - for (const [name, binding] of Object.entries(env)) { - if (binding.type === "assets") { - env[name] = { - type: "node-handler", - handler: (req, res) => { - req[kRequestType] = "asset"; - viteDevServer.middlewares(req, res); - }, - }; - } else if ( - binding.type === "browser" && - getBrowserRenderingHeadfulFromEnv() - ) { - env[name] = { ...binding, headful: true }; - } - } + const { env, exports: workerExports } = workerOptions.config; env.__VITE_INVOKE_MODULE__ = { type: "fetcher", handler: async (request) => { @@ -351,12 +334,6 @@ export async function getDevMiniflareOptions( worker: worker.config.name, exportName: "__VITE_RUNNER_OBJECT__", }; - const workerExports = createMiniflareExports({ - exports: config.exports, - containerRuntimeOptions: containerPlansByWorkerName.get( - worker.config.name - )?.containerRuntimeOptions, - }); workerExports.__VITE_RUNNER_OBJECT__ = { type: "durable-object", storage: "sqlite", @@ -366,17 +343,11 @@ export async function getDevMiniflareOptions( return { config: { - ...config, - env, - exports: workerExports, + ...workerOptions.config, manifest: await getUserWorkerManifest(wrappers.join("\n")), }, dev: { - zone: getWorkerZone(config), - remoteProxyConnectionString: - remoteProxySessionData?.session - ?.remoteProxyConnectionString, - unsafeInspectorProxy: inputInspectorPort !== false, + ...workerOptions.dev, useModuleFallbackService: true, unsafeEvalBinding: "__VITE_UNSAFE_EVAL__", ...(environmentName === diff --git a/packages/workers-dev-shared/src/index.ts b/packages/workers-dev-shared/src/index.ts index 0c954f9cf35..08b8df3901e 100644 --- a/packages/workers-dev-shared/src/index.ts +++ b/packages/workers-dev-shared/src/index.ts @@ -63,6 +63,7 @@ export type { DevLogger } from "./logger"; export { createMiniflareExports } from "./miniflare-exports"; export { getCommonMiniflareOptions, + getDevUserWorkerOptions, getPreviewMiniflareOptions, } from "./miniflare-options"; export { diff --git a/packages/workers-dev-shared/src/miniflare-options.ts b/packages/workers-dev-shared/src/miniflare-options.ts index 4e3cdfb34ea..308d2160265 100644 --- a/packages/workers-dev-shared/src/miniflare-options.ts +++ b/packages/workers-dev-shared/src/miniflare-options.ts @@ -5,12 +5,13 @@ import { resolveDockerHost, } from "@cloudflare/containers-shared"; import { + getBrowserRenderingHeadfulFromEnv, getLocalExplorerEnabledFromEnv, getLocalObservabilityEnabledFromEnv, } from "@cloudflare/workers-utils"; import { getDockerPath } from "@cloudflare/workers-utils/docker-path"; import { getDefaultDevRegistryPath } from "miniflare"; -import { PROXY_SHARED_SECRET } from "./constants"; +import { kRequestType, PROXY_SHARED_SECRET } from "./constants"; import { resolveLocalBindingsByWorkerName, warnForMissingSecrets, @@ -20,14 +21,20 @@ import { getStructuredLogsLogger } from "./miniflare-logger"; import { getMiniflareManifest } from "./miniflare-manifest"; import { getPersistenceRoot } from "./persist"; import { getRemoteProxySessionData, getWorkerZone } from "./remote-bindings"; +import type { ResolvedLocalBindings } from "./local-env"; import type { DevLogger } from "./logger"; import type { PersistState } from "./persist"; -import type { PreviewResolvedConfig } from "./plugin-config"; +import type { + PreviewResolvedConfig, + ResolvedWorkerConfig, +} from "./plugin-config"; import type { ContainerDevOptions, ContainerDevPlan, + ContainerDevRuntimeOptions, } from "@cloudflare/containers-shared"; import type { Log, MiniflareOptions, WorkerOptions } from "miniflare"; +import type * as http from "node:http"; const ASSETS_ONLY_MODULE = "__VITE_ASSETS_ONLY__.js"; @@ -201,6 +208,91 @@ export async function getPreviewMiniflareOptions(options: { }; } +type DevUserWorkerOptions = { + config: Omit< + ResolvedWorkerConfig, + "entrypoint" | "assets" | "env" | "exports" + > & { + env: ResolvedLocalBindings["bindings"]; + exports: ReturnType; + }; + dev: Pick< + NonNullable, + "zone" | "remoteProxyConnectionString" | "unsafeInspectorProxy" + >; +}; + +/** + * Gets the dev options for a user Worker that are the same for every bundler. + * The bundler adds the Worker's modules, and anything else it needs, on top. + * + * @param workerConfig The Worker's resolved config + * @param options.resolvedLocalBindings The Worker's local bindings + * @param options.remoteProxy The options for the Worker's remote proxy session + * @param options.handleAssetRequest Serves the Worker's assets binding from the dev server + * @param options.inspectorPort The inspector port, or `false` if the inspector is disabled + * @param options.containerRuntimeOptions The Worker's Container runtime options, if any + * @returns The Worker's options, without its modules + */ +export async function getDevUserWorkerOptions( + workerConfig: ResolvedWorkerConfig, + options: { + resolvedLocalBindings: ResolvedLocalBindings | undefined; + remoteProxy: Parameters[1]; + handleAssetRequest: ( + req: http.IncomingMessage, + res: http.ServerResponse + ) => void; + inspectorPort: number | false; + containerRuntimeOptions: + | Map + | undefined; + } +): Promise { + const remoteProxySessionData = await getRemoteProxySessionData( + workerConfig, + options.remoteProxy + ); + + const { entrypoint: _entrypoint, assets: _assets, ...config } = workerConfig; + const { resolvedLocalBindings } = options; + assert(resolvedLocalBindings); + const env = { ...resolvedLocalBindings.bindings }; + for (const [name, binding] of Object.entries(env)) { + if (binding.type === "assets") { + env[name] = { + type: "node-handler", + handler: (req, res) => { + req[kRequestType] = "asset"; + options.handleAssetRequest(req, res); + }, + }; + } else if ( + binding.type === "browser" && + getBrowserRenderingHeadfulFromEnv() + ) { + env[name] = { ...binding, headful: true }; + } + } + + return { + config: { + ...config, + env, + exports: createMiniflareExports({ + exports: config.exports, + containerRuntimeOptions: options.containerRuntimeOptions, + }), + }, + dev: { + zone: getWorkerZone(config), + remoteProxyConnectionString: + remoteProxySessionData?.session?.remoteProxyConnectionString, + unsafeInspectorProxy: options.inspectorPort !== false, + }, + }; +} + function getAssetsOnlyManifest( rootPath: string ): NonNullable { From 7bc7ad75989e4cacf7f2adab68876dfd0d030ccd Mon Sep 17 00:00:00 2001 From: James Date: Mon, 19 Oct 2026 22:35:42 +0100 Subject: [PATCH 5/5] [rsbuild-plugin] Run the Vite plugin's e2e suite against Rsbuild seed() converts each fixture to an Rsbuild project when CLOUDFLARE_TEST_BUNDLER=rsbuild, installing a packed copy of the private plugin. Fixes two gaps the suite found: Worker-only projects didn't print the dev server URL, and an unresolvable entrypoint didn't fail at startup. --- .github/workflows/e2e-vite.yml | 9 ++ packages/rsbuild-plugin-cloudflare/README.md | 8 ++ .../rsbuild-plugin-cloudflare/src/index.ts | 11 +- .../src/worker-environment.ts | 12 ++ packages/vite-plugin-cloudflare/AGENTS.md | 4 + packages/vite-plugin-cloudflare/e2e/README.md | 4 + .../vite-plugin-cloudflare/e2e/helpers.ts | 4 + .../vite-plugin-cloudflare/e2e/rsbuild.ts | 128 ++++++++++++++++++ .../e2e/vitest.config.rsbuild.ts | 34 +++++ packages/vite-plugin-cloudflare/package.json | 1 + packages/vite-plugin-cloudflare/turbo.json | 9 ++ 11 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 packages/vite-plugin-cloudflare/e2e/rsbuild.ts create mode 100644 packages/vite-plugin-cloudflare/e2e/vitest.config.rsbuild.ts diff --git a/.github/workflows/e2e-vite.yml b/.github/workflows/e2e-vite.yml index 90fcbf7058b..a89d947a4ce 100644 --- a/.github/workflows/e2e-vite.yml +++ b/.github/workflows/e2e-vite.yml @@ -65,6 +65,15 @@ jobs: NODE_OPTIONS: "--max_old_space_size=8192" CI_OS: ${{ matrix.os }} + - name: Run Vite plugin E2E tests against the Rsbuild plugin + if: steps.changes.outputs.everything_but_markdown == 'true' && matrix.os == 'ubuntu-latest' + run: pnpm dotenv -- pnpm turbo test:e2e:rsbuild -F @cloudflare/vite-plugin --log-order=stream + timeout-minutes: 20 + env: + NODE_DEBUG: "vite-plugin:test" + NODE_OPTIONS: "--max_old_space_size=8192" + CI_OS: ${{ matrix.os }} + - name: Upload turbo logs if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/packages/rsbuild-plugin-cloudflare/README.md b/packages/rsbuild-plugin-cloudflare/README.md index 37c54b1a1fa..de4ad6e8c87 100644 --- a/packages/rsbuild-plugin-cloudflare/README.md +++ b/packages/rsbuild-plugin-cloudflare/README.md @@ -36,3 +36,11 @@ pnpm --filter @vite-plugin-cloudflare/playground test:ci:rsbuild:build ``` See `packages/vite-plugin-cloudflare/playground/vitest.config.e2e.rsbuild.ts` for the playgrounds and tests that are skipped, and why. + +So does the Vite plugin's e2e suite, which installs fixtures from a mock npm registry: + +```sh +pnpm turbo test:e2e:rsbuild -F @cloudflare/vite-plugin +``` + +See `packages/vite-plugin-cloudflare/e2e/vitest.config.rsbuild.ts` for the tests that are skipped. diff --git a/packages/rsbuild-plugin-cloudflare/src/index.ts b/packages/rsbuild-plugin-cloudflare/src/index.ts index ee68fd4b025..2eb8d30d2aa 100644 --- a/packages/rsbuild-plugin-cloudflare/src/index.ts +++ b/packages/rsbuild-plugin-cloudflare/src/index.ts @@ -203,8 +203,15 @@ export function pluginCloudflare( type: "reload-server", }, }, - // The asset Worker handles HTML requests, as when deployed. - server: { htmlFallback: false }, + server: { + // The asset Worker handles HTML requests, as when deployed. + htmlFallback: false, + // Rsbuild only prints the server's URLs for client environments. + ...(Object.keys(environments).length === 0 && + userConfig.server?.printUrls === true && { + printUrls: ({ urls }: { urls: string[] }) => urls, + }), + }, }); }); diff --git a/packages/rsbuild-plugin-cloudflare/src/worker-environment.ts b/packages/rsbuild-plugin-cloudflare/src/worker-environment.ts index 741a9c4c30f..a5877dea215 100644 --- a/packages/rsbuild-plugin-cloudflare/src/worker-environment.ts +++ b/packages/rsbuild-plugin-cloudflare/src/worker-environment.ts @@ -156,6 +156,18 @@ export function getWorkerEntry( const entrypoint = fs.existsSync(entrypointPath) ? entrypointPath : config.entrypoint; + // As in the Vite plugin, fail at startup rather than with a build error, + // which `rsbuild dev` would report and then keep running. + if ( + entrypoint === config.entrypoint && + new rspack.experiments.resolver.ResolverFactory({ + conditionNames: [...conditionNames, "import"], + }).sync(root, entrypoint).error + ) { + throw new Error( + `Failed to resolve Worker entrypoint "${entrypoint}" for Worker "${config.name}"` + ); + } return { [WORKER_ENTRY_NAME]: { diff --git a/packages/vite-plugin-cloudflare/AGENTS.md b/packages/vite-plugin-cloudflare/AGENTS.md index b9ef073e117..aaa6fb8a7bb 100644 --- a/packages/vite-plugin-cloudflare/AGENTS.md +++ b/packages/vite-plugin-cloudflare/AGENTS.md @@ -120,3 +120,7 @@ contract so the parent can drive either impl interchangeably. throws on Vite plugins it can't translate. Playgrounds and tests that depend on Vite-specific behaviour are skipped in `playground/vitest.config.e2e.rsbuild.ts`, each with the reason. Don't edit specs to make them pass under Rsbuild. +- `test:e2e:rsbuild` runs the `e2e/` suite against `@cloudflare/rsbuild-plugin`. + `e2e/rsbuild.ts` packs the private plugin and converts each seeded fixture + (dependencies, scripts and `vite.config` → `rsbuild.config`). Vite-specific + tests are skipped in `e2e/vitest.config.rsbuild.ts`. diff --git a/packages/vite-plugin-cloudflare/e2e/README.md b/packages/vite-plugin-cloudflare/e2e/README.md index 8b86c80d957..65d44f16cbd 100644 --- a/packages/vite-plugin-cloudflare/e2e/README.md +++ b/packages/vite-plugin-cloudflare/e2e/README.md @@ -55,3 +55,7 @@ You can control the logging and cleanup via environment variables: - See debug logs for the tests: `NODE_DEBUG=vite-plugin:test` - See debug logs for the mock npm registry: `NODE_DEBUG=mock-npm-registry` - See debug logs for Vite: `DEBUG="vite:*"` + +## Running against the Rsbuild plugin + +`pnpm turbo test:e2e:rsbuild -F @cloudflare/vite-plugin` runs the same tests against `@cloudflare/rsbuild-plugin`. `seed()` converts each fixture to an Rsbuild project (see `rsbuild.ts`), installing a packed copy of the private Rsbuild plugin. Tests of Vite-specific behaviour are skipped in `vitest.config.rsbuild.ts`. Don't edit tests to make them pass under Rsbuild. diff --git a/packages/vite-plugin-cloudflare/e2e/helpers.ts b/packages/vite-plugin-cloudflare/e2e/helpers.ts index 56fef0a6b44..d0373dcf9c7 100644 --- a/packages/vite-plugin-cloudflare/e2e/helpers.ts +++ b/packages/vite-plugin-cloudflare/e2e/helpers.ts @@ -17,6 +17,7 @@ import { vi, } from "vitest"; import vitePluginPackage from "../package.json"; +import { convertToRsbuild } from "./rsbuild"; const debuglog = util.debuglog("vite-plugin:test"); @@ -62,6 +63,9 @@ export function seed( }); debuglog("Fixture copied to " + projectPath); await updateVitePluginVersion(projectPath, pm); + if (process.env.CLOUDFLARE_TEST_BUNDLER === "rsbuild") { + await convertToRsbuild(projectPath, inject("rsbuildPluginTarball")); + } debuglog("Fixing up replacements in seeded files"); await fixupReplacements(projectPath, replacements); debuglog("Updated vite-plugin version in package.json"); diff --git a/packages/vite-plugin-cloudflare/e2e/rsbuild.ts b/packages/vite-plugin-cloudflare/e2e/rsbuild.ts new file mode 100644 index 00000000000..d0cb1cccccd --- /dev/null +++ b/packages/vite-plugin-cloudflare/e2e/rsbuild.ts @@ -0,0 +1,128 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { removeDir } from "@cloudflare/workers-utils"; +import playgroundPackage from "../playground/package.json"; +import type { TestProject } from "vitest/node"; + +declare module "vitest" { + export interface ProvidedContext { + rsbuildPluginTarball: string; + } +} + +/** + * Packs `@cloudflare/rsbuild-plugin` for the fixtures to install. It's private, + * so it can't be published to the mock npm registry. Its dependencies are the + * Vite plugin's, which the registry does publish. + */ +export default async function ({ provide }: TestProject) { + const packDir = await fs.mkdtemp( + path.join(await fs.realpath(os.tmpdir()), "rsbuild-plugin-") + ); + const output = execFileSync( + "pnpm", + ["pack", "--json", "--pack-destination", packDir], + { + cwd: path.resolve(__dirname, "../../rsbuild-plugin-cloudflare"), + encoding: "utf8", + shell: process.platform === "win32", + } + ); + provide("rsbuildPluginTarball", JSON.parse(output).filename); + return () => removeDir(packDir); +} + +/** + * Turns a seeded Vite fixture into the equivalent Rsbuild project: the + * `cloudflare()` options are passed unchanged to `pluginCloudflare()`, and the + * `vite` scripts become `rsbuild` ones. + * + * @param projectPath The seeded project + * @param tarball The packed `@cloudflare/rsbuild-plugin` + */ +export async function convertToRsbuild(projectPath: string, tarball: string) { + const pkgPath = path.join(projectPath, "package.json"); + const pkg = JSON.parse(await fs.readFile(pkgPath, "utf8")); + const { devDependencies: versions } = playgroundPackage; + for (const deps of [pkg.dependencies, pkg.devDependencies]) { + if (deps?.["@cloudflare/vite-plugin"]) { + delete deps["@cloudflare/vite-plugin"]; + delete deps.vite; + deps["@cloudflare/rsbuild-plugin"] = `file:${tarball}`; + deps["@rsbuild/core"] = versions["@rsbuild/core"]; + } + if (deps?.["@vitejs/plugin-react"]) { + delete deps["@vitejs/plugin-react"]; + deps["@rsbuild/plugin-react"] = versions["@rsbuild/plugin-react"]; + } + } + for (const [name, script] of Object.entries(pkg.scripts)) { + pkg.scripts[name] = script.replace( + /\bvite(?: (dev|build|preview))?\b/g, + (_, command = "dev") => `rsbuild ${command}` + ); + } + await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2)); + + const files = await fs.readdir(projectPath); + const viteConfigFile = files.find((file) => + /^vite\.config\.m?[jt]s$/.test(file) + ); + if (!viteConfigFile) { + throw new Error(`No Vite config found in ${projectPath}.`); + } + let config = await fs.readFile( + path.join(projectPath, viteConfigFile), + "utf8" + ); + for (const [from, to] of [ + [ + `import { cloudflare } from "@cloudflare/vite-plugin";`, + `import { pluginCloudflare as cloudflare } from "@cloudflare/rsbuild-plugin";`, + ], + [ + `import react from "@vitejs/plugin-react";`, + `import { pluginReact as react } from "@rsbuild/plugin-react";`, + ], + [ + `import { defineConfig } from "vite";`, + `import { defineConfig } from "@rsbuild/core";`, + ], + ]) { + config = config.replace(from, to); + } + if (/from "(vite|@vitejs\/.*|@cloudflare\/vite-plugin)"/.test(config)) { + throw new Error( + `${viteConfigFile} has imports with no Rsbuild equivalent.` + ); + } + + // Vite uses `index.html` as the client entry, whereas Rsbuild injects the + // page's script into the HTML. Vite also resolves root-relative imports + // (e.g. `/vite.svg`) from `public/`. + const htmlPath = path.join(projectPath, "index.html"); + const scriptRE = /\s*