From 739ac6c099480856fcc1fd750d1d90b19d9c9ba6 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 7 Sep 2026 19:08:24 +0530 Subject: [PATCH 01/17] Specify parser-aware body hold and Next.js streaming --- ...aware-body-hold-nextjs-streaming-design.md | 554 ++++++++++++++++++ 1 file changed, 554 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-07-850-parser-aware-body-hold-nextjs-streaming-design.md diff --git a/docs/superpowers/specs/2026-09-07-850-parser-aware-body-hold-nextjs-streaming-design.md b/docs/superpowers/specs/2026-09-07-850-parser-aware-body-hold-nextjs-streaming-design.md new file mode 100644 index 000000000..768538706 --- /dev/null +++ b/docs/superpowers/specs/2026-09-07-850-parser-aware-body-hold-nextjs-streaming-design.md @@ -0,0 +1,554 @@ +# Parser-aware body hold and streaming-safe Next.js processing + +**Date:** 2026-09-07 + +**Status:** Proposed + +**Issue:** [IABTechLab/trusted-server#850](https://github.com/IABTechLab/trusted-server/issues/850) + +## 1. Decision + +Use `lol_html`'s structural `body` end-tag handler to place an internal, +request-unique control token in the transformed byte stream. Move the auction hold after +HTML parsing, recognize only that generated token, and replace it with the reader's bid +script after auction collection. Delete the pre-parser scan for the publisher-controlled +byte sequence ` + self.__next_f.push([1, 'text containing here']) + +``` + +causes the publisher stream to wait for the auction before the real body end. A match split +across origin chunks has the same result. + +The issue's original phrase "near-full-page hold" no longer precisely describes that +scanner in the current implementation. Once a match is found, the caller collects the +auction, processes the held tail, removes the hold, and resumes streaming. The false match +therefore causes an **early auction stall**, rather than retaining every later page byte. +The bug and its user-visible latency remain real. + +Separately, `HtmlWithPostProcessing` accumulates all transformed output until EOF whenever +any `IntegrationHtmlPostProcessor` is registered. Next.js is the only current registrant. +This remains a true whole-document buffer and delays all output even when a document has no +RSC payload to rewrite. + +The ESI work does not close #850. It already uses a `lol_html` body end-tag handler to place +`TEMPLATE_SEAM_PLACEHOLDER` at the structural seam, protecting cached templates from +`` strings in scripts and comments. The ordinary inline auction path still uses the +raw scanner, and the Next.js post-processor still buffers to EOF. This design applies the +same structural principle to inline delivery without changing ESI cache assembly. + +## 3. Goals + +1. Make `lol_html` the sole authority for choosing an inline HTML body-close seam. +2. Stream the transformed prefix while the auction is pending and wait only when the + parser-confirmed seam reaches the response controller. +3. Ensure publisher-controlled `` text can never trigger the auction wait. +4. Remove unconditional whole-document buffering when Next.js is enabled. +5. Preserve Next.js URL rewriting, script order, React hydration, and RSC T-chunk length + correction whenever a bounded group is complete. +6. Bound all Next.js deferral and degrade to byte-preserving output when safe rewriting is + impossible. +7. Preserve compression, error, telemetry, CSP, and ESI template-cache behavior. + +## 4. Non-goals + +- Making authorized ESI template-cache fills stream. A complete transformed document is + intentionally required before cache validation and insertion. +- Changing warm ESI template assembly or its stable `AD_ASSEMBLY_SEAM` format. +- Adding streaming response support to adapters that do not currently expose it. +- Expanding which Next.js attributes or URL shapes are rewritten. +- Changing the standalone `text/x-component` RSC Flight response processor. +- Adding another HTML tokenizer or parsing the document a second time. +- Guaranteeing a rewrite for malformed or over-limit RSC data. Hydration-safe unchanged + output takes priority in those cases. + +## 5. Considered approaches + +### A. Parser-generated control seam plus streaming integration sessions — selected + +The existing parser inserts a private token at the structural body end. A post-parser +controller recognizes the token and coordinates the asynchronous auction. Next.js uses a +per-response streaming session and defers only an unresolved RSC group. + +This keeps one HTML parser, makes the async wait occur outside synchronous `lol_html` +callbacks, preserves output order, and limits changes to the core HTML/publisher pipeline +and Next.js registration. + +### B. Return structural events from `StreamProcessor` + +Change `StreamProcessor::process_chunk` to return bytes plus typed events and exact output +offsets. This avoids a generated byte token, but every processor, compression driver, and +caller must adopt the new result type. It still needs an ordered Next.js deferral layer. +The additional surface is not justified for the one asynchronous seam. + +### C. Tokenize before the existing HTML processor + +Run another HTML tokenizer over origin bytes to locate `` before `lol_html` runs. +This duplicates parsing, must map an input boundary onto rewritten output, and can drift +from `lol_html` on malformed-but-renderable documents. It is rejected. + +## 6. Processing architecture + +For processable inline HTML on a streaming adapter, the body path becomes: + +```text +origin chunks + -> bounded decompressor + -> lol_html structural and integration rewrites + -> integration streaming output processors (Next.js when enabled) + -> inline auction seam controller + -> streaming compressor + -> client +``` + +The important ordering change is `lol_html` before the auction seam controller. The current +pipeline holds origin bytes and then parses them. The new pipeline parses first, so only a +control token emitted by the parser can cause a wait. + +Buffered adapters use the same processors and state transitions while writing into their +existing bounded output. They gain behavior parity and lose the redundant Next.js +whole-document accumulation, even though their platform response remains buffered. + +Authorized ESI cold misses continue through their existing bounded finalizer. Their parser +emits the stable template placeholder, the completed transform is validated and stored, +and the reader-specific seam is assembled as it is today. They do not use the inline +request-unique token. + +They also stop using the raw body-close hold. The stable template placeholder does not +depend on bid state, so the transform can run to completion while the auction remains in +flight. After transform and template validation, collect the auction before substituting +the current reader's seam content. The cold miss remains intentionally buffered for cache +insertion, but neither its parsing nor auction lifecycle depends on scanning for ``. +An ESI response whose template-cache gate was rejected follows the ordinary inline deferred +seam path because `effective_assembly_mode` already reduces it to inline delivery. + +## 7. Parser-confirmed inline auction seam + +### 7.1 Control token ownership + +When the response has a dispatched HTML auction and inline body injection is enabled, the +publisher creates one UUID-v4 token using the repository's existing WASM-compatible UUID +support. Its serialized form is an inert HTML comment: + +```text + +``` + +The token is request-private, never stored, and shared by exactly two components: + +- `HtmlProcessorConfig`, whose body end-tag handler emits it once; +- the auction seam controller, which recognizes and removes it. + +Use a distinct `BodyCloseInjection` variant for this token rather than overloading the ESI +`Marker` semantics. The ESI marker must be stable between requests; the inline token must +be unique to one response. + +Select the deferred variant only when orchestration still owns a dispatched auction that +must run concurrently with body processing. Paths which have already collected the auction +retain immediate `InlineBids` insertion. Paths with neither a pending nor completed inline +auction emit no inline token. This prevents any constructor that lacks a seam controller +from producing an unresolved token. + +Represent this choice explicitly at processor construction, for example as an immediate or +deferred inline seam mode. `PublisherBodyProcessor` must expose the generated deferred token +to its async driver; callers must not independently generate a second value. The exact Rust +type is an implementation-plan decision, but one construction result must own both the +configured parser and the matching controller token. + +Publisher bytes cannot predict the generated value. A source document containing the +fixed prefix or another response's token is ordinary content. The implementation must +match the complete current-response token and retain at most `token.len() - 1` candidate +bytes between processed chunks. + +### 7.2 Parser behavior + +The existing `element!("body", ...)` handler remains the structural hook. For the inline +deferred variant, its first available end-tag handler inserts the token immediately before +the structural ``. The existing single-injection guard continues to handle documents +with multiple body elements. + +The handler no longer reads `ad_bids_state` for deferred inline delivery. Synchronous +parser work only identifies the seam; asynchronous auction collection and bid construction +remain in publisher orchestration. + +If `lol_html` exposes no body end tag because the body is implicit, truncated, or absent, +the handler emits no token. The existing diagnostic warning remains appropriate when the +server-side ad path expected an insertion point. + +### 7.3 Seam-controller state machine + +The controller has three states: + +1. **Searching:** stream all processed bytes except the suffix that could begin the exact + token. +2. **Found:** return the prefix before the token to the caller. The caller must make that + prefix available to the client before awaiting auction collection. After collection, + emit the bid script in place of the token, then stream the remaining bytes. +3. **Released:** pass every later processed byte through without scanning or copying. + +On processor EOF while still searching, release the retained candidate bytes, collect the +auction for completion and telemetry, finalize compression, and inject no bids. The token +must never be sent to the client. + +This state machine replaces `BodyCloseHoldBuffer`; no production code may search origin or +transformed HTML for the literal ` Result, io::Error> +``` + +It may retain a documented, bounded subset of output between calls. It must emit all +retained output or return an error at EOF. Generated control placeholders must never reach +the caller. + +`create_html_processor` constructs one `HtmlRewriterAdapter`, then wraps its output in the +registered sessions in registration order. With no streaming output processor, it returns +each `lol_html` output chunk immediately. Delete `HtmlWithPostProcessing`'s +`accumulated_output`, `decoded_input_len`, and EOF-wide post-processing branch. + +The registry methods and builder terminology change from `html_post_processor` to +`html_stream_processor`. This is crate-internal API and has only the Next.js consumer in +the current tree. + +All mutable script-fragment and placeholder state must live in +`IntegrationDocumentState` or the per-document session. Do not retain request-progress +buffers such as `NextJsNextDataRewriter::accumulated_text` on the registry-owned rewriter: +the registry is shared, so request interleaving could otherwise combine fragments from +different documents. Moving this state is required by the new session boundary, not a +separate integration refactor. + +### 8.2 Ordering and bounds + +Processors must preserve document byte order. A processor may withhold bytes after a +control placeholder when earlier content cannot yet be finalized, but it may not emit +later markup before that placeholder is resolved or restored. + +Every processor owns its own semantic limit. The outer publisher decoded-input and output +bounds remain defense in depth for buffered response paths; they do not justify an +unbounded integration session on streaming paths. + +## 9. Next.js RSC streaming design + +### 9.1 Parser-side capture + +Keep `NextJsNextDataRewriter`'s current per-script fragmentation behavior. Intermediate +`__NEXT_DATA__` text fragments are suppressed and accumulated; the complete text node is +rewritten or restored on `last_in_text_node`. Bound this per-script accumulator by +`publisher.max_buffered_body_bytes`. Before another fragment would exceed the bound, +replace the current fragment with all previously suppressed text plus the current fragment, +then pass the rest of that script through unchanged. This changes an oversized script from +"buffer then reject the whole response" to "preserve this script without rewriting" while +the surrounding document continues streaming. + +Store that accumulator in the current `IntegrationDocumentState`, keyed separately from +RSC group state. The immutable `NextJsNextDataRewriter` retains only configuration and its +compiled URL matcher. + +Change the RSC script rewriter to use the same discipline. For each `script` text node: + +- accumulate and suppress fragments until `last_in_text_node`; +- restore non-RSC or unparseable script text exactly; +- for a recognized `self.__next_f.push([1, ...])`, store the original payload in + request-scoped state and emit a request-scoped placeholder for that payload range. + +Bound RSC per-script text accumulation by `max_combined_payload_bytes`. On overflow, restore +the suppressed prefix in the current text fragment, pass the rest of that script through, +and mark the document's RSC output session as bypassed. An incomplete payload cannot be +safely separated from later script continuations. + +Placeholder names include a per-document UUID namespace plus a monotonically increasing +index. A publisher string that resembles the fixed placeholder prefix is not actionable +without the current namespace. + +The factory and parser rewriter obtain the namespace from one shared per-document state +created before the first HTML chunk is processed. Namespace creation must be idempotent so +factory and handler construction order cannot produce different values. + +### 9.2 Output-session state + +The Next.js session owns: + +- the request origin and rewrite configuration; +- the placeholder namespace; +- a FIFO of captured original payloads; +- the output held behind the first unresolved placeholder; +- the combined payload byte count and total held-output byte count; +- whether one over-limit/incomplete warning has been emitted for the current group. +- whether RSC rewriting has entered byte-preserving bypass for the rest of the document. + +It scans only for its generated placeholder namespace. Non-candidate output streams with +at most `longest_placeholder_len - 1` bytes retained for chunk-boundary matching. + +### 9.3 Group resolution + +When the next placeholder is available in both output and captured state: + +1. Append its original payload to the current group. +2. Test the combined payloads with the existing T-chunk parser. +3. If every declared T-chunk is complete, run + `rewrite_rsc_scripts_combined_with_limit`, verify that the output count equals the input + count, substitute every group placeholder in order, and release the entire held segment. +4. If a T-chunk is incomplete, retain the group and the following output until another RSC + payload arrives. + +A complete single-script payload therefore releases on the same processor call. A +cross-script payload releases as soon as the script containing its final declared byte has +been parsed; it does not wait for document EOF. + +Content between grouped scripts must remain in the held segment. Although that may include +ordinary HTML, emitting it early would place it before an earlier executable script and +change document execution order. + +### 9.4 Safe fallback + +`integrations.nextjs.max_combined_payload_bytes` becomes the hard maximum for each of: + +- the sum of original payload bytes in one unresolved group; +- the transformed output bytes held behind that group. + +The configuration key and default remain unchanged. Its guide text must explain the +broader streaming-memory meaning. + +Before either counter would exceed the limit, replace every placeholder already held with +its exact original payload and release the group unchanged. If the group was incomplete or +invalid, enter byte-preserving RSC bypass for the rest of the document: every later RSC +placeholder is replaced immediately with its original payload and is never independently +rewritten. A later payload may be the continuation of the earlier T-chunk, so rewriting it +alone could change content without correcting the header already emitted. + +If a complete, internally valid group is restored only because its held-output size reached +the limit, clear that group and allow the next independent RSC group to be considered. In +either fallback mode, the processor must not repeatedly absorb an unbounded segment. + +At EOF, apply the same unchanged restoration to an incomplete or invalid group. If the +rewrite function returns a different payload count or any generated placeholder remains +after substitution, restore the entire group from originals instead of emitting partial +rewrites. Emit one warning with reason, payload count, and byte counts; never include +payload text or URLs. + +This fallback deliberately favors hydration over proxy URL coverage. It matches the +existing over-limit safety policy while ensuring that no publisher document is withheld +without a bound. + +"Unchanged" here means unchanged relative to the first-pass `lol_html` output with RSC URL +rewriting disabled. Other intentional transformations already performed by the HTML +processor remain present; the fallback must restore the exact captured RSC payload bytes +and preserve their surrounding serialized markup. + +### 9.5 Relationship to the auction seam + +The Next.js session runs before the inline seam controller. Normally it resolves RSC groups +before ``, allowing the body-close token to reach the auction controller at the +structural location. If an incomplete group extends to the body close, the Next.js EOF or +limit fallback restores and releases it; the token then triggers collection. No component +searches RSC content for `` at orchestration boundaries and `io::Error` + inside `StreamProcessor` implementations. +- A `lol_html` processing error abandons an outstanding auction exactly once. +- A stream read or compression error preserves the existing terminal telemetry reason. +- Missing body close is not a parsing error; it is the no-injection EOF path described + above. +- Next.js malformed, incomplete, count-mismatched, or over-limit data is restored unchanged + and logged once per affected group. +- Log completed cross-script RSC groups at debug level with payload count and byte counts. +- Never log HTML, RSC payloads, rewritten URLs, bid contents, or generated control tokens. + +No new metrics or response headers are required. Existing auction telemetry is sufficient +to observe completion and abandonment; streaming correctness is enforced by tests rather +than timing logs. + +## 11. Compatibility and security properties + +- HTML output without an auction or streaming integration remains byte-for-byte equivalent + to the current `lol_html` path. +- `__NEXT_DATA__`, non-RSC scripts, attribute rewriting, head injection, CSP nonce handling, + and DataDome suppression retain existing behavior. +- Per-document fragment state cannot cross-contaminate concurrent or interleaved responses. +- Inline and ESI seam tokens are distinct. An inline token is never cacheable; an ESI + template marker is never interpreted by the inline controller. +- UUID token generation uses an existing dependency and runtime capability; no new source + of randomness is introduced. +- A publisher cannot deliberately trigger the inline wait using a known fixed string. +- Generated tokens and placeholders are removed on success and fallback paths. +- The Next.js memory limit is checked before growing either retained buffer beyond the + configured ceiling. +- Per-script `__NEXT_DATA__` and RSC text accumulation is bounded before complete script + classification is available. +- Script order and text are preserved when rewriting is skipped, preventing hydration + corruption under adversarial or malformed payloads. + +## 12. Test and acceptance matrix + +### 12.1 Parser-aware body seam + +Add focused tests proving: + +- `` inside ordinary script text, `__next_f` payloads, JSON, escaped strings, and + comments does not report a seam; +- false literals split at every token boundary do not report a seam; +- mixed-case structural `` and a structural close split across origin chunks emit + exactly one generated control token through `lol_html`; +- a trailing comment containing `` cannot move the seam; +- multiple body elements still create one insertion; +- a document without an explicit body end emits no token and leaks no internal bytes. + +### 12.2 Auction delivery ordering + +Use the existing pending-auction stream fixtures rather than wall-clock sleeps: + +- poll a response whose script contains `` while the auction remains pending and + assert that output through and beyond that script is available; +- assert that polling stops only when the parser-generated seam is reached; +- complete the auction and assert that bids occur immediately before the structural body + end; +- split the generated token across processed chunks and obtain identical output; +- verify EOF-without-seam completes telemetry and injects no bids; +- verify read, parse, and encode failures abandon the auction once. + +These tests are the direct regression proof for #850. A final-output assertion alone is +insufficient because buffered and streaming implementations can produce identical bytes. + +### 12.3 Next.js streaming + +Add unit and pipeline tests for: + +- a Next.js-enabled document with no RSC scripts emits intermediate output before EOF; +- complete single-script RSC payloads rewrite and release without EOF; +- input-chunk fragmentation within an RSC script is accumulated and rewritten once; +- multiple independent payloads release independently in document order; +- a T-chunk split across two or more scripts updates the original header length and releases + immediately when complete; +- non-RSC scripts and interstitial markup retain exact relative order; +- the payload-byte limit restores a group byte-for-byte; +- the held-output limit restores a group before exceeding the limit; +- invalid and EOF-incomplete T-chunks restore originals; +- an incomplete over-limit group forces later continuation scripts to remain unchanged; +- oversized fragmented `__NEXT_DATA__` and RSC scripts release suppressed text and stream + the rest unchanged; +- two interleaved processor instances never share `__NEXT_DATA__`, RSC payload, namespace, + or bypass state; +- rewrite count mismatch and placeholder-remnant safeguards restore originals; +- no placeholder namespace appears in any final output; +- `__NEXT_DATA__` fragmentation and configured-attribute behavior remain unchanged. + +### 12.4 Compression, ESI, and adapters + +- Exercise identity, gzip, deflate, and Brotli through the new parse -> seam -> encode order, + including trailer preservation and multi-member gzip coverage already present in the + publisher suite. +- Retain and run ESI cold-miss, warm-hit, publisher-collision, script-literal, and + trailing-comment seam tests. +- Prove an authorized ESI cold miss reaches transform EOF without waiting at either a real + or publisher-authored `` sequence, then collects once before reader assembly. +- Fastly tests must prove lazy streaming order. Axum, Cloudflare, and Spin tests must prove + final-byte parity on their buffered response paths. + +### 12.5 Required verification + +Run the repository-prescribed gates: + +```bash +cargo fmt --all -- --check +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity +``` + +The JavaScript suite is not required unless implementation changes JavaScript or +TypeScript. Documentation formatting is required after updating the Next.js guide. + +## 13. Documentation changes + +Update `docs/guide/integrations/nextjs.md` to state: + +- HTML outside unresolved RSC groups streams as it is transformed; +- cross-script T-chunks may briefly defer an ordered group; +- `max_combined_payload_bytes` bounds both combined RSC data and output held for that group; +- over-limit, invalid, or incomplete groups are emitted unchanged to preserve hydration. + +Do not describe the integration as whole-document buffered after this change. Do not claim +that every RSC URL is rewritten when the documented safety fallback applies. + +## 14. Completion criteria + +#850 is complete when all of the following are true: + +1. `BodyCloseHoldBuffer` and every auction-coordination scan for `` literal while the auction + remains pending. +4. Enabling Next.js no longer makes every intermediate HTML processor result empty until + EOF. +5. Cross-script RSC behavior, order, length correction, and bounded unchanged fallback are + covered by tests. +6. No generated control token or placeholder reaches a client or shared cache. +7. Existing ESI assembly tests and all applicable CI gates pass. From 02f56b8700e8cf721c77a7c7f542beb18f870aef Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 7 Sep 2026 19:18:28 +0530 Subject: [PATCH 02/17] Clarify bounded Next.js streaming states --- ...aware-body-hold-nextjs-streaming-design.md | 79 ++++++++++++++++--- 1 file changed, 70 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-09-07-850-parser-aware-body-hold-nextjs-streaming-design.md b/docs/superpowers/specs/2026-09-07-850-parser-aware-body-hold-nextjs-streaming-design.md index 768538706..999a4c780 100644 --- a/docs/superpowers/specs/2026-09-07-850-parser-aware-body-hold-nextjs-streaming-design.md +++ b/docs/superpowers/specs/2026-09-07-850-parser-aware-body-hold-nextjs-streaming-design.md @@ -300,6 +300,29 @@ Store that accumulator in the current `IntegrationDocumentState`, keyed separate RSC group state. The immutable `NextJsNextDataRewriter` retains only configuration and its compiled URL matcher. +Model fragmented-script capture explicitly as: + +```text +Idle -> Buffering -> Idle + \ | + \ +-> BypassUntilLast -> Idle + +----------> BypassUntilLast -> Idle +``` + +- `Idle` plus a non-final fragment starts `Buffering` and suppresses the fragment when it + fits. A first fragment which already exceeds the bound is emitted unchanged and enters + `BypassUntilLast`. +- `Buffering` appends and suppresses while the next fragment fits. Before overflow, emit + the accumulated prefix plus the current fragment unchanged and enter `BypassUntilLast`. +- `Buffering` plus a final in-bound fragment rewrites the complete script or restores it, + then returns to `Idle`. +- `BypassUntilLast` emits every fragment unchanged. It returns to `Idle` only after seeing + `last_in_text_node`. + +The RSC script accumulator uses the same state machine with +`max_combined_payload_bytes`. This prevents a final fragment from being classified or +rewritten independently after an oversized prefix has already been released. + Change the RSC script rewriter to use the same discipline. For each `script` text node: - accumulate and suppress fragments until `last_in_text_node`; @@ -329,7 +352,7 @@ The Next.js session owns: - a FIFO of captured original payloads; - the output held behind the first unresolved placeholder; - the combined payload byte count and total held-output byte count; -- whether one over-limit/incomplete warning has been emitted for the current group. +- whether one over-limit/incomplete warning has been emitted for the current group; - whether RSC rewriting has entered byte-preserving bypass for the rest of the document. It scans only for its generated placeholder namespace. Non-candidate output streams with @@ -340,16 +363,50 @@ at most `longest_placeholder_len - 1` bytes retained for chunk-boundary matching When the next placeholder is available in both output and captured state: 1. Append its original payload to the current group. -2. Test the combined payloads with the existing T-chunk parser. -3. If every declared T-chunk is complete, run +2. Feed it to a boundary-aware T-chunk classifier. +3. If the classifier reports `CompleteRewritable`, run `rewrite_rsc_scripts_combined_with_limit`, verify that the output count equals the input count, substitute every group placeholder in order, and release the entire held segment. -4. If a T-chunk is incomplete, retain the group and the following output until another RSC +4. If it reports `CompleteUnrewritable`, restore all originals, release the complete group, + and begin the next group normally. +5. If it reports `NeedMore`, retain the group and the following output until another RSC payload arrives. - -A complete single-script payload therefore releases on the same processor call. A -cross-script payload releases as soon as the script containing its final declared byte has -been parsed; it does not wait for document EOF. +6. If it reports `Invalid`, restore the group and enter byte-preserving bypass for the rest + of the document because no safe continuation boundary is known. + +Do not use `find_tchunks_impl` or `rewrite_rsc_scripts_combined_with_limit` as the +completeness oracle. Their header regex recognizes only a complete +`[hex]+:T[hex]+,` sequence inside the current physical string. `RSC_MARKER` is inserted +between payloads, so a header split at that boundary is otherwise invisible and an empty +match set can be mistaken for a complete group. + +The classifier consumes the logical concatenation of payloads while retaining physical +payload boundaries. It uses an incremental state machine with these states: + +- `Neutral`: no open header or T-chunk content; +- `HeaderCandidate`: a suffix is a strict prefix of `[hex]+:T[hex]+,`; +- `Content { remaining_unescaped_bytes }`: a complete header declared content that has not + all arrived; +- `Invalid`: malformed length, unreasonable declared length, or inconsistent escape data. + +At a payload boundary, `HeaderCandidate` and nonzero `Content` both yield `NeedMore`. +Because a header may start at the end of one payload, a trailing hexadecimal run is treated +conservatively as a header candidate until the next payload disproves or completes it. The +classifier must count the same JavaScript escape forms and enforce the same +`MAX_REASONABLE_TCHUNK_LENGTH` rule as the rewriter. + +The current combined rewriter supports a complete header in one payload whose declared +content crosses later payloads. Such groups are `CompleteRewritable`. A header physically +split across payloads is `CompleteUnrewritable` once its content is complete: restore that +group unchanged because inserting `RSC_MARKER` inside the header makes the current rewriter +unsafe. Supporting rewritten split headers would require a boundary-mapped rewriter and is +outside #850; safe streaming fallback meets this design's hydration-first contract. + +A complete single-script payload in the classifier's `Neutral` state therefore releases on +the same processor call. A cross-script payload releases as soon as the script containing +its final declared byte has been parsed and the classifier returns to `Neutral`; it does +not wait for document EOF. A trailing header candidate may conservatively hold a payload +until the next RSC payload or EOF, subject to the same hard bounds. Content between grouped scripts must remain in the held segment. Although that may include ordinary HTML, emitting it early would place it before an earlier executable script and @@ -477,7 +534,9 @@ Add unit and pipeline tests for: - input-chunk fragmentation within an RSC script is accumulated and rewritten once; - multiple independent payloads release independently in document order; - a T-chunk split across two or more scripts updates the original header length and releases - immediately when complete; + immediately when complete, when the complete header is in the first payload; +- a T-chunk header split at every payload boundary is detected and restored unchanged, + never treated as an independently rewritable payload; - non-RSC scripts and interstitial markup retain exact relative order; - the payload-byte limit restores a group byte-for-byte; - the held-output limit restores a group before exceeding the limit; @@ -485,6 +544,8 @@ Add unit and pipeline tests for: - an incomplete over-limit group forces later continuation scripts to remain unchanged; - oversized fragmented `__NEXT_DATA__` and RSC scripts release suppressed text and stream the rest unchanged; +- accumulator overflow followed by multiple fragments remains in `BypassUntilLast`, then a + subsequent independent script starts from `Idle` and can be rewritten; - two interleaved processor instances never share `__NEXT_DATA__`, RSC payload, namespace, or bypass state; - rewrite count mismatch and placeholder-remnant safeguards restore originals; From a0b5481540e6212cf3a08bcc85b56729d948dfc7 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 7 Sep 2026 19:22:27 +0530 Subject: [PATCH 03/17] Complete Next.js overflow transitions --- ...aware-body-hold-nextjs-streaming-design.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/superpowers/specs/2026-09-07-850-parser-aware-body-hold-nextjs-streaming-design.md b/docs/superpowers/specs/2026-09-07-850-parser-aware-body-hold-nextjs-streaming-design.md index 999a4c780..122f9b538 100644 --- a/docs/superpowers/specs/2026-09-07-850-parser-aware-body-hold-nextjs-streaming-design.md +++ b/docs/superpowers/specs/2026-09-07-850-parser-aware-body-hold-nextjs-streaming-design.md @@ -312,6 +312,15 @@ Idle -> Buffering -> Idle - `Idle` plus a non-final fragment starts `Buffering` and suppresses the fragment when it fits. A first fragment which already exceeds the bound is emitted unchanged and enters `BypassUntilLast`. +- `Idle` plus a final in-bound fragment is processed directly and remains `Idle`. + `__NEXT_DATA__` rewrites or restores it; RSC classifies it and either emits a placeholder + or restores it. +- `Idle` plus a final over-limit fragment is emitted unchanged without first copying it into + an accumulator and remains `Idle`. For `__NEXT_DATA__`, the next script may be processed + normally. For RSC, inspect the borrowed fragment with the boundary-aware classifier: a + neutral, self-contained payload permits the next RSC script to be processed normally; + `NeedMore` or `Invalid` also enters document-wide byte-preserving RSC bypass because + later scripts may continue data whose header has already been emitted. - `Buffering` appends and suppresses while the next fragment fits. Before overflow, emit the accumulated prefix plus the current fragment unchanged and enter `BypassUntilLast`. - `Buffering` plus a final in-bound fragment rewrites the complete script or restores it, @@ -323,6 +332,12 @@ The RSC script accumulator uses the same state machine with `max_combined_payload_bytes`. This prevents a final fragment from being classified or rewritten independently after an oversized prefix has already been released. +`BypassUntilLast` is per-script capture state. Document-wide RSC bypass is a separate flag: +an RSC overflow which occurs before the script is complete sets both, the per-script state +returns to `Idle` at the final fragment, and the document flag remains set through EOF. In +that mode, later complete RSC scripts are restored immediately rather than captured for +rewriting. `__NEXT_DATA__` overflow never sets the document-wide RSC flag. + Change the RSC script rewriter to use the same discipline. For each `script` text node: - accumulate and suppress fragments until `last_in_text_node`; @@ -546,6 +561,10 @@ Add unit and pipeline tests for: the rest unchanged; - accumulator overflow followed by multiple fragments remains in `BypassUntilLast`, then a subsequent independent script starts from `Idle` and can be rewritten; +- one-fragment over-limit `__NEXT_DATA__` is emitted unchanged and leaves the next script in + `Idle`; +- one-fragment over-limit RSC is emitted without an over-limit copy, and incomplete or + invalid content forces later RSC scripts to remain unchanged; - two interleaved processor instances never share `__NEXT_DATA__`, RSC payload, namespace, or bypass state; - rewrite count mismatch and placeholder-remnant safeguards restore originals; From 545a98c7419270cbc2e90ffed16f445bac6973cc Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 7 Sep 2026 19:25:10 +0530 Subject: [PATCH 04/17] Define RSC bypass flush transition --- ...aware-body-hold-nextjs-streaming-design.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/superpowers/specs/2026-09-07-850-parser-aware-body-hold-nextjs-streaming-design.md b/docs/superpowers/specs/2026-09-07-850-parser-aware-body-hold-nextjs-streaming-design.md index 122f9b538..50dde7a9e 100644 --- a/docs/superpowers/specs/2026-09-07-850-parser-aware-body-hold-nextjs-streaming-design.md +++ b/docs/superpowers/specs/2026-09-07-850-parser-aware-body-hold-nextjs-streaming-design.md @@ -338,6 +338,24 @@ returns to `Idle` at the final fragment, and the document flag remains set throu that mode, later complete RSC scripts are restored immediately rather than captured for rewriting. `__NEXT_DATA__` overflow never sets the document-wide RSC flag. +The RSC output session checks the shared document-wide bypass flag before handling each +new `lol_html` output chunk. If parser-side overflow sets it while an older unresolved group +is held, the session must perform one atomic transition before emitting the overflowing +script's output: + +1. append any separately retained partial placeholder-candidate suffix to the held output; +2. restore every queued placeholder in the held output and current chunk with its exact + captured original payload; +3. release the restored group, interstitial markup, and current chunk in document order; +4. clear the group FIFO, payload/output counters, T-chunk classifier, and placeholder + candidate state; +5. retain only the document-wide bypass flag through EOF and pass later RSC content through + unchanged. + +If a generated placeholder has no matching captured original during this transition, +return a processor error rather than leaking the placeholder or emitting a partially +restored script. This is an internal state-invariant failure, not malformed publisher data. + Change the RSC script rewriter to use the same discipline. For each `script` text node: - accumulate and suppress fragments until `last_in_text_node`; @@ -565,6 +583,9 @@ Add unit and pipeline tests for: `Idle`; - one-fragment over-limit RSC is emitted without an over-limit copy, and incomplete or invalid content forces later RSC scripts to remain unchanged; +- an unresolved group followed by either fragmented or one-fragment RSC overflow is + restored and released in the same output-session call, before the overflowing script, + with all group and partial-placeholder state cleared; - two interleaved processor instances never share `__NEXT_DATA__`, RSC payload, namespace, or bypass state; - rewrite count mismatch and placeholder-remnant safeguards restore originals; From f33a56fb9e3f54f15024d1aa1a312c120277c552 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 7 Sep 2026 19:47:12 +0530 Subject: [PATCH 05/17] Plan parser-aware body and Next.js streaming --- ...parser-aware-body-hold-nextjs-streaming.md | 885 ++++++++++++++++++ 1 file changed, 885 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-07-850-parser-aware-body-hold-nextjs-streaming.md diff --git a/docs/superpowers/plans/2026-09-07-850-parser-aware-body-hold-nextjs-streaming.md b/docs/superpowers/plans/2026-09-07-850-parser-aware-body-hold-nextjs-streaming.md new file mode 100644 index 000000000..16265dde0 --- /dev/null +++ b/docs/superpowers/plans/2026-09-07-850-parser-aware-body-hold-nextjs-streaming.md @@ -0,0 +1,885 @@ +# Parser-Aware Body Hold and Next.js Streaming Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace raw `` auction coordination with a parser-owned inline seam and make Next.js HTML rewriting stream without an unconditional full-document buffer. + +**Architecture:** `lol_html` emits a request-private token at the structural body end; the publisher resolves that token after parsing and before compression, yielding the prefix before awaiting the auction. Next.js moves from an EOF-wide post-processor to a per-document streaming output session that resolves bounded RSC groups in order and restores original payloads on unsafe or over-limit input. + +**Tech Stack:** Rust 2024, `lol_html`, `error-stack`, `uuid`, `flate2`, `brotli`, Fastly/Viceroy tests, native adapter tests, VitePress/Prettier documentation. + +**Spec:** `docs/superpowers/specs/2026-09-07-850-parser-aware-body-hold-nextjs-streaming-design.md` + +--- + +## File structure + +### Create + +- `crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs` — boundary-aware RSC group classification, per-document capture state, placeholder restoration, and the Next.js streaming output processor. + +### Modify + +- `crates/trusted-server-core/src/integrations/registry.rs` — replace full-document post-processor registration with immutable streaming-processor factories and add the per-document factory context. +- `crates/trusted-server-core/src/integrations/mod.rs` — export the new streaming factory/session contracts and remove the production post-processor export. +- `crates/trusted-server-core/src/html_processor.rs` — compose `lol_html` with per-document streaming processors; add the deferred inline body-close variant; delete whole-document accumulation. +- `crates/trusted-server-core/src/streaming_processor.rs` — add a small processor-chain helper only if composition in `html_processor.rs` would otherwise duplicate finalization logic. +- `crates/trusted-server-core/src/integrations/nextjs/mod.rs` — register the Next.js streaming processor and retain legacy public post-processing exports. +- `crates/trusted-server-core/src/integrations/nextjs/rsc.rs` — expose/refine T-chunk scan primitives needed by the boundary-aware classifier without changing the legacy public rewrite API. +- `crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs` — make RSC script capture fragment-safe, bounded, request-namespaced, and per-document. +- `crates/trusted-server-core/src/integrations/nextjs/script_rewriter.rs` — move `__NEXT_DATA__` fragment state out of the shared registry object and enforce bounded unchanged fallback. +- `crates/trusted-server-core/src/integrations/nextjs/html_post_process.rs` — remove the production `NextJsHtmlPostProcessor`; retain the deprecated public compatibility functions and their direct tests. +- `crates/trusted-server-core/src/publisher.rs` — generate and expose the inline seam token, move seam detection after HTML processing, rewire auction collection, and remove `BodyCloseHoldBuffer`. +- `crates/trusted-server-core/src/integrations/google_tag_manager.rs` — supply the new script-context limit in existing unit fixtures if `IntegrationScriptContext` gains that field. +- `docs/guide/integrations/nextjs.md` — document bounded RSC-group streaming and unchanged fallback. + +### Test locations + +Tests remain beside their implementation under each file's existing `#[cfg(test)]` module. Publisher streaming-order tests stay in `publisher.rs` because they need the existing auction and `EdgeBody::Stream` fixtures. Do not create a second integration-test harness. + +## Implementation constraints + +- Follow `CLAUDE.md`; use `cargo test-fastly`, never bare `cargo test --workspace`. +- Keep `StreamProcessor::process_chunk(&mut self, &[u8], bool) -> io::Result>` unchanged unless Task 3 proves composition impossible without changing it. +- Use `IntegrationDocumentState` for request-progress state. Registry-owned `Arc` values must remain immutable between documents. +- Check every configured limit before extending a retained `String` or `Vec`. +- Never emit a generated inline token or Next.js placeholder on success, unchanged fallback, or error recovery. +- Preserve the deprecated `post_process_rsc_html` and `post_process_rsc_html_in_place` public functions. +- Use `log` macros and `expect("should ...")`; do not introduce `anyhow`, `thiserror`, `println!`, or `unwrap()`. +- Each task ends with its focused test, `cargo fmt --all -- --check`, and `cargo test-fastly`. Commit only after those pass. + +--- + +### Task 1: Add boundary-aware RSC group classification + +**Files:** + +- Create: `crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs` +- Modify: `crates/trusted-server-core/src/integrations/nextjs/mod.rs:10-30` +- Modify: `crates/trusted-server-core/src/integrations/nextjs/rsc.rs:7-21,168-270,334-470` +- Test: `crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs` + +- [ ] **Step 1: Write classifier tests before exposing implementation** + +Add table-driven tests for these outcomes: + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RscGroupStatus { + CompleteRewritable, + CompleteUnrewritable, + NeedMore, + Invalid, +} + +#[test] +fn classifies_complete_header_with_cross_payload_content_as_rewritable() { + let payloads = ["1a:T3,ab", "c\n"]; + assert_eq!( + classify_rsc_group(&payloads, usize::MAX), + RscGroupStatus::CompleteRewritable, + "should rewrite a complete header whose content crosses payloads", + ); +} + +#[test] +fn classifies_header_split_across_payloads_as_complete_unrewritable() { + let payloads = ["1a:T", "3,abc\n"]; + assert_eq!( + classify_rsc_group(&payloads, usize::MAX), + RscGroupStatus::CompleteUnrewritable, + "should restore a physically split header unchanged", + ); +} +``` + +Also cover every split within `1a:T3e,`, incomplete content, a trailing hexadecimal header candidate, malformed hex, a length above `MAX_REASONABLE_TCHUNK_LENGTH`, escaped quotes/backslashes/Unicode, multiple T-chunks, ordinary payloads with no T-chunks, and a candidate disproved by the next payload. + +- [ ] **Step 2: Run the new test target and verify red** + +Run: + +```bash +cargo test-fastly rsc_stream -- --nocapture +``` + +Expected: compilation fails because `rsc_stream` and `classify_rsc_group` do not exist. + +- [ ] **Step 3: Refine the existing scan result without changing public rewrite behavior** + +In `rsc.rs`, replace the internal `Option>` ambiguity with a crate-private detailed result used by both old rewriting and new classification: + +```rust +pub(super) enum TChunkScan { + Complete(Vec), + NeedMore, + Invalid, +} +``` + +Make `TChunkInfo` and only the fields required by `rsc_stream.rs` `pub(super)` so the +detailed result does not expose a private type. Keep `find_tchunks` and +`find_tchunks_with_markers` as compatibility wrappers if that minimizes the diff. An +incomplete declared body or incomplete terminal escape returns `NeedMore`; +invalid/unreasonable lengths return `Invalid`. Existing rewrite functions must continue +restoring originals for either non-complete result. + +- [ ] **Step 4: Implement the logical-payload classifier** + +In `rsc_stream.rs`: + +1. Give `classify_rsc_group(payloads, max_combined_payload_bytes)` an explicit bound and + concatenate only after the sum of payload lengths has been checked against it. +2. Record cumulative physical payload boundaries. +3. Parse the logical concatenation without inserting `RSC_MARKER`. +4. Detect a strict terminal prefix of `[0-9a-fA-F]+:T[0-9a-fA-F]+,` as `NeedMore`; include a trailing hexadecimal run. +5. Return `NeedMore` for incomplete declared content. +6. Return `Invalid` for malformed or unreasonable declarations. +7. Return `CompleteUnrewritable` if a complete header range crosses a recorded payload boundary. +8. Otherwise return `CompleteRewritable`. + +Do not use the marker-based combined rewriter as the completeness oracle. It cannot recognize a header containing a physical marker. + +- [ ] **Step 5: Run classifier and existing RSC tests** + +Run: + +```bash +cargo test-fastly rsc_stream -- --nocapture +cargo test-fastly integrations::nextjs::rsc::tests -- --nocapture +``` + +Expected: all classifier cases pass; existing length-recalculation and cross-script-content tests remain green. + +- [ ] **Step 6: Run the target gate for this code change** + +Run: + +```bash +cargo fmt --all -- --check +cargo test-fastly +``` + +Expected: formatting succeeds and all Fastly/core tests pass. + +- [ ] **Step 7: Commit the classifier** + +```bash +git add crates/trusted-server-core/src/integrations/nextjs/mod.rs \ + crates/trusted-server-core/src/integrations/nextjs/rsc.rs \ + crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs +git commit -m "Classify bounded Next.js RSC groups" +``` + +--- + +### Task 2: Make Next.js script capture bounded and per-document + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/registry.rs:58-105,533-563` +- Modify: `crates/trusted-server-core/src/html_processor.rs:762-791` +- Modify: `crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs` +- Modify: `crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs:10-105` +- Modify: `crates/trusted-server-core/src/integrations/nextjs/script_rewriter.rs:14-105` +- Modify fixtures: every `IntegrationScriptContext { ... }` initializer reported by `rg -n "IntegrationScriptContext \\{" crates/trusted-server-core/src` +- Test: `crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs` +- Test: `crates/trusted-server-core/src/integrations/nextjs/script_rewriter.rs` + +- [ ] **Step 1: Add red tests for the fragment state machine** + +Cover `Idle + intermediate`, `Idle + final`, `Buffering + intermediate`, `Buffering + final`, overflow before final, one-fragment overflow, reset after `last_in_text_node`, and two interleaved `IntegrationDocumentState` instances. + +Use a deliberately tiny limit and assert actions, not internal buffers: + +```rust +let first = rewriter.rewrite("prefix", &ctx(false, 8, &document_state)); +let overflow = rewriter.rewrite("-overflow", &ctx(false, 8, &document_state)); +let final_part = rewriter.rewrite("-tail", &ctx(true, 8, &document_state)); + +assert_eq!(first, ScriptRewriteAction::RemoveNode); +assert_eq!(overflow, ScriptRewriteAction::Replace("prefix-overflow".to_owned())); +assert_eq!(final_part, ScriptRewriteAction::Keep); +``` + +For RSC, add one test where overflow occurs before the final fragment and another where one final fragment is already over-limit. Assert that unsafe/incomplete RSC sets document-wide bypass and a subsequent RSC script is restored unchanged. + +- [ ] **Step 2: Run focused tests and verify red** + +Run: + +```bash +cargo test-fastly integrations::nextjs::script_rewriter::tests -- --nocapture +cargo test-fastly integrations::nextjs::rsc_placeholders::tests -- --nocapture +``` + +Expected: new bound/state-isolation assertions fail against the registry-owned `Mutex` and fragment-skipping RSC implementation. + +- [ ] **Step 3: Add the document limit to script context** + +Add: + +```rust +pub struct IntegrationScriptContext<'a> { + // existing fields + pub max_buffered_script_bytes: usize, +} +``` + +Set it from `HtmlProcessorConfig::max_buffered_body_bytes` in the real `html_processor.rs` callback. Update all unit-test initializers, including Google Tag Manager fixtures, with a realistic limit such as `16 * 1024 * 1024`; do not change their behavior. + +- [ ] **Step 4: Define per-document Next.js capture state** + +In `rsc_stream.rs`, add request-scoped state stored through `IntegrationDocumentState`: + +```rust +enum FragmentState { + Idle, + Buffering(String), + BypassUntilLast, +} + +struct NextJsDocumentState { + namespace: String, + next_data: FragmentState, + rsc_script: FragmentState, + captured_payloads: VecDeque, + captured_payload_bytes: usize, + bypass_rsc: bool, +} +``` + +Generate `namespace` once with `Uuid::new_v4().simple()`. Access it through one helper which calls `document_state.get_or_insert_with`; both parser rewriters and the later output session must receive the same `Arc>`. + +- [ ] **Step 5: Move `__NEXT_DATA__` capture out of the registry object** + +Remove `NextJsNextDataRewriter::accumulated_text`. Implement the spec's three-state transition table against `NextJsDocumentState::next_data`: + +- check `current_len + fragment.len()` before `push_str`; +- emit `Replace(accumulated + current)` immediately before overflow; +- stay `BypassUntilLast` until the real final callback; +- reset to `Idle` for the next script; +- on one-fragment overflow, return `Keep` without allocating a second copy. + +- [ ] **Step 6: Make RSC script capture follow the same bounded transitions** + +Replace the current `if !is_last { Keep }` path. Suppress intermediate fragments with `RemoveNode`; at final, parse the complete script and either restore it or replace only the payload range with a namespaced placeholder such as: + +```text +__ts_rsc____ +``` + +Store the exact original payload before returning the replacement. For unsafe overflow, set `bypass_rsc`; never classify the final tail independently after the prefix has been emitted. + +Before copying a recognized payload, check both `payload.len()` and +`captured_payload_bytes + payload.len()` against `max_combined_payload_bytes`. If the +aggregate would exceed the limit, leave the current script unchanged and set `bypass_rsc` +so the output session restores any earlier unresolved group in the same call. Increment the +shared counter only when pushing a captured original; decrement it when the output session +consumes or restores that entry. This prevents the parser-side FIFO from temporarily +exceeding the promised group bound. + +- [ ] **Step 7: Run focused and cross-integration tests** + +Run: + +```bash +cargo test-fastly integrations::nextjs::script_rewriter::tests -- --nocapture +cargo test-fastly integrations::nextjs::rsc_placeholders::tests -- --nocapture +cargo test-fastly integrations::google_tag_manager -- --nocapture +``` + +Expected: fragment, limit, reset, and isolation tests pass; unrelated script rewriters compile and retain behavior. + +- [ ] **Step 8: Run the target gate and commit** + +```bash +cargo fmt --all -- --check +cargo test-fastly +git add crates/trusted-server-core/src/integrations/registry.rs \ + crates/trusted-server-core/src/html_processor.rs \ + crates/trusted-server-core/src/integrations/google_tag_manager.rs \ + crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs \ + crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs \ + crates/trusted-server-core/src/integrations/nextjs/script_rewriter.rs +git commit -m "Isolate bounded Next.js script capture" +``` + +Expected: formatting and all Fastly/core tests pass before the commit. + +--- + +### Task 3: Add per-document streaming integration processors + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/registry.rs:554-565,586-660,700-740,850-865,1030-1050` +- Modify: `crates/trusted-server-core/src/integrations/mod.rs:25-45` +- Modify: `crates/trusted-server-core/src/html_processor.rs:20-160,290-310,790-815,1558-1810` +- Optional modify: `crates/trusted-server-core/src/streaming_processor.rs:297-370` +- Test: `crates/trusted-server-core/src/integrations/registry.rs` +- Test: `crates/trusted-server-core/src/html_processor.rs` + +- [ ] **Step 1: Write red registry and processor-chain tests** + +Add a fake immutable factory which creates a fresh session containing a request-local counter. Assert: + +- builder registration and registry lookup preserve factory order; +- two HTML processors from one registry do not share session counters; +- intermediate `lol_html` output passes through a no-op streaming session before EOF; +- two sessions compose in registration order; +- `is_last = true` reaches every session exactly once. + +Replace the old test `post_processors_accumulate_while_streaming_path_passes_through` with an assertion that a registered streaming processor still produces non-empty intermediate output. + +- [ ] **Step 2: Run focused tests and verify red** + +```bash +cargo test-fastly html_stream_processor -- --nocapture +``` + +Expected: compilation fails because the factory/session registry does not exist. + +- [ ] **Step 3: Define the factory context and trait** + +Add crate-public contracts alongside the existing integration traits: + +```rust +#[derive(Clone)] +pub struct IntegrationHtmlStreamContext { + pub request_host: String, + pub request_scheme: String, + pub origin_host: String, + pub document_state: IntegrationDocumentState, +} + +pub trait IntegrationHtmlStreamProcessorFactory: Send + Sync { + fn integration_id(&self) -> &'static str; + fn create(&self, context: IntegrationHtmlStreamContext) -> Box; +} +``` + +Add repository-standard doc comments to every public item and method. If returning +`Box` requires a visibility or lifetime adjustment, keep the session +request-local and non-`Send`; do not add `Send` to `HtmlRewriterAdapter` or use unsafe code. + +- [ ] **Step 4: Add streaming factories to registration and registry storage** + +Add `html_stream_processors` and `with_html_stream_processor`. Initially keep the old post-processor fields so Next.js can migrate in Task 4 without an uncompilable intermediate commit. Add `html_stream_processor_factories()` for processor construction. + +- [ ] **Step 5: Compose the request-local processor chain** + +Create `HtmlWithStreamingProcessors` in `html_processor.rs`: + +```rust +struct HtmlWithStreamingProcessors { + inner: HtmlRewriterAdapter, + processors: Vec>, +} + +impl StreamProcessor for HtmlWithStreamingProcessors { + fn process_chunk(&mut self, chunk: &[u8], is_last: bool) -> io::Result> { + let mut output = self.inner.process_chunk(chunk, is_last)?; + for processor in &mut self.processors { + output = processor.process_chunk(&output, is_last)?; + } + Ok(output) + } +} +``` + +Construct every session once per call to `create_html_processor`, using the same `IntegrationDocumentState` clone supplied to parser callbacks. Do not construct sessions inside `process_chunk`. + +- [ ] **Step 6: Run chain tests and target gate** + +```bash +cargo test-fastly html_stream_processor -- --nocapture +cargo fmt --all -- --check +cargo test-fastly +``` + +Expected: sessions stream intermediate chunks, finalize in order, and remain isolated. + +- [ ] **Step 7: Commit the streaming contract** + +```bash +git add crates/trusted-server-core/src/integrations/registry.rs \ + crates/trusted-server-core/src/integrations/mod.rs \ + crates/trusted-server-core/src/html_processor.rs \ + crates/trusted-server-core/src/streaming_processor.rs +git commit -m "Add per-document HTML stream processors" +``` + +Only add `streaming_processor.rs` if it changed. + +--- + +### Task 4: Migrate Next.js from EOF post-processing to bounded streaming + +**Files:** + +- Modify: `crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs` +- Modify: `crates/trusted-server-core/src/integrations/nextjs/mod.rs:10-110,120-710` +- Modify: `crates/trusted-server-core/src/integrations/nextjs/html_post_process.rs:1-240,329-390` +- Modify: `crates/trusted-server-core/src/integrations/registry.rs:554-565,586-660,700-740,850-865,1030-1050` +- Modify: `crates/trusted-server-core/src/integrations/mod.rs` +- Modify: `crates/trusted-server-core/src/html_processor.rs:20-160,290-310,790-815,1558-1810` +- Test: `crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs` +- Test: `crates/trusted-server-core/src/integrations/nextjs/mod.rs` +- Test: `crates/trusted-server-core/src/html_processor.rs` + +- [ ] **Step 1: Write red streaming-output tests** + +Build tests around `process_chunk`, not just final bytes. Cover: + +1. Next.js enabled with no RSC emits non-empty output before EOF. +2. A complete single-payload RSC placeholder is rewritten and released in the same call. +3. A content-split T-chunk holds from the first placeholder and releases immediately after the completing payload. +4. A header-split T-chunk restores unchanged. +5. Interstitial HTML stays after the earlier script. +6. Payload and held-output limits restore before exceeding their configured limit. +7. Invalid/EOF-incomplete groups restore unchanged. +8. Parser-side `bypass_rsc` immediately restores a previously held group and current output, clears state, and passes later RSC scripts unchanged. +9. A missing captured original returns `io::Error` and never emits a placeholder. +10. No output contains the current request's placeholder namespace. + +Use small chunks and small limits so each transition occurs deterministically. + +- [ ] **Step 2: Run focused tests and verify red** + +```bash +cargo test-fastly integrations::nextjs::rsc_stream::tests -- --nocapture +cargo test-fastly html_processor::tests::post_processors_accumulate_while_streaming_path_passes_through -- --nocapture +``` + +Expected: streaming-session tests fail because the production post-processor still waits for EOF; the old accumulation test still demonstrates the behavior being removed. + +- [ ] **Step 3: Implement the Next.js factory and session** + +Implement `IntegrationHtmlStreamProcessorFactory` on an immutable Next.js factory. The session must: + +- scan only its request namespace while retaining at most the maximum placeholder length minus one; +- consume captured originals FIFO; +- maintain `group_output`, payload and output counters, classifier state, and `bypass_rsc`; +- substitute `CompleteRewritable` with `rewrite_rsc_scripts_combined_with_limit`; +- restore `CompleteUnrewritable`, `Invalid`, over-limit, and EOF-incomplete groups as specified; +- validate replacement count and absence of generated placeholders before release; +- log only reason/count/byte totals. + +Use checked/saturating length arithmetic before allocation. A document-wide bypass transition restores the held group and current chunk in one call, clears all group/candidate state, and remains pass-through through EOF. + +- [ ] **Step 4: Register the streaming factory** + +Update `nextjs::register` to use: + +```rust +IntegrationRegistration::builder(NEXTJS_INTEGRATION_ID) + .with_script_rewriter(structured) + .with_script_rewriter(placeholders) + .with_html_stream_processor(streaming_factory) +``` + +Remove production construction of `NextJsHtmlPostProcessor`. + +- [ ] **Step 5: Delete whole-document production accumulation** + +Once no registration uses it: + +- remove `IntegrationHtmlPostProcessor`, `html_post_processors`, `has_html_post_processors`, and `with_html_post_processor`; +- remove `HtmlWithPostProcessing` fields `accumulated_output`, `decoded_input_len`, and its EOF branch; +- rename the wrapper to `HtmlWithStreamingProcessors` if not already done; +- delete production-only placeholder substitution from `html_post_process.rs` after moving needed helpers; +- retain deprecated public `post_process_rsc_html` APIs and their tests. + +- [ ] **Step 6: Replace old buffering tests with streaming assertions** + +Update or remove tests tied to the deleted generic post-processor. Keep coverage for request state, configured bounds, UTF-8 behavior, non-RSC scripts, fragmented RSC, and legacy public helpers. Rename tests and comments so they no longer claim Next.js post-processes at EOF. + +- [ ] **Step 7: Run Next.js and HTML processor suites** + +```bash +cargo test-fastly integrations::nextjs -- --nocapture +cargo test-fastly html_processor::tests -- --nocapture +``` + +Expected: all Next.js output is correct, streaming assertions observe intermediate bytes, and no placeholder leaks. + +- [ ] **Step 8: Run the target gate and commit** + +```bash +cargo fmt --all -- --check +cargo test-fastly +git add crates/trusted-server-core/src/integrations/registry.rs \ + crates/trusted-server-core/src/integrations/mod.rs \ + crates/trusted-server-core/src/html_processor.rs \ + crates/trusted-server-core/src/integrations/nextjs +git commit -m "Stream bounded Next.js RSC groups" +``` + +Expected: all Fastly/core tests pass before the commit. + +--- + +### Task 5: Add the parser-owned inline auction seam + +**Files:** + +- Modify: `crates/trusted-server-core/src/html_processor.rs:159-270,450-510,1880-1945,2070-2230` +- Modify: `crates/trusted-server-core/src/publisher.rs:636-690,1260-1420,3710-3767,8410-8515,15560-15690` +- Test: `crates/trusted-server-core/src/html_processor.rs` +- Test: `crates/trusted-server-core/src/publisher.rs` + +- [ ] **Step 1: Write red parser-marker tests** + +Add `BodyCloseInjection::DeferredInlineMarker(String)` expectations: + +- a structural mixed-case body end receives exactly one token; +- script/JSON/comment `` text receives none; +- multiple body elements still inject once; +- no explicit body end emits no token; +- deferred mode never reads or injects `ad_bids_state`. + +Reuse the existing `marker_mode_ignores_a_body_close_written_in_script_data` fixtures where possible, but keep stable ESI `Marker` and request-private deferred behavior as separate assertions. + +- [ ] **Step 2: Write red exact-token seam-controller tests** + +Replace raw-close buffer tests with a controller initialized from a full token: + +```rust +let token = b""; +let mut seam = InlineBodyCloseSeam::new(token.to_vec()); + +let first = seam.push(b""); +assert!(!first.found, "publisher body text must not trigger the seam"); + +let second = seam.push(b"article"); +assert!(second.found, "the exact parser token should trigger the seam"); +assert_eq!(second.ready, b"article"); +assert_eq!(second.tail, b""); +``` + +Split the exact token at every byte boundary. Also prove a different UUID-shaped token is ordinary output and released before the seam. + +- [ ] **Step 3: Run focused tests and verify red** + +```bash +cargo test-fastly deferred_inline_marker -- --nocapture +cargo test-fastly inline_body_close_seam -- --nocapture +``` + +Expected: compilation fails because the deferred variant and exact-token controller do not exist. + +- [ ] **Step 4: Implement the parser variant** + +Add the distinct enum variant and handle it in the existing `body` end-tag callback by inserting its markup verbatim. Keep `InlineBids` for paths whose auction has already completed and stable `Marker` for ESI templates. Update exhaustive matches and documentation. + +- [ ] **Step 5: Generate one token with the processor construction result** + +Change `PublisherBodyProcessor` construction to accept an explicit immediate/deferred inline-seam mode. When deferred inline injection is valid, generate: + +```rust +format!( + "", + uuid::Uuid::new_v4().simple() +) +``` + +Store the bytes on `PublisherBodyProcessor` and pass the same string into `HtmlProcessorConfig`. Add an accessor which transfers or clones this exact token to the auction driver. ESI mode ignores deferred input and retains `TEMPLATE_SEAM_PLACEHOLDER`. + +- [ ] **Step 6: Implement `InlineBodyCloseSeam`** + +Replace `BodyCloseHoldBuffer` with an exact-token scanner over processed bytes. Return a value such as: + +```rust +struct SeamChunk { + ready: Vec, + tail: Vec, + found: bool, +} +``` + +In searching state, retain only a suffix which could begin the exact token. On a match, remove the token, return preceding bytes as ready and following bytes as tail, then enter released state. `finish` returns the candidate suffix unchanged when no token exists. Delete `BODY_CLOSE_PREFIX` and `find_ascii_case_insensitive` if no non-auction caller uses them. + +- [ ] **Step 7: Run focused suites and target gate** + +```bash +cargo test-fastly deferred_inline_marker -- --nocapture +cargo test-fastly inline_body_close_seam -- --nocapture +cargo test-fastly marker_mode -- --nocapture +cargo fmt --all -- --check +cargo test-fastly +``` + +Expected: parser and exact-token scanner tests pass; stable ESI marker tests remain green. + +- [ ] **Step 8: Commit seam primitives** + +```bash +git add crates/trusted-server-core/src/html_processor.rs \ + crates/trusted-server-core/src/publisher.rs +git commit -m "Mark inline body seams through the HTML parser" +``` + +--- + +### Task 6: Move auction holding after HTML processing + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs:774-1150,2280-2550,3507-4055,15500-15695,17130-17690,18035-18140` +- Test: `crates/trusted-server-core/src/publisher.rs` + +- [ ] **Step 1: Write the decisive red streaming-order regression** + +Use the existing pending-auction/lazy-body fixtures. Feed at least three logical regions: + +1. head/body prefix; +2. a Next.js-style script containing a false `` plus later article markup; +3. the structural body close. + +Poll while the auction remains pending and assert region 2 and its later article bytes are available. Then assert the stream becomes pending only at the parser token. Complete the auction and assert the bid script is immediately before the structural close. + +The test must not use elapsed-time thresholds or sleeps. + +- [ ] **Step 2: Add EOF and failure-path red tests** + +Cover no explicit body end, parser failure, source read failure, decoder failure, and encoder failure. Assert one terminal collect/abandon outcome and no generated token. Add an authorized ESI cold-miss test proving transform reaches EOF before auction collection and collects once before reader assembly. + +- [ ] **Step 3: Run focused tests and verify red** + +```bash +cargo test-fastly parser_confirmed_auction_seam -- --nocapture +cargo test-fastly esi_cold_miss_collects_after_transform -- --nocapture +``` + +Expected: old raw scanner stalls at the script literal or new test helpers are not yet wired. + +- [ ] **Step 4: Refactor chunk steps to process before seam detection** + +Replace the old sequence: + +```text +decode -> raw hold -> process -> encode +``` + +with: + +```text +decode -> process -> exact generated seam -> encode +``` + +Refactor `hold_step_decoded_chunk`, `hold_collect_close_tail`, `hold_finish_ready_segments`, and `hold_finish_tail_segments` around `InlineBodyCloseSeam`. Keep shared step/finish helpers for the lazy stream and write-sink driver. + +The step result must separate ready encoded segments from the seam event. Callers yield/write all ready segments before `collect_stream_auction(...).await`. + +At source EOF, first finalize the decoder and HTML processor, feed every final processed +byte through `InlineBodyCloseSeam`, and expose its ready output. If no token was found, +release the seam's retained candidate suffix before awaiting collection, then collect once +for telemetry without injecting bids, and only then finalize the encoder trailer. This +preserves streaming for malformed/bodyless documents and prevents a token emitted during +`lol_html::end()` from being missed. + +- [ ] **Step 5: Wire deferred construction only when a controller exists** + +In `publisher_response_into_streaming_response` and `stream_publisher_body_async`, request a deferred inline token only when all are true: + +- an auction is still dispatched; +- content is HTML; +- `effective_assembly_mode` is inline; +- inline body injection is enabled for the response. + +Already-collected paths retain `InlineBids`; no-auction paths emit no token. Assert at construction that a deferred processor and controller either both have the same token or both have none. + +- [ ] **Step 6: Route authorized ESI cold misses through no-hold processing** + +When `template_cache_key.is_some()`, process the entire reader-neutral transform without an inline seam controller. Preserve the existing order of template validation/store and per-reader assembly, but collect the dispatched auction after transform completion and before substituting current-reader bid content. A cache-gate rejection already becomes inline through `effective_assembly_mode` and must use the deferred inline path. + +- [ ] **Step 7: Remove raw close-body orchestration** + +Delete `BodyCloseHoldBuffer`, `BODY_CLOSE_PREFIX`, raw close tests, and comments describing pre-parser `` scanning. Rename `AuctionHoldState` and helper names to refer to inline seams rather than raw body-close holds. Keep abandonment guard behavior and telemetry reason strings stable unless a test demonstrates they are misleading. + +- [ ] **Step 8: Run publisher regressions** + +```bash +cargo test-fastly parser_confirmed_auction_seam -- --nocapture +cargo test-fastly streaming_finalize_auction -- --nocapture +cargo test-fastly publisher_response_streaming_finalize -- --nocapture +cargo test-fastly template_cache_end_to_end_tests -- --nocapture +``` + +Expected: false literals stream before collection, real seams stall and inject once, EOF/failures terminate once, and ESI behavior remains correct. + +- [ ] **Step 9: Run the target gate and commit** + +```bash +cargo fmt --all -- --check +cargo test-fastly +git add crates/trusted-server-core/src/publisher.rs +git commit -m "Resolve auctions at parser-confirmed body seams" +``` + +Expected: all Fastly/core tests pass before the commit. + +--- + +### Task 7: Complete compression, adapter, and documentation regressions + +**Files:** + +- Modify: `crates/trusted-server-core/src/publisher.rs:16650-16895,17130-17690,18035-18430` +- Modify: `crates/trusted-server-core/src/integrations/nextjs/mod.rs:285-710` +- Modify: `docs/guide/integrations/nextjs.md` +- Test: existing adapter/core test modules only + +- [ ] **Step 1: Add compressed parser-seam cases** + +Extend existing identity/gzip/deflate/Brotli publisher tests so the decoded HTML contains a false script literal before the structural close. For gzip, retain multi-member coverage with the script and close in different members. Assert decoded final bytes, bid placement, and no token leakage. + +- [ ] **Step 2: Add full-pipeline Next.js plus auction coverage** + +Enable Next.js and a pending auction together. Assert: + +- HTML before the first RSC group streams; +- a false `` inside `__next_f` does not collect; +- a bounded content-split group resolves in order; +- the real parser seam triggers collection; +- final output has rewritten URLs, corrected T length, bids before ``, and no placeholders. + +- [ ] **Step 3: Run focused compressed and combined tests** + +```bash +cargo test-fastly streaming_finalize_auction_hold -- --nocapture +cargo test-fastly stream_publisher_body_async_processes_ -- --nocapture +cargo test-fastly nextjs -- --nocapture +``` + +Expected: identity and all supported encodings preserve content/trailers; combined Next.js/auction streaming passes. + +- [ ] **Step 4: Update the Next.js guide** + +Document that ordinary HTML streams immediately, unresolved cross-script T-chunk groups are bounded, `max_combined_payload_bytes` limits payload and held output independently, and invalid/incomplete/over-limit groups are restored unchanged. Remove wording that implies guaranteed rewriting under fallback or full-document EOF post-processing. + +- [ ] **Step 5: Run adapter parity suites** + +```bash +cargo test-axum +cargo test-cloudflare +cargo test-spin +``` + +Expected: native buffered adapters preserve final-byte behavior. + +- [ ] **Step 6: Run formatting and commit regressions/docs** + +```bash +cargo fmt --all -- --check +cd docs && npm run format +git add crates/trusted-server-core/src/publisher.rs \ + crates/trusted-server-core/src/integrations/nextjs/mod.rs \ + docs/guide/integrations/nextjs.md +git commit -m "Cover streaming seams across encodings and adapters" +``` + +Expected: Rust formatting and documentation formatting pass before the commit. If tests land in another touched source file, include that file explicitly in `git add`. + +--- + +### Task 8: Run the complete repository verification and review the diff + +**Files:** + +- Verify all files changed in Tasks 1-7 +- Update only files required to fix failures found by these gates + +- [ ] **Step 1: Confirm the raw scanner and EOF post-processor are gone** + +```bash +rg -n "BodyCloseHoldBuffer|BODY_CLOSE_PREFIX|find_ascii_case_insensitive|IntegrationHtmlPostProcessor|with_html_post_processor|html_post_processors" \ + crates/trusted-server-core/src +``` + +Expected: no production matches. Test names/comments should also use the new streaming terminology. + +- [ ] **Step 2: Confirm generated bytes cannot leak** + +Run the focused token/placeholder suites once more: + +```bash +cargo test-fastly inline_body_close_seam -- --nocapture +cargo test-fastly rsc_stream -- --nocapture +cargo test-fastly parser_confirmed_auction_seam -- --nocapture +``` + +Expected: all pass, including success, fallback, EOF, and error paths. + +- [ ] **Step 3: Run all Rust format and lint gates** + +```bash +cargo fmt --all -- --check +cargo clippy-fastly +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +``` + +Expected: every command exits zero with warnings denied. + +- [ ] **Step 4: Run all Rust test gates** + +```bash +cargo test-fastly +cargo test-axum +cargo test-cloudflare +cargo test-spin +cargo test --manifest-path crates/trusted-server-integration-tests/Cargo.toml --test parity +``` + +Expected: all suites pass with zero failures. + +- [ ] **Step 5: Run JS and documentation gates required by repository CI** + +```bash +cd crates/trusted-server-js/lib && npx vitest run +cd crates/trusted-server-js/lib && node build-all.mjs +cd crates/trusted-server-js/lib && npm run format +cd docs && npm run format +``` + +Expected: tests/build succeed and both format checks report no changes required. + +- [ ] **Step 6: Review scope and requirements against the spec** + +```bash +git diff main...HEAD --stat +git diff main...HEAD -- crates/trusted-server-core/src docs/guide/integrations/nextjs.md +git status --short +``` + +Verify line by line: + +- parser context is the only source of inline body seams; +- the ready prefix is yielded before auction await; +- Next.js no longer buffers every document to EOF; +- every accumulator and held group is checked before growth; +- unsafe RSC continuation enters immediate byte-preserving bypass; +- request state is isolated; +- ESI cold/warm and compression paths retain semantics; +- no unrelated refactor or generated build artifact is present; +- the worktree is clean after the final commit. + +- [ ] **Step 7: Request implementation code review** + +Use `superpowers:requesting-code-review` with `main` as the base and the current branch head. Address all Critical and Important findings, rerun the affected focused suite, and repeat the relevant full gate before claiming completion. + +- [ ] **Step 8: Commit only if verification required corrections** + +If Steps 1-7 required changes: + +```bash +git add +git commit -m "Resolve parser-seam verification findings" +``` + +If no files changed, do not create an empty commit. From 32de022883bf34cbf78e6bc4c132859f1b54afc8 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 7 Sep 2026 20:01:07 +0530 Subject: [PATCH 06/17] Address issue 850 plan review --- ...parser-aware-body-hold-nextjs-streaming.md | 75 +++++++++++++++---- 1 file changed, 61 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/plans/2026-09-07-850-parser-aware-body-hold-nextjs-streaming.md b/docs/superpowers/plans/2026-09-07-850-parser-aware-body-hold-nextjs-streaming.md index 16265dde0..378220f7a 100644 --- a/docs/superpowers/plans/2026-09-07-850-parser-aware-body-hold-nextjs-streaming.md +++ b/docs/superpowers/plans/2026-09-07-850-parser-aware-body-hold-nextjs-streaming.md @@ -182,6 +182,7 @@ git commit -m "Classify bounded Next.js RSC groups" - Modify: `crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs` - Modify: `crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs:10-105` - Modify: `crates/trusted-server-core/src/integrations/nextjs/script_rewriter.rs:14-105` +- Modify: `crates/trusted-server-core/src/integrations/nextjs/html_post_process.rs:1-240,329-390` - Modify fixtures: every `IntegrationScriptContext { ... }` initializer reported by `rg -n "IntegrationScriptContext \\{" crates/trusted-server-core/src` - Test: `crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs` - Test: `crates/trusted-server-core/src/integrations/nextjs/script_rewriter.rs` @@ -251,6 +252,11 @@ struct NextJsDocumentState { Generate `namespace` once with `Uuid::new_v4().simple()`. Access it through one helper which calls `document_state.get_or_insert_with`; both parser rewriters and the later output session must receive the same `Arc>`. +Normalize `max_combined_payload_bytes` through one helper before storing it in this state: +preserve the existing public behavior in `rsc.rs` where a configured value of `0` means +`DEFAULT_MAX_COMBINED_PAYLOAD_BYTES`, and use that effective nonzero value for script +capture, classification, queued payloads, and held output. + - [ ] **Step 5: Move `__NEXT_DATA__` capture out of the registry object** Remove `NextJsNextDataRewriter::accumulated_text`. Implement the spec's three-state transition table against `NextJsDocumentState::next_data`: @@ -279,6 +285,13 @@ shared counter only when pushing a captured original; decrement it when the outp consumes or restores that entry. This prevents the parser-side FIFO from temporarily exceeding the promised group bound. +Keep the Task 2 checkpoint compatible with the registered EOF post-processor. Update +`NextJsHtmlPostProcessor` to obtain the same namespaced `NextJsDocumentState`, consume its +captured payload FIFO, and replace only placeholders from that document's namespace. It +must decrement `captured_payload_bytes` on both rewrite and restoration and reject a +missing/mismatched placeholder rather than leaking generated text. Do not remove the +legacy registration or its EOF buffering wrapper until Task 4. + - [ ] **Step 7: Run focused and cross-integration tests** Run: @@ -301,7 +314,8 @@ git add crates/trusted-server-core/src/integrations/registry.rs \ crates/trusted-server-core/src/integrations/google_tag_manager.rs \ crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs \ crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs \ - crates/trusted-server-core/src/integrations/nextjs/script_rewriter.rs + crates/trusted-server-core/src/integrations/nextjs/script_rewriter.rs \ + crates/trusted-server-core/src/integrations/nextjs/html_post_process.rs git commit -m "Isolate bounded Next.js script capture" ``` @@ -369,11 +383,14 @@ Add `html_stream_processors` and `with_html_stream_processor`. Initially keep th - [ ] **Step 5: Compose the request-local processor chain** -Create `HtmlWithStreamingProcessors` in `html_processor.rs`: +Create `HtmlWithStreamingProcessors` in `html_processor.rs`. At this checkpoint its inner +processor is the complete existing HTML pipeline, including `HtmlWithPostProcessing` when +legacy post-processors are registered, so adding streaming infrastructure does not bypass +Next.js EOF substitution before Task 4: ```rust struct HtmlWithStreamingProcessors { - inner: HtmlRewriterAdapter, + inner: Box, processors: Vec>, } @@ -388,7 +405,7 @@ impl StreamProcessor for HtmlWithStreamingProcessors { } ``` -Construct every session once per call to `create_html_processor`, using the same `IntegrationDocumentState` clone supplied to parser callbacks. Do not construct sessions inside `process_chunk`. +Construct every session once per call to `create_html_processor`, using the same `IntegrationDocumentState` clone supplied to parser callbacks. Do not construct sessions inside `process_chunk`. Build the current rewriter-plus-legacy-postprocessor pipeline first and wrap that pipeline with the new sessions. The fake chain test uses a registration without a legacy post-processor and therefore proves intermediate streaming without changing current Next.js behavior. - [ ] **Step 6: Run chain tests and target gate** @@ -449,10 +466,12 @@ Use small chunks and small limits so each transition occurs deterministically. ```bash cargo test-fastly integrations::nextjs::rsc_stream::tests -- --nocapture -cargo test-fastly html_processor::tests::post_processors_accumulate_while_streaming_path_passes_through -- --nocapture +cargo test-fastly html_processor::tests::nextjs_stream_processor_emits_before_eof -- --nocapture ``` -Expected: streaming-session tests fail because the production post-processor still waits for EOF; the old accumulation test still demonstrates the behavior being removed. +Expected: the new Next.js streaming tests fail because production registration still uses +the EOF post-processor. The named HTML processor test exists in this step and fails by +observing empty intermediate output; do not target the accumulation test removed in Task 3. - [ ] **Step 3: Implement the Next.js factory and session** @@ -535,6 +554,10 @@ Add `BodyCloseInjection::DeferredInlineMarker(String)` expectations: - a structural mixed-case body end receives exactly one token; - script/JSON/comment `` text receives none; +- for a structural close, feed the source HTML with every possible origin-chunk split + across `` and assert exactly one token after parser finalization; +- for each false literal in script, JSON, and comment context, split the source at every + byte boundary within `` and assert no token; - multiple body elements still inject once; - no explicit body end emits no token; - deferred mode never reads or injects `ad_bids_state`. @@ -639,7 +662,10 @@ Use the existing pending-auction/lazy-body fixtures. Feed at least three logical Poll while the auction remains pending and assert region 2 and its later article bytes are available. Then assert the stream becomes pending only at the parser token. Complete the auction and assert the bid script is immediately before the structural close. -The test must not use elapsed-time thresholds or sleeps. +The test must not use elapsed-time thresholds or sleeps. For the write-sink path, use an +observing writer whose `write` and `flush` calls are recorded separately. Before resolving +the auction, assert that region 2 was written, `flush()` was called after that write, and +collection has not started. - [ ] **Step 2: Add EOF and failure-path red tests** @@ -670,7 +696,11 @@ decode -> process -> exact generated seam -> encode Refactor `hold_step_decoded_chunk`, `hold_collect_close_tail`, `hold_finish_ready_segments`, and `hold_finish_tail_segments` around `InlineBodyCloseSeam`. Keep shared step/finish helpers for the lazy stream and write-sink driver. -The step result must separate ready encoded segments from the seam event. Callers yield/write all ready segments before `collect_stream_auction(...).await`. +The step result must separate ready encoded segments from the seam event. The lazy caller +yields every ready segment before `collect_stream_auction(...).await`. A write-sink caller +writes every ready segment and calls synchronous `Write::flush()` successfully before starting +collection; propagate a flush failure through the same one-terminal-outcome guard as write, +decode, process, and encode failures. At source EOF, first finalize the decoder and HTML processor, feed every final processed byte through `InlineBodyCloseSeam`, and expose its ready output. If no token was found, @@ -728,12 +758,20 @@ Expected: all Fastly/core tests pass before the commit. - Modify: `crates/trusted-server-core/src/publisher.rs:16650-16895,17130-17690,18035-18430` - Modify: `crates/trusted-server-core/src/integrations/nextjs/mod.rs:285-710` +- Modify: `crates/trusted-server-adapter-axum/tests/routes.rs` +- Modify: `crates/trusted-server-adapter-cloudflare/tests/routes.rs` +- Modify: `crates/trusted-server-adapter-spin/tests/routes.rs` - Modify: `docs/guide/integrations/nextjs.md` - Test: existing adapter/core test modules only - [ ] **Step 1: Add compressed parser-seam cases** -Extend existing identity/gzip/deflate/Brotli publisher tests so the decoded HTML contains a false script literal before the structural close. For gzip, retain multi-member coverage with the script and close in different members. Assert decoded final bytes, bid placement, and no token leakage. +Extend existing identity/gzip/deflate/Brotli publisher tests so the decoded HTML contains a +false script literal before the structural close. For every encoding, keep the auction +pending, poll the output, and assert that decoded prefix bytes through the false literal are +observable before collection begins. Then resolve the auction and assert decoded final-byte +parity, bid placement, valid encoder trailers, and no token leakage. For gzip, retain +multi-member coverage with the script and close in different members. - [ ] **Step 2: Add full-pipeline Next.js plus auction coverage** @@ -761,6 +799,12 @@ Document that ordinary HTML streams immediately, unresolved cross-script T-chunk - [ ] **Step 5: Run adapter parity suites** +First add one buffered route regression in each adapter test module. Feed the same Next.js +fixture and configuration through Axum, Cloudflare, and Spin, then assert the complete body +matches the core expected bytes: rewritten RSC URL and length, preserved script order, bid +markup immediately before structural ``, and no generated seam or RSC placeholder. +These are final-byte assertions because these adapters collect the core stream. + ```bash cargo test-axum cargo test-cloudflare @@ -773,9 +817,12 @@ Expected: native buffered adapters preserve final-byte behavior. ```bash cargo fmt --all -- --check -cd docs && npm run format +(cd docs && npm run format) git add crates/trusted-server-core/src/publisher.rs \ crates/trusted-server-core/src/integrations/nextjs/mod.rs \ + crates/trusted-server-adapter-axum/tests/routes.rs \ + crates/trusted-server-adapter-cloudflare/tests/routes.rs \ + crates/trusted-server-adapter-spin/tests/routes.rs \ docs/guide/integrations/nextjs.md git commit -m "Cover streaming seams across encodings and adapters" ``` @@ -841,10 +888,10 @@ Expected: all suites pass with zero failures. - [ ] **Step 5: Run JS and documentation gates required by repository CI** ```bash -cd crates/trusted-server-js/lib && npx vitest run -cd crates/trusted-server-js/lib && node build-all.mjs -cd crates/trusted-server-js/lib && npm run format -cd docs && npm run format +(cd crates/trusted-server-js/lib && npx vitest run) +(cd crates/trusted-server-js/lib && node build-all.mjs) +(cd crates/trusted-server-js/lib && npm run format) +(cd docs && npm run format) ``` Expected: tests/build succeed and both format checks report no changes required. From 123ef68937a77601cf4bd8246c419504eb896e5b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 7 Sep 2026 20:06:47 +0530 Subject: [PATCH 07/17] Make adapter parity plan executable --- ...parser-aware-body-hold-nextjs-streaming.md | 52 +++++++++++++++++-- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-09-07-850-parser-aware-body-hold-nextjs-streaming.md b/docs/superpowers/plans/2026-09-07-850-parser-aware-body-hold-nextjs-streaming.md index 378220f7a..ecc0a128d 100644 --- a/docs/superpowers/plans/2026-09-07-850-parser-aware-body-hold-nextjs-streaming.md +++ b/docs/superpowers/plans/2026-09-07-850-parser-aware-body-hold-nextjs-streaming.md @@ -758,8 +758,11 @@ Expected: all Fastly/core tests pass before the commit. - Modify: `crates/trusted-server-core/src/publisher.rs:16650-16895,17130-17690,18035-18430` - Modify: `crates/trusted-server-core/src/integrations/nextjs/mod.rs:285-710` +- Modify: `crates/trusted-server-adapter-axum/src/app.rs:115-215,575-645` - Modify: `crates/trusted-server-adapter-axum/tests/routes.rs` +- Modify: `crates/trusted-server-adapter-cloudflare/src/app.rs:120-230,330-630` - Modify: `crates/trusted-server-adapter-cloudflare/tests/routes.rs` +- Modify: `crates/trusted-server-adapter-spin/src/app.rs:80-170,460-850` - Modify: `crates/trusted-server-adapter-spin/tests/routes.rs` - Modify: `docs/guide/integrations/nextjs.md` - Test: existing adapter/core test modules only @@ -797,14 +800,52 @@ Expected: identity and all supported encodings preserve content/trailers; combin Document that ordinary HTML streams immediately, unresolved cross-script T-chunk groups are bounded, `max_combined_payload_bytes` limits payload and held output independently, and invalid/incomplete/over-limit groups are restored unchanged. Remove wording that implies guaranteed rewriting under fallback or full-document EOF post-processing. -- [ ] **Step 5: Run adapter parity suites** +- [ ] **Step 5: Add red adapter parity route tests** -First add one buffered route regression in each adapter test module. Feed the same Next.js -fixture and configuration through Axum, Cloudflare, and Spin, then assert the complete body +Add one buffered route regression in each adapter test module. Build a fake +`PlatformHttpClient` that returns the same Next.js origin fixture and deterministic auction +response, place it in a complete `RuntimeServices` built with the existing public builder, +and attempt to construct each real router with those services. Assert the complete body matches the core expected bytes: rewritten RSC URL and length, preserved script order, bid markup immediately before structural ``, and no generated seam or RSC placeholder. These are final-byte assertions because these adapters collect the core stream. +Run: + +```bash +cargo test-axum adapter_buffers_nextjs_auction_output -- --nocapture +cargo test-cloudflare adapter_buffers_nextjs_auction_output -- --nocapture +cargo test-spin adapter_buffers_nextjs_auction_output -- --nocapture +``` + +Expected: compilation fails because `routes_with_settings` always constructs platform +services internally and provides no injectable services seam. + +- [ ] **Step 6: Add a narrow injectable-services router seam** + +In each adapter's `app.rs`, add a private cloneable service source with two modes: + +```rust +#[derive(Clone)] +enum RuntimeServicesSource { + Platform, + Fixed(RuntimeServices), +} +``` + +Give it `for_request(&RequestContext) -> RuntimeServices`: production calls the adapter's +existing `build_runtime_services`, while `Fixed` clones the supplied services. Pass the +source into `build_router` and every handler factory/dispatch path that currently constructs +services. Keep `Hooks::routes()` and `routes_with_settings()` on `Platform`. Add a documented +`routes_with_settings_and_services(settings, services)` constructor on each adapter for +cross-crate integration tests; it builds the same `AppState` and router with `Fixed`. + +This seam changes dependency construction only. It must not expose +`OwnedProcessResponseParams`, duplicate core finalization, or alter production client-info +derivation. The injected fixture supplies the client metadata required by its request. + +- [ ] **Step 7: Run adapter parity suites** + ```bash cargo test-axum cargo test-cloudflare @@ -813,15 +854,18 @@ cargo test-spin Expected: native buffered adapters preserve final-byte behavior. -- [ ] **Step 6: Run formatting and commit regressions/docs** +- [ ] **Step 8: Run formatting and commit regressions/docs** ```bash cargo fmt --all -- --check (cd docs && npm run format) git add crates/trusted-server-core/src/publisher.rs \ crates/trusted-server-core/src/integrations/nextjs/mod.rs \ + crates/trusted-server-adapter-axum/src/app.rs \ crates/trusted-server-adapter-axum/tests/routes.rs \ + crates/trusted-server-adapter-cloudflare/src/app.rs \ crates/trusted-server-adapter-cloudflare/tests/routes.rs \ + crates/trusted-server-adapter-spin/src/app.rs \ crates/trusted-server-adapter-spin/tests/routes.rs \ docs/guide/integrations/nextjs.md git commit -m "Cover streaming seams across encodings and adapters" From b177c27520fc078b9cbd7ea39ae3dec8854f17fc Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 7 Sep 2026 20:30:32 +0530 Subject: [PATCH 08/17] Classify bounded Next.js RSC groups --- .../src/integrations/nextjs/mod.rs | 5 + .../src/integrations/nextjs/rsc.rs | 47 ++-- .../src/integrations/nextjs/rsc_stream.rs | 238 ++++++++++++++++++ 3 files changed, 276 insertions(+), 14 deletions(-) create mode 100644 crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs diff --git a/crates/trusted-server-core/src/integrations/nextjs/mod.rs b/crates/trusted-server-core/src/integrations/nextjs/mod.rs index 5452260e7..224a5d082 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/mod.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/mod.rs @@ -13,6 +13,11 @@ const NEXTJS_INTEGRATION_ID: &str = "nextjs"; mod html_post_process; mod rsc; mod rsc_placeholders; +#[allow( + dead_code, + reason = "classifier is consumed by the bounded streaming session introduced with it" +)] +mod rsc_stream; mod script_rewriter; mod shared; diff --git a/crates/trusted-server-core/src/integrations/nextjs/rsc.rs b/crates/trusted-server-core/src/integrations/nextjs/rsc.rs index fbd2c693c..9fbbb1a3f 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/rsc.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/rsc.rs @@ -20,7 +20,7 @@ pub(crate) const DEFAULT_MAX_COMBINED_PAYLOAD_BYTES: usize = 10 * 1024 * 1024; /// Maximum reasonable T-chunk length to prevent `DoS` from malformed input (100 MB). /// A `T-chunk` larger than this is almost certainly malformed and would cause excessive /// memory allocation or iteration. -const MAX_REASONABLE_TCHUNK_LENGTH: usize = 100 * 1024 * 1024; +pub(super) const MAX_REASONABLE_TCHUNK_LENGTH: usize = 100 * 1024 * 1024; // ============================================================================= // Escape Sequence Parsing @@ -189,19 +189,25 @@ fn consume_unescaped_bytes(s: &str, start_pos: usize, byte_count: usize) -> (usi // ============================================================================= /// Information about a T-chunk found in the combined RSC content. -struct TChunkInfo { +pub(super) struct TChunkInfo { /// Position where the T-chunk header starts (e.g., position of "1a:T..."). - match_start: usize, + pub(super) match_start: usize, /// Position right after the chunk ID (position of ":T"). - id_end: usize, + pub(super) id_end: usize, /// Position right after the comma (where content begins). - header_end: usize, + pub(super) header_end: usize, /// Position where the content ends. - content_end: usize, + pub(super) content_end: usize, +} + +pub(super) enum TChunkScan { + Complete(Vec), + NeedMore, + Invalid, } /// Find all T-chunks in content, optionally skipping markers. -fn find_tchunks_impl(content: &str, skip_markers: bool) -> Option> { +fn scan_tchunks_impl(content: &str, skip_markers: bool) -> TChunkScan { let mut chunks = Vec::new(); let mut search_pos = 0; let marker = skip_markers.then(|| RSC_MARKER.as_bytes()); @@ -215,9 +221,12 @@ fn find_tchunks_impl(content: &str, skip_markers: bool) -> Option Option Option TChunkScan { + scan_tchunks_impl(content, false) } fn find_tchunks(content: &str) -> Option> { - find_tchunks_impl(content, false) + match scan_tchunks(content) { + TChunkScan::Complete(chunks) => Some(chunks), + TChunkScan::NeedMore | TChunkScan::Invalid => None, + } } fn find_tchunks_with_markers(content: &str) -> Option> { - find_tchunks_impl(content, true) + match scan_tchunks_impl(content, true) { + TChunkScan::Complete(chunks) => Some(chunks), + TChunkScan::NeedMore | TChunkScan::Invalid => None, + } } // ============================================================================= diff --git a/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs b/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs new file mode 100644 index 000000000..cf168b0fd --- /dev/null +++ b/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs @@ -0,0 +1,238 @@ +use super::rsc::{TChunkScan, scan_tchunks}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RscGroupStatus { + CompleteRewritable, + CompleteUnrewritable, + NeedMore, + Invalid, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HeaderSuffixStatus { + Complete, + NeedMore, + Invalid, +} + +pub(super) fn classify_rsc_group( + payloads: &[&str], + max_combined_payload_bytes: usize, +) -> RscGroupStatus { + let Some(total_size) = payloads + .iter() + .try_fold(0usize, |total, payload| total.checked_add(payload.len())) + else { + return RscGroupStatus::Invalid; + }; + if total_size > max_combined_payload_bytes { + return RscGroupStatus::Invalid; + } + + let mut combined = String::with_capacity(total_size); + let mut boundaries = Vec::with_capacity(payloads.len().saturating_sub(1)); + for (index, payload) in payloads.iter().enumerate() { + combined.push_str(payload); + if index + 1 < payloads.len() { + boundaries.push(combined.len()); + } + } + + let chunks = match scan_tchunks(&combined) { + TChunkScan::Complete(chunks) => chunks, + TChunkScan::NeedMore => return RscGroupStatus::NeedMore, + TChunkScan::Invalid => return RscGroupStatus::Invalid, + }; + + let mut segment_start = 0; + for chunk in &chunks { + if inspect_non_chunk_segment(&combined[segment_start..chunk.match_start], false) + == HeaderSuffixStatus::Invalid + { + return RscGroupStatus::Invalid; + } + segment_start = chunk.content_end; + } + + match inspect_non_chunk_segment(&combined[segment_start..], true) { + HeaderSuffixStatus::Complete => {} + HeaderSuffixStatus::NeedMore => return RscGroupStatus::NeedMore, + HeaderSuffixStatus::Invalid => return RscGroupStatus::Invalid, + } + + if chunks.iter().any(|chunk| { + boundaries + .iter() + .any(|boundary| chunk.match_start < *boundary && *boundary < chunk.header_end) + }) { + RscGroupStatus::CompleteUnrewritable + } else { + RscGroupStatus::CompleteRewritable + } +} + +fn inspect_non_chunk_segment(segment: &str, terminal: bool) -> HeaderSuffixStatus { + let bytes = segment.as_bytes(); + let mut index = 0; + + while index < bytes.len() { + if !bytes[index].is_ascii_hexdigit() || index > 0 && bytes[index - 1].is_ascii_hexdigit() { + index += 1; + continue; + } + + let mut cursor = index; + while cursor < bytes.len() && bytes[cursor].is_ascii_hexdigit() { + cursor += 1; + } + if cursor == bytes.len() { + return if terminal { + HeaderSuffixStatus::NeedMore + } else { + HeaderSuffixStatus::Complete + }; + } + if bytes.get(cursor..cursor + 2) != Some(b":T") { + index = cursor + 1; + continue; + } + + cursor += 2; + if cursor == bytes.len() { + return if terminal { + HeaderSuffixStatus::NeedMore + } else { + HeaderSuffixStatus::Invalid + }; + } + if !bytes[cursor].is_ascii_hexdigit() { + return HeaderSuffixStatus::Invalid; + } + while cursor < bytes.len() && bytes[cursor].is_ascii_hexdigit() { + cursor += 1; + } + if cursor == bytes.len() { + return if terminal { + HeaderSuffixStatus::NeedMore + } else { + HeaderSuffixStatus::Invalid + }; + } + if bytes[cursor] != b',' { + return HeaderSuffixStatus::Invalid; + } + + index = cursor + 1; + } + + HeaderSuffixStatus::Complete +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_complete_header_with_cross_payload_content_as_rewritable() { + let payloads = ["1a:T3,ab", "c\n"]; + + assert_eq!( + classify_rsc_group(&payloads, usize::MAX), + RscGroupStatus::CompleteRewritable, + "should rewrite a complete header whose content crosses payloads", + ); + } + + #[test] + fn classifies_header_split_across_payloads_as_complete_unrewritable() { + let payloads = ["1a:T", "3,abc\n"]; + + assert_eq!( + classify_rsc_group(&payloads, usize::MAX), + RscGroupStatus::CompleteUnrewritable, + "should restore a physically split header unchanged", + ); + } + + #[test] + fn classifies_every_header_split_as_unrewritable_after_completion() { + let header = "1a:T3e,"; + for split in 1..header.len() { + let first = &header[..split]; + let second = format!("{}{}", &header[split..], "x".repeat(0x3e)); + let payloads = [first, second.as_str()]; + + assert_eq!( + classify_rsc_group(&payloads, usize::MAX), + RscGroupStatus::CompleteUnrewritable, + "should restore a header split at byte {split}", + ); + } + } + + #[test] + fn classifies_incomplete_content_and_header_candidates_as_needing_more() { + for payloads in [vec!["1a:T3,ab"], vec!["prefix1a:T"], vec!["prefix1a"]] { + assert_eq!( + classify_rsc_group(&payloads, usize::MAX), + RscGroupStatus::NeedMore, + "should retain an incomplete T-chunk candidate", + ); + } + } + + #[test] + fn classifies_disproved_hex_suffix_as_complete() { + let payloads = ["ordinary1a", "-suffix"]; + + assert_eq!( + classify_rsc_group(&payloads, usize::MAX), + RscGroupStatus::CompleteRewritable, + "should release a trailing hexadecimal run once disproved", + ); + } + + #[test] + fn classifies_malformed_and_unreasonable_lengths_as_invalid() { + for payload in ["1a:Tzz,value", "1a:T6400001,value"] { + assert_eq!( + classify_rsc_group(&[payload], usize::MAX), + RscGroupStatus::Invalid, + "should reject malformed or unreasonable T-chunk lengths", + ); + } + } + + #[test] + fn counts_javascript_escapes_and_unicode_bytes() { + for payload in [r#"1:T3,a\n\""#, r#"1:T4,\ud83d\ude00"#, "1:T3,€"] { + assert_eq!( + classify_rsc_group(&[payload], usize::MAX), + RscGroupStatus::CompleteRewritable, + "should count decoded JavaScript string bytes", + ); + } + } + + #[test] + fn classifies_multiple_complete_tchunks() { + let payloads = ["1:T1,a2:T2,bc"]; + + assert_eq!( + classify_rsc_group(&payloads, usize::MAX), + RscGroupStatus::CompleteRewritable, + "should accept multiple complete T-chunks", + ); + } + + #[test] + fn rejects_payloads_over_the_group_bound_before_combining() { + let payloads = ["1:T1,a", "tail"]; + + assert_eq!( + classify_rsc_group(&payloads, 4), + RscGroupStatus::Invalid, + "should reject a group larger than its configured bound", + ); + } +} From 304504c492adfd1f0d5f08b1c5f302f2f64d5e6b Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 7 Sep 2026 20:44:33 +0530 Subject: [PATCH 09/17] Isolate bounded Next.js script capture --- .../trusted-server-core/src/html_processor.rs | 1 + .../src/integrations/google_tag_manager.rs | 4 + .../integrations/nextjs/html_post_process.rs | 146 +++------- .../integrations/nextjs/rsc_placeholders.rs | 250 ++++++++++++------ .../src/integrations/nextjs/rsc_stream.rs | 143 +++++++++- .../integrations/nextjs/script_rewriter.rs | 146 +++++++--- .../src/integrations/registry.rs | 1 + 7 files changed, 453 insertions(+), 238 deletions(-) diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 01460927f..9fb0df63c 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -775,6 +775,7 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso request_scheme: &patterns.request_scheme, origin_host: &patterns.origin_host, is_last_in_text_node: text.last_in_text_node(), + max_buffered_script_bytes: config.max_buffered_body_bytes, document_state: &document_state, }; match rewriter.rewrite(text.as_str(), &ctx) { diff --git a/crates/trusted-server-core/src/integrations/google_tag_manager.rs b/crates/trusted-server-core/src/integrations/google_tag_manager.rs index 0e6046180..43f240f5c 100644 --- a/crates/trusted-server-core/src/integrations/google_tag_manager.rs +++ b/crates/trusted-server-core/src/integrations/google_tag_manager.rs @@ -1007,6 +1007,7 @@ mod tests { request_scheme: "https", origin_host: "origin.example.com", is_last_in_text_node: true, + max_buffered_script_bytes: 16 * 1024 * 1024, document_state: &doc_state, }; @@ -1908,6 +1909,7 @@ container_id = "GTM-DEFAULT" request_scheme: "https", origin_host: "origin.example.com", is_last_in_text_node: false, + max_buffered_script_bytes: 16 * 1024 * 1024, document_state: &document_state, }; let ctx_last = IntegrationScriptContext { @@ -1967,6 +1969,7 @@ container_id = "GTM-DEFAULT" request_scheme: "https", origin_host: "origin.example.com", is_last_in_text_node: false, + max_buffered_script_bytes: 16 * 1024 * 1024, document_state: &document_state, }; let ctx_last = IntegrationScriptContext { @@ -2015,6 +2018,7 @@ container_id = "GTM-DEFAULT" request_scheme: "https", origin_host: "origin.example.com", is_last_in_text_node: false, + max_buffered_script_bytes: 16 * 1024 * 1024, document_state: &document_state, }; let ctx_last = IntegrationScriptContext { diff --git a/crates/trusted-server-core/src/integrations/nextjs/html_post_process.rs b/crates/trusted-server-core/src/integrations/nextjs/html_post_process.rs index 53e573db8..80468827d 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/html_post_process.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/html_post_process.rs @@ -1,16 +1,13 @@ use std::cell::{Cell, RefCell}; use std::rc::Rc; use std::sync::Arc; -use std::sync::Mutex; use lol_html::{Settings as RewriterSettings, text}; use crate::integrations::{IntegrationHtmlContext, IntegrationHtmlPostProcessor}; use super::rsc::rewrite_rsc_scripts_combined_with_limit; -use super::rsc_placeholders::{ - NextJsRscPostProcessState, RSC_PAYLOAD_PLACEHOLDER_PREFIX, RSC_PAYLOAD_PLACEHOLDER_SUFFIX, -}; +use super::rsc_stream::{CapturedPayload, NextJsDocumentState}; use super::shared::{RscUrlRewriter, find_rsc_push_payload_range}; use super::{NEXTJS_INTEGRATION_ID, NextJsIntegrationConfig}; @@ -37,12 +34,12 @@ impl IntegrationHtmlPostProcessor for NextJsHtmlPostProcessor { // Check if we have captured placeholders from streaming if let Some(state) = ctx .document_state - .get::>(NEXTJS_INTEGRATION_ID) + .get::>(NEXTJS_INTEGRATION_ID) { let guard = state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if !guard.payloads.is_empty() { + if !guard.captured_payloads.is_empty() { return true; } } @@ -54,14 +51,15 @@ impl IntegrationHtmlPostProcessor for NextJsHtmlPostProcessor { fn post_process(&self, html: &mut String, ctx: &IntegrationHtmlContext<'_>) -> bool { // Try to get payloads captured during streaming (placeholder approach) - let payloads = ctx + let captured = ctx .document_state - .get::>(NEXTJS_INTEGRATION_ID) + .get::>(NEXTJS_INTEGRATION_ID) .map(|state| { let mut guard = state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - guard.take_payloads() + guard.captured_payload_bytes = 0; + guard.captured_payloads.drain(..).collect::>() }) .unwrap_or_default(); @@ -69,9 +67,9 @@ impl IntegrationHtmlPostProcessor for NextJsHtmlPostProcessor { // regex is cached and reused regardless of which branch executes. let rsc_rewriter = RscUrlRewriter::new(); - if !payloads.is_empty() { + if !captured.is_empty() { // Placeholder approach: substitute placeholders with rewritten payloads - return self.substitute_placeholders(html, ctx, payloads, &rsc_rewriter); + return self.substitute_placeholders(html, ctx, captured, &rsc_rewriter); } // Fallback: re-parse HTML to find RSC scripts that weren't captured during streaming @@ -93,10 +91,13 @@ impl NextJsHtmlPostProcessor { &self, html: &mut String, ctx: &IntegrationHtmlContext<'_>, - payloads: Vec, + captured: Vec, rsc_rewriter: &RscUrlRewriter, ) -> bool { - let payload_refs: Vec<&str> = payloads.iter().map(String::as_str).collect(); + let payload_refs: Vec<&str> = captured + .iter() + .map(|payload| payload.original.as_str()) + .collect(); let mut rewritten_payloads = rewrite_rsc_scripts_combined_with_limit( payload_refs.as_slice(), rsc_rewriter, @@ -106,13 +107,16 @@ impl NextJsHtmlPostProcessor { self.config.max_combined_payload_bytes, ); - if rewritten_payloads.len() != payloads.len() { + if rewritten_payloads.len() != captured.len() { log::warn!( "NextJs post-process skipping due to rewrite payload count mismatch: original={}, rewritten={}", - payloads.len(), + captured.len(), rewritten_payloads.len() ); - rewritten_payloads = payloads; + rewritten_payloads = captured + .iter() + .map(|payload| payload.original.clone()) + .collect(); } if log::log_enabled!(log::Level::Debug) { @@ -128,113 +132,25 @@ impl NextJsHtmlPostProcessor { ); } - let (updated, replaced) = - substitute_rsc_payload_placeholders(html.as_str(), &rewritten_payloads); - - let expected = rewritten_payloads.len(); - if replaced != expected { + let expected = captured.len(); + if !captured + .iter() + .all(|payload| html.matches(&payload.placeholder).count() == 1) + { log::warn!( - "NextJs post-process placeholder substitution count mismatch: expected={expected}, replaced={replaced}" - ); - } - - if contains_rsc_payload_placeholders(&updated) { - log::error!( - "NextJs post-process left RSC placeholders in output; attempting fallback substitution (scripts={expected})" + "NextJs post-process placeholder substitution count mismatch: expected={expected}" ); - - let fallback = - substitute_rsc_payload_placeholders_exact(html.as_str(), &rewritten_payloads); - - if contains_rsc_payload_placeholders(&fallback) { - log::error!( - "NextJs post-process fallback substitution still left RSC placeholders in output; hydration may break (scripts={expected})" - ); + for payload in &captured { + *html = html.replace(&payload.placeholder, &payload.original); } - - *html = fallback; return true; } - *html = updated; - true - } -} - -fn contains_rsc_payload_placeholders(html: &str) -> bool { - let mut cursor = 0_usize; - while let Some(next) = html[cursor..].find(RSC_PAYLOAD_PLACEHOLDER_PREFIX) { - let start = cursor + next; - let after_prefix = start + RSC_PAYLOAD_PLACEHOLDER_PREFIX.len(); - let mut idx_end = after_prefix; - while idx_end < html.len() && html.as_bytes()[idx_end].is_ascii_digit() { - idx_end += 1; - } - if idx_end > after_prefix && html[idx_end..].starts_with(RSC_PAYLOAD_PLACEHOLDER_SUFFIX) { - return true; + for (payload, replacement) in captured.iter().zip(rewritten_payloads) { + *html = html.replacen(&payload.placeholder, &replacement, 1); } - cursor = after_prefix; - } - false -} - -fn substitute_rsc_payload_placeholders(html: &str, replacements: &[String]) -> (String, usize) { - let mut output = String::with_capacity(html.len()); - let mut cursor = 0_usize; - let mut replaced = 0_usize; - - while let Some(next) = html[cursor..].find(RSC_PAYLOAD_PLACEHOLDER_PREFIX) { - let start = cursor + next; - output.push_str(&html[cursor..start]); - - let after_prefix = start + RSC_PAYLOAD_PLACEHOLDER_PREFIX.len(); - let mut idx_end = after_prefix; - while idx_end < html.len() && html.as_bytes()[idx_end].is_ascii_digit() { - idx_end += 1; - } - - let suffix_ok = - idx_end > after_prefix && html[idx_end..].starts_with(RSC_PAYLOAD_PLACEHOLDER_SUFFIX); - if !suffix_ok { - output.push_str(RSC_PAYLOAD_PLACEHOLDER_PREFIX); - cursor = after_prefix; - continue; - } - - let idx_str = &html[after_prefix..idx_end]; - let Ok(index) = idx_str.parse::() else { - output.push_str(RSC_PAYLOAD_PLACEHOLDER_PREFIX); - output.push_str(idx_str); - output.push_str(RSC_PAYLOAD_PLACEHOLDER_SUFFIX); - cursor = idx_end + RSC_PAYLOAD_PLACEHOLDER_SUFFIX.len(); - continue; - }; - - let Some(replacement) = replacements.get(index) else { - output.push_str(RSC_PAYLOAD_PLACEHOLDER_PREFIX); - output.push_str(idx_str); - output.push_str(RSC_PAYLOAD_PLACEHOLDER_SUFFIX); - cursor = idx_end + RSC_PAYLOAD_PLACEHOLDER_SUFFIX.len(); - continue; - }; - - output.push_str(replacement); - replaced += 1; - cursor = idx_end + RSC_PAYLOAD_PLACEHOLDER_SUFFIX.len(); - } - - output.push_str(&html[cursor..]); - (output, replaced) -} - -fn substitute_rsc_payload_placeholders_exact(html: &str, replacements: &[String]) -> String { - let mut out = html.to_owned(); - for (index, replacement) in replacements.iter().enumerate() { - let placeholder = - format!("{RSC_PAYLOAD_PLACEHOLDER_PREFIX}{index}{RSC_PAYLOAD_PLACEHOLDER_SUFFIX}"); - out = out.replace(&placeholder, replacement); + true } - out } #[derive(Debug, Clone, Copy)] diff --git a/crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs b/crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs index 117b8f982..a2fa8e408 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs @@ -1,35 +1,18 @@ -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use crate::integrations::{ IntegrationScriptContext, IntegrationScriptRewriter, ScriptRewriteAction, }; +use super::rsc::{DEFAULT_MAX_COMBINED_PAYLOAD_BYTES, TChunkScan, scan_tchunks}; +#[cfg(test)] +pub(super) use super::rsc_stream::RSC_PAYLOAD_PLACEHOLDER_PREFIX; +use super::rsc_stream::{ + CapturedPayload, FragmentCapture, capture_fragment, document_state, rsc_payload_placeholder, +}; use super::shared::find_rsc_push_payload_range; use super::{NEXTJS_INTEGRATION_ID, NextJsIntegrationConfig}; -pub(super) const RSC_PAYLOAD_PLACEHOLDER_PREFIX: &str = "__ts_rsc_payload_"; -pub(super) const RSC_PAYLOAD_PLACEHOLDER_SUFFIX: &str = "__"; - -/// State for RSC placeholder-based rewriting. -/// -/// Stores RSC payloads extracted during streaming for later rewriting during post-processing. -/// Only unfragmented RSC scripts are processed during streaming; fragmented scripts are -/// handled by the post-processor which re-parses the final HTML. -#[derive(Default)] -pub(super) struct NextJsRscPostProcessState { - pub(super) payloads: Vec, -} - -impl NextJsRscPostProcessState { - pub(super) fn take_payloads(&mut self) -> Vec { - std::mem::take(&mut self.payloads) - } -} - -fn rsc_payload_placeholder(index: usize) -> String { - format!("{RSC_PAYLOAD_PLACEHOLDER_PREFIX}{index}{RSC_PAYLOAD_PLACEHOLDER_SUFFIX}") -} - pub(super) struct NextJsRscPlaceholderRewriter { config: Arc, } @@ -38,6 +21,70 @@ impl NextJsRscPlaceholderRewriter { pub(super) fn new(config: Arc) -> Self { Self { config } } + + fn rewrite_complete( + &self, + content: &str, + was_buffered: bool, + state: &mut super::rsc_stream::NextJsDocumentState, + limit: usize, + ) -> ScriptRewriteAction { + if !content.contains("__next_f") { + return if was_buffered { + ScriptRewriteAction::replace(content.to_owned()) + } else { + ScriptRewriteAction::Keep + }; + } + + let Some((payload_start, payload_end)) = find_rsc_push_payload_range(content) else { + return if was_buffered { + ScriptRewriteAction::replace(content.to_owned()) + } else { + ScriptRewriteAction::Keep + }; + }; + + if payload_start > payload_end + || payload_end > content.len() + || !content.is_char_boundary(payload_start) + || !content.is_char_boundary(payload_end) + { + state.bypass_rsc = true; + return if was_buffered { + ScriptRewriteAction::replace(content.to_owned()) + } else { + ScriptRewriteAction::Keep + }; + } + + let payload = &content[payload_start..payload_end]; + let exceeds_limit = payload.len() > limit + || state + .captured_payload_bytes + .checked_add(payload.len()) + .is_none_or(|combined| combined > limit); + if exceeds_limit { + state.bypass_rsc = true; + return if was_buffered { + ScriptRewriteAction::replace(content.to_owned()) + } else { + ScriptRewriteAction::Keep + }; + } + + let placeholder = rsc_payload_placeholder(&state.namespace, state.next_placeholder_index); + state.next_placeholder_index = state.next_placeholder_index.saturating_add(1); + state.captured_payload_bytes += payload.len(); + state.captured_payloads.push_back(CapturedPayload { + placeholder: placeholder.clone(), + original: payload.to_owned(), + }); + + let mut rewritten = content.to_owned(); + rewritten.replace_range(payload_start..payload_end, &placeholder); + ScriptRewriteAction::replace(rewritten) + } } impl IntegrationScriptRewriter for NextJsRscPlaceholderRewriter { @@ -54,53 +101,60 @@ impl IntegrationScriptRewriter for NextJsRscPlaceholderRewriter { return ScriptRewriteAction::keep(); } - // Deliberately does not accumulate fragments (unlike NextJsNextDataRewriter - // and GoogleTagManagerIntegration which use Mutex buffers). RSC - // placeholder processing has a post-processor fallback that re-parses - // the final HTML at end-of-document, so fragmented scripts are safely - // deferred. Accumulation here would also risk corrupting non-RSC scripts - // that happen to be fragmented during streaming. - if !ctx.is_last_in_text_node { + let state = document_state(ctx.document_state); + let mut state = state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.bypass_rsc { + if ctx.is_last_in_text_node { + state.rsc_script = super::rsc_stream::FragmentState::Idle; + } return ScriptRewriteAction::keep(); } - - // Quick check: skip scripts that can't be RSC payloads - if !content.contains("__next_f") { - return ScriptRewriteAction::keep(); + if matches!(state.rsc_script, super::rsc_stream::FragmentState::Idle) + && !content.contains("__next_f") + { + return ScriptRewriteAction::Keep; } - let Some((payload_start, payload_end)) = find_rsc_push_payload_range(content) else { - // Contains __next_f but doesn't match RSC push pattern - leave unchanged - return ScriptRewriteAction::keep(); + let limit = if self.config.max_combined_payload_bytes == 0 { + DEFAULT_MAX_COMBINED_PAYLOAD_BYTES + } else { + self.config.max_combined_payload_bytes }; - - if payload_start > payload_end - || payload_end > content.len() - || !content.is_char_boundary(payload_start) - || !content.is_char_boundary(payload_end) - { - return ScriptRewriteAction::keep(); + match capture_fragment( + &mut state.rsc_script, + content, + ctx.is_last_in_text_node, + limit, + ) { + FragmentCapture::CompleteBorrowed(complete) => { + self.rewrite_complete(complete, false, &mut state, limit) + } + FragmentCapture::CompleteOwned(complete) => { + self.rewrite_complete(&complete, true, &mut state, limit) + } + FragmentCapture::Suppress => ScriptRewriteAction::RemoveNode, + FragmentCapture::Restore(restored) => { + state.bypass_rsc = true; + ScriptRewriteAction::replace(restored) + } + FragmentCapture::PassThrough => { + if ctx.is_last_in_text_node && content.len() > limit && content.contains("__next_f") + { + let unsafe_continuation = find_rsc_push_payload_range(content) + .map(|(start, end)| match scan_tchunks(&content[start..end]) { + TChunkScan::Complete(_) => false, + TChunkScan::NeedMore | TChunkScan::Invalid => true, + }) + .unwrap_or(true); + state.bypass_rsc |= unsafe_continuation; + } else if !ctx.is_last_in_text_node && content.len() > limit { + state.bypass_rsc = true; + } + ScriptRewriteAction::Keep + } } - - // Insert placeholder for this RSC payload and store original for post-processing - let state = ctx - .document_state - .get_or_insert_with(NEXTJS_INTEGRATION_ID, || { - Mutex::new(NextJsRscPostProcessState::default()) - }); - let mut guard = state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - - let placeholder_index = guard.payloads.len(); - let placeholder = rsc_payload_placeholder(placeholder_index); - guard - .payloads - .push(content[payload_start..payload_end].to_string()); - - let mut rewritten = content.to_owned(); - rewritten.replace_range(payload_start..payload_end, &placeholder); - ScriptRewriteAction::replace(rewritten) } } @@ -108,6 +162,7 @@ impl IntegrationScriptRewriter for NextJsRscPlaceholderRewriter { mod tests { use super::*; use crate::integrations::IntegrationDocumentState; + use crate::integrations::nextjs::rsc_stream::NextJsDocumentState; fn ctx( is_last_in_text_node: bool, @@ -119,6 +174,7 @@ mod tests { request_scheme: "https", origin_host: "origin.example.com", is_last_in_text_node, + max_buffered_script_bytes: 16 * 1024 * 1024, document_state, } } @@ -148,48 +204,76 @@ mod tests { ); let stored = state - .get::>(NEXTJS_INTEGRATION_ID) + .get::>(NEXTJS_INTEGRATION_ID) .expect("should store RSC state"); let guard = stored.lock().expect("should lock Next.js RSC state"); - assert_eq!(guard.payloads.len(), 1, "Should store exactly one payload"); assert_eq!( - guard.payloads[0], "https://origin.example.com/page", + guard.captured_payloads.len(), + 1, + "Should store exactly one payload" + ); + assert_eq!( + guard + .captured_payloads + .front() + .expect("should contain captured payload") + .original, + "https://origin.example.com/page", "Stored payload should match original" ); } #[test] - fn skips_fragmented_scripts_for_post_processor_handling() { - // Fragmented scripts are not processed during streaming - they're passed through - // unchanged and handled by the post-processor which re-parses the final HTML. + fn captures_fragmented_scripts_as_one_namespaced_placeholder() { let state = IntegrationDocumentState::default(); let rewriter = NextJsRscPlaceholderRewriter::new(test_config()); let first = "self.__next_f.push([1,\"https://origin.example.com"; let second = "/page\"])"; - // Intermediate chunk should be kept (not processed) let action_first = rewriter.rewrite(first, &ctx(false, &state)); assert_eq!( action_first, - ScriptRewriteAction::Keep, - "Intermediate chunk should be kept unchanged" + ScriptRewriteAction::RemoveNode, + "should suppress a bounded intermediate fragment" ); - // Final chunk should also be kept since it doesn't contain the full RSC pattern let action_second = rewriter.rewrite(second, &ctx(true, &state)); + assert!( + matches!(action_second, ScriptRewriteAction::Replace(ref value) if value.contains("__ts_rsc_")), + "should emit one request-namespaced placeholder", + ); + } + + #[test] + fn overflowing_fragmented_rsc_restores_prefix_and_bypasses_later_rsc() { + let state = IntegrationDocumentState::default(); + let rewriter = NextJsRscPlaceholderRewriter::new(Arc::new(NextJsIntegrationConfig { + max_combined_payload_bytes: 24, + ..(*test_config()).clone() + })); + + assert_eq!( + rewriter.rewrite("self.__next_f", &ctx(false, &state)), + ScriptRewriteAction::RemoveNode, + "should initially suppress the script prefix", + ); + assert_eq!( + rewriter.rewrite("-payload-overflow", &ctx(false, &state)), + ScriptRewriteAction::Replace("self.__next_f-payload-overflow".to_owned()), + "should restore suppressed text before overflow", + ); assert_eq!( - action_second, + rewriter.rewrite("tail", &ctx(true, &state)), ScriptRewriteAction::Keep, - "Final chunk of fragmented script should be kept" + "should pass through until the text node ends", ); - // No payloads should be stored - post-processor will handle this - assert!( - state - .get::>(NEXTJS_INTEGRATION_ID) - .is_none(), - "No RSC state should be created for fragmented scripts" + let later = r#"self.__next_f.push([1,"later"])"#; + assert_eq!( + rewriter.rewrite(later, &ctx(true, &state)), + ScriptRewriteAction::Keep, + "should keep later RSC scripts unchanged after unsafe overflow", ); } diff --git a/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs b/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs index cf168b0fd..f7c156cd2 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs @@ -1,5 +1,129 @@ +use std::borrow::Cow; +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use crate::integrations::IntegrationDocumentState; + +use super::NEXTJS_INTEGRATION_ID; use super::rsc::{TChunkScan, scan_tchunks}; +pub(super) const RSC_PAYLOAD_PLACEHOLDER_PREFIX: &str = "__ts_rsc_"; +pub(super) const RSC_PAYLOAD_PLACEHOLDER_SUFFIX: &str = "__"; + +#[derive(Debug, Default)] +pub(super) enum FragmentState { + #[default] + Idle, + Buffering(String), + BypassUntilLast, +} + +#[derive(Debug)] +pub(super) struct CapturedPayload { + pub(super) placeholder: String, + pub(super) original: String, +} + +#[derive(Debug)] +pub(super) struct NextJsDocumentState { + pub(super) namespace: String, + pub(super) next_data: FragmentState, + pub(super) rsc_script: FragmentState, + pub(super) captured_payloads: VecDeque, + pub(super) captured_payload_bytes: usize, + pub(super) next_placeholder_index: usize, + pub(super) bypass_rsc: bool, +} + +impl Default for NextJsDocumentState { + fn default() -> Self { + Self { + namespace: uuid::Uuid::new_v4().simple().to_string(), + next_data: FragmentState::Idle, + rsc_script: FragmentState::Idle, + captured_payloads: VecDeque::new(), + captured_payload_bytes: 0, + next_placeholder_index: 0, + bypass_rsc: false, + } + } +} + +pub(super) fn document_state(state: &IntegrationDocumentState) -> Arc> { + state.get_or_insert_with(NEXTJS_INTEGRATION_ID, || { + Mutex::new(NextJsDocumentState::default()) + }) +} + +pub(super) fn rsc_payload_placeholder(namespace: &str, index: usize) -> String { + format!("{RSC_PAYLOAD_PLACEHOLDER_PREFIX}{namespace}_{index}{RSC_PAYLOAD_PLACEHOLDER_SUFFIX}") +} + +pub(super) enum FragmentCapture<'a> { + CompleteBorrowed(&'a str), + CompleteOwned(String), + Suppress, + Restore(String), + PassThrough, +} + +pub(super) fn capture_fragment<'a>( + state: &mut FragmentState, + content: &'a str, + is_last: bool, + limit: usize, +) -> FragmentCapture<'a> { + match state { + FragmentState::Idle if is_last => { + if content.len() > limit { + FragmentCapture::PassThrough + } else { + FragmentCapture::CompleteBorrowed(content) + } + } + FragmentState::Idle => { + if content.len() > limit { + *state = FragmentState::BypassUntilLast; + FragmentCapture::PassThrough + } else { + *state = FragmentState::Buffering(content.to_owned()); + FragmentCapture::Suppress + } + } + FragmentState::Buffering(buffer) => { + let exceeds_limit = buffer + .len() + .checked_add(content.len()) + .is_none_or(|combined| combined > limit); + if exceeds_limit { + let mut restored = std::mem::take(buffer); + restored.push_str(content); + *state = if is_last { + FragmentState::Idle + } else { + FragmentState::BypassUntilLast + }; + FragmentCapture::Restore(restored) + } else { + buffer.push_str(content); + if is_last { + let complete = std::mem::take(buffer); + *state = FragmentState::Idle; + FragmentCapture::CompleteOwned(complete) + } else { + FragmentCapture::Suppress + } + } + } + FragmentState::BypassUntilLast => { + if is_last { + *state = FragmentState::Idle; + } + FragmentCapture::PassThrough + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum RscGroupStatus { CompleteRewritable, @@ -29,16 +153,21 @@ pub(super) fn classify_rsc_group( return RscGroupStatus::Invalid; } - let mut combined = String::with_capacity(total_size); let mut boundaries = Vec::with_capacity(payloads.len().saturating_sub(1)); - for (index, payload) in payloads.iter().enumerate() { - combined.push_str(payload); - if index + 1 < payloads.len() { - boundaries.push(combined.len()); + let combined = if let [payload] = payloads { + Cow::Borrowed(*payload) + } else { + let mut combined = String::with_capacity(total_size); + for (index, payload) in payloads.iter().enumerate() { + combined.push_str(payload); + if index + 1 < payloads.len() { + boundaries.push(combined.len()); + } } - } + Cow::Owned(combined) + }; - let chunks = match scan_tchunks(&combined) { + let chunks = match scan_tchunks(combined.as_ref()) { TChunkScan::Complete(chunks) => chunks, TChunkScan::NeedMore => return RscGroupStatus::NeedMore, TChunkScan::Invalid => return RscGroupStatus::Invalid, diff --git a/crates/trusted-server-core/src/integrations/nextjs/script_rewriter.rs b/crates/trusted-server-core/src/integrations/nextjs/script_rewriter.rs index fb54ce1b3..b6de84449 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/script_rewriter.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/script_rewriter.rs @@ -1,4 +1,4 @@ -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use error_stack::Report; use regex::{Regex, escape}; @@ -8,20 +8,13 @@ use crate::integrations::{ IntegrationScriptContext, IntegrationScriptRewriter, ScriptRewriteAction, }; +use super::rsc_stream::{FragmentCapture, capture_fragment, document_state}; use super::shared::strip_origin_host_with_optional_port; use super::{NEXTJS_INTEGRATION_ID, NextJsIntegrationConfig}; pub(super) struct NextJsNextDataRewriter { config: Arc, rewriter: UrlRewriter, - /// Accumulates text fragments when `lol_html` splits a text node across - /// chunk boundaries. Drained on `is_last_in_text_node`. - /// - /// Uses `Mutex` to satisfy the `Sync` bound on `IntegrationScriptRewriter`. - /// The pipeline is single-threaded (`lol_html::HtmlRewriter` is `!Send`), - /// so the lock is uncontended. `lol_html` delivers text chunks sequentially - /// per element — the buffer is always empty when a new element's text begins. - accumulated_text: Mutex, } impl NextJsNextDataRewriter { @@ -31,7 +24,6 @@ impl NextJsNextDataRewriter { Ok(Self { rewriter: UrlRewriter::new(&config.rewrite_attributes)?, config, - accumulated_text: Mutex::new(String::new()), }) } @@ -74,33 +66,30 @@ impl IntegrationScriptRewriter for NextJsNextDataRewriter { return ScriptRewriteAction::keep(); } - let mut buf = self - .accumulated_text + let state = document_state(ctx.document_state); + let mut state = state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if !ctx.is_last_in_text_node { - // Intermediate fragment — accumulate and suppress output. - buf.push_str(content); - return ScriptRewriteAction::RemoveNode; - } - - // Last fragment. If nothing was accumulated, process directly. - if buf.is_empty() { - return self.rewrite_structured(content, ctx); - } - - // Complete the accumulated text and process the full content. - // If rewrite_structured returns Keep, we must still emit the full - // accumulated text via Replace — intermediate fragments were already - // removed from lol_html's output via RemoveNode. - buf.push_str(content); - let full_content = std::mem::take(&mut *buf); - let action = self.rewrite_structured(&full_content, ctx); - if matches!(action, ScriptRewriteAction::Keep) { - return ScriptRewriteAction::replace(full_content); + match capture_fragment( + &mut state.next_data, + content, + ctx.is_last_in_text_node, + ctx.max_buffered_script_bytes, + ) { + FragmentCapture::CompleteBorrowed(complete) => self.rewrite_structured(complete, ctx), + FragmentCapture::CompleteOwned(complete) => { + let action = self.rewrite_structured(&complete, ctx); + if matches!(action, ScriptRewriteAction::Keep) { + ScriptRewriteAction::replace(complete) + } else { + action + } + } + FragmentCapture::Suppress => ScriptRewriteAction::RemoveNode, + FragmentCapture::Restore(content) => ScriptRewriteAction::replace(content), + FragmentCapture::PassThrough => ScriptRewriteAction::Keep, } - action } } @@ -240,6 +229,7 @@ mod tests { request_scheme: "https", origin_host: "origin.example.com", is_last_in_text_node: true, + max_buffered_script_bytes: 16 * 1024 * 1024, document_state, } } @@ -514,6 +504,7 @@ mod tests { request_scheme: "https", origin_host: "origin.example.com", is_last_in_text_node: false, + max_buffered_script_bytes: 16 * 1024 * 1024, document_state: &document_state, }; let ctx_last = IntegrationScriptContext { @@ -560,6 +551,7 @@ mod tests { request_scheme: "https", origin_host: "origin.example.com", is_last_in_text_node: true, + max_buffered_script_bytes: 16 * 1024 * 1024, document_state: &document_state, }; @@ -590,6 +582,7 @@ mod tests { request_scheme: "https", origin_host: "origin.example.com", is_last_in_text_node: false, + max_buffered_script_bytes: 16 * 1024 * 1024, document_state: &document_state, }; let ctx_last = IntegrationScriptContext { @@ -614,4 +607,91 @@ mod tests { other => panic!("expected Replace with passthrough, got {other:?}"), } } + + #[test] + fn fragmented_next_data_releases_suppressed_prefix_on_overflow_and_resets() { + let rewriter = NextJsNextDataRewriter::new(test_config()).expect("should build rewriter"); + let document_state = IntegrationDocumentState::default(); + let first = IntegrationScriptContext { + selector: "script#__NEXT_DATA__", + request_host: "ts.example.com", + request_scheme: "https", + origin_host: "origin.example.com", + is_last_in_text_node: false, + max_buffered_script_bytes: 8, + document_state: &document_state, + }; + + assert_eq!( + rewriter.rewrite("prefix", &first), + ScriptRewriteAction::RemoveNode, + "should suppress a bounded prefix", + ); + assert_eq!( + rewriter.rewrite("-overflow", &first), + ScriptRewriteAction::Replace("prefix-overflow".to_owned()), + "should restore the prefix before crossing the limit", + ); + assert_eq!( + rewriter.rewrite( + "-tail", + &IntegrationScriptContext { + is_last_in_text_node: true, + ..first + }, + ), + ScriptRewriteAction::Keep, + "should pass through the rest of an overflowing script", + ); + assert_eq!( + rewriter.rewrite("small", &first), + ScriptRewriteAction::RemoveNode, + "should reset for the next script", + ); + } + + #[test] + fn next_data_fragment_state_is_isolated_between_documents() { + let rewriter = NextJsNextDataRewriter::new(test_config()).expect("should build rewriter"); + let first_state = IntegrationDocumentState::default(); + let second_state = IntegrationDocumentState::default(); + let first_context = IntegrationScriptContext { + selector: "script#__NEXT_DATA__", + request_host: "ts.example.com", + request_scheme: "https", + origin_host: "origin.example.com", + is_last_in_text_node: false, + max_buffered_script_bytes: 64, + document_state: &first_state, + }; + let second_context = IntegrationScriptContext { + document_state: &second_state, + ..first_context + }; + + assert_eq!( + rewriter.rewrite("first-", &first_context), + ScriptRewriteAction::RemoveNode, + "should buffer the first document", + ); + assert_eq!( + rewriter.rewrite("second-", &second_context), + ScriptRewriteAction::RemoveNode, + "should buffer the second document independently", + ); + + let ScriptRewriteAction::Replace(first_output) = rewriter.rewrite( + "done", + &IntegrationScriptContext { + is_last_in_text_node: true, + ..first_context + }, + ) else { + panic!("should restore the first document"); + }; + assert_eq!( + first_output, "first-done", + "should not combine request state" + ); + } } diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index b9aa94eac..fafa839c3 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -96,6 +96,7 @@ pub struct IntegrationScriptContext<'a> { pub request_scheme: &'a str, pub origin_host: &'a str, pub is_last_in_text_node: bool, + pub max_buffered_script_bytes: usize, pub document_state: &'a IntegrationDocumentState, } From e5389fbb5a47f01582def0ea828605b6e5a3b777 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 7 Sep 2026 20:49:41 +0530 Subject: [PATCH 10/17] Add per-document HTML stream processors --- .../trusted-server-core/src/html_processor.rs | 97 ++++++++++++++- .../src/integrations/mod.rs | 11 +- .../src/integrations/registry.rs | 117 ++++++++++++++++++ 3 files changed, 219 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 9fb0df63c..c5cb4e6a0 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -156,6 +156,28 @@ impl StreamProcessor for HtmlWithPostProcessing { fn reset(&mut self) {} } +struct HtmlWithStreamingProcessors { + inner: Box, + processors: Vec>, +} + +impl StreamProcessor for HtmlWithStreamingProcessors { + fn process_chunk(&mut self, chunk: &[u8], is_last: bool) -> Result, io::Error> { + let mut output = self.inner.process_chunk(chunk, is_last)?; + for processor in &mut self.processors { + output = processor.process_chunk(&output, is_last)?; + } + Ok(output) + } + + fn reset(&mut self) { + self.inner.reset(); + for processor in &mut self.processors { + processor.reset(); + } + } +} + /// What the `` seam injects. /// /// This is a decision, not a side effect of whether the `` script exists. @@ -299,6 +321,7 @@ impl HtmlProcessorConfig { #[must_use] pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcessor { let post_processors = config.integrations.html_post_processors(); + let stream_processor_factories = config.integrations.html_stream_processor_factories(); let document_state = IntegrationDocumentState::default(); if config.suppress_datadome_client_side_tag { document_state.get_or_insert_with(DATADOME_INTEGRATION_ID, || DataDomeClientTagSuppressed); @@ -800,7 +823,17 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso let inner = HtmlRewriterAdapter::new(rewriter_settings); - HtmlWithPostProcessing { + let stream_context = crate::integrations::IntegrationHtmlStreamContext { + request_host: config.request_host.clone(), + request_scheme: config.request_scheme.clone(), + origin_host: config.origin_host.clone(), + document_state: document_state.clone(), + }; + let processors = stream_processor_factories + .into_iter() + .map(|factory| factory.create(stream_context.clone())) + .collect(); + let inner = HtmlWithPostProcessing { inner, post_processors, accumulated_output: Vec::new(), @@ -810,6 +843,11 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso request_host: config.request_host, request_scheme: config.request_scheme, document_state, + }; + + HtmlWithStreamingProcessors { + inner: Box::new(inner), + processors, } } @@ -1644,6 +1682,63 @@ mod tests { ); } + #[test] + fn html_stream_processors_compose_in_order_and_receive_final_once() { + struct DecoratingProcessor { + prefix: u8, + final_calls: Arc, + } + + impl StreamProcessor for DecoratingProcessor { + fn process_chunk(&mut self, chunk: &[u8], is_last: bool) -> io::Result> { + if is_last { + self.final_calls.fetch_add(1, Ordering::SeqCst); + } + let mut output = vec![self.prefix]; + output.extend_from_slice(chunk); + Ok(output) + } + } + + let inner_final_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let first_final_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let second_final_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut processor = HtmlWithStreamingProcessors { + inner: Box::new(DecoratingProcessor { + prefix: b'I', + final_calls: Arc::clone(&inner_final_calls), + }), + processors: vec![ + Box::new(DecoratingProcessor { + prefix: b'A', + final_calls: Arc::clone(&first_final_calls), + }), + Box::new(DecoratingProcessor { + prefix: b'B', + final_calls: Arc::clone(&second_final_calls), + }), + ], + }; + + assert_eq!( + processor + .process_chunk(b"x", false) + .expect("should process intermediate chunk"), + b"BAIx", + "should emit intermediate output in registration order", + ); + assert_eq!( + processor + .process_chunk(b"y", true) + .expect("should process final chunk"), + b"BAIy", + "should preserve processor order for final output", + ); + assert_eq!(inner_final_calls.load(Ordering::SeqCst), 1); + assert_eq!(first_final_calls.load(Ordering::SeqCst), 1); + assert_eq!(second_final_calls.load(Ordering::SeqCst), 1); + } + #[test] fn post_processing_accumulator_rejects_growth_past_cap() { use crate::streaming_processor::{HtmlRewriterAdapter, StreamProcessor}; diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 14d026d6b..4b4ac7254 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -32,11 +32,12 @@ pub use registry::{ AttributeRewriteAction, AttributeRewriteOutcome, HeaderMutation, HeaderMutationMode, IntegrationAttributeContext, IntegrationAttributeRewriter, IntegrationDocumentState, IntegrationEndpoint, IntegrationHeadInjector, IntegrationHtmlContext, - IntegrationHtmlPostProcessor, IntegrationMetadata, IntegrationProxy, IntegrationRegistration, - IntegrationRegistrationBuilder, IntegrationRegistry, IntegrationRequestFilter, - IntegrationScriptContext, IntegrationScriptRewriter, ProxyDispatchInput, RequestFilterDecision, - RequestFilterEffects, RequestFilterInput, RequestFilterRegistryInput, - RequestFilterRegistryOutcome, ScriptRewriteAction, + IntegrationHtmlPostProcessor, IntegrationHtmlStreamContext, + IntegrationHtmlStreamProcessorFactory, IntegrationMetadata, IntegrationProxy, + IntegrationRegistration, IntegrationRegistrationBuilder, IntegrationRegistry, + IntegrationRequestFilter, IntegrationScriptContext, IntegrationScriptRewriter, + ProxyDispatchInput, RequestFilterDecision, RequestFilterEffects, RequestFilterInput, + RequestFilterRegistryInput, RequestFilterRegistryOutcome, ScriptRewriteAction, }; /// Registers or retrieves a platform backend for the given URL. diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index fafa839c3..4c699bdfc 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -16,6 +16,7 @@ use crate::geo::GeoInfo; use crate::http_util::is_navigation_request; use crate::platform::RuntimeServices; use crate::settings::Settings; +use crate::streaming_processor::StreamProcessor; /// Action returned by attribute rewriters to describe how the runtime should mutate the element. #[derive(Debug, Clone, PartialEq, Eq)] @@ -571,6 +572,24 @@ pub trait IntegrationHtmlPostProcessor: Send + Sync { fn post_process(&self, html: &mut String, ctx: &IntegrationHtmlContext<'_>) -> bool; } +/// Owned request data supplied when an integration creates an HTML stream processor. +#[derive(Clone)] +pub struct IntegrationHtmlStreamContext { + pub request_host: String, + pub request_scheme: String, + pub origin_host: String, + pub document_state: IntegrationDocumentState, +} + +/// Creates one mutable HTML output processor for each document. +pub trait IntegrationHtmlStreamProcessorFactory: Send + Sync { + /// Identifier for logging and diagnostics. + fn integration_id(&self) -> &'static str; + + /// Create a request-local streaming processor. + fn create(&self, context: IntegrationHtmlStreamContext) -> Box; +} + /// Trait for integration-provided HTML head injections. pub trait IntegrationHeadInjector: Send + Sync { /// Identifier for logging/diagnostics. @@ -593,6 +612,7 @@ pub struct IntegrationRegistration { pub attribute_rewriters: Vec>, pub script_rewriters: Vec>, pub html_post_processors: Vec>, + pub html_stream_processors: Vec>, pub head_injectors: Vec>, pub request_filters: Vec>, } @@ -619,6 +639,7 @@ impl IntegrationRegistrationBuilder { attribute_rewriters: Vec::new(), script_rewriters: Vec::new(), html_post_processors: Vec::new(), + html_stream_processors: Vec::new(), head_injectors: Vec::new(), request_filters: Vec::new(), }, @@ -655,6 +676,15 @@ impl IntegrationRegistrationBuilder { self } + #[must_use] + pub fn with_html_stream_processor( + mut self, + processor: Arc, + ) -> Self { + self.registration.html_stream_processors.push(processor); + self + } + #[must_use] pub fn with_head_injector(mut self, injector: Arc) -> Self { self.registration.head_injectors.push(injector); @@ -709,6 +739,7 @@ struct IntegrationRegistryInner { html_rewriters: Vec>, script_rewriters: Vec>, html_post_processors: Vec>, + html_stream_processors: Vec>, head_injectors: Vec>, request_filters: Vec>, } @@ -730,6 +761,7 @@ impl Default for IntegrationRegistryInner { html_rewriters: Vec::new(), script_rewriters: Vec::new(), html_post_processors: Vec::new(), + html_stream_processors: Vec::new(), head_injectors: Vec::new(), request_filters: Vec::new(), } @@ -859,6 +891,9 @@ impl IntegrationRegistry { inner .html_post_processors .extend(registration.html_post_processors); + inner + .html_stream_processors + .extend(registration.html_stream_processors); inner.head_injectors.extend(registration.head_injectors); inner.request_filters.extend(registration.request_filters); if registration.js_disabled { @@ -1047,6 +1082,14 @@ impl IntegrationRegistry { self.inner.html_post_processors.clone() } + /// Expose registered per-document HTML stream processor factories. + #[must_use] + pub fn html_stream_processor_factories( + &self, + ) -> Vec> { + self.inner.html_stream_processors.clone() + } + /// Collect HTML snippets for insertion at the start of ``. #[must_use] pub fn head_inserts(&self, ctx: &IntegrationHtmlContext<'_>) -> Vec { @@ -1217,6 +1260,7 @@ impl IntegrationRegistry { html_rewriters: attribute_rewriters, script_rewriters, html_post_processors: Vec::new(), + html_stream_processors: Vec::new(), head_injectors: Vec::new(), request_filters: Vec::new(), deferred_js_ids: Vec::new(), @@ -1246,6 +1290,7 @@ impl IntegrationRegistry { html_rewriters: attribute_rewriters, script_rewriters, html_post_processors: Vec::new(), + html_stream_processors: Vec::new(), head_injectors, request_filters: Vec::new(), deferred_js_ids: Vec::new(), @@ -1271,6 +1316,7 @@ impl IntegrationRegistry { html_rewriters: Vec::new(), script_rewriters: Vec::new(), html_post_processors: Vec::new(), + html_stream_processors: Vec::new(), head_injectors: Vec::new(), request_filters, deferred_js_ids: Vec::new(), @@ -1336,6 +1382,7 @@ impl IntegrationRegistry { html_rewriters: Vec::new(), script_rewriters: Vec::new(), html_post_processors: Vec::new(), + html_stream_processors: Vec::new(), head_injectors: Vec::new(), request_filters: Vec::new(), deferred_js_ids: Vec::new(), @@ -1540,6 +1587,76 @@ mod tests { ); } + struct CountingStreamFactory(&'static str); + + impl IntegrationHtmlStreamProcessorFactory for CountingStreamFactory { + fn integration_id(&self) -> &'static str { + self.0 + } + + fn create(&self, _context: IntegrationHtmlStreamContext) -> Box { + struct CountingStreamProcessor(usize); + + impl StreamProcessor for CountingStreamProcessor { + fn process_chunk( + &mut self, + chunk: &[u8], + _is_last: bool, + ) -> std::io::Result> { + self.0 += 1; + let mut output = self.0.to_string().into_bytes(); + output.extend_from_slice(chunk); + Ok(output) + } + } + + Box::new(CountingStreamProcessor(0)) + } + } + + #[test] + fn html_stream_factories_preserve_order_and_create_isolated_sessions() { + let registration = IntegrationRegistration::builder("test") + .with_html_stream_processor(Arc::new(CountingStreamFactory("first"))) + .with_html_stream_processor(Arc::new(CountingStreamFactory("second"))) + .build(); + let identifiers: Vec<_> = registration + .html_stream_processors + .iter() + .map(|factory| factory.integration_id()) + .collect(); + assert_eq!( + identifiers, + ["first", "second"], + "should preserve factory registration order", + ); + + let context = IntegrationHtmlStreamContext { + request_host: "proxy.example.com".to_owned(), + request_scheme: "https".to_owned(), + origin_host: "origin.example.com".to_owned(), + document_state: IntegrationDocumentState::default(), + }; + let factory = ®istration.html_stream_processors[0]; + let mut first = factory.create(context.clone()); + let mut second = factory.create(context); + + assert_eq!( + first + .process_chunk(b"a", false) + .expect("should process first session"), + b"1a", + "should initialize the first session counter", + ); + assert_eq!( + second + .process_chunk(b"b", true) + .expect("should process second session"), + b"1b", + "should initialize an independent second session counter", + ); + } + #[test] fn default_html_post_processor_should_process_is_false() { let processor = NoopHtmlPostProcessor; From f297e65001932294d94a6fa99ed3d506e18a48c3 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 7 Sep 2026 21:11:12 +0530 Subject: [PATCH 11/17] Stream bounded Next.js RSC groups --- .../trusted-server-core/src/html_processor.rs | 400 +-------------- .../src/integrations/mod.rs | 5 +- .../integrations/nextjs/html_post_process.rs | 147 ------ .../src/integrations/nextjs/mod.rs | 30 +- .../integrations/nextjs/rsc_placeholders.rs | 127 +++-- .../src/integrations/nextjs/rsc_stream.rs | 472 +++++++++++++++++- .../src/integrations/nextjs/shared.rs | 2 +- .../src/integrations/registry.rs | 86 ---- crates/trusted-server-core/src/publisher.rs | 24 +- 9 files changed, 586 insertions(+), 707 deletions(-) diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index c5cb4e6a0..6a83b971b 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -17,145 +17,13 @@ use crate::integrations::datadome::{DATADOME_INTEGRATION_ID, DataDomeClientTagSu use crate::integrations::gpt_diagnostics::GptDiagnosticsRequestDecision; use crate::integrations::{ AttributeRewriteOutcome, IntegrationAttributeContext, IntegrationDocumentState, - IntegrationHtmlContext, IntegrationHtmlPostProcessor, IntegrationRegistry, - IntegrationScriptContext, ScriptRewriteAction, + IntegrationHtmlContext, IntegrationRegistry, IntegrationScriptContext, ScriptRewriteAction, }; use crate::publisher::build_empty_bids_script; use crate::settings::Settings; use crate::streaming_processor::{HtmlRewriterAdapter, StreamProcessor}; use crate::tsjs; -/// Wraps [`HtmlRewriterAdapter`] with optional post-processing. -/// -/// When `post_processors` is empty (the common streaming path), chunks pass -/// through immediately with no extra copying. When post-processors are -/// registered, intermediate output is accumulated in `accumulated_output` -/// until `is_last`, then post-processors run on the full document. This adds -/// an extra copy per chunk compared to the pre-streaming adapter (which -/// accumulated raw input instead of rewriter output). The overhead is -/// acceptable because the post-processor path is already fully buffered — -/// the real streaming win comes from the empty-post-processor path in Phase 2. -struct HtmlWithPostProcessing { - inner: HtmlRewriterAdapter, - post_processors: Vec>, - /// Buffer that accumulates all intermediate output when post-processors - /// need the full document. Left empty on the streaming-only path. - accumulated_output: Vec, - /// Cumulative decoded input length seen on the post-processing path. Bounded - /// independently of `accumulated_output` so a rewriter that stashes the - /// original payload in `document_state` and emits a small placeholder (e.g. - /// the Next.js RSC rewriter) cannot grow the Wasm heap past the cap behind - /// the output check. Unused on the streaming-only path. - decoded_input_len: usize, - /// Upper bound on `accumulated_output` (and the post-processed result) to - /// prevent the buffered post-processing path from growing the Wasm heap - /// without limit on highly-compressible documents. - max_buffered_body_bytes: usize, - origin_host: String, - request_host: String, - request_scheme: String, - document_state: IntegrationDocumentState, -} - -impl StreamProcessor for HtmlWithPostProcessing { - fn process_chunk(&mut self, chunk: &[u8], is_last: bool) -> Result, io::Error> { - // Streaming-optimized path: no post-processors, pass through immediately - // with no buffering cap (legacy parity: the streaming path is unbounded). - if self.post_processors.is_empty() { - return self.inner.process_chunk(chunk, is_last); - } - - // On the buffered post-processing path, bound the cumulative decoded - // input before the rewriter runs. The rewriter (and the post-processors - // it feeds) may stash the original payload in `document_state` and emit - // only a small placeholder, so the `accumulated_output` check below - // cannot observe that growth. Capping decoded input first closes that - // hole. Matches the `BoundedWriter` error path (mapped to a 5xx proxy - // error downstream). - self.decoded_input_len = self.decoded_input_len.saturating_add(chunk.len()); - if self.decoded_input_len > self.max_buffered_body_bytes { - return Err(io::Error::other( - "publisher body exceeded maximum buffered size", - )); - } - - let output = self.inner.process_chunk(chunk, is_last)?; - - // Post-processors need the full document. Accumulate until the last chunk, - // but enforce the buffering cap before growing the heap so a highly - // compressible document cannot OOM the accumulator. - if self.accumulated_output.len() + output.len() > self.max_buffered_body_bytes { - return Err(io::Error::other( - "publisher body exceeded maximum buffered size", - )); - } - self.accumulated_output.extend_from_slice(&output); - if !is_last { - return Ok(Vec::new()); - } - - // Final chunk: run post-processors on the full accumulated output. - let full_output = std::mem::take(&mut self.accumulated_output); - if full_output.is_empty() { - return Ok(full_output); - } - - let Ok(output_str) = std::str::from_utf8(&full_output) else { - return Ok(full_output); - }; - - let ctx = IntegrationHtmlContext { - request_host: &self.request_host, - request_scheme: &self.request_scheme, - origin_host: &self.origin_host, - document_state: &self.document_state, - }; - - // Preflight to avoid allocating a `String` unless at least one post-processor wants to run. - if !self - .post_processors - .iter() - .any(|p| p.should_process(output_str, &ctx)) - { - return Ok(full_output); - } - - let mut html = String::from_utf8(full_output).map_err(|e| { - io::Error::other(format!( - "HTML post-processing expected valid UTF-8 output: {e}" - )) - })?; - - let mut changed = false; - for processor in &self.post_processors { - if processor.should_process(&html, &ctx) { - changed |= processor.post_process(&mut html, &ctx); - } - } - - if changed { - log::debug!("HTML post-processing complete: output_len={}", html.len()); - } - - // Post-processors may append content (e.g. injected scripts); enforce the - // same cap on the final document so growth during post-processing cannot - // push the buffer past the limit either. - if html.len() > self.max_buffered_body_bytes { - return Err(io::Error::other( - "publisher body exceeded maximum buffered size", - )); - } - - Ok(html.into_bytes()) - } - - /// No-op. `HtmlWithPostProcessing` wraps a single-use - /// [`HtmlRewriterAdapter`] that cannot be reset. Clearing auxiliary - /// state without resetting the rewriter would leave the processor - /// in an inconsistent state, so this method intentionally does nothing. - fn reset(&mut self) {} -} - struct HtmlWithStreamingProcessors { inner: Box, processors: Vec>, @@ -215,9 +83,8 @@ pub struct HtmlProcessorConfig { /// Handler reads this in `el.on_end_tag()` on the body element. /// `None` means no auction ran; inject empty `tsjs.bids = {}` as fallback. pub ad_bids_state: std::sync::Arc>>, - /// Maximum bytes the post-processing accumulator may buffer before the - /// processor aborts. Mirrors `publisher.max_buffered_body_bytes` so the - /// full-document buffering done for post-processors is bounded. + /// Maximum bytes an integration may retain while processing one script or + /// unresolved streaming group. pub max_buffered_body_bytes: usize, /// Request-scoped conditional diagnostics delivery decision. pub gpt_diagnostics: Option, @@ -320,7 +187,6 @@ impl HtmlProcessorConfig { /// normal operation since no code holds the lock across a panic boundary. #[must_use] pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcessor { - let post_processors = config.integrations.html_post_processors(); let stream_processor_factories = config.integrations.html_stream_processor_factories(); let document_state = IntegrationDocumentState::default(); if config.suppress_datadome_client_side_tag { @@ -833,18 +699,6 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso .into_iter() .map(|factory| factory.create(stream_context.clone())) .collect(); - let inner = HtmlWithPostProcessing { - inner, - post_processors, - accumulated_output: Vec::new(), - decoded_input_len: 0, - max_buffered_body_bytes: config.max_buffered_body_bytes, - origin_host: config.origin_host, - request_host: config.request_host, - request_scheme: config.request_scheme, - document_state, - }; - HtmlWithStreamingProcessors { inner: Box::new(inner), processors, @@ -1593,95 +1447,6 @@ mod tests { ); } - #[test] - fn post_processors_accumulate_while_streaming_path_passes_through() { - use crate::streaming_processor::{HtmlRewriterAdapter, StreamProcessor as _}; - use lol_html::Settings; - - // --- Streaming path: no post-processors → output emitted per chunk --- - let mut streaming = HtmlWithPostProcessing { - inner: HtmlRewriterAdapter::new(Settings::default()), - post_processors: Vec::new(), - accumulated_output: Vec::new(), - decoded_input_len: 0, - max_buffered_body_bytes: 16 * 1024 * 1024, - origin_host: String::new(), - request_host: String::new(), - request_scheme: String::new(), - document_state: IntegrationDocumentState::default(), - }; - - let chunk1 = streaming - .process_chunk(b"", false) - .expect("should process chunk1"); - let chunk2 = streaming - .process_chunk(b"

