Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/eight-regions-tease.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@astrojs/cloudflare': minor
---

Adds support for the [experimental static headers Astro feature](https://docs.astro.build/en/reference/adapter-reference/#experimentalstaticheaders).

When the feature is enabled via the option `experimentalStaticHeaders`, and [experimental Content Security Policy](https://docs.astro.build/en/reference/experimental-flags/csp/) is enabled, the adapter will generate `Response` headers for static pages, which allows support for CSP directives that are not supported inside a `<meta>` tag (e.g. `frame-ancestors`).

```js
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";

export default defineConfig({
adapter: cloudflare({
experimentalStaticHeaders: true
}),
experimental: {
csp: true
}
})
```
19 changes: 19 additions & 0 deletions packages/integrations/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,22 @@ import {
cloudflareModuleLoader,
} from './utils/cloudflare-module-loader.js';
import { createGetEnv } from './utils/env.js';
import { createHeadersFile } from './utils/generate-headers-file.js';
import { createRoutesFile, getParts } from './utils/generate-routes-json.js';
import { type ImageService, setImageConfig } from './utils/image-config.js';

export type { Runtime } from './utils/handler.js';

export type Options = {
/**
* If enabled, the adapter will save static headers in the framework API file,
* as documented for [workers](https://developers.cloudflare.com/workers/static-assets/headers) and [pages](https://developers.cloudflare.com/pages/configuration/headers).
*
* Here the list of the headers that are added:
* - The CSP header of the static pages is added when CSP support is enabled.
*/
experimentalStaticHeaders?: boolean;

/** Options for handling images. */
imageService?: ImageService;
/** Configuration for `_routes.json` generation. A _routes.json file controls when your Function is invoked. This file will include three different properties:
Expand Down Expand Up @@ -142,6 +152,7 @@ function setProcessEnv(config: AstroConfig, env: Record<string, unknown>) {
export default function createIntegration(args?: Options): AstroIntegration {
let _config: AstroConfig;
let finalBuildOutput: HookParameters<'astro:config:done'>['buildOutput'];
let staticHeadersMap: Map<IntegrationResolvedRoute, Headers> | undefined = undefined;

const cloudflareModulePlugin: PluginOption & CloudflareModulePluginExtra = cloudflareModuleLoader(
args?.cloudflareModules ?? true,
Expand Down Expand Up @@ -268,6 +279,7 @@ export default function createIntegration(args?: Options): AstroIntegration {
adapterFeatures: {
edgeMiddleware: false,
buildOutput: 'server',
experimentalStaticHeaders: args?.experimentalStaticHeaders ?? false,
},
supportedAstroFeatures: {
serverOutput: 'stable',
Expand Down Expand Up @@ -369,6 +381,9 @@ export default function createIntegration(args?: Options): AstroIntegration {
};
}
},
'astro:build:generated': ({ experimentalRouteToHeaders }) => {
staticHeadersMap = experimentalRouteToHeaders;
},
'astro:build:done': async ({ pages, dir, logger, assets }) => {
await cloudflareModulePlugin.afterBuildCompleted(_config);

Expand Down Expand Up @@ -429,6 +444,10 @@ export default function createIntegration(args?: Options): AstroIntegration {
);
}

if (args?.experimentalStaticHeaders && staticHeadersMap?.size) {
await createHeadersFile(_config, logger, staticHeadersMap);
}

const trueRedirects = createRedirectsFromAstroRoutes({
config: _config,
routeToDynamicTargetMap: new Map(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { readFile, writeFile } from 'node:fs/promises';
import type { AstroConfig, AstroIntegrationLogger, IntegrationResolvedRoute } from 'astro';
import { createHostedRouteDefinition } from '@astrojs/underscore-redirects';

export async function createHeadersFile(
config: AstroConfig,
logger: AstroIntegrationLogger,
routeToHeaders: Map<IntegrationResolvedRoute, Headers>,
) {
const outUrl = new URL('_headers', config.outDir);
const publicUrl = new URL('_headers', config.publicDir);

// Parse existing _headers
const headersByPattern = await loadExistingHeaders(publicUrl);

// Merge in CSP headers if enabled
if (config.experimental?.csp) {
for (const [route, heads] of routeToHeaders) {
if (!route.isPrerendered) continue;
if (route.redirect) continue;
const csp = heads.get('Content-Security-Policy');
if (csp) {
const def = createHostedRouteDefinition(route, config);
const bucket = headersByPattern.get(def.input) ?? {};
bucket['Content-Security-Policy'] = csp;
headersByPattern.set(def.input, bucket);
}
}
}

if (headersByPattern.size === 0) {
logger.info('No headers to write, skipping _headers generation.');
return;
}

const output =
[...headersByPattern]
.map(([pattern, headers]) =>
[pattern, ...Object.entries(headers).map(([k, v]) => ` ${k}: ${v}`)].join('\n'),
)
.join('\n\n') + '\n';
Comment on lines +36 to +41
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we have a shared util from astro for this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Be aware that this PR #13972 will change headersByPattern, so we will probably have to update this code.

Nonetheless, we have @astrojs/underscore-redirects that might provide something we can use here, but you should look into it

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I was thinking about printAsRedirects from @astrojs/underscore-redirects, but I'm not sure if the syntax is 100% equal


try {
await writeFile(outUrl, output, 'utf-8');
} catch (err) {
logger.error(`Error writing _headers file: ${err}`);
}
}

async function loadExistingHeaders(publicUrl: URL): Promise<Map<string, Record<string, string>>> {
try {
const text = await readFile(publicUrl, 'utf-8');
return text
.split(/\r?\n/)
.filter(Boolean)
.reduce(
(map, line) => {
if (!line.startsWith(' ')) {
map.current = line.trim();
map.store.set(map.current, map.store.get(map.current) ?? {});
} else {
const [key, ...rest] = line.trim().split(':');
map.store.get(map.current)![key.trim()] = rest.join(':').trim();
}
return map;
},
{ current: '', store: new Map<string, Record<string, string>>() },
).store;
} catch {
return new Map();
}
}
Comment on lines +50 to +72
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good for now, but in the long term I think we should also have this shared from astro, I feel like this could also apply to other providers not just Cloudflare. WDYT @ematipico?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we can. Each adapter (i.e., hosting platform) has its own configuration file, with its own syntax. For example, with Netlify we use their config.json file. With the Node.js adapter, we created a custom JSON file with a different structure

Copy link
Member

@alexanderniebuhr alexanderniebuhr Jul 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, I thought the _headers file is a common syntax, than disregard my comment.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the _headers file is a kinda-common syntax. I think it's generally supported in the same places that support _redirects, so there is a good argument for adding support in that package. We don't use it on Netlify now, as we use their new framework API. On Netlify at least, the _headers file is now mainly created by users rather than auto-generated by frameworks.

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import cloudflare from '@astrojs/cloudflare';
import { defineConfig } from 'astro/config';

export default defineConfig({
output: 'static',
adapter: cloudflare({
experimentalStaticHeaders: true
}),
site: "http://example.com",
experimental: {
csp: true
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@test/cloudflare-static-headers",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/cloudflare": "workspace:"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@

/
Surrogate-Key: root

/*
Surrogate-Key: catch-all

/unknown-route
Surrogate-Key: unknown-route

/has-header
Surrogate-Key: has-header
X-Robots-Tag: noindex

/blog/:post
Surrogate-Key: blog-post

/parent/*/page
Surrogate-Key: parent-page
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<p>I am a Server Island</p>

Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
import Island from "../components/Island.astro"

const { slug } = Astro.params;

export async function getStaticPaths() {
return [
{ params: { slug: '1' } },
{ params: { slug: '2' } },
];
}
---
<html>
<head><title>{slug}</title></head>
<body>
<h1>{slug}</h1>
<Island server:defer />
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
import Island from "../components/Island.astro"
---
<html>
<head><title>Page with header</title></head>
<body>
<h1>Page with header</h1>
<Island server:defer />
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
import Island from "../../components/Island.astro"

const { post } = Astro.params;

export async function getStaticPaths() {
return [
{ params: { post: '1' } },
];
}
---
<html>
<head><title>Post #{post}</title></head>
<body>
<h1>Post #{post}</h1>
<Island server:defer />
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
import Island from "../components/Island.astro"
---
<html>
<head><title>Page with header</title></head>
<body>
<h1>Page with header</h1>
<Island server:defer />
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
import Island from "../components/Island.astro"
---
<html>
<head><title>Index</title></head>
<body>
<h1>Index</h1>
<Island server:defer />
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
import Island from "../../../components/Island.astro"

const { dynamic } = Astro.params;

export async function getStaticPaths() {
return [
{ params: { dynamic: '1' } },
];
}
---
<html>
<head><title>{dynamic}</title></head>
<body>
<h1>{dynamic}</h1>
<Island server:defer />
</body>
</html>
82 changes: 82 additions & 0 deletions packages/integrations/cloudflare/test/static-headers.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import * as assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { fileURLToPath } from 'node:url';
import { astroCli, wranglerCli } from './_test-utils.js';

const root = new URL('./fixtures/static-headers/', import.meta.url);
describe('StaticHeaders', () => {
let wrangler;
before(async () => {
await astroCli(fileURLToPath(root), 'build');

wrangler = wranglerCli(fileURLToPath(root));
await new Promise((resolve) => {
wrangler.stdout.on('data', (data) => {
// console.log('[stdout]', data.toString());
if (data.toString().includes('http://127.0.0.1:8788')) resolve();
});
wrangler.stderr.on('data', (_data) => {
// console.log('[stderr]', data.toString());
});
});
});


after((_done) => {
wrangler.kill();
});

it('serves headers correctly for /', async () => {
const res = await fetch('http://127.0.0.1:8788/');
assert.equal(res.status, 200);
assert.equal(res.headers.get('Surrogate-Key'), 'root, catch-all');
assert.ok(res.headers.get('Content-Security-Policy'));
});

it('serves headers correctly for /has-header', async () => {
const res = await fetch('http://127.0.0.1:8788/has-header');
assert.equal(res.status, 200);
const cspHeader= res.headers.get('Content-Security-Policy')
assert.ok(cspHeader.includes("script-src 'self' 'sha256-"))
assert.ok(cspHeader.includes("style-src 'self' 'sha256-"))
});

it('serves headers correctly for /blog/post-slug', async () => {
const res = await fetch('http://127.0.0.1:8788/blog/post-slug');
assert.equal(res.status, 200);
assert.equal(res.headers.get('Surrogate-Key'), 'catch-all, blog-post');
const cspHeader= res.headers.get('Content-Security-Policy')
assert.ok(cspHeader.includes("script-src 'self' 'sha256-"))
assert.ok(cspHeader.includes("style-src 'self' 'sha256-")) });

it('serves headers correctly for /parent/something/page', async () => {
const res = await fetch('http://127.0.0.1:8788/parent/something/page');
assert.equal(res.status, 200);
assert.equal(res.headers.get('Surrogate-Key'), 'catch-all, parent-page');
const cspHeader= res.headers.get('Content-Security-Policy')
assert.ok(cspHeader.includes("script-src 'self' 'sha256-"))
assert.ok(cspHeader.includes("style-src 'self' 'sha256-")) });

it('serves headers correctly for /unknown-route', async () => {
const res = await fetch('http://127.0.0.1:8788/unknown-route');
assert.equal(res.status, 200);
const cspHeader= res.headers.get('Content-Security-Policy')
assert.ok(cspHeader.includes("script-src 'self' 'sha256-"))
assert.ok(cspHeader.includes("style-src 'self' 'sha256-")) });

it('serves headers correctly for /blank', async () => {
const res = await fetch('http://127.0.0.1:8788/blank');
assert.equal(res.status, 200);
assert.equal(res.headers.get('Surrogate-Key'), 'catch-all');
const cspHeader= res.headers.get('Content-Security-Policy')
assert.ok(cspHeader.includes("script-src 'self' 'sha256-"))
assert.ok(cspHeader.includes("style-src 'self' 'sha256-")) });

it('serves headers correctly for catch-all routes', async () => {
const res = await fetch('http://127.0.0.1:8788/some-random-path');
assert.equal(res.status, 200);
assert.equal(res.headers.get('Surrogate-Key'), 'catch-all');
const cspHeader= res.headers.get('Content-Security-Policy')
assert.ok(cspHeader.includes("script-src 'self' 'sha256-"))
assert.ok(cspHeader.includes("style-src 'self' 'sha256-")) });
});
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading