Skip to content

Preserve Next.js and Google Tag Manager rewrites in streamed pages - #1222

Open
ChristianPavilonis wants to merge 19 commits into
mainfrom
fix/1206-1208-script-composition
Open

ChristianPavilonis wants to merge 19 commits into
mainfrom
fix/1206-1208-script-composition

Conversation

@ChristianPavilonis

@ChristianPavilonis ChristianPavilonis commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Fix aborted App Router responses and invalid or duplicated Pages Router data by composing script rewrites instead of letting one integration overwrite another.
  • Preserve both Next.js origin-URL rewrites and first-party Google Tag Manager/Analytics URLs inside React Server Components flight data, with correct payload lengths.
  • Keep buffering bounded and response-local, preserve raw script source, and restore original data safely when a payload cannot be rewritten.
  • Reject malformed effective request hosts with 400 before inserting request-derived bytes into HTML or Flight source.

Spec

Not a feature. Fixes #1206, #1207 and #1208.

Changes

Core paths below are relative to crates/trusted-server-core/src/.

File Change
html_processor.rs Match selectors as before, compose every script stage, and emit each fragment once without escaping JavaScript as HTML text.
http_util.rs Validate the selected request host, retaining valid ports, IPv6, local hosts, and header precedence. Malformed hosts return a bad-request error without fallback or reflection.
publisher.rs Propagate host validation errors before publisher and page-bids URL rewriting.
proxy.rs Propagate validation errors when signing needs request metadata to determine the scheme.
integrations/mod.rs Document that Next.js must precede GTM so both stages observe the same fragment; remove the unused ScriptTextAccumulator re-export.
integrations/registry.rs Document script-only selectors and how replacement, suppression, and final-fragment flushing compose; remove the unused ScriptTextAccumulator type and implementation.
integrations/prebid.rs Adapt the test helper to fallible request metadata extraction.
integrations/google_tag_manager.rs Preserve fragment order with bounded response-local buffering and rewrite quoted flight URLs without damaging escaped queries.
integrations/nextjs/mod.rs Connect enabled Google Tag Manager rewriting to flight processing and add chunk, compression, ownership, and existing-fixture regressions.
integrations/nextjs/rsc.rs Apply both URL rewrites after reading original record boundaries, then recompute their decoded byte lengths.
integrations/nextjs/rsc_placeholders.rs Capture multiple payloads atomically, find qualified pushes after foreign references, preserve complete serialized channel-2 form state, and enter conservative document-wide fallback with a source-free reason warning.
integrations/nextjs/rsc_stream.rs Carry the optional flight transformation and add fallback checks that preserve original payloads and strict placeholder errors.
integrations/nextjs/script_rewriter.rs Mark Pages Router fragments so JSON strings cannot be mistaken for executable flight scripts.
integrations/nextjs/script_lexer.rs Use bounded lexical filtering to distinguish executable Flight calls from inert spellings in strings, comments, and regular expressions. This is not a full JavaScript parser.
test_support.rs Provide canned identity/gzip origin responses and shared assertions for complete adapter output, without changing the existing default fixture.
CHANGELOG.md Record composed rewrites, conservative document-wide fallback, and malformed-host rejection under Security.
docs/guide/integrations/google_tag_manager.md Document GTM Flight prerequisites, recomputed lengths, fallback triggers, queued restoration, and policy boundaries.
docs/guide/integrations/nextjs.md Document document-wide fallback and inert data-head hints, including Next.js-only pages.
docs/guide/ts-dev-proxy.md Explain effective-host precedence, validation, 400 responses, and adapter trust policy.
docs/guide/creative-processing.md Explain composed script stages and fragment suppression rather than element removal.
crates/trusted-server-adapter-axum/tests/routes.rs Verify complete rewritten 200 responses, malformed-host rejection with 400, and compatibility with local hosts, IPv6, and ports.
crates/trusted-server-adapter-fastly/src/app.rs Drain the lazy response stream completely and verify identity/gzip output, rewrites, and payload lengths.

Scope

These three issues share the same script-processing defect, so this PR fixes them together. The diff is mostly regression tests and test fixtures covering fragment boundaries, compression, multiple payloads, buffering limits, bootstrap scripts, and escaped queries. Runtime changes stay in the shared Rust processing path; the adapter changes are tests only.

There are no new settings, dependencies, integration-order changes, or adapter error-handling changes. Missing generated placeholders still fail rather than silently hiding a lost rewrite.

