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>> { + 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>() }, + ).store; + } catch { + return new Map(); + } +} diff --git a/packages/integrations/cloudflare/test/fixtures/static-headers/astro.config.mjs b/packages/integrations/cloudflare/test/fixtures/static-headers/astro.config.mjs new file mode 100644 index 000000000000..eef3ed09651b --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/static-headers/astro.config.mjs @@ -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 + } +}); \ No newline at end of file diff --git a/packages/integrations/cloudflare/test/fixtures/static-headers/package.json b/packages/integrations/cloudflare/test/fixtures/static-headers/package.json new file mode 100644 index 000000000000..e36ef6470d4e --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/static-headers/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/cloudflare-static-headers", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/cloudflare": "workspace:" + } +} \ No newline at end of file diff --git a/packages/integrations/cloudflare/test/fixtures/static-headers/public/_headers b/packages/integrations/cloudflare/test/fixtures/static-headers/public/_headers new file mode 100644 index 000000000000..66361c8d8393 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/static-headers/public/_headers @@ -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 diff --git a/packages/integrations/cloudflare/test/fixtures/static-headers/src/components/Island.astro b/packages/integrations/cloudflare/test/fixtures/static-headers/src/components/Island.astro new file mode 100644 index 000000000000..2274deb09168 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/static-headers/src/components/Island.astro @@ -0,0 +1,2 @@ +

I am a Server Island

+ diff --git a/packages/integrations/cloudflare/test/fixtures/static-headers/src/pages/[...slug].astro b/packages/integrations/cloudflare/test/fixtures/static-headers/src/pages/[...slug].astro new file mode 100644 index 000000000000..0d5916f45a61 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/static-headers/src/pages/[...slug].astro @@ -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' } }, + ]; +} +--- + +{slug} + +

{slug}

+ + + diff --git a/packages/integrations/cloudflare/test/fixtures/static-headers/src/pages/blank.astro b/packages/integrations/cloudflare/test/fixtures/static-headers/src/pages/blank.astro new file mode 100644 index 000000000000..8059d298adbb --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/static-headers/src/pages/blank.astro @@ -0,0 +1,10 @@ +--- +import Island from "../components/Island.astro" +--- + +Page with header + +

Page with header

+ + + diff --git a/packages/integrations/cloudflare/test/fixtures/static-headers/src/pages/blog/[post].astro b/packages/integrations/cloudflare/test/fixtures/static-headers/src/pages/blog/[post].astro new file mode 100644 index 000000000000..b605e5431709 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/static-headers/src/pages/blog/[post].astro @@ -0,0 +1,18 @@ +--- +import Island from "../../components/Island.astro" + +const { post } = Astro.params; + +export async function getStaticPaths() { + return [ + { params: { post: '1' } }, + ]; +} +--- + +Post #{post} + +

Post #{post}

+ + + diff --git a/packages/integrations/cloudflare/test/fixtures/static-headers/src/pages/has-header.astro b/packages/integrations/cloudflare/test/fixtures/static-headers/src/pages/has-header.astro new file mode 100644 index 000000000000..8059d298adbb --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/static-headers/src/pages/has-header.astro @@ -0,0 +1,10 @@ +--- +import Island from "../components/Island.astro" +--- + +Page with header + +

Page with header

+ + + diff --git a/packages/integrations/cloudflare/test/fixtures/static-headers/src/pages/index.astro b/packages/integrations/cloudflare/test/fixtures/static-headers/src/pages/index.astro new file mode 100644 index 000000000000..39d04ccc10bd --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/static-headers/src/pages/index.astro @@ -0,0 +1,10 @@ +--- +import Island from "../components/Island.astro" +--- + +Index + +

Index

+ + + diff --git a/packages/integrations/cloudflare/test/fixtures/static-headers/src/pages/parent/[...dynamic]/page.astro b/packages/integrations/cloudflare/test/fixtures/static-headers/src/pages/parent/[...dynamic]/page.astro new file mode 100644 index 000000000000..960d29a227a3 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/static-headers/src/pages/parent/[...dynamic]/page.astro @@ -0,0 +1,18 @@ +--- +import Island from "../../../components/Island.astro" + +const { dynamic } = Astro.params; + +export async function getStaticPaths() { + return [ + { params: { dynamic: '1' } }, + ]; +} +--- + +{dynamic} + +

{dynamic}

+ + + diff --git a/packages/integrations/cloudflare/test/static-headers.test.js b/packages/integrations/cloudflare/test/static-headers.test.js new file mode 100644 index 000000000000..b774c30fd2cc --- /dev/null +++ b/packages/integrations/cloudflare/test/static-headers.test.js @@ -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-")) }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb03e280f336..deb5d3d14b48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4803,6 +4803,12 @@ importers: specifier: ^4.14.1 version: 4.14.1(@cloudflare/workers-types@4.20250607.0) + packages/integrations/cloudflare/test/fixtures/static-headers: + dependencies: + '@astrojs/cloudflare': + specifier: 'workspace:' + version: link:../../.. + packages/integrations/cloudflare/test/fixtures/with-base: dependencies: '@astrojs/cloudflare':