diff --git a/.changeset/eight-regions-tease.md b/.changeset/eight-regions-tease.md
new file mode 100644
index 000000000000..2c2189fc5bc8
--- /dev/null
+++ b/.changeset/eight-regions-tease.md
@@ -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 `` 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
+ }
+})
+```
\ No newline at end of file
diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts
index 2d706e7b3d41..9fc24b293742 100644
--- a/packages/integrations/cloudflare/src/index.ts
+++ b/packages/integrations/cloudflare/src/index.ts
@@ -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:
@@ -142,6 +152,7 @@ function setProcessEnv(config: AstroConfig, env: Record) {
export default function createIntegration(args?: Options): AstroIntegration {
let _config: AstroConfig;
let finalBuildOutput: HookParameters<'astro:config:done'>['buildOutput'];
+ let staticHeadersMap: Map | undefined = undefined;
const cloudflareModulePlugin: PluginOption & CloudflareModulePluginExtra = cloudflareModuleLoader(
args?.cloudflareModules ?? true,
@@ -268,6 +279,7 @@ export default function createIntegration(args?: Options): AstroIntegration {
adapterFeatures: {
edgeMiddleware: false,
buildOutput: 'server',
+ experimentalStaticHeaders: args?.experimentalStaticHeaders ?? false,
},
supportedAstroFeatures: {
serverOutput: 'stable',
@@ -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);
@@ -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(
diff --git a/packages/integrations/cloudflare/src/utils/generate-headers-file.ts b/packages/integrations/cloudflare/src/utils/generate-headers-file.ts
new file mode 100644
index 000000000000..cdf0193b6db1
--- /dev/null
+++ b/packages/integrations/cloudflare/src/utils/generate-headers-file.ts
@@ -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,
+) {
+ 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';
+
+ try {
+ await writeFile(outUrl, output, 'utf-8');
+ } catch (err) {
+ logger.error(`Error writing _headers file: ${err}`);
+ }
+}
+
+async function loadExistingHeaders(publicUrl: URL): Promise