Implement XMLHttpRequest.overrideMimeType()

Adds the overrideMimeType(mime) method.

Wires the final MIME type (override ?? response) into responseXML for
the default response type (""): when the final MIME is text/xml, the
body is lazily parsed into a Document and cached in a new
_response_xml field. Other XML MIME types (application/xml, image/svg+xml)
land in Mime.ContentType.other whose backing slices are unsafe to read
after Mime.parse returns; supporting them is left for a follow-up.

The override does not affect response / responseText today —
responseText charset decoding and the document responseType's
HTML-vs-XML parser choice are noted as follow-ups in the code.
This commit is contained in:
Pierre Tachoire
2026-06-04 10:29:36 +02:00
parent 7335dcf736
commit bf5d43c541
2 changed files with 145 additions and 6 deletions

View File

@@ -306,6 +306,98 @@
}
</script>
<script id=xhr_override_mime type=module>
{
// overrideMimeType is callable in state UNSENT (no open() yet)
const req = new XMLHttpRequest();
testing.expectEqual(0, req.readyState);
req.overrideMimeType('text/xml');
}
{
// overrideMimeType is callable in state OPENED
const req = new XMLHttpRequest();
req.open('GET', 'http://127.0.0.1:9582/xhr');
testing.expectEqual(1, req.readyState);
req.overrideMimeType('text/xml; charset=utf-8');
}
{
// Invalid mime values must NOT throw; they fall back to
// application/octet-stream per spec.
const req = new XMLHttpRequest();
req.overrideMimeType('!!!');
req.overrideMimeType('');
req.overrideMimeType('not a mime');
}
{
// After send() reaches DONE, overrideMimeType throws InvalidStateError.
const state = await testing.async();
const req = new XMLHttpRequest();
req.onload = () => state.resolve();
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.send();
await state.done(() => {
testing.expectEqual(4, req.readyState);
testing.withError((err) => {
testing.expectEqual('InvalidStateError', err.name);
}, () => {
req.overrideMimeType('text/xml');
});
});
}
{
// Override survives open() (spec: open() resets state but keeps the
// override MIME type). Re-opening then sending must not throw on the
// prior override.
const state = await testing.async();
const req = new XMLHttpRequest();
req.overrideMimeType('text/xml');
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.onload = () => state.resolve();
req.send();
await state.done(() => {
testing.expectEqual(200, req.status);
});
}
{
// End-to-end: server returns text/html, but overrideMimeType("text/xml")
// makes responseXML lazily parse the body as a Document, while
// responseText is unaffected (responseType is still the default "").
const state = await testing.async();
const req = new XMLHttpRequest();
req.overrideMimeType('text/xml');
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.onload = () => state.resolve();
req.send();
await state.done(() => {
testing.expectEqual(200, req.status);
testing.expectEqual(100, req.responseText.length);
testing.expectEqual(true, req.responseXML instanceof Document);
// Cached across calls.
testing.expectEqual(req.responseXML, req.responseXML);
});
}
{
// Without overrideMimeType, a text/html response still yields
// responseXML === null when responseType is the default.
const state = await testing.async();
const req = new XMLHttpRequest();
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.onload = () => state.resolve();
req.send();
await state.done(() => {
testing.expectEqual(200, req.status);
testing.expectEqual(null, req.responseXML);
});
}
</script>
<script id=xhr_timeout type=module>
{
// timeout property: default is 0

View File

@@ -59,6 +59,7 @@ _response_status: u16 = 0,
_response_len: ?usize = 0,
_response_url: [:0]const u8 = "",
_response_mime: ?Mime = null,
_override_mime: ?Mime = null,
_response_headers: std.ArrayList([]const u8) = .empty,
_response_type: ResponseType = .text,
@@ -200,7 +201,8 @@ pub fn open(self: *XMLHttpRequest, method_: []const u8, url: [:0]const u8) !void
self._http_response = null;
}
// Reset internal state
// Reset internal state. _override_mime intentionally survives open()
// per https://xhr.spec.whatwg.org/#the-overridemimetype()-method.
self._response = null;
self._response_data.clearRetainingCapacity();
self._response_status = 0;
@@ -223,6 +225,15 @@ pub fn setRequestHeader(self: *XMLHttpRequest, name: []const u8, value: []const
return self._request_headers.append(name, value, exec);
}
// https://xhr.spec.whatwg.org/#the-overridemimetype()-method
pub fn overrideMimeType(self: *XMLHttpRequest, mime: []const u8) !void {
if (self._ready_state == .loading or self._ready_state == .done) {
return error.InvalidStateError;
}
self._override_mime = Mime.parse(mime) catch
Mime.parse("application/octet-stream") catch unreachable;
}
pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !void {
if (comptime IS_DEBUG) {
log.debug(.http, "XMLHttpRequest.send", .{ .url = self._url });
@@ -338,6 +349,10 @@ pub fn setResponseType(self: *XMLHttpRequest, value: []const u8) void {
}
pub fn getResponseText(self: *const XMLHttpRequest) []const u8 {
// TODO: per WHATWG XHR "get a text response", the bytes must be decoded
// using the final encoding derived from the final MIME type
// (_override_mime ?? _response_mime). Currently the raw bytes are
// returned and V8 treats them as UTF-8.
return self._response_data.items;
}
@@ -373,6 +388,12 @@ pub fn getResponse(self: *XMLHttpRequest, exec: *const Execution) !?Response {
.document => blk: {
// responseType=document is only meaningful in a Frame; workers
// have no DOM. Drastically different impls -> switch on global.
//
// TODO: per WHATWG XHR "set a document response", the final MIME
// type (_override_mime ?? _response_mime) should select an XML
// parser when it is an XML MIME type, and the HTML parser
// otherwise. We only have an HTML parser today, so the body is
// always parsed as HTML regardless of the override.
switch (exec.js.global) {
.frame => |frame| {
const document = try exec._factory.node(Node.Document{ ._proto = undefined, ._type = .generic });
@@ -390,11 +411,36 @@ pub fn getResponse(self: *XMLHttpRequest, exec: *const Execution) !?Response {
}
pub fn getResponseXML(self: *XMLHttpRequest, exec: *const Execution) !?*Node.Document {
const res = (try self.getResponse(exec)) orelse return null;
return switch (res) {
.document => |doc| doc,
else => null,
};
if (self._ready_state != .done) {
return null;
}
// responseType="document": getResponse already parses + caches it.
if (self._response_type == .document) {
const res = (try self.getResponse(exec)) orelse return null;
return switch (res) {
.document => |doc| doc,
else => null,
};
}
// responseType="" (we map "" to .text — see setResponseType): lazily
// produce a Document when the final MIME type is XML, per WHATWG XHR
// "set a document response". For an HTML final MIME the spec returns
// null in this branch, so we only act on text/xml.
if (self._response_type != .text) return null;
const final = self._override_mime orelse self._response_mime orelse return null;
if (final.content_type != .text_xml) return null;
switch (exec.js.global) {
.frame => |frame| {
const document = try exec._factory.node(Node.Document{ ._proto = undefined, ._type = .generic });
try frame.parseHtmlAsChildren(document.asNode(), self._response_data.items);
return document;
},
.worker => return error.NotSupportedInWorker,
}
}
fn httpStartCallback(response: HttpClient.Response) !void {
@@ -624,6 +670,7 @@ pub const JsApi = struct {
pub const responseXML = bridge.accessor(XMLHttpRequest.getResponseXML, null, .{});
pub const responseURL = bridge.accessor(XMLHttpRequest.getResponseURL, null, .{});
pub const setRequestHeader = bridge.function(XMLHttpRequest.setRequestHeader, .{ .dom_exception = true });
pub const overrideMimeType = bridge.function(XMLHttpRequest.overrideMimeType, .{ .dom_exception = true });
pub const getResponseHeader = bridge.function(XMLHttpRequest.getResponseHeader, .{});
pub const getAllResponseHeaders = bridge.function(XMLHttpRequest.getAllResponseHeaders, .{});
pub const abort = bridge.function(XMLHttpRequest.abort, .{});