-
-
Notifications
You must be signed in to change notification settings - Fork 3.1k
feat(cloudflare): add experimental support for static headers #13959
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b84628e
314b14d
45bbfba
0667f93
fa3259e
8c44d35
0ffa508
c4964f3
faa63df
e6552a9
3fdcd82
a9ba349
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| } | ||
| }) | ||
| ``` |
| 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'; | ||
|
|
||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Interesting, I thought the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think the |
||
| 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> |
| 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-")) }); | ||
| }); |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
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
astrofor this?There was a problem hiding this comment.
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-redirectsthat might provide something we can use here, but you should look into itThere was a problem hiding this comment.
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
printAsRedirectsfrom@astrojs/underscore-redirects, but I'm not sure if the syntax is 100% equal