Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/all-loops-deny.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'astro': minor
---

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.

`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.

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).
7 changes: 7 additions & 0 deletions .changeset/full-hoops-hear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astrojs/netlify': patch
'@astrojs/vercel': patch
---

Fixes the internal implementation of the new feature `experimentalStaticHeaders`, where dynamic routes
were mapped to use always the same header.
22 changes: 22 additions & 0 deletions .changeset/purple-spoons-jog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@astrojs/node': 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 node from "@astrojs/node";

export default defineConfig({
adapter: node({
mode: "standalone",
experimentalStaticHeaders: true
}),
experimental: {
cps: true
}
})
```
18 changes: 16 additions & 2 deletions packages/astro/src/core/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,15 @@ export class App {
}
}

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

if (!routeData) return undefined;
if (allowPrerenderedRoutes) {
return routeData;
}
// missing routes fall-through, pre rendered are handled by static layer
if (!routeData || routeData.prerender) return undefined;
else if (routeData.prerender) {
return undefined;
}
return routeData;
}

Expand Down
12 changes: 9 additions & 3 deletions packages/astro/src/core/app/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { deserializeManifest } from './common.js';
import { createOutgoingHttpHeaders } from './createOutgoingHttpHeaders.js';
import type { RenderOptions } from './index.js';
import { App } from './index.js';
import type { SerializedSSRManifest, SSRManifest } from './types.js';
import type { NodeAppHeadersJson, SerializedSSRManifest, SSRManifest } from './types.js';

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

Expand All @@ -20,13 +20,19 @@ interface NodeRequest extends IncomingMessage {
}

export class NodeApp extends App {
match(req: NodeRequest | Request) {
headersMap: NodeAppHeadersJson | undefined = undefined;

public setHeadersMap(headers: NodeAppHeadersJson) {
this.headersMap = headers;
}

match(req: NodeRequest | Request, allowPrerenderedRoutes = false) {
if (!(req instanceof Request)) {
req = NodeApp.createRequest(req, {
skipBody: true,
});
}
return super.match(req);
return super.match(req, allowPrerenderedRoutes);
}
render(request: NodeRequest | Request, options?: RenderOptions): Promise<Response>;
/**
Expand Down
8 changes: 8 additions & 0 deletions packages/astro/src/core/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,11 @@ export type SerializedSSRManifest = Omit<
serverIslandNameMap: [string, string][];
key: string;
};

export type NodeAppHeadersJson = {
pathname: string;
headers: {
key: string;
value: string;
}[];
}[];
43 changes: 28 additions & 15 deletions packages/astro/src/core/build/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { getServerOutputDirectory } from '../../prerender/utils.js';
import type { AstroSettings, ComponentInstance } from '../../types/astro.js';
import type { GetStaticPathsItem, MiddlewareHandler } from '../../types/public/common.js';
import type { AstroConfig } from '../../types/public/config.js';
import type { IntegrationResolvedRoute } from '../../types/public/index.js';
import type { IntegrationResolvedRoute, RouteToHeaders } from '../../types/public/index.js';
import type {
RouteData,
RouteType,
Expand Down Expand Up @@ -102,7 +102,7 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil
logger.info('SKIP_FORMAT', `\n${bgGreen(black(` ${verb} static routes `))}`);
const builtPaths = new Set<string>();
const pagesToGenerate = pipeline.retrieveRoutesToGenerate();
const routeToHeaders = new Map<IntegrationResolvedRoute, Headers>();
const routeToHeaders: RouteToHeaders = new Map();
if (ssr) {
for (const [pageData, filePath] of pagesToGenerate) {
if (pageData.route.prerender) {
Expand Down Expand Up @@ -234,7 +234,7 @@ async function generatePage(
ssrEntry: SinglePageBuiltModule,
builtPaths: Set<string>,
pipeline: BuildPipeline,
routeToHeaders: Map<IntegrationResolvedRoute, Headers>,
routeToHeaders: RouteToHeaders,
) {
// prepare information we need
const { config, logger } = pipeline;
Expand Down Expand Up @@ -264,6 +264,7 @@ async function generatePage(
async function generatePathWithLogs(
path: string,
route: RouteData,
integrationRoute: IntegrationResolvedRoute,
index: number,
paths: string[],
isConcurrent: boolean,
Expand All @@ -281,7 +282,14 @@ async function generatePage(
logger.info(null, ` ${blue(lineIcon)} ${dim(filePath)}`, false);
}

const created = await generatePath(path, pipeline, generationOptions, route, routeToHeaders);
const created = await generatePath(
path,
pipeline,
generationOptions,
route,
integrationRoute,
routeToHeaders,
);

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

// Now we explode the routes. A route render itself, and it can render its fallbacks (i18n routing)
for (const route of eachRouteInRouteData(pageData)) {
const integrationRoute = toIntegrationResolvedRoute(route);
const icon =
route.type === 'page' || route.type === 'redirect' || route.type === 'fallback'
? green('▶')
Expand All @@ -313,13 +322,15 @@ async function generatePage(
const promises: Promise<void>[] = [];
for (let i = 0; i < paths.length; i++) {
const path = paths[i];
promises.push(limit(() => generatePathWithLogs(path, route, i, paths, true)));
promises.push(
limit(() => generatePathWithLogs(path, route, integrationRoute, i, paths, true)),
);
}
await Promise.all(promises);
} else {
for (let i = 0; i < paths.length; i++) {
const path = paths[i];
await generatePathWithLogs(path, route, i, paths, false);
await generatePathWithLogs(path, route, integrationRoute, i, paths, false);
}
}
}
Expand Down Expand Up @@ -499,7 +510,8 @@ async function generatePath(
pipeline: BuildPipeline,
gopts: GeneratePathOptions,
route: RouteData,
routeToHeaders: Map<IntegrationResolvedRoute, Headers>,
integrationRoute: IntegrationResolvedRoute,
routeToHeaders: RouteToHeaders,
): Promise<boolean | undefined> {
const { mod } = gopts;
const { config, logger, options } = pipeline;
Expand Down Expand Up @@ -569,20 +581,14 @@ async function generatePath(
throw err;
}

if (
pipeline.settings.adapter?.adapterFeatures?.experimentalStaticHeaders &&
pipeline.settings.config.experimental?.csp
) {
routeToHeaders.set(toIntegrationResolvedRoute(route), response.headers);
}

const responseHeaders = response.headers;
if (response.status >= 300 && response.status < 400) {
// Adapters may handle redirects themselves, turning off Astro's redirect handling using `config.build.redirects` in the process.
// In that case, we skip rendering static files for the redirect routes.
if (routeIsRedirect(route) && !config.build.redirects) {
return undefined;
}
const locationSite = getRedirectLocationOrThrow(response.headers);
const locationSite = getRedirectLocationOrThrow(responseHeaders);
const siteURL = config.site;
const location = siteURL ? new URL(locationSite, siteURL) : locationSite;
const fromPath = new URL(request.url).pathname;
Expand Down Expand Up @@ -616,6 +622,13 @@ async function generatePath(
route.distURL = [outFile];
}

if (
pipeline.settings.adapter?.adapterFeatures?.experimentalStaticHeaders &&
pipeline.settings.config.experimental?.csp
) {
routeToHeaders.set(pathname, { headers: responseHeaders, route: integrationRoute });
}

await fs.promises.mkdir(outFolder, { recursive: true });
await fs.promises.writeFile(outFile, body);

Expand Down
8 changes: 7 additions & 1 deletion packages/astro/src/core/build/plugins/plugin-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,9 +244,15 @@ async function buildManifest(
staticFiles.push(file);
}

const needsStaticHeaders = settings.adapter?.adapterFeatures?.experimentalStaticHeaders ?? false;

for (const route of opts.routesList.routes) {
const pageData = internals.pagesByKeys.get(makePageDataKey(route.route, route.component));
if (route.prerender || !pageData) continue;
if (!pageData) continue;

if (route.prerender && !needsStaticHeaders) {
continue;
}
const scripts: SerializedRouteInfo['scripts'] = [];
if (settings.scripts.some((script) => script.stage === 'page')) {
const src = entryModules[PAGE_SCRIPT_ID];
Expand Down
3 changes: 2 additions & 1 deletion packages/astro/src/integrations/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type {
IntegrationResolvedRoute,
IntegrationRouteData,
RouteOptions,
RouteToHeaders,
} from '../types/public/integrations.js';
import type { RouteData } from '../types/public/internal.js';
import { validateSupportedFeatures } from './features-validation.js';
Expand Down Expand Up @@ -590,7 +591,7 @@ export async function runHookBuildGenerated({
}: {
settings: AstroSettings;
logger: Logger;
experimentalRouteToHeaders: Map<IntegrationResolvedRoute, Headers>;
experimentalRouteToHeaders: RouteToHeaders;
}) {
const dir =
settings.buildOutput === 'server' ? settings.config.build.client : settings.config.outDir;
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/types/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export type {
UnresolvedImageTransform,
} from '../../assets/types.js';
export type { ContainerRenderer } from '../../container/index.js';
export type { AssetsPrefix, SSRManifest } from '../../core/app/types.js';
export type { AssetsPrefix, NodeAppHeadersJson, SSRManifest } from '../../core/app/types.js';
export type {
AstroCookieGetOptions,
AstroCookieSetOptions,
Expand Down
9 changes: 8 additions & 1 deletion packages/astro/src/types/public/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ export interface BaseIntegrationHooks {
'astro:build:generated': (options: {
dir: URL;
logger: AstroIntegrationLogger;
experimentalRouteToHeaders: Map<IntegrationResolvedRoute, Headers>;
experimentalRouteToHeaders: RouteToHeaders;
}) => void | Promise<void>;
'astro:build:done': (options: {
pages: { pathname: string }[];
Expand Down Expand Up @@ -277,6 +277,13 @@ export type IntegrationRouteData = Omit<
redirectRoute?: IntegrationRouteData;
};

export type RouteToHeaders = Map<string, HeaderPayload>;

export type HeaderPayload = {
headers: Headers;
route: IntegrationResolvedRoute;
};

export interface IntegrationResolvedRoute
extends Pick<
RouteData,
Expand Down
13 changes: 7 additions & 6 deletions packages/astro/test/csp.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -321,13 +321,14 @@ describe('CSP', () => {
await fixture.build();
app = await fixture.loadTestAdapterApp();

assert.equal(
routeToHeaders.size,
4,
'expected four routes: /, /scripts, /title/foo, /title/bar',
);
assert.equal(routeToHeaders.size, 4, 'expected four routes: /, /scripts, /foo, /bar');

assert.ok(routeToHeaders.has('/'), 'should have a CSP header for /');
assert.ok(routeToHeaders.has('/scripts'), 'should have a CSP header for /scripts');
assert.ok(routeToHeaders.has('/foo'), 'should have a CSP header for /foo');
assert.ok(routeToHeaders.has('/bar'), 'should have a CSP header for /bar');

for (const headers of routeToHeaders.values()) {
for (const { headers } of routeToHeaders.values()) {
assert.ok(headers.has('content-security-policy'), 'should have a CSP header');
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ export const prerender = true

export const getStaticPaths = (() => {
return [
{ params: { title: 'Foo' } },
{ params: { title: 'Bar' } },
{ params: { title: 'foo' } },
{ params: { title: 'bar' } },
];
});

Expand Down
Loading