diff --git a/docs/runtime/html-rewriter.mdx b/docs/runtime/html-rewriter.mdx
index ad70d1ca7f9e..044c0b0bc554 100644
--- a/docs/runtime/html-rewriter.mdx
+++ b/docs/runtime/html-rewriter.mdx
@@ -342,6 +342,7 @@ rewriter.onDocument({
When transforming a Response, HTMLRewriter:
- Preserves the status code, headers, and other response properties
+- Sets `Content-Type` from the input body when no header gives one: the `type` of a `Bun.file()` or `Blob` body, or `text/plain;charset=utf-8` for a string body
- Transforms the body while maintaining streaming capabilities
- Handles content-encoding (like gzip) automatically
- Marks the original response body as used after transformation
diff --git a/src/runtime/api/html_rewriter.rs b/src/runtime/api/html_rewriter.rs
index 59eb0e840910..c16382614269 100644
--- a/src/runtime/api/html_rewriter.rs
+++ b/src/runtime/api/html_rewriter.rs
@@ -22,6 +22,7 @@ use bun_sys::Error as SysError;
use crate::api::native_promise_context;
use crate::generated_classes::{js_HTMLRewriterTransform, js_Response};
use crate::webcore::blob::SizeType as BlobSizeType;
+use crate::webcore::response::HeadersRef;
use crate::webcore::sink::JSSink;
use crate::webcore::streams::{
self, SourceHandle, Start, StartTag, StreamError, StreamResult, Writable, WritablePending,
@@ -963,6 +964,19 @@ impl RewriterPipe {
original: &Response,
sync_only_noun: Option<&'static str>,
) -> JsResult {
+ // Taken before `wire_input` consumes the body its Content-Type may derive from (#3334).
+ let mut init = original.clone_init(global)?;
+ // A string body is `text/plain` by itself; the Response overload's output body is not.
+ if sync_only_noun.is_none() && original.get_body_value().was_string() {
+ init.headers
+ .get_or_insert_with(HeadersRef::create_empty)
+ .put_default(
+ jsc::HTTPHeaderName::ContentType,
+ &BunString::ascii(&bun_http_types::MimeType::TEXT.value),
+ global,
+ )?;
+ }
+
let pipe = bun_core::heap::alloc_nn(RewriterPipe {
global: GlobalRef::from(global),
cell: Cell::new(JSValue::ZERO),
@@ -1023,10 +1037,7 @@ impl RewriterPipe {
// the sink buffers into `output_buffer`, and `on_start_streaming`
// hands that over as `DrainResult::Owned`.
let result = bun_core::heap::alloc_nn(Response::init(
- webcore::response::Init {
- status_code: 200,
- ..Default::default()
- },
+ init,
webcore::Body::new({
let mut pv = webcore::body::PendingValue::new(global);
pv.task = Some(pipe.cast::());
@@ -1044,15 +1055,6 @@ impl RewriterPipe {
this.response
.set(Some(unsafe { RefPtr::init_ref(result.as_ptr()) }));
- result_ref.set_init(
- original.get_method(),
- original.get_init_status_code(),
- original.get_init_status_text().clone(),
- );
-
- // https://github.com/oven-sh/bun/issues/3334
- result_ref.set_init_headers(original.clone_init_headers(global)?);
-
let response_js_value = result_ref.to_js(&this.global);
// Hand ownership of `pipe` to its `JSHTMLRewriterTransform` wrapper cell.
diff --git a/src/runtime/webcore/Request.rs b/src/runtime/webcore/Request.rs
index 8ceaadd119dd..24f0599096f8 100644
--- a/src/runtime/webcore/Request.rs
+++ b/src/runtime/webcore/Request.rs
@@ -199,7 +199,7 @@ impl Request {
/// Immutable view of the body value.
#[inline]
- fn body_value(&self) -> &BodyValue {
+ pub(crate) fn body_value(&self) -> &BodyValue {
&self.body
}
@@ -1157,18 +1157,16 @@ impl Request {
}
if !fields.contains(Fields::Headers) {
- if let Some(headers) = response.get_init_headers_mut() {
- // The flag is set unconditionally once `getInitHeaders()` yielded a
- // value, even if `cloneThis` returns null — so a later arg can't
- // repopulate headers from a different source.
- match headers.clone_this(global_this) {
- Ok(h) => {
- // SAFETY: clone_this returns a +1 ref FetchHeaders.
- req.headers.set(h.map(|p| unsafe { HeadersRef::adopt(p) }));
+ // Flagged whenever the Response had headers (even if the copy came back null) so a later arg can't repopulate them.
+ let had_headers = response.get_init_headers().is_some();
+ match response.clone_headers(global_this) {
+ Ok(headers) => {
+ if had_headers || headers.is_some() {
+ req.headers.set(headers);
fields.insert(Fields::Headers);
}
- Err(e) => bail!(Err(e)),
}
+ Err(e) => bail!(Err(e)),
}
}
diff --git a/src/runtime/webcore/Response.rs b/src/runtime/webcore/Response.rs
index 79b0121bddab..5576ece31678 100644
--- a/src/runtime/webcore/Response.rs
+++ b/src/runtime/webcore/Response.rs
@@ -84,6 +84,24 @@ impl HeadersRef {
.clone_this(global)?
.map(|p| unsafe { Self::adopt(p) }))
}
+
+ /// What a lazy `.headers` getter creates for `body`: its Blob's `Content-Type` (`Bun.file()` mime, `Blob.type`), or `None`.
+ pub(crate) fn for_body(body: &BodyValue, global: &JSGlobalObject) -> JsResult