Skip to content

Commit 7e5592a

Browse files
committed
chore: merge main into next
2 parents c63e7e4 + 7e8d371 commit 7e5592a

8 files changed

Lines changed: 233 additions & 19 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@astrojs/cloudflare': patch
3+
---
4+
5+
Fixes a build crash when using `experimental.advancedRouting` with a custom `fetchFile` that statically imports `cf` from `@astrojs/cloudflare/fetch`. The circular dependency between `@astrojs/cloudflare/fetch` and `astro/app/entrypoint` caused `createApp` or `createGetEnv` to be `undefined` at module evaluation time. Initialization is now deferred to the first `cf()` call, breaking the cycle.

.changeset/kind-snails-wash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Fixes `Astro.request.url` not reflecting validated `X-Forwarded-Proto`/`X-Forwarded-Host` headers when `security.allowedDomains` is configured. Previously, only `Astro.url` was updated with the forwarded origin while `Astro.request.url` retained the socket-derived URL, causing the two to diverge behind TLS-terminating proxies.

packages/astro/src/core/errors/errors-data.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -223,8 +223,8 @@ export const NoClientOnlyHint = {
223223
* ---
224224
* export async function getStaticPaths() {
225225
* return [
226-
* { params: { slug: "blog" } },
227-
* { params: { slug: "about" } }
226+
* { params: { id: "blog" } },
227+
* { params: { id: "about" } }
228228
* ];
229229
*}
230230
*---
@@ -247,8 +247,8 @@ export const InvalidGetStaticPathParam = {
247247
* ```ts title="pages/blog/[id].astro"
248248
* export async function getStaticPaths() {
249249
* return [ // <-- Array
250-
* { params: { slug: "blog" } }, // <-- Object
251-
* { params: { slug: "about" } }
250+
* { params: { id: "blog" } }, // <-- Object
251+
* { params: { id: "about" } }
252252
* ];
253253
*}
254254
* ```
@@ -271,8 +271,8 @@ export const InvalidGetStaticPathsEntry = {
271271
* ```ts title="pages/blog/[id].astro"
272272
* export async function getStaticPaths() {
273273
* return [ // <-- Array
274-
* { params: { slug: "blog" } },
275-
* { params: { slug: "about" } }
274+
* { params: { id: "blog" } },
275+
* { params: { id: "about" } }
276276
* ];
277277
*}
278278
* ```

packages/astro/src/core/fetch/fetch-state.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { RouteData, SSRResult } from '../../types/public/internal.js';
1414
import { AstroCookies } from '../cookies/index.js';
1515
import { type Pipeline, Slots } from '../render/index.js';
1616
import {
17+
appSymbol,
1718
ASTRO_GENERATOR,
1819
fetchStateSymbol,
1920
originPathnameSymbol,
@@ -308,10 +309,12 @@ export class FetchState implements AstroFetchState {
308309
this.#applyForwardedHeaders();
309310
}
310311

311-
// Set origin pathname for rewrite tracking.
312-
if (!Reflect.get(request, originPathnameSymbol)) {
312+
// Set origin pathname for rewrite tracking. Use this.request
313+
// (not the local parameter) because #applyForwardedHeaders()
314+
// may have reconstructed it with a forwarded URL.
315+
if (!Reflect.get(this.request, originPathnameSymbol)) {
313316
setOriginPathname(
314-
request,
317+
this.request,
315318
this.pathname,
316319
pipeline.manifest.trailingSlash,
317320
pipeline.manifest.buildFormat,
@@ -956,6 +959,23 @@ export class FetchState implements AstroFetchState {
956959
this.clientAddress = forwardedFor;
957960
}
958961
}
962+
963+
// Reconstruct the Request with the resolved URL so that
964+
// request.url stays in sync with this.url. Request.url is a
965+
// readonly string, so we must create a new Request object. The
966+
// constructor carries over method, headers, body (incl. stream +
967+
// duplex) and signal from the old request.
968+
const oldRequest = this.request;
969+
this.request = new Request(this.url, oldRequest);
970+
// Re-attach `appSymbol`: the rest of the pipeline resolves the app
971+
// via `getApp(state.request)` (see core/fetch/index.ts), so the new
972+
// Request must carry it. We copy only this known Astro symbol.
973+
// Other request-bound state is either already captured on
974+
// `this` (clientAddress) or set after this point (originPathname).
975+
const app = Reflect.get(oldRequest, appSymbol);
976+
if (app !== undefined) {
977+
Reflect.set(this.request, appSymbol, app);
978+
}
959979
}
960980

961981
/**

packages/astro/test/units/fetch/index.test.ts

Lines changed: 111 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -831,8 +831,6 @@ describe('FetchState X-Forwarded-* header resolution', () => {
831831
});
832832

833833
it('handles headers set by user fetch handler before FetchState creation', () => {
834-
// This is the core use case from the issue: user sets forwarded
835-
// headers in their src/app.ts fetch() before creating FetchState.
836834
const app = createTestApp([createPage(simplePage, { route: '/' })], {
837835
allowedDomains: [{ hostname: 'example.com' }],
838836
});
@@ -849,8 +847,6 @@ describe('FetchState X-Forwarded-* header resolution', () => {
849847
});
850848

851849
it('renders through the full pipeline with forwarded headers applied', async () => {
852-
// End-to-end: forwarded headers should be visible in Astro.url
853-
// when rendering through the astro() combined handler.
854850
const app = createTestApp([createPage(simplePage, { route: '/' })], {
855851
allowedDomains: [{ hostname: 'example.com' }],
856852
});
@@ -865,13 +861,123 @@ describe('FetchState X-Forwarded-* header resolution', () => {
865861
);
866862
const state = new FetchState(request);
867863

868-
// Verify URL was updated before the handler runs
869864
assert.equal(state.url.protocol, 'https:');
870865
assert.equal(state.url.hostname, 'example.com');
871866

872867
const response = await astro(state);
873868
assert.equal(response.status, 200);
874869
});
870+
871+
it('updates request.url to match the forwarded URL', () => {
872+
const app = createTestApp([createPage(simplePage, { route: '/' })], {
873+
allowedDomains: [{ hostname: 'example.com' }],
874+
});
875+
const request = stampApp(
876+
new Request('http://localhost:4321/page', {
877+
headers: {
878+
'x-forwarded-proto': 'https',
879+
'x-forwarded-host': 'example.com',
880+
},
881+
}),
882+
app,
883+
);
884+
const state = new FetchState(request);
885+
886+
assert.equal(state.url.protocol, 'https:');
887+
assert.equal(state.url.hostname, 'example.com');
888+
889+
const requestUrl = new URL(state.request.url);
890+
assert.equal(requestUrl.protocol, 'https:');
891+
assert.equal(requestUrl.hostname, 'example.com');
892+
assert.equal(requestUrl.pathname, '/page');
893+
});
894+
895+
it('does not reconstruct request when no forwarded headers are validated', () => {
896+
const app = createTestApp([createPage(simplePage, { route: '/' })], {
897+
allowedDomains: [{ hostname: 'trusted.com' }],
898+
});
899+
const original = new Request('http://localhost:4321/', {
900+
headers: {
901+
'x-forwarded-host': 'evil.com',
902+
},
903+
});
904+
const request = stampApp(original, app);
905+
const state = new FetchState(request);
906+
907+
// Rejected forwarded host — request should stay unchanged
908+
assert.equal(state.url.hostname, 'localhost');
909+
assert.equal(new URL(state.request.url).hostname, 'localhost');
910+
});
911+
912+
it('carries appSymbol onto the reconstructed request so the app still resolves', async () => {
913+
const app = createTestApp([createPage(simplePage, { route: '/' })], {
914+
allowedDomains: [{ hostname: 'example.com' }],
915+
});
916+
const original = new Request('http://localhost:4321/', {
917+
headers: {
918+
'x-forwarded-proto': 'https',
919+
'x-forwarded-host': 'example.com',
920+
},
921+
});
922+
const request = stampApp(original, app);
923+
const state = new FetchState(request);
924+
925+
assert.notEqual(state.request, original);
926+
assert.equal(Reflect.get(state.request, appSymbol), app);
927+
const response = await astro(state);
928+
assert.equal(response.status, 200);
929+
});
930+
931+
it('preserves method, headers and body when reconstructing for forwarded headers', async () => {
932+
const app = createTestApp([createPage(simplePage, { route: '/api' })], {
933+
allowedDomains: [{ hostname: 'example.com' }],
934+
});
935+
const request = stampApp(
936+
new Request('http://localhost:4321/api', {
937+
method: 'POST',
938+
headers: {
939+
'content-type': 'application/x-www-form-urlencoded',
940+
'x-forwarded-proto': 'https',
941+
'x-forwarded-host': 'example.com',
942+
},
943+
body: 'token=abc123',
944+
}),
945+
app,
946+
);
947+
const state = new FetchState(request);
948+
949+
assert.equal(new URL(state.request.url).protocol, 'https:');
950+
assert.equal(new URL(state.request.url).hostname, 'example.com');
951+
assert.equal(state.request.method, 'POST');
952+
assert.equal(state.request.headers.get('content-type'), 'application/x-www-form-urlencoded');
953+
assert.equal(await state.request.text(), 'token=abc123');
954+
});
955+
956+
it('preserves a streaming request body (duplex) when reconstructing', async () => {
957+
const app = createTestApp([createPage(simplePage, { route: '/api' })], {
958+
allowedDomains: [{ hostname: 'example.com' }],
959+
});
960+
const body = new ReadableStream({
961+
start(controller) {
962+
controller.enqueue(new TextEncoder().encode('chunked-payload'));
963+
controller.close();
964+
},
965+
});
966+
const init: any = {
967+
method: 'POST',
968+
headers: {
969+
'x-forwarded-proto': 'https',
970+
'x-forwarded-host': 'example.com',
971+
},
972+
body,
973+
duplex: 'half',
974+
};
975+
const request = stampApp(new Request('http://localhost:4321/api', init), app);
976+
const state = new FetchState(request);
977+
978+
assert.equal(new URL(state.request.url).hostname, 'example.com');
979+
assert.equal(await state.request.text(), 'chunked-payload');
980+
});
875981
});
876982

877983
// #endregion

packages/integrations/cloudflare/src/fetch.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,18 @@ import {
3131
getClientAddress,
3232
} from './utils/cf.js';
3333

34-
setGetEnv(createGetEnv(globalEnv));
34+
// Lazy initialization — `createApp` and `setGetEnv` are deferred to the
35+
// first `cf()` call so that this module can be statically imported from a
36+
// custom `fetchFile` without triggering a circular-dependency crash.
37+
// (The cycle: fetch.ts → astro/app/entrypoint → virtual:astro:fetchable → user worker → fetch.ts)
38+
let app: ReturnType<typeof createApp> | undefined;
3539

36-
const app = createApp();
40+
function ensureInitialized() {
41+
if (!app) {
42+
setGetEnv(createGetEnv(globalEnv));
43+
app = createApp();
44+
}
45+
}
3746

3847
/**
3948
* Applies Cloudflare-specific setup to a `FetchState`:
@@ -50,9 +59,10 @@ export async function cf(
5059
env: Env,
5160
ctx: ExecutionContext,
5261
): Promise<Response | undefined> {
53-
injectSessionBinding(app.manifest, env);
62+
ensureInitialized();
63+
injectSessionBinding(app!.manifest, env);
5464

55-
const staticAsset = matchStaticAsset(app.manifest, state.request.url, env);
65+
const staticAsset = matchStaticAsset(app!.manifest, state.request.url, env);
5666
if (staticAsset) return staticAsset;
5767

5868
if (!state.routeData) {
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import * as assert from 'node:assert/strict';
2+
import { readFileSync } from 'node:fs';
3+
import { describe, it } from 'node:test';
4+
5+
/**
6+
* `@astrojs/cloudflare/fetch` must not call `createApp()` or `setGetEnv()`
7+
* at module top-level. Eager calls cause a circular-dependency crash when
8+
* a user's custom `fetchFile` (`src/worker.ts`) statically imports `cf`
9+
* from `@astrojs/cloudflare/fetch`.
10+
*
11+
* The cycle: fetch.ts → astro/app/entrypoint → virtual:astro:fetchable
12+
* → user worker → fetch.ts
13+
*
14+
* See: https://github.com/withastro/astro/issues/16956
15+
*/
16+
describe('@astrojs/cloudflare/fetch lazy initialization', () => {
17+
const source = readFileSync(new URL('../dist/fetch.js', import.meta.url), 'utf-8');
18+
19+
it('does not call createApp() at module top-level', () => {
20+
// After the fix, createApp() should only appear inside the
21+
// ensureInitialized() function body, not as a bare top-level statement.
22+
// We check that there is no top-level `createApp()` by verifying the
23+
// pattern does NOT appear outside a function body.
24+
// A simple heuristic: the call `= createApp()` should not exist as a
25+
// top-level const/let/var assignment (the old pattern was `const app = createApp()`).
26+
const topLevelCreateApp = /^(?:const|let|var)\s+\w+\s*=\s*createApp\(\)/m;
27+
assert.equal(
28+
topLevelCreateApp.test(source),
29+
false,
30+
'createApp() must not be called at module top-level (causes circular import crash)',
31+
);
32+
});
33+
34+
it('does not call setGetEnv() at module top-level', () => {
35+
// The old pattern was a bare `setGetEnv(createGetEnv(globalEnv))` statement
36+
// at the top level. After the fix this should only appear inside ensureInitialized().
37+
// A line starting with `setGetEnv(` (not inside a function) is the problem pattern.
38+
const lines = source.split('\n');
39+
const topLevelSetGetEnv = lines.some((line) => {
40+
const trimmed = line.trim();
41+
// Skip lines inside function bodies (indented or after opening brace)
42+
// A simple heuristic: if the line starts with setGetEnv and is not indented,
43+
// it's top-level.
44+
return trimmed.startsWith('setGetEnv(') && !line.startsWith(' ') && !line.startsWith('\t');
45+
});
46+
assert.equal(
47+
topLevelSetGetEnv,
48+
false,
49+
'setGetEnv() must not be called at module top-level (causes circular import crash)',
50+
);
51+
});
52+
53+
it('exports ensureInitialized or calls it inside cf()', () => {
54+
// Verify that lazy init is wired into the cf() function
55+
assert.ok(
56+
source.includes('ensureInitialized()'),
57+
'cf() should call ensureInitialized() for lazy setup',
58+
);
59+
});
60+
61+
it('exports a cf function', () => {
62+
// Sanity check: the module still exports cf
63+
assert.ok(
64+
source.includes('export {') && source.includes('cf'),
65+
'Module should export the cf function',
66+
);
67+
});
68+
});

packages/integrations/mdx/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
"vfile": "^6.0.3"
5151
},
5252
"peerDependencies": {
53-
"@astrojs/markdown-satteri": "workspace:*",
53+
"@astrojs/markdown-satteri": "0.3.0",
5454
"astro": "^7.0.0-alpha.0"
5555
},
5656
"peerDependenciesMeta": {

0 commit comments

Comments
 (0)