From 532726ecde171d2b755fa5c99d938c1918b9f0e8 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 21 Aug 2026 23:07:16 +0200 Subject: [PATCH] perf(router-core): avoid byte round-trip in SSR stream transform --- RESULT-perf-task5.md | 111 ++++++ .../src/ssr/transformStreamWithRouter.ts | 343 +++++++++++++----- .../tests/closing-tag-detection.bench.ts | 198 ++++++++++ .../tests/transformStreamWithRouter.bench.ts | 143 ++++++++ .../tests/transformStreamWithRouter.test.ts | 146 ++++++++ 5 files changed, 842 insertions(+), 99 deletions(-) create mode 100644 RESULT-perf-task5.md create mode 100644 packages/router-core/tests/transformStreamWithRouter.bench.ts diff --git a/RESULT-perf-task5.md b/RESULT-perf-task5.md new file mode 100644 index 00000000000..03cb850a8a6 --- /dev/null +++ b/RESULT-perf-task5.md @@ -0,0 +1,111 @@ +# RESULT — perf/task5-stream-byte-passthrough + +## Goal + +Eliminate the UTF-8 decode → scan → re-encode round trip in the SSR stream +transformer (`transformStreamWithRouter`, main path). Previously every app +chunk was decoded to a JS string (`TextDecoder`), scanned for closing tags / +the barrier marker, then re-encoded (`TextEncoder`) at enqueue time. The +optimization keeps app bytes as raw `Uint8Array` end-to-end. + +## Design + +- **Byte-level scanner** (`findHtmlBoundaryBytes`): direct port of + `findHtmlBoundary` operating on `Uint8Array`. Legal because closing tags and + tag names are pure ASCII, and no multi-byte UTF-8 sequence contains a byte + `< 0x80`, so byte-level matching can neither produce false positives inside + multi-byte characters nor split them. +- **Pending body buffer**: raw bytes not yet emitted downstream (replaces the + `leftover` string + `pendingTail` string). Grows geometrically, compacts via + `copyWithin`. Emission is always a `.slice()` copy, so upstream-owned buffers + are never aliased into our output queue. String chunks (rare; some upstreams) + are encoded directly into the buffer with `encodeInto`. +- **Barrier-marker scan on written ranges only** (`scanWrittenPrefixForMarker` + + `byteRangeContains`): preserves the old semantics of marking the barrier ID + "seen" only once its bytes have actually been flushed downstream (lifting the + barrier earlier could let injections land inside the marker script tag). The + search window is rewound by `needle.length - 1` bytes per flush so a marker + split across two *written* ranges is now detected too (previously missed by + the old per-chunk `String.includes`). +- **Output queue polymorphism**: `pendingWrites: Array`. + Router HTML still arrives/queues as strings (encoded at enqueue time); app + bytes enqueue verbatim. Backpressure accounting (`MAX_PENDING_WRITE_CHARS`) + now mixes chars and bytes — an approximate bound, acceptable for a safety cap. +- Tail capture (`` onward), leftover bounds (`MAX_LEFTOVER_CHARS`), + tail bounds (`MAX_TAIL_CHARS`), finish ordering (leftover → router HTML → + tail) are byte-for-byte ports of the previous logic. + +## Multibyte correctness argument + +1. UTF-8 guarantees: lead bytes are `0xC2–0xF4`, continuation bytes + `0x80–0xBF`. Every ASCII byte (`< 0x80`) encodes to itself and appears + **only** as itself. Hence: + - scanning for ASCII patterns (``, tag-name charset, barrier ID) + never matches a byte inside a multi-byte sequence; + - slicing/emitting at closing-tag boundaries (ASCII positions) can never + cut a multi-byte character; + - compaction (`copyWithin`) and append (`set`) are byte-exact moves. +2. Split-across-chunk sequences are safe because nothing is decoded: chunks are + concatenated as raw bytes in `pendingBody`; a sequence split across reads is + simply contiguous bytes by the time any boundary decision is made. +3. String chunks use `encodeInto` with capacity `3 * length + 1` ≥ max UTF-8 + size (surrogate pair = 2 code units ≤ 6 bytes); `encodeInto` never splits + surrogate pairs. +4. Regression test added: `'é'` (2 B) and `'🎉'` (4 B) each split mid-sequence + across chunk boundaries round-trip byte-identically. + +## Audit findings & fixes made during this session + +- **Bug fixed**: in the `` branch, `state = MergeState.HoldingTail` was + set *before* `emitBodyPrefix(bodyEndIndex, /* scanMarker */ true)`, but the + marker scan is gated on `state < HoldingTail`. If the marker script and + `` completed in the same upstream chunk, the marker was never marked + seen and `liftScriptBarrier()` never fired (router scripts silently dropped). + Fixed by emitting/scanning while still in `ReadingBody`, then transitioning + state; regression test added (`detects barrier marker arriving in the same + chunk as `). +- **Lint fix**: removed two unnecessary `as Uint8Array` casts in + `appendToPendingBody`. +- **Type fix in bench fake**: `takeBufferedHtml` needed an explicit + `string | undefined` return type. +- Verified equivalent-to-old behavior for: closing tags split across chunks, + injection immediately after barrier lift at a boundary, MAX_LEFTOVER forced + flushes, no-`` termination ordering (leftover before injected HTML, + tail last), and out-of-bounds reads in the byte scanner (return `undefined`, + failing all comparisons). + +## Benchmarks + +Node v22, vitest bench, Linux. Baseline = same bench files against stashed +original implementation. Full runs saved at `/tmp/opencode/bench-before.txt` +and `/tmp/opencode/bench-after.txt`. + +### End-to-end transform throughput (`transformStreamWithRouter.bench.ts`) + +| Scenario | Before | After | Δ | +|---|---:|---:|---:| +| Large body passthrough (~2 MB, 256×8 KB chunks) | 385 ops/s | 423 ops/s | **+9.9%** | +| Small body with frequent injections | 12,424 ops/s | 18,864 ops/s | **+51.8%** | +| Fast path passthrough (control, unchanged code) | 7,023 ops/s | 7,396 ops/s | +5.3% (noise) | + +### Boundary scanner micro-benches (`closing-tag-detection.bench.ts`, new section) + +| Scenario | String impl | Byte impl | Δ | +|---|---:|---:|---:| +| Small chunk (~70 B), last-closing-tag scan | 26.7 M ops/s | 28.3 M ops/s | +6% | +| Medium chunk (~1.5 KB), lazy boundary scan | 0.94 M ops/s | 1.50 M ops/s | **+60%** | +| Medium chunk (~1.5 KB), last-closing-tag scan | 26.8 M ops/s | 28.7 M ops/s | +7% | +| Large chunk w/o `` (~13 KB), lazy boundary scan | 126 K ops/s | 198 K ops/s | **+57%** | +| Large chunk w/o `` (~13 KB), last-closing-tag scan | 26.7 M ops/s | 28.5 M ops/s | +7% | + +The byte-level implementations were cross-verified against the string +implementations over 14 generated/adversarial cases at bench-module load time +(`verifyByteImplementations()` asserts identical results). + +## Verification status + +- `pnpm nx run @tanstack/router-core:test:unit` — **pass** + (106 files, 1610 passed / 3 expected fail; includes 4 new tests) +- `pnpm nx run @tanstack/router-core:test:eslint` — **pass** +- `pnpm nx run @tanstack/router-core:test:types` — **pass** +- Prettier check on changed files — **clean** diff --git a/packages/router-core/src/ssr/transformStreamWithRouter.ts b/packages/router-core/src/ssr/transformStreamWithRouter.ts index 03c6ecf2e13..a51c632c25d 100644 --- a/packages/router-core/src/ssr/transformStreamWithRouter.ts +++ b/packages/router-core/src/ssr/transformStreamWithRouter.ts @@ -64,45 +64,60 @@ type MergeState = (typeof MergeState)[keyof typeof MergeState] // Module-level encoder (stateless, safe to reuse) const textEncoder = new TextEncoder() +// ASCII bytes of the barrier marker; safe to search for in raw UTF-8 because +// multi-byte sequences never contain ASCII (< 0x80) continuation bytes. +const BARRIER_MARKER_BYTES = textEncoder.encode(TSR_SCRIPT_BARRIER_ID) + const noop = () => {} const resolvedPromise = Promise.resolve() +// Byte-level port of findHtmlBoundary operating directly on UTF-8 bytes. +// Closing tags are pure ASCII, and no multi-byte UTF-8 sequence contains a +// byte < 0x80, so scanning/slicing at byte positions can never corrupt text. // Returns -bodyEndIndex - 2 when is found; otherwise returns // the position after the last valid closing tag, or -1 when none exists. -function findHtmlBoundary(str: string): number { +function findHtmlBoundaryBytes(buf: Uint8Array, len: number): number { let lastClosingTagEnd = -1 - let searchFrom = str.length - MIN_CLOSING_TAG_LENGTH + let searchFrom = len - MIN_CLOSING_TAG_LENGTH while (searchFrom >= 0) { - const openSlash = str.lastIndexOf('= 0; i--) { + if (buf[i] === 60 && buf[i + 1] === 47) { + openSlash = i + break + } + } if (openSlash === -1) break // Fast case-insensitive match for . Negative return encodes the - // body start index without allocating a result object. + // body start index without allocating a result object. Out-of-bounds + // reads yield undefined, which fails every comparison below. if ( - (str.charCodeAt(openSlash + 2) | 32) === 98 && - (str.charCodeAt(openSlash + 3) | 32) === 111 && - (str.charCodeAt(openSlash + 4) | 32) === 100 && - (str.charCodeAt(openSlash + 5) | 32) === 121 && - str.charCodeAt(openSlash + 6) === 62 + ((buf[openSlash + 2] as number) | 32) === 98 && + ((buf[openSlash + 3] as number) | 32) === 111 && + ((buf[openSlash + 4] as number) | 32) === 100 && + ((buf[openSlash + 5] as number) | 32) === 121 && + buf[openSlash + 6] === 62 ) { return -openSlash - 2 } if (lastClosingTagEnd === -1) { let i = openSlash + 2 - const startCode = str.charCodeAt(i) + const startCode = buf[i] if ( - (startCode >= 97 && startCode <= 122) || - (startCode >= 65 && startCode <= 90) + (startCode! >= 97 && startCode! <= 122) || + (startCode! >= 65 && startCode! <= 90) ) { i++ - while (i < str.length) { - const code = str.charCodeAt(i) + while (i < len) { + const code = buf[i] if ( - (code >= 97 && code <= 122) || // a-z - (code >= 65 && code <= 90) || // A-Z - (code >= 48 && code <= 57) || // 0-9 + (code! >= 97 && code! <= 122) || // a-z + (code! >= 65 && code! <= 90) || // A-Z + (code! >= 48 && code! <= 57) || // 0-9 code === 95 || // _ code === 58 || // : code === 46 || // . @@ -114,7 +129,7 @@ function findHtmlBoundary(str: string): number { } } - if (str.charCodeAt(i) === 62) { + if (i < len && buf[i] === 62) { lastClosingTagEnd = i + 1 } } @@ -126,6 +141,34 @@ function findHtmlBoundary(str: string): number { return lastClosingTagEnd } +/** + * Search buf[from, to) for `needle` using typed-array indexOf hops to the + * first needle byte (memchr-backed in V8). + */ +function byteRangeContains( + buf: Uint8Array, + from: number, + to: number, + needle: Uint8Array, +): boolean { + const n = needle.length + if (n === 0 || to - from < n) return false + const first = needle[0]! + let i = buf.indexOf(first, from) + while (i !== -1 && i <= to - n) { + let match = true + for (let j = 1; j < n; j++) { + if (buf[i + j] !== needle[j]) { + match = false + break + } + } + if (match) return true + i = buf.indexOf(first, i + 1) + } + return false +} + /** * Releasing the lock can throw if a pending read is still settling or if the * lock was already released. @@ -417,21 +460,22 @@ function makeMainStream( const notifyAbort = createAbortNotifier(opts) // Single output queue: app chunks + router-injected HTML/scripts. - // Stored as STRINGS to avoid holding native-backed Uint8Arrays in our queue - // while waiting for downstream capacity. Encoding happens at enqueue time - // (drainPending) so the bytes live only inside the controller's internal - // queue, not in two places. + // App chunks are stored as RAW BYTES and enqueued verbatim (no + // decode/re-encode round trip); router HTML arrives as strings from + // serverSsr and is encoded once at enqueue time. This keeps native-backed + // Uint8Arrays only inside the controller's internal queue, not in two + // places. // // Uses an index pointer instead of Array.prototype.shift() (which is O(n)) // so many small router-injected script chunks stay O(1) per chunk. - const pendingWrites: Array = [] + const pendingWrites: Array = [] let pendingWriteHead = 0 - let pendingWriteChars = 0 + let pendingWriteSize = 0 function clearPending() { pendingWrites.length = 0 pendingWriteHead = 0 - pendingWriteChars = 0 + pendingWriteSize = 0 } // Backpressure: pull() resolves drainResolve to let the read loop advance. @@ -459,9 +503,11 @@ function makeMainStream( // Release reference for GC; compact when fully drained. pendingWrites[pendingWriteHead] = '' pendingWriteHead++ - pendingWriteChars -= next.length + pendingWriteSize -= next.length try { - controller.enqueue(textEncoder.encode(next)) + controller.enqueue( + typeof next === 'string' ? textEncoder.encode(next) : next, + ) } catch (error) { safeError(error) cleanup(error) @@ -485,19 +531,39 @@ function makeMainStream( * Enqueue a string chunk through the backpressure queue. Stored as a * string and encoded only when the downstream actually accepts the chunk * — keeps native-memory pressure inside the controller's queue (which - * honors desiredSize) rather than ours. + * honors desiredSize) rather than ours. Used for router-injected HTML, + * which arrives as strings from serverSsr. */ function writeChunk(chunk: string) { if (cleanedUp || isDone()) return if (!chunk.length) return - if (pendingWriteChars + chunk.length > MAX_PENDING_WRITE_CHARS) { + if (pendingWriteSize + chunk.length > MAX_PENDING_WRITE_CHARS) { const err = new Error('SSR stream pending output exceeded maximum buffer') safeError(err) cleanup(err) return } pendingWrites.push(chunk) - pendingWriteChars += chunk.length + pendingWriteSize += chunk.length + drainPending() + } + + /** + * Enqueue raw app bytes verbatim — no decode/encode round trip. The bytes + * must not alias upstream-owned buffers, which is guaranteed because all + * writes come from `pendingBody.slice(...)` copies. + */ + function writeRawBytes(bytes: Uint8Array) { + if (cleanedUp || isDone()) return + if (!bytes.length) return + if (pendingWriteSize + bytes.length > MAX_PENDING_WRITE_CHARS) { + const err = new Error('SSR stream pending output exceeded maximum buffer') + safeError(err) + cleanup(err) + return + } + pendingWrites.push(bytes) + pendingWriteSize += bytes.length drainPending() } @@ -549,8 +615,7 @@ function makeMainStream( } clearPendingRouterHtml() - leftover = '' - pendingTail = '' + resetPendingBody() clearPending() if (cancelReader) { @@ -570,30 +635,123 @@ function makeMainStream( return readerDone } - const textDecoder = new TextDecoder() + // ===================================================================== + // Pending body buffer: raw UTF-8 bytes not yet emitted downstream. + // + // Before is seen this holds the "leftover" bytes since the last + // closing-tag boundary; afterwards it holds the captured tail. All app + // bytes stay encoded exactly once (as produced upstream) and are enqueued + // verbatim — the decoder/encoder round trip is gone entirely. + // + // Closing tags and the barrier marker are pure ASCII, and multi-byte + // UTF-8 sequences never contain bytes < 0x80, so byte-level scanning and + // byte-offset slicing are always text-safe. + // ===================================================================== + let pendingBody = new Uint8Array(8 * 1024) + let pendingBodyLen = 0 + + // Absolute stream offset of pendingBody[0]; used to reason about which + // bytes have already been scanned for the barrier marker. + let regionStartAbs = 0 + // Every byte at absolute offset < markerScannedAbs has been included in a + // barrier-marker search over a WRITTEN range (matching the previous + // behavior of scanning only flushed chunks). + let markerScannedAbs = 0 + // Total tail bytes held while state >= HoldingTail. + let tailBytes = 0 + + function resetPendingBody() { + pendingBodyLen = 0 + regionStartAbs = 0 + markerScannedAbs = 0 + tailBytes = 0 + } + + function ensurePendingBodyCapacity(needed: number) { + if (needed <= pendingBody.length) return + let cap = pendingBody.length * 2 + if (cap < needed) cap = needed + const next = new Uint8Array(cap) + if (pendingBodyLen > 0) { + next.set(pendingBody.subarray(0, pendingBodyLen)) + } + pendingBody = next + } + + /** + * Append an upstream chunk to the pending body buffer. Copies immediately + * so upstream-owned/reused Uint8Arrays are never aliased. + */ + function appendToPendingBody(value: string | Uint8Array) { + if (typeof value === 'string') { + ensurePendingBodyCapacity(pendingBodyLen + value.length * 3 + 1) + const res = textEncoder.encodeInto( + value, + pendingBody.subarray(pendingBodyLen), + ) + pendingBodyLen += res.written + return + } + const len = value.byteLength + ensurePendingBodyCapacity(pendingBodyLen + len) + if (len > 0) { + pendingBody.set(value, pendingBodyLen) + pendingBodyLen += len + } + } + + /** + * Search the written prefix [regionStartAbs, endRel) for the barrier + * marker. Only bytes that have actually been written downstream count as + * "seen" — lifting the barrier before the marker script has been fully + * flushed could allow injections inside the marker script tag itself. + */ + function scanWrittenPrefixForMarker(endRel: number) { + if (streamBarrierMarkerSeen) return + const endAbs = regionStartAbs + endRel + let fromAbs = markerScannedAbs - (BARRIER_MARKER_BYTES.length - 1) + if (fromAbs < regionStartAbs) fromAbs = regionStartAbs + if (endAbs > markerScannedAbs) markerScannedAbs = endAbs + if (endAbs <= fromAbs) return + if ( + byteRangeContains( + pendingBody, + fromAbs - regionStartAbs, + endRel, + BARRIER_MARKER_BYTES, + ) + ) { + streamBarrierMarkerSeen = true + } + } + + /** + * Copy out and enqueue the body prefix [0, k), then compact the buffer. + * `scanMarker` mirrors the previous behavior of checking the barrier + * marker only on chunks flushed at safe boundaries. + */ + function emitBodyPrefix(k: number, scanMarker: boolean) { + if (k <= 0 || pendingBodyLen === 0) return + if (k > pendingBodyLen) k = pendingBodyLen + if (scanMarker && state < MergeState.HoldingTail) { + scanWrittenPrefixForMarker(k) + } + const out = pendingBody.slice(0, k) + writeRawBytes(out) + pendingBody.copyWithin(0, k, pendingBodyLen) + pendingBodyLen -= k + regionStartAbs += k + } // Router-injected scripts/HTML waiting for the next safe body boundary. // Keep chunks separate so flushing does not flatten a large rope string. const pendingRouterHtml: Array = [] let pendingRouterHtmlChars = 0 - // between-chunk text buffer; keep bounded to avoid unbounded memory - let leftover = '' - - // captured bytes from onward; must stay behind router scripts. - let pendingTail = '' - let streamBarrierLifted = false let streamBarrierMarkerSeen = false let serializationFinished = false - function noteBarrierMarker(chunk: string) { - if (streamBarrierMarkerSeen) return - if (chunk.includes(TSR_SCRIPT_BARRIER_ID)) { - streamBarrierMarkerSeen = true - } - } - function liftBarrierAfterBoundary() { if (streamBarrierLifted) return if (!streamBarrierMarkerSeen) return @@ -667,13 +825,6 @@ function makeMainStream( pendingRouterHtmlChars = 0 } - function appendTail(chunk: string) { - pendingTail += chunk - if (pendingTail.length > MAX_TAIL_CHARS) { - throw new Error('SSR stream tail exceeded maximum buffer') - } - } - function waitForBackpressure() { return !!( controller && @@ -713,21 +864,26 @@ function makeMainStream( drainRouterHtml() if (cleanedUp || isDone()) return - // Flush any remaining bytes in the TextDecoder - const decoderRemainder = textDecoder.decode() - - if (leftover) writeChunk(leftover) - if (cleanedUp || isDone()) return - if (decoderRemainder) writeChunk(decoderRemainder) + // If never arrived, everything still buffered is pre-tail body + // content ("leftover") and must precede injected router HTML. + if (state < MergeState.HoldingTail && pendingBodyLen > 0) { + const out = pendingBody.slice(0, pendingBodyLen) + writeRawBytes(out) + pendingBodyLen = 0 + regionStartAbs += out.length + } if (cleanedUp || isDone()) return flushPendingRouterHtml() if (cleanedUp || isDone()) return - if (pendingTail) writeChunk(pendingTail) + // Captured tail bytes (from onward) go last, behind scripts. + if (pendingBodyLen > 0) { + const out = pendingBody.slice(0, pendingBodyLen) + writeRawBytes(out) + pendingBodyLen = 0 + regionStartAbs += out.length + } if (cleanedUp || isDone()) return - leftover = '' - pendingTail = '' - state = MergeState.Draining closeWhenDrained = true // Try immediately; if queue not drained yet, pull() will retry. @@ -814,72 +970,61 @@ function makeMainStream( if (cleanedUp || isDone()) return - const text = - typeof value === 'string' - ? value - : textDecoder.decode(value as ArrayBufferView, { stream: true }) - - const chunkString = leftover ? leftover + text : text + // Keep app bytes encoded exactly once: append raw bytes (or encode + // string chunks directly into the buffer) — no streaming decode. + const chunkStart = regionStartAbs + pendingBodyLen + appendToPendingBody(value as string | Uint8Array) + const chunkBytes = regionStartAbs + pendingBodyLen - chunkStart + if (chunkBytes === 0) continue // If we already saw , everything else is tail. Keep it bounded // and held until router scripts are ready so injection remains before . if (state >= MergeState.HoldingTail) { - appendTail(chunkString) - leftover = '' + tailBytes += chunkBytes + if (tailBytes > MAX_TAIL_CHARS) { + throw new Error('SSR stream tail exceeded maximum buffer') + } continue } - const boundary = findHtmlBoundary(chunkString) + const boundary = findHtmlBoundaryBytes(pendingBody, pendingBodyLen) if (boundary < -1) { const bodyEndIndex = -boundary - 2 - state = MergeState.HoldingTail - appendTail(chunkString.slice(bodyEndIndex)) - const bodyChunk = chunkString.slice(0, bodyEndIndex) - writeChunk(bodyChunk) + // Scan/write the body prefix while still in ReadingBody so the + // barrier marker inside it is detected (the scan is gated on + // state < HoldingTail). + emitBodyPrefix(bodyEndIndex, true) if (cleanedUp || isDone()) return - noteBarrierMarker(bodyChunk) + state = MergeState.HoldingTail + tailBytes = pendingBodyLen + if (tailBytes > MAX_TAIL_CHARS) { + throw new Error('SSR stream tail exceeded maximum buffer') + } liftBarrierAfterBoundary() if (cleanedUp || isDone()) return flushPendingRouterHtml() - leftover = '' continue } const lastClosingTagEnd = boundary if (lastClosingTagEnd > 0) { - const safeChunk = chunkString.slice(0, lastClosingTagEnd) - writeChunk(safeChunk) + emitBodyPrefix(lastClosingTagEnd, true) if (cleanedUp || isDone()) return - noteBarrierMarker(safeChunk) liftBarrierAfterBoundary() if (cleanedUp || isDone()) return flushPendingRouterHtml() - leftover = chunkString.slice(lastClosingTagEnd) - if (leftover.length > MAX_LEFTOVER_CHARS) { + if (pendingBodyLen > MAX_LEFTOVER_CHARS) { // Ensure bounded memory even if a consumer streams long text sequences // without any closing tags. This may reduce injection granularity but is correct. - noteBarrierMarker(leftover) - const flushed = leftover.slice( - 0, - leftover.length - MAX_LEFTOVER_CHARS, - ) - writeChunk(flushed) - leftover = leftover.slice(-MAX_LEFTOVER_CHARS) + emitBodyPrefix(pendingBodyLen - MAX_LEFTOVER_CHARS, true) } } else { // No closing tag found; keep small tail to handle split closing tags, // but stream older bytes to prevent unbounded buffering. - const combined = chunkString - if (combined.length > MAX_LEFTOVER_CHARS) { - noteBarrierMarker(combined) - const flushUpto = combined.length - MAX_LEFTOVER_CHARS - const flushed = combined.slice(0, flushUpto) - writeChunk(flushed) - leftover = combined.slice(flushUpto) - } else { - leftover = combined + if (pendingBodyLen > MAX_LEFTOVER_CHARS) { + emitBodyPrefix(pendingBodyLen - MAX_LEFTOVER_CHARS, true) } } } diff --git a/packages/router-core/tests/closing-tag-detection.bench.ts b/packages/router-core/tests/closing-tag-detection.bench.ts index 5a52f6c5800..92fedfe99e0 100644 --- a/packages/router-core/tests/closing-tag-detection.bench.ts +++ b/packages/router-core/tests/closing-tag-detection.bench.ts @@ -444,6 +444,120 @@ function findHtmlBoundaryLastOpenSlashLazy(str: string): BoundaryScanResult { return { bodyEndIndex: -1, lastClosingTagEnd } } +// ============================================================================ +// Byte-level variants (UTF-8 passthrough scanner): operate directly on the +// encoded bytes so no decode/re-encode round trip is needed. Valid only +// because closing tags are ASCII and UTF-8 continuation bytes are >= 0x80. +// ============================================================================ +function findLastClosingTagBytes(str: string): number { + const buf = Buffer.from(str, 'utf8') + return findLastClosingTagBytesBuf(buf, buf.length) +} + +function findLastClosingTagBytesBuf(buf: Uint8Array, len: number): number { + let i = len - 1 + + while (i >= 3) { + if (buf[i] === 62) { + let j = i - 1 + + while (j >= 1) { + const code = buf[j]! + if ( + (code >= 97 && code <= 122) || + (code >= 65 && code <= 90) || + (code >= 48 && code <= 57) || + code === 95 || + code === 58 || + code === 46 || + code === 45 + ) { + j-- + } else { + break + } + } + + const tagNameStart = j + 1 + if (tagNameStart < i) { + const startCode = buf[tagNameStart]! + if ( + (startCode >= 97 && startCode <= 122) || + (startCode >= 65 && startCode <= 90) + ) { + if (j >= 1 && buf[j] === 47 && buf[j - 1] === 60) { + return i + 1 + } + } + } + } + i-- + } + return -1 +} + +function findHtmlBoundaryBytesEncoded(buf: Uint8Array): number { + const len = buf.length + let lastClosingTagEnd = -1 + let searchFrom = len - MIN_CLOSING_TAG_LENGTH + + while (searchFrom >= 0) { + let openSlash = -1 + for (let i = searchFrom; i >= 0; i--) { + if (buf[i] === 60 && buf[i + 1] === 47) { + openSlash = i + break + } + } + if (openSlash === -1) break + + if ( + ((buf[openSlash + 2] as number) | 32) === 98 && + ((buf[openSlash + 3] as number) | 32) === 111 && + ((buf[openSlash + 4] as number) | 32) === 100 && + ((buf[openSlash + 5] as number) | 32) === 121 && + buf[openSlash + 6] === 62 + ) { + return -openSlash - 2 + } + + if (lastClosingTagEnd === -1) { + let i = openSlash + 2 + const startCode = buf[i] + if ( + (startCode! >= 97 && startCode! <= 122) || + (startCode! >= 65 && startCode! <= 90) + ) { + i++ + while (i < len) { + const code = buf[i] + if ( + (code! >= 97 && code! <= 122) || + (code! >= 65 && code! <= 90) || + (code! >= 48 && code! <= 57) || + code === 95 || + code === 58 || + code === 46 || + code === 45 + ) { + i++ + } else { + break + } + } + + if (i < len && buf[i] === 62) { + lastClosingTagEnd = i + 1 + } + } + } + + searchFrom = openSlash - 1 + } + + return lastClosingTagEnd +} + // Encoded return avoids allocation: body index => -index - 2; otherwise last closing tag end. function findHtmlBoundaryLastOpenSlashLazyEncoded(str: string): number { let lastClosingTagEnd = -1 @@ -673,6 +787,57 @@ function verifyBodyImplementations() { verifyImplementations() verifyBodyImplementations() +function verifyByteImplementations() { + const testCases = [ + generateSmallChunk(), + generateMediumChunk(), + generateLargeChunk(), + generateUppercaseBodyChunk(), + generateLargePlainTextBodyChunk(), + generateWebComponentChunk(), + generateNoClosingTagChunk(), + generatePartialChunk(), + generateNestedChunk(), + '', + '
', + '', + 'no tags here', + '', + ] + + for (const testCase of testCases) { + const stringResult = findHtmlBoundaryLastOpenSlashLazyEncoded(testCase) + const buf = Buffer.from(testCase, 'utf8') + const byteResult = findHtmlBoundaryBytesEncoded(buf) + const lastClosingString = findLastClosingTagOptimized(testCase) + const lastClosingBytes = findLastClosingTagBytesBuf(buf, buf.length) + + if (stringResult !== byteResult) { + console.error('Byte mismatch for:', testCase.slice(0, 50)) + console.error(' String:', stringResult) + console.error(' Byte:', byteResult) + throw new Error('Byte implementation mismatch!') + } + // When is found the encoded result carries no closing-tag info; + // compare closing-tag positions only in the no-body case. + if (stringResult >= -1) { + if (lastClosingString !== stringResult) { + console.error('Sanity mismatch for:', testCase.slice(0, 50)) + throw new Error('String closing-tag sanity mismatch!') + } + if (lastClosingBytes !== stringResult) { + console.error('Byte closing mismatch for:', testCase.slice(0, 50)) + console.error(' String:', stringResult) + console.error(' Byte:', lastClosingBytes) + throw new Error('Byte closing-tag implementation mismatch!') + } + } + } + console.log('All byte implementations verified to produce identical results') +} + +verifyByteImplementations() + // ============================================================================ // Benchmarks // ============================================================================ @@ -899,3 +1064,36 @@ benchBoundaryDetection( generateLargeChunkNoBody(), ) benchBoundaryDetection('Nested Chunk Without ', generateNestedChunk()) + +// ============================================================================ +// Byte-level vs string-level scanning (UTF-8 passthrough optimization) +// ============================================================================ + +function benchByteVsString(name: string, chunk: string) { + const buf = Buffer.from(chunk, 'utf8') + + describe(`Byte vs String Boundary Scan - ${name}`, () => { + bench('string: lazy encoded boundary scan', () => { + findHtmlBoundaryLastOpenSlashLazyEncoded(chunk) + }) + + bench('bytes: lazy encoded boundary scan', () => { + findHtmlBoundaryBytesEncoded(buf) + }) + + bench('string: last closing tag', () => { + findLastClosingTagOptimized(chunk) + }) + + bench('bytes: last closing tag', () => { + findLastClosingTagBytesBuf(buf, buf.length) + }) + }) +} + +benchByteVsString('Small Chunk (~70B)', generateSmallChunk()) +benchByteVsString('Medium Chunk (~1.5KB)', generateMediumChunk()) +benchByteVsString( + 'Large Chunk Without (~13KB)', + generateLargeChunkNoBody(), +) diff --git a/packages/router-core/tests/transformStreamWithRouter.bench.ts b/packages/router-core/tests/transformStreamWithRouter.bench.ts new file mode 100644 index 00000000000..fc68a3390b2 --- /dev/null +++ b/packages/router-core/tests/transformStreamWithRouter.bench.ts @@ -0,0 +1,143 @@ +import { ReadableStream } from 'node:stream/web' +import { bench, describe } from 'vitest' +import { transformStreamWithRouter } from '../src/ssr/transformStreamWithRouter' + +/** + * Full-transform benchmarks for transformStreamWithRouter (main path). + * + * Measures end-to-end throughput of the SSR stream transformer: + * - "passthrough": large HTML body streamed through the scanner with no + * router injections (worst case for per-chunk decode/encode overhead). + * - "injections": small body with frequent router HTML injections at + * closing-tag boundaries. + * + * Both scenarios deliberately force the MAIN path (reserveStreamFastPath => + * false) so the scanner pipeline is what's being measured. + */ + +function makeFakeServerSsr() { + let cleanedUp = false + return { + isSerializationFinished: () => true, + reserveStreamFastPath: () => false, + onInjectedHtml: () => () => {}, + onSerializationFinished: () => () => {}, + takeBufferedHtml: (): string | undefined => undefined, + setRenderFinished: () => {}, + cleanup: () => { + cleanedUp = true + }, + liftScriptBarrier: () => {}, + isCleanedUp: () => cleanedUp, + } +} + +function makeBenchUpstream(chunks: Array): ReadableStream { + let i = 0 + return new ReadableStream({ + pull(c) { + if (i < chunks.length) { + c.enqueue(chunks[i++]!) + } else { + c.close() + } + }, + }) +} + +async function drain(stream: ReadableStream): Promise { + const reader = stream.getReader() + let bytes = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + bytes += value!.byteLength + } + return bytes +} + +// Deterministic realistic-ish HTML body content with plenty of closing tags +// so the scanner hits boundaries frequently. +function generateBodyChunk(size: number, seed: number): string { + let html = '' + while (html.length < size) { + const open = `
` + const inner = `Label ${seed}

Some paragraph content for chunk ${seed}.

` + html += `${open}${inner}
` + seed++ + } + return html.slice(0, size) +} + +function makeChunks( + count: number, + size: number, + wrapInDocument: boolean, +): Array { + const encoder = new TextEncoder() + const chunks: Array = [] + if (wrapInDocument) { + chunks.push(encoder.encode('t')) + } + for (let i = 0; i < count; i++) { + chunks.push(encoder.encode(generateBodyChunk(size, i))) + } + if (wrapInDocument) { + chunks.push(encoder.encode('')) + } + return chunks +} + +const LARGE_CHUNKS = makeChunks(256, 8 * 1024, true) +const SMALL_CHUNKS = (() => { + const encoder = new TextEncoder() + const chunks: Array = [] + chunks.push(encoder.encode('')) + for (let i = 0; i < 50; i++) { + chunks.push(encoder.encode(`
a${i}
`)) + } + chunks.push(encoder.encode('')) + return chunks +})() + +describe('transformStreamWithRouter full transform', () => { + bench('large body passthrough (~2MB, 256x8KB chunks)', async () => { + const serverSsr = makeFakeServerSsr() + const router = { serverSsr } + const out = transformStreamWithRouter( + router as any, + makeBenchUpstream(LARGE_CHUNKS), + ) + await drain(out as any) + }) + + bench('small body with frequent injections', async () => { + const serverSsr = makeFakeServerSsr() + // Inject router HTML between app chunks: takeBufferedHtml returns a + // script for every other drain, exercising the injection splice path. + let drainCount = 0 + serverSsr.takeBufferedHtml = () => { + drainCount++ + return drainCount % 2 === 0 + ? `` + : undefined + } + const router = { serverSsr } + const out = transformStreamWithRouter( + router as any, + makeBenchUpstream(SMALL_CHUNKS), + ) + await drain(out as any) + }) + + bench('fast path passthrough (~2MB, 256x8KB chunks)', async () => { + const serverSsr = makeFakeServerSsr() + serverSsr.reserveStreamFastPath = () => true + const router = { serverSsr } + const out = transformStreamWithRouter( + router as any, + makeBenchUpstream(LARGE_CHUNKS), + ) + await drain(out as any) + }) +}) diff --git a/packages/router-core/tests/transformStreamWithRouter.test.ts b/packages/router-core/tests/transformStreamWithRouter.test.ts index 903efeb0c95..0e5a2d2800d 100644 --- a/packages/router-core/tests/transformStreamWithRouter.test.ts +++ b/packages/router-core/tests/transformStreamWithRouter.test.ts @@ -113,6 +113,7 @@ function makeRouter(opts: Partial = {}): { function makeManualUpstream(): { stream: ReadableStream push: (s: string) => void + pushBytes: (b: Uint8Array) => void close: () => void cancelled: { value: boolean; reason: unknown } } { @@ -131,6 +132,7 @@ function makeManualUpstream(): { return { stream, push: (s) => controllerRef!.enqueue(encoder.encode(s)), + pushBytes: (b) => controllerRef!.enqueue(b), close: () => controllerRef!.close(), cancelled, } @@ -433,6 +435,150 @@ describe('transformStreamWithRouter — real SSR scripts', () => { await readAll(output as any) expect(liftCalls).toBe(1) }) + + test('handles closing tags split across chunk boundaries byte-identically', async () => { + const { router, injectHtml, finishSerialization } = makeRouter({ + liftScriptBarrier: () => { + injectHtml('') + }, + }) + const upstream = makeManualUpstream() + const output = transformStreamWithRouter( + router as any, + upstream.stream as any, + ) + + upstream.push('
con') + // "
" split mid-tag-name, plus the barrier script split so the + // marker only completes in a later chunk. + upstream.push(`tent') + // Injection lands after the LAST closing tag of the completing chunk + // (

), never mid-tag, and always before . + expect(html.indexOf('')).toBeGreaterThan( + html.indexOf(''), + ) + expect(html.indexOf('')).toBeLessThan( + html.indexOf(''), + ) + }) + + test('preserves multi-byte UTF-8 sequences split across chunks', async () => { + const { router, finishSerialization } = makeRouter() + const upstream = makeManualUpstream() + const output = transformStreamWithRouter( + router as any, + upstream.stream as any, + ) + + const encoder = new TextEncoder() + // "héllo 🎉 world": 'é' is 2 bytes, '🎉' is 4 bytes. Split both + // sequences across chunk boundaries mid-sequence. + const full = '
héllo 🎉 wörld
' + const bytes = encoder.encode(full) + const cuts = [ + '
h'.length + 1, // inside 'é' (byte 1 of 2) + '
héllo '.length + 2, // inside '🎉' (byte 2 of 4) + ] + let prev = 0 + for (const cut of cuts) { + upstream.pushBytes(bytes.slice(prev, cut)) + prev = cut + } + upstream.pushBytes(bytes.slice(prev)) + upstream.close() + finishSerialization() + + const text = await readAll(output as any) + expect(text).toBe(full) + }) + + test('flushes injection immediately after barrier lifts at a closing-tag boundary', async () => { + let liftCalls = 0 + const { router, injectHtml, finishSerialization } = makeRouter({ + liftScriptBarrier: () => { + liftCalls++ + injectHtml('') + }, + }) + const upstream = makeManualUpstream() + const output = transformStreamWithRouter( + router as any, + upstream.stream as any, + ) + + // Chunk ends exactly at the barrier script's closing tag: writing this + // boundary must (a) mark the marker as seen and (b) lift the barrier, + // with the injected HTML flushed right after the written prefix. + upstream.push( + `
app
`, + ) + await flush() + + upstream.push('
after
') + upstream.push('') + upstream.close() + finishSerialization() + + const html = await readAll(output as any) + + expect(liftCalls).toBe(1) + expect(html).toContain('') + expect(html.indexOf(TSR_SCRIPT_BARRIER_ID)).toBeLessThan( + html.indexOf(''), + ) + expect(html.indexOf('')).toBeLessThan( + html.indexOf(''), + ) + expect(html).toBe( + `
app
after
`, + ) + }) + + test('detects barrier marker arriving in the same chunk as ', async () => { + let liftCalls = 0 + const { router, injectHtml, finishSerialization } = makeRouter({ + liftScriptBarrier: () => { + liftCalls++ + injectHtml('') + }, + }) + const upstream = makeManualUpstream() + const output = transformStreamWithRouter( + router as any, + upstream.stream as any, + ) + + // Marker script AND complete in a single chunk: the marker scan + // must still run on the written body prefix even though the merge state + // transitions to HoldingTail for this chunk. + upstream.push( + `
x
`, + ) + await flush() + + upstream.close() + finishSerialization() + + const html = await readAll(output as any) + + expect(liftCalls).toBe(1) + expect(html.indexOf(TSR_SCRIPT_BARRIER_ID)).toBeLessThan( + html.indexOf(''), + ) + expect(html.indexOf('')).toBeLessThan( + html.indexOf(''), + ) + }) }) describe('transformStreamWithRouter — cleanup side-effects', () => {