hello

", false) - .expect("should process chunk2"); - let chunk3 = streaming - .process_chunk(b"", true) - .expect("should process final chunk"); - - assert!( - !chunk1.is_empty() || !chunk2.is_empty(), - "should emit intermediate output on streaming path" - ); - - let mut streaming_all = chunk1; - streaming_all.extend_from_slice(&chunk2); - streaming_all.extend_from_slice(&chunk3); - - // --- Buffered path: post-processor registered → accumulates until is_last --- - struct NoopPostProcessor; - impl IntegrationHtmlPostProcessor for NoopPostProcessor { - fn integration_id(&self) -> &'static str { - "test-noop" - } - fn post_process(&self, _html: &mut String, _ctx: &IntegrationHtmlContext<'_>) -> bool { - false - } - } - - let mut buffered = HtmlWithPostProcessing { - inner: HtmlRewriterAdapter::new(Settings::default()), - post_processors: vec![Arc::new(NoopPostProcessor)], - accumulated_output: Vec::new(), - decoded_input_len: 0, - max_buffered_body_bytes: 16 * 1024 * 1024, - origin_host: String::new(), - request_host: String::new(), - request_scheme: String::new(), - document_state: IntegrationDocumentState::default(), - }; - - let buf1 = buffered - .process_chunk(b"", false) - .expect("should process chunk1"); - let buf2 = buffered - .process_chunk(b"

hello

", false) - .expect("should process chunk2"); - let buf3 = buffered - .process_chunk(b"", true) - .expect("should process final chunk"); - - assert!( - buf1.is_empty() && buf2.is_empty(), - "should return empty for intermediate chunks when post-processors are registered" - ); - assert!( - !buf3.is_empty(), - "should emit all output in final chunk when post-processors are registered" - ); - - // Both paths should produce identical output - let streaming_str = - String::from_utf8(streaming_all).expect("streaming output should be valid UTF-8"); - let buffered_str = String::from_utf8(buf3).expect("buffered output should be valid UTF-8"); - assert_eq!( - streaming_str, buffered_str, - "streaming and buffered paths should produce identical output" - ); - } - #[test] fn html_stream_processors_compose_in_order_and_receive_final_once() { struct DecoratingProcessor { @@ -1739,165 +1504,6 @@ mod tests { assert_eq!(second_final_calls.load(Ordering::SeqCst), 1); } - #[test] - fn post_processing_accumulator_rejects_growth_past_cap() { - use crate::streaming_processor::{HtmlRewriterAdapter, StreamProcessor}; - use lol_html::Settings; - - struct NoopPostProcessor; - impl IntegrationHtmlPostProcessor for NoopPostProcessor { - fn integration_id(&self) -> &'static str { - "test-noop" - } - fn post_process(&self, _html: &mut String, _ctx: &IntegrationHtmlContext<'_>) -> bool { - false - } - } - - // Tiny cap so a single non-final chunk overflows the accumulator. - let mut processor = HtmlWithPostProcessing { - inner: HtmlRewriterAdapter::new(Settings::default()), - post_processors: vec![Arc::new(NoopPostProcessor)], - accumulated_output: Vec::new(), - decoded_input_len: 0, - max_buffered_body_bytes: 16, - origin_host: String::new(), - request_host: String::new(), - request_scheme: String::new(), - document_state: IntegrationDocumentState::default(), - }; - - // A complete element well past the cap. The error must fire on this - // non-final chunk — proving the accumulator itself is bounded, not just - // the final write after the whole document was already buffered. - let oversized = format!("

{}

", "a".repeat(100)); - let err = processor - .process_chunk(oversized.as_bytes(), false) - .expect_err("accumulator growth past the cap must error mid-stream"); - assert!( - err.to_string().contains("exceeded maximum buffered size"), - "should report the buffering cap violation, got: {err}" - ); - - // The accumulator must never retain more than the configured cap. - assert!( - processor.accumulated_output.len() <= 16, - "accumulator must not grow past the cap, held {} bytes", - processor.accumulated_output.len() - ); - } - - #[test] - fn decoded_input_cap_rejects_oversized_input_with_small_output() { - use crate::streaming_processor::{HtmlRewriterAdapter, StreamProcessor}; - use lol_html::Settings; - - struct NoopPostProcessor; - impl IntegrationHtmlPostProcessor for NoopPostProcessor { - fn integration_id(&self) -> &'static str { - "test-noop" - } - fn post_process(&self, _html: &mut String, _ctx: &IntegrationHtmlContext<'_>) -> bool { - false - } - } - - // Tiny cap so a single oversized chunk overflows the decoded-input bound. - let mut processor = HtmlWithPostProcessing { - inner: HtmlRewriterAdapter::new(Settings::default()), - post_processors: vec![Arc::new(NoopPostProcessor)], - accumulated_output: Vec::new(), - decoded_input_len: 0, - max_buffered_body_bytes: 16, - origin_host: String::new(), - request_host: String::new(), - request_scheme: String::new(), - document_state: IntegrationDocumentState::default(), - }; - - // An unclosed tag far larger than the cap. lol_html buffers it internally - // and emits little or no output, so the output accumulator stays small — - // the same shape as a rewriter stashing the payload in `document_state` - // behind a small placeholder. The decoded-input bound must still reject - // it, which the output-only check could not. - let oversized = format!("
&'static str { - "test-append" - } - fn should_process(&self, html: &str, _ctx: &IntegrationHtmlContext<'_>) -> bool { - html.contains("") - } - fn post_process(&self, html: &mut String, _ctx: &IntegrationHtmlContext<'_>) -> bool { - html.push_str(""); - true - } - } - - let mut processor = HtmlWithPostProcessing { - inner: HtmlRewriterAdapter::new(Settings::default()), - post_processors: vec![Arc::new(AppendCommentProcessor)], - accumulated_output: Vec::new(), - decoded_input_len: 0, - max_buffered_body_bytes: 16 * 1024 * 1024, - origin_host: String::new(), - request_host: String::new(), - request_scheme: String::new(), - document_state: IntegrationDocumentState::default(), - }; - - // Feed multiple chunks - let r1 = processor - .process_chunk(b"", false) - .expect("should process chunk1"); - let r2 = processor - .process_chunk(b"

content

", false) - .expect("should process chunk2"); - let r3 = processor - .process_chunk(b"", true) - .expect("should process final chunk"); - - // Intermediate chunks return empty (buffered for post-processor) - assert!( - r1.is_empty() && r2.is_empty(), - "should buffer intermediate chunks" - ); - - // Final chunk contains the full document with post-processor mutation - let output = String::from_utf8(r3).expect("should be valid UTF-8"); - assert!( - output.contains("

content

"), - "should contain original content" - ); - assert!( - output.contains(""), - "should contain complete document" - ); - assert!( - output.contains(""), - "should contain post-processor mutation" - ); - } - #[test] fn injects_ad_slots_at_head_open() { let config = HtmlProcessorConfig { diff --git a/crates/trusted-server-core/src/integrations/mod.rs b/crates/trusted-server-core/src/integrations/mod.rs index 4b4ac7254..5feee56e6 100644 --- a/crates/trusted-server-core/src/integrations/mod.rs +++ b/crates/trusted-server-core/src/integrations/mod.rs @@ -32,9 +32,8 @@ pub use registry::{ AttributeRewriteAction, AttributeRewriteOutcome, HeaderMutation, HeaderMutationMode, IntegrationAttributeContext, IntegrationAttributeRewriter, IntegrationDocumentState, IntegrationEndpoint, IntegrationHeadInjector, IntegrationHtmlContext, - IntegrationHtmlPostProcessor, IntegrationHtmlStreamContext, - IntegrationHtmlStreamProcessorFactory, IntegrationMetadata, IntegrationProxy, - IntegrationRegistration, IntegrationRegistrationBuilder, IntegrationRegistry, + IntegrationHtmlStreamContext, IntegrationHtmlStreamProcessorFactory, IntegrationMetadata, + IntegrationProxy, IntegrationRegistration, IntegrationRegistrationBuilder, IntegrationRegistry, IntegrationRequestFilter, IntegrationScriptContext, IntegrationScriptRewriter, ProxyDispatchInput, RequestFilterDecision, RequestFilterEffects, RequestFilterInput, RequestFilterRegistryInput, RequestFilterRegistryOutcome, ScriptRewriteAction, diff --git a/crates/trusted-server-core/src/integrations/nextjs/html_post_process.rs b/crates/trusted-server-core/src/integrations/nextjs/html_post_process.rs index 80468827d..b5bcbceec 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/html_post_process.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/html_post_process.rs @@ -1,157 +1,10 @@ use std::cell::{Cell, RefCell}; use std::rc::Rc; -use std::sync::Arc; use lol_html::{Settings as RewriterSettings, text}; -use crate::integrations::{IntegrationHtmlContext, IntegrationHtmlPostProcessor}; - use super::rsc::rewrite_rsc_scripts_combined_with_limit; -use super::rsc_stream::{CapturedPayload, NextJsDocumentState}; use super::shared::{RscUrlRewriter, find_rsc_push_payload_range}; -use super::{NEXTJS_INTEGRATION_ID, NextJsIntegrationConfig}; - -pub(crate) struct NextJsHtmlPostProcessor { - config: Arc, -} - -impl NextJsHtmlPostProcessor { - pub(crate) fn new(config: Arc) -> Self { - Self { config } - } -} - -impl IntegrationHtmlPostProcessor for NextJsHtmlPostProcessor { - fn integration_id(&self) -> &'static str { - NEXTJS_INTEGRATION_ID - } - - fn should_process(&self, html: &str, ctx: &IntegrationHtmlContext<'_>) -> bool { - if !self.config.enabled || self.config.rewrite_attributes.is_empty() { - return false; - } - - // Check if we have captured placeholders from streaming - if let Some(state) = ctx - .document_state - .get::>(NEXTJS_INTEGRATION_ID) - { - let guard = state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if !guard.captured_payloads.is_empty() { - return true; - } - } - - // Also check if HTML contains RSC scripts that weren't captured during streaming - // (e.g., fragmented scripts that we skipped during the streaming pass) - html.contains("__next_f.push") && html.contains(ctx.origin_host) - } - - fn post_process(&self, html: &mut String, ctx: &IntegrationHtmlContext<'_>) -> bool { - // Try to get payloads captured during streaming (placeholder approach) - let captured = ctx - .document_state - .get::>(NEXTJS_INTEGRATION_ID) - .map(|state| { - let mut guard = state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - guard.captured_payload_bytes = 0; - guard.captured_payloads.drain(..).collect::>() - }) - .unwrap_or_default(); - - // Single rewriter instance shared across both code paths so the compiled - // regex is cached and reused regardless of which branch executes. - let rsc_rewriter = RscUrlRewriter::new(); - - if !captured.is_empty() { - // Placeholder approach: substitute placeholders with rewritten payloads - return self.substitute_placeholders(html, ctx, captured, &rsc_rewriter); - } - - // Fallback: re-parse HTML to find RSC scripts that weren't captured during streaming - // (e.g., fragmented scripts that we skipped during the streaming pass) - post_process_rsc_html_in_place_with_limit( - html, - ctx.origin_host, - ctx.request_host, - ctx.request_scheme, - self.config.max_combined_payload_bytes, - &rsc_rewriter, - ) - } -} - -impl NextJsHtmlPostProcessor { - /// Substitute placeholders with rewritten payloads (fast path for unfragmented scripts). - fn substitute_placeholders( - &self, - html: &mut String, - ctx: &IntegrationHtmlContext<'_>, - captured: Vec, - rsc_rewriter: &RscUrlRewriter, - ) -> bool { - let payload_refs: Vec<&str> = captured - .iter() - .map(|payload| payload.original.as_str()) - .collect(); - let mut rewritten_payloads = rewrite_rsc_scripts_combined_with_limit( - payload_refs.as_slice(), - rsc_rewriter, - ctx.origin_host, - ctx.request_host, - ctx.request_scheme, - self.config.max_combined_payload_bytes, - ); - - if rewritten_payloads.len() != captured.len() { - log::warn!( - "NextJs post-process skipping due to rewrite payload count mismatch: original={}, rewritten={}", - captured.len(), - rewritten_payloads.len() - ); - rewritten_payloads = captured - .iter() - .map(|payload| payload.original.clone()) - .collect(); - } - - if log::log_enabled!(log::Level::Debug) { - let origin_count_before: usize = rewritten_payloads - .iter() - .map(|p| p.matches(ctx.origin_host).count()) - .sum(); - log::debug!( - "NextJs post-processor substituting RSC payloads: scripts={}, origin_urls={}, html_len={}", - rewritten_payloads.len(), - origin_count_before, - html.len() - ); - } - - let expected = captured.len(); - if !captured - .iter() - .all(|payload| html.matches(&payload.placeholder).count() == 1) - { - log::warn!( - "NextJs post-process placeholder substitution count mismatch: expected={expected}" - ); - for payload in &captured { - *html = html.replace(&payload.placeholder, &payload.original); - } - return true; - } - - for (payload, replacement) in captured.iter().zip(rewritten_payloads) { - *html = html.replacen(&payload.placeholder, &replacement, 1); - } - true - } -} #[derive(Debug, Clone, Copy)] struct RscPushScriptRange { diff --git a/crates/trusted-server-core/src/integrations/nextjs/mod.rs b/crates/trusted-server-core/src/integrations/nextjs/mod.rs index 224a5d082..8b6b59ce4 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/mod.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/mod.rs @@ -13,16 +13,12 @@ const NEXTJS_INTEGRATION_ID: &str = "nextjs"; mod html_post_process; mod rsc; mod rsc_placeholders; -#[allow( - dead_code, - reason = "classifier is consumed by the bounded streaming session introduced with it" -)] mod rsc_stream; mod script_rewriter; mod shared; // Re-export deprecated legacy functions for backward compatibility. -// Production code should use the placeholder-based approach via NextJsHtmlPostProcessor. +// Production code uses the bounded placeholder streaming processor. #[allow( deprecated, reason = "legacy HTML post-processing functions remain re-exported for compatibility" @@ -30,8 +26,8 @@ mod shared; pub use html_post_process::{post_process_rsc_html, post_process_rsc_html_in_place}; pub use rsc::rewrite_rsc_scripts_combined; -use html_post_process::NextJsHtmlPostProcessor; use rsc_placeholders::NextJsRscPlaceholderRewriter; +use rsc_stream::NextJsRscStreamProcessorFactory; use script_rewriter::NextJsNextDataRewriter; #[derive(Debug, Clone, Deserialize, Serialize, Validate)] @@ -99,17 +95,16 @@ pub fn register( // Register a structured (Pages Router __NEXT_DATA__) rewriter. let structured = Arc::new(NextJsNextDataRewriter::new(config.clone())?); - // Insert placeholders for App Router RSC payload scripts during the initial HTML rewrite pass, - // then substitute them during post-processing without re-parsing HTML. + // Insert placeholders for App Router RSC payload scripts during the HTML rewrite pass, + // then substitute them through the bounded output stream processor. let placeholders = Arc::new(NextJsRscPlaceholderRewriter::new(config.clone())); - // Register post-processor for cross-script RSC T-chunks - let post_processor = Arc::new(NextJsHtmlPostProcessor::new(config.clone())); + let stream_processor = Arc::new(NextJsRscStreamProcessorFactory::new(config.clone())); let builder = IntegrationRegistration::builder(NEXTJS_INTEGRATION_ID) .with_script_rewriter(structured) .with_script_rewriter(placeholders) - .with_html_post_processor(post_processor); + .with_html_stream_processor(stream_processor); Ok(Some(builder.build())) } @@ -642,19 +637,12 @@ mod tests { } /// Regression test: a fragmented `self.__next_f.push([1, "…"])` RSC script - /// must still have its origin URLs rewritten after going through the full - /// streaming pipeline into the accumulating post-processor. Exercises the - /// "fallback" branch of `NextJsHtmlPostProcessor` where no placeholders - /// were captured during streaming (because every fragment returned `Keep` - /// on `!is_last`) and `post_process_rsc_html_in_place_with_limit` has to - /// re-parse the accumulated HTML to find RSC push scripts. + /// must still have its origin URLs rewritten through the streaming pipeline. #[test] - fn small_chunk_rsc_push_survives_fragmentation_via_post_processor_fallback() { + fn small_chunk_rsc_push_survives_fragmentation() { // Build an RSC push script whose payload contains multiple origin URLs. // With chunk_size = 128, this script's text node will be fragmented at - // chunk boundaries by the streaming input, so NextJsRscPlaceholderRewriter - // will return Keep on every fragment and the post-processor fallback - // has to rewrite on the accumulated HTML. + // chunk boundaries by the streaming input. let html = format!( r#""#, "x".repeat(400), // pad to guarantee chunk-boundary fragmentation diff --git a/crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs b/crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs index a2fa8e408..829e0f2af 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs @@ -85,6 +85,42 @@ impl NextJsRscPlaceholderRewriter { rewritten.replace_range(payload_start..payload_end, &placeholder); ScriptRewriteAction::replace(rewritten) } + + fn rewrite_claimed_fragment( + &self, + content: &str, + is_last: bool, + state: &mut super::rsc_stream::NextJsDocumentState, + limit: usize, + ) -> ScriptRewriteAction { + match capture_fragment(&mut state.rsc_script, content, is_last, limit) { + FragmentCapture::CompleteBorrowed(complete) => { + self.rewrite_complete(complete, false, state, limit) + } + FragmentCapture::CompleteOwned(complete) => { + self.rewrite_complete(&complete, true, state, limit) + } + FragmentCapture::Suppress => ScriptRewriteAction::RemoveNode, + FragmentCapture::Restore(restored) => { + state.bypass_rsc = true; + ScriptRewriteAction::replace(restored) + } + FragmentCapture::PassThrough => { + if is_last && content.len() > limit && content.contains("__next_f") { + let unsafe_continuation = find_rsc_push_payload_range(content) + .map(|(start, end)| match scan_tchunks(&content[start..end]) { + TChunkScan::Complete(_) => false, + TChunkScan::NeedMore | TChunkScan::Invalid => true, + }) + .unwrap_or(true); + state.bypass_rsc |= unsafe_continuation; + } else if !is_last && content.len() > limit { + state.bypass_rsc = true; + } + ScriptRewriteAction::Keep + } + } + } } impl IntegrationScriptRewriter for NextJsRscPlaceholderRewriter { @@ -111,53 +147,74 @@ impl IntegrationScriptRewriter for NextJsRscPlaceholderRewriter { } return ScriptRewriteAction::keep(); } - if matches!(state.rsc_script, super::rsc_stream::FragmentState::Idle) - && !content.contains("__next_f") - { - return ScriptRewriteAction::Keep; - } - let limit = if self.config.max_combined_payload_bytes == 0 { DEFAULT_MAX_COMBINED_PAYLOAD_BYTES } else { self.config.max_combined_payload_bytes }; - match capture_fragment( - &mut state.rsc_script, - content, - ctx.is_last_in_text_node, - limit, - ) { - FragmentCapture::CompleteBorrowed(complete) => { - self.rewrite_complete(complete, false, &mut state, limit) + if !matches!(state.rsc_script, super::rsc_stream::FragmentState::Idle) { + return self.rewrite_claimed_fragment( + content, + ctx.is_last_in_text_node, + &mut state, + limit, + ); + } + + let prior_probe = std::mem::take(&mut state.rsc_probe); + let mut combined = prior_probe.clone(); + combined.push_str(content); + let Some(identifier_start) = combined.find("__next_f") else { + if ctx.is_last_in_text_node { + return if prior_probe.is_empty() { + ScriptRewriteAction::Keep + } else { + ScriptRewriteAction::replace(combined) + }; } - FragmentCapture::CompleteOwned(complete) => { - self.rewrite_complete(&complete, true, &mut state, limit) + let probe_length = longest_identifier_prefix(combined.as_bytes()); + let ready_length = combined.len() - probe_length; + state.rsc_probe.push_str(&combined[ready_length..]); + if prior_probe.is_empty() && probe_length == 0 { + return ScriptRewriteAction::Keep; } - FragmentCapture::Suppress => ScriptRewriteAction::RemoveNode, - FragmentCapture::Restore(restored) => { - state.bypass_rsc = true; - ScriptRewriteAction::replace(restored) - } - FragmentCapture::PassThrough => { - if ctx.is_last_in_text_node && content.len() > limit && content.contains("__next_f") - { - let unsafe_continuation = find_rsc_push_payload_range(content) - .map(|(start, end)| match scan_tchunks(&content[start..end]) { - TChunkScan::Complete(_) => false, - TChunkScan::NeedMore | TChunkScan::Invalid => true, - }) - .unwrap_or(true); - state.bypass_rsc |= unsafe_continuation; - } else if !ctx.is_last_in_text_node && content.len() > limit { - state.bypass_rsc = true; - } - ScriptRewriteAction::Keep + return if ready_length == 0 { + ScriptRewriteAction::RemoveNode + } else { + ScriptRewriteAction::replace(&combined[..ready_length]) + }; + }; + + let claimed_start = if prior_probe.is_empty() { + 0 + } else { + identifier_start + }; + let prefix = &combined[..claimed_start]; + let claimed = &combined[claimed_start..]; + let action = + self.rewrite_claimed_fragment(claimed, ctx.is_last_in_text_node, &mut state, limit); + match action { + ScriptRewriteAction::RemoveNode if prefix.is_empty() => ScriptRewriteAction::RemoveNode, + ScriptRewriteAction::RemoveNode => ScriptRewriteAction::replace(prefix), + ScriptRewriteAction::Replace(rewritten) => { + ScriptRewriteAction::replace(format!("{prefix}{rewritten}")) } + ScriptRewriteAction::Keep if prior_probe.is_empty() => ScriptRewriteAction::Keep, + ScriptRewriteAction::Keep => ScriptRewriteAction::replace(combined), } } } +fn longest_identifier_prefix(bytes: &[u8]) -> usize { + let identifier = b"__next_f"; + let maximum = bytes.len().min(identifier.len().saturating_sub(1)); + (1..=maximum) + .rev() + .find(|length| bytes.ends_with(&identifier[..*length])) + .unwrap_or(0) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs b/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs index f7c156cd2..3e3b2e66b 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs @@ -1,11 +1,19 @@ use std::borrow::Cow; use std::collections::VecDeque; +use std::io; use std::sync::{Arc, Mutex}; -use crate::integrations::IntegrationDocumentState; +use crate::integrations::{ + IntegrationDocumentState, IntegrationHtmlStreamContext, IntegrationHtmlStreamProcessorFactory, +}; +use crate::streaming_processor::StreamProcessor; -use super::NEXTJS_INTEGRATION_ID; -use super::rsc::{TChunkScan, scan_tchunks}; +use super::rsc::{ + DEFAULT_MAX_COMBINED_PAYLOAD_BYTES, TChunkScan, rewrite_rsc_scripts_combined_with_limit, + scan_tchunks, +}; +use super::shared::RscUrlRewriter; +use super::{NEXTJS_INTEGRATION_ID, NextJsIntegrationConfig}; pub(super) const RSC_PAYLOAD_PLACEHOLDER_PREFIX: &str = "__ts_rsc_"; pub(super) const RSC_PAYLOAD_PLACEHOLDER_SUFFIX: &str = "__"; @@ -18,7 +26,7 @@ pub(super) enum FragmentState { BypassUntilLast, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub(super) struct CapturedPayload { pub(super) placeholder: String, pub(super) original: String, @@ -29,6 +37,7 @@ pub(super) struct NextJsDocumentState { pub(super) namespace: String, pub(super) next_data: FragmentState, pub(super) rsc_script: FragmentState, + pub(super) rsc_probe: String, pub(super) captured_payloads: VecDeque, pub(super) captured_payload_bytes: usize, pub(super) next_placeholder_index: usize, @@ -41,6 +50,7 @@ impl Default for NextJsDocumentState { namespace: uuid::Uuid::new_v4().simple().to_string(), next_data: FragmentState::Idle, rsc_script: FragmentState::Idle, + rsc_probe: String::new(), captured_payloads: VecDeque::new(), captured_payload_bytes: 0, next_placeholder_index: 0, @@ -124,6 +134,361 @@ pub(super) fn capture_fragment<'a>( } } +pub(super) struct NextJsRscStreamProcessorFactory { + config: Arc, +} + +impl NextJsRscStreamProcessorFactory { + pub(super) fn new(config: Arc) -> Self { + Self { config } + } +} + +impl IntegrationHtmlStreamProcessorFactory for NextJsRscStreamProcessorFactory { + fn integration_id(&self) -> &'static str { + NEXTJS_INTEGRATION_ID + } + + fn create(&self, context: IntegrationHtmlStreamContext) -> Box { + let limit = if self.config.max_combined_payload_bytes == 0 { + DEFAULT_MAX_COMBINED_PAYLOAD_BYTES + } else { + self.config.max_combined_payload_bytes + }; + Box::new(NextJsRscStreamProcessor::new( + document_state(&context.document_state), + context.origin_host, + context.request_host, + context.request_scheme, + limit, + )) + } +} + +pub(super) struct NextJsRscStreamProcessor { + state: Arc>, + origin_host: String, + request_host: String, + request_scheme: String, + limit: usize, + pending_candidate: Vec, + held_output: Vec, + group: Vec, + rewriter: RscUrlRewriter, +} + +impl NextJsRscStreamProcessor { + fn new( + state: Arc>, + origin_host: String, + request_host: String, + request_scheme: String, + limit: usize, + ) -> Self { + Self { + state, + origin_host, + request_host, + request_scheme, + limit, + pending_candidate: Vec::new(), + held_output: Vec::new(), + group: Vec::new(), + rewriter: RscUrlRewriter::new(), + } + } + + fn namespace_prefix(&self) -> Vec { + let state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + format!("{RSC_PAYLOAD_PLACEHOLDER_PREFIX}{}_", state.namespace).into_bytes() + } + + fn next_captured(&self) -> Option { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .captured_payloads + .front() + .cloned() + } + + fn pop_captured(&self, placeholder: &str) -> io::Result { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let Some(payload) = state.captured_payloads.pop_front() else { + return Err(io::Error::other( + "Next.js RSC placeholder has no captured payload", + )); + }; + if payload.placeholder != placeholder { + state.captured_payloads.push_front(payload); + return Err(io::Error::other( + "Next.js RSC placeholders are out of document order", + )); + } + state.captured_payload_bytes = state + .captured_payload_bytes + .saturating_sub(payload.original.len()); + Ok(payload) + } + + fn append_held(&mut self, bytes: &[u8]) -> bool { + if self + .held_output + .len() + .checked_add(bytes.len()) + .is_none_or(|combined| combined > self.limit) + { + false + } else { + self.held_output.extend_from_slice(bytes); + true + } + } + + fn release_group(&mut self, rewritten: Option>) -> io::Result> { + let replacements: Vec<&str> = match &rewritten { + Some(rewritten) => rewritten.iter().map(String::as_str).collect(), + None => self + .group + .iter() + .map(|payload| payload.original.as_str()) + .collect(), + }; + let output = substitute_payloads( + std::mem::take(&mut self.held_output), + &self.group, + &replacements, + &self.namespace_prefix(), + )?; + self.group.clear(); + Ok(output) + } + + fn resolve_group(&mut self) -> io::Result>> { + let payloads: Vec<&str> = self + .group + .iter() + .map(|payload| payload.original.as_str()) + .collect(); + match classify_rsc_group(&payloads, self.limit) { + RscGroupStatus::NeedMore => Ok(None), + RscGroupStatus::CompleteRewritable => { + let rewritten = rewrite_rsc_scripts_combined_with_limit( + &payloads, + &self.rewriter, + &self.origin_host, + &self.request_host, + &self.request_scheme, + self.limit, + ); + if rewritten.len() != self.group.len() { + return Err(io::Error::other( + "Next.js RSC rewrite returned a mismatched payload count", + )); + } + self.release_group(Some(rewritten)).map(Some) + } + RscGroupStatus::CompleteUnrewritable => self.release_group(None).map(Some), + RscGroupStatus::Invalid => { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .bypass_rsc = true; + self.release_group(None).map(Some) + } + } + } + + fn release_bypass(&mut self, current: &[u8]) -> io::Result> { + let mut output = self.release_group(None)?; + output.extend_from_slice(&self.pending_candidate); + self.pending_candidate.clear(); + let captured = { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.bypass_rsc = true; + state.captured_payload_bytes = 0; + state.captured_payloads.drain(..).collect::>() + }; + let replacements: Vec<&str> = captured + .iter() + .map(|payload| payload.original.as_str()) + .collect(); + output.extend(substitute_payloads( + current.to_vec(), + &captured, + &replacements, + &self.namespace_prefix(), + )?); + Ok(output) + } +} + +impl StreamProcessor for NextJsRscStreamProcessor { + fn process_chunk(&mut self, chunk: &[u8], is_last: bool) -> io::Result> { + let mut current = std::mem::take(&mut self.pending_candidate); + current.extend_from_slice(chunk); + + let bypass = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .bypass_rsc; + if bypass { + return self.release_bypass(¤t); + } + + let namespace_prefix = self.namespace_prefix(); + let mut output = Vec::new(); + let mut cursor = 0; + loop { + let Some(expected) = self.next_captured() else { + let remainder = ¤t[cursor..]; + if self.group.is_empty() { + output.extend_from_slice(remainder); + } else if !self.append_held(remainder) { + output.extend(self.release_bypass(remainder)?); + } + break; + }; + + let remainder = ¤t[cursor..]; + let Some(relative_start) = find_bytes(remainder, &namespace_prefix) else { + let retained = longest_suffix_prefix(remainder, expected.placeholder.as_bytes()); + let ready_end = remainder.len() - retained; + let ready = &remainder[..ready_end]; + if self.group.is_empty() { + output.extend_from_slice(ready); + } else if !self.append_held(ready) { + output.extend(self.release_bypass(remainder)?); + break; + } + self.pending_candidate + .extend_from_slice(&remainder[ready_end..]); + break; + }; + + let placeholder_start = cursor + relative_start; + let before = ¤t[cursor..placeholder_start]; + if self.group.is_empty() { + output.extend_from_slice(before); + } else if !self.append_held(before) { + output.extend(self.release_bypass(¤t[placeholder_start..])?); + break; + } + + let placeholder = expected.placeholder.as_bytes(); + let available = ¤t[placeholder_start..]; + if available.len() < placeholder.len() && placeholder.starts_with(available) { + self.pending_candidate.extend_from_slice(available); + break; + } + if !available.starts_with(placeholder) { + return Err(io::Error::other( + "Next.js RSC output contains an unknown generated placeholder", + )); + } + if !self.append_held(placeholder) { + output.extend(self.release_bypass(¤t[placeholder_start..])?); + break; + } + let captured = self.pop_captured(&expected.placeholder)?; + self.group.push(captured); + cursor = placeholder_start + placeholder.len(); + + if let Some(released) = self.resolve_group()? { + output.extend(released); + } + } + + if is_last { + if !self.pending_candidate.is_empty() { + if self.group.is_empty() { + output.append(&mut self.pending_candidate); + } else { + let pending = std::mem::take(&mut self.pending_candidate); + if !self.append_held(&pending) { + output.extend(self.release_bypass(&pending)?); + } + } + } + if !self.group.is_empty() { + output.extend(self.release_group(None)?); + } + if self.next_captured().is_some() { + return Err(io::Error::other( + "Next.js RSC captured payload was not present in parser output", + )); + } + } + + Ok(output) + } +} + +fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + (!needle.is_empty()) + .then(|| { + haystack + .windows(needle.len()) + .position(|window| window == needle) + }) + .flatten() +} + +fn longest_suffix_prefix(bytes: &[u8], pattern: &[u8]) -> usize { + let maximum = bytes.len().min(pattern.len().saturating_sub(1)); + (1..=maximum) + .rev() + .find(|length| bytes.ends_with(&pattern[..*length])) + .unwrap_or(0) +} + +fn substitute_payloads( + mut input: Vec, + payloads: &[CapturedPayload], + replacements: &[&str], + namespace_prefix: &[u8], +) -> io::Result> { + if payloads.len() != replacements.len() { + return Err(io::Error::other( + "Next.js RSC substitution received mismatched payloads", + )); + } + for (payload, replacement) in payloads.iter().zip(replacements) { + let placeholder = payload.placeholder.as_bytes(); + let Some(position) = find_bytes(&input, placeholder) else { + return Err(io::Error::other( + "Next.js RSC captured placeholder is missing from held output", + )); + }; + let mut next = Vec::with_capacity( + input + .len() + .saturating_sub(placeholder.len()) + .saturating_add(replacement.len()), + ); + next.extend_from_slice(&input[..position]); + next.extend_from_slice(replacement.as_bytes()); + next.extend_from_slice(&input[position + placeholder.len()..]); + input = next; + } + if find_bytes(&input, namespace_prefix).is_some() { + return Err(io::Error::other( + "Next.js RSC generated placeholder remained after substitution", + )); + } + Ok(input) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum RscGroupStatus { CompleteRewritable, @@ -364,4 +729,103 @@ mod tests { "should reject a group larger than its configured bound", ); } + + fn processor_with_payloads( + payloads: &[&str], + limit: usize, + ) -> (NextJsRscStreamProcessor, Vec) { + let integration_state = IntegrationDocumentState::default(); + let shared = document_state(&integration_state); + let mut placeholders = Vec::new(); + { + let mut state = shared.lock().expect("should lock document state"); + for payload in payloads { + let placeholder = + rsc_payload_placeholder(&state.namespace, state.next_placeholder_index); + state.next_placeholder_index += 1; + state.captured_payload_bytes += payload.len(); + state.captured_payloads.push_back(CapturedPayload { + placeholder: placeholder.clone(), + original: (*payload).to_owned(), + }); + placeholders.push(placeholder); + } + } + ( + NextJsRscStreamProcessor::new( + shared, + "origin.example.com".to_owned(), + "proxy.example.com".to_owned(), + "https".to_owned(), + limit, + ), + placeholders, + ) + } + + #[test] + fn stream_processor_emits_ordinary_html_before_eof() { + let (mut processor, _) = processor_with_payloads(&[], 1024); + + assert_eq!( + processor + .process_chunk(b"ordinary", false) + .expect("should process ordinary HTML"), + b"ordinary", + "should not wait for EOF without an unresolved RSC group", + ); + } + + #[test] + fn stream_processor_rewrites_and_releases_a_complete_payload_in_one_call() { + let payload = r#"1:T29,{"url":"https://origin.example.com/path"}"#; + let (mut processor, placeholders) = processor_with_payloads(&[payload], 1024); + let input = format!("before{}after", placeholders[0]); + + let output = processor + .process_chunk(input.as_bytes(), false) + .expect("should process complete RSC payload"); + let output = String::from_utf8(output).expect("should emit UTF-8 HTML"); + + assert!(output.starts_with("before")); + assert!(output.ends_with("after")); + assert!(output.contains("proxy.example.com/path")); + assert!(!output.contains(RSC_PAYLOAD_PLACEHOLDER_PREFIX)); + } + + #[test] + fn stream_processor_holds_only_until_cross_payload_content_completes() { + let payloads = ["1:T3,ab", "c"]; + let (mut processor, placeholders) = processor_with_payloads(&payloads, 1024); + + let first = processor + .process_chunk(format!("head{}middle", placeholders[0]).as_bytes(), false) + .expect("should process incomplete group"); + assert_eq!(first, b"head", "should hold from the first placeholder"); + + let second = processor + .process_chunk(format!("{}tail", placeholders[1]).as_bytes(), false) + .expect("should complete group"); + assert_eq!( + second, + format!("{}middle{}tail", payloads[0], payloads[1]).as_bytes(), + "should release the complete group and interstitial output in order", + ); + } + + #[test] + fn stream_processor_matches_a_placeholder_split_across_output_chunks() { + let (mut processor, placeholders) = processor_with_payloads(&["plain"], 1024); + let placeholder = &placeholders[0]; + let split = placeholder.len() / 2; + + let first = processor + .process_chunk(placeholder[..split].as_bytes(), false) + .expect("should retain a partial placeholder"); + assert!(first.is_empty(), "should retain only the candidate suffix"); + let second = processor + .process_chunk(placeholder[split..].as_bytes(), false) + .expect("should finish the placeholder"); + assert_eq!(second, b"plain", "should restore the captured payload"); + } } diff --git a/crates/trusted-server-core/src/integrations/nextjs/shared.rs b/crates/trusted-server-core/src/integrations/nextjs/shared.rs index 7b88aa0ae..cea8add94 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/shared.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/shared.rs @@ -15,7 +15,7 @@ use crate::host_rewrite::rewrite_bare_host_at_boundaries; /// RSC push script call pattern for extracting payload string boundaries. pub(crate) static RSC_PUSH_CALL_PATTERN: LazyLock = LazyLock::new(|| { Regex::new( - r#"(?s)(?:(?:self|window)\.__next_f\.push|\(\s*(?:self|window)\.__next_f\s*=\s*(?:self|window)\.__next_f\s*\|\|\s*\[\]\s*\)\s*\.push)\(\[\s*1\s*,\s*(['"])"#, + r#"(?s)(?:(?:(?:self|window)\.)?__next_f\.push|\(\s*(?:self|window)\.__next_f\s*=\s*(?:self|window)\.__next_f\s*\|\|\s*\[\]\s*\)\s*\.push)\(\[\s*1\s*,\s*(['"])"#, ) .expect("valid RSC push call regex") }); diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 4c699bdfc..33e68e0cd 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -550,28 +550,6 @@ pub struct IntegrationHtmlContext<'a> { pub document_state: &'a IntegrationDocumentState, } -/// Trait for integration-provided HTML post-processors. -/// These run after streaming HTML processing to handle cases that require -/// access to the complete HTML (e.g., cross-script RSC T-chunks). -pub trait IntegrationHtmlPostProcessor: Send + Sync { - /// Identifier for logging/diagnostics. - fn integration_id(&self) -> &'static str; - - /// Fast preflight check to decide whether post-processing should run for this document. - /// - /// Implementations should keep this cheap (e.g., a substring check) because it may run on - /// every HTML response when the integration is enabled. - fn should_process(&self, html: &str, ctx: &IntegrationHtmlContext<'_>) -> bool { - let _ = (html, ctx); - false - } - - /// Post-process complete HTML content. - /// This is called after streaming HTML processing with the complete HTML. - /// Implementations should mutate `html` in-place and return `true` when changes were made. - fn post_process(&self, html: &mut String, ctx: &IntegrationHtmlContext<'_>) -> bool; -} - /// Owned request data supplied when an integration creates an HTML stream processor. #[derive(Clone)] pub struct IntegrationHtmlStreamContext { @@ -611,7 +589,6 @@ pub struct IntegrationRegistration { pub proxies: Vec>, pub attribute_rewriters: Vec>, pub script_rewriters: Vec>, - pub html_post_processors: Vec>, pub html_stream_processors: Vec>, pub head_injectors: Vec>, pub request_filters: Vec>, @@ -638,7 +615,6 @@ impl IntegrationRegistrationBuilder { proxies: Vec::new(), attribute_rewriters: Vec::new(), script_rewriters: Vec::new(), - html_post_processors: Vec::new(), html_stream_processors: Vec::new(), head_injectors: Vec::new(), request_filters: Vec::new(), @@ -667,15 +643,6 @@ impl IntegrationRegistrationBuilder { self } - #[must_use] - pub fn with_html_post_processor( - mut self, - processor: Arc, - ) -> Self { - self.registration.html_post_processors.push(processor); - self - } - #[must_use] pub fn with_html_stream_processor( mut self, @@ -738,7 +705,6 @@ struct IntegrationRegistryInner { disabled_js_ids: Vec<&'static str>, html_rewriters: Vec>, script_rewriters: Vec>, - html_post_processors: Vec>, html_stream_processors: Vec>, head_injectors: Vec>, request_filters: Vec>, @@ -760,7 +726,6 @@ impl Default for IntegrationRegistryInner { disabled_js_ids: Vec::new(), html_rewriters: Vec::new(), script_rewriters: Vec::new(), - html_post_processors: Vec::new(), html_stream_processors: Vec::new(), head_injectors: Vec::new(), request_filters: Vec::new(), @@ -888,9 +853,6 @@ impl IntegrationRegistry { .html_rewriters .extend(registration.attribute_rewriters); inner.script_rewriters.extend(registration.script_rewriters); - inner - .html_post_processors - .extend(registration.html_post_processors); inner .html_stream_processors .extend(registration.html_stream_processors); @@ -1067,21 +1029,6 @@ impl IntegrationRegistry { self.inner.script_rewriters.clone() } - /// Check whether any HTML post-processors are registered. - /// - /// Cheaper than [`html_post_processors()`](Self::html_post_processors) when - /// only the presence check is needed — avoids cloning `Vec>`. - #[must_use] - pub fn has_html_post_processors(&self) -> bool { - !self.inner.html_post_processors.is_empty() - } - - /// Expose registered HTML post-processors. - #[must_use] - pub fn html_post_processors(&self) -> Vec> { - self.inner.html_post_processors.clone() - } - /// Expose registered per-document HTML stream processor factories. #[must_use] pub fn html_stream_processor_factories( @@ -1259,7 +1206,6 @@ impl IntegrationRegistry { enabled_integration_ids: Vec::new(), html_rewriters: attribute_rewriters, script_rewriters, - html_post_processors: Vec::new(), html_stream_processors: Vec::new(), head_injectors: Vec::new(), request_filters: Vec::new(), @@ -1289,7 +1235,6 @@ impl IntegrationRegistry { enabled_integration_ids: Vec::new(), html_rewriters: attribute_rewriters, script_rewriters, - html_post_processors: Vec::new(), html_stream_processors: Vec::new(), head_injectors, request_filters: Vec::new(), @@ -1315,7 +1260,6 @@ impl IntegrationRegistry { enabled_integration_ids: Vec::new(), html_rewriters: Vec::new(), script_rewriters: Vec::new(), - html_post_processors: Vec::new(), html_stream_processors: Vec::new(), head_injectors: Vec::new(), request_filters, @@ -1381,7 +1325,6 @@ impl IntegrationRegistry { enabled_integration_ids: Vec::new(), html_rewriters: Vec::new(), script_rewriters: Vec::new(), - html_post_processors: Vec::new(), html_stream_processors: Vec::new(), head_injectors: Vec::new(), request_filters: Vec::new(), @@ -1517,18 +1460,6 @@ mod tests { } } - struct NoopHtmlPostProcessor; - - impl IntegrationHtmlPostProcessor for NoopHtmlPostProcessor { - fn integration_id(&self) -> &'static str { - "noop" - } - - fn post_process(&self, _html: &mut String, _ctx: &IntegrationHtmlContext<'_>) -> bool { - false - } - } - struct EchoProxy; #[async_trait(?Send)] @@ -1657,23 +1588,6 @@ mod tests { ); } - #[test] - fn default_html_post_processor_should_process_is_false() { - let processor = NoopHtmlPostProcessor; - let document_state = IntegrationDocumentState::default(); - let ctx = IntegrationHtmlContext { - request_host: "proxy.example.com", - request_scheme: "https", - origin_host: "origin.example.com", - document_state: &document_state, - }; - - assert!( - !processor.should_process("", &ctx), - "Default `should_process` should be false to avoid running post-processing unexpectedly" - ); - } - #[test] fn handle_proxy_passes_http_request_without_fastly_round_trip() { let settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 3d7e7e74d..9060c12a7 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -18268,12 +18268,11 @@ mod tests { ); } - /// Streaming dispatch contract: HTML with a registered post-processor still - /// routes through `Stream`, and the shared processor pipeline still applies - /// the post-processor rewrite. + /// Streaming dispatch contract: HTML with a registered stream processor + /// routes through `Stream`, and the shared processor pipeline applies it. #[test] - fn streaming_html_with_post_processors_rewrites_body() { - // Configure nextjs so a post-processor is registered. + fn streaming_html_with_stream_processors_rewrites_body() { + // Configure nextjs so a stream processor is registered. let mut settings = create_test_settings(); settings .integrations @@ -18290,8 +18289,8 @@ mod tests { IntegrationRegistry::new(&settings).expect("should create integration registry"); assert!( - registry.has_html_post_processors(), - "nextjs integration must register an HTML post-processor" + !registry.html_stream_processor_factories().is_empty(), + "nextjs integration must register an HTML stream processor" ); assert_eq!( classify_response_route( @@ -18301,7 +18300,7 @@ mod tests { "proxy.example.com", ), ResponseRoute::Stream, - "HTML with post-processors must route to Stream" + "HTML with stream processors must route to Stream" ); // Feed a small HTML body through the same pipeline the Stream arm uses. @@ -18348,13 +18347,12 @@ mod tests { ); } - /// Document-state survives from the streaming pass into the post-processor. + /// Document-state survives from the parser pass into the stream processor. /// `NextJsRscPlaceholderRewriter` writes into `IntegrationDocumentState` - /// during streaming; `NextJsHtmlPostProcessor` reads it and substitutes. - /// Regression test: with post-processors registered, placeholders must - /// be inserted during streaming and substituted out of the final output. + /// during parsing; the request-local stream processor reads it and substitutes. + /// Regression test: placeholders must be inserted and removed from final output. #[test] - fn document_state_placeholders_substitute_through_accumulating_path() { + fn document_state_placeholders_substitute_through_streaming_path() { let mut settings = create_test_settings(); settings .integrations From 93cfb3f06a20986cc4967617291b4c1b87077fe8 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 7 Sep 2026 21:31:46 +0530 Subject: [PATCH 12/17] Resolve auctions at parser-confirmed body seams --- .../trusted-server-core/src/html_processor.rs | 89 ++- crates/trusted-server-core/src/publisher.rs | 643 +++++++++++------- 2 files changed, 502 insertions(+), 230 deletions(-) diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 6a83b971b..817ffc68a 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -63,6 +63,9 @@ pub enum BodyCloseInjection { /// Read the auction result from `ad_bids_state` and inject it, falling back to /// an empty payload. Today's shipped behaviour. InlineBids, + /// Emit a request-specific marker at a structural body end. The publisher + /// streaming controller removes it after the auction completes. + DeferredInlineMarker(String), /// Emit this markup verbatim — an inert marker the assembly step splits on. /// Must be identical for every request that reaches the transform, or the /// cached template is not shared-safe. @@ -371,7 +374,10 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso // Verbatim, and identical on every request that // reaches the transform — that is what makes the // cached template shared-safe. - BodyCloseInjection::Marker(marker) => marker.clone(), + BodyCloseInjection::Marker(marker) + | BodyCloseInjection::DeferredInlineMarker(marker) => { + marker.clone() + } BodyCloseInjection::InlineBids => { let script_guard = state.lock().expect("should lock bid state"); match &*script_guard { @@ -1849,6 +1855,87 @@ mod tests { ); } + #[test] + fn deferred_inline_marker_uses_only_the_structural_body_end() { + const TOKEN: &str = ""; + let state = + std::sync::Arc::new(std::sync::Mutex::new(Some("must-not-be-read".to_string()))); + let mut config = marker_mode_config(TOKEN, None); + config.body_close = BodyCloseInjection::DeferredInlineMarker(TOKEN.to_string()); + config.ad_bids_state = state; + let mut processor = create_html_processor(config); + let output = processor + .process_chunk( + br#""#, + true, + ) + .expect("should process deferred marker document"); + let html = String::from_utf8(output).expect("output should be UTF-8"); + + assert_eq!( + html.matches(TOKEN).count(), + 1, + "should emit one marker: {html}" + ); + assert!( + html.contains(&format!("{TOKEN}")), + "marker should precede only the structural close: {html}" + ); + assert!(!html.contains("must-not-be-read")); + } + + #[test] + fn deferred_inline_marker_is_absent_without_an_explicit_body_end() { + const TOKEN: &str = ""; + let mut config = marker_mode_config(TOKEN, None); + config.body_close = BodyCloseInjection::DeferredInlineMarker(TOKEN.to_string()); + let mut processor = create_html_processor(config); + let output = processor + .process_chunk(b"", true) + .expect("should process bodyless document"); + let html = String::from_utf8(output).expect("output should be UTF-8"); + + assert!( + !html.contains(TOKEN), + "bodyless document must have no marker: {html}" + ); + } + + #[test] + fn deferred_inline_marker_uses_parser_context_across_every_source_split() { + const TOKEN: &str = ""; + for source in [ + "

x

", + "

later

", + "

later

", + ] { + for split in 0..=source.len() { + let mut config = marker_mode_config(TOKEN, None); + config.body_close = BodyCloseInjection::DeferredInlineMarker(TOKEN.to_string()); + let mut processor = create_html_processor(config); + let mut output = processor + .process_chunk(&source.as_bytes()[..split], false) + .expect("should process first source fragment"); + output.extend( + processor + .process_chunk(&source.as_bytes()[split..], true) + .expect("should process final source fragment"), + ); + let html = String::from_utf8(output).expect("output should be UTF-8"); + assert_eq!( + html.matches(TOKEN).count(), + 1, + "should mark one structural close for split {split}: {html}" + ); + assert!( + html.to_ascii_lowercase() + .contains(&format!("{TOKEN}")), + "marker should precede structural close for split {split}: {html}" + ); + } + } + } + #[test] fn a_nonce_bearing_meta_policy_is_observed() { let observed = Arc::new(AtomicBool::new(false)); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 9060c12a7..8f2ab28f5 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -635,6 +635,7 @@ struct ProcessResponseParams<'a> { struct PublisherBodyProcessor { inner: Box, + inline_seam_token: Option>, } impl PublisherBodyProcessor { @@ -646,6 +647,12 @@ impl PublisherBodyProcessor { let is_html = is_html_content_type(¶ms.content_type); let is_rsc_flight = content_type_contains_ascii_case_insensitive(¶ms.content_type, "text/x-component"); + let inline_seam_token = deferred_inline_seam_token( + settings, + params.template_cache_key.is_some(), + params.ad_slots_script.is_some(), + is_html && params.dispatched_auction.is_some(), + ); let inner: Box = if is_html { Box::new(create_html_stream_processor(HtmlStreamProcessorParams { origin_host: ¶ms.origin_host, @@ -659,6 +666,9 @@ impl PublisherBodyProcessor { gpt_diagnostics: params.gpt_diagnostics.clone(), shared_template_authorized: params.template_cache_key.is_some(), csp_nonce_observed: params.csp_nonce_observed.clone(), + deferred_inline_marker: inline_seam_token + .as_ref() + .map(|token| String::from_utf8_lossy(token).into_owned()), })?) } else if is_rsc_flight { Box::new(RscFlightUrlRewriter::new( @@ -676,7 +686,14 @@ impl PublisherBodyProcessor { )) }; - Ok(Self { inner }) + Ok(Self { + inner, + inline_seam_token, + }) + } + + fn take_inline_seam_token(&mut self) -> Option> { + self.inline_seam_token.take() } } @@ -740,6 +757,7 @@ fn process_response_streaming( gpt_diagnostics: params.gpt_diagnostics.cloned(), shared_template_authorized: params.shared_template_authorized, csp_nonce_observed: params.csp_nonce_observed.cloned(), + deferred_inline_marker: None, })?; StreamingPipeline::new(config, processor) .with_max_pending_decoded_bytes(max_pending_decoded_bytes) @@ -967,15 +985,19 @@ impl Drop for DispatchedAuctionGuard { /// Mutable auction-hold state threaded through the streaming hold pipeline. struct AuctionHoldState { - hold: Option, + hold: Option, dispatched: DispatchedAuctionGuard, telemetry: AuctionTelemetryCarry, } impl AuctionHoldState { - fn new(dispatched: DispatchedAuctionGuard, telemetry: AuctionTelemetryCarry) -> Self { + fn new( + dispatched: DispatchedAuctionGuard, + telemetry: AuctionTelemetryCarry, + seam_token: Option>, + ) -> Self { Self { - hold: Some(BodyCloseHoldBuffer::new()), + hold: seam_token.map(InlineBodyCloseSeam::new), dispatched, telemetry, } @@ -1004,7 +1026,7 @@ async fn abandon_hold_auction( } } -/// Output of a single close-body hold step, split at the auction-collection +/// Output of one parser-confirmed seam step, split at the auction-collection /// barrier. /// /// `ready` is the prefix the caller must emit *before* collecting the auction, @@ -1018,7 +1040,7 @@ struct HoldStepSegments { close_found: bool, } -/// Feed one decoded chunk through the close-body hold and processor. +/// Process one decoded chunk, then scan its output for the parser marker. /// /// Returns the ready prefix for the caller to emit — written to a client stream /// by [`body_close_hold_loop_stream`], yielded from the lazy body by @@ -1038,24 +1060,33 @@ async fn hold_step_decoded_chunk( collect_refs: &AuctionCollectDeps<'_>, ) -> Result> { let mut ready = Vec::new(); + let processed = + match processor + .process_chunk(chunk, false) + .change_context(TrustedServerError::Proxy { + message: "Failed to process chunk".to_string(), + }) { + Ok(processed) => processed, + Err(err) => { + abandon_hold_auction(state, collect_refs.services, "stream_process_error").await; + return Err(err); + } + }; let bytes: Cow<'_, [u8]> = match state.hold.as_mut() { - // Once the hold has been released the chunk streams straight through, - // borrowed rather than copied. - None => Cow::Borrowed(chunk), - Some(hold_buffer) => Cow::Owned(hold_buffer.push(chunk)), + None => Cow::Borrowed(&processed), + Some(seam) => Cow::Owned(seam.push(&processed)), }; - match process_and_encode_chunk(processor, encoder, &bytes, false, "Failed to process chunk") { - Ok(Some(encoded)) => ready.push(encoded), - Ok(None) => {} + match encoder.encode_chunk(bytes.into_owned()) { + Ok(encoded) if !encoded.is_empty() => ready.push(bytes::Bytes::from(encoded)), + Ok(_) => {} Err(err) => { - abandon_hold_auction(state, collect_refs.services, "stream_process_error").await; - return Err(err); + abandon_hold_auction(state, collect_refs.services, "stream_encode_error").await; + return Err(err.change_context(TrustedServerError::Proxy { + message: "Failed to encode processed chunk".to_string(), + })); } } - let close_found = state - .hold - .as_ref() - .is_some_and(BodyCloseHoldBuffer::found_close); + let close_found = state.hold.as_ref().is_some_and(InlineBodyCloseSeam::found); Ok(HoldStepSegments { ready, close_found }) } @@ -1068,7 +1099,7 @@ async fn hold_step_decoded_chunk( /// riding alongside transfer instead of blocking it. Collection runs before the /// tail is processed so `lol_html` sees live bids at the injection point. async fn hold_collect_close_tail( - processor: &mut P, + _processor: &mut P, encoder: &mut BodyStreamEncoder, state: &mut AuctionHoldState, collect_refs: &AuctionCollectDeps<'_>, @@ -1083,24 +1114,25 @@ async fn hold_collect_close_tail( // collect await above was still pending is reported. state.dispatched.disarm(); + let bids = inline_bids_script(collect_refs.ad_bids_state); + let encoded = encoder.encode_chunk(bids.into_bytes())?; + if !encoded.is_empty() { + segments.push(bytes::Bytes::from(encoded)); + } + let held = state .hold .take() - .expect("should have close-body hold buffer") + .expect("should have inline body seam") .finish(); - if let Some(encoded) = process_and_encode_chunk( - processor, - encoder, - &held, - false, - "Failed to process held body close", - )? { - segments.push(encoded); + let encoded = encoder.encode_chunk(held)?; + if !encoded.is_empty() { + segments.push(bytes::Bytes::from(encoded)); } Ok(segments) } -/// Pull and decode the next chunk of the close-body hold pipeline, feeding it +/// Pull, decode, process, and scan the next chunk of the inline seam pipeline. /// through [`hold_step_decoded_chunk`]. /// /// Returns `Ok(None)` when the source is exhausted; the caller must then emit @@ -1160,7 +1192,7 @@ async fn hold_finish_ready_segments( encoder: &mut BodyStreamEncoder, state: &mut AuctionHoldState, collect_refs: &AuctionCollectDeps<'_>, -) -> Result, Report> { +) -> Result> { let decoded_tail = match decoder.finish() { Ok(decoded_tail) => decoded_tail, Err(err) => { @@ -1168,42 +1200,72 @@ async fn hold_finish_ready_segments( return Err(err); } }; - if decoded_tail.is_empty() { - return Ok(Vec::new()); - } - let step = + let mut step = hold_step_decoded_chunk(processor, encoder, &decoded_tail, state, collect_refs).await?; - Ok(step.ready) + + let final_processed = + match processor + .process_chunk(&[], true) + .change_context(TrustedServerError::Proxy { + message: "Failed to finalize processor".to_string(), + }) { + Ok(processed) => processed, + Err(err) => { + abandon_hold_auction(state, collect_refs.services, "stream_process_error").await; + return Err(err); + } + }; + let final_ready = match state.hold.as_mut() { + Some(seam) => seam.push(&final_processed), + None => final_processed, + }; + let encoded = match encoder.encode_chunk(final_ready) { + Ok(encoded) => encoded, + Err(err) => { + abandon_hold_auction(state, collect_refs.services, "stream_encode_error").await; + return Err(err.change_context(TrustedServerError::Proxy { + message: "Failed to encode finalized HTML".to_string(), + })); + } + }; + if !encoded.is_empty() { + step.ready.push(bytes::Bytes::from(encoded)); + } + step.close_found = state.hold.as_ref().is_some_and(InlineBodyCloseSeam::found); + + if !step.close_found { + if let Some(seam) = state.hold.take() { + let encoded = encoder.encode_chunk(seam.finish())?; + if !encoded.is_empty() { + step.ready.push(bytes::Bytes::from(encoded)); + } + } + } + Ok(step) } -/// Finalize the close-body hold pipeline after [`hold_finish_ready_segments`]. +/// Finalize the inline seam pipeline after [`hold_finish_ready_segments`]. /// /// Collects the auction if the close-body tag never streamed, processes the held /// tail plus the processor's final chunk, and emits the encoder trailer. Returns /// the encoded segments for the caller to emit. async fn hold_finish_tail_segments( - processor: &mut P, + _processor: &mut P, encoder: &mut BodyStreamEncoder, state: &mut AuctionHoldState, collect_refs: &AuctionCollectDeps<'_>, ) -> Result, Report> { let mut segments = Vec::new(); - // If the hold is still armed the auction was never collected mid-stream: - // `` arrived only in the decoder tail, or the document had none at - // all. Collect now and flush the held remainder before finalizing. - if state.hold.is_some() { - segments.extend(hold_collect_close_tail(processor, encoder, state, collect_refs).await?); + if let Some(dispatched) = state.dispatched.take() { + collect_stream_auction(dispatched, state.telemetry.take(), collect_refs).await; + state.dispatched.disarm(); } - - if let Some(encoded) = process_and_encode_chunk( - processor, - encoder, - &[], - true, - "Failed to finalize processor", - )? { - segments.push(encoded); + if let Some(seam) = state.hold.take() { + let encoded = encoder.encode_chunk(seam.finish())?; + if !encoded.is_empty() { + segments.push(bytes::Bytes::from(encoded)); + } } let trailer = encoder.finish()?; if !trailer.is_empty() { @@ -1241,6 +1303,8 @@ struct HtmlStreamProcessorParams<'a> { shared_template_authorized: bool, /// Where the transform records a response-bound CSP nonce, when one matters. csp_nonce_observed: Option>, + /// Request-specific parser marker used by a pending inline auction. + deferred_inline_marker: Option, } /// The diagnostics decision the template may carry. @@ -1384,6 +1448,27 @@ pub(crate) fn body_close_injection( } } +fn deferred_inline_seam_token( + settings: &Settings, + shared_template_authorized: bool, + head_script_present: bool, + auction_pending: bool, +) -> Option> { + (auction_pending + && head_script_present + && matches!( + effective_assembly_mode(settings, shared_template_authorized), + AssemblyMode::Inline + )) + .then(|| { + format!( + "", + uuid::Uuid::new_v4().simple() + ) + .into_bytes() + }) +} + fn create_html_stream_processor( params: HtmlStreamProcessorParams<'_>, ) -> Result, Report> { @@ -1398,7 +1483,12 @@ fn create_html_stream_processor( ); let assembly_mode = effective_assembly_mode(params.settings, params.shared_template_authorized); - let body_close = body_close_injection(assembly_mode, params.ad_slots_script.is_some()); + let body_close = match (assembly_mode, params.deferred_inline_marker) { + (AssemblyMode::Inline, Some(marker)) if params.ad_slots_script.is_some() => { + BodyCloseInjection::DeferredInlineMarker(marker) + } + _ => body_close_injection(assembly_mode, params.ad_slots_script.is_some()), + }; let gpt_diagnostics = template_gpt_diagnostics(assembly_mode, params.gpt_diagnostics); @@ -2468,6 +2558,7 @@ pub async fn publisher_response_into_streaming_response( return Err(err); } }; + let inline_seam_token = processor.take_inline_seam_token(); // The guard is created before the lazy stream so an auction whose // response body is dropped unpolled still logs the loss. let dispatched_auction = params.dispatched_auction.take().map(|dispatched| { @@ -2485,7 +2576,7 @@ pub async fn publisher_response_into_streaming_response( let mut source = BodyChunkSource::new(body, STREAM_CHUNK_SIZE) .with_max_bytes(max_body_bytes); - // HTML rides the close-body hold so bids land before ``; + // HTML rides the parser-confirmed seam so bids land before ``; // non-HTML has no injection point, so its auction is collected // before any byte streams (matching the buffered finalizer). let mut hold_auction = None; @@ -2510,7 +2601,11 @@ pub async fn publisher_response_into_streaming_response( } if let Some((guard, telemetry)) = hold_auction { - let mut state = AuctionHoldState::new(guard, telemetry); + let mut state = AuctionHoldState::new( + guard, + telemetry, + inline_seam_token, + ); let collect_refs = AuctionCollectDeps { price_granularity: params.price_granularity, ad_bids_state: ¶ms.ad_bids_state, @@ -2559,7 +2654,7 @@ pub async fn publisher_response_into_streaming_response( // before collection, for the same reason as the mid-stream // prefix above: a small compressed page can surface its // whole document here. - for encoded in hold_finish_ready_segments( + let final_step = hold_finish_ready_segments( &mut processor, &mut decoder, &mut encoder, @@ -2567,10 +2662,23 @@ pub async fn publisher_response_into_streaming_response( &collect_refs, ) .await - .map_err(publisher_stream_error)? - { + .map_err(publisher_stream_error)?; + for encoded in final_step.ready { yield encoded; } + if final_step.close_found { + for encoded in hold_collect_close_tail( + &mut processor, + &mut encoder, + &mut state, + &collect_refs, + ) + .await + .map_err(publisher_stream_error)? + { + yield encoded; + } + } for encoded in hold_finish_tail_segments( &mut processor, &mut encoder, @@ -2794,17 +2902,16 @@ pub fn stream_publisher_body( process_response_streaming(body, output, &borrowed, output_compression) } -/// Stream publisher body with a `` handler with bids now in state. +/// 3. The generated marker is replaced with the collected bid script. /// /// For non-HTML content types the auction is collected before any body bytes /// are written (no `` to inject). If `params.dispatched_auction` is @@ -2871,9 +2978,13 @@ pub async fn stream_publisher_body_async( return stream_publisher_body(body, output, params, settings, integration_registry); } - // HTML: build the processor once and drive it chunk by chunk. - // One-behind buffer: stream chunk N-1 immediately; hold chunk N until origin - // EOF, then await auction and process chunk N (which contains ). + // HTML: let lol_html mark the structural body end for the auction seam. + let inline_seam_token = deferred_inline_seam_token( + settings, + params.template_cache_key.is_some(), + params.ad_slots_script.is_some(), + true, + ); let mut processor = match create_html_stream_processor(HtmlStreamProcessorParams { origin_host: ¶ms.origin_host, request_host: ¶ms.request_host, @@ -2886,6 +2997,9 @@ pub async fn stream_publisher_body_async( gpt_diagnostics: params.gpt_diagnostics.clone(), shared_template_authorized: params.template_cache_key.is_some(), csp_nonce_observed: params.csp_nonce_observed.clone(), + deferred_inline_marker: inline_seam_token + .as_ref() + .map(|token| String::from_utf8_lossy(token).into_owned()), }) { Ok(processor) => processor, Err(err) => { @@ -2910,6 +3024,7 @@ pub async fn stream_publisher_body_async( body, output, &mut processor, + inline_seam_token, input_compression, output_compression, AuctionCollectCtx { @@ -3540,12 +3655,13 @@ struct AuctionCollectDeps<'a> { request_origin: String, } -/// Run the close-body hold loop for HTML bodies, collecting the auction before -/// the raw `( body: EdgeBody, output: &mut W, processor: &mut P, + inline_seam_token: Option>, input_compression: Compression, output_compression: Compression, ctx: AuctionCollectCtx<'_>, @@ -3560,6 +3676,7 @@ async fn stream_html_with_auction_hold( output_compression, ctx, max_body_bytes, + inline_seam_token, ) .await; } @@ -3570,25 +3687,29 @@ async fn stream_html_with_auction_hold( let body = body_as_reader(body)?; if output_compression == Compression::None { return match input_compression { - Compression::None => body_close_hold_loop(body, output, processor, ctx).await, + Compression::None => { + body_close_hold_loop(body, output, processor, ctx, inline_seam_token).await + } Compression::Gzip => { let decoder = GzipDecodeReader::new(body, max_body_bytes); - body_close_hold_loop(decoder, output, processor, ctx).await + body_close_hold_loop(decoder, output, processor, ctx, inline_seam_token).await } Compression::Deflate => { let decoder = ZlibDecoder::new(body); - body_close_hold_loop(decoder, output, processor, ctx).await + body_close_hold_loop(decoder, output, processor, ctx, inline_seam_token).await } Compression::Brotli => { let decoder = Decompressor::new(body, STREAM_CHUNK_SIZE); - body_close_hold_loop(decoder, output, processor, ctx).await + body_close_hold_loop(decoder, output, processor, ctx, inline_seam_token).await } }; } debug_assert_eq!(input_compression, output_compression); match input_compression { - Compression::None => body_close_hold_loop(body, output, processor, ctx).await, + Compression::None => { + body_close_hold_loop(body, output, processor, ctx, inline_seam_token).await + } Compression::Gzip => { // `GzipDecodeReader` decodes concatenated gzip members (RFC 1952) // and bounds decoded output, unlike `flate2::read::GzDecoder`, which @@ -3596,7 +3717,7 @@ async fn stream_html_with_auction_hold( // markup (potentially including ``) on buffered adapters. let decoder = GzipDecodeReader::new(body, max_body_bytes); let mut encoder = GzEncoder::new(&mut *output, flate2::Compression::default()); - body_close_hold_loop(decoder, &mut encoder, processor, ctx).await?; + body_close_hold_loop(decoder, &mut encoder, processor, ctx, inline_seam_token).await?; encoder.finish().change_context(TrustedServerError::Proxy { message: "Failed to finalize gzip encoder".to_string(), })?; @@ -3605,7 +3726,7 @@ async fn stream_html_with_auction_hold( Compression::Deflate => { let decoder = ZlibDecoder::new(body); let mut encoder = ZlibEncoder::new(&mut *output, flate2::Compression::default()); - body_close_hold_loop(decoder, &mut encoder, processor, ctx).await?; + body_close_hold_loop(decoder, &mut encoder, processor, ctx, inline_seam_token).await?; encoder.finish().change_context(TrustedServerError::Proxy { message: "Failed to finalize deflate encoder".to_string(), })?; @@ -3620,7 +3741,7 @@ async fn stream_html_with_auction_hold( }; let mut encoder = CompressorWriter::with_params(&mut *output, STREAM_CHUNK_SIZE, ¶ms); - body_close_hold_loop(decoder, &mut encoder, processor, ctx).await?; + body_close_hold_loop(decoder, &mut encoder, processor, ctx, inline_seam_token).await?; let _ = encoder.into_inner(); Ok(()) } @@ -3646,6 +3767,7 @@ async fn body_close_hold_loop_stream( output_compression: Compression, ctx: AuctionCollectCtx<'_>, max_body_bytes: usize, + inline_seam_token: Option>, ) -> Result<(), Report> { let AuctionCollectCtx { dispatched, @@ -3655,7 +3777,11 @@ async fn body_close_hold_loop_stream( let mut decoder = BodyStreamDecoder::new(input_compression, max_body_bytes); let mut encoder = BodyStreamEncoder::new(output_compression); let mut source = BodyChunkSource::new(body, STREAM_CHUNK_SIZE).with_max_bytes(max_body_bytes); - let mut state = AuctionHoldState::new(DispatchedAuctionGuard::new(dispatched), telemetry); + let mut state = AuctionHoldState::new( + DispatchedAuctionGuard::new(dispatched), + telemetry, + inline_seam_token, + ); while let Some(step) = hold_step_next_chunk( &mut source, @@ -3673,6 +3799,9 @@ async fn body_close_hold_loop_stream( write_encoded_segment(writer, &encoded)?; } if step.close_found { + writer.flush().change_context(TrustedServerError::Proxy { + message: "Failed to flush output before auction collection".to_string(), + })?; for encoded in hold_collect_close_tail(processor, &mut encoder, &mut state, &collect_refs).await? { @@ -3683,17 +3812,27 @@ async fn body_close_hold_loop_stream( // Write the decoder-finalized prefix before collection, matching the lazy // Fastly stream: only the held `` tail waits on the auction. - for encoded in hold_finish_ready_segments( + let final_step = hold_finish_ready_segments( processor, &mut decoder, &mut encoder, &mut state, &collect_refs, ) - .await? - { + .await?; + for encoded in final_step.ready { write_encoded_segment(writer, &encoded)?; } + writer.flush().change_context(TrustedServerError::Proxy { + message: "Failed to flush output before auction collection".to_string(), + })?; + if final_step.close_found { + for encoded in + hold_collect_close_tail(processor, &mut encoder, &mut state, &collect_refs).await? + { + write_encoded_segment(writer, &encoded)?; + } + } for encoded in hold_finish_tail_segments(processor, &mut encoder, &mut state, &collect_refs).await? { @@ -3705,34 +3844,37 @@ async fn body_close_hold_loop_stream( Ok(()) } -const BODY_CLOSE_PREFIX: &[u8] = b", buffered: Vec, - found_close: bool, + found: bool, } -impl BodyCloseHoldBuffer { - fn new() -> Self { +impl InlineBodyCloseSeam { + fn new(token: Vec) -> Self { + debug_assert!(!token.is_empty()); Self { + token, buffered: Vec::new(), - found_close: false, + found: false, } } fn push(&mut self, chunk: &[u8]) -> Vec { self.buffered.extend_from_slice(chunk); - if self.found_close { + if self.found { return Vec::new(); } - if let Some(pos) = find_ascii_case_insensitive(&self.buffered, BODY_CLOSE_PREFIX) { - self.found_close = true; - return self.buffered.drain(..pos).collect(); + if let Some(pos) = find_bytes(&self.buffered, &self.token) { + self.found = true; + let ready = self.buffered.drain(..pos).collect(); + self.buffered.drain(..self.token.len()); + return ready; } - let keep_len = BODY_CLOSE_PREFIX.len().saturating_sub(1); + let keep_len = longest_suffix_prefix(&self.buffered, &self.token); if self.buffered.len() <= keep_len { return Vec::new(); } @@ -3741,8 +3883,8 @@ impl BodyCloseHoldBuffer { self.buffered.drain(..split_at).collect() } - fn found_close(&self) -> bool { - self.found_close + fn found(&self) -> bool { + self.found } fn finish(self) -> Vec { @@ -3750,26 +3892,40 @@ impl BodyCloseHoldBuffer { } } -fn find_ascii_case_insensitive(haystack: &[u8], needle: &[u8]) -> Option { - haystack.windows(needle.len()).position(|window| { - window - .iter() - .zip(needle) - .all(|(left, right)| left.eq_ignore_ascii_case(right)) - }) +fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + (!needle.is_empty()) + .then(|| { + haystack + .windows(needle.len()) + .position(|window| window == needle) + }) + .flatten() } -/// Core close-body hold loop. -/// -/// Streams processed output until the first case-insensitive ` usize { + let maximum = bytes.len().min(pattern.len().saturating_sub(1)); + (1..=maximum) + .rev() + .find(|length| bytes.ends_with(&pattern[..*length])) + .unwrap_or(0) +} + +fn inline_bids_script(ad_bids_state: &AdBidsState) -> String { + ad_bids_state + .script_cell() + .lock() + .expect("should lock bid state") + .clone() + .unwrap_or_else(build_empty_bids_script) +} + +/// Core parser-confirmed inline-seam loop for reader-backed bodies. async fn body_close_hold_loop( mut reader: R, writer: &mut W, processor: &mut P, ctx: AuctionCollectCtx<'_>, + inline_seam_token: Option>, ) -> Result<(), Report> { let AuctionCollectCtx { dispatched, @@ -3777,94 +3933,110 @@ async fn body_close_hold_loop( deps, } = ctx; let mut buffer = vec![0u8; STREAM_CHUNK_SIZE]; - let mut hold = Some(BodyCloseHoldBuffer::new()); + let mut hold = inline_seam_token.map(InlineBodyCloseSeam::new); let mut dispatched = Some(dispatched); loop { match reader.read(&mut buffer) { Ok(0) => { - if let Some(hold) = hold.take() { - let dispatched = dispatched - .take() - .expect("should have dispatched auction to collect"); - collect_stream_auction(dispatched, telemetry.take(), &deps).await; - - let held = hold.finish(); - write_processed_chunk( - writer, - processor, - &held, - false, - "Failed to process held body close", - "Failed to write held body close", - )?; - } - // Signal EOF to lol_html (fires end() which flushes remaining state). let final_out = processor.process_chunk(&[], true).change_context( TrustedServerError::Proxy { message: "Failed to finalize processor".to_string(), }, )?; - if !final_out.is_empty() { + let ready = match hold.as_mut() { + Some(seam) => seam.push(&final_out), + None => final_out, + }; + writer + .write_all(&ready) + .change_context(TrustedServerError::Proxy { + message: "Failed to write finalized output".to_string(), + })?; + + if hold.as_ref().is_some_and(InlineBodyCloseSeam::found) { + writer.flush().change_context(TrustedServerError::Proxy { + message: "Failed to flush output before auction collection".to_string(), + })?; + let dispatched = dispatched + .take() + .expect("should have dispatched auction to collect"); + collect_stream_auction(dispatched, telemetry.take(), &deps).await; writer - .write_all(&final_out) + .write_all(inline_bids_script(deps.ad_bids_state).as_bytes()) .change_context(TrustedServerError::Proxy { - message: "Failed to write finalized output".to_string(), + message: "Failed to write inline bids".to_string(), })?; + writer + .write_all(&hold.take().expect("should have inline body seam").finish()) + .change_context(TrustedServerError::Proxy { + message: "Failed to write held body tail".to_string(), + })?; + } else { + if let Some(seam) = hold.take() { + writer.write_all(&seam.finish()).change_context( + TrustedServerError::Proxy { + message: "Failed to write terminal HTML output".to_string(), + }, + )?; + } + writer.flush().change_context(TrustedServerError::Proxy { + message: "Failed to flush output before auction collection".to_string(), + })?; + if let Some(pending) = dispatched.take() { + collect_stream_auction(pending, telemetry.take(), &deps).await; + } } break; } Ok(n) => { - if let Some(hold_buffer) = hold.as_mut() { - let ready = hold_buffer.push(&buffer[..n]); - if let Err(err) = write_processed_chunk( - writer, - processor, - &ready, - false, - "Failed to process chunk", - "Failed to write chunk", - ) { - if let Some(dispatched) = dispatched.take() { + let processed = match processor.process_chunk(&buffer[..n], false).change_context( + TrustedServerError::Proxy { + message: "Failed to process chunk".to_string(), + }, + ) { + Ok(processed) => processed, + Err(err) => { + if let Some(pending) = dispatched.take() { emit_abandoned_auction( deps.services, telemetry.observation.take(), - dispatched, + pending, "stream_process_error", ) .await; } return Err(err); } - - if hold_buffer.found_close() { - let dispatched = dispatched - .take() - .expect("should have dispatched auction to collect"); - collect_stream_auction(dispatched, telemetry.take(), &deps).await; - - let held = hold - .take() - .expect("should have close-body hold buffer") - .finish(); - write_processed_chunk( - writer, - processor, - &held, - false, - "Failed to process held body close", - "Failed to write held body close", - )?; - } - } else { - write_processed_chunk( - writer, - processor, - &buffer[..n], - false, - "Failed to process chunk", - "Failed to write chunk", - )?; + }; + let ready = match hold.as_mut() { + Some(seam) => seam.push(&processed), + None => processed, + }; + writer + .write_all(&ready) + .change_context(TrustedServerError::Proxy { + message: "Failed to write processed chunk".to_string(), + })?; + + if hold.as_ref().is_some_and(InlineBodyCloseSeam::found) { + writer.flush().change_context(TrustedServerError::Proxy { + message: "Failed to flush output before auction collection".to_string(), + })?; + let pending = dispatched + .take() + .expect("should have dispatched auction to collect"); + collect_stream_auction(pending, telemetry.take(), &deps).await; + writer + .write_all(inline_bids_script(deps.ad_bids_state).as_bytes()) + .change_context(TrustedServerError::Proxy { + message: "Failed to write inline bids".to_string(), + })?; + writer + .write_all(&hold.take().expect("should have inline body seam").finish()) + .change_context(TrustedServerError::Proxy { + message: "Failed to write held body tail".to_string(), + })?; } } Err(e) => { @@ -4029,35 +4201,6 @@ async fn collect_stream_auction( } } -fn write_processed_chunk( - writer: &mut W, - processor: &mut P, - chunk: &[u8], - is_last: bool, - process_error: &str, - write_error: &str, -) -> Result<(), Report> { - if chunk.is_empty() && !is_last { - return Ok(()); - } - - let out = - processor - .process_chunk(chunk, is_last) - .change_context(TrustedServerError::Proxy { - message: process_error.to_string(), - })?; - if !out.is_empty() { - writer - .write_all(&out) - .change_context(TrustedServerError::Proxy { - message: write_error.to_string(), - })?; - } - - Ok(()) -} - /// Auction dispatch context passed to [`handle_publisher_request`]. pub struct AuctionDispatch<'a> { /// Orchestrator that dispatches and collects SSP bid requests. @@ -8084,7 +8227,7 @@ mod tests { impl StreamProcessor for RecordingProcessor { fn process_chunk(&mut self, chunk: &[u8], _is_last: bool) -> Result, io::Error> { - if find_ascii_case_insensitive(chunk, BODY_CLOSE_PREFIX).is_some() { + if find_bytes(chunk, b""; let reader = ChunkedReader::new( &[ - b"painted", + b"painted", b"", b"", ], @@ -15542,19 +15686,35 @@ mod tests { }; let mut output = Vec::new(); - body_close_hold_loop(reader, &mut output, &mut processor, ctx) - .await - .expect("should stream body with auction hold"); + body_close_hold_loop( + reader, + &mut output, + &mut processor, + ctx, + Some(token.to_vec()), + ) + .await + .expect("should stream body with auction hold"); assert_eq!( body_close_processed_at.load(Ordering::SeqCst), 1, "close-body tail should be processed as soon as it is found, before later chunks are read" ); - assert_eq!( - std::str::from_utf8(&output).expect("should be utf8"), - "painted", - "post-body chunks should still stream in order" + let output = std::str::from_utf8(&output).expect("should be utf8"); + let painted = output + .find("painted") + .expect("should preserve body content"); + let bids = output + .find("var b=JSON.parse(") + .expect("should inject collected bids"); + let close = output.find("").expect("should preserve body close"); + let late = output + .find("late()") + .expect("should preserve trailing script"); + assert!( + painted < bids && bids < close && close < late, + "output order: {output}" ); } @@ -15570,6 +15730,7 @@ mod tests { let services = noop_services(); let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); let ad_bids_state = AdBidsState::default(); + let token = b""; let mut state = AuctionHoldState::new( DispatchedAuctionGuard::new(DispatchedAuction::empty_for_test( test_auction_request(), @@ -15579,6 +15740,7 @@ mod tests { observation: None, auction_request: None, }, + Some(token.to_vec()), ); let collect_refs = AuctionCollectDeps { price_granularity: PriceGranularity::default(), @@ -15599,7 +15761,7 @@ mod tests { let step = hold_step_decoded_chunk( &mut processor, &mut encoder, - b"painted", + b"painted", &mut state, &collect_refs, ) @@ -15608,7 +15770,7 @@ mod tests { assert!( step.close_found, - " in the first chunk must be detected" + "parser marker in the first chunk must be detected" ); let ready: Vec = step.ready.iter().flat_map(|b| b.to_vec()).collect(); assert_eq!( @@ -15629,10 +15791,10 @@ mod tests { .await .expect("collect should succeed"); let tail_bytes: Vec = tail.iter().flat_map(|b| b.to_vec()).collect(); - assert_eq!( - std::str::from_utf8(&tail_bytes).expect("held tail should be utf8"), - "", - "the held close tail must be emitted after collection" + let tail = std::str::from_utf8(&tail_bytes).expect("held tail should be utf8"); + assert!( + tail.contains("var b=JSON.parse(") && tail.ends_with(""), + "collected bids and the held close tail must be emitted together: {tail}" ); assert!( ad_bids_state @@ -15645,10 +15807,11 @@ mod tests { } #[test] - fn body_close_hold_buffer_holds_close_body_tail_in_single_chunk() { - let mut hold = BodyCloseHoldBuffer::new(); + fn inline_body_close_seam_holds_tail_in_single_chunk() { + let token = b""; + let mut hold = InlineBodyCloseSeam::new(token.to_vec()); - let ready = hold.push(b"painted"); + let ready = hold.push(b"painted"); let held = hold.finish(); assert_eq!( @@ -15664,11 +15827,12 @@ mod tests { } #[test] - fn body_close_hold_buffer_holds_close_body_tail_across_chunks() { - let mut hold = BodyCloseHoldBuffer::new(); + fn inline_body_close_seam_holds_tail_across_chunks() { + let token = b""; + let mut hold = InlineBodyCloseSeam::new(token.to_vec()); - let first = hold.push(b"painted"); + let first = hold.push(b"painted"); let held = hold.finish(); let streamed = [first, second].concat(); @@ -15684,6 +15848,29 @@ mod tests { ); } + #[test] + fn inline_body_close_seam_matches_every_token_split_and_ignores_other_tokens() { + let token = b""; + for split in 0..=token.len() { + let mut seam = InlineBodyCloseSeam::new(token.to_vec()); + let mut ready = seam.push(b""); + ready.extend(seam.push(&token[..split])); + ready.extend(seam.push(&token[split..])); + assert!(seam.found(), "should match token split at {split}"); + assert_eq!( + ready, b"", + "should release all bytes before split {split}" + ); + assert!(seam.finish().is_empty()); + } + + let other = b""; + let mut seam = InlineBodyCloseSeam::new(token.to_vec()); + let ready = seam.push(other); + assert!(!seam.found()); + assert_eq!([ready, seam.finish()].concat(), other); + } + #[test] fn unsupported_encoding_response_is_returned_unmodified() { assert_eq!( @@ -17593,11 +17780,9 @@ mod tests { #[test] fn streaming_finalize_auction_hold_emits_prefix_before_origin_eof() { - // The auction-hold path must stream the document prefix (up to the held - // `` tail) before the origin finishes and before the auction is - // collected — otherwise the hold reintroduces the FCP regression. The - // origin sends the head/body prefix (no ``) then stays Pending. - let page = b"

hello

more streamed content here

"; + // A body-close literal in script data must not stop streaming. Only the + // request token emitted by lol_html at the structural end is a seam. + let page = b"
still streaming
"; let params = html_stream_params( "", Some(DispatchedAuction::empty_for_test( @@ -17613,8 +17798,8 @@ mod tests { let first = first_lazy_body_chunk(body); let html = String::from_utf8(first.to_vec()).expect("should be valid UTF-8"); assert!( - html.contains("hello"), - "auction-hold path must stream the prefix before EOF. Got: {html}" + html.contains("const x = ''") && html.contains("still streaming"), + "script data and later article bytes must stream before EOF. Got: {html}" ); assert!( html.contains(".adSlots=JSON.parse"), From 6d20f24961be2f239d6712aefca8fbd39df6bfb1 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 7 Sep 2026 21:36:42 +0530 Subject: [PATCH 13/17] Document bounded Next.js streaming --- .../trusted-server-core/src/html_processor.rs | 4 ++ .../src/integrations/nextjs/rsc_stream.rs | 8 ++-- crates/trusted-server-core/src/publisher.rs | 40 ++++++++++++++----- docs/guide/integrations/nextjs.md | 27 ++++++------- 4 files changed, 51 insertions(+), 28 deletions(-) diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 817ffc68a..26d5e19c1 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -189,6 +189,10 @@ impl HtmlProcessorConfig { /// Panics if the `ad_bids_state` `Mutex` is poisoned. This cannot happen in /// normal operation since no code holds the lock across a panic boundary. #[must_use] +#[allow( + clippy::needless_pass_by_value, + reason = "the returned processor owns request configuration captured by its handlers" +)] pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcessor { let stream_processor_factories = config.integrations.html_stream_processor_factories(); let document_state = IntegrationDocumentState::default(); diff --git a/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs b/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs index 3e3b2e66b..63ec5048c 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs @@ -251,7 +251,7 @@ impl NextJsRscStreamProcessor { } } - fn release_group(&mut self, rewritten: Option>) -> io::Result> { + fn release_group(&mut self, rewritten: Option<&[String]>) -> io::Result> { let replacements: Vec<&str> = match &rewritten { Some(rewritten) => rewritten.iter().map(String::as_str).collect(), None => self @@ -292,7 +292,7 @@ impl NextJsRscStreamProcessor { "Next.js RSC rewrite returned a mismatched payload count", )); } - self.release_group(Some(rewritten)).map(Some) + self.release_group(Some(&rewritten)).map(Some) } RscGroupStatus::CompleteUnrewritable => self.release_group(None).map(Some), RscGroupStatus::Invalid => { @@ -820,11 +820,11 @@ mod tests { let split = placeholder.len() / 2; let first = processor - .process_chunk(placeholder[..split].as_bytes(), false) + .process_chunk(&placeholder.as_bytes()[..split], false) .expect("should retain a partial placeholder"); assert!(first.is_empty(), "should retain only the candidate suffix"); let second = processor - .process_chunk(placeholder[split..].as_bytes(), false) + .process_chunk(&placeholder.as_bytes()[split..], false) .expect("should finish the placeholder"); assert_eq!(second, b"plain", "should restore the captured payload"); } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 8f2ab28f5..f90684d00 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1233,12 +1233,12 @@ async fn hold_finish_ready_segments( } step.close_found = state.hold.as_ref().is_some_and(InlineBodyCloseSeam::found); - if !step.close_found { - if let Some(seam) = state.hold.take() { - let encoded = encoder.encode_chunk(seam.finish())?; - if !encoded.is_empty() { - step.ready.push(bytes::Bytes::from(encoded)); - } + if !step.close_found + && let Some(seam) = state.hold.take() + { + let encoded = encoder.encode_chunk(seam.finish())?; + if !encoded.is_empty() { + step.ready.push(bytes::Bytes::from(encoded)); } } Ok(step) @@ -3759,6 +3759,10 @@ async fn stream_html_with_auction_hold( /// Cloudflare, Spin) never produce `Body::Stream` because the publisher fetch /// is gated on `supports_streaming_responses()`. It is groundwork for those /// adapters' streaming cutover; Fastly uses the lazy stream instead. +#[allow( + clippy::too_many_arguments, + reason = "stream state remains explicit across the shared adapter driver" +)] async fn body_close_hold_loop_stream( body: EdgeBody, writer: &mut W, @@ -17782,7 +17786,18 @@ mod tests { fn streaming_finalize_auction_hold_emits_prefix_before_origin_eof() { // A body-close literal in script data must not stop streaming. Only the // request token emitted by lol_html at the structural end is a seam. - let page = b"
still streaming
"; + let page = br#"
still streaming
"#; + let mut settings = create_test_settings(); + settings + .integrations + .insert_config( + "nextjs", + &serde_json::json!({ + "enabled": true, + "rewrite_attributes": ["href", "link", "url"], + }), + ) + .expect("should enable Next.js"); let params = html_stream_params( "", Some(DispatchedAuction::empty_for_test( @@ -17790,16 +17805,21 @@ mod tests { 10, )), ); - let body = streaming_finalize_response( + let body = streaming_finalize_response_with_settings( params, origin_chunk_then_pending(bytes::Bytes::from(&page[..])), + settings, ); let first = first_lazy_body_chunk(body); let html = String::from_utf8(first.to_vec()).expect("should be valid UTF-8"); assert!( - html.contains("const x = ''") && html.contains("still streaming"), - "script data and later article bytes must stream before EOF. Got: {html}" + html.contains("") && html.contains("still streaming"), + "RSC script data and later article bytes must stream before EOF. Got: {html}" + ); + assert!( + html.contains("proxy.example.com/app") && !html.contains("origin.example.com/app"), + "Next.js rewriting must complete before the parser seam: {html}" ); assert!( html.contains(".adSlots=JSON.parse"), diff --git a/docs/guide/integrations/nextjs.md b/docs/guide/integrations/nextjs.md index 24f7bbac4..fecae5947 100644 --- a/docs/guide/integrations/nextjs.md +++ b/docs/guide/integrations/nextjs.md @@ -29,14 +29,16 @@ Next.js applications generate framework-specific JSON data (`__NEXT_DATA__`) and [integrations.nextjs] enabled = false rewrite_attributes = ["href", "link", "url"] +max_combined_payload_bytes = 10485760 ``` ### Configuration Options -| Field | Type | Default | Description | -| -------------------- | ------- | ------------------------- | ------------------------------------- | -| `enabled` | boolean | `false` | Enable Next.js integration | -| `rewrite_attributes` | array | `["href", "link", "url"]` | Attributes to rewrite in Next.js data | +| Field | Type | Default | Description | +| ---------------------------- | ------- | ------------------------- | ------------------------------------------------ | +| `enabled` | boolean | `false` | Enable Next.js integration | +| `rewrite_attributes` | array | `["href", "link", "url"]` | Attributes to rewrite in Next.js data | +| `max_combined_payload_bytes` | integer | `10485760` | Maximum bytes retained for one unresolved group | ## How It Works @@ -109,9 +111,12 @@ Targets the Next.js data script for rewriting. **RSC Stream Processing**: -- Parses React Server Component streaming format -- Rewrites URLs in streaming chunks -- Preserves component structure +- Emits ordinary HTML as soon as the HTML parser produces it +- Retains only an unresolved cross-script `T` chunk group +- Rewrites URLs and recalculates `T` chunk byte lengths before releasing a complete group +- Restores invalid, incomplete, or over-limit groups unchanged +- Applies `max_combined_payload_bytes` independently to captured payloads and held output +- Preserves script order and React hydration data ## Use Cases @@ -163,7 +168,7 @@ Verify React Server Components hydrate correctly: ### 4. Monitor Performance -Next.js integration adds minimal overhead (<10ms), but monitor: +Monitor: - Time to First Byte (TTFB) - First Contentful Paint (FCP) @@ -203,12 +208,6 @@ Next.js integration adds minimal overhead (<10ms), but monitor: ## Performance -### Overhead - -- RSC parsing: ~5-10ms -- URL rewriting: ~2-5ms -- Total: <15ms per request - ### Optimization - Enable HTTP/2 for streaming From 5bfce980fa416142d41786f22aea1873c8a56f16 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Mon, 7 Sep 2026 21:58:26 +0530 Subject: [PATCH 14/17] Resolve streaming fallback review findings --- .../trusted-server-core/src/html_processor.rs | 5 +- .../src/integrations/nextjs/rsc.rs | 20 ++ .../integrations/nextjs/rsc_placeholders.rs | 16 ++ .../src/integrations/nextjs/rsc_stream.rs | 165 ++++++++++++-- crates/trusted-server-core/src/publisher.rs | 202 ++++++++++++++++-- docs/guide/integrations/nextjs.md | 10 +- 6 files changed, 370 insertions(+), 48 deletions(-) diff --git a/crates/trusted-server-core/src/html_processor.rs b/crates/trusted-server-core/src/html_processor.rs index 26d5e19c1..2f4a62375 100644 --- a/crates/trusted-server-core/src/html_processor.rs +++ b/crates/trusted-server-core/src/html_processor.rs @@ -398,7 +398,10 @@ pub fn create_html_processor(config: HtmlProcessorConfig) -> impl StreamProcesso Ok(()) }); handlers.push(handler); - } else if matches!(body_close, BodyCloseInjection::InlineBids) { + } else if matches!( + body_close, + BodyCloseInjection::InlineBids | BodyCloseInjection::DeferredInlineMarker(_) + ) { // No end tag (implicitly closed or EOF ``): lol_html // cannot attach an end-tag handler, so tsjs.bids/adInit() are // never injected even though adSlots was injected at ``. diff --git a/crates/trusted-server-core/src/integrations/nextjs/rsc.rs b/crates/trusted-server-core/src/integrations/nextjs/rsc.rs index 9fbbb1a3f..9dbe9c5b0 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/rsc.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/rsc.rs @@ -241,12 +241,18 @@ fn scan_tchunks_impl(content: &str, skip_markers: bool) -> TChunkScan { None => break, } } + if consumed > declared_length { + return TChunkScan::Invalid; + } if consumed < declared_length { return TChunkScan::NeedMore; } iter.position() } else { let (pos, consumed) = consume_unescaped_bytes(content, header_end, declared_length); + if consumed > declared_length { + return TChunkScan::Invalid; + } if consumed < declared_length { return TChunkScan::NeedMore; } @@ -591,6 +597,20 @@ mod tests { assert_eq!(calculate_unescaped_byte_length(r"\u00e9"), 2); } + #[test] + fn rejects_tchunk_lengths_that_split_a_decoded_character() { + for content in ["1:T1,€", r"1:T1,\ud83d\ude00"] { + assert!( + matches!(scan_tchunks(content), TChunkScan::Invalid), + "plain scanner should reject a split decoded character: {content}" + ); + assert!( + matches!(scan_tchunks_impl(content, true), TChunkScan::Invalid), + "marker-aware scanner should reject a split decoded character: {content}" + ); + } + } + #[test] fn multiple_tchunks() { let content = r#"1a:T1c,{"url":"https://short.io/x"}\n1b:T1c,{"url":"https://short.io/y"}"#; diff --git a/crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs b/crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs index 829e0f2af..38b9bb1b0 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs @@ -161,6 +161,22 @@ impl IntegrationScriptRewriter for NextJsRscPlaceholderRewriter { ); } + if state.rsc_probe.is_empty() && !content.contains("__next_f") { + if ctx.is_last_in_text_node { + return ScriptRewriteAction::Keep; + } + let probe_length = longest_identifier_prefix(content.as_bytes()); + let ready_length = content.len() - probe_length; + state.rsc_probe.push_str(&content[ready_length..]); + return if probe_length == 0 { + ScriptRewriteAction::Keep + } else if ready_length == 0 { + ScriptRewriteAction::RemoveNode + } else { + ScriptRewriteAction::replace(&content[..ready_length]) + }; + } + let prior_probe = std::mem::take(&mut state.rsc_probe); let mut combined = prior_probe.clone(); combined.push_str(content); diff --git a/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs b/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs index 63ec5048c..53b260c27 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs @@ -17,6 +17,7 @@ use super::{NEXTJS_INTEGRATION_ID, NextJsIntegrationConfig}; pub(super) const RSC_PAYLOAD_PLACEHOLDER_PREFIX: &str = "__ts_rsc_"; pub(super) const RSC_PAYLOAD_PLACEHOLDER_SUFFIX: &str = "__"; +const MAX_UNRESOLVED_RSC_PAYLOADS: usize = 256; #[derive(Debug, Default)] pub(super) enum FragmentState { @@ -231,9 +232,6 @@ impl NextJsRscStreamProcessor { "Next.js RSC placeholders are out of document order", )); } - state.captured_payload_bytes = state - .captured_payload_bytes - .saturating_sub(payload.original.len()); Ok(payload) } @@ -252,6 +250,11 @@ impl NextJsRscStreamProcessor { } fn release_group(&mut self, rewritten: Option<&[String]>) -> io::Result> { + let released_payload_bytes = self + .group + .iter() + .map(|payload| payload.original.len()) + .sum::(); let replacements: Vec<&str> = match &rewritten { Some(rewritten) => rewritten.iter().map(String::as_str).collect(), None => self @@ -260,17 +263,35 @@ impl NextJsRscStreamProcessor { .map(|payload| payload.original.as_str()) .collect(), }; + let held_output = std::mem::take(&mut self.held_output); let output = substitute_payloads( - std::mem::take(&mut self.held_output), + &held_output, &self.group, &replacements, &self.namespace_prefix(), )?; self.group.clear(); + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.captured_payload_bytes = state + .captured_payload_bytes + .saturating_sub(released_payload_bytes); Ok(output) } fn resolve_group(&mut self) -> io::Result>> { + // Classification scans the logical group. Bound its segment count as + // well as its bytes so adversarial tiny scripts cannot amplify that + // work quadratically; the hydration-safe fallback restores originals. + if self.group.len() > MAX_UNRESOLVED_RSC_PAYLOADS { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .bypass_rsc = true; + return self.release_group(None).map(Some); + } let payloads: Vec<&str> = self .group .iter() @@ -323,7 +344,7 @@ impl NextJsRscStreamProcessor { .map(|payload| payload.original.as_str()) .collect(); output.extend(substitute_payloads( - current.to_vec(), + current, &captured, &replacements, &self.namespace_prefix(), @@ -381,7 +402,7 @@ impl StreamProcessor for NextJsRscStreamProcessor { if self.group.is_empty() { output.extend_from_slice(before); } else if !self.append_held(before) { - output.extend(self.release_bypass(¤t[placeholder_start..])?); + output.extend(self.release_bypass(¤t[cursor..])?); break; } @@ -407,6 +428,15 @@ impl StreamProcessor for NextJsRscStreamProcessor { if let Some(released) = self.resolve_group()? { output.extend(released); } + if self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .bypass_rsc + { + output.extend(self.release_bypass(¤t[cursor..])?); + break; + } } if is_last { @@ -453,7 +483,7 @@ fn longest_suffix_prefix(bytes: &[u8], pattern: &[u8]) -> usize { } fn substitute_payloads( - mut input: Vec, + input: &[u8], payloads: &[CapturedPayload], replacements: &[&str], namespace_prefix: &[u8], @@ -463,30 +493,27 @@ fn substitute_payloads( "Next.js RSC substitution received mismatched payloads", )); } + let mut output = Vec::with_capacity(input.len()); + let mut cursor = 0; for (payload, replacement) in payloads.iter().zip(replacements) { let placeholder = payload.placeholder.as_bytes(); - let Some(position) = find_bytes(&input, placeholder) else { + let Some(relative_position) = find_bytes(&input[cursor..], placeholder) else { return Err(io::Error::other( "Next.js RSC captured placeholder is missing from held output", )); }; - let mut next = Vec::with_capacity( - input - .len() - .saturating_sub(placeholder.len()) - .saturating_add(replacement.len()), - ); - next.extend_from_slice(&input[..position]); - next.extend_from_slice(replacement.as_bytes()); - next.extend_from_slice(&input[position + placeholder.len()..]); - input = next; + let position = cursor + relative_position; + output.extend_from_slice(&input[cursor..position]); + output.extend_from_slice(replacement.as_bytes()); + cursor = position + placeholder.len(); } - if find_bytes(&input, namespace_prefix).is_some() { + output.extend_from_slice(&input[cursor..]); + if find_bytes(&output, namespace_prefix).is_some() { return Err(io::Error::other( "Next.js RSC generated placeholder remained after substitution", )); } - Ok(input) + Ok(output) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -813,6 +840,50 @@ mod tests { ); } + #[test] + fn held_output_overflow_restores_interstitial_bytes_before_the_next_payload() { + let payloads = ["1:T3,ab", "c"]; + let (mut processor, placeholders) = processor_with_payloads(&payloads, 80); + + assert!( + processor + .process_chunk(placeholders[0].as_bytes(), false) + .expect("should hold incomplete group") + .is_empty() + ); + let interstitial = "x".repeat(50); + let output = processor + .process_chunk( + format!("{interstitial}{}tail", placeholders[1]).as_bytes(), + false, + ) + .expect("should restore over-limit group"); + assert_eq!( + output, + format!("{}{interstitial}{}tail", payloads[0], payloads[1]).as_bytes(), + "overflow fallback must preserve bytes between payload scripts" + ); + } + + #[test] + fn invalid_group_restores_later_payloads_in_the_same_output_chunk() { + let payloads = [ + "1:Tzz,invalid", + r#"1:T29,{"url":"https://origin.example.com/path"}"#, + ]; + let (mut processor, placeholders) = processor_with_payloads(&payloads, 1024); + let input = format!("{}middle{}tail", placeholders[0], placeholders[1]); + + let output = processor + .process_chunk(input.as_bytes(), false) + .expect("should restore invalid group and later payload"); + assert_eq!( + output, + format!("{}middle{}tail", payloads[0], payloads[1]).as_bytes(), + "document-wide bypass must take effect within the current output chunk" + ); + } + #[test] fn stream_processor_matches_a_placeholder_split_across_output_chunks() { let (mut processor, placeholders) = processor_with_payloads(&["plain"], 1024); @@ -828,4 +899,58 @@ mod tests { .expect("should finish the placeholder"); assert_eq!(second, b"plain", "should restore the captured payload"); } + + #[test] + fn unresolved_group_bytes_remain_charged_until_release() { + let payloads = ["1:T3,ab", "c"]; + let (mut processor, placeholders) = processor_with_payloads(&payloads, 1024); + let state = Arc::clone(&processor.state); + + assert!( + processor + .process_chunk(placeholders[0].as_bytes(), false) + .expect("should hold incomplete group") + .is_empty() + ); + assert_eq!( + state + .lock() + .expect("should lock document state") + .captured_payload_bytes, + payloads.iter().map(|payload| payload.len()).sum::(), + "held payloads must remain in the request-scoped capture budget" + ); + + processor + .process_chunk(placeholders[1].as_bytes(), false) + .expect("should release complete group"); + assert_eq!( + state + .lock() + .expect("should lock document state") + .captured_payload_bytes, + 0, + "released payloads should return their request-scoped budget" + ); + } + + #[test] + fn excessive_unresolved_payload_count_falls_back_unchanged() { + let payloads = vec!["1:Tffff,x"; MAX_UNRESOLVED_RSC_PAYLOADS + 1]; + let (mut processor, placeholders) = processor_with_payloads(&payloads, usize::MAX); + let input = placeholders.join(""); + + let output = processor + .process_chunk(input.as_bytes(), false) + .expect("should restore excessive unresolved group"); + assert_eq!(output, payloads.join("").as_bytes()); + assert!( + processor + .state + .lock() + .expect("should lock document state") + .bypass_rsc, + "excessive segment count should enable document-wide fallback" + ); + } } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index f90684d00..bbdb6f55f 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1236,7 +1236,13 @@ async fn hold_finish_ready_segments( if !step.close_found && let Some(seam) = state.hold.take() { - let encoded = encoder.encode_chunk(seam.finish())?; + let encoded = match encoder.encode_chunk(seam.finish()) { + Ok(encoded) => encoded, + Err(err) => { + abandon_hold_auction(state, collect_refs.services, "stream_encode_error").await; + return Err(err); + } + }; if !encoded.is_empty() { step.ready.push(bytes::Bytes::from(encoded)); } @@ -3943,25 +3949,57 @@ async fn body_close_hold_loop( loop { match reader.read(&mut buffer) { Ok(0) => { - let final_out = processor.process_chunk(&[], true).change_context( + let final_out = match processor.process_chunk(&[], true).change_context( TrustedServerError::Proxy { message: "Failed to finalize processor".to_string(), }, - )?; + ) { + Ok(output) => output, + Err(err) => { + abandon_reader_auction( + &mut dispatched, + &mut telemetry, + deps.services, + "stream_process_error", + ) + .await; + return Err(err); + } + }; let ready = match hold.as_mut() { Some(seam) => seam.push(&final_out), None => final_out, }; - writer - .write_all(&ready) - .change_context(TrustedServerError::Proxy { - message: "Failed to write finalized output".to_string(), - })?; + if let Err(err) = + writer + .write_all(&ready) + .change_context(TrustedServerError::Proxy { + message: "Failed to write finalized output".to_string(), + }) + { + abandon_reader_auction( + &mut dispatched, + &mut telemetry, + deps.services, + "stream_write_error", + ) + .await; + return Err(err); + } if hold.as_ref().is_some_and(InlineBodyCloseSeam::found) { - writer.flush().change_context(TrustedServerError::Proxy { + if let Err(err) = writer.flush().change_context(TrustedServerError::Proxy { message: "Failed to flush output before auction collection".to_string(), - })?; + }) { + abandon_reader_auction( + &mut dispatched, + &mut telemetry, + deps.services, + "stream_write_error", + ) + .await; + return Err(err); + } let dispatched = dispatched .take() .expect("should have dispatched auction to collect"); @@ -3977,16 +4015,34 @@ async fn body_close_hold_loop( message: "Failed to write held body tail".to_string(), })?; } else { - if let Some(seam) = hold.take() { - writer.write_all(&seam.finish()).change_context( + if let Some(seam) = hold.take() + && let Err(err) = writer.write_all(&seam.finish()).change_context( TrustedServerError::Proxy { message: "Failed to write terminal HTML output".to_string(), }, - )?; + ) + { + abandon_reader_auction( + &mut dispatched, + &mut telemetry, + deps.services, + "stream_write_error", + ) + .await; + return Err(err); } - writer.flush().change_context(TrustedServerError::Proxy { + if let Err(err) = writer.flush().change_context(TrustedServerError::Proxy { message: "Failed to flush output before auction collection".to_string(), - })?; + }) { + abandon_reader_auction( + &mut dispatched, + &mut telemetry, + deps.services, + "stream_write_error", + ) + .await; + return Err(err); + } if let Some(pending) = dispatched.take() { collect_stream_auction(pending, telemetry.take(), &deps).await; } @@ -4017,16 +4073,36 @@ async fn body_close_hold_loop( Some(seam) => seam.push(&processed), None => processed, }; - writer - .write_all(&ready) - .change_context(TrustedServerError::Proxy { - message: "Failed to write processed chunk".to_string(), - })?; + if let Err(err) = + writer + .write_all(&ready) + .change_context(TrustedServerError::Proxy { + message: "Failed to write processed chunk".to_string(), + }) + { + abandon_reader_auction( + &mut dispatched, + &mut telemetry, + deps.services, + "stream_write_error", + ) + .await; + return Err(err); + } if hold.as_ref().is_some_and(InlineBodyCloseSeam::found) { - writer.flush().change_context(TrustedServerError::Proxy { + if let Err(err) = writer.flush().change_context(TrustedServerError::Proxy { message: "Failed to flush output before auction collection".to_string(), - })?; + }) { + abandon_reader_auction( + &mut dispatched, + &mut telemetry, + deps.services, + "stream_write_error", + ) + .await; + return Err(err); + } let pending = dispatched .take() .expect("should have dispatched auction to collect"); @@ -4066,6 +4142,17 @@ async fn body_close_hold_loop( Ok(()) } +async fn abandon_reader_auction( + dispatched: &mut Option, + telemetry: &mut AuctionTelemetryCarry, + services: &RuntimeServices, + reason: &'static str, +) { + if let Some(pending) = dispatched.take() { + emit_abandoned_auction(services, telemetry.observation.take(), pending, reason).await; + } +} + async fn emit_abandoned_auction( services: &RuntimeServices, observation: Option, @@ -15722,6 +15809,77 @@ mod tests { ); } + #[tokio::test] + async fn parser_seam_write_failure_abandons_auction_once() { + struct FailingWriter; + + impl Write for FailingWriter { + fn write(&mut self, _buf: &[u8]) -> io::Result { + Err(io::Error::other("injected write failure")) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + let settings = create_test_settings(); + let sink = Arc::new(RecordingTelemetrySink::default()); + let services = noop_services_with_telemetry_sink(Arc::clone(&sink) as _); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let ad_bids_state = AdBidsState::default(); + let ec_context = EcContext::new_for_test(None, ConsentContext::default()); + let ctx = AuctionCollectCtx { + dispatched: DispatchedAuction::empty_for_test(test_auction_request(), 500), + telemetry: AuctionTelemetryCarry { + observation: Some(AuctionObservationContext::from_parts( + AuctionSource::InitialNavigation, + "proxy.example.com", + "/article", + 1, + &ec_context, + )), + auction_request: None, + }, + deps: AuctionCollectDeps { + price_granularity: PriceGranularity::default(), + ad_bids_state: &ad_bids_state, + orchestrator: &orchestrator, + services: &services, + settings: &settings, + request_origin: String::new(), + }, + }; + let mut processor = RecordingProcessor { + read_count: Arc::new(AtomicUsize::new(0)), + body_close_processed_at: Arc::new(AtomicUsize::new(0)), + }; + + let error = body_close_hold_loop( + std::io::Cursor::new(b"ready"), + &mut FailingWriter, + &mut processor, + ctx, + Some(b"".to_vec()), + ) + .await + .expect_err("injected writer failure should surface"); + assert!(format!("{error:?}").contains("Failed to write processed chunk")); + + let batches = sink.batches.lock().expect("should lock telemetry batches"); + let summaries: Vec<_> = batches + .iter() + .flat_map(crate::auction::telemetry::AuctionEventBatch::rows) + .filter(|row| row.event_kind == "summary") + .collect(); + assert_eq!(summaries.len(), 1, "should emit one terminal summary"); + assert_eq!(summaries[0].terminal_status.as_deref(), Some("abandoned")); + assert_eq!( + summaries[0].terminal_reason.as_deref(), + Some("stream_write_error") + ); + } + #[tokio::test] async fn hold_step_yields_ready_prefix_before_collecting_auction() { // A small page whose `` lands in the first source chunk must diff --git a/docs/guide/integrations/nextjs.md b/docs/guide/integrations/nextjs.md index fecae5947..7c6cc7c50 100644 --- a/docs/guide/integrations/nextjs.md +++ b/docs/guide/integrations/nextjs.md @@ -34,11 +34,11 @@ max_combined_payload_bytes = 10485760 ### Configuration Options -| Field | Type | Default | Description | -| ---------------------------- | ------- | ------------------------- | ------------------------------------------------ | -| `enabled` | boolean | `false` | Enable Next.js integration | -| `rewrite_attributes` | array | `["href", "link", "url"]` | Attributes to rewrite in Next.js data | -| `max_combined_payload_bytes` | integer | `10485760` | Maximum bytes retained for one unresolved group | +| Field | Type | Default | Description | +| ---------------------------- | ------- | ------------------------- | ----------------------------------------------- | +| `enabled` | boolean | `false` | Enable Next.js integration | +| `rewrite_attributes` | array | `["href", "link", "url"]` | Attributes to rewrite in Next.js data | +| `max_combined_payload_bytes` | integer | `10485760` | Maximum bytes retained for one unresolved group | ## How It Works From 6f5d32953fcb494c839643b1ebfa6cc52d1a0304 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Wed, 9 Sep 2026 20:43:26 +0530 Subject: [PATCH 15/17] Complete parser-aware streaming and resolve review findings --- Cargo.lock | 1 + crates/trusted-server-adapter-axum/src/app.rs | 43 +- .../src/app.rs | 49 +- crates/trusted-server-adapter-spin/src/app.rs | 59 +- .../integrations/nextjs/rsc_placeholders.rs | 89 +++- .../src/integrations/nextjs/rsc_stream.rs | 83 ++- .../src/integrations/nextjs/shared.rs | 5 +- crates/trusted-server-core/src/publisher.rs | 502 +++++++++++++++--- .../Cargo.toml | 1 + .../tests/parity.rs | 267 ++++++++++ ...parser-aware-body-hold-nextjs-streaming.md | 36 ++ 11 files changed, 1042 insertions(+), 93 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8da486f8c..13aabb584 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5518,6 +5518,7 @@ dependencies = [ name = "trusted-server-integration-tests" version = "0.1.0" dependencies = [ + "async-trait", "axum", "bytes", "derive_more", diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index ef3169f7c..a7e3131ec 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -49,6 +49,7 @@ pub struct AppState { settings: Arc, orchestrator: Arc, registry: Arc, + services: Option, } /// Build the application state, loading settings and constructing all per-application components. @@ -73,6 +74,13 @@ fn build_state() -> Result, Report> { /// registry fail to initialise. fn build_state_with_settings( settings: Settings, +) -> Result, Report> { + build_state_with_services(settings, None) +} + +fn build_state_with_services( + settings: Settings, + services: Option, ) -> Result, Report> { let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; @@ -81,9 +89,18 @@ fn build_state_with_settings( settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + services, })) } +impl AppState { + fn services_for_request(&self, ctx: &RequestContext) -> RuntimeServices { + self.services + .clone() + .unwrap_or_else(|| build_runtime_services(ctx)) + } +} + // --------------------------------------------------------------------------- // Error helper // --------------------------------------------------------------------------- @@ -133,7 +150,7 @@ where F: FnOnce(Arc, RuntimeServices, Request) -> Fut, Fut: Future>>, { - let services = build_runtime_services(&ctx); + let services = state.services_for_request(&ctx); let mut req = ctx.into_request(); if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &state.settings, @@ -594,6 +611,30 @@ impl TrustedServerApp { let state = build_state_with_settings(settings)?; Ok(build_router(&state)) } + + /// Build the full router with explicit settings and runtime services. + /// + /// Each request receives a clone of the supplied services, allowing callers + /// to exercise production routes with deterministic platform dependencies. + /// The supplied client metadata applies to every request to this router. + /// + /// # Errors + /// + /// Returns an error when the auction orchestrator or integration registry + /// cannot be initialized. + /// + /// # Examples + /// + /// ```ignore + /// let router = TrustedServerApp::routes_with_settings_and_services(settings, services)?; + /// ``` + pub fn routes_with_settings_and_services( + settings: Settings, + services: RuntimeServices, + ) -> Result> { + let state = build_state_with_services(settings, Some(services))?; + Ok(build_router(&state)) + } } fn build_router(state: &Arc) -> RouterService { diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index a3457ef7c..194cd2ba5 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -56,6 +56,7 @@ pub struct AppState { settings: Arc, orchestrator: Arc, registry: Arc, + services: Option, } /// Build the application state, loading settings and constructing all per-application components. @@ -112,6 +113,13 @@ fn settings_from_cloudflare_config_json() -> Result Result, Report> { + build_state_with_services(settings, None) +} + +fn build_state_with_services( + settings: Settings, + services: Option, ) -> Result, Report> { let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; @@ -120,17 +128,22 @@ fn build_state_with_settings( settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + services, })) } +impl AppState { + fn services_for_request(&self, ctx: &RequestContext) -> RuntimeServices { + self.services + .clone() + .unwrap_or_else(|| build_runtime_services(ctx)) + } +} + // --------------------------------------------------------------------------- // Per-request RuntimeServices // --------------------------------------------------------------------------- -fn build_per_request_services(ctx: &RequestContext) -> RuntimeServices { - build_runtime_services(ctx) -} - /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, /// `/_ts/page-bids`, and the publisher fallback). /// @@ -178,7 +191,7 @@ where let s = Arc::clone(&state); let f = f.clone(); Box::pin(async move { - let services = build_per_request_services(&ctx); + let services = s.services_for_request(&ctx); let mut req = ctx.into_request(); if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( &s.settings, @@ -365,6 +378,30 @@ impl TrustedServerApp { let state = build_state_with_settings(settings)?; Ok(build_router(&state)) } + + /// Build the full router with explicit settings and runtime services. + /// + /// Each request receives a clone of the supplied services, allowing callers + /// to exercise production routes with deterministic platform dependencies. + /// The supplied client metadata applies to every request to this router. + /// + /// # Errors + /// + /// Returns an error when the auction orchestrator or integration registry + /// cannot be initialized. + /// + /// # Examples + /// + /// ```ignore + /// let router = TrustedServerApp::routes_with_settings_and_services(settings, services)?; + /// ``` + pub fn routes_with_settings_and_services( + settings: Settings, + services: RuntimeServices, + ) -> Result> { + let state = build_state_with_services(settings, Some(services))?; + Ok(build_router(&state)) + } } fn build_router(state: &Arc) -> RouterService { @@ -376,7 +413,7 @@ fn build_router(state: &Arc) -> RouterService { state: Arc, ctx: RequestContext, ) -> Result { - let services = build_per_request_services(&ctx); + let services = state.services_for_request(&ctx); let mut req = ctx.into_request(); if let Some(response) = deny_admin_diagnostic_fallback(&req) { return Ok(response); diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 8917f2a65..e31351341 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -49,6 +49,7 @@ pub struct AppState { settings: Arc, orchestrator: Arc, registry: Arc, + services: Option, } /// Build the application state, loading settings and constructing all per-application components. @@ -70,6 +71,13 @@ fn build_state() -> Result, Report> { /// registry fail to initialise. fn build_state_with_settings( settings: Settings, +) -> Result, Report> { + build_state_with_services(settings, None) +} + +fn build_state_with_services( + settings: Settings, + services: Option, ) -> Result, Report> { let orchestrator = build_orchestrator(&settings)?; let registry = IntegrationRegistry::new(&settings)?; @@ -78,9 +86,18 @@ fn build_state_with_settings( settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + services, })) } +impl AppState { + fn services_for_request(&self, ctx: &RequestContext) -> RuntimeServices { + self.services + .clone() + .unwrap_or_else(|| build_runtime_services(ctx)) + } +} + // --------------------------------------------------------------------------- // Publisher response helper // --------------------------------------------------------------------------- @@ -494,6 +511,30 @@ impl TrustedServerApp { let state = build_state_with_settings(settings)?; Ok(build_router(&state)) } + + /// Build the full router with explicit settings and runtime services. + /// + /// Each request receives a clone of the supplied services, allowing callers + /// to exercise production routes with deterministic platform dependencies. + /// The supplied client metadata applies to every request to this router. + /// + /// # Errors + /// + /// Returns an error when the auction orchestrator or integration registry + /// cannot be initialized. + /// + /// # Examples + /// + /// ```ignore + /// let router = TrustedServerApp::routes_with_settings_and_services(settings, services)?; + /// ``` + pub fn routes_with_settings_and_services( + settings: Settings, + services: RuntimeServices, + ) -> Result> { + let state = build_state_with_services(settings, Some(services))?; + Ok(build_router(&state)) + } } fn build_router(state: &Arc) -> RouterService { @@ -505,7 +546,7 @@ fn build_router(state: &Arc) -> RouterService { let discovery_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = s.services_for_request(&ctx); let req = ctx.into_request(); Ok(handle_trusted_server_discovery(&s.settings, &services, req) .unwrap_or_else(|e| http_error(&e))) @@ -517,7 +558,7 @@ fn build_router(state: &Arc) -> RouterService { let verify_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = s.services_for_request(&ctx); let req = ctx.into_request(); Ok(handle_verify_signature(&s.settings, &services, req) .unwrap_or_else(|e| http_error(&e))) @@ -550,7 +591,7 @@ fn build_router(state: &Arc) -> RouterService { let auction_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = s.services_for_request(&ctx); // Request normalization (forwarded-header stripping, trusted // Host/scheme/client-IP derivation) is applied centrally by // `NormalizeMiddleware` before this handler runs, so the signed @@ -588,7 +629,7 @@ fn build_router(state: &Arc) -> RouterService { let page_bids_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = s.services_for_request(&ctx); let mut req = ctx.into_request(); if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request( @@ -623,7 +664,7 @@ fn build_router(state: &Arc) -> RouterService { let fp_proxy_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = s.services_for_request(&ctx); let req = ctx.into_request(); Ok(handle_first_party_proxy(&s.settings, &services, req) .await @@ -636,7 +677,7 @@ fn build_router(state: &Arc) -> RouterService { let fp_click_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = s.services_for_request(&ctx); let req = ctx.into_request(); Ok(handle_first_party_click(&s.settings, &services, req) .await @@ -649,7 +690,7 @@ fn build_router(state: &Arc) -> RouterService { let fp_sign_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = s.services_for_request(&ctx); let req = ctx.into_request(); Ok(handle_first_party_proxy_sign(&s.settings, &services, req) .await @@ -666,7 +707,7 @@ fn build_router(state: &Arc) -> RouterService { let fp_rebuild_handler = move |ctx: RequestContext| { let s = Arc::clone(&s); async move { - let services = build_runtime_services(&ctx); + let services = s.services_for_request(&ctx); let req = ctx.into_request(); Ok( handle_first_party_proxy_rebuild(&s.settings, &services, req) @@ -682,7 +723,7 @@ fn build_router(state: &Arc) -> RouterService { state: Arc, ctx: RequestContext, ) -> Result { - let services = build_runtime_services(&ctx); + let services = state.services_for_request(&ctx); let mut req = ctx.into_request(); if let Some(response) = deny_admin_diagnostic_fallback(&req) { return Ok(response); diff --git a/crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs b/crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs index 38b9bb1b0..1d6c7f49b 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/rsc_placeholders.rs @@ -4,11 +4,12 @@ use crate::integrations::{ IntegrationScriptContext, IntegrationScriptRewriter, ScriptRewriteAction, }; -use super::rsc::{DEFAULT_MAX_COMBINED_PAYLOAD_BYTES, TChunkScan, scan_tchunks}; +use super::rsc::DEFAULT_MAX_COMBINED_PAYLOAD_BYTES; #[cfg(test)] pub(super) use super::rsc_stream::RSC_PAYLOAD_PLACEHOLDER_PREFIX; use super::rsc_stream::{ - CapturedPayload, FragmentCapture, capture_fragment, document_state, rsc_payload_placeholder, + CapturedPayload, FragmentCapture, RscGroupStatus, capture_fragment, classify_rsc_group, + document_state, rsc_payload_placeholder, }; use super::shared::find_rsc_push_payload_range; use super::{NEXTJS_INTEGRATION_ID, NextJsIntegrationConfig}; @@ -108,12 +109,14 @@ impl NextJsRscPlaceholderRewriter { FragmentCapture::PassThrough => { if is_last && content.len() > limit && content.contains("__next_f") { let unsafe_continuation = find_rsc_push_payload_range(content) - .map(|(start, end)| match scan_tchunks(&content[start..end]) { - TChunkScan::Complete(_) => false, - TChunkScan::NeedMore | TChunkScan::Invalid => true, + .map(|(start, end)| { + matches!( + classify_rsc_group(&[&content[start..end]], usize::MAX), + RscGroupStatus::NeedMore | RscGroupStatus::Invalid + ) }) .unwrap_or(true); - state.bypass_rsc |= unsafe_continuation; + state.bypass_rsc |= unsafe_continuation || state.captured_payload_bytes > 0; } else if !is_last && content.len() > limit { state.bypass_rsc = true; } @@ -318,6 +321,28 @@ mod tests { ); } + #[test] + fn captures_initializer_push_at_every_fragment_boundary() { + let script = r#"(self.__next_f=self.__next_f||[]).push([1,"1:T3,ab"])"#; + for split in 1..script.len() { + let state = IntegrationDocumentState::default(); + let rewriter = NextJsRscPlaceholderRewriter::new(test_config()); + let _ = rewriter.rewrite(&script[..split], &ctx(false, &state)); + let _ = rewriter.rewrite(&script[split..], &ctx(true, &state)); + let shared = document_state(&state); + let guard = shared.lock().expect("should lock document state"); + assert_eq!( + guard.captured_payloads.len(), + 1, + "should capture initializer split at byte {split}" + ); + assert_eq!( + guard.captured_payloads[0].original, "1:T3,ab", + "should capture complete payload" + ); + } + } + #[test] fn overflowing_fragmented_rsc_restores_prefix_and_bypasses_later_rsc() { let state = IntegrationDocumentState::default(); @@ -364,4 +389,56 @@ mod tests { "Non-RSC scripts should be kept unchanged" ); } + + #[test] + fn oversized_partial_header_bypasses_later_continuation() { + for suffix in ["1", "1:", "1:T", "1:T2"] { + let state = IntegrationDocumentState::default(); + let rewriter = NextJsRscPlaceholderRewriter::new(Arc::new(NextJsIntegrationConfig { + max_combined_payload_bytes: 100, + ..(*test_config()).clone() + })); + let script = format!("self.__next_f.push([1,\"{}{suffix}\"])", "x".repeat(100),); + assert_eq!( + rewriter.rewrite(&script, &ctx(true, &state)), + ScriptRewriteAction::Keep, + "should pass through an oversized script", + ); + let continuation = r#"self.__next_f.push([1,"a,https://origin.example.com/path"] )"#; + assert_eq!( + rewriter.rewrite(continuation, &ctx(true, &state)), + ScriptRewriteAction::Keep, + "should preserve later payloads after oversized header prefix {suffix}", + ); + } + } + + #[test] + fn oversized_continuation_bypasses_an_unresolved_group() { + let state = IntegrationDocumentState::default(); + let rewriter = NextJsRscPlaceholderRewriter::new(Arc::new(NextJsIntegrationConfig { + max_combined_payload_bytes: 100, + ..(*test_config()).clone() + })); + let first = r#"self.__next_f.push([1,"1:T200,start"])"#; + assert!( + matches!( + rewriter.rewrite(first, &ctx(true, &state)), + ScriptRewriteAction::Replace(_) + ), + "should capture incomplete group" + ); + let oversized = format!("self.__next_f.push([1,\"{}\"])", "x".repeat(101)); + assert_eq!( + rewriter.rewrite(&oversized, &ctx(true, &state)), + ScriptRewriteAction::Keep, + "should pass through oversized continuation" + ); + let later = r#"self.__next_f.push([1,"https://origin.example.com/path/"])"#; + assert_eq!( + rewriter.rewrite(later, &ctx(true, &state)), + ScriptRewriteAction::Keep, + "should preserve later continuation of bypassed group" + ); + } } diff --git a/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs b/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs index 53b260c27..1db16f592 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/rsc_stream.rs @@ -286,6 +286,10 @@ impl NextJsRscStreamProcessor { // well as its bytes so adversarial tiny scripts cannot amplify that // work quadratically; the hydration-safe fallback restores originals. if self.group.len() > MAX_UNRESOLVED_RSC_PAYLOADS { + log::warn!( + "Next.js RSC fallback: segment limit, {} payloads", + self.group.len() + ); self.state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -309,14 +313,27 @@ impl NextJsRscStreamProcessor { self.limit, ); if rewritten.len() != self.group.len() { - return Err(io::Error::other( - "Next.js RSC rewrite returned a mismatched payload count", - )); + log::warn!( + "Next.js RSC fallback: rewrite count mismatch, {} payloads", + self.group.len() + ); + return self.release_group(None).map(Some); } + log::debug!("Next.js RSC group completes: {} payloads", self.group.len()); self.release_group(Some(&rewritten)).map(Some) } - RscGroupStatus::CompleteUnrewritable => self.release_group(None).map(Some), + RscGroupStatus::CompleteUnrewritable => { + log::warn!( + "Next.js RSC fallback: split header, {} payloads", + self.group.len() + ); + self.release_group(None).map(Some) + } RscGroupStatus::Invalid => { + log::warn!( + "Next.js RSC fallback: invalid group, {} payloads", + self.group.len() + ); self.state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -327,6 +344,12 @@ impl NextJsRscStreamProcessor { } fn release_bypass(&mut self, current: &[u8]) -> io::Result> { + if !self.group.is_empty() || self.next_captured().is_some() { + log::warn!( + "Next.js RSC fallback: capture or output limit, {} held payloads", + self.group.len() + ); + } let mut output = self.release_group(None)?; output.extend_from_slice(&self.pending_candidate); self.pending_candidate.clear(); @@ -451,6 +474,10 @@ impl StreamProcessor for NextJsRscStreamProcessor { } } if !self.group.is_empty() { + log::warn!( + "Next.js RSC fallback: incomplete group at EOF, {} payloads", + self.group.len() + ); output.extend(self.release_group(None)?); } if self.next_captured().is_some() { @@ -613,6 +640,9 @@ fn inspect_non_chunk_segment(segment: &str, terminal: bool) -> HeaderSuffixStatu HeaderSuffixStatus::Complete }; } + if terminal && &bytes[cursor..] == b":" { + return HeaderSuffixStatus::NeedMore; + } if bytes.get(cursor..cursor + 2) != Some(b":T") { index = cursor + 1; continue; @@ -702,6 +732,18 @@ mod tests { } } + #[test] + fn retains_every_incomplete_header_prefix() { + let header = "1a:T3e,"; + for split in 1..header.len() { + assert_eq!( + classify_rsc_group(&[&header[..split]], usize::MAX), + RscGroupStatus::NeedMore, + "should retain the header prefix split at byte {split}", + ); + } + } + #[test] fn classifies_disproved_hex_suffix_as_complete() { let payloads = ["ordinary1a", "-suffix"]; @@ -803,6 +845,39 @@ mod tests { ); } + #[test] + fn stream_processor_restores_header_split_after_colon() { + let payloads = ["1:", "T25,https://origin.example.com/longer-path!"]; + let (mut processor, placeholders) = processor_with_payloads(&payloads, 1024); + let first = processor + .process_chunk(placeholders[0].as_bytes(), false) + .expect("should retain partial header"); + assert!(first.is_empty(), "should wait for the rest of the header"); + + let second = processor + .process_chunk(placeholders[1].as_bytes(), true) + .expect("should restore a physically split header"); + assert_eq!( + second, + payloads.concat().as_bytes(), + "should preserve URL and declared length together", + ); + } + + #[test] + fn stream_processor_restores_payloads_on_rewrite_count_mismatch() { + let payloads = ["1:T3,ab", "c\n\0SPLIT\0https://origin.example.com/path/"]; + let (mut processor, placeholders) = processor_with_payloads(&payloads, 1024); + let output = processor + .process_chunk(placeholders.concat().as_bytes(), true) + .expect("should restore originals when the rewrite count differs"); + assert_eq!( + output, + payloads.concat().as_bytes(), + "should preserve all original bytes" + ); + } + #[test] fn stream_processor_rewrites_and_releases_a_complete_payload_in_one_call() { let payload = r#"1:T29,{"url":"https://origin.example.com/path"}"#; diff --git a/crates/trusted-server-core/src/integrations/nextjs/shared.rs b/crates/trusted-server-core/src/integrations/nextjs/shared.rs index cea8add94..2b0f7eff1 100644 --- a/crates/trusted-server-core/src/integrations/nextjs/shared.rs +++ b/crates/trusted-server-core/src/integrations/nextjs/shared.rs @@ -13,9 +13,12 @@ use crate::host_rewrite::rewrite_bare_host_at_boundaries; // intentionally remain lazy statics instead of participating in // `Settings::prepare_runtime`. /// RSC push script call pattern for extracting payload string boundaries. +/// +/// The initializer prologue may already have streamed before a fragmented +/// `__next_f` identifier is recognized, so matching can start at the identifier. pub(crate) static RSC_PUSH_CALL_PATTERN: LazyLock = LazyLock::new(|| { Regex::new( - r#"(?s)(?:(?:(?:self|window)\.)?__next_f\.push|\(\s*(?:self|window)\.__next_f\s*=\s*(?:self|window)\.__next_f\s*\|\|\s*\[\]\s*\)\s*\.push)\(\[\s*1\s*,\s*(['"])"#, + r#"(?s)(?:(?:(?:self|window)\.)?__next_f\.push|(?:(?:\(\s*)?(?:self|window)\.)?__next_f\s*=\s*(?:self|window)\.__next_f\s*\|\|\s*\[\]\s*\)\s*\.push)\(\[\s*1\s*,\s*(['"])"#, ) .expect("valid RSC push call regex") }); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index bbdb6f55f..90ee9fd49 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -1032,7 +1032,7 @@ async fn abandon_hold_auction( /// `ready` is the prefix the caller must emit *before* collecting the auction, /// so a small page whose `` lands in the first source chunk still /// streams its document prefix immediately instead of stalling behind the -/// auction. `close_found` signals that `( Ok(HoldStepSegments { ready, close_found }) } -/// Collect the dispatched auction and process the held `` tail. +/// Collect the dispatched auction and emit bids before the parsed closing tail. /// /// Call only after [`hold_step_decoded_chunk`] (or /// [`hold_finish_ready_segments`]) reports `close_found` and the ready prefix /// has already been emitted: /// collecting here — after the prefix streams — is what keeps the auction -/// riding alongside transfer instead of blocking it. Collection runs before the -/// tail is processed so `lol_html` sees live bids at the injection point. -async fn hold_collect_close_tail( - _processor: &mut P, +/// riding alongside transfer instead of blocking it. The parser has already +/// transformed the tail; replace its marker with the collected bids before encoding. +async fn hold_collect_close_tail( encoder: &mut BodyStreamEncoder, state: &mut AuctionHoldState, collect_refs: &AuctionCollectDeps<'_>, @@ -1252,11 +1251,10 @@ async fn hold_finish_ready_segments( /// Finalize the inline seam pipeline after [`hold_finish_ready_segments`]. /// -/// Collects the auction if the close-body tag never streamed, processes the held -/// tail plus the processor's final chunk, and emits the encoder trailer. Returns +/// Collects the auction if the body-end marker never streamed, releases any +/// remaining seam bytes, and emits the encoder trailer. Returns /// the encoded segments for the caller to emit. -async fn hold_finish_tail_segments( - _processor: &mut P, +async fn hold_finish_tail_segments( encoder: &mut BodyStreamEncoder, state: &mut AuctionHoldState, collect_refs: &AuctionCollectDeps<'_>, @@ -2643,7 +2641,6 @@ pub async fn publisher_response_into_streaming_response( } if step.close_found { for encoded in hold_collect_close_tail( - &mut processor, &mut encoder, &mut state, &collect_refs, @@ -2674,7 +2671,6 @@ pub async fn publisher_response_into_streaming_response( } if final_step.close_found { for encoded in hold_collect_close_tail( - &mut processor, &mut encoder, &mut state, &collect_refs, @@ -2686,7 +2682,6 @@ pub async fn publisher_response_into_streaming_response( } } for encoded in hold_finish_tail_segments( - &mut processor, &mut encoder, &mut state, &collect_refs, @@ -3793,65 +3788,68 @@ async fn body_close_hold_loop_stream( inline_seam_token, ); - while let Some(step) = hold_step_next_chunk( - &mut source, - &mut decoder, - &mut encoder, - processor, - &mut state, - &collect_refs, - ) - .await? - { - // Write the ready prefix before collecting the auction, matching the - // lazy Fastly stream: only the held `` tail waits on collection. - for encoded in step.ready { + let result = async { + while let Some(step) = hold_step_next_chunk( + &mut source, + &mut decoder, + &mut encoder, + processor, + &mut state, + &collect_refs, + ) + .await? + { + // Write the ready prefix before collecting the auction, matching the + // lazy Fastly stream: only the held `` tail waits on collection. + for encoded in step.ready { + write_encoded_segment(writer, &encoded)?; + } + if step.close_found { + writer.flush().change_context(TrustedServerError::Proxy { + message: "Failed to flush output before auction collection".to_string(), + })?; + for encoded in + hold_collect_close_tail(&mut encoder, &mut state, &collect_refs).await? + { + write_encoded_segment(writer, &encoded)?; + } + } + } + + // Write the decoder-finalized prefix before collection, matching the lazy + // Fastly stream: only the held `` tail waits on the auction. + let final_step = hold_finish_ready_segments( + processor, + &mut decoder, + &mut encoder, + &mut state, + &collect_refs, + ) + .await?; + for encoded in final_step.ready { write_encoded_segment(writer, &encoded)?; } - if step.close_found { - writer.flush().change_context(TrustedServerError::Proxy { - message: "Failed to flush output before auction collection".to_string(), - })?; - for encoded in - hold_collect_close_tail(processor, &mut encoder, &mut state, &collect_refs).await? - { + writer.flush().change_context(TrustedServerError::Proxy { + message: "Failed to flush output before auction collection".to_string(), + })?; + if final_step.close_found { + for encoded in hold_collect_close_tail(&mut encoder, &mut state, &collect_refs).await? { write_encoded_segment(writer, &encoded)?; } } - } - - // Write the decoder-finalized prefix before collection, matching the lazy - // Fastly stream: only the held `` tail waits on the auction. - let final_step = hold_finish_ready_segments( - processor, - &mut decoder, - &mut encoder, - &mut state, - &collect_refs, - ) - .await?; - for encoded in final_step.ready { - write_encoded_segment(writer, &encoded)?; - } - writer.flush().change_context(TrustedServerError::Proxy { - message: "Failed to flush output before auction collection".to_string(), - })?; - if final_step.close_found { - for encoded in - hold_collect_close_tail(processor, &mut encoder, &mut state, &collect_refs).await? - { + for encoded in hold_finish_tail_segments(&mut encoder, &mut state, &collect_refs).await? { write_encoded_segment(writer, &encoded)?; } + writer.flush().change_context(TrustedServerError::Proxy { + message: "Failed to flush output".to_string(), + })?; + Ok(()) } - for encoded in - hold_finish_tail_segments(processor, &mut encoder, &mut state, &collect_refs).await? - { - write_encoded_segment(writer, &encoded)?; + .await; + if result.is_err() { + abandon_hold_auction(&mut state, collect_refs.services, "stream_write_error").await; } - writer.flush().change_context(TrustedServerError::Proxy { - message: "Failed to flush output".to_string(), - })?; - Ok(()) + result } struct InlineBodyCloseSeam { @@ -15880,6 +15878,116 @@ mod tests { ); } + #[tokio::test] + async fn parser_seam_async_sink_failures_abandon_auction_once() { + struct FailingWriter { + fail_flush: bool, + } + + impl Write for FailingWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + if self.fail_flush { + Ok(buf.len()) + } else { + Err(io::Error::other("injected write failure")) + } + } + + fn flush(&mut self) -> io::Result<()> { + Err(io::Error::other("injected flush failure")) + } + } + + for fail_flush in [false, true] { + for with_close in [false, true] { + let settings = create_test_settings(); + let sink = Arc::new(RecordingTelemetrySink::default()); + let services = noop_services_with_telemetry_sink(Arc::clone(&sink) as _); + let orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + let ad_bids_state = AdBidsState::default(); + let ec_context = EcContext::new_for_test(None, ConsentContext::default()); + let ctx = AuctionCollectCtx { + dispatched: DispatchedAuction::empty_for_test(test_auction_request(), 500), + telemetry: AuctionTelemetryCarry { + observation: Some(AuctionObservationContext::from_parts( + AuctionSource::InitialNavigation, + "proxy.example.com", + "/article", + 1, + &ec_context, + )), + auction_request: None, + }, + deps: AuctionCollectDeps { + price_granularity: PriceGranularity::default(), + ad_bids_state: &ad_bids_state, + orchestrator: &orchestrator, + services: &services, + settings: &settings, + request_origin: String::new(), + }, + }; + let mut processor = RecordingProcessor { + read_count: Arc::new(AtomicUsize::new(0)), + body_close_processed_at: Arc::new(AtomicUsize::new(0)), + }; + let html = if with_close { + "ready" + } else { + "ready" + }; + let body = + EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from_static( + html.as_bytes(), + )])); + + let error = body_close_hold_loop_stream( + body, + &mut FailingWriter { fail_flush }, + &mut processor, + Compression::None, + Compression::None, + ctx, + settings.publisher.max_buffered_body_bytes, + Some(b"".to_vec()), + ) + .await + .expect_err("should propagate the sink failure before collecting"); + let expected_error = if fail_flush { + "Failed to flush output before auction collection" + } else { + "Failed to write encoded chunk" + }; + assert!( + format!("{error:?}").contains(expected_error), + "should preserve the sink error: {error:?}" + ); + + let batches = sink.batches.lock().expect("should lock telemetry batches"); + let summaries: Vec<_> = batches + .iter() + .flat_map(crate::auction::telemetry::AuctionEventBatch::rows) + .filter(|row| row.event_kind == "summary") + .collect(); + assert_eq!( + summaries.len(), + 1, + "should emit one terminal summary for fail_flush={fail_flush}, with_close={with_close}" + ); + assert_eq!( + summaries[0].terminal_status.as_deref(), + Some("abandoned"), + "should abandon the uncollected auction" + ); + assert_eq!( + summaries[0].terminal_reason.as_deref(), + Some("stream_write_error"), + "should classify write and flush failures consistently" + ); + } + } + } + #[tokio::test] async fn hold_step_yields_ready_prefix_before_collecting_auction() { // A small page whose `` lands in the first source chunk must @@ -15949,7 +16057,7 @@ mod tests { "auction must not be collected while the ready prefix is emitted" ); - let tail = hold_collect_close_tail(&mut processor, &mut encoder, &mut state, &collect_refs) + let tail = hold_collect_close_tail(&mut encoder, &mut state, &collect_refs) .await .expect("collect should succeed"); let tail_bytes: Vec = tail.iter().flat_map(|b| b.to_vec()).collect(); @@ -18031,6 +18139,268 @@ mod tests { ); } + struct GatedAuctionHttpClient { + inner: StubHttpClient, + released: std::sync::atomic::AtomicBool, + collections: AtomicUsize, + } + + #[async_trait::async_trait(?Send)] + impl crate::platform::PlatformHttpClient for GatedAuctionHttpClient { + async fn send( + &self, + request: crate::platform::PlatformHttpRequest, + ) -> Result> + { + self.inner.send(request).await + } + + async fn send_async( + &self, + request: crate::platform::PlatformHttpRequest, + ) -> Result> + { + self.inner.send_async(request).await + } + + async fn select( + &self, + pending: Vec, + ) -> Result> + { + self.collections.fetch_add(1, Ordering::SeqCst); + futures::future::poll_fn(|_| { + if self.released.load(Ordering::SeqCst) { + std::task::Poll::Ready(()) + } else { + std::task::Poll::Pending + } + }) + .await; + self.inner.select(pending).await + } + } + + struct GatedAuctionProvider; + + #[async_trait::async_trait(?Send)] + impl AuctionProvider for GatedAuctionProvider { + fn provider_name(&self) -> &'static str { + "seam-test" + } + + async fn request_bids( + &self, + _request: &AuctionRequest, + context: &AuctionContext<'_>, + ) -> Result> { + context + .services + .http_client() + .send_async(crate::platform::PlatformHttpRequest::new( + Request::builder() + .uri("https://bidder.example.com/bid") + .body(EdgeBody::empty()) + .expect("should build test bid request"), + "seam-test", + )) + .await + .change_context(TrustedServerError::Auction { + message: "Failed to dispatch test auction".to_string(), + }) + .map(ProviderRequestOutcome::pending) + } + + async fn parse_response( + &self, + _response: crate::platform::PlatformResponse, + response_time_ms: u64, + ) -> Result> { + Ok(AuctionResponse::success( + "seam-test", + Vec::new(), + response_time_ms, + )) + } + + fn timeout_ms(&self) -> u32 { + 60_000 + } + } + + #[test] + fn parser_confirmed_auction_seam_streams_nextjs_for_every_encoding() { + for encoding in ["", "gzip", "deflate", "br"] { + let mut settings = create_test_settings(); + settings.auction.enabled = true; + settings.auction.providers = vec!["seam-test".to_string()]; + settings.auction.timeout_ms = 60_000; + settings.auction.mediator = None; + settings + .integrations + .insert_config( + "nextjs", + &serde_json::json!({ + "enabled": true, + "rewrite_attributes": ["href", "link", "url"], + }), + ) + .expect("should enable Next.js"); + let client = Arc::new(GatedAuctionHttpClient { + inner: StubHttpClient::new(), + released: std::sync::atomic::AtomicBool::new(false), + collections: AtomicUsize::new(0), + }); + client.inner.push_response(200, Vec::new()); + let services = build_services_with_http_client(Arc::clone(&client) as _); + let mut orchestrator = AuctionOrchestrator::new(settings.auction.clone()); + orchestrator.register_provider(Arc::new(GatedAuctionProvider)); + let request = Request::new(EdgeBody::empty()); + let dispatched = futures::executor::block_on(orchestrator.dispatch_auction( + &test_auction_request(), + &AuctionContext { + settings: &settings, + request: &request, + timeout_ms: 60_000, + provider_responses: None, + services: &services, + }, + )); + let crate::auction::orchestrator::DispatchAuctionOutcome::Dispatched(dispatched) = + dispatched + else { + panic!("should dispatch a pending auction"); + }; + let payload = r#"{"url":"https://origin.example.com/path","text":""}"#; + let split = payload.find("/path").expect("should find payload split"); + let first_payload = format!("1:T{:x},{}", payload.len(), &payload[..split]); + let first_script = + serde_json::to_string(&first_payload).expect("should encode first payload"); + let second_script = + serde_json::to_string(&payload[split..]).expect("should encode second payload"); + let prefix = format!( + "

before RSC

between scripts
still streaming
" + ); + let page = format!("{prefix}"); + let encoded = match encoding { + "gzip" => [ + gzip_encode(prefix.as_bytes()), + gzip_encode(b""), + ] + .concat(), + "deflate" => deflate_encode(page.as_bytes()), + "br" => brotli_encode(page.as_bytes()), + _ => page.as_bytes().to_vec(), + }; + let params = html_stream_params(encoding, Some(dispatched)); + let state = params.ad_bids_state.clone(); + let registry = IntegrationRegistry::new(&settings).expect("should create registry"); + let response = Response::builder() + .header(header::CONTENT_TYPE, "text/html") + .body(EdgeBody::empty()) + .expect("should build response"); + let response = futures::executor::block_on(publisher_response_into_streaming_response( + PublisherResponse::Stream { + response, + body: EdgeBody::stream(futures::stream::iter(vec![bytes::Bytes::from( + encoded, + )])), + params: Box::new(params), + }, + &Method::GET, + Arc::new(settings), + ®istry, + Arc::new(orchestrator), + services, + )) + .expect("should create lazy response"); + let mut stream = response + .into_body() + .into_stream() + .expect("should retain lazy body"); + let waker = futures::task::noop_waker(); + let mut context = std::task::Context::from_waker(&waker); + let mut output = Vec::new(); + loop { + match futures::Stream::poll_next(stream.as_mut(), &mut context) { + std::task::Poll::Ready(Some(Ok(chunk))) => output.extend_from_slice(&chunk), + std::task::Poll::Pending => break, + other => { + panic!("should wait on the unresolved auction for {encoding}: {other:?}") + } + } + } + let mut decoder = + BodyStreamDecoder::new(Compression::from_content_encoding(encoding), 1024 * 1024); + let decoded = decoder + .decode_chunk(bytes::Bytes::from(output.clone())) + .expect("should decode flushed prefix"); + let prefix = String::from_utf8(decoded.to_vec()).expect("should decode UTF-8 prefix"); + assert!( + prefix.contains("still streaming") && prefix.contains(""), + "should emit false literal and later article before auction completes for {encoding}: {prefix}" + ); + assert!( + prefix.contains("proxy.example.com") && !prefix.contains("origin.example.com"), + "should rewrite the split RSC group for {encoding}: {prefix}" + ); + assert!( + !prefix.contains("var b=JSON.parse("), + "should hold bids until auction completes" + ); + assert_eq!( + client.collections.load(Ordering::SeqCst), + 1, + "should begin collection at the real seam" + ); + assert!( + state + .script_cell() + .lock() + .expect("should lock bids") + .is_none(), + "should keep auction unresolved at seam" + ); + client.released.store(true, Ordering::SeqCst); + futures::executor::block_on(async { + while let Some(chunk) = futures::StreamExt::next(&mut stream).await { + output.extend_from_slice(&chunk.expect("should stream completed auction")); + } + }); + let decoded = match encoding { + "gzip" => gzip_decode(&output), + "deflate" => deflate_decode(&output), + "br" => brotli_decode(&output), + _ => output, + }; + let html = String::from_utf8(decoded).expect("should emit UTF-8 HTML"); + let bids = html + .find("var b=JSON.parse(") + .expect("should inject collected bids"); + let close = html + .rfind("") + .expect("should retain real body close"); + assert!( + html.find("still streaming").expect("should retain article") < bids && bids < close, + "should inject bids only before the real close for {encoding}" + ); + assert_eq!( + html.matches("var b=JSON.parse(").count(), + 1, + "should inject once" + ); + assert!( + !html.contains("ts-inline-body-close-") && !html.contains("__ts_rsc_"), + "should remove internal markers for {encoding}: {html}" + ); + assert_eq!( + client.collections.load(Ordering::SeqCst), + 1, + "should collect once" + ); + } + } + // (method, status, expected Content-Length, expected Transfer-Encoding) // after bodiless normalization. 204 forbids Content-Length (removed); 205 // must advertise a zero-length body; HEAD and 304 legitimately advertise the diff --git a/crates/trusted-server-integration-tests/Cargo.toml b/crates/trusted-server-integration-tests/Cargo.toml index f2319fec8..cb617ace2 100644 --- a/crates/trusted-server-integration-tests/Cargo.toml +++ b/crates/trusted-server-integration-tests/Cargo.toml @@ -26,6 +26,7 @@ serde_json = { workspace = true } trusted-server-core = { workspace = true } [dev-dependencies] +async-trait = { workspace = true } axum = { workspace = true } bytes = { workspace = true } derive_more = { workspace = true } diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index acf7f5f4b..cfad745d1 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -8,16 +8,30 @@ // Both adapters define `TrustedServerApp` — alias both to avoid name collision. // axum::http re-exports from the `http` crate, so HeaderMap types are identical. +use std::net::IpAddr; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + use axum::body::Body as AxumBody; use axum::http::Request as AxumRequest; use edgezero_adapter_axum::service::EdgeZeroAxumService; use edgezero_core::http::request_builder; use edgezero_core::router::RouterService; +use error_stack::Report; use http::HeaderMap; use tower::{Service as _, ServiceExt as _}; use trusted_server_adapter_axum::app::TrustedServerApp as AxumApp; +use trusted_server_adapter_axum::platform::{ + AxumPlatformBackend, AxumPlatformConfigStore, AxumPlatformSecretStore, +}; use trusted_server_adapter_cloudflare::app::TrustedServerApp as CloudflareApp; use trusted_server_adapter_spin::app::TrustedServerApp as SpinApp; +use trusted_server_core::platform::{ + ClientInfo, GeoInfo, PlatformError, PlatformGeo, PlatformHttpClient, PlatformHttpRequest, + PlatformPendingRequest, PlatformResponse, PlatformSelectResult, RuntimeServices, +}; use trusted_server_core::settings::Settings; /// Shared test settings for all adapters. @@ -919,3 +933,256 @@ async fn legacy_admin_aliases_are_denied_locally_not_proxied() { } } } + +/// A known non-regulated location permits the fixture's server-side auction. +struct ParityGeo; + +impl PlatformGeo for ParityGeo { + fn lookup(&self, _client_ip: Option) -> Result, Report> { + Ok(Some(GeoInfo { + country: "AU".to_owned(), + city: "Example City".to_owned(), + continent: "Oceania".to_owned(), + latitude: 0.0, + longitude: 0.0, + metro_code: 0, + region: None, + asn: None, + })) + } +} + +struct NextJsAuctionOrigin { + auction_requests: AtomicUsize, +} + +fn nextjs_auction_origin_html() -> String { + let content = r#"{"url":"https://origin.test-publisher.example.com/app","text":""}"#; + let split = content.find("/app").expect("should locate content split"); + let first = serde_json::json!(format!("1:T{:x},{}", content.len(), &content[..split])); + let second = serde_json::json!(&content[split..]); + format!( + "

prefix

suffix

" + ) +} + +#[async_trait::async_trait(?Send)] +impl PlatformHttpClient for NextJsAuctionOrigin { + async fn send( + &self, + request: PlatformHttpRequest, + ) -> Result> { + let (content_type, body) = match request.request.uri().host() { + Some("origin.test-publisher.example.com") => { + ("text/html", nextjs_auction_origin_html()) + } + Some("auction.example.com") => { + self.auction_requests.fetch_add(1, Ordering::SeqCst); + ( + "application/json", + serde_json::json!({ + "id": "parity-auction", + "seatbid": [{"seat": "example", "bid": [{ + "id": "parity-bid", "impid": "parity-slot", "price": 1.25, + "adm": "
parity-creative
", "w": 300, "h": 250, + "crid": "example-creative", "adomain": ["advertiser.example.com"] + }]}] + }) + .to_string(), + ) + } + host => { + return Err(Report::new(PlatformError::HttpClient) + .attach(format!("unexpected fixture upstream: {host:?}"))); + } + }; + Ok(PlatformResponse::new( + http::Response::builder() + .status(200) + .header("content-type", content_type) + .body(edgezero_core::body::Body::from(body)) + .expect("should build deterministic upstream response"), + ) + .with_backend_name(request.backend_name)) + } + + async fn send_async( + &self, + request: PlatformHttpRequest, + ) -> Result> { + let backend = request.backend_name.clone(); + Ok(PlatformPendingRequest::new(request).with_backend_name(backend)) + } + + async fn select( + &self, + mut pending_requests: Vec, + ) -> Result> { + let request = pending_requests + .remove(0) + .downcast::() + .expect("should recover fixture pending request"); + Ok(PlatformSelectResult { + ready: self.send(request).await, + remaining: pending_requests, + failed_backend_name: None, + }) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn adapter_buffers_nextjs_auction_output() { + let mut settings = test_settings(); + settings + .integrations + .insert("nextjs".to_owned(), serde_json::json!({"enabled": true})); + settings.integrations.insert( + "adserver_mock".to_owned(), + serde_json::json!({ + "enabled": true, "endpoint": "https://auction.example.com/mediate", "timeout_ms": 5000 + }), + ); + settings.auction.enabled = true; + settings.auction.providers = vec!["adserver_mock".to_owned()]; + settings.creative_opportunities = Some( + toml::from_str( + r#" + gam_network_id = "12345" + [[slot]] + id = "parity-slot" + page_patterns = ["/article"] + formats = [{ width = 300, height = 250 }] + "#, + ) + .expect("should parse fixture creative opportunities"), + ); + let client = Arc::new(NextJsAuctionOrigin { + auction_requests: AtomicUsize::new(0), + }); + let services = RuntimeServices::builder() + .config_store(Arc::new(AxumPlatformConfigStore)) + .secret_store(Arc::new(AxumPlatformSecretStore)) + .kv_store(Arc::new(trusted_server_core::platform::UnavailableKvStore)) + .backend(Arc::new(AxumPlatformBackend)) + .http_client(client.clone()) + .geo(Arc::new(ParityGeo)) + .client_info(ClientInfo::default()) + .build(); + let routers = [ + ( + "Axum", + AxumApp::routes_with_settings_and_services(settings.clone(), services.clone()), + ), + ( + "Cloudflare", + CloudflareApp::routes_with_settings_and_services(settings.clone(), services.clone()), + ), + ( + "Spin", + SpinApp::routes_with_settings_and_services(settings, services), + ), + ]; + let mut expected_html = None; + for (index, (adapter, router)) in routers.into_iter().enumerate() { + let request = request_builder() + .method("GET") + .uri("https://test-publisher.example.com/article") + .header("host", "test-publisher.example.com") + .header("accept", "text/html") + .body(edgezero_core::body::Body::empty()) + .expect("should build publisher navigation"); + let response = router + .expect("should build router with fixed services") + .oneshot(request) + .await + .expect("should serve publisher navigation"); + assert_eq!( + response.status(), + 200, + "{adapter} should serve fixture HTML" + ); + let body = response + .into_body() + .into_bytes() + .expect("should buffer adapter output"); + let html = String::from_utf8(body.to_vec()).expect("should emit UTF-8 HTML"); + assert_eq!( + client.auction_requests.load(Ordering::SeqCst), + index + 1, + "{adapter} should dispatch one auction" + ); + let content = r#"{"url":"http://test-publisher.example.com/app","text":""}"#; + let document = scraper::Html::parse_document(&html); + let scripts = scraper::Selector::parse("script").expect("should parse script selector"); + let payload: String = document + .select(&scripts) + .filter_map(|script| { + let text: String = script.text().collect(); + let array = text + .strip_prefix("self.__next_f.push(")? + .strip_suffix(')')?; + let push: serde_json::Value = + serde_json::from_str(array).expect("should retain valid Flight push JSON"); + Some( + push[1] + .as_str() + .expect("should retain Flight string payload") + .to_owned(), + ) + }) + .collect(); + assert_eq!( + payload, + format!("1:T{:x},{}", content.len(), content), + "{adapter} should rewrite the URL and T length while preserving complete payload bytes" + ); + assert!( + !html.contains("origin.test-publisher.example.com/app"), + "{adapter} should remove the origin URL" + ); + let first = html + .find("self.__next_f.push") + .expect("should retain first RSC script"); + let between = html + .find("window.between=true") + .expect("should retain intervening script"); + let last = html + .rfind("self.__next_f.push") + .expect("should retain last RSC script"); + assert!( + first < between && between < last, + "{adapter} should preserve script order" + ); + let bids = html + .find("var b=JSON.parse(") + .expect("should inject auction bids"); + let suffix = html.find("

suffix

").expect("should retain suffix"); + let close = html + .rfind("") + .expect("should retain structural close"); + assert!( + suffix < bids && bids < close, + "{adapter} should inject bids at the structural body close" + ); + assert!( + html.contains("parity-creative"), + "{adapter} should include deterministic auction creative" + ); + assert!( + html[bids..].ends_with(""), + "{adapter} should place bid markup immediately before the body close" + ); + assert!( + !html.contains("__ts_rsc_") && !html.contains("