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
35 changes: 35 additions & 0 deletions .changeset/rare-ducks-sip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
'astro': patch
---

Adds a new Astro Adapter Feature called `experimentalStaticHeaders` to allow your adapter to receive the `Headers` for rendered static pages.

Adapters that enable support for this feature can access header values directly, affecting their handling of some Astro features such as Content Security Policy (CSP). For example, Astro will no longer serve the CSP `<meta http-equiv="content-security-policy">` element in static pages to adapters with this support.

Astro will serve the value of the header inside a map that can be retrieved from the hook `astro:build:generated`. Adapters can read this mapping and use their hosting headers capabilities to create a configuration file.

A new field called `experimentalRouteToHeaders` will contain a map of `Map<IntegrationResolvedRoute, Headers>` where the `Headers` type contains the headers emitted by the rendered static route.

To enable support for this experimental Astro Adapter Feature, add it to your `adapterFeatures` in your adapter config:

```js
// my-adapter.mjs
export default function createIntegration() {
return {
name: '@example/my-adapter',
hooks: {
'astro:config:done': ({ setAdapter }) => {
setAdapter({
name: '@example/my-adapter',
serverEntrypoint: '@example/my-adapter/server.js',
adapterFeatures: {
experimentalStaticHeaders: true
}
});
},
},
};
}
```

