From 52a38665ace8298dfcc7c2a8df5ddbc87be5e0a7 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Tue, 22 Apr 2025 13:24:43 +0100 Subject: [PATCH 01/12] chore: build hashes of scripts (#13590) * chore: build hashes of scripts * chore: fix changes * chore: fix changes * chore: fix changes --- packages/astro/src/core/csp-hashes.js | 10 ++++++++++ scripts/cmd/prebuild.js | 19 ++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 packages/astro/src/core/csp-hashes.js diff --git a/packages/astro/src/core/csp-hashes.js b/packages/astro/src/core/csp-hashes.js new file mode 100644 index 000000000000..bdc5499f94d0 --- /dev/null +++ b/packages/astro/src/core/csp-hashes.js @@ -0,0 +1,10 @@ +// This file is code-generated, please don't change it manually +export default [ + "GI/D8grziRZwfj/Mqmn+dcgU/i8sylHSR/IfobqcUT4=", + "HDWxd14AUw8OvjrhhRRyyZFHCGnzxXGDrg59Qi8ayhc=", + "XN6a2Vn8uvpBr/WhdYPdK0jVeCzlcOD2XYaP10veV4Y=", + "ZR0ZAU8UNTzLmo/ApeWH0y1mVLT+XtFkvZ5nw32W8jI=", + "cSNmhdbFlyTDRozeu9HPjo+B2S4QAeMp0RO41PqgAcA=", + "mH3H4wSoDVWMXJKrmeBKYJQMdAZQ3dArB2N66JomkzI=", + "mH3H4wSoDVWMXJKrmeBKYJQMdAZQ3dArB2N66JomkzI=" +]; \ No newline at end of file diff --git a/scripts/cmd/prebuild.js b/scripts/cmd/prebuild.js index 1c62e813549e..e7bca657a3c6 100644 --- a/scripts/cmd/prebuild.js +++ b/scripts/cmd/prebuild.js @@ -4,6 +4,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import esbuild from 'esbuild'; import { red } from 'kleur/colors'; import { glob } from 'tinyglobby'; +import crypto from 'node:crypto'; function escapeTemplateLiterals(str) { return str.replace(/\`/g, '\\`').replace(/\$\{/g, '\\${'); @@ -40,6 +41,8 @@ export default async function prebuild(...args) { return outURL; } + const hashes = []; + async function prebuildFile(filepath) { let tscode = await fs.promises.readFile(filepath, 'utf-8'); // If we're bundling a client directive, modify the code to match `packages/astro/src/core/client-directive/build.ts`. @@ -99,17 +102,31 @@ export default async function prebuild(...args) { const code = result.build.outputFiles[0].text.trim(); const rootURL = new URL('../../', import.meta.url); const rel = path.relative(fileURLToPath(rootURL), filepath); + const generatedCode = escapeTemplateLiterals(code); const mod = `/** * This file is prebuilt from ${rel} * Do not edit this directly, but instead edit that file and rerun the prebuild * to generate this file. */ -export default \`${escapeTemplateLiterals(code)}\`;`; +export default \`${generatedCode}\`;`; const url = getPrebuildURL(filepath, result.dev); await fs.promises.writeFile(url, mod, 'utf-8'); + const hash = crypto.createHash('sha256').update(code).digest('base64'); + hashes.push(hash); } } await Promise.all(entryPoints.map(prebuildFile)); + hashes.sort(); + const entries = hashes.map((hash) => `"${hash}"`); + const content = `// This file is code-generated, please don't change it manually +export default [ + ${entries.join(',\n ')} +];`; + await fs.promises.writeFile( + path.join(fileURLToPath(import.meta.url), '../../../packages/astro/src/core', 'csp-hashes.js'), + content, + 'utf-8', + ); } From 51ae0c1a7d837caef23e30397f03ab17b932d473 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Wed, 23 Apr 2025 13:56:42 +0100 Subject: [PATCH 02/12] feat(csp): create hashes of tracked scripts and hashes (#13675) Co-authored-by: florian-lefebvre <69633530+florian-lefebvre@users.noreply.github.com> --- packages/astro/src/container/index.ts | 8 ++++ packages/astro/src/core/app/types.ts | 7 ++++ ...{csp-hashes.js => astro-islands-hashes.ts} | 5 ++- packages/astro/src/core/build/generate.ts | 19 +++++++-- .../src/core/build/plugins/plugin-manifest.ts | 14 +++++++ .../astro/src/core/config/schemas/base.ts | 2 + packages/astro/src/core/csp/common.ts | 40 ++++++++++++++++++ packages/astro/src/core/render-context.ts | 3 ++ packages/astro/src/integrations/hooks.ts | 16 +++---- .../src/runtime/server/astro-island-styles.ts | 2 + .../astro/src/runtime/server/render/common.ts | 1 - .../astro/src/runtime/server/render/head.ts | 21 ++++++++++ packages/astro/src/runtime/server/scripts.ts | 3 +- packages/astro/src/types/public/config.ts | 7 ++++ packages/astro/src/types/public/internal.ts | 6 +++ .../src/vite-plugin-astro-server/plugin.ts | 9 +++- packages/astro/test/csp.test.js | 42 +++++++++++++++++++ .../astro/test/fixtures/csp/astro.config.mjs | 8 ++++ packages/astro/test/fixtures/csp/package.json | 8 ++++ .../test/fixtures/csp/src/pages/index.astro | 16 +++++++ .../test/fixtures/csp/src/pages/index.css | 5 +++ packages/astro/test/test-adapter.js | 8 +++- scripts/cmd/prebuild.js | 20 +++++++-- 23 files changed, 248 insertions(+), 22 deletions(-) rename packages/astro/src/core/{csp-hashes.js => astro-islands-hashes.ts} (72%) create mode 100644 packages/astro/src/core/csp/common.ts create mode 100644 packages/astro/src/runtime/server/astro-island-styles.ts create mode 100644 packages/astro/test/csp.test.js create mode 100644 packages/astro/test/fixtures/csp/astro.config.mjs create mode 100644 packages/astro/test/fixtures/csp/package.json create mode 100644 packages/astro/test/fixtures/csp/src/pages/index.astro create mode 100644 packages/astro/test/fixtures/csp/src/pages/index.css diff --git a/packages/astro/src/container/index.ts b/packages/astro/src/container/index.ts index 5c92c47a05db..b570dd8531c5 100644 --- a/packages/astro/src/container/index.ts +++ b/packages/astro/src/container/index.ts @@ -161,6 +161,10 @@ function createManifest( checkOrigin: false, middleware: manifest?.middleware ?? middlewareInstance, key: createKey(), + clientScriptHashes: manifest?.clientScriptHashes ?? [], + clientStyleHashes: manifest?.clientStyleHashes ?? [], + shouldInjectCspMetaTags: manifest?.shouldInjectCspMetaTags ?? false, + astroIslandHashes: manifest?.astroIslandHashes ?? [], }; } @@ -246,6 +250,10 @@ type AstroContainerManifest = Pick< | 'publicDir' | 'outDir' | 'cacheDir' + | 'clientScriptHashes' + | 'clientStyleHashes' + | 'shouldInjectCspMetaTags' + | 'astroIslandHashes' >; type AstroContainerConstructor = { diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index 90e4be871f6d..6c60ea2dcd62 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -86,6 +86,13 @@ export type SSRManifest = { publicDir: string | URL; buildClientDir: string | URL; buildServerDir: string | URL; + clientScriptHashes: string[]; + clientStyleHashes: string[]; + /** + * When enabled, Astro tracks the hashes of script and styles, and eventually it will render the `` tag + */ + shouldInjectCspMetaTags: boolean; + astroIslandHashes: string[]; }; export type SSRActions = { diff --git a/packages/astro/src/core/csp-hashes.js b/packages/astro/src/core/astro-islands-hashes.ts similarity index 72% rename from packages/astro/src/core/csp-hashes.js rename to packages/astro/src/core/astro-islands-hashes.ts index bdc5499f94d0..9581e3b6456d 100644 --- a/packages/astro/src/core/csp-hashes.js +++ b/packages/astro/src/core/astro-islands-hashes.ts @@ -1,10 +1,11 @@ // This file is code-generated, please don't change it manually -export default [ +export const ASTRO_ISLAND_HASHES = [ "GI/D8grziRZwfj/Mqmn+dcgU/i8sylHSR/IfobqcUT4=", "HDWxd14AUw8OvjrhhRRyyZFHCGnzxXGDrg59Qi8ayhc=", "XN6a2Vn8uvpBr/WhdYPdK0jVeCzlcOD2XYaP10veV4Y=", "ZR0ZAU8UNTzLmo/ApeWH0y1mVLT+XtFkvZ5nw32W8jI=", "cSNmhdbFlyTDRozeu9HPjo+B2S4QAeMp0RO41PqgAcA=", "mH3H4wSoDVWMXJKrmeBKYJQMdAZQ3dArB2N66JomkzI=", - "mH3H4wSoDVWMXJKrmeBKYJQMdAZQ3dArB2N66JomkzI=" + "mH3H4wSoDVWMXJKrmeBKYJQMdAZQ3dArB2N66JomkzI=", + "s81ZcLcyAa7P/Jh5M5hUxYthTGwW+iZY3e6aHrQ8H9E=" ]; \ No newline at end of file diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 2313ca01a89b..f6170516b36a 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -9,7 +9,7 @@ import { getStaticImageList, prepareAssetsGenerationEnv, } from '../../assets/build/generate.js'; -import { type BuildInternals, hasPrerenderedPages } from '../../core/build/internal.js'; +import { type BuildInternals, hasPrerenderedPages } from './internal.js'; import { isRelativePath, joinPaths, @@ -50,6 +50,8 @@ import type { StylesheetAsset, } from './types.js'; import { getTimeStat, shouldAppendForwardSlash } from './util.js'; +import { shouldTrackCspHashes, trackScriptHashes, trackStyleHashes } from '../csp/common.js'; +import { ASTRO_ISLAND_HASHES } from '../astro-islands-hashes.js'; export async function generatePages(options: StaticBuildOptions, internals: BuildInternals) { const generatePagesTimer = performance.now(); @@ -601,8 +603,6 @@ function getPrettyRouteName(route: RouteData): string { * It creates a `SSRManifest` from the `AstroSettings`. * * Renderers needs to be pulled out from the page module emitted during the build. - * @param settings - * @param renderers */ function createBuildManifest( settings: AstroSettings, @@ -613,6 +613,15 @@ function createBuildManifest( key: Promise, ): SSRManifest { let i18nManifest: SSRManifestI18n | undefined = undefined; + + let clientStyleHashes: string[] = []; + let clientScriptHashes: string[] = []; + + if (shouldTrackCspHashes(settings.config)) { + clientScriptHashes = trackScriptHashes(internals, settings); + clientStyleHashes = trackStyleHashes(internals); + } + if (settings.config.i18n) { i18nManifest = { fallback: settings.config.i18n.fallback, @@ -656,5 +665,9 @@ function createBuildManifest( checkOrigin: (settings.config.security?.checkOrigin && settings.buildOutput === 'server') ?? false, key, + clientStyleHashes, + clientScriptHashes, + shouldInjectCspMetaTags: shouldTrackCspHashes(settings.config), + astroIslandHashes: ASTRO_ISLAND_HASHES, }; } diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index cd7f62a8238f..015e79ff20d0 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -23,6 +23,8 @@ import { type BuildInternals, cssOrder, mergeInlineCss } from '../internal.js'; import type { AstroBuildPlugin } from '../plugin.js'; import type { StaticBuildOptions } from '../types.js'; import { makePageDataKey } from './util.js'; +import { shouldTrackCspHashes, trackScriptHashes, trackStyleHashes } from '../../csp/common.js'; +import { ASTRO_ISLAND_HASHES } from '../../astro-islands-hashes.js'; const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@'; const replaceExp = new RegExp(`['"]${manifestReplace}['"]`, 'g'); @@ -275,6 +277,14 @@ function buildManifest( }; } + let clientScriptHashes: string[] = []; + let clientStyleHashes: string[] = []; + + if (shouldTrackCspHashes(settings.config)) { + clientScriptHashes = trackScriptHashes(internals, opts.settings); + clientStyleHashes = trackStyleHashes(internals); + } + return { hrefRoot: opts.settings.config.root.toString(), cacheDir: opts.settings.config.cacheDir.toString(), @@ -304,5 +314,9 @@ function buildManifest( serverIslandNameMap: Array.from(settings.serverIslandNameMap), key: encodedKey, sessionConfig: settings.config.session, + shouldInjectCspMetaTags: shouldTrackCspHashes(opts.settings.config), + clientStyleHashes, + clientScriptHashes, + astroIslandHashes: ASTRO_ISLAND_HASHES, }; } diff --git a/packages/astro/src/core/config/schemas/base.ts b/packages/astro/src/core/config/schemas/base.ts index f33b2f9e55c4..f9cbbf1d0350 100644 --- a/packages/astro/src/core/config/schemas/base.ts +++ b/packages/astro/src/core/config/schemas/base.ts @@ -99,6 +99,7 @@ export const ASTRO_CONFIG_DEFAULTS = { responsiveImages: false, headingIdCompat: false, preserveScriptOrder: false, + csp: false, }, } satisfies AstroUserConfig & { server: { open: boolean } }; @@ -473,6 +474,7 @@ export const AstroConfigSchema = z.object({ .optional() .default(ASTRO_CONFIG_DEFAULTS.experimental.preserveScriptOrder), fonts: z.array(z.union([localFontFamilySchema, remoteFontFamilySchema])).optional(), + csp: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.csp), }) .strict( `Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/experimental-flags/ for a list of all current experiments.`, diff --git a/packages/astro/src/core/csp/common.ts b/packages/astro/src/core/csp/common.ts new file mode 100644 index 000000000000..97d63c271ddf --- /dev/null +++ b/packages/astro/src/core/csp/common.ts @@ -0,0 +1,40 @@ +import type { AstroConfig } from '../../types/public/index.js'; +import type { BuildInternals } from '../build/internal.js'; +import crypto from 'node:crypto'; +import type { AstroSettings } from '../../types/astro.js'; + +export function shouldTrackCspHashes(config: AstroConfig): boolean { + return config.experimental?.csp === true; +} + +export function trackStyleHashes(internals: BuildInternals): string[] { + const clientStyleHashes: string[] = []; + for (const [_, page] of internals.pagesByViteID.entries()) { + for (const style of page.styles) { + if (style.sheet.type === 'inline') { + clientStyleHashes.push( + crypto.createHash('sha256').update(style.sheet.content).digest('base64'), + ); + } + } + } + + return clientStyleHashes; +} + +export function trackScriptHashes(internals: BuildInternals, settings: AstroSettings): string[] { + const clientScriptHashes: string[] = []; + + for (const script of internals.inlinedScripts.values()) { + clientScriptHashes.push(crypto.createHash('sha256').update(script).digest('base64')); + } + + for (const script of settings.scripts) { + const { content, stage } = script; + if (stage === 'head-inline' || stage === 'before-hydration') { + clientScriptHashes.push(crypto.createHash('sha256').update(content).digest('base64')); + } + } + + return clientScriptHashes; +} diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index 5a7c6a188239..b877b4c859fe 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -462,6 +462,9 @@ export class RenderContext { extraHead: [], propagators: new Set(), }, + shouldInjectCspMetaTags: manifest.shouldInjectCspMetaTags, + clientScriptHashes: manifest.clientScriptHashes, + clientStyleHashes: manifest.clientStyleHashes, }; return result; diff --git a/packages/astro/src/integrations/hooks.ts b/packages/astro/src/integrations/hooks.ts index bf782c7080e6..eb9c797cb2b0 100644 --- a/packages/astro/src/integrations/hooks.ts +++ b/packages/astro/src/integrations/hooks.ts @@ -163,19 +163,21 @@ export function normalizeInjectedTypeFilename(filename: string, integrationName: return `${normalizeCodegenDir(integrationName)}${filename.replace(SAFE_CHARS_RE, '_')}`; } +interface RunHookConfigSetup { + settings: AstroSettings; + command: 'dev' | 'build' | 'preview' | 'sync'; + logger: Logger; + isRestart?: boolean; + fs?: typeof fsMod; +} + export async function runHookConfigSetup({ settings, command, logger, isRestart = false, fs = fsMod, -}: { - settings: AstroSettings; - command: 'dev' | 'build' | 'preview' | 'sync'; - logger: Logger; - isRestart?: boolean; - fs?: typeof fsMod; -}): Promise { +}: RunHookConfigSetup): Promise { // An adapter is an integration, so if one is provided add it to the list of integrations. if (settings.config.adapter) { settings.config.integrations.unshift(settings.config.adapter); diff --git a/packages/astro/src/runtime/server/astro-island-styles.ts b/packages/astro/src/runtime/server/astro-island-styles.ts new file mode 100644 index 000000000000..ca816c9204b9 --- /dev/null +++ b/packages/astro/src/runtime/server/astro-island-styles.ts @@ -0,0 +1,2 @@ +export const ISLAND_STYLES = + ''; diff --git a/packages/astro/src/runtime/server/render/common.ts b/packages/astro/src/runtime/server/render/common.ts index 8611a7b0066d..90d0c1e5756a 100644 --- a/packages/astro/src/runtime/server/render/common.ts +++ b/packages/astro/src/runtime/server/render/common.ts @@ -1,5 +1,4 @@ import type { RenderInstruction } from './instruction.js'; - import type { SSRResult } from '../../../types/public/internal.js'; import type { HTMLBytes, HTMLString } from '../escape.js'; import { markHTMLString } from '../escape.js'; diff --git a/packages/astro/src/runtime/server/render/head.ts b/packages/astro/src/runtime/server/render/head.ts index 79edc9621898..bc7a70f9b965 100644 --- a/packages/astro/src/runtime/server/render/head.ts +++ b/packages/astro/src/runtime/server/render/head.ts @@ -51,6 +51,27 @@ export function renderAllHeadContent(result: SSRResult) { } } + const hashes = []; + + if (result.shouldInjectCspMetaTags) { + for (const scriptHash of [...result.clientScriptHashes, ...result.clientStyleHashes]) { + hashes.push( + renderElement( + 'meta', + { + props: { + 'http-equiv': 'content-security-policy', + content: scriptHash, + }, + children: '', + }, + false, + ), + ); + } + } + content += hashes.join('\n'); + return markHTMLString(content); } diff --git a/packages/astro/src/runtime/server/scripts.ts b/packages/astro/src/runtime/server/scripts.ts index ca9cad1fb186..4756a0adb510 100644 --- a/packages/astro/src/runtime/server/scripts.ts +++ b/packages/astro/src/runtime/server/scripts.ts @@ -1,8 +1,7 @@ import type { SSRResult } from '../../types/public/internal.js'; import islandScriptDev from './astro-island.prebuilt-dev.js'; import islandScript from './astro-island.prebuilt.js'; - -const ISLAND_STYLES = ``; +import { ISLAND_STYLES } from './astro-island-styles.js'; export function determineIfNeedsHydrationScript(result: SSRResult): boolean { if (result._metadata.hasHydrationScript) { diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index bf4b9778ed8b..39178cf7ae8c 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -2229,6 +2229,13 @@ export interface ViteUserConfig extends OriginalViteUserConfig { */ headingIdCompat?: boolean; + + /** + * + */ + // TODO: add docs once we are reaching the end + csp?: boolean, + /** * @name experimental.preserveScriptOrder * @type {boolean} diff --git a/packages/astro/src/types/public/internal.ts b/packages/astro/src/types/public/internal.ts index a9af47f8e125..cb29517a2970 100644 --- a/packages/astro/src/types/public/internal.ts +++ b/packages/astro/src/types/public/internal.ts @@ -246,6 +246,12 @@ export interface SSRResult { trailingSlash: AstroConfig['trailingSlash']; key: Promise; _metadata: SSRMetadata; + /** + * Whether Astro should inject the CSP tag into the head of the component. + */ + shouldInjectCspMetaTags: boolean; + clientScriptHashes: string[]; + clientStyleHashes: string[]; } /** diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts index 3e64b560a87f..e43e45dd71af 100644 --- a/packages/astro/src/vite-plugin-astro-server/plugin.ts +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -25,6 +25,8 @@ import { DevPipeline } from './pipeline.js'; import { handleRequest } from './request.js'; import { setRouteError } from './server-state.js'; import { trailingSlashMiddleware } from './trailing-slash.js'; +import { ASTRO_ISLAND_HASHES } from '../core/astro-islands-hashes.js'; +import { shouldTrackCspHashes } from '../core/csp/common.js'; interface AstroPluginOptions { settings: AstroSettings; @@ -100,8 +102,7 @@ export default function createVitePluginAstroServer({ }); const store = localStorage.getStore(); if (store instanceof IncomingMessage) { - const request = store; - setRouteError(controller.state, request.url!, error); + setRouteError(controller.state, store.url!, error); } const { errorWithMetadata } = recordServerError(loader, settings.config, pipeline, error); setTimeout( @@ -207,5 +208,9 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest }; }, sessionConfig: settings.config.session, + clientScriptHashes: [], + clientStyleHashes: [], + shouldInjectCspMetaTags: shouldTrackCspHashes(settings.config), + astroIslandHashes: ASTRO_ISLAND_HASHES, }; } diff --git a/packages/astro/test/csp.test.js b/packages/astro/test/csp.test.js new file mode 100644 index 000000000000..23ae039a305d --- /dev/null +++ b/packages/astro/test/csp.test.js @@ -0,0 +1,42 @@ +import { before, describe, it } from 'node:test'; +import { loadFixture } from './test-utils.js'; +import testAdapter from './test-adapter.js'; +import assert from 'node:assert/strict'; +import * as cheerio from 'cheerio'; + +describe('CSP', () => { + let app; + /** + * @type {import('../dist/core/build/types.js').SSGManifest} + */ + let manifest; + /** @type {import('./test-utils.js').Fixture} */ + let fixture; + before(async () => { + fixture = await loadFixture({ + root: './fixtures/csp/', + adapter: testAdapter({ + setManifest(_manifest) { + manifest = _manifest; + }, + }), + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + }); + + it('should contain the meta style hashes when CSS is imported from Astro component', async () => { + if (manifest) { + const request = new Request('http://example.com/index.html'); + const response = await app.render(request); + const $ = cheerio.load(await response.text()); + + for (const hash of manifest.clientStyleHashes) { + let meta = $('meta[http-equiv="Content-Security-Policy"][content="' + hash + '"]'); + assert.equal(meta.length, 1, `Should have a CSP meta tag for ${hash}`); + } + } else { + assert.fail('Should have the manifest'); + } + }); +}); diff --git a/packages/astro/test/fixtures/csp/astro.config.mjs b/packages/astro/test/fixtures/csp/astro.config.mjs new file mode 100644 index 000000000000..7184336cde47 --- /dev/null +++ b/packages/astro/test/fixtures/csp/astro.config.mjs @@ -0,0 +1,8 @@ +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + experimental: { + csp: true, + } +}); + diff --git a/packages/astro/test/fixtures/csp/package.json b/packages/astro/test/fixtures/csp/package.json new file mode 100644 index 000000000000..60390ccd8f87 --- /dev/null +++ b/packages/astro/test/fixtures/csp/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/csp", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/csp/src/pages/index.astro b/packages/astro/test/fixtures/csp/src/pages/index.astro new file mode 100644 index 000000000000..e54b6c325b75 --- /dev/null +++ b/packages/astro/test/fixtures/csp/src/pages/index.astro @@ -0,0 +1,16 @@ +--- +import "./index.css" +--- + + + + + + Index + + +
+

Index

+
+ + diff --git a/packages/astro/test/fixtures/csp/src/pages/index.css b/packages/astro/test/fixtures/csp/src/pages/index.css new file mode 100644 index 000000000000..3496bc852199 --- /dev/null +++ b/packages/astro/test/fixtures/csp/src/pages/index.css @@ -0,0 +1,5 @@ +.content { + display: flex; + background: red; + border: 1px solid blue; +} diff --git a/packages/astro/test/test-adapter.js b/packages/astro/test/test-adapter.js index 15134f2306d5..3883647977d8 100644 --- a/packages/astro/test/test-adapter.js +++ b/packages/astro/test/test-adapter.js @@ -26,6 +26,7 @@ export default function ({ setEntryPoints, setMiddlewareEntryPoint, setRoutes, + setManifest, env, } = {}) { return { @@ -109,7 +110,7 @@ export default function ({ exports: ['manifest', 'createApp'], supportedAstroFeatures: { serverOutput: 'stable', - envGetSecret: 'experimental', + envGetSecret: 'stable', staticOutput: 'stable', hybridOutput: 'stable', assets: 'stable', @@ -121,13 +122,16 @@ export default function ({ ...extendAdapter, }); }, - 'astro:build:ssr': ({ entryPoints, middlewareEntryPoint }) => { + 'astro:build:ssr': ({ entryPoints, middlewareEntryPoint, manifest }) => { if (setEntryPoints) { setEntryPoints(entryPoints); } if (setMiddlewareEntryPoint) { setMiddlewareEntryPoint(middlewareEntryPoint); } + if (setManifest) { + setManifest(manifest); + } }, 'astro:build:done': ({ routes }) => { if (setRoutes) { diff --git a/scripts/cmd/prebuild.js b/scripts/cmd/prebuild.js index e7bca657a3c6..1ee505500308 100644 --- a/scripts/cmd/prebuild.js +++ b/scripts/cmd/prebuild.js @@ -10,6 +10,8 @@ function escapeTemplateLiterals(str) { return str.replace(/\`/g, '\\`').replace(/\$\{/g, '\\${'); } +const ASTRO_ISLAND_STYLE_REGEX = /'([^']*)'/; + export default async function prebuild(...args) { let buildToString = args.indexOf('--to-string'); if (buildToString !== -1) { @@ -116,16 +118,28 @@ export default \`${generatedCode}\`;`; hashes.push(hash); } } + for (const entrypoint of entryPoints) { + await prebuildFile(entrypoint); + } - await Promise.all(entryPoints.map(prebuildFile)); + const fileContent = await fs.promises.readFile( + new URL('../../packages/astro/src/runtime/server/astro-island-styles.ts', import.meta.url), + 'utf-8', + ); + const styleContent = fileContent.match(ASTRO_ISLAND_STYLE_REGEX)[1]; + hashes.push(crypto.createHash('sha256').update(styleContent).digest('base64')); hashes.sort(); const entries = hashes.map((hash) => `"${hash}"`); const content = `// This file is code-generated, please don't change it manually -export default [ +export const ASTRO_ISLAND_HASHES = [ ${entries.join(',\n ')} ];`; await fs.promises.writeFile( - path.join(fileURLToPath(import.meta.url), '../../../packages/astro/src/core', 'csp-hashes.js'), + path.join( + fileURLToPath(import.meta.url), + '../../../packages/astro/src/core', + 'astro-islands-hashes.ts', + ), content, 'utf-8', ); From d2077eafbee20e2c0ca7b73f1f32c8cf23d6acf5 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Wed, 23 Apr 2025 14:09:39 +0100 Subject: [PATCH 03/12] rebase --- pnpm-lock.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31583e1c76ec..a63ebed7a8d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2787,6 +2787,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/csp: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/csrf-check-origin: dependencies: astro: From d2482b3b4c8d4a2aca3c4bdcc98359d72dc873a0 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Mon, 28 Apr 2025 13:06:04 +0100 Subject: [PATCH 04/12] feat(csp): fix CSP header, inject astro island script/style (#13687) --- packages/astro/src/container/index.ts | 2 +- packages/astro/src/core/app/types.ts | 2 +- .../astro/src/core/astro-islands-hashes.ts | 19 ++++---- packages/astro/src/core/base-pipeline.ts | 12 +++-- packages/astro/src/core/csp/common.ts | 12 +++-- packages/astro/src/core/csp/middleware.ts | 11 +++++ packages/astro/src/core/render-context.ts | 1 + .../astro/src/runtime/server/render/common.ts | 13 ++--- .../astro/src/runtime/server/render/csp.ts | 28 +++++++++++ .../astro/src/runtime/server/render/head.ts | 30 +++++------- packages/astro/src/runtime/server/scripts.ts | 9 ++-- packages/astro/src/types/public/config.ts | 5 +- packages/astro/src/types/public/internal.ts | 6 ++- packages/astro/test/csp.test.js | 26 +++++++++- .../astro/test/fixtures/csp/astro.config.mjs | 6 ++- packages/astro/test/fixtures/csp/package.json | 5 +- .../test/fixtures/csp/src/components/Text.jsx | 5 ++ .../test/fixtures/csp/src/pages/react.astro | 18 +++++++ pnpm-lock.yaml | 48 +++++++++++++++---- scripts/cmd/prebuild.js | 18 +++---- 20 files changed, 198 insertions(+), 78 deletions(-) create mode 100644 packages/astro/src/core/csp/middleware.ts create mode 100644 packages/astro/src/runtime/server/render/csp.ts create mode 100644 packages/astro/test/fixtures/csp/src/components/Text.jsx create mode 100644 packages/astro/test/fixtures/csp/src/pages/react.astro diff --git a/packages/astro/src/container/index.ts b/packages/astro/src/container/index.ts index b570dd8531c5..acacdf22b8d3 100644 --- a/packages/astro/src/container/index.ts +++ b/packages/astro/src/container/index.ts @@ -164,7 +164,7 @@ function createManifest( clientScriptHashes: manifest?.clientScriptHashes ?? [], clientStyleHashes: manifest?.clientStyleHashes ?? [], shouldInjectCspMetaTags: manifest?.shouldInjectCspMetaTags ?? false, - astroIslandHashes: manifest?.astroIslandHashes ?? [], + astroIslandHashes: manifest?.astroIslandHashes ?? {}, }; } diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index 6c60ea2dcd62..62211ea1f937 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -92,7 +92,7 @@ export type SSRManifest = { * When enabled, Astro tracks the hashes of script and styles, and eventually it will render the `` tag */ shouldInjectCspMetaTags: boolean; - astroIslandHashes: string[]; + astroIslandHashes: Record; }; export type SSRActions = { diff --git a/packages/astro/src/core/astro-islands-hashes.ts b/packages/astro/src/core/astro-islands-hashes.ts index 9581e3b6456d..56f4ad6fbffe 100644 --- a/packages/astro/src/core/astro-islands-hashes.ts +++ b/packages/astro/src/core/astro-islands-hashes.ts @@ -1,11 +1,10 @@ // This file is code-generated, please don't change it manually -export const ASTRO_ISLAND_HASHES = [ - "GI/D8grziRZwfj/Mqmn+dcgU/i8sylHSR/IfobqcUT4=", - "HDWxd14AUw8OvjrhhRRyyZFHCGnzxXGDrg59Qi8ayhc=", - "XN6a2Vn8uvpBr/WhdYPdK0jVeCzlcOD2XYaP10veV4Y=", - "ZR0ZAU8UNTzLmo/ApeWH0y1mVLT+XtFkvZ5nw32W8jI=", - "cSNmhdbFlyTDRozeu9HPjo+B2S4QAeMp0RO41PqgAcA=", - "mH3H4wSoDVWMXJKrmeBKYJQMdAZQ3dArB2N66JomkzI=", - "mH3H4wSoDVWMXJKrmeBKYJQMdAZQ3dArB2N66JomkzI=", - "s81ZcLcyAa7P/Jh5M5hUxYthTGwW+iZY3e6aHrQ8H9E=" -]; \ No newline at end of file +export const ASTRO_ISLAND_HASHES = { + "astro-island": "p9VbHs/ClkQc+x63XdUjvCAgeWxA4ZGvpebJtMn9jbs=", + "idle": "BF0290pkb3jxQsE7z00xR8Imp8X34FLC88L0lkMnrGw=", + "load": "QzWFZi+FLIx23tnm9SBU4aEgx4x8DsuASP07mfqol/c=", + "media": "0chmwFk0zaA528yFfGV7J9ppIpdfTPPULncDF3WG7Zs=", + "only": "eIXWvAmxkr251LJZkjniEK5LcPF3NkapbJepohwYRIc=", + "visible": "Q2BPg90ZMplYY+FSdApNErhpWafg2hcRRbndmvxuL/Q=", + "astro-island-styles": "s81ZcLcyAa7P/Jh5M5hUxYthTGwW+iZY3e6aHrQ8H9E=" +}; \ No newline at end of file diff --git a/packages/astro/src/core/base-pipeline.ts b/packages/astro/src/core/base-pipeline.ts index bbaf7da6cda3..7e3174a48c46 100644 --- a/packages/astro/src/core/base-pipeline.ts +++ b/packages/astro/src/core/base-pipeline.ts @@ -20,6 +20,7 @@ import { NOOP_MIDDLEWARE_FN } from './middleware/noop-middleware.js'; import { sequence } from './middleware/sequence.js'; import { RouteCache } from './render/route-cache.js'; import { createDefaultRoutes } from './routing/default.js'; +import { createCSPMiddleware } from './csp/middleware.js'; /** * The `Pipeline` represents the static parts of rendering that do not change between requests. @@ -112,11 +113,16 @@ export abstract class Pipeline { else if (this.middleware) { const middlewareInstance = await this.middleware(); const onRequest = middlewareInstance.onRequest ?? NOOP_MIDDLEWARE_FN; + const internalMiddlewares = [onRequest]; if (this.manifest.checkOrigin) { - this.resolvedMiddleware = sequence(createOriginCheckMiddleware(), onRequest); - } else { - this.resolvedMiddleware = onRequest; + // this middleware must be placed at the beginning because it needs to block incoming requests + internalMiddlewares.unshift(createOriginCheckMiddleware()); } + if (this.manifest.shouldInjectCspMetaTags) { + // this middleware must be placed at the end because it needs to inject the CSP headers + internalMiddlewares.push(createCSPMiddleware()); + } + this.resolvedMiddleware = sequence(...internalMiddlewares); return this.resolvedMiddleware; } else { this.resolvedMiddleware = NOOP_MIDDLEWARE_FN; diff --git a/packages/astro/src/core/csp/common.ts b/packages/astro/src/core/csp/common.ts index 97d63c271ddf..74c7c2f67652 100644 --- a/packages/astro/src/core/csp/common.ts +++ b/packages/astro/src/core/csp/common.ts @@ -12,9 +12,7 @@ export function trackStyleHashes(internals: BuildInternals): string[] { for (const [_, page] of internals.pagesByViteID.entries()) { for (const style of page.styles) { if (style.sheet.type === 'inline') { - clientStyleHashes.push( - crypto.createHash('sha256').update(style.sheet.content).digest('base64'), - ); + clientStyleHashes.push(generateHash(style.sheet.content)); } } } @@ -26,15 +24,19 @@ export function trackScriptHashes(internals: BuildInternals, settings: AstroSett const clientScriptHashes: string[] = []; for (const script of internals.inlinedScripts.values()) { - clientScriptHashes.push(crypto.createHash('sha256').update(script).digest('base64')); + clientScriptHashes.push(generateHash(script)); } for (const script of settings.scripts) { const { content, stage } = script; if (stage === 'head-inline' || stage === 'before-hydration') { - clientScriptHashes.push(crypto.createHash('sha256').update(content).digest('base64')); + clientScriptHashes.push(generateHash(content)); } } return clientScriptHashes; } + +function generateHash(content: string): string { + return crypto.createHash('sha256').update(content).digest('base64'); +} diff --git a/packages/astro/src/core/csp/middleware.ts b/packages/astro/src/core/csp/middleware.ts new file mode 100644 index 000000000000..f05a8443b75f --- /dev/null +++ b/packages/astro/src/core/csp/middleware.ts @@ -0,0 +1,11 @@ +import type { MiddlewareHandler } from '../../types/public/index.js'; + +export function createCSPMiddleware(): MiddlewareHandler { + return async (_, next) => { + const response = await next(); + + // Do something with the response + + return response; + }; +} diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index b877b4c859fe..81610f42635a 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -465,6 +465,7 @@ export class RenderContext { shouldInjectCspMetaTags: manifest.shouldInjectCspMetaTags, clientScriptHashes: manifest.clientScriptHashes, clientStyleHashes: manifest.clientStyleHashes, + astroIslandHashes: manifest.astroIslandHashes, }; return result; diff --git a/packages/astro/src/runtime/server/render/common.ts b/packages/astro/src/runtime/server/render/common.ts index 90d0c1e5756a..de70c6127e82 100644 --- a/packages/astro/src/runtime/server/render/common.ts +++ b/packages/astro/src/runtime/server/render/common.ts @@ -3,7 +3,6 @@ import type { SSRResult } from '../../../types/public/internal.js'; import type { HTMLBytes, HTMLString } from '../escape.js'; import { markHTMLString } from '../escape.js'; import { - type PrescriptType, determineIfNeedsHydrationScript, determinesIfNeedsDirectiveScript, getPrescripts, @@ -65,13 +64,11 @@ function stringifyChunk( let needsDirectiveScript = hydration && determinesIfNeedsDirectiveScript(result, hydration.directive); - let prescriptType: PrescriptType = needsHydrationScript - ? 'both' - : needsDirectiveScript - ? 'directive' - : null; - if (prescriptType) { - let prescripts = getPrescripts(result, prescriptType, hydration.directive); + if (needsHydrationScript) { + let prescripts = getPrescripts(result, 'both', hydration.directive); + return markHTMLString(prescripts); + } else if (needsDirectiveScript) { + let prescripts = getPrescripts(result, 'directive', hydration.directive); return markHTMLString(prescripts); } else { return ''; diff --git a/packages/astro/src/runtime/server/render/csp.ts b/packages/astro/src/runtime/server/render/csp.ts new file mode 100644 index 000000000000..143bf0746b9a --- /dev/null +++ b/packages/astro/src/runtime/server/render/csp.ts @@ -0,0 +1,28 @@ +import type { SSRResult } from '../../../types/public/index.js'; + +export function renderCspContent(result: SSRResult): string { + const finalScriptHashes = new Set(); + const finalStyleHashes = new Set(); + + for (const scriptHash of result.clientScriptHashes) { + finalScriptHashes.add(`'sha256-${scriptHash}'`); + } + + for (const styleHash of result.clientStyleHashes) { + finalStyleHashes.add(`'sha256-${styleHash}'`); + } + + if (result.renderers.length > 0) { + for (const [ name, hash ] of Object.entries(result.astroIslandHashes)) { + if (name === 'astro-island-styles') { + finalStyleHashes.add(`'sha256-${hash}'`); + } else { + finalScriptHashes.add(`'sha256-${hash}'`); + } + } + } + + const scriptSrc = `style-src 'self' ${Array.from(finalStyleHashes).join(' ')};`; + const styleSrc = `script-src 'self' ${Array.from(finalScriptHashes).join(' ')};`; + return `${scriptSrc} ${styleSrc}`; +} diff --git a/packages/astro/src/runtime/server/render/head.ts b/packages/astro/src/runtime/server/render/head.ts index bc7a70f9b965..7989cb7acb5d 100644 --- a/packages/astro/src/runtime/server/render/head.ts +++ b/packages/astro/src/runtime/server/render/head.ts @@ -3,6 +3,7 @@ import { markHTMLString } from '../escape.js'; import type { MaybeRenderHeadInstruction, RenderHeadInstruction } from './instruction.js'; import { createRenderInstruction } from './instruction.js'; import { renderElement } from './util.js'; +import { renderCspContent } from './csp.js'; // Filter out duplicate elements in our set const uniqueElements = (item: any, index: number, all: any[]) => { @@ -51,26 +52,19 @@ export function renderAllHeadContent(result: SSRResult) { } } - const hashes = []; - if (result.shouldInjectCspMetaTags) { - for (const scriptHash of [...result.clientScriptHashes, ...result.clientStyleHashes]) { - hashes.push( - renderElement( - 'meta', - { - props: { - 'http-equiv': 'content-security-policy', - content: scriptHash, - }, - children: '', - }, - false, - ), - ); - } + content += renderElement( + 'meta', + { + props: { + 'http-equiv': 'content-security-policy', + content: renderCspContent(result), + }, + children: '', + }, + false, + ); } - content += hashes.join('\n'); return markHTMLString(content); } diff --git a/packages/astro/src/runtime/server/scripts.ts b/packages/astro/src/runtime/server/scripts.ts index 4756a0adb510..e77947de9168 100644 --- a/packages/astro/src/runtime/server/scripts.ts +++ b/packages/astro/src/runtime/server/scripts.ts @@ -18,7 +18,7 @@ export function determinesIfNeedsDirectiveScript(result: SSRResult, directive: s return true; } -export type PrescriptType = null | 'both' | 'directive'; +export type PrescriptType = 'both' | 'directive'; function getDirectiveScriptText(result: SSRResult, directive: string): string { const clientDirectives = result.clientDirectives; @@ -31,8 +31,8 @@ function getDirectiveScriptText(result: SSRResult, directive: string): string { export function getPrescripts(result: SSRResult, type: PrescriptType, directive: string): string { // Note that this is a classic script, not a module script. - // This is so that it executes immediate, and when the browser encounters - // an astro-island element the callbacks will fire immediately, causing the JS + // This is so that it executes immediately, and when the browser encounters + // an astro-island element, the callbacks will fire immediately, causing the JS // deps to be loaded immediately. switch (type) { case 'both': @@ -41,8 +41,5 @@ export function getPrescripts(result: SSRResult, type: PrescriptType, directive: }`; case 'directive': return ``; - case null: - break; } - return ''; } diff --git a/packages/astro/src/types/public/config.ts b/packages/astro/src/types/public/config.ts index 39178cf7ae8c..c8393f577906 100644 --- a/packages/astro/src/types/public/config.ts +++ b/packages/astro/src/types/public/config.ts @@ -2229,12 +2229,11 @@ export interface ViteUserConfig extends OriginalViteUserConfig { */ headingIdCompat?: boolean; - /** - * + * */ // TODO: add docs once we are reaching the end - csp?: boolean, + csp?: boolean; /** * @name experimental.preserveScriptOrder diff --git a/packages/astro/src/types/public/internal.ts b/packages/astro/src/types/public/internal.ts index cb29517a2970..50d202389a5d 100644 --- a/packages/astro/src/types/public/internal.ts +++ b/packages/astro/src/types/public/internal.ts @@ -7,6 +7,7 @@ import type { Params } from './common.js'; import type { AstroConfig, RedirectConfig } from './config.js'; import type { AstroGlobal, AstroGlobalPartial } from './context.js'; import type { AstroRenderer } from './integrations.js'; +import type { SSRManifest } from '../../core/app/types.js'; export type { SSRManifest } from '../../core/app/types.js'; @@ -250,8 +251,9 @@ export interface SSRResult { * Whether Astro should inject the CSP tag into the head of the component. */ shouldInjectCspMetaTags: boolean; - clientScriptHashes: string[]; - clientStyleHashes: string[]; + clientScriptHashes: SSRManifest['clientScriptHashes']; + clientStyleHashes: SSRManifest['clientStyleHashes']; + astroIslandHashes: SSRManifest['astroIslandHashes']; } /** diff --git a/packages/astro/test/csp.test.js b/packages/astro/test/csp.test.js index 23ae039a305d..fc5703c0a016 100644 --- a/packages/astro/test/csp.test.js +++ b/packages/astro/test/csp.test.js @@ -31,10 +31,32 @@ describe('CSP', () => { const response = await app.render(request); const $ = cheerio.load(await response.text()); + const meta = $('meta[http-equiv="Content-Security-Policy"]'); for (const hash of manifest.clientStyleHashes) { - let meta = $('meta[http-equiv="Content-Security-Policy"][content="' + hash + '"]'); - assert.equal(meta.length, 1, `Should have a CSP meta tag for ${hash}`); + assert.match( + meta.attr('content'), + new RegExp(`'sha256-${hash}'`), + `Should have a CSP meta tag for ${hash}`, + ); } + + let [, astroStyleHash] = Object.entries(manifest.astroIslandHashes).find( + ([name, _]) => name === 'astro-island-styles', + ); + astroStyleHash = `sha256-${astroStyleHash}`; + + let [, astroIsland] = Object.entries(manifest.astroIslandHashes).find(([name, _]) => name === 'astro-island'); + astroIsland = `sha256-${astroIsland}`; + + assert.ok( + meta.attr('content').includes(astroStyleHash), + `Should have a CSP meta tag for ${astroStyleHash}`, + ); + + assert.ok( + meta.attr('content').includes(astroIsland), + `Should have a CSP meta tag for ${astroIsland}`, + ); } else { assert.fail('Should have the manifest'); } diff --git a/packages/astro/test/fixtures/csp/astro.config.mjs b/packages/astro/test/fixtures/csp/astro.config.mjs index 7184336cde47..9ea073c7d5f6 100644 --- a/packages/astro/test/fixtures/csp/astro.config.mjs +++ b/packages/astro/test/fixtures/csp/astro.config.mjs @@ -1,8 +1,12 @@ import { defineConfig } from 'astro/config'; +import react from '@astrojs/react'; export default defineConfig({ experimental: { csp: true, - } + }, + integrations: [ + react() + ], }); diff --git a/packages/astro/test/fixtures/csp/package.json b/packages/astro/test/fixtures/csp/package.json index 60390ccd8f87..4965f2c6fe5d 100644 --- a/packages/astro/test/fixtures/csp/package.json +++ b/packages/astro/test/fixtures/csp/package.json @@ -3,6 +3,9 @@ "version": "0.0.0", "private": true, "dependencies": { - "astro": "workspace:*" + "astro": "workspace:*", + "@astrojs/react": "workspace:*", + "react": "^19.1.0", + "react-dom": "^19.1.0" } } diff --git a/packages/astro/test/fixtures/csp/src/components/Text.jsx b/packages/astro/test/fixtures/csp/src/components/Text.jsx new file mode 100644 index 000000000000..5317786a7d94 --- /dev/null +++ b/packages/astro/test/fixtures/csp/src/components/Text.jsx @@ -0,0 +1,5 @@ + + +export function Text() { + return "Text" +} diff --git a/packages/astro/test/fixtures/csp/src/pages/react.astro b/packages/astro/test/fixtures/csp/src/pages/react.astro new file mode 100644 index 000000000000..934af5df38f6 --- /dev/null +++ b/packages/astro/test/fixtures/csp/src/pages/react.astro @@ -0,0 +1,18 @@ +--- +import {Text} from "../components/Text.jsx" +--- + + + + + + + Index + + +
+

React

+ +
+ + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a63ebed7a8d1..13f5bf53479e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2789,9 +2789,18 @@ importers: packages/astro/test/fixtures/csp: dependencies: + '@astrojs/react': + specifier: workspace:* + version: link:../../../../integrations/react astro: specifier: workspace:* version: link:../../.. + react: + specifier: ^19.1.0 + version: 19.1.0 + react-dom: + specifier: ^19.1.0 + version: 19.1.0(react@19.1.0) packages/astro/test/fixtures/csrf-check-origin: dependencies: @@ -4322,7 +4331,7 @@ importers: version: 1.0.2 drizzle-orm: specifier: ^0.31.2 - version: 0.31.4(@cloudflare/workers-types@4.20250327.0)(@libsql/client@0.15.2)(@types/react@18.3.20)(react@19.0.0) + version: 0.31.4(@cloudflare/workers-types@4.20250327.0)(@libsql/client@0.15.2)(@types/react@18.3.20)(react@19.1.0) github-slugger: specifier: ^2.0.0 version: 2.0.0 @@ -4774,7 +4783,7 @@ importers: version: link:../../astro-prism '@markdoc/markdoc': specifier: ^0.5.1 - version: 0.5.1(@types/react@18.3.20)(react@19.0.0) + version: 0.5.1(@types/react@18.3.20)(react@19.1.0) esbuild: specifier: ^0.25.0 version: 0.25.0 @@ -5688,7 +5697,7 @@ importers: version: link:../../internal-helpers '@vercel/analytics': specifier: ^1.5.0 - version: 1.5.0(react@19.0.0)(svelte@5.28.2)(vue@3.5.13(typescript@5.8.3)) + version: 1.5.0(react@19.1.0)(svelte@5.28.2)(vue@3.5.13(typescript@5.8.3)) '@vercel/edge': specifier: ^1.2.1 version: 1.2.1 @@ -11067,6 +11076,11 @@ packages: peerDependencies: react: ^19.0.0 + react-dom@19.1.0: + resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} + peerDependencies: + react: ^19.1.0 + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -11079,6 +11093,10 @@ packages: resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} engines: {node: '>=0.10.0'} + react@19.1.0: + resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} + engines: {node: '>=0.10.0'} + read-yaml-file@1.1.0: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} @@ -11295,6 +11313,9 @@ packages: scheduler@0.25.0: resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} + scheduler@0.26.0: + resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + scslre@0.3.0: resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} engines: {node: ^14.0.0 || >=16.0.0} @@ -13717,12 +13738,12 @@ snapshots: - encoding - supports-color - '@markdoc/markdoc@0.5.1(@types/react@18.3.20)(react@19.0.0)': + '@markdoc/markdoc@0.5.1(@types/react@18.3.20)(react@19.1.0)': optionalDependencies: '@types/linkify-it': 3.0.5 '@types/markdown-it': 12.2.3 '@types/react': 18.3.20 - react: 19.0.0 + react: 19.1.0 '@mdx-js/mdx@3.1.0(acorn@8.14.1)': dependencies: @@ -14375,9 +14396,9 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@vercel/analytics@1.5.0(react@19.0.0)(svelte@5.28.2)(vue@3.5.13(typescript@5.8.3))': + '@vercel/analytics@1.5.0(react@19.1.0)(svelte@5.28.2)(vue@3.5.13(typescript@5.8.3))': optionalDependencies: - react: 19.0.0 + react: 19.1.0 svelte: 5.28.2 vue: 3.5.13(typescript@5.8.3) @@ -15292,12 +15313,12 @@ snapshots: dotenv@8.6.0: {} - drizzle-orm@0.31.4(@cloudflare/workers-types@4.20250327.0)(@libsql/client@0.15.2)(@types/react@18.3.20)(react@19.0.0): + drizzle-orm@0.31.4(@cloudflare/workers-types@4.20250327.0)(@libsql/client@0.15.2)(@types/react@18.3.20)(react@19.1.0): optionalDependencies: '@cloudflare/workers-types': 4.20250327.0 '@libsql/client': 0.15.2 '@types/react': 18.3.20 - react: 19.0.0 + react: 19.1.0 dset@3.1.4: {} @@ -17693,6 +17714,11 @@ snapshots: react: 19.0.0 scheduler: 0.25.0 + react-dom@19.1.0(react@19.1.0): + dependencies: + react: 19.1.0 + scheduler: 0.26.0 + react-refresh@0.17.0: {} react@18.3.1: @@ -17701,6 +17727,8 @@ snapshots: react@19.0.0: {} + react@19.1.0: {} + read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 @@ -18041,6 +18069,8 @@ snapshots: scheduler@0.25.0: {} + scheduler@0.26.0: {} + scslre@0.3.0: dependencies: '@eslint-community/regexpp': 4.12.1 diff --git a/scripts/cmd/prebuild.js b/scripts/cmd/prebuild.js index 1ee505500308..4b26aa529bd5 100644 --- a/scripts/cmd/prebuild.js +++ b/scripts/cmd/prebuild.js @@ -43,7 +43,7 @@ export default async function prebuild(...args) { return outURL; } - const hashes = []; + const hashes = new Map(); async function prebuildFile(filepath) { let tscode = await fs.promises.readFile(filepath, 'utf-8'); @@ -115,7 +115,8 @@ export default \`${generatedCode}\`;`; const url = getPrebuildURL(filepath, result.dev); await fs.promises.writeFile(url, mod, 'utf-8'); const hash = crypto.createHash('sha256').update(code).digest('base64'); - hashes.push(hash); + const basename = path.basename(filepath); + hashes.set(basename.slice(0, basename.indexOf('.')), hash); } } for (const entrypoint of entryPoints) { @@ -127,13 +128,14 @@ export default \`${generatedCode}\`;`; 'utf-8', ); const styleContent = fileContent.match(ASTRO_ISLAND_STYLE_REGEX)[1]; - hashes.push(crypto.createHash('sha256').update(styleContent).digest('base64')); - hashes.sort(); - const entries = hashes.map((hash) => `"${hash}"`); + hashes.set( + 'astro-island-styles', + crypto.createHash('sha256').update(styleContent).digest('base64'), + ); + + const entries = JSON.stringify(Object.fromEntries(hashes.entries()), null, 2); const content = `// This file is code-generated, please don't change it manually -export const ASTRO_ISLAND_HASHES = [ - ${entries.join(',\n ')} -];`; +export const ASTRO_ISLAND_HASHES = ${entries};`; await fs.promises.writeFile( path.join( fileURLToPath(import.meta.url), From 3bf54c321a65636c069937c98725ea7456beb80b Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Wed, 30 Apr 2025 14:44:14 +0100 Subject: [PATCH 05/12] feat(csp): track client scripts and CSS (#13725) Co-authored-by: ascorbic <213306+ascorbic@users.noreply.github.com> --- packages/astro/src/container/index.ts | 2 - packages/astro/src/core/app/types.ts | 1 - .../astro/src/core/astro-islands-hashes.ts | 10 --- packages/astro/src/core/base-pipeline.ts | 2 +- packages/astro/src/core/build/generate.ts | 16 +++-- packages/astro/src/core/build/internal.ts | 5 ++ .../core/build/plugins/plugin-internals.ts | 4 +- .../src/core/build/plugins/plugin-manifest.ts | 14 ++--- .../src/core/build/plugins/plugin-ssr.ts | 1 - packages/astro/src/core/csp/common.ts | 62 +++++++++++++++---- packages/astro/src/core/encryption.ts | 10 +++ packages/astro/src/core/render-context.ts | 1 - .../src/runtime/server/astro-island-styles.ts | 3 +- .../astro/src/runtime/server/render/common.ts | 2 +- .../astro/src/runtime/server/render/csp.ts | 10 --- .../astro/src/runtime/server/render/head.ts | 2 +- packages/astro/src/runtime/server/scripts.ts | 4 +- packages/astro/src/types/public/internal.ts | 3 +- .../src/vite-plugin-astro-server/plugin.ts | 4 +- packages/astro/test/astro-dynamic.test.js | 8 +-- .../astro/test/astro-slot-with-client.test.js | 2 +- .../astro/test/astro-slots-nested.test.js | 4 +- packages/astro/test/csp.test.js | 25 ++------ packages/astro/test/hydration-race.test.js | 6 +- scripts/cmd/prebuild.js | 31 ---------- 25 files changed, 104 insertions(+), 128 deletions(-) delete mode 100644 packages/astro/src/core/astro-islands-hashes.ts diff --git a/packages/astro/src/container/index.ts b/packages/astro/src/container/index.ts index acacdf22b8d3..dddd6b09e09e 100644 --- a/packages/astro/src/container/index.ts +++ b/packages/astro/src/container/index.ts @@ -164,7 +164,6 @@ function createManifest( clientScriptHashes: manifest?.clientScriptHashes ?? [], clientStyleHashes: manifest?.clientStyleHashes ?? [], shouldInjectCspMetaTags: manifest?.shouldInjectCspMetaTags ?? false, - astroIslandHashes: manifest?.astroIslandHashes ?? {}, }; } @@ -253,7 +252,6 @@ type AstroContainerManifest = Pick< | 'clientScriptHashes' | 'clientStyleHashes' | 'shouldInjectCspMetaTags' - | 'astroIslandHashes' >; type AstroContainerConstructor = { diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index 62211ea1f937..18f926eb37f0 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -92,7 +92,6 @@ export type SSRManifest = { * When enabled, Astro tracks the hashes of script and styles, and eventually it will render the `` tag */ shouldInjectCspMetaTags: boolean; - astroIslandHashes: Record; }; export type SSRActions = { diff --git a/packages/astro/src/core/astro-islands-hashes.ts b/packages/astro/src/core/astro-islands-hashes.ts deleted file mode 100644 index 56f4ad6fbffe..000000000000 --- a/packages/astro/src/core/astro-islands-hashes.ts +++ /dev/null @@ -1,10 +0,0 @@ -// This file is code-generated, please don't change it manually -export const ASTRO_ISLAND_HASHES = { - "astro-island": "p9VbHs/ClkQc+x63XdUjvCAgeWxA4ZGvpebJtMn9jbs=", - "idle": "BF0290pkb3jxQsE7z00xR8Imp8X34FLC88L0lkMnrGw=", - "load": "QzWFZi+FLIx23tnm9SBU4aEgx4x8DsuASP07mfqol/c=", - "media": "0chmwFk0zaA528yFfGV7J9ppIpdfTPPULncDF3WG7Zs=", - "only": "eIXWvAmxkr251LJZkjniEK5LcPF3NkapbJepohwYRIc=", - "visible": "Q2BPg90ZMplYY+FSdApNErhpWafg2hcRRbndmvxuL/Q=", - "astro-island-styles": "s81ZcLcyAa7P/Jh5M5hUxYthTGwW+iZY3e6aHrQ8H9E=" -}; \ No newline at end of file diff --git a/packages/astro/src/core/base-pipeline.ts b/packages/astro/src/core/base-pipeline.ts index 7e3174a48c46..d239a4a2fae2 100644 --- a/packages/astro/src/core/base-pipeline.ts +++ b/packages/astro/src/core/base-pipeline.ts @@ -13,6 +13,7 @@ import type { } from '../types/public/internal.js'; import { createOriginCheckMiddleware } from './app/middlewares.js'; import type { SSRActions } from './app/types.js'; +import { createCSPMiddleware } from './csp/middleware.js'; import { ActionNotFoundError } from './errors/errors-data.js'; import { AstroError } from './errors/index.js'; import type { Logger } from './logger/core.js'; @@ -20,7 +21,6 @@ import { NOOP_MIDDLEWARE_FN } from './middleware/noop-middleware.js'; import { sequence } from './middleware/sequence.js'; import { RouteCache } from './render/route-cache.js'; import { createDefaultRoutes } from './routing/default.js'; -import { createCSPMiddleware } from './csp/middleware.js'; /** * The `Pipeline` represents the static parts of rendering that do not change between requests. diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index f6170516b36a..b143fc06a3fd 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -9,7 +9,6 @@ import { getStaticImageList, prepareAssetsGenerationEnv, } from '../../assets/build/generate.js'; -import { type BuildInternals, hasPrerenderedPages } from './internal.js'; import { isRelativePath, joinPaths, @@ -29,6 +28,7 @@ import type { SSRLoadedRenderer, } from '../../types/public/internal.js'; import type { SSRActions, SSRManifest, SSRManifestI18n } from '../app/types.js'; +import { shouldTrackCspHashes, trackScriptHashes, trackStyleHashes } from '../csp/common.js'; import { NoPrerenderedRoutesWithDomains } from '../errors/errors-data.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; import { NOOP_MIDDLEWARE_FN } from '../middleware/noop-middleware.js'; @@ -41,6 +41,7 @@ import { matchRoute } from '../routing/match.js'; import { stringifyParams } from '../routing/params.js'; import { getOutputFilename } from '../util.js'; import { getOutFile, getOutFolder } from './common.js'; +import { type BuildInternals, hasPrerenderedPages } from './internal.js'; import { cssOrder, mergeInlineCss } from './internal.js'; import { BuildPipeline } from './pipeline.js'; import type { @@ -50,8 +51,6 @@ import type { StylesheetAsset, } from './types.js'; import { getTimeStat, shouldAppendForwardSlash } from './util.js'; -import { shouldTrackCspHashes, trackScriptHashes, trackStyleHashes } from '../csp/common.js'; -import { ASTRO_ISLAND_HASHES } from '../astro-islands-hashes.js'; export async function generatePages(options: StaticBuildOptions, internals: BuildInternals) { const generatePagesTimer = performance.now(); @@ -70,7 +69,7 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil const actions: SSRActions = internals.astroActionsEntryPoint ? await import(internals.astroActionsEntryPoint.toString()).then((mod) => mod) : NOOP_ACTIONS_MOD; - manifest = createBuildManifest( + manifest = await createBuildManifest( options.settings, internals, renderers.renderers as SSRLoadedRenderer[], @@ -604,22 +603,22 @@ function getPrettyRouteName(route: RouteData): string { * * Renderers needs to be pulled out from the page module emitted during the build. */ -function createBuildManifest( +async function createBuildManifest( settings: AstroSettings, internals: BuildInternals, renderers: SSRLoadedRenderer[], middleware: MiddlewareHandler, actions: SSRActions, key: Promise, -): SSRManifest { +): Promise { let i18nManifest: SSRManifestI18n | undefined = undefined; let clientStyleHashes: string[] = []; let clientScriptHashes: string[] = []; if (shouldTrackCspHashes(settings.config)) { - clientScriptHashes = trackScriptHashes(internals, settings); - clientStyleHashes = trackStyleHashes(internals); + clientScriptHashes = await trackScriptHashes(internals, settings); + clientStyleHashes = await trackStyleHashes(internals, settings); } if (settings.config.i18n) { @@ -668,6 +667,5 @@ function createBuildManifest( clientStyleHashes, clientScriptHashes, shouldInjectCspMetaTags: shouldTrackCspHashes(settings.config), - astroIslandHashes: ASTRO_ISLAND_HASHES, }; } diff --git a/packages/astro/src/core/build/internal.ts b/packages/astro/src/core/build/internal.ts index a726480b8cef..5f303596b620 100644 --- a/packages/astro/src/core/build/internal.ts +++ b/packages/astro/src/core/build/internal.ts @@ -80,6 +80,10 @@ export interface BuildInternals { // A list of all static files created during the build. Used for SSR. staticFiles: Set; + + // A list of all statics chunks and assets that are built in the client + clientChunksAndAssets: Set; + // The SSR entry chunk. Kept in internals to share between ssr/client build steps ssrEntryChunk?: Rollup.OutputChunk; // The SSR manifest entry chunk. @@ -121,6 +125,7 @@ export function createBuildInternals(): BuildInternals { prerenderOnlyChunks: [], astroActionsEntryPoint: undefined, middlewareEntryPoint: undefined, + clientChunksAndAssets: new Set(), }; } diff --git a/packages/astro/src/core/build/plugins/plugin-internals.ts b/packages/astro/src/core/build/plugins/plugin-internals.ts index 2d4dfc3603f3..6bef4414b3a5 100644 --- a/packages/astro/src/core/build/plugins/plugin-internals.ts +++ b/packages/astro/src/core/build/plugins/plugin-internals.ts @@ -40,7 +40,9 @@ function vitePluginInternals(input: Set, internals: BuildInternals): Vit ); } await Promise.all(promises); - for (const [, chunk] of Object.entries(bundle)) { + for (const [_, chunk] of Object.entries(bundle)) { + internals.clientChunksAndAssets.add(chunk.fileName); + if (chunk.type === 'chunk' && chunk.facadeModuleId) { const specifiers = mapping.get(chunk.facadeModuleId) || new Set([chunk.facadeModuleId]); for (const specifier of specifiers) { diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index 015e79ff20d0..75fb1b466c0a 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -12,6 +12,7 @@ import type { SerializedRouteInfo, SerializedSSRManifest, } from '../../app/types.js'; +import { shouldTrackCspHashes, trackScriptHashes, trackStyleHashes } from '../../csp/common.js'; import { encodeKey } from '../../encryption.js'; import { fileExtension, joinPaths, prependForwardSlash } from '../../path.js'; import { DEFAULT_COMPONENTS } from '../../routing/default.js'; @@ -23,8 +24,6 @@ import { type BuildInternals, cssOrder, mergeInlineCss } from '../internal.js'; import type { AstroBuildPlugin } from '../plugin.js'; import type { StaticBuildOptions } from '../types.js'; import { makePageDataKey } from './util.js'; -import { shouldTrackCspHashes, trackScriptHashes, trackStyleHashes } from '../../csp/common.js'; -import { ASTRO_ISLAND_HASHES } from '../../astro-islands-hashes.js'; const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@'; const replaceExp = new RegExp(`['"]${manifestReplace}['"]`, 'g'); @@ -143,7 +142,7 @@ async function createManifest( const staticFiles = internals.staticFiles; const encodedKey = await encodeKey(await buildOpts.key); - return buildManifest(buildOpts, internals, Array.from(staticFiles), encodedKey); + return await buildManifest(buildOpts, internals, Array.from(staticFiles), encodedKey); } /** @@ -157,12 +156,12 @@ function injectManifest(manifest: SerializedSSRManifest, chunk: Readonly { const { settings } = opts; const routes: SerializedRouteInfo[] = []; @@ -281,8 +280,8 @@ function buildManifest( let clientStyleHashes: string[] = []; if (shouldTrackCspHashes(settings.config)) { - clientScriptHashes = trackScriptHashes(internals, opts.settings); - clientStyleHashes = trackStyleHashes(internals); + clientScriptHashes = await trackScriptHashes(internals, opts.settings); + clientStyleHashes = await trackStyleHashes(internals, opts.settings); } return { @@ -317,6 +316,5 @@ function buildManifest( shouldInjectCspMetaTags: shouldTrackCspHashes(opts.settings.config), clientStyleHashes, clientScriptHashes, - astroIslandHashes: ASTRO_ISLAND_HASHES, }; } diff --git a/packages/astro/src/core/build/plugins/plugin-ssr.ts b/packages/astro/src/core/build/plugins/plugin-ssr.ts index 649bdc39ebec..60bf63ef2289 100644 --- a/packages/astro/src/core/build/plugins/plugin-ssr.ts +++ b/packages/astro/src/core/build/plugins/plugin-ssr.ts @@ -113,7 +113,6 @@ function vitePluginSSR( internals.staticFiles.add(chunk.fileName); } } - for (const [, chunk] of Object.entries(bundle)) { if (chunk.type === 'asset') { continue; diff --git a/packages/astro/src/core/csp/common.ts b/packages/astro/src/core/csp/common.ts index 74c7c2f67652..d1ed301f7432 100644 --- a/packages/astro/src/core/csp/common.ts +++ b/packages/astro/src/core/csp/common.ts @@ -1,42 +1,82 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { ISLAND_STYLES } from '../../runtime/server/astro-island-styles.js'; +import astroIslandPrebuiltDev from '../../runtime/server/astro-island.prebuilt-dev.js'; +import astroIslandPrebuilt from '../../runtime/server/astro-island.prebuilt.js'; +import type { AstroSettings } from '../../types/astro.js'; import type { AstroConfig } from '../../types/public/index.js'; import type { BuildInternals } from '../build/internal.js'; -import crypto from 'node:crypto'; -import type { AstroSettings } from '../../types/astro.js'; +import { generateDigest } from '../encryption.js'; export function shouldTrackCspHashes(config: AstroConfig): boolean { return config.experimental?.csp === true; } -export function trackStyleHashes(internals: BuildInternals): string[] { +export async function trackStyleHashes( + internals: BuildInternals, + settings: AstroSettings, +): Promise { const clientStyleHashes: string[] = []; for (const [_, page] of internals.pagesByViteID.entries()) { for (const style of page.styles) { if (style.sheet.type === 'inline') { - clientStyleHashes.push(generateHash(style.sheet.content)); + clientStyleHashes.push(await generateDigest(style.sheet.content)); } } } + for (const clientAsset in internals.clientChunksAndAssets) { + const contents = readFileSync( + fileURLToPath(new URL(clientAsset, settings.config.build.client)), + 'utf-8', + ); + if (clientAsset.endsWith('.css') || clientAsset.endsWith('.css')) { + clientStyleHashes.push(await generateDigest(contents)); + } + } + + if (settings.renderers.length > 0) { + clientStyleHashes.push(await generateDigest(ISLAND_STYLES)); + } + return clientStyleHashes; } -export function trackScriptHashes(internals: BuildInternals, settings: AstroSettings): string[] { +export async function trackScriptHashes( + internals: BuildInternals, + settings: AstroSettings, +): Promise { const clientScriptHashes: string[] = []; for (const script of internals.inlinedScripts.values()) { - clientScriptHashes.push(generateHash(script)); + clientScriptHashes.push(await generateDigest(script)); + } + + for (const directiveContent of Array.from(settings.clientDirectives.values())) { + clientScriptHashes.push(await generateDigest(directiveContent)); + } + + for (const clientAsset in internals.clientChunksAndAssets) { + const contents = readFileSync( + fileURLToPath(new URL(clientAsset, settings.config.build.client)), + 'utf-8', + ); + if (clientAsset.endsWith('.js') || clientAsset.endsWith('.mjs')) { + clientScriptHashes.push(await generateDigest(contents)); + } } for (const script of settings.scripts) { const { content, stage } = script; if (stage === 'head-inline' || stage === 'before-hydration') { - clientScriptHashes.push(generateHash(content)); + clientScriptHashes.push(await generateDigest(content)); } } - return clientScriptHashes; -} + if (settings.renderers.length > 0) { + clientScriptHashes.push(await generateDigest(astroIslandPrebuilt)); + clientScriptHashes.push(await generateDigest(astroIslandPrebuiltDev)); + } -function generateHash(content: string): string { - return crypto.createHash('sha256').update(content).digest('base64'); + return clientScriptHashes; } diff --git a/packages/astro/src/core/encryption.ts b/packages/astro/src/core/encryption.ts index 5f72e7367f16..e3668ab5d865 100644 --- a/packages/astro/src/core/encryption.ts +++ b/packages/astro/src/core/encryption.ts @@ -109,3 +109,13 @@ export async function decryptString(key: CryptoKey, encoded: string) { const decryptedString = decoder.decode(decryptedBuffer); return decryptedString; } + +/** + * Generates an SHA-256 digest of the given string. + * @param {string} data The string to hash. + */ +export async function generateDigest(data: string): Promise { + const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(data)); + + return encodeBase64(new Uint8Array(hashBuffer)); +} diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index 81610f42635a..b877b4c859fe 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -465,7 +465,6 @@ export class RenderContext { shouldInjectCspMetaTags: manifest.shouldInjectCspMetaTags, clientScriptHashes: manifest.clientScriptHashes, clientStyleHashes: manifest.clientStyleHashes, - astroIslandHashes: manifest.astroIslandHashes, }; return result; diff --git a/packages/astro/src/runtime/server/astro-island-styles.ts b/packages/astro/src/runtime/server/astro-island-styles.ts index ca816c9204b9..422f6bbc49f4 100644 --- a/packages/astro/src/runtime/server/astro-island-styles.ts +++ b/packages/astro/src/runtime/server/astro-island-styles.ts @@ -1,2 +1 @@ -export const ISLAND_STYLES = - ''; +export const ISLAND_STYLES = 'astro-island,astro-slot,astro-static-slot{display:contents}'; diff --git a/packages/astro/src/runtime/server/render/common.ts b/packages/astro/src/runtime/server/render/common.ts index de70c6127e82..5c765d6b2b35 100644 --- a/packages/astro/src/runtime/server/render/common.ts +++ b/packages/astro/src/runtime/server/render/common.ts @@ -1,4 +1,3 @@ -import type { RenderInstruction } from './instruction.js'; import type { SSRResult } from '../../../types/public/internal.js'; import type { HTMLBytes, HTMLString } from '../escape.js'; import { markHTMLString } from '../escape.js'; @@ -8,6 +7,7 @@ import { getPrescripts, } from '../scripts.js'; import { renderAllHeadContent } from './head.js'; +import type { RenderInstruction } from './instruction.js'; import { isRenderInstruction } from './instruction.js'; import { renderServerIslandRuntime } from './server-islands.js'; import { type SlotString, isSlotString } from './slot.js'; diff --git a/packages/astro/src/runtime/server/render/csp.ts b/packages/astro/src/runtime/server/render/csp.ts index 143bf0746b9a..2a148c35006b 100644 --- a/packages/astro/src/runtime/server/render/csp.ts +++ b/packages/astro/src/runtime/server/render/csp.ts @@ -12,16 +12,6 @@ export function renderCspContent(result: SSRResult): string { finalStyleHashes.add(`'sha256-${styleHash}'`); } - if (result.renderers.length > 0) { - for (const [ name, hash ] of Object.entries(result.astroIslandHashes)) { - if (name === 'astro-island-styles') { - finalStyleHashes.add(`'sha256-${hash}'`); - } else { - finalScriptHashes.add(`'sha256-${hash}'`); - } - } - } - const scriptSrc = `style-src 'self' ${Array.from(finalStyleHashes).join(' ')};`; const styleSrc = `script-src 'self' ${Array.from(finalScriptHashes).join(' ')};`; return `${scriptSrc} ${styleSrc}`; diff --git a/packages/astro/src/runtime/server/render/head.ts b/packages/astro/src/runtime/server/render/head.ts index 7989cb7acb5d..3af8be5a87b6 100644 --- a/packages/astro/src/runtime/server/render/head.ts +++ b/packages/astro/src/runtime/server/render/head.ts @@ -1,9 +1,9 @@ import type { SSRResult } from '../../../types/public/internal.js'; import { markHTMLString } from '../escape.js'; +import { renderCspContent } from './csp.js'; import type { MaybeRenderHeadInstruction, RenderHeadInstruction } from './instruction.js'; import { createRenderInstruction } from './instruction.js'; import { renderElement } from './util.js'; -import { renderCspContent } from './csp.js'; // Filter out duplicate elements in our set const uniqueElements = (item: any, index: number, all: any[]) => { diff --git a/packages/astro/src/runtime/server/scripts.ts b/packages/astro/src/runtime/server/scripts.ts index e77947de9168..fa87fcd631ca 100644 --- a/packages/astro/src/runtime/server/scripts.ts +++ b/packages/astro/src/runtime/server/scripts.ts @@ -1,7 +1,7 @@ import type { SSRResult } from '../../types/public/internal.js'; +import { ISLAND_STYLES } from './astro-island-styles.js'; import islandScriptDev from './astro-island.prebuilt-dev.js'; import islandScript from './astro-island.prebuilt.js'; -import { ISLAND_STYLES } from './astro-island-styles.js'; export function determineIfNeedsHydrationScript(result: SSRResult): boolean { if (result._metadata.hasHydrationScript) { @@ -36,7 +36,7 @@ export function getPrescripts(result: SSRResult, type: PrescriptType, directive: // deps to be loaded immediately. switch (type) { case 'both': - return `${ISLAND_STYLES}`; case 'directive': diff --git a/packages/astro/src/types/public/internal.ts b/packages/astro/src/types/public/internal.ts index 50d202389a5d..73e1502c99a6 100644 --- a/packages/astro/src/types/public/internal.ts +++ b/packages/astro/src/types/public/internal.ts @@ -1,13 +1,13 @@ // TODO: Should the types here really be public? import type { ErrorPayload as ViteErrorPayload } from 'vite'; +import type { SSRManifest } from '../../core/app/types.js'; import type { AstroCookies } from '../../core/cookies/cookies.js'; import type { AstroComponentInstance } from '../../runtime/server/index.js'; import type { Params } from './common.js'; import type { AstroConfig, RedirectConfig } from './config.js'; import type { AstroGlobal, AstroGlobalPartial } from './context.js'; import type { AstroRenderer } from './integrations.js'; -import type { SSRManifest } from '../../core/app/types.js'; export type { SSRManifest } from '../../core/app/types.js'; @@ -253,7 +253,6 @@ export interface SSRResult { shouldInjectCspMetaTags: boolean; clientScriptHashes: SSRManifest['clientScriptHashes']; clientStyleHashes: SSRManifest['clientStyleHashes']; - astroIslandHashes: SSRManifest['astroIslandHashes']; } /** diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts index e43e45dd71af..567afee158f2 100644 --- a/packages/astro/src/vite-plugin-astro-server/plugin.ts +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'; import type * as vite from 'vite'; import { normalizePath } from 'vite'; import type { SSRManifest, SSRManifestI18n } from '../core/app/types.js'; +import { shouldTrackCspHashes } from '../core/csp/common.js'; import { warnMissingAdapter } from '../core/dev/adapter-validation.js'; import { createKey, getEnvironmentKey, hasEnvironmentKey } from '../core/encryption.js'; import { getViteErrorPayload } from '../core/errors/dev/index.js'; @@ -25,8 +26,6 @@ import { DevPipeline } from './pipeline.js'; import { handleRequest } from './request.js'; import { setRouteError } from './server-state.js'; import { trailingSlashMiddleware } from './trailing-slash.js'; -import { ASTRO_ISLAND_HASHES } from '../core/astro-islands-hashes.js'; -import { shouldTrackCspHashes } from '../core/csp/common.js'; interface AstroPluginOptions { settings: AstroSettings; @@ -211,6 +210,5 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest clientScriptHashes: [], clientStyleHashes: [], shouldInjectCspMetaTags: shouldTrackCspHashes(settings.config), - astroIslandHashes: ASTRO_ISLAND_HASHES, }; } diff --git a/packages/astro/test/astro-dynamic.test.js b/packages/astro/test/astro-dynamic.test.js index 47344bf0ea97..0af25560e3c8 100644 --- a/packages/astro/test/astro-dynamic.test.js +++ b/packages/astro/test/astro-dynamic.test.js @@ -17,7 +17,7 @@ describe('Dynamic components', () => { const html = await fixture.readFile('/index.html'); const $ = cheerio.load(html); - assert.equal($('script').length, 1); + assert.equal($('script').length, 2, "to have directive and astro island script"); }); it('Loads pages using client:media hydrator', async () => { @@ -25,7 +25,7 @@ describe('Dynamic components', () => { const $ = cheerio.load(html); // test 1: static value rendered - assert.equal($('script').length, 1); + assert.equal($('script').length, 2, "to have directive and astro island script"); }); it('Loads pages using client:only hydrator', async () => { @@ -56,7 +56,7 @@ describe('Dynamic components subpath', () => { const html = await fixture.readFile('/index.html'); const $ = cheerio.load(html); - assert.equal($('script').length, 1); + assert.equal($('script').length, 2); }); it('Loads pages using client:media hydrator', async () => { @@ -64,7 +64,7 @@ describe('Dynamic components subpath', () => { const $ = cheerio.load(html); // test 1: static value rendered - assert.equal($('script').length, 1); + assert.equal($('script').length, 2, "to have directive and astro island script"); }); it('Loads pages using client:only hydrator', async () => { diff --git a/packages/astro/test/astro-slot-with-client.test.js b/packages/astro/test/astro-slot-with-client.test.js index 8f34b8fc9158..cf762a9db051 100644 --- a/packages/astro/test/astro-slot-with-client.test.js +++ b/packages/astro/test/astro-slot-with-client.test.js @@ -15,7 +15,7 @@ describe('Slots with client: directives', () => { it('Tags of dynamic tags works', async () => { const html = await fixture.readFile('/index.html'); const $ = cheerio.load(html); - assert.equal($('script').length, 1); + assert.equal($('script').length, 2); }); it('Astro slot tags are kept', async () => { diff --git a/packages/astro/test/astro-slots-nested.test.js b/packages/astro/test/astro-slots-nested.test.js index 3d04b00e27ba..7ab56249c32e 100644 --- a/packages/astro/test/astro-slots-nested.test.js +++ b/packages/astro/test/astro-slots-nested.test.js @@ -15,7 +15,7 @@ describe('Nested Slots', () => { it('Hidden nested slots see their hydration scripts hoisted', async () => { const html = await fixture.readFile('/hidden-nested/index.html'); const $ = cheerio.load(html); - assert.equal($('script').length, 1, 'script rendered'); + assert.equal($('script').length, 2, 'script rendered'); const scriptInTemplate = $($('template')[0].children[0]).find('script'); assert.equal(scriptInTemplate.length, 0, 'script defined outside of the inner template'); }); @@ -23,7 +23,7 @@ describe('Nested Slots', () => { it('Slots rendered via Astro.slots.render have the hydration script', async () => { const html = await fixture.readFile('/component-slot/index.html'); const $ = cheerio.load(html); - assert.equal($('script').length, 1, 'script rendered'); + assert.equal($('script').length, 2, 'script rendered'); }); describe('Client components nested inside server-only framework components', () => { diff --git a/packages/astro/test/csp.test.js b/packages/astro/test/csp.test.js index fc5703c0a016..6671483dceed 100644 --- a/packages/astro/test/csp.test.js +++ b/packages/astro/test/csp.test.js @@ -1,8 +1,8 @@ -import { before, describe, it } from 'node:test'; -import { loadFixture } from './test-utils.js'; -import testAdapter from './test-adapter.js'; import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; import * as cheerio from 'cheerio'; +import testAdapter from './test-adapter.js'; +import { loadFixture } from './test-utils.js'; describe('CSP', () => { let app; @@ -39,24 +39,7 @@ describe('CSP', () => { `Should have a CSP meta tag for ${hash}`, ); } - - let [, astroStyleHash] = Object.entries(manifest.astroIslandHashes).find( - ([name, _]) => name === 'astro-island-styles', - ); - astroStyleHash = `sha256-${astroStyleHash}`; - - let [, astroIsland] = Object.entries(manifest.astroIslandHashes).find(([name, _]) => name === 'astro-island'); - astroIsland = `sha256-${astroIsland}`; - - assert.ok( - meta.attr('content').includes(astroStyleHash), - `Should have a CSP meta tag for ${astroStyleHash}`, - ); - - assert.ok( - meta.attr('content').includes(astroIsland), - `Should have a CSP meta tag for ${astroIsland}`, - ); + } else { assert.fail('Should have the manifest'); } diff --git a/packages/astro/test/hydration-race.test.js b/packages/astro/test/hydration-race.test.js index 00837fdd9bc3..1916c20d06ad 100644 --- a/packages/astro/test/hydration-race.test.js +++ b/packages/astro/test/hydration-race.test.js @@ -25,7 +25,7 @@ describe('Hydration script ordering', async () => { // Sanity check that we're only rendering them once. assert.equal($('style').length, 1, 'hydration style added once'); - assert.equal($('script').length, 1, 'only one hydration script needed'); + assert.equal($('script').length, 2, 'only 2 hydration scripts needed'); }); it('Hydration script included when inside dynamic slot', async () => { @@ -35,7 +35,7 @@ describe('Hydration script ordering', async () => { // First, let's make sure all islands rendered assert.equal($('astro-island').length, 1); - // There should be 1 script - assert.equal($('script').length, 1); + // There should be 2 scripts: directive and astro island + assert.equal($('script').length, 2); }); }); diff --git a/scripts/cmd/prebuild.js b/scripts/cmd/prebuild.js index 4b26aa529bd5..68a3a4f91386 100644 --- a/scripts/cmd/prebuild.js +++ b/scripts/cmd/prebuild.js @@ -4,14 +4,11 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import esbuild from 'esbuild'; import { red } from 'kleur/colors'; import { glob } from 'tinyglobby'; -import crypto from 'node:crypto'; function escapeTemplateLiterals(str) { return str.replace(/\`/g, '\\`').replace(/\$\{/g, '\\${'); } -const ASTRO_ISLAND_STYLE_REGEX = /'([^']*)'/; - export default async function prebuild(...args) { let buildToString = args.indexOf('--to-string'); if (buildToString !== -1) { @@ -43,8 +40,6 @@ export default async function prebuild(...args) { return outURL; } - const hashes = new Map(); - async function prebuildFile(filepath) { let tscode = await fs.promises.readFile(filepath, 'utf-8'); // If we're bundling a client directive, modify the code to match `packages/astro/src/core/client-directive/build.ts`. @@ -114,35 +109,9 @@ export default async function prebuild(...args) { export default \`${generatedCode}\`;`; const url = getPrebuildURL(filepath, result.dev); await fs.promises.writeFile(url, mod, 'utf-8'); - const hash = crypto.createHash('sha256').update(code).digest('base64'); - const basename = path.basename(filepath); - hashes.set(basename.slice(0, basename.indexOf('.')), hash); } } for (const entrypoint of entryPoints) { await prebuildFile(entrypoint); } - - const fileContent = await fs.promises.readFile( - new URL('../../packages/astro/src/runtime/server/astro-island-styles.ts', import.meta.url), - 'utf-8', - ); - const styleContent = fileContent.match(ASTRO_ISLAND_STYLE_REGEX)[1]; - hashes.set( - 'astro-island-styles', - crypto.createHash('sha256').update(styleContent).digest('base64'), - ); - - const entries = JSON.stringify(Object.fromEntries(hashes.entries()), null, 2); - const content = `// This file is code-generated, please don't change it manually -export const ASTRO_ISLAND_HASHES = ${entries};`; - await fs.promises.writeFile( - path.join( - fileURLToPath(import.meta.url), - '../../../packages/astro/src/core', - 'astro-islands-hashes.ts', - ), - content, - 'utf-8', - ); } From 9ddc748bd974cf77d167af28a90f0a1b8870cd5c Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Thu, 1 May 2025 10:08:43 +0100 Subject: [PATCH 06/12] feat(csp): support view transitions --- packages/astro/e2e/csp-client-only.test.js | 126 ++ .../astro/e2e/csp-view-transitions.test.js | 1665 +++++++++++++++++ packages/astro/src/core/render-context.ts | 1 + .../astro/src/runtime/server/render/csp.ts | 4 + .../astro/src/runtime/server/transition.ts | 7 +- packages/astro/src/types/public/internal.ts | 5 + packages/astro/test/astro-dynamic.test.js | 6 +- packages/astro/test/csp.test.js | 1 - packages/astro/test/hydration-race.test.js | 2 +- 9 files changed, 1810 insertions(+), 7 deletions(-) create mode 100644 packages/astro/e2e/csp-client-only.test.js create mode 100644 packages/astro/e2e/csp-view-transitions.test.js diff --git a/packages/astro/e2e/csp-client-only.test.js b/packages/astro/e2e/csp-client-only.test.js new file mode 100644 index 000000000000..89f9ec4b9355 --- /dev/null +++ b/packages/astro/e2e/csp-client-only.test.js @@ -0,0 +1,126 @@ +import { expect } from '@playwright/test'; +import { testFactory } from './test-utils.js'; + +const test = testFactory(import.meta.url, { + root: './fixtures/client-only/', + experimental: { + csp: true, + }, +}); + +let previewServer; + +test.beforeAll(async ({ astro }) => { + await astro.build(); + previewServer = await astro.preview(); +}); + +test.afterAll(async () => { + await previewServer.stop(); +}); +test.describe('CSP Client only', () => { + test('React counter', async ({ astro, page }) => { + await page.goto(astro.resolveUrl('/')); + + const counter = await page.locator('#react-counter'); + await expect(counter, 'component is visible').toBeVisible(); + + const fallback = await page.locator('[data-fallback=react]'); + await expect(fallback, 'fallback content is hidden').not.toBeVisible(); + + const count = await counter.locator('pre'); + await expect(count, 'initial count is 0').toHaveText('0'); + + const children = await counter.locator('.children'); + await expect(children, 'children exist').toHaveText('react'); + + const increment = await counter.locator('.increment'); + await increment.click(); + + await expect(count, 'count incremented by 1').toHaveText('1'); + }); + + test('Preact counter', async ({ astro, page }) => { + await page.goto(astro.resolveUrl('/')); + + const counter = await page.locator('#preact-counter'); + await expect(counter, 'component is visible').toBeVisible(); + + const fallback = await page.locator('[data-fallback=preact]'); + await expect(fallback, 'fallback content is hidden').not.toBeVisible(); + + const count = await counter.locator('pre'); + await expect(count, 'initial count is 0').toHaveText('0'); + + const children = await counter.locator('.children'); + await expect(children, 'children exist').toHaveText('preact'); + + const increment = await counter.locator('.increment'); + await increment.click(); + + await expect(count, 'count incremented by 1').toHaveText('1'); + }); + + test('Solid counter', async ({ astro, page }) => { + await page.goto(astro.resolveUrl('/')); + + const counter = await page.locator('#solid-counter'); + await expect(counter, 'component is visible').toBeVisible(); + + const fallback = await page.locator('[data-fallback=solid]'); + await expect(fallback, 'fallback content is hidden').not.toBeVisible(); + + const count = await counter.locator('pre'); + await expect(count, 'initial count is 0').toHaveText('0'); + + const children = await counter.locator('.children'); + await expect(children, 'children exist').toHaveText('solid'); + + const increment = await counter.locator('.increment'); + await increment.click(); + + await expect(count, 'count incremented by 1').toHaveText('1'); + }); + + test('Vue counter', async ({ astro, page }) => { + await page.goto(astro.resolveUrl('/')); + + const counter = await page.locator('#vue-counter'); + await expect(counter, 'component is visible').toBeVisible(); + + const fallback = await page.locator('[data-fallback=vue]'); + await expect(fallback, 'fallback content is hidden').not.toBeVisible(); + + const count = await counter.locator('pre'); + await expect(count, 'initial count is 0').toHaveText('0'); + + const children = await counter.locator('.children'); + await expect(children, 'children exist').toHaveText('vue'); + + const increment = await counter.locator('.increment'); + await increment.click(); + + await expect(count, 'count incremented by 1').toHaveText('1'); + }); + + test('Svelte counter', async ({ astro, page }) => { + await page.goto(astro.resolveUrl('/')); + + const counter = await page.locator('#svelte-counter'); + await expect(counter, 'component is visible').toBeVisible(); + + const fallback = await page.locator('[data-fallback=svelte]'); + await expect(fallback, 'fallback content is hidden').not.toBeVisible(); + + const count = await counter.locator('pre'); + await expect(count, 'initial count is 0').toHaveText('0'); + + const children = await counter.locator('.children'); + await expect(children, 'children exist').toHaveText('svelte'); + + const increment = await counter.locator('.increment'); + await increment.click(); + + await expect(count, 'count incremented by 1').toHaveText('1'); + }); +}); diff --git a/packages/astro/e2e/csp-view-transitions.test.js b/packages/astro/e2e/csp-view-transitions.test.js new file mode 100644 index 000000000000..22234a90e3cb --- /dev/null +++ b/packages/astro/e2e/csp-view-transitions.test.js @@ -0,0 +1,1665 @@ +import { expect } from '@playwright/test'; +import { testFactory, waitForHydrate } from './test-utils.js'; + +const test = testFactory(import.meta.url, { + root: './fixtures/view-transitions/', + experimental: { + csp: true, + }, +}); + +let previewServer; + +test.beforeAll(async ({ astro }) => { + await astro.build(); + previewServer = await astro.preview(); +}); + +test.afterAll(async () => { + await previewServer.stop(); +}); +function collectLoads(page) { + const loads = []; + page.on('load', async () => { + const url = page.url(); + if (url !== 'about:blank') loads.push(await page.title()); + }); + return loads; +} +function scrollToBottom(page) { + return page.evaluate(() => { + window.scrollY = document.documentElement.scrollHeight; + window.dispatchEvent(new Event('scroll')); + }); +} + +function collectPreloads(page) { + return page.evaluate(() => { + window.preloads = []; + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => + mutation.addedNodes.forEach((node) => { + if (node.nodeName === 'LINK' && node.rel === 'preload') preloads.push(node.href); + }), + ); + }); + observer.observe(document.head, { childList: true }); + }); +} + +async function nativeViewTransition(page) { + return page.evaluate(() => document.startViewTransition !== undefined); +} + +test.describe('CSP View Transitions', () => { + test('Moving from page 1 to page 2', async ({ page, astro }) => { + const loads = collectLoads(page); + + // Go to page 1 + await page.goto(astro.resolveUrl('/one')); + let p = page.locator('#one'); + await expect(p, 'should have content').toHaveText('Page 1'); + + // go to page 2 + await page.click('#click-two'); + p = page.locator('#two'); + await expect(p, 'should have content').toHaveText('Page 2'); + + expect(loads.length, 'There should only be 1 page load').toEqual(1); + }); + + test('Back button is captured', async ({ page, astro }) => { + const loads = collectLoads(page); + + // Go to page 1 + await page.goto(astro.resolveUrl('/one')); + let p = page.locator('#one'); + await expect(p, 'should have content').toHaveText('Page 1'); + + // go to page 2 + await page.click('#click-two'); + p = page.locator('#two'); + await expect(p, 'should have content').toHaveText('Page 2'); + + // Back to page 1 + await page.goBack(); + p = page.locator('#one'); + await expect(p, 'should have content').toHaveText('Page 1'); + + expect(loads.length, 'There should only be 1 page load').toEqual(1); + }); + + test('Clicking on a link with nested content', async ({ page, astro }) => { + const loads = collectLoads(page); + // Go to page 4 + await page.goto(astro.resolveUrl('/four')); + let p = page.locator('#four'); + await expect(p, 'should have content').toHaveText('Page 4'); + + // Go to page 1 + await page.click('#click-one'); + p = page.locator('#one'); + await expect(p, 'should have content').toHaveText('Page 1'); + + expect(loads.length, 'There should only be 1 page load').toEqual(1); + }); + + test('Clicking on a link to a page with non-recommended headers', async ({ page, astro }) => { + const loads = collectLoads(page); + // Go to page 4 + await page.goto(astro.resolveUrl('/one')); + let p = page.locator('#one'); + await expect(p, 'should have content').toHaveText('Page 1'); + + // Go to page 1 + await page.click('#click-seven'); + p = page.locator('#seven'); + await expect(p, 'should have content').toHaveText('Page 7'); + + expect(loads.length, 'There should only be 1 page load').toEqual(1); + }); + + test('Moving to a page without ClientRouter triggers a full page navigation', async ({ + page, + astro, + }) => { + const loads = collectLoads(page); + + // Go to page 1 + await page.goto(astro.resolveUrl('/one')); + let p = page.locator('#one'); + await expect(p, 'should have content').toHaveText('Page 1'); + + // Go to page 3 which does *not* have ClientRouter enabled + await page.click('#click-three'); + p = page.locator('#three'); + await expect(p, 'should have content').toHaveText('Page 3'); + + expect( + loads.length, + 'There should be 2 page loads. The original, then going from 3 to 2', + ).toEqual(2); + }); + + test('Moving within a page without ClientRouter does not trigger a full page navigation', async ({ + page, + astro, + }) => { + const loads = collectLoads(page); + // Go to page 1 + await page.goto(astro.resolveUrl('/one')); + let p = page.locator('#one'); + await expect(p, 'should have content').toHaveText('Page 1'); + + // Go to page 3 which does *not* have ClientRouter enabled + await page.click('#click-three'); + p = page.locator('#three'); + await expect(p, 'should have content').toHaveText('Page 3'); + + // click a hash link to navigate further down the page + await page.click('#click-hash'); + // still on page 3 + p = page.locator('#three'); + await expect(p, 'should have content').toHaveText('Page 3'); + + // check that we are further down the page + const Y = await page.evaluate(() => window.scrollY); + expect(Y, 'The target is further down the page').toBeGreaterThan(0); + + expect( + loads.length, + 'There should be only 2 page loads (for page one & three), but no additional loads for the hash change', + ).toEqual(2); + }); + + test('Moving from a page without ClientRouter w/ back button', async ({ page, astro }) => { + const loads = collectLoads(page); + // Go to page 1 + await page.goto(astro.resolveUrl('/one')); + let p = page.locator('#one'); + await expect(p, 'should have content').toHaveText('Page 1'); + + // Go to page 3 which does *not* have ClientRouter enabled + await page.click('#click-three'); + p = page.locator('#three'); + await expect(p, 'should have content').toHaveText('Page 3'); + + // Back to page 1 + await page.goBack(); + p = page.locator('#one'); + await expect(p, 'should have content').toHaveText('Page 1'); + expect( + loads.length, + 'There should be 3 page loads (for page one & three), and an additional loads for the back navigation', + ).toEqual(3); + }); + + test('Stylesheets in the head are waited on', async ({ page, astro }) => { + // Go to page 1 + await page.goto(astro.resolveUrl('/one')); + let p = page.locator('#one'); + await expect(p, 'should have content').toHaveText('Page 1'); + + await collectPreloads(page); + + // Go to page 2 + await page.click('#click-two'); + p = page.locator('#two'); + await expect(p, 'should have content').toHaveText('Page 2'); + await expect(p, 'imported CSS updated').toHaveCSS('font-size', '24px'); + const preloads = await page.evaluate(() => window.preloads); + expect(preloads.length === 1 && preloads[0].endsWith('/two.css')).toBeTruthy(); + }); + + test('astro:page-load event fires when navigating to new page', async ({ page, astro }) => { + // Go to page 1 + await page.goto(astro.resolveUrl('/one')); + const p = page.locator('#one'); + await expect(p, 'should have content').toHaveText('Page 1'); + + // go to page 2 + await page.click('#click-two'); + const article = page.locator('#twoarticle'); + await expect(article, 'should have script content').toHaveText('works'); + }); + + test('astro:page-load event fires when navigating directly to a page', async ({ + page, + astro, + }) => { + // Go to page 2 + await page.goto(astro.resolveUrl('/two')); + const article = page.locator('#twoarticle'); + await expect(article, 'should have script content').toHaveText('works'); + }); + + test('astro:after-swap event fires right after the swap', async ({ page, astro }) => { + // Go to page 1 + await page.goto(astro.resolveUrl('/one')); + let p = page.locator('#one'); + await expect(p, 'should have content').toHaveText('Page 1'); + + // go to page 2 + await page.click('#click-two'); + p = page.locator('#two'); + const h = page.locator('html'); + await expect(h, 'imported CSS updated').toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); + }); + + test('No page rendering during swap()', async ({ page, astro }) => { + // This has been a problem with theme switchers (e.g. for darkmode) + // Swap() should not trigger any page renders and give users the chance to + // correct attributes in the astro:after-swap handler before they become visible + + // This test uses a CSS animation to detect page rendering + // The test succeeds if no additional animation beside those of the + // view transition is triggered during swap() + + // Only works for browsers with native view transitions + if (!(await nativeViewTransition(page))) return; + + await page.goto(astro.resolveUrl('/listener-one')); + let p = page.locator('#totwo'); + await expect(p, 'should have content').toHaveText('Go to listener two'); + + // setting the blue class on the html element triggers a CSS animation + let animations = await page.evaluate(async () => { + document.documentElement.classList.add('blue'); + return document.getAnimations(); + }); + expect(animations.length).toEqual(1); + + // go to page 2 + await page.click('#totwo'); + p = page.locator('#toone'); + await expect(p, 'should have content').toHaveText('Go to listener one'); + // swap() resets the "blue" class, as it is not set in the static html of page 2 + // The astro:after-swap listener (defined in the layout) sets it to "blue" again. + // The temporarily missing class must not trigger page rendering. + + // When the after-swap listener starts, no animations should be running + // after-swap listener sets animations to document.getAnimations().length + // and we expect this to be zero + await expect(page.locator('html')).toHaveAttribute('animations', '0'); + }); + + test('click hash links does not do navigation', async ({ page, astro }) => { + // Go to page 1 + await page.goto(astro.resolveUrl('/one')); + const p = page.locator('#one'); + await expect(p, 'should have content').toHaveText('Page 1'); + + // Clicking 1 stays put + await page.click('#click-one'); + await expect(p, 'should have content').toHaveText('Page 1'); + }); + + test('click self link (w/o hash) does not do navigation', async ({ page, astro }) => { + const loads = collectLoads(page); + + // Go to page 1 + await page.goto(astro.resolveUrl('/one')); + const p = page.locator('#one'); + await expect(p, 'should have content').toHaveText('Page 1'); + + // Clicking href="" stays on page + await page.click('#click-self'); + await expect(p, 'should have content').toHaveText('Page 1'); + expect(loads.length, 'There should only be 1 page load').toEqual(1); + }); + + test('Scroll position restored on back button', async ({ page, astro }) => { + // Go to page 1 + await page.goto(astro.resolveUrl('/long-page')); + let article = page.locator('#longpage'); + await expect(article, 'should have script content').toBeVisible('exists'); + + await scrollToBottom(page); + const oldScrollY = await page.evaluate(() => window.scrollY); + + // go to page long-page + await page.click('#click-one'); + let p = page.locator('#one'); + await expect(p, 'should have content').toHaveText('Page 1'); + + // Back to page 1 + await page.goBack(); + + const newScrollY = await page.evaluate(() => window.scrollY); + expect(oldScrollY).toEqual(newScrollY); + }); + + test('Fragment scroll position restored on back button', async ({ page, astro }) => { + // Go to the long page + await page.goto(astro.resolveUrl('/long-page')); + let locator = page.locator('#longpage'); + await expect(locator).toBeInViewport(); + + // Scroll down to middle fragment + await page.click('#click-scroll-down'); + locator = page.locator('#click-one-again'); + await expect(locator).toBeInViewport(); + + // Scroll up to top fragment + await page.click('#click-scroll-up'); + locator = page.locator('#longpage'); + await expect(locator).toBeInViewport(); + + // Back to middle of the page + await page.goBack(); + locator = page.locator('#click-one-again'); + await expect(locator).toBeInViewport(); + }); + + test('Scroll position restored when transitioning back to fragment', async ({ page, astro }) => { + // Go to the long page + await page.goto(astro.resolveUrl('/long-page')); + let locator = page.locator('#longpage'); + await expect(locator).toBeInViewport(); + + // Scroll down to middle fragment + await page.click('#click-scroll-down'); + locator = page.locator('#click-one-again'); + await expect(locator).toBeInViewport(); + + // goto page 1 + await page.click('#click-one-again'); + locator = page.locator('#one'); + await expect(locator).toHaveText('Page 1'); + + // Back to middle of the previous page + await page.goBack(); + locator = page.locator('#click-one-again'); + await expect(locator).toBeInViewport(); + }); + + test('Scroll position restored on forward button', async ({ page, astro }) => { + // Go to page 1 + await page.goto(astro.resolveUrl('/one')); + let p = page.locator('#one'); + await expect(p, 'should have content').toHaveText('Page 1'); + + // go to page long-page + await page.click('#click-longpage'); + let article = page.locator('#longpage'); + await expect(article, 'should have script content').toBeVisible('exists'); + + await scrollToBottom(page); + const oldScrollY = await page.evaluate(() => window.scrollY); + + // Back to page 1 + await page.goBack(); + + // Go forward + await page.goForward(); + article = page.locator('#longpage'); + await expect(article, 'should have script content').toBeVisible('exists'); + + const newScrollY = await page.evaluate(() => window.scrollY); + expect(oldScrollY).toEqual(newScrollY); + }); + + test('Fragment scroll position restored on forward button', async ({ page, astro }) => { + // Go to the long page + await page.goto(astro.resolveUrl('/long-page')); + let locator = page.locator('#longpage'); + await expect(locator).toBeInViewport(); + + // Scroll down to middle fragment + await page.click('#click-scroll-down'); + locator = page.locator('#click-one-again'); + await expect(locator).toBeInViewport(); + + // Scroll back to top + await page.goBack(); + locator = page.locator('#longpage'); + await expect(locator).toBeInViewport(); + + // Forward to middle of page + await page.goForward(); + locator = page.locator('#click-one-again'); + await expect(locator).toBeInViewport(); + }); + + test('View Transitions Rule', async ({ page, astro }) => { + let consoleCount = 0; + page.on('console', (msg) => { + // This count is used for transition events + if (msg.text() === 'ready') consoleCount++; + }); + // Don't test back and forward '' to '', because They are not stored in the history. + // click '' to '' (transition) + await page.goto(astro.resolveUrl('/long-page')); + let locator = page.locator('#longpage'); + await expect(locator).toBeInViewport(); + let consolePromise = page.waitForEvent('console'); + await page.click('#click-self'); + await consolePromise; + locator = page.locator('#longpage'); + await expect(locator).toBeInViewport(); + expect(consoleCount).toEqual(1); + + // click '' to 'hash' (no transition) + await page.click('#click-scroll-down'); + locator = page.locator('#click-one-again'); + await expect(locator).toBeInViewport(); + expect(consoleCount).toEqual(1); + + // back 'hash' to '' (no transition) + await page.goBack(); + locator = page.locator('#longpage'); + await expect(locator).toBeInViewport(); + expect(consoleCount).toEqual(1); + + // forward '' to 'hash' (no transition) + // NOTE: the networkidle below is needed for Firefox to consistently + // pass the `#longpage` viewport check below + await page.goForward({ waitUntil: 'networkidle' }); + locator = page.locator('#click-one-again'); + await expect(locator).toBeInViewport(); + expect(consoleCount).toEqual(1); + + // click 'hash' to 'hash' (no transition) + await page.click('#click-scroll-up'); + locator = page.locator('#longpage'); + await expect(locator).toBeInViewport(); + expect(consoleCount).toEqual(1); + + // back 'hash' to 'hash' (no transition) + await page.goBack(); + locator = page.locator('#click-one-again'); + await expect(locator).toBeInViewport(); + expect(consoleCount).toEqual(1); + + // forward 'hash' to 'hash' (no transition) + await page.goForward(); + locator = page.locator('#longpage'); + await expect(locator).toBeInViewport(); + expect(consoleCount).toEqual(1); + + // click 'hash' to '' (transition) + consolePromise = page.waitForEvent('console'); + await page.click('#click-self'); + await consolePromise; + locator = page.locator('#longpage'); + await expect(locator).toBeInViewport(); + expect(consoleCount).toEqual(2); + + // back '' to 'hash' (transition) + consolePromise = page.waitForEvent('console'); + await page.goBack(); + await consolePromise; + locator = page.locator('#longpage'); + await expect(locator).toBeInViewport(); + expect(consoleCount).toEqual(3); + + // forward 'hash' to '' (transition) + consolePromise = page.waitForEvent('console'); + await page.goForward(); + await consolePromise; + locator = page.locator('#longpage'); + await expect(locator).toBeInViewport(); + expect(consoleCount).toEqual(4); + }); + + test(' component forwards transitions to the ', async ({ page, astro }) => { + // Go to page 1 + await page.goto(astro.resolveUrl('/image-one')); + const img = page.locator('img[data-astro-transition-scope]'); + await expect(img).toBeVisible('The image tag should have the transition scope attribute.'); + }); + + test('