You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Since #1135 (commit a4e01eb, merged 2026-09-24), Trusted Server can no longer serve some Next.js App Router pages. The trigger is a page whose React Server Components data (the self.__next_f.push(...) flight scripts) contains a googletagmanager.com or google-analytics.com URL, on a deployment that enables both [integrations.nextjs] and [integrations.google_tag_manager]. The HTML transform fails partway through the document:
Fastly: the client gets 200 OK and the headers, then the body stream is abandoned. With a small page, no body bytes arrive at all.
Axum:502 Bad Gateway with the body An internal error occurred. Cloudflare and Spin use the same buffered path and the same error mapping, so by code reading they return the same 502. I did not run those two.
Loading gtag with next/script is enough to trigger it. Take this Next.js 14.2.33 App Router root layout:
importScriptfrom"next/script";exportdefaultfunctionRootLayout({ children }: {children: React.ReactNode}){return(<htmllang="en"><body>{children}<Scriptsrc="https://www.googletagmanager.com/gtag/js?id=G-TEST"strategy="afterInteractive"/></body></html>);}
next build serializes the client component's props into the page's flight data:
Served through Trusted Server at a4e01eb with both integrations enabled, that page comes back as 200 OK with an empty, aborted body on Fastly (Viceroy) and as a 502 on Axum. The same happens in two other cases:
A layout that passes the standard GTM snippet to next/script as inline children.
The repository's own App Router fixture crates/trusted-server-core/src/html_processor.test.html. Its flight data carries https://www.googletagmanager.com/ns.html?id=… for a <noscript> iframe.
The same inputs work at 4c6d26a, the commit before #1135: they are served completely, with both the Next.js and the GTM rewrite applied. At a4e01eb they also work when only one of the two integrations is enabled. CI did not catch this. The App Router fixture test in google_tag_manager.rs (test_html_processing_with_fixture) enables GTM without Next.js. The only core tests that enable both integrations (fragmented_next_data_survives_with_gtm_enabled and fragmented_next_data_with_trailing_g_survives_gtm) use __NEXT_DATA__ pages with no GTM URL in them.
Steps to reproduce
Unit tests (repository only). Add these tests to mod tests in crates/trusted-server-core/src/integrations/google_tag_manager.rs, next to test_html_processing_with_fixture. The module already has make_settings, config_from_settings, create_html_processor, IntegrationRegistry and the streaming pipeline types in scope.
/// An App Router flight script that mentions a GTM URL must still be/// processed, and the Next.js rewrite inside it must survive.#[test]fnrsc_flight_script_containing_gtm_url_is_processed(){usecrate::streaming_processor::{Compression,PipelineConfig,StreamingPipeline};use std::io::Cursor;letmut settings = make_settings();
settings
.integrations.insert_config("google_tag_manager",&serde_json::json!({"enabled":true,"container_id":"GTM-ABC123"}),).expect("should update gtm config");
settings
.integrations.insert_config("nextjs",&serde_json::json!({"enabled":true,"rewrite_attributes":["href","link","url"]}),).expect("should update nextjs config");let registry = IntegrationRegistry::with_plan(&settings,Arc::new(crate::auction::compile_auction_plan(&settings).expect("should compile auction plan"),),).expect("should create registry");let html = r#"<html><body><script>self.__next_f.push([1,"{\"href\":\"https://origin.example.com/app\",\"gtm\":\"https://www.googletagmanager.com/gtm.js?id=GTM-ABC123\"}"])</script></body></html>"#;let processor = create_html_processor(config_from_settings(&settings,®istry));letmut pipeline = StreamingPipeline::new(PipelineConfig{input_compression:Compression::None,output_compression:Compression::None,chunk_size:8192,},
processor,);letmut output = Vec::new();
pipeline
.process(Cursor::new(html.as_bytes()),&mut output).expect("should process an App Router page that mentions GTM");let processed = String::from_utf8(output).expect("should produce UTF-8");assert!(
processed.contains(r#"\"href\":\"https://test.example.com/app\""#),"should keep the Next.js RSC rewrite: {processed}");}/// The repository's App Router fixture carries a GTM URL inside Next.js/// flight data. With both integrations enabled it must still be processed.#[test]fnhtml_fixture_with_gtm_and_nextjs_is_processed(){use std::io::Cursor;letmut settings = make_settings();
settings
.integrations.insert_config("google_tag_manager",&serde_json::json!({"enabled":true,"container_id":"GTM-522ZT3X6"}),).expect("should update gtm config");
settings
.integrations.insert_config("nextjs",&serde_json::json!({"enabled":true,"rewrite_attributes":["href","link","url"]}),).expect("should update nextjs config");let registry = IntegrationRegistry::with_plan(&settings,Arc::new(crate::auction::compile_auction_plan(&settings).expect("should compile auction plan"),),).expect("should create registry");let processor = create_html_processor(config_from_settings(&settings,®istry));letmut pipeline = StreamingPipeline::new(PipelineConfig{input_compression:Compression::None,output_compression:Compression::None,chunk_size:8192,},
processor,);let html_content = include_str!("../html_processor.test.html");letmut output = Vec::new();
pipeline
.process(Cursor::new(html_content.as_bytes()),&mut output).expect("should process the App Router fixture with GTM and Next.js enabled");let processed = String::from_utf8(output).expect("should produce UTF-8");assert!(
processed.contains("/integrations/google_tag_manager/gtm.js?id=GTM-522ZT3X6"),"should still rewrite the GTM preload link");}
---- integrations::google_tag_manager::tests::rsc_flight_script_containing_gtm_url_is_processed stdout ----
should process an App Router page that mentions GTM: Proxy error: Failed to process final chunk
╰─▶ Next.js RSC captured payload was not present in parser output
---- integrations::google_tag_manager::tests::html_fixture_with_gtm_and_nextjs_is_processed stdout ----
should process the App Router fixture with GTM and Next.js enabled: Proxy error: Failed to process chunk
╰─▶ Next.js RSC output contains an unknown generated placeholder
End to end on the Fastly adapter (Viceroy). This uses the integration-test config generator.
Build a Next.js 14.2.33 app with the layout above and src/app/page.tsx containing export default function Page() { return <a href="https://origin.example.com/reviews">Reviews</a>; }. Run next build, then copy .next/server/app/index.html to origin/gtag.html.
Copy crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.toml to app.toml. Set enabled = true under [integrations.nextjs] and [integrations.google_tag_manager], and delete the GTM upstream_url line.
Build generate-viceroy-config (cargo build --manifest-path crates/trusted-server-integration-tests/Cargo.toml --target <host-triple> --bin generate-viceroy-config) and run it with --template crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml --app-config app.toml --output viceroy.toml --origin-url http://127.0.0.1:8899.
In origin/, run python3 -m http.server 8899 --bind 127.0.0.1.
At 4c6d26a the flight data came out with GTM's rewrite of the gtag URL (/integrations/google_tag_manager/gtag/js?id=G-TEST). When I ran the same page through the core pipeline with origin.example.com as the origin host, the Next.js rewrite of the page's link was applied as well.
Axum. I served the same three pages from a stub upstream through the Axum router (TrustedServerApp::routes_with_settings_and_services), which is what the Axum dev server runs, at a4e01eb. With GTM and Next.js enabled, all three returned 502 Bad Gateway, content-type: text/plain; charset=utf-8, body An internal error occurred\n. With only Next.js enabled, all three returned 200 with the processed page.
Expected behavior
With both integrations enabled, an App Router page whose flight data mentions GTM or GA is served completely with status 200, and the Next.js rewrite of its flight data is applied.
Actual behavior
The HTML transform returns an error for the document.
On Fastly the response has already been committed as 200 OK, so the client receives a truncated or empty body.
On Axum the client receives 502 Bad Gateway with An internal error occurred.
Root cause
Three script rewriters match a flight script: Next.js's script#__NEXT_DATA__ (which does not apply here), Next.js's NextJsRscPlaceholderRewriter on script, and GTM on script. create_html_processor gives each its own lol_html text handler that applies its action directly to the shared chunk (html_processor.rs:664-694). In lol_html 2.9.0 every handler reads the original chunk text, and for each chunk the last replace() wins. GTM is registered after Next.js (integrations/mod.rs:302-305 and 326-329).
It now captures each flight payload during the parse and replaces it with a generated placeholder (nextjs/rsc_placeholders.rs:96-106).
A post-parser stream processor must then find those placeholders in the parser output, in order. If a placeholder is missing or out of order, it returns an error for the whole document (nextjs/rsc_stream.rs:467-470, Next.js RSC output contains an unknown generated placeholder, and :521-524, Next.js RSC captured payload was not present in parser output).
GTM buffers every script that contains its hosts and, on the last fragment, replaces it with Self::rewrite_gtm_urls(text) built from the original text (google_tag_manager.rs:1045-1077). Because GTM runs last, its replacement discards the placeholder text. The stream processor then finds a placeholder missing and fails.
How the error reaches the client:
Fastly: the adapter calls publisher_response_into_streaming_response (trusted-server-adapter-fastly/src/app.rs:906-915). That function returns the origin status and headers with a lazy body. The processing error surfaces inside that body stream through publisher_stream_error (publisher.rs:275-277, 2707-2727). send_edgezero_response has already called stream_to_client(). On the error it logs EdgeZero streaming failed and drops the StreamingBody without finish() (trusted-server-adapter-fastly/src/main.rs:346-363). The fastly 0.12.1 crate documents: "A streaming body will be automatically aborted if it goes out of scope without calling finish()."
Axum:buffer_publisher_response_async returns TrustedServerError::Proxy. execute_handler maps it through http_error (trusted-server-adapter-axum/src/app.rs:118-130, 170-172), and IntoHttpResponse maps Proxy to 502 Bad Gateway with the generic message An internal error occurred (error.rs:128, 147).
Cloudflare and Spin: they call the same buffer_publisher_response_async and have identical http_error functions (trusted-server-adapter-cloudflare/src/app.rs:256, 274-286; trusted-server-adapter-spin/src/app.rs:173, 455-467).
Impact
Affected deployments: those that enable both [integrations.nextjs] (off by default) and [integrations.google_tag_manager] and serve App Router pages. The failure needs a GTM or GA URL inside flight data. Verified ways to get one: a next/script tag with a GTM or gtag src, the GTM snippet passed to next/script as children, and a server-rendered <noscript> GTM iframe (the repository fixture). A component that builds the URL in the browser from an ID alone should not put the host into flight data, but I did not test one.
What visitors get: on Fastly, a blank page or a page cut off where the failing chunk started, because the transfer is aborted after 200 OK. On the other adapters, a 502 error page. Monitoring that counts status codes sees 200 on Fastly.
Likelihood: every page I tried with a GTM or GA URL in its flight data failed at the production chunk size. The synthetic single-script test does not fail with 32-byte chunks, so the outcome depends on where fragment boundaries fall. The common case fails, though.
Proposed fix
Compose script rewriters in core instead of letting lol_html merge independent handlers. This is the change proposed in #1208: lol_html still matches selectors, and one text!("script") handler pipes each chunk through the matching rewriters in registration order and applies a single mutation. GTM then works on the placeholder rewriter's output, so the placeholders survive.
The three pages in the table are served completely under Viceroy with both integrations enabled (200, 3,854, 4,071 and 554,503 bytes).
Every existing test in a native cargo test -p trusted-server-core --lib run passes.
One behavior depends on order. In today's order (Next.js first), GTM sees placeholders, so a GTM URL inside flight data stays third-party: the prototype left {"src":"https://www.googletagmanager.com/gtag/js?id=G-TEST"} unchanged. GTM's attribute rewriter still rewrote the <link rel="preload"> Next.js emits for that script. Before #1135 the flight URL was rewritten. Running GTM's script rewriter ahead of Next.js's restores that. The prototype did so on the real page, together with the GTM buffering fix described in #1208.
Reordering alone, without composition, is not enough. It avoided the error on the real page, but GTM's rewrite of the flight URL was lost, and the two rewriters would still act on the same fragments independently.
A defensive follow-up, which I have not tested: when a captured placeholder is missing, the RSC stream processor could release the parser output unchanged and log a warning instead of failing the whole document. Any other script rewriter could otherwise trigger the same failure.
Done when
rsc_flight_script_containing_gtm_url_is_processed and html_fixture_with_gtm_and_nextjs_is_processed pass.
An adapter-level test serves an App Router page with a GTM URL in its flight data, with GTM and Next.js enabled, and gets 200 with complete HTML.
Whether GTM URLs inside flight data are rewritten is decided and pinned by a test.
Existing Next.js streaming and GTM tests still pass.
Description
Since #1135 (commit a4e01eb, merged 2026-09-24), Trusted Server can no longer serve some Next.js App Router pages. The trigger is a page whose React Server Components data (the
self.__next_f.push(...)flight scripts) contains agoogletagmanager.comorgoogle-analytics.comURL, on a deployment that enables both[integrations.nextjs]and[integrations.google_tag_manager]. The HTML transform fails partway through the document:200 OKand the headers, then the body stream is abandoned. With a small page, no body bytes arrive at all.502 Bad Gatewaywith the bodyAn internal error occurred. Cloudflare and Spin use the same buffered path and the same error mapping, so by code reading they return the same 502. I did not run those two.Loading gtag with
next/scriptis enough to trigger it. Take this Next.js 14.2.33 App Router root layout:next buildserializes the client component's props into the page's flight data:Served through Trusted Server at a4e01eb with both integrations enabled, that page comes back as
200 OKwith an empty, aborted body on Fastly (Viceroy) and as a 502 on Axum. The same happens in two other cases:next/scriptas inline children.crates/trusted-server-core/src/html_processor.test.html. Its flight data carrieshttps://www.googletagmanager.com/ns.html?id=…for a<noscript>iframe.The same inputs work at 4c6d26a, the commit before #1135: they are served completely, with both the Next.js and the GTM rewrite applied. At a4e01eb they also work when only one of the two integrations is enabled. CI did not catch this. The App Router fixture test in
google_tag_manager.rs(test_html_processing_with_fixture) enables GTM without Next.js. The only core tests that enable both integrations (fragmented_next_data_survives_with_gtm_enabledandfragmented_next_data_with_trailing_g_survives_gtm) use__NEXT_DATA__pages with no GTM URL in them.Steps to reproduce
Unit tests (repository only). Add these tests to
mod testsincrates/trusted-server-core/src/integrations/google_tag_manager.rs, next totest_html_processing_with_fixture. The module already hasmake_settings,config_from_settings,create_html_processor,IntegrationRegistryand the streaming pipeline types in scope.Observed at a4e01eb, both tests fail:
Both tests pass at 4c6d26a.
End to end on the Fastly adapter (Viceroy). This uses the integration-test config generator.
src/app/page.tsxcontainingexport default function Page() { return <a href="https://origin.example.com/reviews">Reviews</a>; }. Runnext build, then copy.next/server/app/index.htmltoorigin/gtag.html.cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1crates/trusted-server-integration-tests/fixtures/configs/trusted-server.integration.tomltoapp.toml. Setenabled = trueunder[integrations.nextjs]and[integrations.google_tag_manager], and delete the GTMupstream_urlline.generate-viceroy-config(cargo build --manifest-path crates/trusted-server-integration-tests/Cargo.toml --target <host-triple> --bin generate-viceroy-config) and run it with--template crates/trusted-server-integration-tests/fixtures/configs/viceroy-template.toml --app-config app.toml --output viceroy.toml --origin-url http://127.0.0.1:8899.origin/, runpython3 -m http.server 8899 --bind 127.0.0.1.viceroy target/wasm32-wasip1/release/trusted-server-adapter-fastly.wasm -C viceroy.toml --addr 127.0.0.1:7801curl -sS -D - -o out.html -H 'Accept: text/html' -H 'Sec-Fetch-Dest: document' -H 'Sec-Fetch-Mode: navigate' http://127.0.0.1:7801/gtag.htmlObserved with a4e01eb (Viceroy 0.21.0):
out.htmlstays empty. Viceroy logsEdgeZero streaming failed: Proxy error: streaming platform response body failed: Proxy error: Failed to process chunk … Next.js RSC output contains an unknown generated placeholder.The same harness with other pages, configurations and builds:
next/scriptsrcnext/scriptchildrenhtml_processor.test.html(554,355 bytes)At 4c6d26a the flight data came out with GTM's rewrite of the gtag URL (
/integrations/google_tag_manager/gtag/js?id=G-TEST). When I ran the same page through the core pipeline withorigin.example.comas the origin host, the Next.js rewrite of the page's link was applied as well.Axum. I served the same three pages from a stub upstream through the Axum router (
TrustedServerApp::routes_with_settings_and_services), which is what the Axum dev server runs, at a4e01eb. With GTM and Next.js enabled, all three returned502 Bad Gateway,content-type: text/plain; charset=utf-8, bodyAn internal error occurred\n. With only Next.js enabled, all three returned 200 with the processed page.Expected behavior
With both integrations enabled, an App Router page whose flight data mentions GTM or GA is served completely with status 200, and the Next.js rewrite of its flight data is applied.
Actual behavior
200 OK, so the client receives a truncated or empty body.502 Bad GatewaywithAn internal error occurred.Root cause
Three script rewriters match a flight script: Next.js's
script#__NEXT_DATA__(which does not apply here), Next.js'sNextJsRscPlaceholderRewriteronscript, and GTM onscript.create_html_processorgives each its own lol_html text handler that applies its action directly to the shared chunk (html_processor.rs:664-694). In lol_html 2.9.0 every handler reads the original chunk text, and for each chunk the lastreplace()wins. GTM is registered after Next.js (integrations/mod.rs:302-305and326-329).#1135 changed how the placeholder rewriter works:
nextjs/rsc_placeholders.rs:96-106).nextjs/rsc_stream.rs:467-470,Next.js RSC output contains an unknown generated placeholder, and:521-524,Next.js RSC captured payload was not present in parser output).Keepon every intermediate fragment and relied on a post-processor that re-parsed the final HTML. See the comment atrsc_placeholders.rs:57-62in 4c6d26a.GTM buffers every script that contains its hosts and, on the last fragment, replaces it with
Self::rewrite_gtm_urls(text)built from the original text (google_tag_manager.rs:1045-1077). Because GTM runs last, its replacement discards the placeholder text. The stream processor then finds a placeholder missing and fails.How the error reaches the client:
publisher_response_into_streaming_response(trusted-server-adapter-fastly/src/app.rs:906-915). That function returns the origin status and headers with a lazy body. The processing error surfaces inside that body stream throughpublisher_stream_error(publisher.rs:275-277,2707-2727).send_edgezero_responsehas already calledstream_to_client(). On the error it logsEdgeZero streaming failedand drops theStreamingBodywithoutfinish()(trusted-server-adapter-fastly/src/main.rs:346-363). The fastly 0.12.1 crate documents: "A streaming body will be automatically aborted if it goes out of scope without callingfinish()."buffer_publisher_response_asyncreturnsTrustedServerError::Proxy.execute_handlermaps it throughhttp_error(trusted-server-adapter-axum/src/app.rs:118-130,170-172), andIntoHttpResponsemapsProxyto502 Bad Gatewaywith the generic messageAn internal error occurred(error.rs:128,147).buffer_publisher_response_asyncand have identicalhttp_errorfunctions (trusted-server-adapter-cloudflare/src/app.rs:256,274-286;trusted-server-adapter-spin/src/app.rs:173,455-467).Impact
[integrations.nextjs](off by default) and[integrations.google_tag_manager]and serve App Router pages. The failure needs a GTM or GA URL inside flight data. Verified ways to get one: anext/scripttag with a GTM or gtagsrc, the GTM snippet passed tonext/scriptas children, and a server-rendered<noscript>GTM iframe (the repository fixture). A component that builds the URL in the browser from an ID alone should not put the host into flight data, but I did not test one.200 OK. On the other adapters, a 502 error page. Monitoring that counts status codes sees 200 on Fastly.__NEXT_DATA__do not hit this error. They have separate problems, tracked in GTM script rewriter clobbers Next.js __NEXT_DATA__ rewrites and can truncate it #1208 and Regression from #1135: __NEXT_DATA__ is corrupted when script text splits after_#1207.Proposed fix
Compose script rewriters in core instead of letting lol_html merge independent handlers. This is the change proposed in #1208: lol_html still matches selectors, and one
text!("script")handler pipes each chunk through the matching rewriters in registration order and applies a single mutation. GTM then works on the placeholder rewriter's output, so the placeholders survive.I prototyped it in a scratch copy of a4e01eb:
cargo test -p trusted-server-core --librun passes.One behavior depends on order. In today's order (Next.js first), GTM sees placeholders, so a GTM URL inside flight data stays third-party: the prototype left
{"src":"https://www.googletagmanager.com/gtag/js?id=G-TEST"}unchanged. GTM's attribute rewriter still rewrote the<link rel="preload">Next.js emits for that script. Before #1135 the flight URL was rewritten. Running GTM's script rewriter ahead of Next.js's restores that. The prototype did so on the real page, together with the GTM buffering fix described in #1208.Reordering alone, without composition, is not enough. It avoided the error on the real page, but GTM's rewrite of the flight URL was lost, and the two rewriters would still act on the same fragments independently.
A defensive follow-up, which I have not tested: when a captured placeholder is missing, the RSC stream processor could release the parser output unchanged and log a warning instead of failing the whole document. Any other script rewriter could otherwise trigger the same failure.
Done when
rsc_flight_script_containing_gtm_url_is_processedandhtml_fixture_with_gtm_and_nextjs_is_processedpass.Affected area
HTML processing / JS injection
Version
main at a4e01eb
Related
__NEXT_DATA__. Its proposed fix also resolves this issue._#1207: the other script rewriter regression from Make body hold parser-aware and stream Next.js processing #1135, with the same fix.scriptselector rewriter.text.replace()wins" for overlapping script rewriters.