diff --git a/.changeset/late-ssr-nonce.md b/.changeset/late-ssr-nonce.md new file mode 100644 index 00000000000..7dd991ac6cf --- /dev/null +++ b/.changeset/late-ssr-nonce.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +Apply an `ssr.nonce` set after the router's SSR utilities are attached (for example in the `createStartHandler` callback) to the hydration scripts again. diff --git a/packages/router-core/benchmarks/ssr-streaming/run.mjs b/packages/router-core/benchmarks/ssr-streaming/run.mjs index 2d1f2245c26..45985aedfef 100644 --- a/packages/router-core/benchmarks/ssr-streaming/run.mjs +++ b/packages/router-core/benchmarks/ssr-streaming/run.mjs @@ -319,6 +319,19 @@ const transformExportNames = { : 'transformReadableStreamWithRouter', } +// Older revisions take the nonce as a string; newer ones take a getter. The +// worker always passes a getter, so wrap factories that still expect a string. +function hydrationOwnerFactorySource(sourceRoot, importName, name) { + const ownerSource = existsSync(join(sourceRoot, 'ssr/hydrationScripts.ts')) + ? readFileSync(join(sourceRoot, 'ssr/hydrationScripts.ts'), 'utf8') + : '' + const takesNonceGetter = + /export function createHydrationScripts\(\s*getNonce:/.test(ownerSource) + return takesNonceGetter + ? `const ${name} = ${importName}\n` + : `const ${name} = (getNonce, initialSources) => ${importName}(getNonce(), initialSources)\n` +} + // The worktree transform imports protocol constants from hydrationScripts. // Its producer is not used by this benchmark worker, so the Vite-only script // string can stay empty in the benchmark bundle. @@ -344,7 +357,12 @@ async function bundle(implementation) { entry, suite === 'owner' || suite === 'owner-soak' ? `import { runHydrationOwner, runHydrationOwnerSoak } from ${JSON.stringify(resolve(here, 'worker.ts'))}\n` + - `import { createHydrationScripts } from ${JSON.stringify(join(sourceRoot, 'ssr/hydrationScripts.ts'))}\n` + + `import { createHydrationScripts as createHydrationScriptsImplementation } from ${JSON.stringify(join(sourceRoot, 'ssr/hydrationScripts.ts'))}\n` + + hydrationOwnerFactorySource( + sourceRoot, + 'createHydrationScriptsImplementation', + 'createHydrationScripts', + ) + `const values = Object.fromEntries(process.argv.slice(2).map((value) => value.replace(/^--/, '').split('=', 2)))\n` + `${suite === 'owner-soak' ? 'runHydrationOwnerSoak' : 'runHydrationOwner'}(createHydrationScripts, ${JSON.stringify(implementation)}, values.scenario)\n` : `import { run, runProbe } from ${JSON.stringify(resolve(here, 'worker.ts'))}\n` + @@ -374,8 +392,18 @@ async function bundleWarmComparison() { entry, suite === 'owner-warm' ? `import { runWarmHydrationOwnerComparison } from ${JSON.stringify(resolve(here, 'worker.ts'))}\n` + - `import { createHydrationScripts as origin } from ${JSON.stringify(join(originSourceRoot, 'ssr/hydrationScripts.ts'))}\n` + - `import { createHydrationScripts as worktree } from ${JSON.stringify(join(candidateSourceRoot, 'ssr/hydrationScripts.ts'))}\n` + + `import { createHydrationScripts as originImplementation } from ${JSON.stringify(join(originSourceRoot, 'ssr/hydrationScripts.ts'))}\n` + + `import { createHydrationScripts as worktreeImplementation } from ${JSON.stringify(join(candidateSourceRoot, 'ssr/hydrationScripts.ts'))}\n` + + hydrationOwnerFactorySource( + originSourceRoot, + 'originImplementation', + 'origin', + ) + + hydrationOwnerFactorySource( + candidateSourceRoot, + 'worktreeImplementation', + 'worktree', + ) + `const values = Object.fromEntries(process.argv.slice(2).map((value) => value.replace(/^--/, '').split('=', 2)))\n` + `const result = runWarmHydrationOwnerComparison({ origin, worktree }, values.scenarios.split(','), Number(values.warmups), Number(values.iterations), Number(values.batchRequests))\n` + `process.stdout.write(JSON.stringify(result) + '\\n')\n` diff --git a/packages/router-core/benchmarks/ssr-streaming/worker.ts b/packages/router-core/benchmarks/ssr-streaming/worker.ts index 5a12fe29201..69b158b4c5c 100644 --- a/packages/router-core/benchmarks/ssr-streaming/worker.ts +++ b/packages/router-core/benchmarks/ssr-streaming/worker.ts @@ -1877,7 +1877,7 @@ type HydrationOwner = { } type CreateHydrationOwner = ( - nonce: string | undefined, + getNonce: () => string | undefined, initialSources: ReadonlyArray, ) => HydrationOwner @@ -1966,7 +1966,7 @@ function measureHydrationOwner( maxMemory(peak, delta(memory(), baseline!)) } } - const owner = createHydrationOwner(undefined, []) + const owner = createHydrationOwner(() => undefined, []) owner.takeInitialHydrationScriptTags() const output = owner.claimOutput() owner.liftBarrier() diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index ecf443168de..5c553915b27 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -541,6 +541,14 @@ export interface RouterOptions< */ origin?: string ssr?: { + /** + * The CSP nonce added to the router's inline hydration scripts. + * + * It may be set after the router is created (for example in the + * `createStartHandler` callback), but it must be final before `` + * renders: the initial scripts read it then, and the first streamed + * script reuses the same opening tag for the rest of the response. + */ nonce?: string } } diff --git a/packages/router-core/src/ssr/STREAMING.md b/packages/router-core/src/ssr/STREAMING.md index 12d3bd7b085..e1301f9e9c3 100644 --- a/packages/router-core/src/ssr/STREAMING.md +++ b/packages/router-core/src/ssr/STREAMING.md @@ -31,7 +31,7 @@ Seroval produces complete hydration sources. The hydration state owns these tasks: -- It stores the request nonce. +- It reads the request nonce from the router options when it takes the initial tags and when it frames the first streamed script, which caches the opening tag for the rest of the response. A nonce set after attachment still applies, but it must be final before `` renders; development builds warn when the streamed nonce differs from the initial one. - It creates the initial tags and the boundary. - It frames late records. - It keeps sources in FIFO order. diff --git a/packages/router-core/src/ssr/hydrationScripts.ts b/packages/router-core/src/ssr/hydrationScripts.ts index 79222e5d731..2dc9909593e 100644 --- a/packages/router-core/src/ssr/hydrationScripts.ts +++ b/packages/router-core/src/ssr/hydrationScripts.ts @@ -77,6 +77,10 @@ function escapeAttribute(value: string) { return value.replace(/[&"'<>]/g, (char) => `&#${char.charCodeAt(0)};`) } +function createScriptOpening(nonce: string | undefined) { + return nonce ? `