Skip to content

fix: give twinOf the request, and match the CSP nonce on hydration - #34

Merged
cody-dot-js merged 3 commits into
mainfrom
cody/twin-context-csp-nonce
Nov 4, 2026
Merged

cody-dot-js merged 3 commits into
mainfrom
cody/twin-context-csp-nonce

Conversation

@cody-dot-js

@cody-dot-js cody-dot-js commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

Why

What

  • markdownTwins.twinOf(path, context): it gets the Hono context, the same as rewrite and headers, in development and in production. If it gives undefined, the request gets no twin. A twinOf with one parameter works as before.
  • New cspNonce() in @ngrok/app-server/client. On the server, it gives requestContext()?.cspNonce. In the browser, it reads the .nonce property of the document's first [nonce] element, because a browser hides the attribute's value. One call works in entry.server, the root Layout, and TanStack's getRouter.
  • ./client has a browser export condition (dist/client-browser.js). Vite's client build resolves it, so the browser gets a cspNonce() with no server code. Vite's server build, Node.js, Bun, and Deno do not resolve browser. A test keeps the export names of both builds the same.
  • The README's CSP section shows the React Router Layout step. It uses cspNonce() in each render snippet.
  • The request path does no new work: twinOf gets one more argument.

Results

Bundle that imports only cspNonce Before After Change
Browser, minified 580 B 191 B −389 B (−67%)
Browser, gzip 331 B 168 B −163 B (−49%)

Vite 8.3.5 lib build of a one-line entry against dist/, with minify: true, one run each. The numbers include the entry's own console.log call.

twinOf got only the path, so a request with a query that rewrite sends
to the framework still got the prerendered twin. twinOf now gets the
Hono context, the same as rewrite, and undefined keeps the request off
the twin.

Fixes #33
HydratedRouter takes no nonce, so React warned that the server's nonce
did not match. cspNonce() from @ngrok/app-server/client gives the same
nonce on the server and in the browser. The README also shows twinOf
with the request context.

Fixes #32
@cody-dot-js cody-dot-js self-assigned this Nov 4, 2026
Copilot AI balanced review requested due to automatic review settings November 4, 2026 18:20
@changeset-bot

changeset-bot Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6c6d629

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@ngrok/app-server Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Pass request context to twinOf and preserve CSP nonces during hydration

🐞 Bug fix ✨ Enhancement 📝 Documentation 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Pass the Hono request context to twinOf so query-dependent requests can bypass markdown twins.
• Add cspNonce() to supply the same nonce during server rendering and browser hydration.
• Document framework integration and cover both fixes with tests.
Diagram

graph TD
  R["HTTP request"] --> P["Prerendered middleware"] --> T{"Twin eligible?"} -->|yes| F["Markdown twin"]
  T -->|no| A["Framework render"]
  R --> C["CSP plugin"] --> N["cspNonce helper"] --> H["Hydrated UI"]
Loading
High-Level Assessment

The focused approach fits both fixes: reuse the Hono context already supplied to neighboring callbacks, and expose a framework-independent nonce helper. Passing a nonce through React Router's HydratedRouter was not viable because it accepts no nonce.

Files changed (8) +207 / -18

Enhancement (1) +1 / -0
index.tsExport cspNonce from the client entry point +1/-0

Export cspNonce from the client entry point

• Makes the new helper available through '@ngrok/app-server/client'.

src/client/index.ts

Bug fix (2) +39 / -12
csp-nonce.tsAdd an environment-aware CSP nonce helper +24/-0

Add an environment-aware CSP nonce helper

• Reads the active request's nonce on the server and the first nonce-bearing document element's '.nonce' property in the browser.

src/client/csp-nonce.ts

static.tsPass Hono context to markdown twin selection +15/-12

Pass Hono context to markdown twin selection

• Changes the 'twinOf' callback type and calls in production and development to include the request context. A callback returning 'undefined' leaves the request on the non-twin path.

src/core/plugins/static.ts

Tests (2) +92 / -0
csp-nonce.test.tsTest server and browser nonce consistency +59/-0

Test server and browser nonce consistency

• Checks that the helper returns the response's CSP nonce in both environments and returns 'undefined' when no nonce is available.

src/client/csp-nonce.test.ts

static.test.tsTest query-dependent twin bypass in both modes +33/-0

Test query-dependent twin bypass in both modes

• Verifies that production and development pass request context to 'twinOf', serving a twin without a query and falling through when a query is present.

src/core/plugins/static.test.ts

Documentation (3) +75 / -6
csp-nonce-client.mdRecord the client nonce helper patch +14/-0

Record the client nonce helper patch

• Adds a patch changeset describing 'cspNonce()' and its React Router and TanStack Start usage.

.changeset/csp-nonce-client.md

twin-of-context.mdRecord the request-aware twinOf patch +14/-0

Record the request-aware twinOf patch

• Adds a patch changeset explaining the context argument and how returning 'undefined' bypasses a twin.

.changeset/twin-of-context.md

README.mdDocument nonce hydration and request-aware twins +47/-6

Document nonce hydration and request-aware twins

• Shows how to use 'cspNonce()' in server rendering, the React Router root Layout, and TanStack Start. Adds a query-dependent 'twinOf' example and clarifies that declining a twin allows normal routing.

README.md

@qodo-code-review

qodo-code-review Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (1)

Grey Divider


Informational

1. Option summary takes four sentences 📜 Skill insight ⚙ Maintainability
Description
The JSDoc summaries for markdownTwins in src/core/plugins/static.ts and cspNonce in
src/client/csp-nonce.ts run to four and two sentences respectively, with this PR adding two
twinOf context sentences to markdownTwins. Editors display these summaries on hover, so readers
encounter the longer prose before the supporting details.
Code

src/core/plugins/static.ts[R76-78]

+	 * page has a twin when `twinOf` names a file in the client build. `twinOf`
+	 * gets the request path and the request's Hono context, the same as
+	 * `rewrite`. It returns `undefined` for a request with no twin.
Relevance

● Weak

Team repeatedly rejected 'shorten JSDoc summary to one sentence' suggestions, including recent
contract-summary cases in the same style. Only example-adding JSDoc suggestions were accepted.

PR-#21
PR-#19
PR-#24

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The markdownTwins JSDoc has multiple sentences before its bulleted list, including two added at
lines 76–78. The cspNonce JSDoc has two full sentences before its bullet list and @example tag;
together, these blocks show that both contract summaries exceed one sentence.

src/core/plugins/static.ts[73-80]
src/client/csp-nonce.ts[3-5]
Skill: simplified-technical-english

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The JSDoc summaries for `markdownTwins` and `cspNonce` contain multiple sentences. Make each contract summary one sentence while preserving its supporting details below the summary.

## Fix Focus Areas
- src/core/plugins/static.ts[73-80]
- src/client/csp-nonce.ts[3-5]

## Recommended Fix
Use a one-sentence `markdownTwins` summary, such as “Serves the markdown twin of a page to a request that prefers markdown.” Move the details about `twinOf` receiving request context and returning `undefined` into the bulleted list. Rewrite the `cspNonce` summary as one sentence, for example, “The CSP nonce of the document, the same on the server and in the browser so hydration matches.” Keep its bullet list and `Why:` note below the summary in the description body.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗


Grey Divider

Context sources
✅ Compliance rules (platform): 12 rules
✅ Web pages:
  +6 more
✅ Cross-repo context — repo relationships
  Explored: repo: ngrok-private/frontend (sha: e1bab4b0) — View relationship
✅ REVIEW.md
Review mode: Auto: ⚖️ Balanced: Touches CSP nonce handling and request-path twin routing; security-adjacent, so not lite.

Grey Divider

Tip of the day
💡 Did you know, you can commit Qodo's fix in one click with committable suggestions (GitHub & GitLab)

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues are recorded for this change.

No open issues 🎉

What changed in this PR

This PR addresses request-aware markdown selection (#33) and CSP nonce hydration mismatches (#32) in @ngrok/app-server.

Changes:

  • Pass the Hono context to twinOf in development and production so apps can skip twins for specific requests.
  • Export cspNonce() to read the request nonce on the server and the document nonce in the browser.
  • Add regression tests, update framework setup examples, and include two patch changesets.

Conventions pass: Applied Minimize reader load to keep the summary concise. Checked the supplied API documentation and changesets. Automated checks were not run because this review environment prohibits project execution.

File Description
src/​core/​plugins/​static.ts Pass the request context to markdown twin callbacks.
src/​core/​plugins/​static.test.ts Cover query-based twin exclusion in both environments.
src/​client/​index.ts Export cspNonce.
src/​client/​csp-nonce.ts Read the nonce on the server and in the browser.
src/​client/​csp-nonce.test.ts Cover matching nonces and absent nonces.
README.md Document request-aware twins and framework nonce setup.
.changeset/​twin-of-context.md Add patch release notes for request-aware twins.
.changeset/​csp-nonce-client.md Add patch release notes for cspNonce.

Review effort: Balanced

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Benchmarks

Runtime App Route Base req/s Head req/s Change Head p99
node react-router / prerendered 10,619 10,703 +0.8% 10.3 ms
node react-router /ssr rendered 1,880 1,871 -0.5% 67.0 ms
node react-router /assets/*.js 8,088 7,773 -3.9% 12.8 ms
node react-router /ping route 12,941 12,976 +0.3% 10.8 ms
node tanstack-start / prerendered 10,452 10,671 +2.1% 10.4 ms
node tanstack-start /ssr rendered 2,179 2,175 -0.2% 57.9 ms
node tanstack-start /assets/*.js 7,164 7,136 -0.4% 14.3 ms
node tanstack-start /ping route 13,195 13,081 -0.9% 9.3 ms
bun react-router / prerendered 11,351 11,539 +1.7% 8.4 ms
bun react-router /ssr rendered 1,228 1,151 -6.2% 222.5 ms
bun react-router /assets/*.js 7,880 7,915 +0.4% 11.9 ms
bun react-router /ping route 9,196 9,175 -0.2% 30.0 ms
bun tanstack-start / prerendered 11,641 11,278 -3.1% 8.5 ms
bun tanstack-start /ssr rendered 1,650 1,612 -2.3% 59.8 ms
bun tanstack-start /assets/*.js 7,303 7,340 +0.5% 12.3 ms
bun tanstack-start /ping route 8,942 9,170 +2.5% 30.1 ms
deno react-router / prerendered 5,136 5,103 -0.7% 20.7 ms
deno react-router /ssr rendered 1,056 1,068 +1.1% 104.3 ms
deno react-router /assets/*.js 4,671 4,633 -0.8% 21.0 ms
deno react-router /ping route 7,495 7,478 -0.2% 15.6 ms
deno tanstack-start / prerendered 5,194 5,137 -1.1% 19.1 ms
deno tanstack-start /ssr rendered 1,290 1,315 +2.0% 92.0 ms
deno tanstack-start /assets/*.js 4,494 4,479 -0.3% 20.2 ms
deno tanstack-start /ping route 7,607 7,643 +0.5% 15.1 ms

⚠️ marks a route that got slower by more than 10%, that fails, or whose server exits under load.

How this was measured

Base 8764863, head 6c6d629. The production build of each fixture app runs as <build>/server/app-server.js. For each route, oha sends 64 connections for 5 s after a 1 s warmup, with Accept-Encoding: gzip, deflate, br. Each value is the median of 3 rounds, and base and head alternate in each round.

Machines:

  • node v26.13.0: AMD EPYC 9V74 80-Core Processor, 4 CPUs, linux. The server runs on half of the CPUs, and oha on the other half.
  • bun 1.3.14: AMD EPYC 9V74 80-Core Processor, 4 CPUs, linux. The server runs on half of the CPUs, and oha on the other half.
  • deno deno 2.9.8 (stable, release, x86_64-unknown-linux-gnu): AMD EPYC 7763 64-Core Processor, 4 CPUs, linux. The server runs on half of the CPUs, and oha on the other half.

The browser bundle kept the requestContext() branch of cspNonce(), which
never runs there. The ./client export now has a browser condition, so
Vite's client build gets a cspNonce() that only reads the document.
@cody-dot-js
cody-dot-js merged commit 3221e97 into main Nov 4, 2026
8 checks passed
@cody-dot-js
cody-dot-js deleted the cody/twin-context-csp-nonce branch November 4, 2026 18:45
@github-actions github-actions Bot mentioned this pull request Nov 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants