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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/late-ssr-nonce.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/router-core/src/ssr/STREAMING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 frames scripts, so a nonce set after attachment still applies.
- It creates the initial tags and the boundary.
- It frames late records.
- It keeps sources in FIFO order.
Expand Down
23 changes: 18 additions & 5 deletions packages/router-core/src/ssr/hydrationScripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ function escapeAttribute(value: string) {
return value.replace(/[&"'<>]/g, (char) => `&#${char.charCodeAt(0)};`)
}

function createScriptOpening(nonce: string | undefined) {
return nonce ? `<script nonce="${escapeAttribute(nonce)}">` : '<script>'
}

function createInitialTags(
sources: ReadonlyArray<string>,
nonce: string | undefined,
Expand Down Expand Up @@ -178,7 +182,7 @@ class HydrationScriptsOwner implements HydrationScriptOutput {
private opening: string | undefined

constructor(
private readonly nonce: string | undefined,
private readonly nonceSource: HydrationScriptsNonce,
initialSources?: ReadonlyArray<string>,
) {
// ServerSsr exposes this method as a bare callback, so keep this single
Expand All @@ -199,6 +203,14 @@ class HydrationScriptsOwner implements HydrationScriptOutput {
return this.outputState
}

// Read on use: an app may set `ssr.nonce` after the SSR utilities are
// attached, e.g. in the `createStartHandler` callback.
private get nonce() {
return typeof this.nonceSource === 'function'
? this.nonceSource()
: this.nonceSource
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

get error() {
return this.outputError
}
Expand Down Expand Up @@ -450,9 +462,7 @@ class HydrationScriptsOwner implements HydrationScriptOutput {
}

private pullReady() {
const scriptOpening = (this.opening ??= this.nonce
? `<script nonce="${escapeAttribute(this.nonce)}">`
: '<script>')
const scriptOpening = (this.opening ??= createScriptOpening(this.nonce))
let codeUnits = scriptOpening.length + DYNAMIC_CLOSE_SOURCE.length
let batchLength = 0
for (
Expand Down Expand Up @@ -680,9 +690,12 @@ class HydrationScriptsOwner implements HydrationScriptOutput {
}
}

/** A nonce, or a getter that reads it when the first script is framed. */
type HydrationScriptsNonce = string | undefined | (() => string | undefined)

/** Create the hydration-script owner for one server request. */
export function createHydrationScripts(
nonce: string | undefined,
nonce: HydrationScriptsNonce,
initialSources?: ReadonlyArray<string>,
): HydrationScripts {
return new HydrationScriptsOwner(nonce, initialSources)
Expand Down
4 changes: 3 additions & 1 deletion packages/router-core/src/ssr/ssr-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,9 @@ export function attachRouterServerSsrUtils({
// still be pending when the response ends.
let settled = false
let disposeSerialization: (() => void) | undefined
const hydrationScripts = createHydrationScripts(router.options.ssr?.nonce)
const hydrationScripts = createHydrationScripts(
() => router.options.ssr?.nonce,
)

const serverSsr: ServerSsr = {
hydrationScripts,
Expand Down
37 changes: 37 additions & 0 deletions packages/router-core/tests/transformStreamWithRouter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,43 @@ describe('transformReadableStreamWithRouter — real SSR scripts', () => {
)
})

test('uses an ssr nonce set after the SSR utilities are attached', async () => {
const streamed = createDeferred<string>()
const router = createRealSsrRouter({ streamed: streamed.promise })
attachRouterServerSsrUtils({ router, manifest: undefined })
// Apps may set the nonce later, e.g. in the createStartHandler callback.
router.options.ssr = { ...router.options.ssr, nonce: 'late' }

await router.load()
await router.serverSsr!.dehydrate()

const initialScripts = router.serverSsr!.takeInitialHydrationScriptTags()!
expect(initialScripts.before.length).toBeGreaterThan(0)
for (const tag of [...initialScripts.before, initialScripts.boundary]) {
expect(tag.attrs?.nonce).toBe('late')
}

const upstream = makeManualUpstream()
const output = transformReadableStreamWithRouter(router, upstream.stream)
upstream.push(
`<html><body><main>app</main>${renderInitialScripts(initialScripts)}`,
)
streamed.resolve('done')
await flush(20)
upstream.push('</body></html>')
upstream.close()

const html = await readAll(output)
const streamEnd = html.lastIndexOf(`${GLOBAL_TSR}.e()`)
expect(streamEnd).toBeGreaterThan(-1)
expect(
html.lastIndexOf('<script nonce="late">', streamEnd),
).toBeGreaterThan(
html.lastIndexOf(renderManagedScript(initialScripts.boundary)),
)
expect(html).not.toContain('<script>')
})

test('fuses the final resolver and stream-end scripts', async () => {
const streamed = createControlledStream<string>()
const router = createRealSsrRouter({ streamed: streamed.stream })
Expand Down