See the [Adapter API docs](https://docs.astro.build/en/reference/adapter-reference/#adapter-features) for more information about providing adapter features.
5 changes: 5 additions & 0 deletions .changeset/tough-parks-fly.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes an issue where the experimental CSP `meta` element wasn't placed in the `<head>` element as early as possible, causing these policies to not apply to styles and scripts that came before the `meta` element.
1 change: 1 addition & 0 deletions packages/astro/src/core/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export type SSRManifestI18n = {
};

export type SSRManifestCSP = {
cspDestination: 'adapter' | 'meta' | 'header' | undefined;
algorithm: CspAlgorithm;
scriptHashes: string[];
scriptResources: string[];
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/core/base-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import type {
SSRLoadedRenderer,
SSRManifest,
SSRResult,
SSRActions,
} from '../types/public/internal.js';
import { createOriginCheckMiddleware } from './app/middlewares.js';
import type { SSRActions } from './app/types.js';
import { ActionNotFoundError } from './errors/errors-data.js';
import { AstroError } from './errors/index.js';
import type { Logger } from './logger/core.js';
Expand Down
30 changes: 24 additions & 6 deletions packages/astro/src/core/build/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
removeTrailingForwardSlash,
} from '../../core/path.js';
import { toFallbackType, toRoutingStrategy } from '../../i18n/utils.js';
import { runHookBuildGenerated } from '../../integrations/hooks.js';
import { runHookBuildGenerated, toIntegrationResolvedRoute } from '../../integrations/hooks.js';
import { getServerOutputDirectory } from '../../prerender/utils.js';
import type { AstroSettings, ComponentInstance } from '../../types/astro.js';
import type { GetStaticPathsItem, MiddlewareHandler } from '../../types/public/common.js';
Expand Down Expand Up @@ -62,6 +62,7 @@ import type {
StylesheetAsset,
} from './types.js';
import { getTimeStat, shouldAppendForwardSlash } from './util.js';
import type { IntegrationResolvedRoute } from '../../types/public/index.js';

export async function generatePages(options: StaticBuildOptions, internals: BuildInternals) {
const generatePagesTimer = performance.now();
Expand Down Expand Up @@ -102,6 +103,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>();
if (ssr) {
for (const [pageData, filePath] of pagesToGenerate) {
if (pageData.route.prerender) {
Expand All @@ -116,13 +118,13 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil
const ssrEntryPage = await pipeline.retrieveSsrEntry(pageData.route, filePath);

const ssrEntry = ssrEntryPage as SinglePageBuiltModule;
await generatePage(pageData, ssrEntry, builtPaths, pipeline);
await generatePage(pageData, ssrEntry, builtPaths, pipeline, routeToHeaders);
}
}
} else {
for (const [pageData, filePath] of pagesToGenerate) {
const entry = await pipeline.retrieveSsrEntry(pageData.route, filePath);
await generatePage(pageData, entry, builtPaths, pipeline);
await generatePage(pageData, entry, builtPaths, pipeline, routeToHeaders);
}
}
logger.info(
Expand Down Expand Up @@ -219,7 +221,11 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil
delete globalThis?.astroAsset?.addStaticImage;
}

await runHookBuildGenerated({ settings: options.settings, logger });
await runHookBuildGenerated({
settings: options.settings,
logger,
experimentalRouteToHeaders: routeToHeaders,
});
}

const THRESHOLD_SLOW_RENDER_TIME_MS = 500;
Expand All @@ -229,6 +235,7 @@ async function generatePage(
ssrEntry: SinglePageBuiltModule,
builtPaths: Set<string>,
pipeline: BuildPipeline,
routeToHeaders: Map<IntegrationResolvedRoute, Headers>,
) {
// prepare information we need
const { config, logger } = pipeline;
Expand Down Expand Up @@ -275,7 +282,7 @@ async function generatePage(
logger.info(null, ` ${blue(lineIcon)} ${dim(filePath)}`, false);
}

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

const timeEnd = performance.now();
const isSlow = timeEnd - timeStart > THRESHOLD_SLOW_RENDER_TIME_MS;
Expand Down Expand Up @@ -493,6 +500,7 @@ async function generatePath(
pipeline: BuildPipeline,
gopts: GeneratePathOptions,
route: RouteData,
routeToHeaders: Map<IntegrationResolvedRoute, Headers>,
): Promise<boolean | undefined> {
const { mod } = gopts;
const { config, logger, options } = pipeline;
Expand Down Expand Up @@ -562,6 +570,13 @@ async function generatePath(
throw err;
}

if (
pipeline.settings.adapter?.adapterFeatures?.experimentalStaticHeaders &&
pipeline.settings.config.experimental?.csp
) {
routeToHeaders.set(toIntegrationResolvedRoute(route), 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.
Expand Down Expand Up @@ -659,6 +674,9 @@ async function createBuildManifest(
];

csp = {
cspDestination: settings.adapter?.adapterFeatures?.experimentalStaticHeaders
? 'adapter'
: undefined,
styleHashes,
styleResources: getStyleResources(settings.config.experimental.csp),
scriptHashes,
Expand All @@ -681,7 +699,7 @@ async function createBuildManifest(
entryModules: Object.fromEntries(internals.entrySpecifierToBundleMap.entries()),
inlinedScripts: internals.inlinedScripts,
routes: [],
adapterName: '',
adapterName: settings.adapter?.name ?? '',
clientDirectives: settings.clientDirectives,
compressHTML: settings.config.compressHTML,
renderers,
Expand Down
3 changes: 3 additions & 0 deletions packages/astro/src/core/build/plugins/plugin-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,9 @@ async function buildManifest(
];

csp = {
cspDestination: settings.adapter?.adapterFeatures?.experimentalStaticHeaders
? 'adapter'
: undefined,
scriptHashes,
scriptResources: getScriptResources(settings.config.experimental.csp),
styleHashes,
Expand Down
7 changes: 1 addition & 6 deletions packages/astro/src/core/render-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,11 +454,6 @@ export class RenderContext {
},
} satisfies AstroGlobal['response'];

let cspDestination: 'meta' | 'header' = 'meta';
if (!routeData.prerender) {
cspDestination = 'header';
}

// Create the result object that will be passed into the renderPage function.
// This object starts here as an empty shell (not yet the result) but then
// calling the render() function will populate the object with scripts, styles, etc.
Expand Down Expand Up @@ -501,7 +496,7 @@ export class RenderContext {
extraScriptHashes: [],
propagators: new Set(),
},
cspDestination,
cspDestination: manifest.csp?.cspDestination ?? (routeData.prerender ? 'meta' : 'header'),
shouldInjectCspMetaTags: !!manifest.csp,
cspAlgorithm: manifest.csp?.algorithm ?? 'SHA-256',
// The following arrays must be cloned, otherwise they become mutable across routes.
Expand Down
13 changes: 0 additions & 13 deletions packages/astro/src/integrations/features-validation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { shouldTrackCspHashes } from '../core/csp/common.js';
import type { Logger } from '../core/logger/core.js';
import type { AstroSettings } from '../types/astro.js';
import type {
Expand Down Expand Up @@ -39,7 +38,6 @@ export function validateSupportedFeatures(
i18nDomains = AdapterFeatureStability.UNSUPPORTED,
envGetSecret = AdapterFeatureStability.UNSUPPORTED,
sharpImageService = AdapterFeatureStability.UNSUPPORTED,
cspHeader = AdapterFeatureStability.UNSUPPORTED,
} = featureMap;
const validationResult: ValidationResult = {};

Expand Down Expand Up @@ -95,17 +93,6 @@ export function validateSupportedFeatures(
() => settings.config?.image?.service?.entrypoint === 'astro/assets/services/sharp',
);

validationResult.cspHeader = validateSupportKind(
cspHeader,
adapterName,
logger,
'cspHeader',
() =>
settings?.config?.experimental?.csp
? shouldTrackCspHashes(settings.config.experimental.csp)
: false,
);

return validationResult;
}

Expand Down
6 changes: 4 additions & 2 deletions packages/astro/src/integrations/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -583,9 +583,11 @@ export async function runHookBuildSsr({
export async function runHookBuildGenerated({
settings,
logger,
experimentalRouteToHeaders,
}: {
settings: AstroSettings;
logger: Logger;
experimentalRouteToHeaders: Map<IntegrationResolvedRoute, Headers>;
}) {
const dir =
settings.buildOutput === 'server' ? settings.config.build.client : settings.config.outDir;
Expand All @@ -595,7 +597,7 @@ export async function runHookBuildGenerated({
integration,
hookName: 'astro:build:generated',
logger,
params: () => ({ dir }),
params: () => ({ dir, experimentalRouteToHeaders }),
});
}
}
Expand Down Expand Up @@ -679,7 +681,7 @@ export async function runHookRoutesResolved({
}
}

function toIntegrationResolvedRoute(route: RouteData): IntegrationResolvedRoute {
export function toIntegrationResolvedRoute(route: RouteData): IntegrationResolvedRoute {
return {
isPrerendered: route.prerender,
entrypoint: route.component,
Expand Down
30 changes: 15 additions & 15 deletions packages/astro/src/runtime/server/render/head.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ const uniqueElements = (item: any, index: number, all: any[]) => {

export function renderAllHeadContent(result: SSRResult) {
result._metadata.hasRenderedHead = true;
let content = '';
if (result.shouldInjectCspMetaTags && result.cspDestination === 'meta') {
content += renderElement(
'meta',
{
props: {
'http-equiv': 'content-security-policy',
content: renderCspContent(result),
},
children: '',
},
false,
);
}
const styles = Array.from(result.styles)
.filter(uniqueElements)
.map((style) =>
Expand Down Expand Up @@ -44,28 +58,14 @@ export function renderAllHeadContent(result: SSRResult) {
// consist of CSS modules which should naturally take precedence over CSS styles, so the
// order will still work. In prod, all CSS are stylesheet links.
// In the future, it may be better to have only an array of head elements to avoid these assumptions.
let content = styles.join('\n') + links.join('\n') + scripts.join('\n');
content += styles.join('\n') + links.join('\n') + scripts.join('\n');

if (result._metadata.extraHead.length > 0) {
for (const part of result._metadata.extraHead) {
content += part;
}
}

if (result.cspDestination === 'meta') {
content += renderElement(
'meta',
{
props: {
'http-equiv': 'content-security-policy',
content: renderCspContent(result),
},
children: '',
},
false,
);
}

return markHTMLString(content);
}

Expand Down
4 changes: 2 additions & 2 deletions packages/astro/src/runtime/server/render/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export async function renderPage(
['Content-Type', 'text/html'],
['Content-Length', bytes.byteLength.toString()],
]);
if (result.cspDestination === 'header') {
if (result.cspDestination === 'header' || result.cspDestination === 'adapter') {
headers.set('content-security-policy', renderCspContent(result));
}
return new Response(bytes, {
Expand Down Expand Up @@ -78,7 +78,7 @@ export async function renderPage(
// Create final response from body
const init = result.response;
const headers = new Headers(init.headers);
if (result.shouldInjectCspMetaTags && result.cspDestination === 'header') {
if (result.shouldInjectCspMetaTags && result.cspDestination === 'header' || result.cspDestination === 'adapter') {
headers.set('content-security-policy', renderCspContent(result));
}
// For non-streaming, convert string to byte array to calculate Content-Length
Expand Down
18 changes: 12 additions & 6 deletions packages/astro/src/types/public/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,17 @@ export interface AstroAdapterFeatures {
* Determine the type of build output the adapter is intended for. Defaults to `server`;
*/
buildOutput?: 'static' | 'server';

/**
* If supported by the adapter and enabled, Astro won't add any `<meta http-equiv>` tags
* in the static pages, instead it will return a mapping in the
* `astro:build:generated` hook, so adapters can consume them and add them inside
* their hosting headers configuration file.
*
* NOTE: the semantics and list of headers might change until the feature
* is out of experimental
*/
experimentalStaticHeaders?: boolean;
}

export interface AstroAdapter {
Expand Down Expand Up @@ -136,12 +147,6 @@ export type AstroAdapterFeatureMap = {
* The adapter supports image transformation using the built-in Sharp image service
*/
sharpImageService?: AdapterSupport;

/**
* The adapter is able to provide CSP hashes using the Response header `Content-Security-Policy`. Either via hosting configuration
* for static pages or at runtime using `Response` headers for dynamic pages.
*/
cspHeader?: AdapterSupport;
};

/**
Expand Down Expand Up @@ -233,6 +238,7 @@ export interface BaseIntegrationHooks {
'astro:build:generated': (options: {
dir: URL;
logger: AstroIntegrationLogger;
experimentalRouteToHeaders: Map<IntegrationResolvedRoute, Headers>;
}) => void | Promise<void>;
'astro:build:done': (options: {
pages: { pathname: string }[];
Expand Down
Loading
Loading