hi
")))); + expect(await transformed.text()).toBe("bye
"); + }); + + it("single string chunk", async () => { + // readableStreamToArrayBuffer hands a single string chunk back as a + // Uint8Array view rather than an ArrayBuffer, so this exercises a + // different branch than the binary chunk above. + const transformed = rewriter().transform(new Response(streamOf("hi
"))); + expect(await transformed.text()).toBe("bye
"); + }); + + it("an element split across chunk boundaries", async () => { + const transformed = rewriter().transform( + new Response(streamOf(encode("h"), encode("i"), encode("p>
two
"))), + ); + expect(await transformed.text()).toBe("bye
bye
"); + }); + + it("mixed string and binary chunks", async () => { + const transformed = rewriter().transform(new Response(streamOf("a
", encode("b
")))); + expect(await transformed.text()).toBe("bye
bye
"); + }); + + it("empty stream", async () => { + let endCalls = 0; + const transformed = new HTMLRewriter() + .onDocument({ + end() { + endCalls++; + }, + }) + .transform(new Response(streamOf())); + expect(await transformed.text()).toBe(""); + expect(endCalls).toBe(1); + }); + + it("a direct stream", async () => { + const body = new ReadableStream({ + type: "direct", + pull(controller) { + controller.write("hi
"); + controller.close(); + }, + }); + expect(await rewriter().transform(new Response(body)).text()).toBe("bye
"); + }); + + it("a stream that only produces chunks after transform() returns", async () => { + // start() stays pending across transform(), so the rewriter has to take + // the asynchronous path instead of buffering everything up front. + const { promise: gate, resolve: openGate } = Promise.withResolvers(); + const body = new ReadableStream({ + async start(controller) { + await gate; + controller.enqueue(encode("hi
")); + controller.close(); + }, + }); + const text = rewriter().transform(new Response(body)).text(); + openGate(); + expect(await text).toBe("bye
"); + }); + + it("every way of reading the transformed response", async () => { + const read = { + text: response => response.text(), + arrayBuffer: async response => new TextDecoder().decode(await response.arrayBuffer()), + bytes: async response => new TextDecoder().decode(await response.bytes()), + blob: response => response.blob().then(blob => blob.text()), + json: response => response.json().then(value => JSON.stringify(value)), + getReader: async response => { + const reader = response.body.getReader(); + const parts = []; + for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) { + parts.push(new TextDecoder().decode(chunk.value)); + } + return parts.join(""); + }, + readableStreamToText: response => Bun.readableStreamToText(response.body), + }; + + const html = 'hi
there
'; + const expected = 'bye
bye
'; + for (const [name, consume] of Object.entries(read)) { + const transformed = rewriter().transform(new Response(streamOf(encode(html)))); + if (name === "json") { + // Not valid JSON — but it must fail as a JSON parse error, which + // still proves the transformed bytes reached the parser. + await expect(consume(transformed)).rejects.toThrow(/JSON/i); + continue; + } + expect({ [name]: await consume(transformed) }).toEqual({ [name]: expected }); + } + }); + + it("element handlers observe the streamed document", async () => { + const tags = []; + const transformed = new HTMLRewriter() + .on("*", { + element(element) { + tags.push(element.tagName); + }, + }) + .transform(new Response(streamOf(encode("hi
hi
hi
")); + controller.error(new Error("upstream boom")); + }, + }); + const transformed = rewriter().transform(new Response(body)); + // Must reject rather than resolve with the truncated document. + await expect(transformed.text()).rejects.toThrow("upstream boom"); + }); + + it("a stream that errors after transform() returns rejects the transformed body", async () => { + const { promise: gate, resolve: openGate } = Promise.withResolvers(); + const body = new ReadableStream({ + async start(controller) { + await gate; + controller.error(new Error("late boom")); + }, + }); + const text = rewriter().transform(new Response(body)).text(); + openGate(); + await expect(text).rejects.toThrow("late boom"); + }); + + it("a chunk that is neither a string nor a view throws", async () => { + const transform = () => rewriter().transform(new Response(streamOf(42))); + // The underlying TypeError must surface, not "Failed to pipe stream". + expect(transform).toThrow(TypeError); + expect(transform).not.toThrow("Failed to pipe stream"); + }); + + it("reusing the transformed response's source stream throws", async () => { + const response = new Response(streamOf(encode("hi
"))); + expect(await rewriter().transform(response).text()).toBe("bye
"); + expect(() => rewriter().transform(response)).toThrow("Response body already used"); + }); + + it("does not rewrite out of the source buffer a handler can detach", async () => { + // readableStreamToArrayBuffer's single-chunk fast path returns the user's + // own ArrayBuffer. lol-html tokenizes its input in place and dispatches + // handlers mid-scan, so a handler that mutates (or transfers, then frees) + // that buffer must not be able to reach into the bytes still being parsed. + let chunk; + const body = new ReadableStream({ + start(controller) { + chunk = encode("xhi
")); + controller.close(); + }, 1); + }, + }), + "controller escaping to an outer scope": () => { + let escaped; + const stream = new ReadableStream({ + start(controller) { + escaped = controller; + }, + }); + queueMicrotask(() => { + escaped.enqueue(encode("hi
")); + escaped.close(); + }); + return stream; + }, + "controller reachable only from a pending pull": () => + new ReadableStream({ + type: "direct", + async pull(controller) { + await Bun.sleep(1); + controller.write("hi
"); + controller.close(); + }, + }), + }; + + for (const [name, makeStream] of Object.entries(cases)) { + it(name, async () => { + const transformed = rewriter().transform(new Response(makeStream())); + // Collect aggressively while the source is still in flight. + for (let i = 0; i < 3; i++) { + Bun.gc(true); + await Bun.sleep(1); + } + expect(await transformed.text()).toBe("bye
"); + }); + } + }); + + it("does not leak a transform whose source stream never settles", async () => { + // The bufferer must not hold a Strong to the source: the stream reaches + // the promise chain, which reaches the NativePromiseContext cell holding + // the sink's refcount, so rooting it means an abandoned transform can + // never be collected. + const abandon = () => { + const body = new ReadableStream({ + pull() { + return new Promise(() => {}); + }, + }); + rewriter().transform(new Response(body)); + }; + // BufferOutputSink's ref is released on the event loop, so drain between + // collections rather than measuring straight after Bun.gc(). + const settle = async () => { + for (let i = 0; i < 5; i++) { + Bun.gc(true); + await Bun.sleep(1); + } + }; + const live = () => heapStats().objectTypeCounts.Response ?? 0; + + for (let i = 0; i < 10; i++) abandon(); // warm up lazy structures + await settle(); + const before = live(); + for (let i = 0; i < 200; i++) abandon(); + await settle(); + // Pre-fix this grew by exactly 200 (one leaked sink per transform, each + // pinning its output Response). + expect(live() - before).toBeLessThan(20); + }); + + // Resolves with "" instead: the `.body` getter builds a ByteStream the + // producer is never told about, so done() closes it empty. Pre-existing and + // not specific to JS sources — a fetch body that is still mid-stream when + // transform() returns does the same thing on main (#19305, and #6068 for + // the Bun.serve shape, which hangs). Un-skip once the output side is fixed. + it.todo(".body of a transform whose source is still pending", async () => { + const { promise: gate, resolve: openGate } = Promise.withResolvers(); + const body = new ReadableStream({ + async start(controller) { + await gate; + controller.enqueue(encode("hi
")); + controller.close(); + }, + }); + const transformed = rewriter().transform(new Response(body)); + const reader = transformed.body.getReader(); + openGate(); + const parts = []; + for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) { + parts.push(new TextDecoder().decode(chunk.value)); + } + expect(parts.join("")).toBe("bye
"); + }); + + it("served over Bun.serve", async () => { + using server = Bun.serve({ + port: 0, + fetch() { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(encode("hello world")); + controller.close(); + }, + }); + return new HTMLRewriter() + .on("b", { + element(element) { + element.before("