diff --git a/packages/vite-plugin-cloudflare/playground/__test-utils__/index.ts b/packages/vite-plugin-cloudflare/playground/__test-utils__/index.ts
index ffc0462924a5..cc37aaf32e10 100644
--- a/packages/vite-plugin-cloudflare/playground/__test-utils__/index.ts
+++ b/packages/vite-plugin-cloudflare/playground/__test-utils__/index.ts
@@ -1,2 +1,8 @@
+import { test } from "vitest";
+
export * from "../vitest-setup";
export * from "./responses";
+
+export function failsIf(condition: boolean) {
+ return condition ? test.fails : test;
+}
diff --git a/packages/vite-plugin-cloudflare/playground/react-spa/__tests__/assets.spec.ts b/packages/vite-plugin-cloudflare/playground/react-spa/__tests__/assets.spec.ts
index 262e57150a83..22f788be5895 100644
--- a/packages/vite-plugin-cloudflare/playground/react-spa/__tests__/assets.spec.ts
+++ b/packages/vite-plugin-cloudflare/playground/react-spa/__tests__/assets.spec.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
-import { isBuild, page, viteTestUrl } from "../../__test-utils__";
+import { failsIf, isBuild, page, viteTestUrl } from "../../__test-utils__";
describe("react-spa", () => {
test("returns the correct home page", async () => {
@@ -65,7 +65,3 @@ describe("react-spa", () => {
);
});
});
-
-function failsIf(condition: boolean) {
- return condition ? test.fails : test;
-}
diff --git a/packages/vite-plugin-cloudflare/playground/react-spa/__tests__/experimental-headers-and-redirects/assets.spec.ts b/packages/vite-plugin-cloudflare/playground/react-spa/__tests__/experimental-headers-and-redirects/assets.spec.ts
index 377912476c52..6f4fa0b7b036 100644
--- a/packages/vite-plugin-cloudflare/playground/react-spa/__tests__/experimental-headers-and-redirects/assets.spec.ts
+++ b/packages/vite-plugin-cloudflare/playground/react-spa/__tests__/experimental-headers-and-redirects/assets.spec.ts
@@ -1,7 +1,7 @@
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test, vi } from "vitest";
-import { isBuild, page, viteTestUrl } from "../../../__test-utils__";
+import { failsIf, isBuild, page, viteTestUrl } from "../../../__test-utils__";
describe(
"react-spa (with experimental support)",
@@ -115,7 +115,3 @@ describe("reloading the server", () => {
}
);
});
-
-function failsIf(condition: boolean) {
- return condition ? test.fails : test;
-}
diff --git a/packages/vite-plugin-cloudflare/playground/run-worker-first/__tests__/run-worker-first.spec.ts b/packages/vite-plugin-cloudflare/playground/run-worker-first/__tests__/run-worker-first.spec.ts
new file mode 100644
index 000000000000..a83df69766cb
--- /dev/null
+++ b/packages/vite-plugin-cloudflare/playground/run-worker-first/__tests__/run-worker-first.spec.ts
@@ -0,0 +1,44 @@
+import { describe, expect, test } from "vitest";
+import { failsIf, isBuild, page, viteTestUrl } from "../../__test-utils__";
+
+describe("run_worker_first support", () => {
+ test("returns the correct home page", async () => {
+ const content = await page.textContent("h1");
+ expect(content).toBe("Vite + React");
+ });
+
+ test("returns the response from the API", async () => {
+ const button = page.getByRole("button", { name: "get-name" });
+ const contentBefore = await button.innerText();
+ expect(contentBefore).toBe("Name from API is: unknown");
+ const responsePromise = page.waitForResponse((response) =>
+ response.url().endsWith("/api/")
+ );
+ await button.click();
+ await responsePromise;
+ const contentAfter = await button.innerText();
+ expect(contentAfter).toBe("Name from API is: Cloudflare");
+ });
+
+ test("returns UNAUTH for the admin page", async () => {
+ const response = await fetch(viteTestUrl + "/admin");
+ expect(response.status).toBe(401);
+ });
+
+ // This is the only use case that is not currently supported in dev mode.
+ // In that mode the middleware that runs the Worker is after the built-in Vite middleware that handles the assets.
+ failsIf(!isBuild)("returns UNAUTH for an admin image", async () => {
+ const response = await fetch(viteTestUrl + "/admin/secret.svg");
+ expect(response.status).toBe(401);
+ });
+
+ test("returns response for authorized admin page", async () => {
+ const response = await fetch(viteTestUrl + "/admin?auth=xxx");
+ expect(response.status).toBe(200);
+ });
+
+ test("returns response for authorized admin image", async () => {
+ const response = await fetch(viteTestUrl + "/admin/secret.svg?auth=xxx");
+ expect(response.status).toBe(200);
+ });
+});
diff --git a/packages/vite-plugin-cloudflare/playground/run-worker-first/index.html b/packages/vite-plugin-cloudflare/playground/run-worker-first/index.html
new file mode 100644
index 000000000000..ef80c79b33a5
--- /dev/null
+++ b/packages/vite-plugin-cloudflare/playground/run-worker-first/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React + TS
+
+
+
+
+
+
diff --git a/packages/vite-plugin-cloudflare/playground/run-worker-first/middleware/index.ts b/packages/vite-plugin-cloudflare/playground/run-worker-first/middleware/index.ts
new file mode 100644
index 000000000000..f5b941a6a8a1
--- /dev/null
+++ b/packages/vite-plugin-cloudflare/playground/run-worker-first/middleware/index.ts
@@ -0,0 +1,25 @@
+interface Env {
+ ASSETS: Fetcher;
+}
+
+export default {
+ async fetch(request, env) {
+ const url = new URL(request.url);
+
+ // Protect assets in the `/admin` directory from "unauthorized" access!
+ if (url.pathname.startsWith("/admin")) {
+ const auth = url.searchParams.get("auth");
+ if (!auth) {
+ return new Response("Unauthorized access", { status: 401 });
+ }
+ }
+
+ if (url.pathname.startsWith("/api/")) {
+ return Response.json({
+ name: "Cloudflare",
+ });
+ }
+
+ return env.ASSETS.fetch(request);
+ },
+} satisfies ExportedHandler;
diff --git a/packages/vite-plugin-cloudflare/playground/run-worker-first/package.json b/packages/vite-plugin-cloudflare/playground/run-worker-first/package.json
new file mode 100644
index 000000000000..636a0336cc69
--- /dev/null
+++ b/packages/vite-plugin-cloudflare/playground/run-worker-first/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@playground/run-worker-first",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "vite build --app",
+ "check:types": "tsc --build",
+ "dev": "vite dev",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@cloudflare/vite-plugin": "workspace:*",
+ "@cloudflare/workers-tsconfig": "workspace:*",
+ "@cloudflare/workers-types": "^4.20250310.0",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "@vitejs/plugin-react": "^4.3.4",
+ "typescript": "catalog:default",
+ "vite": "catalog:vite-plugin",
+ "wrangler": "workspace:*"
+ }
+}
diff --git a/packages/vite-plugin-cloudflare/playground/run-worker-first/public/admin/secret.svg b/packages/vite-plugin-cloudflare/playground/run-worker-first/public/admin/secret.svg
new file mode 100644
index 000000000000..09787d20ebe7
--- /dev/null
+++ b/packages/vite-plugin-cloudflare/playground/run-worker-first/public/admin/secret.svg
@@ -0,0 +1,26 @@
+
+
+
+
\ No newline at end of file
diff --git a/packages/vite-plugin-cloudflare/playground/run-worker-first/public/vite.svg b/packages/vite-plugin-cloudflare/playground/run-worker-first/public/vite.svg
new file mode 100644
index 000000000000..e7b8dfb1b2a6
--- /dev/null
+++ b/packages/vite-plugin-cloudflare/playground/run-worker-first/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/vite-plugin-cloudflare/playground/run-worker-first/src/App.css b/packages/vite-plugin-cloudflare/playground/run-worker-first/src/App.css
new file mode 100644
index 000000000000..df674c0d8958
--- /dev/null
+++ b/packages/vite-plugin-cloudflare/playground/run-worker-first/src/App.css
@@ -0,0 +1,42 @@
+#root {
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 2rem;
+ text-align: center;
+}
+
+.logo {
+ height: 6em;
+ padding: 1.5em;
+ will-change: filter;
+ transition: filter 300ms;
+}
+.logo:hover {
+ filter: drop-shadow(0 0 2em #646cffaa);
+}
+.logo.react:hover {
+ filter: drop-shadow(0 0 2em #61dafbaa);
+}
+
+@keyframes logo-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ a:nth-of-type(2) .logo {
+ animation: logo-spin infinite 20s linear;
+ }
+}
+
+.card {
+ padding: 2em;
+}
+
+.read-the-docs {
+ color: #888;
+}
diff --git a/packages/vite-plugin-cloudflare/playground/run-worker-first/src/App.tsx b/packages/vite-plugin-cloudflare/playground/run-worker-first/src/App.tsx
new file mode 100644
index 000000000000..1fa5abd00015
--- /dev/null
+++ b/packages/vite-plugin-cloudflare/playground/run-worker-first/src/App.tsx
@@ -0,0 +1,54 @@
+import viteLogo from "/vite.svg";
+import { useState } from "react";
+import reactLogo from "./assets/react.svg";
+import "./App.css";
+
+function App() {
+ const [count, setCount] = useState(0);
+ const [name, setName] = useState("unknown");
+
+ return (
+ <>
+
+ Vite + React
+
+
+
+ Edit src/App.tsx and save to test HMR
+
+
+
+
+
+ Edit api/index.ts to change the name
+
+
+
+ Click on the Vite and React logos to learn more
+
+ >
+ );
+}
+
+export default App;
diff --git a/packages/vite-plugin-cloudflare/playground/run-worker-first/src/assets/react.svg b/packages/vite-plugin-cloudflare/playground/run-worker-first/src/assets/react.svg
new file mode 100644
index 000000000000..6c87de9bb335
--- /dev/null
+++ b/packages/vite-plugin-cloudflare/playground/run-worker-first/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/vite-plugin-cloudflare/playground/run-worker-first/src/index.css b/packages/vite-plugin-cloudflare/playground/run-worker-first/src/index.css
new file mode 100644
index 000000000000..9cfcb00f249a
--- /dev/null
+++ b/packages/vite-plugin-cloudflare/playground/run-worker-first/src/index.css
@@ -0,0 +1,68 @@
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+a {
+ font-weight: 500;
+ color: #646cff;
+ text-decoration: inherit;
+}
+a:hover {
+ color: #535bf2;
+}
+
+body {
+ margin: 0;
+ display: flex;
+ place-items: center;
+ min-width: 320px;
+ min-height: 100vh;
+}
+
+h1 {
+ font-size: 3.2em;
+ line-height: 1.1;
+}
+
+button {
+ border-radius: 8px;
+ border: 1px solid transparent;
+ padding: 0.6em 1.2em;
+ font-size: 1em;
+ font-weight: 500;
+ font-family: inherit;
+ background-color: #1a1a1a;
+ cursor: pointer;
+ transition: border-color 0.25s;
+}
+button:hover {
+ border-color: #646cff;
+}
+button:focus,
+button:focus-visible {
+ outline: 4px auto -webkit-focus-ring-color;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ color: #213547;
+ background-color: #ffffff;
+ }
+ a:hover {
+ color: #747bff;
+ }
+ button {
+ background-color: #f9f9f9;
+ }
+}
diff --git a/packages/vite-plugin-cloudflare/playground/run-worker-first/src/main.tsx b/packages/vite-plugin-cloudflare/playground/run-worker-first/src/main.tsx
new file mode 100644
index 000000000000..b310b355463c
--- /dev/null
+++ b/packages/vite-plugin-cloudflare/playground/run-worker-first/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import "./index.css";
+import App from "./App.tsx";
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+);
diff --git a/packages/vite-plugin-cloudflare/playground/run-worker-first/src/vite-env.d.ts b/packages/vite-plugin-cloudflare/playground/run-worker-first/src/vite-env.d.ts
new file mode 100644
index 000000000000..11f02fe2a006
--- /dev/null
+++ b/packages/vite-plugin-cloudflare/playground/run-worker-first/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/packages/vite-plugin-cloudflare/playground/run-worker-first/tsconfig.client.json b/packages/vite-plugin-cloudflare/playground/run-worker-first/tsconfig.client.json
new file mode 100644
index 000000000000..719bc3b2504b
--- /dev/null
+++ b/packages/vite-plugin-cloudflare/playground/run-worker-first/tsconfig.client.json
@@ -0,0 +1,4 @@
+{
+ "extends": ["@cloudflare/workers-tsconfig/react.json"],
+ "include": ["src"]
+}
diff --git a/packages/vite-plugin-cloudflare/playground/run-worker-first/tsconfig.json b/packages/vite-plugin-cloudflare/playground/run-worker-first/tsconfig.json
new file mode 100644
index 000000000000..f4ac20b7cbcf
--- /dev/null
+++ b/packages/vite-plugin-cloudflare/playground/run-worker-first/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.node.json" },
+ { "path": "./tsconfig.client.json" },
+ { "path": "./tsconfig.worker.json" }
+ ]
+}
diff --git a/packages/vite-plugin-cloudflare/playground/run-worker-first/tsconfig.node.json b/packages/vite-plugin-cloudflare/playground/run-worker-first/tsconfig.node.json
new file mode 100644
index 000000000000..773be9834af5
--- /dev/null
+++ b/packages/vite-plugin-cloudflare/playground/run-worker-first/tsconfig.node.json
@@ -0,0 +1,4 @@
+{
+ "extends": ["@cloudflare/workers-tsconfig/base.json"],
+ "include": ["vite.config.ts", "__tests__"]
+}
diff --git a/packages/vite-plugin-cloudflare/playground/run-worker-first/tsconfig.worker.json b/packages/vite-plugin-cloudflare/playground/run-worker-first/tsconfig.worker.json
new file mode 100644
index 000000000000..92609dff01c5
--- /dev/null
+++ b/packages/vite-plugin-cloudflare/playground/run-worker-first/tsconfig.worker.json
@@ -0,0 +1,4 @@
+{
+ "extends": ["@cloudflare/workers-tsconfig/worker.json"],
+ "include": ["middleware"]
+}
diff --git a/packages/vite-plugin-cloudflare/playground/run-worker-first/turbo.json b/packages/vite-plugin-cloudflare/playground/run-worker-first/turbo.json
new file mode 100644
index 000000000000..6556dcf3e5e5
--- /dev/null
+++ b/packages/vite-plugin-cloudflare/playground/run-worker-first/turbo.json
@@ -0,0 +1,9 @@
+{
+ "$schema": "http://turbo.build/schema.json",
+ "extends": ["//"],
+ "tasks": {
+ "build": {
+ "outputs": ["dist/**"]
+ }
+ }
+}
diff --git a/packages/vite-plugin-cloudflare/playground/run-worker-first/vite.config.ts b/packages/vite-plugin-cloudflare/playground/run-worker-first/vite.config.ts
new file mode 100644
index 000000000000..ecbdd51b7f51
--- /dev/null
+++ b/packages/vite-plugin-cloudflare/playground/run-worker-first/vite.config.ts
@@ -0,0 +1,7 @@
+import { cloudflare } from "@cloudflare/vite-plugin";
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ plugins: [react(), cloudflare({ inspectorPort: false, persistState: false })],
+});
diff --git a/packages/vite-plugin-cloudflare/playground/run-worker-first/wrangler.jsonc b/packages/vite-plugin-cloudflare/playground/run-worker-first/wrangler.jsonc
new file mode 100644
index 000000000000..d7eeee5d0ceb
--- /dev/null
+++ b/packages/vite-plugin-cloudflare/playground/run-worker-first/wrangler.jsonc
@@ -0,0 +1,10 @@
+{
+ "name": "api",
+ "main": "./middleware/index.ts",
+ "compatibility_date": "2024-12-30",
+ "assets": {
+ "not_found_handling": "single-page-application",
+ "binding": "ASSETS",
+ "run_worker_first": true,
+ },
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fde1972e5378..a6e438da92a3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2567,6 +2567,43 @@ importers:
specifier: workspace:*
version: link:../../../wrangler
+ packages/vite-plugin-cloudflare/playground/run-worker-first:
+ dependencies:
+ react:
+ specifier: ^19.0.0
+ version: 19.0.0
+ react-dom:
+ specifier: ^19.0.0
+ version: 19.0.0(react@19.0.0)
+ devDependencies:
+ '@cloudflare/vite-plugin':
+ specifier: workspace:*
+ version: link:../..
+ '@cloudflare/workers-tsconfig':
+ specifier: workspace:*
+ version: link:../../../workers-tsconfig
+ '@cloudflare/workers-types':
+ specifier: ^4.20250310.0
+ version: 4.20250424.0
+ '@types/react':
+ specifier: ^19.0.0
+ version: 19.0.7
+ '@types/react-dom':
+ specifier: ^19.0.0
+ version: 19.0.3(@types/react@19.0.7)
+ '@vitejs/plugin-react':
+ specifier: ^4.3.4
+ version: 4.3.4(vite@6.1.0(@types/node@18.19.76)(jiti@2.4.2)(lightningcss@1.29.2))
+ typescript:
+ specifier: catalog:default
+ version: 5.7.3
+ vite:
+ specifier: catalog:vite-plugin
+ version: 6.1.0(@types/node@18.19.76)(jiti@2.4.2)(lightningcss@1.29.2)
+ wrangler:
+ specifier: workspace:*
+ version: link:../../../wrangler
+
packages/vite-plugin-cloudflare/playground/same-worker-service-bindings:
devDependencies:
'@cloudflare/vite-plugin':