Skip to content

Commit f7d6560

Browse files
ematipicoflorian-lefebvreascorbicsarah11918
authored andcommitted
feat(node): experimental static headers (withastro#13972)
Co-authored-by: Matt Kane <m@mk.gg> Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com> Co-authored-by: florian-lefebvre <69633530+florian-lefebvre@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 4a87105 commit f7d6560

30 files changed

Lines changed: 560 additions & 270 deletions

File tree

.changeset/all-loops-deny.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'astro': minor
3+
---
4+
5+
Updates the `NodeApp.match()` function in the Adapter API to accept a second, optional parameter to allow adapter authors to add headers to static, prerendered pages.
6+
7+
`NodeApp.match(request)` currently checks whether there is a route that matches the given `Request`. If there is a prerendered route, the function returns `undefined`, because static routes are already rendered and their headers cannot be updated.
8+
9+
When the new, optional boolean parameter is passed (e.g. `NodeApp.match(request, true)`), Astro will return the first matched route, even when it's a prerendered route. This allows your adapter to now access static routes and provides the opportunity to set headers for these pages, for example, to implement a Content Security Policy (CSP).

.changeset/full-hoops-hear.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@astrojs/netlify': patch
3+
'@astrojs/vercel': patch
4+
---
5+
6+
Fixes the internal implementation of the new feature `experimentalStaticHeaders`, where dynamic routes
7+
were mapped to use always the same header.

.changeset/purple-spoons-jog.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@astrojs/node': 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 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`).
8+
9+
```js
10+
import { defineConfig } from "astro/config";
11+
import node from "@astrojs/node";
12+
13+
export default defineConfig({
14+
adapter: node({
15+
mode: "standalone",
16+
experimentalStaticHeaders: true
17+
}),
18+
experimental: {
19+
cps: true
20+
}
21+
})
22+
```

packages/astro/src/core/app/index.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,15 @@ export class App {
191191
}
192192
}
193193

