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/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/.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/.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/README.md b/packages/rsbuild-plugin-cloudflare/README.md new file mode 100644 index 00000000000..de4ad6e8c87 --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/README.md @@ -0,0 +1,46 @@ +# `@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. + +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/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/.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/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..74e3c140823 --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/src/__tests__/helpers.ts @@ -0,0 +1,103 @@ +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 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, + inspectorPort: 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..2eb8d30d2aa --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/src/index.ts @@ -0,0 +1,659 @@ +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, + writeRootConfig, + writeWorkerConfig, +} from "@cloudflare/build-output-utils"; +import { + createDebugMiddleware, + createRequestHandler, + createStaticRoutingMiddleware, + DEBUG_PATH, + DevMiniflareLogger, + dispatchDevRequest, + getAssetsConfig, + getCommonMiniflareOptions, + getDevAssetWorkerOptions, + getDevUserWorkerOptions, + getPreviewMiniflareOptions, + handleWebSocket, + isPreviewBuild, + moveImportedAssets, + readBuildOutputPreview, + resolveDevOnly, + resolveInspectorPort, + resolveLocalBindingsByWorkerName, + resolvePluginConfig, + resolveStaticRouting, + warnForMissingSecrets, +} from "@cloudflare/workers-dev-shared"; +import { + getDevVarsCandidatePaths, + getEnvPaths, +} from "@cloudflare/workers-utils/local-env"; +import { CoreHeaders, Miniflare } from "miniflare"; +import { + getAdditionalModuleTypes, + getImportedAssetPaths, + getManifestFromCompilation, + getWorkerEntry, + getWorkerEnvironmentConfig, + removeUnemittedFiles, +} from "./worker-environment"; +import type { + DevHtmlHandlers, + IncomingMessage, + PluginConfig, + ResolvedPluginConfig, + Worker, +} from "@cloudflare/workers-dev-shared"; +import type { + EnvironmentContext, + PublicDir, + RsbuildDevServer, + RsbuildPlugin, +} from "@rsbuild/core"; +import type { MiniflareOptions, WorkerOptions } from "miniflare"; + +/** 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 entry Worker. */ +const ENVIRONMENT_NAME = "cloudflare"; + +type MiniflareManifest = NonNullable; + +/** + * 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. + */ +export function pluginCloudflare( + options: PluginCloudflareOptions = {} +): RsbuildPlugin { + return { + name: "rsbuild:cloudflare", + setup(api) { + const { action, rootPath: root } = api.context; + let mode = "development"; + 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 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; + }); + let miniflareLogger: DevMiniflareLogger; + + api.modifyRsbuildConfig(async (userConfig, { mergeRsbuildConfig }) => { + 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, { + output: { distPath: { root: BUILD_OUTPUT_ROOT } }, + }); + } + + 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 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, + ...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: [ + ...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", + }, + }, + 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, + }), + }, + }); + }); + + api.modifyEnvironmentConfig((config, { 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 = getWorkerEntry(worker, root); + } else if ( + action === "build" && + resolvedPluginConfig.type !== "preview" && + config.output.target === "web" + ) { + config.output.distPath.root = getWorkerAssetsDir(root); + } + }); + + api.onBeforeStartDevServer(({ server, environments }) => { + devServer = server; + environmentContexts = environments; + addServerHandlers( + server, + [...workerEnvironments.values()].map(({ config }) => config.name) + ); + const entryWorker = workerEnvironments.get(ENVIRONMENT_NAME); + if (entryWorker) { + const userWorkerHandler = createRequestHandler(async (request) => { + request.headers.set( + CoreHeaders.ROUTE_OVERRIDE, + entryWorker.config.name + ); + return (await getMiniflare()).dispatchFetch(request, { + 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/", toUserWorker); + const staticRouting = + resolvedPluginConfig.type === "workers" + ? resolveStaticRouting( + entryWorker.config, + resolvedPluginConfig.staticRouting + ) + : undefined; + if (staticRouting) { + server.middlewares.use( + createStaticRoutingMiddleware(staticRouting, toUserWorker) + ); + } + } + // 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 router 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 (!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; + } + // 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; + } + 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 () => { + await miniflare?.dispose(); + }); + + api.onBeforeStartPreviewServer(async ({ server }) => { + 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 ({ 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) { + // Rsbuild normalizes `name` to an absolute path. + if (copyOnBuild && fs.existsSync(name)) { + await fsp.cp(name, 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, + }); + }); + + async function startOrUpdateMiniflare( + miniflareOptions: MiniflareOptions + ): Promise { + if (miniflare) { + await miniflare.setOptions(miniflareOptions); + } else { + miniflare = new Miniflare(miniflareOptions); + await miniflare.ready; + } + } + + async function getMiniflare(): Promise { + await firstStart; + if (miniflareError || !miniflare) { + throw miniflareError; + } + return miniflare; + } + + /** 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", + }) + ); + + /** 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"); + }, + }; + + 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 }; + } + } + } + } + + 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, + }) + ); + } + } + + function getInspectorPort(): Promise { + return resolveInspectorPort({ + inspectorPort: resolvedPluginConfig.inspectorPort, + getPreviousPort: async () => + miniflare ? Number((await miniflare.getInspectorURL()).port) : null, + logger: api.logger, + }); + } + + 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( + [...workerEnvironments.values()].map(({ config }) => config), + resolvedPluginConfig.localEnv, + resolvedPluginConfig.devVars + ); + warnForMissingSecrets(localBindings, mode, api.logger); + + const workers = await Promise.all( + [...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 { + ...getCommonMiniflareOptions({ + root, + persistState: resolvedPluginConfig.persistState, + logger: miniflareLogger, + inspectorPort, + publicUrl: undefined, + }), + // As in the Vite plugin, don't log every request. + logRequests: false, + workers: [...assetWorkers, ...workers], + }; + } + }, + }; +} + +/** 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 new file mode 100644 index 00000000000..a5877dea215 --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/src/worker-environment.ts @@ -0,0 +1,408 @@ +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 { 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 { 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"; + +/** + * 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 resolved Worker. + * @param root The project root. + * @returns An Rsbuild environment config. + */ +export function getWorkerEnvironmentConfig( + worker: Worker, + root: string +): EnvironmentConfig { + const { config, directoryName, nodeJsCompat } = worker; + /** The additional modules to emit, keyed by output file name. */ + const additionalModules = new Map< + string, + { filePath: string; type: ModuleType } + >(); + + return { + source: { entry: getWorkerEntry(worker, root) }, + resolve: { + 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: getWorkerBundleDir(root, directoryName), + 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: [ + async ({ request = "", context = "", dependencyType, getResolve }) => { + const nodeRequest = request.startsWith("node:") + ? request + : `node:${request}`; + if (request.startsWith("cloudflare:")) { + 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}`; + } + // 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)}`; + } + } + }, + ], + }, + 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" } }, + 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), + ], + }, + }, + }; +} + +/** + * 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; + // 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]: { + 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 + * 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); + }); +} + +/** 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 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 module. + */ +export function getManifestFromCompilation(compilation: Rspack.Compilation): { + mainModule: string; + modules: ManifestModules; +} { + const modules: ManifestModules = {}; + for (const asset of compilation.getAssets()) { + const type = getModuleType(asset); + if (type) { + const contents = asset.source.buffer(); + modules[asset.name] = { + type, + contents: + type === "wasm" || type === "data" + ? new Uint8Array(contents) + : contents.toString(), + }; + } + } + + 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]; +} + +/** + * 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..e9e80dd314c --- /dev/null +++ b/packages/rsbuild-plugin-cloudflare/tsdown.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from "tsdown"; +import type { UserConfig } from "tsdown"; + +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", + }, + // 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/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/AGENTS.md b/packages/vite-plugin-cloudflare/AGENTS.md index 8809e740e7a..aaa6fb8a7bb 100644 --- a/packages/vite-plugin-cloudflare/AGENTS.md +++ b/packages/vite-plugin-cloudflare/AGENTS.md @@ -113,3 +113,14 @@ 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. +- `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* - `; -} 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 237ac662ec3..3cdf73dcba1 100644 --- a/packages/vite-plugin-cloudflare/src/miniflare-options.ts +++ b/packages/vite-plugin-cloudflare/src/miniflare-options.ts @@ -3,139 +3,64 @@ 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 { - getBrowserRenderingHeadfulFromEnv, - getLocalExplorerEnabledFromEnv, - getLocalObservabilityEnabledFromEnv, - getZoneFromRoute, -} from "@cloudflare/workers-utils"; + DevMiniflareLogger, + getPreviewMiniflareOptions as getSharedPreviewMiniflareOptions, + getAssetsConfig, + getCommonMiniflareOptions, + getDevAssetWorkerOptions, + getDevUserWorkerOptions, + getInternalWorkerManifest, + INTERNAL_WORKERS_COMPATIBILITY_DATE, + miniflareModulesRoot, + resolveLocalBindingsByWorkerName, + VITE_PROXY_WORKER_NAME, + warnForMissingSecrets, +} from "@cloudflare/workers-dev-shared"; 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 { resolveLocalBindings } from "./local-env"; 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 { 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 { 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, - 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__"; -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`). -// 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; -type MiniflareExports = 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 @@ -169,78 +94,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 @@ -254,7 +107,10 @@ export async function getDevMiniflareOptions( const assetsConfig = getAssetsConfig( resolvedPluginConfig, entryWorkerConfig, - resolvedViteConfig + { + publicDir: resolvedViteConfig.publicDir, + logger: resolvedViteConfig.logger, + } ); const resolvedLocalBindingsByWorkerName = resolveLocalBindingsByWorkerName( ctx.allWorkerConfigs, @@ -266,191 +122,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], - } - : {}), - }, - }, - 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); - } - - 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 {} - } + ...(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; } + } 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" }, - }); - } - - 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 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); + } - 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, @@ -505,42 +271,27 @@ 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 workerOptions = await getDevUserWorkerOptions( + worker.config, + { + 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, + } + ); const wrappers = [ `import { createWorkerEntrypointWrapper, createDurableObjectWrapper, createWorkflowEntrypointWrapper } from "${RUNNER_PATH}";`, @@ -559,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) => { @@ -607,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", @@ -622,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 === @@ -646,7 +361,7 @@ export async function getDevMiniflareOptions( ) : []; - const logger = new ViteMiniflareLogger(resolvedViteConfig); + const logger = getMiniflareLogger(resolvedViteConfig); const serverConfig = viteDevServer.config.server; const publicUrl = buildPublicUrl({ @@ -657,22 +372,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 @@ -685,12 +393,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); @@ -755,361 +457,32 @@ 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 -): 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, - }; -} - -/** - * 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. - */ -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 5acb510efa6..aa529666139 100644 --- a/packages/vite-plugin-cloudflare/src/nodejs-compat.ts +++ b/packages/vite-plugin-cloudflare/src/nodejs-compat.ts @@ -1,239 +1,9 @@ import assert from "node:assert"; import * as path from "node:path"; -import { - getCloudflarePreset, - 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 { nonPrefixedNodeModules } from "@cloudflare/unenv-preset"; +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). - */ -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 bcc62b80e1b..5e1391110a6 100644 --- a/packages/vite-plugin-cloudflare/src/plugin-config.ts +++ b/packages/vite-plugin-cloudflare/src/plugin-config.ts @@ -1,578 +1,48 @@ -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 { - generateTypes, - InputWorkerSchema, - loadAndParseConfig, -} from "@cloudflare/config"; -import { - generateRuntimeTypes, - RUNTIME_TYPES_MARKER, -} from "@cloudflare/runtime-types"; -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"; + readBuildOutputPreview, + resolvePluginConfig as resolveSharedPluginConfig, +} from "@cloudflare/workers-dev-shared"; import type { - ParsedInputContainerConfig, - InputWorkerConfig, - ParsedInputConfig, - ParsedInputSettingsConfig, - ParsedInputWorkerConfig, - ParsedOutputContainerConfig, -} from "@cloudflare/config"; -import type { StaticRouting } from "@cloudflare/workers-shared/utils/types"; -import type { LoadedEnv } from "@cloudflare/workers-utils/local-env"; - -type ParsedInputConfigWithWorker = Omit & { - worker: ParsedInputWorkerConfig; -}; - -export type PersistState = boolean | { path: string }; -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 }, - }; -} + PluginConfig, + ResolvedPluginConfig, +} from "@cloudflare/workers-dev-shared"; +import type * as vite from "vite"; + +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, - 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 - ); - } - } } /** @@ -588,232 +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, - }; -} - -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/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/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/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/vite-plugin-cloudflare/turbo.json b/packages/vite-plugin-cloudflare/turbo.json index 8ab9c1fd582..f2bb75c98ea 100644 --- a/packages/vite-plugin-cloudflare/turbo.json +++ b/packages/vite-plugin-cloudflare/turbo.json @@ -13,6 +13,15 @@ "env": ["$TURBO_EXTENDS$", "CLOUDFLARE_VITE_E2E_KEEP_TEMP_DIRS"], "dependsOn": ["build"], "inputs": ["e2e/**"] + }, + "test:e2e:rsbuild": { + "env": [ + "$TURBO_EXTENDS$", + "CLOUDFLARE_TEST_BUNDLER", + "CLOUDFLARE_VITE_E2E_KEEP_TEMP_DIRS" + ], + "dependsOn": ["build", "@cloudflare/rsbuild-plugin#build"], + "inputs": ["e2e/**"] } } } diff --git a/packages/workers-dev-shared/package.json b/packages/workers-dev-shared/package.json new file mode 100644 index 00000000000..331c8a6d1a2 --- /dev/null +++ b/packages/workers-dev-shared/package.json @@ -0,0 +1,61 @@ +{ + "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 && tsc -p tsconfig.worker.json", + "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/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", + "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__/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/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 94% rename from packages/vite-plugin-cloudflare/src/build-output-preview.ts rename to packages/workers-dev-shared/src/build-output-preview.ts index d99f02a2637..41e357ab4f9 100644 --- a/packages/vite-plugin-cloudflare/src/build-output-preview.ts +++ b/packages/workers-dev-shared/src/build-output-preview.ts @@ -3,20 +3,14 @@ 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 { - ModuleType, ParsedOutputContainerConfig, ParsedOutputRootConfig, ParsedOutputWorkerConfig, } from "@cloudflare/config"; -export interface Bundle { - rootPath: string; - mainModule: string; - modules: Record; -} - export interface BuildOutputPreviewWorker { config: ParsedOutputWorkerConfig; assetsDir: string | undefined; 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/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/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 new file mode 100644 index 00000000000..08b8df3901e --- /dev/null +++ b/packages/workers-dev-shared/src/index.ts @@ -0,0 +1,111 @@ +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, +} from "./cloudflare-config"; +export { + resolveLocalBindings, + resolveLocalBindingsByWorkerName, + 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, + getDevUserWorkerOptions, + 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/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/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-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-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-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/miniflare-options.ts b/packages/workers-dev-shared/src/miniflare-options.ts new file mode 100644 index 00000000000..308d2160265 --- /dev/null +++ b/packages/workers-dev-shared/src/miniflare-options.ts @@ -0,0 +1,309 @@ +import assert from "node:assert"; +import * as path from "node:path"; +import { + createContainerPreviewPlanForCfConfig, + 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 { kRequestType, 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 { ResolvedLocalBindings } from "./local-env"; +import type { DevLogger } from "./logger"; +import type { PersistState } from "./persist"; +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"; + +/** + * 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, + }; +} + +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 { + 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 new file mode 100644 index 00000000000..153b59386d4 --- /dev/null +++ b/packages/workers-dev-shared/src/nodejs-compat.ts @@ -0,0 +1,237 @@ +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 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. + */ +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/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 new file mode 100644 index 00000000000..0066e22b4c8 --- /dev/null +++ b/packages/workers-dev-shared/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@cloudflare/workers-tsconfig/base.json", + "compilerOptions": { + "types": ["@types/node", "@cloudflare/workers-types/experimental"] + }, + "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/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..bde5838bb54 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 @@ -2966,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 @@ -4176,7 +4252,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 +4306,79 @@ 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/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) + 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)) + ws: + specifier: catalog:default + version: 8.21.0 + packages/workers-editor-shared: dependencies: react-split-pane: @@ -4494,7 +4643,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 +4993,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 +6153,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 +6165,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' @@ -7857,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'} @@ -8313,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'} @@ -9281,6 +9487,133 @@ 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 + + '@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] + 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 + + '@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: @@ -9767,6 +10100,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'} @@ -10635,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'} @@ -10917,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: @@ -10993,10 +11337,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 +12378,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'} @@ -14197,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'} @@ -14613,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'} @@ -14723,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==} @@ -14811,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'} @@ -15037,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==} @@ -15349,9 +15707,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==} @@ -15762,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==} @@ -15796,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==} @@ -18316,6 +18678,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 +18699,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 @@ -19284,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': @@ -19586,9 +19959,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 @@ -19607,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': @@ -19922,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 @@ -20357,17 +20826,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 +21082,105 @@ 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' + + '@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 + + '@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 + + '@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 @@ -21263,6 +21831,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 +21968,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 +21984,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 +22504,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 +22578,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 +22631,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: @@ -22329,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: @@ -22588,6 +23167,8 @@ snapshots: bytes@3.1.2: {} + bytestreamjs@2.0.1: {} + c12@3.1.0: dependencies: chokidar: 4.0.3 @@ -22679,8 +23260,6 @@ snapshots: - bufferutil - utf-8-validate - chai@6.2.2: {} - chai@6.3.0: {} chalk@4.1.2: @@ -23801,8 +24380,6 @@ snapshots: expect-type@0.15.0: {} - expect-type@1.3.0: {} - expect-type@1.4.0: {} expect@29.7.0: @@ -25361,7 +25938,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 +26156,7 @@ snapshots: dependencies: citty: 0.2.0 pathe: 2.0.3 - tinyexec: 1.2.4 + tinyexec: 1.3.1 object-assign@4.1.1: {} @@ -25986,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 @@ -26394,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: @@ -26514,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): @@ -26630,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 @@ -26722,7 +27318,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 +27329,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 +27347,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 +27365,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 +27387,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 +27395,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 +27410,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 @@ -26995,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 @@ -27411,8 +28012,6 @@ snapshots: std-env@3.9.0: {} - std-env@4.0.0: {} - std-env@4.3.0: {} steno@0.4.4: @@ -27791,7 +28390,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 +28399,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 +28417,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 +28426,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 +28444,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 +28453,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: @@ -27874,6 +28473,8 @@ snapshots: - synckit - vue-tsc + tslib@1.14.1: {} + tslib@2.3.0: {} tslib@2.8.1: {} @@ -28005,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 @@ -28260,10 +28865,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 +29155,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 +29184,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 +29213,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 +29242,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 +29271,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: