Skip to content

[Bug][regression] @astrojs/cloudflare v14 tunnel option causes HTTP 200 with empty response body in dev mode #14533

Description

@arcsynxae

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:

{
    "name": "my-app",
    "compatibility_date": "2026-04-15",
    "compatibility_flags": ["nodejs_compat"],
    "assets": {
        "binding": "ASSETS",
        "directory": "./dist"
    }
}

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:

  1. app.match(request) runs (production path)
  2. No manifest exists in dev mode → returns undefined
  3. fallbackToAssets() tries env.ASSETS.fetch('/') on empty ./dist → returns undefined
  4. 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

  1. Without the tunnel option, Astro 7 + @astrojs/cloudflare v14 works correctly — pages render as expected. The bug is exclusively triggered by the tunnel option.

  2. 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.

  3. No console errors from the dev server or browser — the failure is completely silent, making diagnosis very difficult.

  4. All routes affected — not just /. Every path (/, /about, etc.) returns 200 with empty body when tunnel is active.

  5. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions