diff --git a/packages/vite/src/node/__tests__/build.spec.ts b/packages/vite/src/node/__tests__/build.spec.ts index 1b632a8b20fdcf..013b577d450c00 100644 --- a/packages/vite/src/node/__tests__/build.spec.ts +++ b/packages/vite/src/node/__tests__/build.spec.ts @@ -2,7 +2,12 @@ import { resolve } from 'node:path' import { fileURLToPath } from 'node:url' import colors from 'picocolors' import { describe, expect, test, vi } from 'vitest' -import type { OutputChunk, OutputOptions, RollupOutput } from 'rollup' +import type { + OutputAsset, + OutputChunk, + OutputOptions, + RollupOutput, +} from 'rollup' import type { LibraryFormats, LibraryOptions } from '../build' import { build, resolveBuildOutputs, resolveLibFilename } from '../build' import type { Logger } from '../logger' @@ -102,6 +107,54 @@ describe('build', () => { ]) assertOutputHashContentChange(result[0], result[1]) }) + + describe('nonce placeholder', () => { + const buildProject = async (noncePlaceholder?: string) => { + return (await build({ + root: resolve(__dirname, 'packages/build-project'), + logLevel: 'silent', + build: { + write: false, + noncePlaceholder, + }, + plugins: [ + { + name: 'test', + resolveId(id) { + if (id === 'entry.js') { + return '\0' + id + } + }, + load(id) { + if (id === '\0entry.js') { + return `console.log('hello world')` + } + }, + }, + ], + })) as RollupOutput + } + + test('nonce placeholder should be added to html script tag', async () => { + const result = await buildProject('TEST_NONCE') + + expect(result.output).toHaveLength(2) + + const htmlAsset = result.output[1] as OutputAsset + + expect(htmlAsset.source).toMatch(`nonce=\"TEST_NONCE\"`) + }) + + test('nonce placeholder should not be added to html script tag', async () => { + const result = await buildProject() + + expect(result.output).toHaveLength(2) + + const htmlAsset = result.output[1] as OutputAsset + + expect(htmlAsset.source).not.toMatch(`nonce`) + }) + }) }) const baseLibOptions: LibraryOptions = { diff --git a/packages/vite/src/node/build.ts b/packages/vite/src/node/build.ts index 41a360a4fd87ed..0b960058c19ad4 100644 --- a/packages/vite/src/node/build.ts +++ b/packages/vite/src/node/build.ts @@ -244,6 +244,11 @@ export interface BuildOptions { * @default null */ watch?: WatcherOptions | null + + /** + * If specified, adds a `nonce` attribute to HTML script and link tags with the placeholder value. + */ + noncePlaceholder?: string } export interface LibraryOptions { diff --git a/packages/vite/src/node/plugins/html.ts b/packages/vite/src/node/plugins/html.ts index bda226ff7d4c54..5967b8333931bb 100644 --- a/packages/vite/src/node/plugins/html.ts +++ b/packages/vite/src/node/plugins/html.ts @@ -625,6 +625,9 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin { return chunks } + const hasNonce = config.build.noncePlaceholder?.length > 0 + const nonce = config.build.noncePlaceholder + const toScriptTag = ( chunk: OutputChunk, toOutputPath: (filename: string) => string, @@ -636,6 +639,7 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin { type: 'module', crossorigin: true, src: toOutputPath(chunk.fileName), + ...(hasNonce ? { nonce } : {}), }, }) @@ -648,6 +652,7 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin { rel: 'modulepreload', crossorigin: true, href: toOutputPath(filename), + ...(hasNonce ? { nonce } : {}), }, }) @@ -675,6 +680,7 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin { attrs: { rel: 'stylesheet', href: toOutputPath(file), + ...(hasNonce ? { nonce } : {}), }, }) } @@ -782,6 +788,7 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin { attrs: { rel: 'stylesheet', href: toOutputAssetFilePath(cssChunk.fileName), + ...(hasNonce ? { nonce } : {}), }, }, ]) diff --git a/playground/html/__tests__/html.spec.ts b/playground/html/__tests__/html.spec.ts index 815fc68f7b88c0..b8fc347aef85cd 100644 --- a/playground/html/__tests__/html.spec.ts +++ b/playground/html/__tests__/html.spec.ts @@ -179,6 +179,28 @@ describe.runIf(isBuild)('build', () => { ) }) }) + + describe('nonce', () => { + beforeAll(async () => { + await page.goto(viteTestUrl + '/nonce.html') + }) + + test('nonce should be included in html tags', async () => { + const scripts = await page.locator('script').all() + const links = await page.locator('link[rel=stylesheet]').all() + + await Promise.all( + scripts.map(async (script) => + expect(await script.getAttribute('nonce')).toBe('TEST_NONCE'), + ), + ) + await Promise.all( + links.map(async (link) => + expect(await link.getAttribute('nonce')).toBe('TEST_NONCE'), + ), + ) + }) + }) }) describe('noHead', () => { diff --git a/playground/html/nonce.html b/playground/html/nonce.html new file mode 100644 index 00000000000000..0a12bf3fccea49 --- /dev/null +++ b/playground/html/nonce.html @@ -0,0 +1,17 @@ + + +
+ + + + +