Skip to content

Commit de82ef2

Browse files
ematipicoascorbicsarah11918
authored
feat(netlify): add experimental support for static headers (#13952)
Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com> Co-authored-by: ascorbic <213306+ascorbic@users.noreply.github.com> Co-authored-by: sarah11918 <5098874+sarah11918@users.noreply.github.com>
1 parent 448bddc commit de82ef2

20 files changed

Lines changed: 271 additions & 56 deletions

File tree

.changeset/huge-crabs-remain.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@astrojs/underscore-redirects': minor
3+
---
4+
5+
Adds a new method called `createHostedRouteDefinition`, which returns a `HostRoute` type from a `IntegrationResolvedRoute`.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@astrojs/underscore-redirects': minor
3+
---
4+
5+
Adds a new method called `printAsRedirects` to print `HostRoutes` as redirects for the `_redirects` file.

.changeset/sixty-icons-camp.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@astrojs/underscore-redirects': major
3+
---
4+
5+
- The type `Redirects` has been renamed to `HostRoutes`.
6+
- `RouteDefinition.target` is now optional
7+
- `RouteDefinition.weight` is now optional
8+
- `Redirects.print` has been removed. Now you need to pass `Redirects` type to the `print` function
9+
10+
```diff
11+
- redirects.print()
12+
+ import { printAsRedirects } from "@astrojs/underscore-redirects"
13+
+ printAsRedirects(redirects)
14+
```

.changeset/sweet-hotels-cross.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@astrojs/netlify': minor
3+
---
4+
5+
Adds support for the [experimental static headers Astro feature](https://docs.astro.build/en/reference/adapter-reference/#experimentalstaticheaders).
6+
7+
When the feature is enabled via 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`).
8+
9+
```js
10+
import { defineConfig } from "astro/config";
11+
import netlify from "@astrojs/netlify";
12+
13+
export default defineConfig({
14+
adapter: netlify({
15+
experimentalStaticHeaders: true
16+
}),
17+
experimental: {
18+
cps: true
19+
}
20+
})
21+
```

packages/astro/test/csp.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ describe('CSP', () => {
228228
const $ = cheerio.load(html);
229229

230230
const meta = $('meta[http-equiv="Content-Security-Policy"]');
231-
assert.ok(meta.attr('content').toString().includes('strict-dynamic;'));
231+
assert.ok(meta.attr('content').toString().includes("'strict-dynamic';"));
232232
});
233233

234234
it('should serve hashes via headers for dynamic pages, when the strategy is "auto"', async () => {

packages/integrations/cloudflare/src/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
prependForwardSlash,
1616
removeLeadingForwardSlash,
1717
} from '@astrojs/internal-helpers/path';
18-
import { createRedirectsFromAstroRoutes } from '@astrojs/underscore-redirects';
18+
import { createRedirectsFromAstroRoutes, printAsRedirects } from '@astrojs/underscore-redirects';
1919
import { AstroError } from 'astro/errors';
2020
import { defaultClientConditions } from 'vite';
2121
import { type GetPlatformProxyOptions, getPlatformProxy } from 'wrangler';
@@ -408,7 +408,10 @@ export default function createIntegration(args?: Options): AstroIntegration {
408408

409409
if (!trueRedirects.empty()) {
410410
try {
411-
await appendFile(new URL('./_redirects', _config.outDir), trueRedirects.print());
411+
await appendFile(
412+
new URL('./_redirects', _config.outDir),
413+
printAsRedirects(trueRedirects),
414+
);
412415
} catch (_error) {
413416
logger.error('Failed to write _redirects file');
414417
}

packages/integrations/netlify/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"dist"
3030
],
3131
"scripts": {
32+
"dev": "astro-scripts dev \"src/**/*.ts\"",
3233
"build": "astro-scripts build \"src/**/*.ts\" && tsc",
3334
"build:ci": "astro-scripts build \"src/**/*.ts\"",
3435
"test": "pnpm run test-fn && pnpm run test-static",

packages/integrations/netlify/src/index.ts

Lines changed: 65 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
33
import type { IncomingMessage } from 'node:http';
44
import { fileURLToPath, pathToFileURL } from 'node:url';
55
import { emptyDir } from '@astrojs/internal-helpers/fs';
6-
import { createRedirectsFromAstroRoutes } from '@astrojs/underscore-redirects';
6+
import {
7+
createHostedRouteDefinition,
8+
createRedirectsFromAstroRoutes,
9+
printAsRedirects,
10+
} from '@astrojs/underscore-redirects';
711
import type { Context } from '@netlify/functions';
812
import type {
913
AstroConfig,
@@ -103,7 +107,11 @@ export function remotePatternToRegex(
103107
return regexStr;
104108
}
105109

106-
async function writeNetlifyFrameworkConfig(config: AstroConfig, logger: AstroIntegrationLogger) {
110+
async function writeNetlifyFrameworkConfig(
111+
config: AstroConfig,
112+
staticHeaders: Map<IntegrationResolvedRoute, Headers> | undefined,
113+
logger: AstroIntegrationLogger,
114+
) {
107115
const remoteImages: Array<string> = [];
108116
// Domains get a simple regex match
109117
remoteImages.push(
@@ -116,16 +124,41 @@ async function writeNetlifyFrameworkConfig(config: AstroConfig, logger: AstroInt
116124
.filter(Boolean as unknown as (pattern?: string) => pattern is string),
117125
);
118126

119-
const headers = config.build.assetsPrefix
120-
? undefined
121-
: [
122-
{
123-
for: `${config.base}${config.base.endsWith('/') ? '' : '/'}${config.build.assets}/*`,
124-
values: {
125-
'Cache-Control': 'public, max-age=31536000, immutable',
126-
},
127-
},
128-
];
127+
const headers = [];
128+
if (!config.build.assetsPrefix) {
129+
headers.push({
130+
for: `${config.base}${config.base.endsWith('/') ? '' : '/'}${config.build.assets}/*`,
131+
values: {
132+
'Cache-Control': 'public, max-age=31536000, immutable',
133+
},
134+
});
135+
}
136+
137+
if (staticHeaders && staticHeaders.size > 0) {
138+
for (const [route, routeHeaders] of staticHeaders.entries()) {
139+
if (!route.isPrerendered) {
140+
continue;
141+
}
142+
if (route.redirect) {
143+
continue;
144+
}
145+
146+
const definition = createHostedRouteDefinition(route, config);
147+
148+
if (config.experimental.csp) {
149+
const csp = routeHeaders.get('Content-Security-Policy');
150+
151+
if (csp) {
152+
headers.push({
153+
for: definition.input,
154+
values: {
155+
'Content-Security-Policy': csp,
156+
},
157+
});
158+
}
159+
}
160+
}
161+
}
129162

130163
// See https://docs.netlify.com/image-cdn/create-integration/
131164
const deployConfigDir = new URL('.netlify/v1/', config.root);
@@ -205,6 +238,14 @@ export interface NetlifyIntegrationConfig {
205238
* @default {true}
206239
*/
207240
imageCDN?: boolean;
241+
242+
/**
243+
* If enabled, the adapter will save [static headers in the framework API file](https://docs.netlify.com/frameworks-api/#headers).
244+
*
245+
* Here the list of the headers that are added:
246+
* - The CSP header of the static pages is added when CSP support is enabled.
247+
*/
248+
experimentalStaticHeaders?: boolean;
208249
}
209250

210251
export default function netlifyIntegration(
@@ -218,6 +259,7 @@ export default function netlifyIntegration(
218259
let outDir: URL;
219260
let rootDir: URL;
220261
let astroMiddlewareEntryPoint: URL | undefined = undefined;
262+
let staticHeadersMap: Map<IntegrationResolvedRoute, Headers> | undefined = undefined;
221263
// Extra files to be merged with `includeFiles` during build
222264
const extraFilesToInclude: URL[] = [];
223265
// Secret used to verify that the caller is the astro-generated edge middleware and not a third-party
@@ -271,7 +313,7 @@ export default function netlifyIntegration(
271313
});
272314

273315
if (!redirects.empty()) {
274-
await appendFile(new URL('_redirects', outDir), `\n${redirects.print()}\n`);
316+
await appendFile(new URL('_redirects', outDir), `\n${printAsRedirects(redirects)}\n`);
275317
}
276318
}
277319

@@ -570,22 +612,22 @@ export default function netlifyIntegration(
570612
'astro:routes:resolved': (params) => {
571613
routes = params.routes;
572614
},
573-
'astro:config:done': async ({ config, setAdapter, logger, buildOutput }) => {
615+
'astro:config:done': async ({ config, setAdapter, buildOutput }) => {
574616
rootDir = config.root;
575617
_config = config;
576618

577619
finalBuildOutput = buildOutput;
578620

579-
await writeNetlifyFrameworkConfig(config, logger);
580-
581-
const edgeMiddleware = integrationConfig?.edgeMiddleware ?? false;
621+
const useEdgeMiddleware = integrationConfig?.edgeMiddleware ?? false;
622+
const useStaticHeaders = integrationConfig?.experimentalStaticHeaders ?? false;
582623

583624
setAdapter({
584625
name: '@astrojs/netlify',
585626
serverEntrypoint: '@astrojs/netlify/ssr-function.js',
586627
exports: ['default'],
587628
adapterFeatures: {
588-
edgeMiddleware,
629+
edgeMiddleware: useEdgeMiddleware,
630+
experimentalStaticHeaders: useStaticHeaders,
589631
},
590632
args: { middlewareSecret } satisfies Args,
591633
supportedAstroFeatures: {
@@ -597,6 +639,9 @@ export default function netlifyIntegration(
597639
},
598640
});
599641
},
642+
'astro:build:generated': ({ experimentalRouteToHeaders }) => {
643+
staticHeadersMap = experimentalRouteToHeaders;
644+
},
600645
'astro:build:ssr': async ({ middlewareEntryPoint }) => {
601646
astroMiddlewareEntryPoint = middlewareEntryPoint;
602647
},
@@ -616,6 +661,8 @@ export default function netlifyIntegration(
616661
await writeMiddleware(astroMiddlewareEntryPoint);
617662
logger.info('Generated Middleware Edge Function');
618663
}
664+
665+
await writeNetlifyFrameworkConfig(_config, staticHeadersMap, logger);
619666
},
620667

621668
// local dev
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import netlify from '@astrojs/netlify';
2+
import { defineConfig } from 'astro/config';
3+
4+
export default defineConfig({
5+
output: 'static',
6+
adapter: netlify({
7+
experimentalStaticHeaders: true
8+
}),
9+
site: "http://example.com",
10+
experimental: {
11+
csp: true
12+
}
13+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "@test/netlify-static-headers",
3+
"version": "0.0.0",
4+
"private": true,
5+
"dependencies": {
6+
"@astrojs/netlify": "workspace:"
7+
}
8+
}

0 commit comments

Comments
 (0)