Bug Report: tunnel option in @astrojs/cloudflare v14 causes blank white screen (HTTP 200, empty body)
Bug Summary
When the tunnel option ({ autoStart: true, name: '...' }) is configured inside the @astrojs/cloudflare adapter in astro.config.mjs, the dev server starts successfully but every page request returns HTTP 200 with a completely empty response body — a blank white screen with no HTML content whatsoever.
This regression does not occur in Astro 6 / @astrojs/cloudflare v13. Upgrading to Astro 7 / @astrojs/cloudflare v14 introduced this breaking behavior.
Environment
| Package |
Version |
astro |
7.0.5 |
@astrojs/cloudflare |
14.1.0 |
@cloudflare/vite-plugin |
1.42.4 |
miniflare |
4.20260630.0 |
wrangler |
4.106.0 |
vite |
8.x (via Astro 7) |
node |
v24.18.0 |
| OS |
Windows 11 (NT 10.0.26200.0) |
Steps to Reproduce
1. Minimal astro.config.mjs that triggers the bug:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
export default defineConfig({
output: 'server',
adapter: cloudflare({
imageService: 'passthrough',
// ↓ THIS OPTION causes the blank white screen
tunnel: {
autoStart: true,
name: 'my-named-tunnel' // must be a pre-configured cloudflared tunnel
}
}),
});
2. wrangler.jsonc:
3. Run the dev server:
astro dev --host 127.0.0.1
4. Open http://127.0.0.1:<port>/ in browser.
Expected: The page renders normally.
Actual: Browser shows a blank white page. DevTools shows:
<html><head></head><body></body></html> (completely empty DOM)
- HTTP Status:
200 OK
Content-Type: text/html
- Response body: 0 bytes (verified via
fetch())
Workaround
Remove the tunnel option from the adapter configuration entirely. Use cloudflared CLI directly as a separate process:
cloudflared tunnel --url http://127.0.0.1:<port> run my-named-tunnel
Root Cause Analysis
Through detailed source code investigation, I identified the exact failure chain:
Step 1 — tunnel option activates wrangler.unstable_resolveNamedTunnel
When tunnel: { autoStart, name } is provided, @astrojs/cloudflare passes it to @cloudflare/vite-plugin, which calls wrangler.unstable_resolveNamedTunnel(name) and configures Miniflare's workerd with a named tunnel. This changes the internal Miniflare routing behavior.
Step 2 — Miniflare routing dispatches requests to the user Worker
With the tunnel active, requests are routed through __router-worker__ → user Worker (the Astro SSR entry at @astrojs/cloudflare/entrypoints/server).
Step 3 — handler.js calls createApp() → returns wrong App class
In @astrojs/cloudflare/dist/utils/handler.js:
import { createApp } from "astro/app/entrypoint"; // virtual module
const app = createApp();
async function handle(request, env, context) {
if (app.isDev()) {
// Dev path: uses DevApp with route matching via Vite module runner
const result = await app.devMatch(app.getPathnameFromRequest(request));
if (result) { routeData = result.routeData; }
} else {
// Production path: uses static manifest
routeData = app.match(request); // ← THIS EXECUTES in dev mode (BUG)
}
if (!routeData) {
const asset = await fallbackToAssets(request.url, env); // ← returns undefined
if (asset) return asset;
}
// ← Falls off the function: no return → empty response body
}
Step 4 — virtual:astro:app resolves to production App instead of DevApp
In astro/dist/vite-plugin-app/index.js:
handler() {
// "serve" = Vite dev mode, "build" = production build
const entrypoint = command === "serve"
? "astro/app/entrypoint/dev" // DevApp — has isDev() = true
: "astro/app/entrypoint/prod"; // App — has isDev() = false ← resolves here (BUG)
const code = `export { createApp } from '${entrypoint}';`;
return { code };
}
And in astro/dist/core/app/app.js:
class App extends BaseApp {
isDev() { return false; } // Production class, always returns false
}
Step 5 — Why does enabling tunnel cause wrong App class resolution?
When tunnel is active, @cloudflare/vite-plugin changes how the SSR Vite environment (name: "ssr") is configured — specifically how the Vite Module Runner operates inside workerd. The vitePluginApp plugin reads config.command from the root Vite config during configResolved, but the SSR environment inside workerd processes modules in a context where command is not reliably "serve".
This causes virtual:astro:app to resolve to the production App class instead of the development DevApp/AstroServerApp class. Since App.isDev() returns false:
app.match(request) runs (production path)
- No manifest exists in dev mode → returns
undefined
fallbackToAssets() tries env.ASSETS.fetch('/') on empty ./dist → returns undefined
- Function returns nothing → HTTP 200 with 0-byte body
Key Files Involved
| File |
Role |
node_modules/@astrojs/cloudflare/dist/entrypoints/server.js |
Entry point calling handle |
node_modules/@astrojs/cloudflare/dist/utils/handler.js |
The handle() function with the problematic isDev() check |
node_modules/astro/dist/vite-plugin-app/index.js |
Resolves virtual:astro:app to dev or prod based on command |
node_modules/astro/dist/core/app/app.js |
Production App class (isDev() = false) |
node_modules/astro/dist/core/app/dev/app.js |
Development DevApp class (isDev() = true) |
node_modules/@cloudflare/vite-plugin/dist/index.mjs |
Tunnel setup via setupDevTunnel and Miniflare options |
Regression Details
|
Astro 6 + @astrojs/cloudflare v13 |
Astro 7 + @astrojs/cloudflare v14 |
| Dev runtime |
Node.js (standard Vite) |
workerd (Miniflare via @cloudflare/vite-plugin) |
tunnel handling |
Passed to @cloudflare/vite-plugin |
Same, but now breaks Vite environment isolation |
virtual:astro:app resolution |
Correctly resolves to DevApp |
Resolves to production App when tunnel is active |
| Page rendering |
Works correctly |
HTTP 200 with empty body |
| Workaround needed |
None |
Run cloudflared as separate CLI process |
Additional Observations
-
Without the tunnel option, Astro 7 + @astrojs/cloudflare v14 works correctly — pages render as expected. The bug is exclusively triggered by the tunnel option.
-
Quirks Mode warning: Browser DevTools shows "Page layout may be unexpected due to Quirks Mode" — confirming that the HTML response has no <!DOCTYPE html> declaration, consistent with an empty response body.
-
No console errors from the dev server or browser — the failure is completely silent, making diagnosis very difficult.
-
All routes affected — not just /. Every path (/, /about, etc.) returns 200 with empty body when tunnel is active.
-
Verified via fetch() in DevTools Console:
const resp = await fetch('http://127.0.0.1:4323/');
// { status: 200, bodyLength: 0, body: "" }
Suggested Fix Direction
The vitePluginApp plugin needs to ensure that virtual:astro:app always resolves to the development entry point (astro/app/entrypoint/dev) when running in dev mode, regardless of how the SSR Vite environment is configured by adapters.
A potential fix in astro/dist/vite-plugin-app/index.js:
handler() {
- const entrypoint = command === "serve"
+ const isDev = command === "serve" || process.env.NODE_ENV !== "production";
+ const entrypoint = isDev
? "astro/app/entrypoint/dev"
: "astro/app/entrypoint/prod";
Alternatively, the adapter's tunnel configuration inside @cloudflare/vite-plugin should be isolated from affecting Vite environment resolution, ensuring the command value propagates correctly to all Vite plugins in the SSR environment context.
Labels
bug, @astrojs/cloudflare, regression, Astro 7
Bug Report:
tunneloption in@astrojs/cloudflarev14 causes blank white screen (HTTP 200, empty body)Bug Summary
When the
tunneloption ({ autoStart: true, name: '...' }) is configured inside the@astrojs/cloudflareadapter inastro.config.mjs, the dev server starts successfully but every page request returns HTTP 200 with a completely empty response body — a blank white screen with no HTML content whatsoever.This regression does not occur in Astro 6 /
@astrojs/cloudflarev13. Upgrading to Astro 7 /@astrojs/cloudflarev14 introduced this breaking behavior.Environment
astro7.0.5@astrojs/cloudflare14.1.0@cloudflare/vite-plugin1.42.4miniflare4.20260630.0wrangler4.106.0vite8.x(via Astro 7)nodev24.18.0Steps to Reproduce
1. Minimal
astro.config.mjsthat triggers the bug:2.
wrangler.jsonc:{ "name": "my-app", "compatibility_date": "2026-04-15", "compatibility_flags": ["nodejs_compat"], "assets": { "binding": "ASSETS", "directory": "./dist" } }3. Run the dev server:
4. Open
http://127.0.0.1:<port>/in browser.Expected: The page renders normally.
Actual: Browser shows a blank white page. DevTools shows:
<html><head></head><body></body></html>(completely empty DOM)200 OKContent-Type: text/htmlfetch())Workaround
Remove the
tunneloption from the adapter configuration entirely. UsecloudflaredCLI directly as a separate process:Root Cause Analysis
Through detailed source code investigation, I identified the exact failure chain:
Step 1 —
tunneloption activateswrangler.unstable_resolveNamedTunnelWhen
tunnel: { autoStart, name }is provided,@astrojs/cloudflarepasses it to@cloudflare/vite-plugin, which callswrangler.unstable_resolveNamedTunnel(name)and configures Miniflare's workerd with a named tunnel. This changes the internal Miniflare routing behavior.Step 2 — Miniflare routing dispatches requests to the user Worker
With the tunnel active, requests are routed through
__router-worker__→ user Worker (the Astro SSR entry at@astrojs/cloudflare/entrypoints/server).Step 3 —
handler.jscallscreateApp()→ returns wrongAppclassIn
@astrojs/cloudflare/dist/utils/handler.js:Step 4 —
virtual:astro:appresolves to productionAppinstead ofDevAppIn
astro/dist/vite-plugin-app/index.js:And in
astro/dist/core/app/app.js:Step 5 — Why does enabling
tunnelcause wrongAppclass resolution?When
tunnelis active,@cloudflare/vite-pluginchanges how the SSR Vite environment (name: "ssr") is configured — specifically how the Vite Module Runner operates insideworkerd. ThevitePluginAppplugin readsconfig.commandfrom the root Vite config duringconfigResolved, but the SSR environment inside workerd processes modules in a context wherecommandis not reliably"serve".This causes
virtual:astro:appto resolve to the productionAppclass instead of the developmentDevApp/AstroServerAppclass. SinceApp.isDev()returnsfalse:app.match(request)runs (production path)undefinedfallbackToAssets()triesenv.ASSETS.fetch('/')on empty./dist→ returnsundefinedKey Files Involved
node_modules/@astrojs/cloudflare/dist/entrypoints/server.jshandlenode_modules/@astrojs/cloudflare/dist/utils/handler.jshandle()function with the problematicisDev()checknode_modules/astro/dist/vite-plugin-app/index.jsvirtual:astro:appto dev or prod based oncommandnode_modules/astro/dist/core/app/app.jsAppclass (isDev() = false)node_modules/astro/dist/core/app/dev/app.jsDevAppclass (isDev() = true)node_modules/@cloudflare/vite-plugin/dist/index.mjssetupDevTunneland Miniflare optionsRegression Details
@astrojs/cloudflarev13@astrojs/cloudflarev14@cloudflare/vite-plugin)tunnelhandling@cloudflare/vite-pluginvirtual:astro:appresolutionDevAppAppwhen tunnel is activecloudflaredas separate CLI processAdditional Observations
Without the
tunneloption, Astro 7 +@astrojs/cloudflarev14 works correctly — pages render as expected. The bug is exclusively triggered by thetunneloption.Quirks Mode warning: Browser DevTools shows
"Page layout may be unexpected due to Quirks Mode"— confirming that the HTML response has no<!DOCTYPE html>declaration, consistent with an empty response body.No console errors from the dev server or browser — the failure is completely silent, making diagnosis very difficult.
All routes affected — not just
/. Every path (/,/about, etc.) returns 200 with empty body when tunnel is active.Verified via
fetch()in DevTools Console:Suggested Fix Direction
The
vitePluginAppplugin needs to ensure thatvirtual:astro:appalways resolves to the development entry point (astro/app/entrypoint/dev) when running in dev mode, regardless of how the SSR Vite environment is configured by adapters.A potential fix in
astro/dist/vite-plugin-app/index.js:handler() { - const entrypoint = command === "serve" + const isDev = command === "serve" || process.env.NODE_ENV !== "production"; + const entrypoint = isDev ? "astro/app/entrypoint/dev" : "astro/app/entrypoint/prod";Alternatively, the adapter's
tunnelconfiguration inside@cloudflare/vite-pluginshould be isolated from affecting Vite environment resolution, ensuring thecommandvalue propagates correctly to all Vite plugins in the SSR environment context.Labels
bug,@astrojs/cloudflare,regression,Astro 7