Retained boundaries:

  • Standalone Google Tag Manager rewriting retains pre-existing chunk-boundary limitations. The missed-rewrite window includes splits from just after the opening quote through the initial googl portion of a GTM URL; the ordinary-script path can also half-rewrite a URL split just after its scheme. The Flight-specific correction does not fix that separate ordinary-script path.
  • A hostname split across separate flight payload scripts is safely preserved rather than rewritten; input chunks within a captured payload are handled.
  • Some unsafe cases disable Flight rewriting for the rest of the document: an uncaptured channel-1 data head, a binary channel-3 or other unsupported push, a capture/script/held-output limit, or an invalid group. Queued payloads that have not streamed are restored to their original bytes; complete groups already streamed cannot be restored.
  • The conservative raw-head guard also treats inert self.__next_f.push([1, text in strings, comments, or JSON-LD as a fallback trigger. Pages quoting Flight source can therefore lose later origin/GTM rewrites, including with only nextjs enabled. This trade-off is pinned by the JSON-LD regression.
  • Document-wide fallback logs its reason without publisher source. The stream stage does not emit a second limit warning when the placeholder stage already initiated fallback.
  • Complete serialized channel-2 form state is inert and does not disable later rewriting. Binary channel-3 remains unsupported.

Deferred review discussion

  • Flight GTM rewriting applies to quoted or whole-span URLs, including bare origins and values used as rendered content. This differs from SSR HTML attribute and text rewriting and may cause hydration mismatches or visible-text changes. Restricting Flight rewrites to script-bearing values needs a separate policy discussion. Rejecting bare origins alone would not solve the full mismatch and could break scripts that append tracking paths later. No Flight-policy change is included in this review follow-up.
  • Performance optimization is deferred. This review follow-up adds no lexer, qualifier-tail, or GTM-transform optimization.
  • Host validation checks syntax, not whether a forwarded host comes from a trusted proxy. Forwarded-header precedence and adapter trust policy remain unchanged. Requests without host metadata retain their existing empty-host behavior.

Closes

Closes #1206
Closes #1207
Closes #1208

Test plan

Current pushed head: a063906ae77e0c16fa2a665300352b6f7c2f0722, removing the unused accumulator and its re-export. Local validation on this change passes formatting, native core tests with 3,046 passed, test-fastly including 3,040 core tests passed and 6 ignored, test-axum, clippy-fastly, and clippy-axum. GitHub checks for this head have started and are not yet complete.

Additional local checks pass on this head: test-fastly-reuse, test-cloudflare, test-spin, cross-adapter parity with 17 passed, native build-digest tests, all remaining target-matched clippy aliases, core rustdoc on WASM, and the WASM clock lint. Unchanged JS/docs/script checks reuse the passing preceding-head CI evidence.

All 23 GitHub checks passed on the preceding head ad5f5035a6c7121d8d8dc8d9289870a58153bd95, including required test/format checks, integration and browser suites, and adapter checks. Its core WASM CI log confirms the warn-once, foreign-reference, and JSON-LD fallback regressions ran and passed. The native-only gate on the logging regression has been removed.

The local checks below record earlier implementation validation. They were not rerun just to reply to review threads; current-head verification uses the CI results above.

Regression failures were recorded before their corresponding fixes, including lost placeholders, invalid JSON, duplicate/reordered fragments, bootstrap bypass, escaped-query misses, and raw-source escaping. The malformed-host adapter regression returns 200 on the original code and 400 after validation. The deferred Flight-policy concern is recorded above.

  • cargo test-fastly && cargo test-axum
  • cargo clippy-fastly && cargo clippy-axum
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npm exec --no -- vitest run using the installed Vitest without dependency installation.
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • Docs format: the unchanged npm run format script passed with a command-local PATH to the existing Prettier 3.8.1, matching the docs lockfile; the ordinary command cannot resolve Prettier because docs-local dependencies are absent.
  • WASM release build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve; direct Viceroy client testing was used instead.
  • Other: native core tests, Cloudflare/Spin tests, CLI/browser fixtures, host adapter parity, all remaining target-matched lint aliases, and the JavaScript build passed.
  • Complete Axum and Fastly response bodies were checked for identity and gzip, including normal bootstrap, escaped queries, Unicode, both URL rewrites, and decoded payload lengths.
  • Actual local Viceroy clients received complete 200 responses for identity and gzip, with valid payload JSON, both rewrites, the final HTML suffix, and no generated placeholders.

Earlier local test counts are omitted because they predate the latest push. See the current-head CI results above. Existing target-specific panic/doctest skips remain; native panic counterparts and all CLI browser fixtures ran. Local transport testing used debug WASM and a synthetic HTTP origin, not production TLS.

Remaining review work

  • Removed the unused ScriptTextAccumulator type, implementation, re-export, and type-specific imports in a063906ae. This completes Aram's outstanding cleanup request.
  • Observer-state consolidation and a shared, guarded test capture logger are deferred optional cleanups. The current suite still has a single logger installer.
  • Pre-existing removed-script placeholder failure is tracked in Next.js: removed script bodies can enqueue missing Flight placeholders #1441; T-header lookalikes inside JSON are tracked in Next.js: T-header lookalikes inside JSON can leave stale record lengths #1442. Neither is claimed fixed here.
  • At squash merge, replace the generated branch-commit prose with a short imperative subject and a clean body, rather than carrying the session-residue commit subjects into main.

Checklist

  • Changes follow AGENTS.md conventions.
  • No unwrap() in new production code; use expect("should ...").
  • Uses the project's log macros rather than println!, as required by AGENTS.md.
  • New behavior has regression tests.
  • No secrets or credentials committed.

Apply matching script stages in sequence and emit each fragment once.
Keep GTM buffering response-local and bounded, and rewrite captured
Flight payloads only after reading their original record boundaries.
Preserve unsafe originals and recompute decoded record lengths.

Cover Pages and App Router data, bootstrap controls, escaped queries,
source-order fallback, and complete identity/gzip adapter responses.

Refs #1206, #1207, #1208
@ChristianPavilonis
ChristianPavilonis marked this pull request as ready for review October 1, 2026 12:06
@ChristianPavilonis
ChristianPavilonis requested review from aram356, dhruv8sh and prk-Jr and removed request for aram356 and prk-Jr October 1, 2026 12:07

@dhruv8sh dhruv8sh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

Composes script stages instead of letting one integration's Replace overwrite another's, and threads GTM rewriting into captured Flight payloads with recomputed T lengths. The fallback paths are conservative and the boundary-sweep test coverage is excellent; no blocking issues found. A few design questions and minor cleanups below.

3 of the inline comments below carry a one-click GitHub suggestion — use Commit suggestion (or Add suggestion to batch for several at once) to apply them as commits on the PR branch. The remaining comments describe the fix in prose because the change touches lines outside the diff or needs an author decision.

Non-blocking

🤔 thinking / ⛏ nitpick

  • Flight GTM rewriting is broader than the HTML policy (bare origins, rendered content) — see inline at crates/trusted-server-core/src/integrations/google_tag_manager.rs:194
  • ContentType::Html removes the last escaping layer for request-host-derived bytes — see inline at crates/trusted-server-core/src/html_processor.rs:716
  • Script rewriter selectors are now effectively script-only — see inline at crates/trusted-server-core/src/html_processor.rs:682
  • Per-character work on every inline script — see inline at crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs:476
  • Redundant enabled filter — see inline at crates/trusted-server-core/src/integrations/nextjs/mod.rs:114
  • Assertions without messages — see inline at crates/trusted-server-core/src/integrations/google_tag_manager.rs:3606 and crates/trusted-server-core/src/html_processor.rs:838

Cross-cutting / body-level findings

  • 🏕 Registration order is now load-bearing but undocumented — GTM's protects_current_flight_fragment reads a snapshot the Next.js placeholder stage writes for the same fragment, so nextjs must stay ahead of google_tag_manager in builders() (crates/trusted-server-core/src/integrations/mod.rs). Likewise NextJsNextDataRewriter must precede NextJsRscPlaceholderRewriter, which consumes its one-shot next_data_fragment. A reorder would make GTM read the previous fragment's flag, and only the mixed integration tests would catch it. Suggest a comment alongside the existing "This must remain first" note (apply manually — mod.rs is outside the diff):

            // `nextjs` must precede `google_tag_manager`: GTM's script stage reads the
            // Flight-protection snapshot the Next.js stage records for the same fragment.
  • 📝 PR description misses the new file — the Changes table doesn't list the new nextjs/script_lexer.rs (318 lines, the lexical filter that decides executable vs inert __next_f spellings), and Scope says "11-file diff" while the diff is 12 files. Worth adding so reviewers know it exists and that it is intentionally "not a JavaScript parser".

  • 👍 Atomic capture and fallback discipline — batched capture in rsc_placeholders.rs discovers every range and validates the queue budget before committing anything, fallback always restores untouched originals (enabled_gtm_transform_never_mutates_fallback_originals locks that in), and the every-split-boundary sweeps plus identity/gzip end-to-end checks through both Axum and Fastly with decoded T-length assertions are exactly the right regression net for this class of streaming bug.

CI Status

  • integration tests (Fastly EC lifecycle): PASS
  • browser integration tests: PASS
  • integration tests: PASS
  • cargo test (cross-adapter parity): PASS
  • CodeQL: PASS
  • cargo test (ts CLI, native): PASS
  • format-docs: PASS (required)
  • cargo check (cloudflare native + wasm32-unknown-unknown): PASS
  • cargo check/build/test (spin native + wasm32-wasip1): PASS
  • format-typescript: PASS (required)
  • Analyze (rust): PASS
  • Analyze (javascript-typescript): PASS
  • prepare integration artifacts: PASS
  • vitest: PASS
  • cargo test (axum native): PASS
  • cargo fmt: PASS (required)
  • Analyze (javascript-typescript): PASS
  • cargo test: PASS (required)
  • CLAUDE.md symlink guard: PASS
  • Analyze (actions): PASS

Comment thread crates/trusted-server-core/src/integrations/google_tag_manager.rs Outdated
Comment thread crates/trusted-server-core/src/html_processor.rs Outdated
Comment thread crates/trusted-server-core/src/html_processor.rs Outdated
Comment thread crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs Outdated
Comment thread crates/trusted-server-core/src/integrations/nextjs/mod.rs Outdated
Comment thread crates/trusted-server-core/src/integrations/google_tag_manager.rs Outdated
Comment thread crates/trusted-server-core/src/html_processor.rs Outdated
Keep regex literals after statement blocks inert without hiding Flight calls after object, function, or class expressions. Bound brace and pending-class context alongside existing lexical nesting limits.

Add fragment-boundary and identity/gzip regressions for origin/GTM rewrites and decoded Flight T lengths. Both reported brace/slash regressions were reproduced before the fix.

@prk-Jr prk-Jr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Request changes for three reproduced P2 correctness issues:

  • A superclass function body confuses the new lexer, allowing GTM rewriting to produce an invalid Flight T-record byte length.
  • A trailing __next_f property reference disables rewriting for the current and subsequent Flight scripts.
  • An ordinary GTM URL before a Flight call is rewritten differently depending on input chunk boundaries.

The inline comments describe the reproductions and proposed remedies.

Validation: 2,876 existing native core tests passed, as did Axum tests, cargo clippy-axum, cargo fmt --all -- --check, and the focused Fastly complete-response regression under Viceroy. Additional scratch regression tests reproduced the issues above. GitHub CI was green at submission.

Reviewed commit: d738d93f70e3a1e79bfa9256cc730a317d14381a.

Comment thread crates/trusted-server-core/src/integrations/nextjs/script_lexer.rs
Comment thread crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs Outdated
Comment thread crates/trusted-server-core/src/integrations/google_tag_manager.rs Outdated

@prk-Jr prk-Jr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

Re-reviewed the streamed Next.js/GTM composition and the host-validation follow-up at e81e5d98d7518836ac2aad3e80728785b535c9c4. Request changes: the three previously reproduced P2 correctness findings remain unresolved; the affected runtime code is unchanged from the earlier review, and the author confirms the follow-up commits do not address them.

Blocking

🔧 wrench

The existing inline threads contain the reproductions and proposed remedies. No duplicate inline comments or new findings are included in this re-review.

Validation

Compared the affected runtime code with the previously reviewed commit d738d93f70e3a1e79bfa9256cc730a317d14381a: the lexer and payload-discovery files are byte-identical, and the GTM change is an assertion message only. Reviewed the complete changed files and removed diff code with independent reviewers. Verified the remote CI results at the reviewed head, including all eight target-matched clippy steps; no full local CI rerun was performed.

CI Status

  • browser integration tests: PASS
  • integration tests (Fastly EC lifecycle): PASS
  • integration tests: PASS
  • CodeQL: PASS
  • cargo test (ts CLI, native): PASS
  • cargo check (cloudflare native + wasm32-unknown-unknown): PASS
  • cargo test (axum native): PASS
  • CLAUDE.md symlink guard: PASS
  • Analyze (javascript-typescript): PASS
  • cargo fmt: PASS (required)
  • Analyze (rust): PASS
  • format-typescript: PASS (required)
  • Analyze (javascript-typescript): PASS
  • cargo test: PASS (required)
  • format-docs: PASS (required)
  • cargo check/build/test (spin native + wasm32-wasip1): PASS
  • cargo test (cross-adapter parity): PASS
  • prepare integration artifacts: PASS
  • vitest: PASS
  • Analyze (actions): PASS

@prk-Jr prk-Jr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

Re-reviewed at b3538b47d. 4d73741 resolves the three P2s from the previous round. I checked each fix against its regression test: the superclass lexer state, trailing __next_f references, and the chunk-invariant ordinary GTM before captured Flight. One correctness issue remains in the same family as the first P2. A lexer false negative still lets GTM corrupt raw Flight T lengths, because protection depends only on the masked-code view. Requesting changes for a fail-closed guard instead of more per-spelling lexer fixes.

Blocking

🔧 wrench

  • Lexer false negatives fail open into invalid Flight T lengths. Five valid-JS prefixes reproduce it. A raw-qualifier protection fallback was prototyped locally and fixes all five. See inline at crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs:435.

Non-blocking

🤔 thinking

  • Underscore host labels now return 400. See inline at crates/trusted-server-core/src/http_util.rs:242.

Cross-cutting / body-level findings

  • 📌 GTM without Next.js still corrupts App Router Flight lengths. With nextjs disabled and GTM enabled, <script>self.__next_f.push([1,"1:T64,{\"tag\":\"http://www.googletagmanager.com/gtm.js?id=…\"…}"])</script> is rewritten to declare 0x64 (100) for a 101-byte body. protects_current_flight_fragment returns false when no Next.js state exists. By code reading, this path is unchanged from main, so it is out of scope here. It is worth a follow-up issue, since GTM-only deployments on App Router sites hit it with no Next.js involvement.
  • 👍 Prior-round fixes are well locked in. trailing_next_f_references_preserve_current_and_later_flight, ordinary_gtm_before_captured_flight_is_chunk_invariant and the expanded superclass sweep cover every source split plus identity/gzip. The Suppress → unprotected change in rewrite_claimed_fragment fixes chunk variance without touching GTM's raw fallback restoration.

Local validation (PR head b3538b47d)

  • cargo test -p trusted-server-core --lib --target aarch64-apple-darwin: 2,946 passed
  • cargo test-fastly (Viceroy): pass (core 2,940 passed / 6 ignored; adapter 196 passed)
  • cargo test-axum: pass
  • cargo clippy-fastly: pass
  • cargo fmt --all -- --check: pass

CI Status

All 19 checks pass, including cargo fmt, cargo test, axum, cloudflare, spin, the parity, CLI and integration suites, vitest, format-docs, format-typescript and CodeQL.

Comment thread crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs Outdated
Comment thread crates/trusted-server-core/src/http_util.rs Outdated

@prk-Jr prk-Jr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

Approving. Script stages now compose instead of overwriting each other, ContentType::Html stops entity-escaping JS, GTM state is per-document, and the raw push-head observer from af69781 closes the fail-open lexer path from round 3. I found no fail-open path in Next.js-emitted spellings. The earlier round-1 and round-3 points are resolved or explicitly deferred.

Verified locally at 514a617: core lib tests (2949 passed), cargo test-spin, cargo test-axum, Fastly adapter under Viceroy (196 passed), and clippy-fastly / clippy-axum / clippy-spin-native / clippy-spin-wasm all clean.

CI note: 7 jobs (integration, browser integration, spin, format-typescript, CodeQL rust/python/js-ts) were cancelled with "job was not acquired by Runner", which is an infra failure, not code. Please re-run them before merge.

Non-blocking follow-ups

  • 🤔 Inline-script cost with Next.js enabled (rsc_placeholders.rs ~L434/441/540). This adds numbers to the deferred perf thread. Release build, native arm64, 8 KB chunks, Next.js + GTM: a 2.1 MB non-Flight inline script goes from ~2 ms (base 8048301) to ~36 ms (~18×), and a 1.7 MB Flight push goes from ~8 ms to ~20 ms. WASM will be slower. A cheap pre-filter would avoid most of it: only mask and observe when the fragment plus a short carried tail contains __next_f, or when a head is in progress. Batched trimming instead of a per-char drain would also help. Worth a tracked issue if it stays deferred.
  • 🤔 Host validation applies to every proxied request (publisher.rs ~L4443, http_util.rs ~L185), including assets that never reach a script rewriter. An empty X-Forwarded-Host, a Forwarded value that is not visible ASCII, or a valueless host= now returns 400 instead of falling back to a lower-priority header. Low impact on Fastly and Spin, which strip these headers, but notable for Axum or Cloudflare behind a proxy. Either document it as intended or tolerate an unparseable Forwarded value that has no host=.
  • ⛏ Redundant RequestInfo::from_request in handle_page_bids (publisher.rs ~L6985). It duplicates the extraction ~30 lines earlier, and the first binding is still live.
  • ⛏ About 40 new assert!/assert_eq! calls lack a "should …" message (AGENTS.md). Examples: adapter-axum/tests/routes.rs:1079, adapter-fastly/src/app.rs:3191, test_support.rs, and nextjs/mod.rs.
  • 📝 For the deferred Flight GTM policy discussion: rewrite_gtm_rsc_span rewrites quoted analytics.google.com URLs and bare origins without any GTM marker. The ordinary script path deliberately doesn't treat that domain as a trigger.

@aram356 aram356 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

Re-reviewed at 514a617a0. The composition model, per-document GTM state, raw ContentType::Html emission and host validation hold up. Two new correctness bugs remain in the Flight path: a lexer miss can still leave invalid T lengths when the row continues in the next script (Next.js alone, a regression from main), and GTM's whole-span Flight routing can produce http:/integrations/… for a URL split after its scheme. The branch also conflicts with main, and the required format-typescript check never ran.

3 of the inline comments below carry a one-click GitHub suggestion. Use Commit suggestion (or Add suggestion to batch for several at once) to apply them as commits on the PR branch. The remaining comments describe the fix in prose because the change touches multiple files or lines outside the diff and can't be auto-applied.

Blocking

🔧 wrench

  • A Flight push the lexer misses still corrupts T lengths when its row continues in the next script: see inline at crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs:475
  • Whole-span routing half-rewrites a URL split after its scheme across Flight payload scripts: see inline at crates/trusted-server-core/src/integrations/google_tag_manager.rs:204
  • Conflicts with main: see cross-cutting below
  • Required CI never ran: see cross-cutting below

Non-blocking

🤔 thinking / ♻️ refactor / ⛏ nitpick

  • Non-null form state and binary pushes disable Flight rewriting for the rest of the page: see inline at crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs:40
  • Skip the script handlers when no script rewriter is registered: see inline at crates/trusted-server-core/src/html_processor.rs:665
  • Domain-lint markers for PR-added test hosts: see inline at crates/trusted-server-core/src/integrations/google_tag_manager.rs:3636 and crates/trusted-server-core/src/integrations/nextjs/mod.rs:1216

Cross-cutting / body-level findings

  • 🔧 Conflicts with main. google_tag_manager.rs conflicts with #1179, which moved GTM's buffer to a per-document ScriptTextAccumulator (registry.rs, re-exported from integrations/mod.rs). A resolution that keeps this PR's GtmScriptState checks out locally:

    • Take the PR side of both hunks (the struct and the top of rewrite). Main's comment above get_or_insert_with, its // allow-domain: comment and its new an_interrupted_document_leaves_no_residue_for_the_next_document test merge cleanly.
    • Restore Mutex in use std::sync::{Arc, LazyLock, Mutex}; (the auto-merge drops it) and drop the now-unused use crate::integrations::ScriptTextAccumulator; (clippy -D warnings fails on it).
    • Delete ScriptTextAccumulator from registry.rs and its re-export from integrations/mod.rs. After the merge it is public API with no users, and the deletion passes fmt and clippy.

    On that merged tree, cargo fmt --check, clippy-fastly, clippy-axum, test-axum, test-fastly and test-fastly-reuse pass, as do 2,956 native core tests, template_build_digest (no fixture update needed: the build digest rotates automatically) and both of main's new regression tests against this PR's logic.

  • 🔧 Required CI never ran. Seven jobs were cancelled with "The job was not acquired by Runner of type hosted even after multiple attempts" and never re-run: format-typescript (required), integration tests, browser integration tests, cargo check/build/test (spin native + wasm32-wasip1), Analyze (rust), Analyze (javascript-typescript) and Analyze (python). The ruleset requires the branch to be up to date with main, so the merge above will start a fresh run; please make sure these go green.

  • 📌 The ordinary GTM script path has the same scheme-split problem, and it predates this PR. rewrite_gtm_urls (L515-537, called at L1182) tries GTM_WHOLE_URL_PATTERN, which is meant for attribute values, on held script text that can begin at any chunk boundary. A split just after the scheme gives j.src='http:/integrations/google_tag_manager/gtm.js?id='+i+dl, and a split at the opening quote leaves later URLs in the script unrewritten. Main behaves the same, so this is follow-up material: a quoted-only helper for the script path fixes it (the URL is then left unrewritten instead of corrupted). Relatedly, the retained boundary in the PR description ("a hostname split inside its initial six-byte marker prefix") understates the window: every split from just after the opening quote through googl misses, about 17 positions for "https://www.googletagmanager.com.

  • 🌱 GTM and Next.js now depend on each other. GTM calls nextjs::protects_current_flight_fragment (google_tag_manager.rs:1125), nextjs/mod.rs reads GoogleTagManagerConfig and takes rewrite_gtm_rsc_span, and correctness depends on builders() order. A third integration that needs Flight protection or a Flight transform would add more cross-imports. A neutral home would fit better as a follow-up: a registry-owned "current fragment holds raw Flight" flag on IntegrationScriptContext, and a span-transform hook such as IntegrationRegistration::with_flight_span_transform that GTM registers.

  • 🤔 The lexer keeps producing this bug class. The superclass case, the five round-3 prefixes and the continuation case above all come from script_lexer.rs misclassifying publisher code that shares a <script> with a Flight push. Next.js never emits that: each push is its own script. An alternative that removes most of the 513-line lexer is to capture only scripts whose whole content is a sequence of Next.js-shaped pushes, and to treat any other script with a raw qualified data head as unsupported (bypass_rsc), as main did for unsupported pushes. The trade-off is losing rewriting on pages where publisher code and a push share a script. Worth weighing before the next lexer fix.

  • 🏕 Docs and changelog. docs/guide/integrations/google_tag_manager.md ("1. Script Rewriting") doesn't mention that GTM/GA URLs inside App Router Flight payloads are now rewritten with recomputed T lengths, or that this depends on nextjs being enabled. No guide mentions the new 400 for a malformed effective host; main's docs/guide/ts-dev-proxy.md:291 says core "prefers X-Forwarded-Host, then Host", omitting Forwarded and the 400. docs/guide/creative-processing.md:752 still says remove_node() deletes the script element, which contradicts the new ScriptRewriteAction::RemoveNode doc in registry.rs. CHANGELOG.md has no [Unreleased] entry.

  • 🏕 Leftover function-local imports. crates/trusted-server-adapter-axum/tests/routes.rs:883-885 still has use std::sync::Arc; and use trusted_server_core::test_support::nextjs_auction; inside nextjs_auction_output_holds_until_the_structural_body_close, now that the PR hoists both to module level. AGENTS.md asks for no local imports.

  • ♻️ Transform plumbing. Option<fn(&str) -> String> now runs through eight signatures (rewrite_span, both rewrite functions, the factory and the processor), and the fix for the whole-span bug pushes rewrite_span to seven arguments, the AGENTS.md limit. A small SpanRewrite { rewriter, origin_host, request_host, request_scheme, transform } with fn apply(&self, span, record_text) would carry it. The single-payload loop (rsc.rs ~L465-513) and the combined loop (~L715-763) differ only in the length function and could share one helper, and rewrite_rsc_scripts_combined_with_limit now only forwards None.

Lower-priority notes (each verified, all minor):

  • 🤔 html_processor.rs:667-679: the flat match-flag vector is reset at every <script> start tag and never restored at its end tag. Inside <svg> a <script> can nest, so the outer script's remaining text runs with the inner script's flags (<svg><script>a<script id="target">b</script>c</script></svg> feeds "c" to a script#target stage). Negligible in practice; worth a doc line.
  • 📝 http_util.rs:214-255 accepts any single DNS label, including script. With that host, bare-host Flight rewriting (host_rewrite.rs) turns an origin-supplied raw </origin.example.com> into </script>. Not a regression (main had no validation, and Next.js never emits a raw < in Flight), but it qualifies the reasoning that validation makes raw emission safe. Not treating < or </ before the host as a boundary would close it.
  • 🤔 http_util.rs:202-203: an empty Host: now returns 400, while an absent Host still yields an empty host. RFC 9112 sends an empty Host when the target has no authority, so the two cases mean the same thing. It only affects Axum or proxy setups.
  • 📝 rsc.rs:223 (unchanged by this PR): the unescaped-length model miscounts \xe9, \0, octal escapes, \q, line continuations and \u{…}. Next.js's JSON.stringify plus htmlEscapeJsonString never emits them, and the GTM transform can't introduce them, so nothing is needed here.
  • 📝 google_tag_manager.rs:1147-1159: the limit counts held bytes plus the current fragment, so a single final fragment larger than max_buffered_script_bytes now passes through unrewritten, where main rewrote it. Unreachable at the defaults (16 MiB limit, 8 KB chunks); a one-line comment would make the intent clear.
  • ⛏ google_tag_manager.rs:451-455: GtmScriptState and its fields have no doc comments, and the release blocks at L1129-1134 and L1154-1159 are identical. An impl GtmScriptState { fn release(&mut self, content: &str) -> ScriptRewriteAction } would remove the duplication.
  • ⛏ script_lexer.rs: MAX_TEMPLATE_DEPTH (L6) also caps braces, parentheses and pending classes, so MAX_LEXICAL_DEPTH would read better; self.word.len() < 10 (L130) is a magic number; ScriptLexer (L56) and its expression_start / statement_start invariants are undocumented. In rsc_placeholders.rs, slicing code at offsets taken from content relies on mask emitting one # per masked byte; a debug_assert_eq!(code.len(), content.len()) would document it.
  • ⛏ rsc.rs:626 (mostly pre-existing): the classifier compares payload bytes with the limit, but the combined rewriter adds 7 marker bytes per boundary, so a group within 7 * (n - 1) bytes of the limit is classified rewritable and then skipped with two warn! lines. Removing the origin-host early exit for GTM-enabled groups makes this more reachable.

CI Status

  • integration tests (Fastly EC lifecycle): PASS
  • integration tests: CANCELLED
  • browser integration tests: CANCELLED
  • CodeQL: SKIPPED
  • Analyze (actions): PASS
  • Analyze (javascript-typescript): PASS
  • cargo test (ts CLI, native) (ubuntu-latest): PASS
  • cargo test (cross-adapter parity): PASS
  • cargo test (ts CLI, native) (macos-latest): PASS
  • prepare integration artifacts: PASS
  • format-docs: PASS (required)
  • cargo test (axum native): PASS
  • CLAUDE.md symlink guard: PASS
  • cargo test: PASS (required)
  • cargo fmt: PASS (required)
  • cargo check (cloudflare native + wasm32-unknown-unknown): PASS
  • vitest: PASS
  • cargo check/build/test (spin native + wasm32-wasip1): CANCELLED
  • format-typescript: CANCELLED (required)
  • Analyze (rust): CANCELLED
  • Analyze (javascript-typescript): CANCELLED
  • Analyze (python): CANCELLED

Comment thread crates/trusted-server-core/src/integrations/google_tag_manager.rs Outdated
Comment thread crates/trusted-server-core/src/html_processor.rs Outdated
Comment thread crates/trusted-server-core/src/integrations/google_tag_manager.rs Outdated
Comment thread crates/trusted-server-core/src/integrations/nextjs/mod.rs Outdated
@aram356 aram356 added this to the 202610 milestone Oct 12, 2026
aram356 added a commit that referenced this pull request Oct 12, 2026
Brings #1222 at 514a617.

Integration decisions:
- RequestInfo::from_request keeps #1251's source order (authenticated
  forwarder, immutable ingress, runtime origin, Host) and applies
  #1222's host syntax check to whichever host is selected. It returns
  BadRequest for a malformed or non-text selected host. Raw Forwarded
  and X-Forwarded-Host are never selected in the RC, so #1222's tests
  for those headers now assert they are ignored instead of rejected.
- RC-only callers (DataDome protection, Fastly compat, Spin, forwarder,
  orchestrator and publisher tests) handle the fallible signature.
  DataDome only reads RequestInfo behind an authenticated origin and
  keeps its request_host fallback.
- The first-party sign path prefers the authenticated forwarder scheme
  (#1251) and propagates #1222's host error.
- The GTM rewriter takes #1222's per-document GtmScriptState, which
  supersedes main's ScriptTextAccumulator use from #1179; state still
  lives in IntegrationDocumentState, and #1179's two-document residue
  tests remain.
@aram356

aram356 commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator

@ChristianPavilonis I don't see resolution for the previous review. Can you please address with comment and resolve it? Thanks.

aram356 added a commit that referenced this pull request Oct 15, 2026
ChristianPavilonis and others added 2 commits October 16, 2026 15:53
Resolve merge conflicts preserving streamed rewrites and base regressions; exclude build-only git metadata from legacy settings parsing.

@aram356 aram356 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

Re-reviewed at 6b317b558. Every round-1 blocker is resolved, and each fix was checked against the original repro: the lexer-miss continuation now fails closed with consistent T lengths, a Flight URL split after its scheme is preserved (no bad T header across about 100k sweep cases), non-null form state no longer disables rewriting, the handler guard and lint markers are applied, the branch is up to date with main, and all 22 checks pass.

Requesting changes for what is still open: how far an inert data-head hint should reach, the settings change that came in through a merge commit, the round-1 leftovers (ScriptTextAccumulator, the local imports, docs, changelog and PR description) and a test gap on the new record_text sites.

3 of the inline comments below carry a one-click GitHub suggestion. Use Commit suggestion (or Add suggestion to batch for several at once) to apply them as commits on the PR branch. The remaining comments describe the fix in prose because the change touches multiple files or lines outside the diff and can't be auto-applied.

Requested before merge

  • 🤔 Inert self.__next_f.push([1, text in any script disables all later Flight rewriting: see inline at crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs:35
  • ♻️ Settings change from merge commit d0cfd88 duplicates open PR #1326: see inline at crates/trusted-server-core/src/settings.rs:3057
  • ♻️ ScriptTextAccumulator is now dead public API: see inline at crates/trusted-server-core/src/integrations/registry.rs:575
  • ⛏ Function-local imports still present: see inline at crates/trusted-server-adapter-axum/tests/routes.rs:10
  • 🏕 Docs, changelog and PR description: see inline at crates/trusted-server-core/src/integrations/registry.rs:69
  • 🏕 Pin every record_text = false site: see inline at crates/trusted-server-core/src/integrations/nextjs/mod.rs:1322

Other findings

  • 🤔 The document-wide Flight fallback is silent: see inline at crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs:586
  • ⛏ Restore main's comment that the merge dropped: see inline at crates/trusted-server-core/src/integrations/google_tag_manager.rs:1135
  • ⛏ Say why a combined record body is still safe to route: see inline at crates/trusted-server-core/src/integrations/google_tag_manager.rs:194
  • ♻️ Observer state sprawl: see inline at crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs:73
  • ⛏ Oversized pass-through branch is unreachable through lol_html: see inline at crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs:258
  • 📌 A removed <script src> with an inline Flight push fails the whole response (pre-existing): see inline at crates/trusted-server-core/src/html_processor.rs:684
  • 📌 An unanchored T-header lookalike can leave a stale length (pre-existing): see inline at crates/trusted-server-core/src/integrations/nextjs/rsc.rs:478

Lower-priority notes:

  • 📝 A comment or BOM inside a data head (push(/*c*/[1, or push(\u{FEFF}[1,) still yields a stale T length, but only when the lexer also misses that push (for example after foo: { bar() } /'/.test(s);). Without such a prefix this head handles both, [1.0, and [0x1, correctly, and main is stale on the comment spelling even without a prefix, so this is not a regression.
  • ⛏ The 22ef7ca07 subject is 103 characters and ends with a period (CONTRIBUTING.md asks for about 50 and no period). A squash merge makes this moot.

Round-1 verification

Round-1 finding Status at 6b317b558
Lexer miss leaves a stale T length when the row continues in the next script Fixed, fail-closed: all five prefixes give consistent lengths at chunk 16, 32 and 8192, Next.js only and mixed; the regression test fails when the bypass assignment is removed
Whole-span routing half-rewrites a URL split after its scheme Fixed for the reported shape; see the test-gap comment for the other three span sites
Non-null form state disables later rewriting Fixed; binary channel 3 keeps the document fallback by design, as documented
Script handlers registered with zero rewriters Applied, byte-identical to the suggestion
Domain-lint markers Applied; ts dev lint domains --changed-vs a9037770a reports 0 violations
Conflict with main Resolved; up to date with main, main's #1179 regression tests pass
Required CI never ran Resolved; all checks pass

CI Status

  • browser integration tests: PASS
  • integration tests (Fastly EC lifecycle): PASS
  • integration tests: PASS
  • CodeQL: PASS
  • cargo test (ts CLI, native) (macos-latest): PASS
  • Analyze (rust): PASS
  • Analyze (python): PASS
  • cargo test (axum native): PASS
  • CLAUDE.md symlink guard: PASS
  • cargo fmt: PASS (required)
  • Analyze (javascript-typescript): PASS
  • cargo test: PASS (required)
  • format-typescript: PASS (required)
  • cargo check (cloudflare native + wasm32-unknown-unknown): PASS
  • Analyze (actions): PASS
  • format-docs: PASS (required)
  • cargo check/build/test (spin native + wasm32-wasip1): PASS
  • cargo test (cross-adapter parity): PASS
  • cargo test (ts CLI, native) (ubuntu-latest): PASS
  • prepare integration artifacts: PASS
  • vitest: PASS
  • Analyze (javascript-typescript): PASS

Comment thread crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs Outdated
Comment thread crates/trusted-server-core/src/integrations/nextjs/mod.rs
Comment thread crates/trusted-server-core/src/integrations/google_tag_manager.rs
Comment thread crates/trusted-server-core/src/integrations/google_tag_manager.rs
Comment thread crates/trusted-server-core/src/integrations/registry.rs
Comment thread crates/trusted-server-core/src/html_processor.rs
Comment thread crates/trusted-server-core/src/integrations/nextjs/rsc.rs
Comment thread crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs
…nce capture fix, conservative fallback regressions, once-only warnings, span-site tests, documentation/changelog updates, settings rollback, and routine cleanup. No public
aram356 added a commit that referenced this pull request Oct 21, 2026

@aram356 aram356 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

Re-reviewed at b5aac6daf. The round-2 code items are addressed: the foreign-reference trigger is fixed (13 foreign-reference shapes now capture fully at every split, with about 250k probe runs and a fuzz pass all producing consistent T rows), the inert data-head fallback is pinned by the JSON-LD regression test and stated in the CHANGELOG, the page-wide fallback now warns once, the new span-site test kills all four record_text mutants, and the settings rollback, restored comment, doc line and local-import cleanup are in. Requesting changes because none of the required checks have run on this head: the branch conflicts with main, so GitHub did not start the test or format workflows. Smaller items are inline and below.

4 of the inline comments below carry a one-click GitHub suggestion. Use Commit suggestion (or Add suggestion to batch for several at once) to apply them as commits on the PR branch. The remaining comments describe the fix in prose because the change touches lines outside the diff and can't be auto-applied.

Blocking

  • 🔧 Required CI has not run on this head. Only the two CodeQL analyses ran on b5aac6daf; cargo test, cargo fmt, format-docs and format-typescript (all required) and the rest of the suite did not, because test.yml / format.yml trigger on pull_request and GitHub skips those runs while the PR conflicts with main. The conflict is CHANGELOG.md only (#1238 added a ### Fixed entry at the same spot); keeping both sides, with this PR's entries first, resolves it. On that merged tree, cargo fmt --check, clippy-fastly, clippy-axum, test-axum and all 3,046 native core tests pass locally (no #1327 failures now that #1326 is on main), and #1238's new header rewriting uses the validated request host. Please merge main and make sure the full CI run goes green.

Still open from earlier rounds

  • ♻️ ScriptTextAccumulator is still dead public API (third round). integrations/registry.rs:219 and the re-export at integrations/mod.rs:40 have no users since this PR replaces GTM's use of it. The round-2 thread on registry.rs:575 is still open.
  • 📝 PR description is stale. It still cites 2,883 native core tests (3,046 on the merged tree); it has no ## Spec line, which main's #1385 template asks for ("Not a feature" for a fix); the Changes table leaves out CHANGELOG.md and the three docs files; "Retained boundaries" still reads per payload and doesn't mention the document-wide fallback triggers, the inert-hint behavior (including Next.js-only pages) or the once-per-document warning; and the rsc_placeholders.rs row leaves out the foreign-reference fix, channel-2 form state and the warning.
  • 📌 Follow-up issues for the two round-2 pre-existing bugs are not filed (the removed <script src> placeholder error, and the unanchored T-header lookalike). Their threads are still open.
  • ♻️ Observer state sprawl (round-2, optional) is unchanged.

Other findings

  • 🏕 The warn-once test never runs in CI: see inline at crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs:807
  • ⛏ A second, mislabeled fallback warning: see inline at crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs:71
  • 🌱 Unguarded process-wide test logger: see inline at crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs:841
  • 📝 Fallback paragraph lists two of about ten triggers: see inline at docs/guide/integrations/google_tag_manager.md:156
  • ⛏ Fastly does not use sanitize_forwarded_headers: see inline at crates/trusted-server-core/src/http_util.rs:152
  • ⛏ CHANGELOG details: see inline at CHANGELOG.md:37
  • ⛏ Stale expect messages: see inline at crates/trusted-server-core/src/integrations/nextjs/mod.rs:794
  • ⛏ Commit message. The b5aac6daf subject is 243 characters of session residue ("Prepared scoped, uncommitted changes for findings 0–6: …"), not imperative, and cut off at "No public", with no body. CONTRIBUTING.md asks for an imperative subject of about 50 characters, and main's squash merges keep branch commits as body bullets, so this text would land on main. Rewording the commit (or editing the squash body at merge) avoids that, for example "Warn once on document-wide Flight fallback".

Lower-priority notes:

  • 📝 A comment or BOM inside a data head (push(/*c*/[1,, push(\u{FEFF}[1,) still yields a stale T length when the lexer also misses that push, unchanged from round 2; main is stale on the comment spelling even without a lexer miss, so this is not a regression.
  • 📝 Two record_text sites (rsc.rs L508 and L758, the remaining spans) survive mutation, but they are equivalent mutants: a remaining span after a T body starts at a row id or a marker and can never match the anchored whole-URL pattern.

Round-2 verification

Round-2 finding Status at b5aac6daf
Inert data-head text disables later Flight rewriting Accepted (option a): pinned by json_ld_data_head_preserves_later_flight_and_queued_originals, stated in the CHANGELOG; foreign-reference trigger fixed
Document-wide fallback is silent Fixed with bypass_document(); see the CI-gating and double-warning comments
record_text test gap Fixed; the new test fails under each of the L474, L706, L724 and L735 mutants
Restore main's per-document comment Done
record_text doc line Done
settings.rs change from a merge commit Reverted; identical to main's #1326 after merging
ScriptTextAccumulator Not done
Function-local imports in routes.rs Done
Docs, changelog, PR description Docs and CHANGELOG added (details inline); description not updated
Pre-existing 📌 bugs No follow-up issues yet
Observer state sprawl Not done (optional)
Unreachable pass-through branch Defensive comment added

CI Status

  • Analyze (javascript-typescript): PASS
  • Analyze (python): PASS
  • cargo test: not run (required)
  • cargo fmt: not run (required)
  • format-docs: not run (required)
  • format-typescript: not run (required)

Comment thread crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs Outdated
Comment thread docs/guide/integrations/google_tag_manager.md Outdated
Comment thread crates/trusted-server-core/src/http_util.rs Outdated
Comment thread CHANGELOG.md Outdated
Comment thread crates/trusted-server-core/src/integrations/nextjs/mod.rs Outdated
Resolve changelog merge conflict while preserving both branches’ changes
…, clarify fallback/security documentation, and apply accepted routine corrections.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

4 participants