194-
match(request: Request): RouteData | undefined {
194+
/**
195+
* Given a `Request`, it returns the `RouteData` that matches its `pathname`. By default, prerendered
196+
* routes aren't returned, even if they are matched.
197+
*
198+
* When `allowPrerenderedRoutes` is `true`, the function returns matched prerendered routes too.
199+
* @param request
200+
* @param allowPrerenderedRoutes
201+
*/
202+
match(request: Request, allowPrerenderedRoutes = false): RouteData | undefined {
195203
const url = new URL(request.url);
196204
// ignore requests matching public assets
197205
if (this.#manifest.assets.has(url.pathname)) return undefined;
@@ -201,8 +209,14 @@ export class App {
201209
}
202210
let routeData = matchRoute(decodeURI(pathname), this.#manifestData);
203211

212+
if (!routeData) return undefined;
213+
if (allowPrerenderedRoutes) {
214+
return routeData;
215+
}
204216
// missing routes fall-through, pre rendered are handled by static layer
205-
if (!routeData || routeData.prerender) return undefined;
217+
else if (routeData.prerender) {
218+
return undefined;
219+
}
206220
return routeData;
207221
}
208222

packages/astro/src/core/app/node.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { deserializeManifest } from './common.js';
77
import { createOutgoingHttpHeaders } from './createOutgoingHttpHeaders.js';
88
import type { RenderOptions } from './index.js';
99
import { App } from './index.js';
10-
import type { SerializedSSRManifest, SSRManifest } from './types.js';
10+
import type { NodeAppHeadersJson, SerializedSSRManifest, SSRManifest } from './types.js';
1111

1212
export { apply as applyPolyfills } from '../polyfill.js';
1313

@@ -20,13 +20,19 @@ interface NodeRequest extends IncomingMessage {
2020
}
2121

2222
export class NodeApp extends App {
23-
match(req: NodeRequest | Request) {
23+
headersMap: NodeAppHeadersJson | undefined = undefined;
24+
25+
public setHeadersMap(headers: NodeAppHeadersJson) {
26+
this.headersMap = headers;
27+
}
28+
29+
match(req: NodeRequest | Request, allowPrerenderedRoutes = false) {
2430
if (!(req instanceof Request)) {
2531
req = NodeApp.createRequest(req, {
2632
skipBody: true,
2733
});
2834
}
29-
return super.match(req);
35+
return super.match(req, allowPrerenderedRoutes);
3036
}
3137
render(request: NodeRequest | Request, options?: RenderOptions): Promise<Response>;
3238
/**

packages/astro/src/core/app/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,11 @@ export type SerializedSSRManifest = Omit<
139139
serverIslandNameMap: [string, string][];
140140
key: string;
141141
};
142+
143+
export type NodeAppHeadersJson = {
144+
pathname: string;
145+
headers: {
146+
key: string;
147+
value: string;
148+
}[];
149+
}[];

packages/astro/src/core/build/generate.ts

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { getServerOutputDirectory } from '../../prerender/utils.js';
2121
import type { AstroSettings, ComponentInstance } from '../../types/astro.js';
2222
import type { GetStaticPathsItem, MiddlewareHandler } from '../../types/public/common.js';
2323
import type { AstroConfig } from '../../types/public/config.js';
24-
import type { IntegrationResolvedRoute } from '../../types/public/index.js';
24+
import type { IntegrationResolvedRoute, RouteToHeaders } from '../../types/public/index.js';
2525
import type {
2626
RouteData,
2727
RouteType,
@@ -102,7 +102,7 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil
102102
logger.info('SKIP_FORMAT', `\n${bgGreen(black(` ${verb} static routes `))}`);
103103
const builtPaths = new Set<string>();
104104
const pagesToGenerate = pipeline.retrieveRoutesToGenerate();
105-
const routeToHeaders = new Map<IntegrationResolvedRoute, Headers>();
105+
const routeToHeaders: RouteToHeaders = new Map();
106106
if (ssr) {
107107
for (const [pageData, filePath] of pagesToGenerate) {
108108
if (pageData.route.prerender) {
@@ -234,7 +234,7 @@ async function generatePage(
234234
ssrEntry: SinglePageBuiltModule,
235235
builtPaths: Set<string>,
236236
pipeline: BuildPipeline,
237-
routeToHeaders: Map<IntegrationResolvedRoute, Headers>,
237+
routeToHeaders: RouteToHeaders,
238238
) {
239239
// prepare information we need
240240
const { config, logger } = pipeline;
@@ -264,6 +264,7 @@ async function generatePage(
264264
async function generatePathWithLogs(
265265
path: string,
266266
route: RouteData,
267+
integrationRoute: IntegrationResolvedRoute,
267268
index: number,
268269
paths: string[],
269270
isConcurrent: boolean,
@@ -281,7 +282,14 @@ async function generatePage(
281282
logger.info(null, ` ${blue(lineIcon)} ${dim(filePath)}`, false);
282283
}
283284

284-
const created = await generatePath(path, pipeline, generationOptions, route, routeToHeaders);
285+
const created = await generatePath(
286+
path,
287+
pipeline,
288+
generationOptions,
289+
route,
290+
integrationRoute,
291+
routeToHeaders,
292+
);
285293

286294
const timeEnd = performance.now();
287295
const isSlow = timeEnd - timeStart > THRESHOLD_SLOW_RENDER_TIME_MS;
@@ -298,6 +306,7 @@ async function generatePage(
298306

299307
// Now we explode the routes. A route render itself, and it can render its fallbacks (i18n routing)
300308
for (const route of eachRouteInRouteData(pageData)) {
309+
const integrationRoute = toIntegrationResolvedRoute(route);
301310
const icon =
302311
route.type === 'page' || route.type === 'redirect' || route.type === 'fallback'
303312
? green('▶')
@@ -313,13 +322,15 @@ async function generatePage(
313322
const promises: Promise<void>[] = [];
314323
for (let i = 0; i < paths.length; i++) {
315324
const path = paths[i];
316-
promises.push(limit(() => generatePathWithLogs(path, route, i, paths, true)));
325+
promises.push(
326+
limit(() => generatePathWithLogs(path, route, integrationRoute, i, paths, true)),
327+
);
317328
}
318329
await Promise.all(promises);
319330
} else {
320331
for (let i = 0; i < paths.length; i++) {
321332
const path = paths[i];
322-
await generatePathWithLogs(path, route, i, paths, false);
333+
await generatePathWithLogs(path, route, integrationRoute, i, paths, false);
323334
}
324335
}
325336
}
@@ -499,7 +510,8 @@ async function generatePath(
499510
pipeline: BuildPipeline,
500511
gopts: GeneratePathOptions,
501512
route: RouteData,
502-
routeToHeaders: Map<IntegrationResolvedRoute, Headers>,
513+
integrationRoute: IntegrationResolvedRoute,
514+
routeToHeaders: RouteToHeaders,
503515
): Promise<boolean | undefined> {
504516
const { mod } = gopts;
505517
const { config, logger, options } = pipeline;
@@ -569,20 +581,14 @@ async function generatePath(
569581
throw err;
570582
}
571583

572-
if (
573-
pipeline.settings.adapter?.adapterFeatures?.experimentalStaticHeaders &&
574-
pipeline.settings.config.experimental?.csp
575-
) {
576-
routeToHeaders.set(toIntegrationResolvedRoute(route), response.headers);
577-
}
578-
584+
const responseHeaders = response.headers;
579585
if (response.status >= 300 && response.status < 400) {
580586
// Adapters may handle redirects themselves, turning off Astro's redirect handling using `config.build.redirects` in the process.
581587
// In that case, we skip rendering static files for the redirect routes.
582588
if (routeIsRedirect(route) && !config.build.redirects) {
583589
return undefined;
584590
}
585-
const locationSite = getRedirectLocationOrThrow(response.headers);
591+
const locationSite = getRedirectLocationOrThrow(responseHeaders);
586592
const siteURL = config.site;
587593
const location = siteURL ? new URL(locationSite, siteURL) : locationSite;
588594
const fromPath = new URL(request.url).pathname;
@@ -616,6 +622,13 @@ async function generatePath(
616622
route.distURL = [outFile];
617623
}
618624

625+
if (
626+
pipeline.settings.adapter?.adapterFeatures?.experimentalStaticHeaders &&
627+
pipeline.settings.config.experimental?.csp
628+
) {
629+
routeToHeaders.set(pathname, { headers: responseHeaders, route: integrationRoute });
630+
}
631+
619632
await fs.promises.mkdir(outFolder, { recursive: true });
620633
await fs.promises.writeFile(outFile, body);
621634

packages/astro/src/core/build/plugins/plugin-manifest.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,9 +244,15 @@ async function buildManifest(
244244
staticFiles.push(file);
245245
}
246246

247+
const needsStaticHeaders = settings.adapter?.adapterFeatures?.experimentalStaticHeaders ?? false;
248+
247249
for (const route of opts.routesList.routes) {
248250
const pageData = internals.pagesByKeys.get(makePageDataKey(route.route, route.component));
249-
if (route.prerender || !pageData) continue;
251+
if (!pageData) continue;
252+
253+
if (route.prerender && !needsStaticHeaders) {
254+
continue;
255+
}
250256
const scripts: SerializedRouteInfo['scripts'] = [];
251257
if (settings.scripts.some((script) => script.stage === 'page')) {
252258
const src = entryModules[PAGE_SCRIPT_ID];

packages/astro/src/integrations/hooks.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import type {
3232
IntegrationResolvedRoute,
3333
IntegrationRouteData,
3434
RouteOptions,
35+
RouteToHeaders,
3536
} from '../types/public/integrations.js';
3637
import type { RouteData } from '../types/public/internal.js';
3738
import { validateSupportedFeatures } from './features-validation.js';
@@ -590,7 +591,7 @@ export async function runHookBuildGenerated({
590591
}: {
591592
settings: AstroSettings;
592593
logger: Logger;
593-
experimentalRouteToHeaders: Map<IntegrationResolvedRoute, Headers>;
594+
experimentalRouteToHeaders: RouteToHeaders;
594595
}) {
595596
const dir =
596597
settings.buildOutput === 'server' ? settings.config.build.client : settings.config.outDir;

packages/astro/src/types/public/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export type {
2121
UnresolvedImageTransform,
2222
} from '../../assets/types.js';
2323
export type { ContainerRenderer } from '../../container/index.js';
24-
export type { AssetsPrefix, SSRManifest } from '../../core/app/types.js';
24+
export type { AssetsPrefix, NodeAppHeadersJson, SSRManifest } from '../../core/app/types.js';
2525
export type {
2626
AstroCookieGetOptions,
2727
AstroCookieSetOptions,

0 commit comments

Comments
 (0)