withastro / astro · Issue No. 17591
Astro v7.1.6
Vite v8.2.0
Node v24.19.0
System Linux (x64)
Package Manager pnpm
Output server
Adapter @astrojs/cloudflare (v14.1.7)
Integrations none
wrangler 4.118.0, compatibility_date 2026-08-04.
The Cloudflare adapter documents a custom worker entrypoint that combines cf() with the advanced
routing handlers, for cases such as exporting a Durable Object:
https://docs.astro.build/en/guides/integrations-guide/cloudflare/#using-advanced-routing
Following that page, every request that reaches the worker returns 500:
Error: FetchState(request) called on a request without an attached app. Ensure it runs inside Astro's request pipeline.
src/worker.ts is the documented example, unchanged:
import { astro, FetchState } from 'astro/fetch';
import { cf } from '@astrojs/cloudflare/fetch';
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const state = new FetchState(request);
const asset = await cf(state, env, ctx);
if (asset) return asset;
return astro(state);
},
};
Files under public/ still return 200, because the assets layer answers them before the worker
runs. Everything Astro would render fails.
Pointing main back at @astrojs/cloudflare/entrypoints/server in the same project makes the page
return 200, so nothing else in the setup is at fault.
The documented entrypoint serves pages.
FetchState resolves its pipeline from the request:
// astro/dist/core/fetch/index.js
function getApp(request) {
const app = Reflect.get(request, appSymbol);
if (!app) throw new Error("FetchState(request) called on a request without an attached app. …");
return app;
}
class FetchState extends BaseFetchState {
constructor(request) { super(getApp(request).pipeline, request); }
}
appSymbol is Symbol.for("astro.app"). The only place in astro/dist that writes it isBaseApp.render(), at core/app/base.js:242 and :246. The one other occurrence,core/fetch/fetch-state.js:821, carries an already-attached app across a rewrite.
A custom worker entrypoint replaces the adapter's default handler, and that handler is what callsapp.render(). So the app is never attached, and cf() throws on its first line before doing any
work.
The same applies to the Hono example further down that page. cf() from @astrojs/cloudflare/hono
opens with getFetchState(), which builds the same FetchState from a bare request. I hit it that
way first, in a separate project; this repro uses the Fetch API version because it needs one fewer
dependency to show the same thing.
Nothing is exported for attaching the app either. astro/fetch narrows the base constructor(pipeline, request, options) down to FetchState(request), so a user entrypoint has no supported
way to supply one.
Setting the symbol by hand before cf() runs is enough:
import { createApp } from 'astro/app/entrypoint';
const app = createApp();
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
Reflect.set(request, Symbol.for('astro.app'), app);
// … documented body unchanged
},
};
With that one line added, the repro serves its page. I also ran it against a larger SSR project,
where routing, trailing-slash redirects, 404 pages, and static assets through the ASSETS binding all
behaved as they had on the default entrypoint.
It is not something to recommend, since it depends on an internal symbol and creates a second App
next to the one cf() keeps module-private. Two directions that would avoid both:
cf() attach the app itself. It already holds one through ensureInitialized() andFetchState that was constructed too early.createFetchState(app, request) from astro/fetch and use it in theRepro: https://github.com/iseraph-dev/repro-astro-worker-appsymbol
pnpm install
pnpm types
pnpm build
pnpm preview
curl -i http://localhost:4321/
Returns 500. Change main in wrangler.json to @astrojs/cloudflare/entrypoints/server, rebuild,
and the same request returns 200.
Relay reads this issue against the repository's contribution signals: the files it is likely to touch, how the maintainers triage work this size, and what the first contribution would exercise.
The full analysis for this issue is still being assembled. Until then, the description above and the thread on GitHub are the most reliable context.