improve XMLHTTPRequest. Legacy xhr.html pass

This commit is contained in:
Karl Seguin
2025-12-15 17:56:23 +08:00
parent 9b3107d4fe
commit 1639ff1b98
7 changed files with 384 additions and 66 deletions

View File

@@ -201,7 +201,11 @@ fn promiseRejectCallback(v8_msg: v8.C_PromiseRejectMessage) callconv(.c) void {
else
"no value";
log.debug(.js, "unhandled rejection", .{ .value = value, .stack = context.stackTrace() catch |err| @errorName(err) orelse "???" });
log.debug(.js, "unhandled rejection", .{
.value = value,
.stack = context.stackTrace() catch |err| @errorName(err) orelse "???",
.note = "This should be updated to call window.unhandledrejection",
});
}
// Give it a Zig struct, get back a v8.FunctionTemplate.

View File

@@ -11,13 +11,12 @@
testing.expectEqual(cbk, req.onload);
req.onload = cbk;
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.open('GET', 'http://127.0.0.1:9589/xhr');
testing.expectEqual(0, req.status);
testing.expectEqual('', req.statusText);
testing.expectEqual('', req.getAllResponseHeaders());
testing.expectEqual(null, req.getResponseHeader('Content-Type'));
testing.expectEqual('', req.responseText);
req.send();
});
@@ -31,7 +30,6 @@
testing.expectEqual('content-length: 100\r\nContent-Type: text/html; charset=utf-8\r\n', req.getAllResponseHeaders());
testing.expectEqual(100, req.responseText.length);
testing.expectEqual(req.responseText.length, req.response.length);
testing.expectEqual(true, req.responseXML instanceof Document);
});
</script>
@@ -39,7 +37,7 @@
const req2 = new XMLHttpRequest()
const promise2 = new Promise((resolve) => {
req2.onload = resolve;
req2.open('GET', 'http://127.0.0.1:9582/xhr')
req2.open('GET', 'http://127.0.0.1:9589/xhr')
req2.responseType = 'document';
req2.send()
});
@@ -56,7 +54,7 @@
const req3 = new XMLHttpRequest()
const promise3 = new Promise((resolve) => {
req3.onload = resolve;
req3.open('GET', 'http://127.0.0.1:9582/xhr/json')
req3.open('GET', 'http://127.0.0.1:9589/xhr/json')
req3.responseType = 'json';
req3.send()
});
@@ -72,7 +70,7 @@
const req4 = new XMLHttpRequest()
const promise4 = new Promise((resolve) => {
req4.onload = resolve;
req4.open('POST', 'http://127.0.0.1:9582/xhr')
req4.open('POST', 'http://127.0.0.1:9589/xhr')
req4.send('foo')
});
@@ -94,7 +92,7 @@
}
}
req5.open('GET', 'http://127.0.0.1:9582/xhr');
req5.open('GET', 'http://127.0.0.1:9589/xhr');
req5.send();
});

View File

@@ -7,4 +7,195 @@
testing.expectEqual(2, XMLHttpRequest.HEADERS_RECEIVED);
testing.expectEqual(3, XMLHttpRequest.LOADING);
testing.expectEqual(4, XMLHttpRequest.DONE);
testing.async(async (restore) => {
const req = new XMLHttpRequest();
const event = await new Promise((resolve) => {
function cbk(event) {
resolve(event)
}
req.onload = cbk;
testing.expectEqual(cbk, req.onload);
req.onload = cbk;
req.open('GET', 'http://127.0.0.1:9582/xhr');
testing.expectEqual(0, req.status);
testing.expectEqual('', req.statusText);
testing.expectEqual('', req.getAllResponseHeaders());
testing.expectEqual(null, req.getResponseHeader('Content-Type'));
testing.expectEqual('', req.responseText);
testing.expectEqual('', req.responseURL);
req.send();
});
restore();
testing.expectEqual('load', event.type);
testing.expectEqual(true, event.loaded > 0);
testing.expectEqual(true, event instanceof ProgressEvent);
testing.expectEqual(200, req.status);
testing.expectEqual('OK', req.statusText);
testing.expectEqual('text/html; charset=utf-8', req.getResponseHeader('Content-Type'));
testing.expectEqual('content-length: 100\r\nContent-Type: text/html; charset=utf-8\r\n', req.getAllResponseHeaders());
testing.expectEqual(100, req.responseText.length);
testing.expectEqual(req.responseText.length, req.response.length);
testing.expectEqual('http://127.0.0.1:9582/xhr', req.responseURL);
});
</script>
<script id=xhr2>
const req2 = new XMLHttpRequest()
testing.async(async (restore) => {
await new Promise((resolve) => {
req2.onload = resolve;
req2.open('GET', 'http://127.0.0.1:9582/xhr')
req2.responseType = 'document';
req2.send()
});
restore();
testing.expectEqual(200, req2.status);
testing.expectEqual('OK', req2.statusText);
testing.expectEqual(true, req2.response instanceof Document);
testing.expectEqual(true, req2.responseXML instanceof Document);
});
</script>
<script id=xhr3>
const req3 = new XMLHttpRequest()
testing.async(async (restore) => {
await new Promise((resolve) => {
req3.onload = resolve;
req3.open('GET', 'http://127.0.0.1:9582/xhr/json')
req3.responseType = 'json';
req3.send()
});
restore();
testing.expectEqual(200, req3.status);
testing.expectEqual('OK', req3.statusText);
testing.expectEqual('9000!!!', req3.response.over);
});
</script>
<script id=xhr4>
const req4 = new XMLHttpRequest()
testing.async(async (restore) => {
await new Promise((resolve) => {
req4.onload = resolve;
req4.open('POST', 'http://127.0.0.1:9582/xhr')
req4.send('foo')
});
restore();
testing.expectEqual(200, req4.status);
testing.expectEqual('OK', req4.statusText);
testing.expectEqual(true, req4.responseText.length > 64);
});
</script>
<script id=xhr5>
testing.async(async (restore) => {
let state = [];
const req5 = new XMLHttpRequest();
const result = await new Promise((resolve) => {
req5.onreadystatechange = (e) => {
state.push(req5.readyState);
if (req5.readyState === XMLHttpRequest.DONE) {
resolve({states: state, target: e.currentTarget});
}
}
req5.open('GET', 'http://127.0.0.1:9582/xhr');
req5.send();
});
restore();
const {states: states, target: target} = result;
testing.expectEqual(4, states.length)
testing.expectEqual(XMLHttpRequest.OPENED, states[0]);
testing.expectEqual(XMLHttpRequest.HEADERS_RECEIVED, states[1]);
testing.expectEqual(XMLHttpRequest.LOADING, states[2]);
testing.expectEqual(XMLHttpRequest.DONE, states[3]);
testing.expectEqual(req5, target);
})
</script>
<script id=xhr_redirect>
testing.async(async (restore) => {
const req = new XMLHttpRequest();
await new Promise((resolve) => {
req.onload = resolve;
req.open('GET', 'http://127.0.0.1:9582/xhr/redirect');
req.send();
});
restore();
testing.expectEqual(200, req.status);
testing.expectEqual('OK', req.statusText);
testing.expectEqual('http://127.0.0.1:9582/xhr', req.responseURL);
testing.expectEqual(100, req.responseText.length);
});
</script>
<script id=xhr_404>
testing.async(async (restore) => {
const req = new XMLHttpRequest();
await new Promise((resolve) => {
req.onload = resolve;
req.open('GET', 'http://127.0.0.1:9582/xhr/404');
req.send();
});
restore();
testing.expectEqual(404, req.status);
testing.expectEqual('Not Found', req.statusText);
testing.expectEqual('Not Found', req.responseText);
});
</script>
<script id=xhr_500>
testing.async(async (restore) => {
const req = new XMLHttpRequest();
await new Promise((resolve) => {
req.onload = resolve;
req.open('GET', 'http://127.0.0.1:9582/xhr/500');
req.send();
});
restore();
testing.expectEqual(500, req.status);
testing.expectEqual('Internal Server Error', req.statusText);
testing.expectEqual('Internal Server Error', req.responseText);
});
</script>
<script id=xhr_abort>
testing.async(async (restore) => {
const req = new XMLHttpRequest();
let abortFired = false;
let errorFired = false;
let loadEndFired = false;
await new Promise((resolve) => {
req.onabort = () => { abortFired = true; };
req.onerror = () => { errorFired = true; };
req.onloadend = () => {
loadEndFired = true;
resolve();
};
req.open('GET', 'http://127.0.0.1:9582/xhr');
req.send();
req.abort();
});
restore();
testing.expectEqual(true, abortFired);
testing.expectEqual(true, errorFired);
testing.expectEqual(true, loadEndFired);
testing.expectEqual(XMLHttpRequest.UNSENT, req.readyState);
});
</script>

View File

@@ -64,10 +64,8 @@
}
async function async(cb) {
const script_id = document.currentScript.id;
const stack = new Error().stack;
async_capture = {script_id: script_id, stack: stack};
await cb();
let capture = {script_id: document.currentScript.id, stack: new Error().stack};
await cb(() => { async_capture = capture; });
async_capture = null;
}

View File

@@ -25,6 +25,7 @@ const Http = @import("../../../http/Http.zig");
const URL = @import("../../URL.zig");
const Mime = @import("../../Mime.zig");
const Page = @import("../../Page.zig");
const Node = @import("../Node.zig");
const Event = @import("../Event.zig");
const Headers = @import("Headers.zig");
const EventTarget = @import("../EventTarget.zig");
@@ -44,17 +45,19 @@ _method: Http.Method = .GET,
_request_headers: *Headers,
_request_body: ?[]const u8 = null,
_response: std.ArrayList(u8) = .empty,
_response: ?Response = null,
_response_data: std.ArrayList(u8) = .empty,
_response_status: u16 = 0,
_response_len: ?usize = 0,
_response_url: [:0]const u8 = "",
_response_mime: ?Mime = null,
_response_headers: std.ArrayList([]const u8) = .empty,
_response_type: ResponseType = .text,
_state: State = .unsent,
_ready_state: ReadyState = .unsent,
_on_ready_state_change: ?js.Function = null,
const State = enum(u8) {
const ReadyState = enum(u8) {
unsent = 0,
opened = 1,
headers_received = 2,
@@ -62,9 +65,16 @@ const State = enum(u8) {
done = 4,
};
const Response = union(ResponseType) {
text: []const u8,
json: std.json.Value,
document: *Node.Document,
};
const ResponseType = enum {
text,
json,
document,
// TODO: other types to support
};
@@ -100,30 +110,6 @@ pub fn setOnReadyStateChange(self: *XMLHttpRequest, cb_: ?js.Function) !void {
}
}
pub fn getResponseType(self: *const XMLHttpRequest) []const u8 {
return @tagName(self._response_type);
}
pub fn setResponseType(self: *XMLHttpRequest, value: []const u8) void {
if (std.meta.stringToEnum(ResponseType, value)) |rt| {
self._response_type = rt;
}
}
pub fn getStatus(self: *const XMLHttpRequest) u16 {
return self._response_status;
}
pub fn getResponse(self: *const XMLHttpRequest, page: *Page) !Response {
switch (self._response_type) {
.text => return .{ .text = self._response.items },
.json => {
const parsed = try std.json.parseFromSliceLeaky(std.json.Value, page.call_arena, self._response.items, .{});
return .{ .json = parsed };
},
}
}
// TODO: this takes an opitonal 3 more parameters
// TODO: url should be a union, as it can be multiple things
pub fn open(self: *XMLHttpRequest, method_: []const u8, url: [:0]const u8) !void {
@@ -168,6 +154,105 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
.error_callback = httpErrorCallback,
});
}
pub fn getReadyState(self: *const XMLHttpRequest) u32 {
return @intFromEnum(self._ready_state);
}
pub fn getResponseHeader(self: *const XMLHttpRequest, name: []const u8) ?[]const u8 {
for (self._response_headers.items) |entry| {
if (entry.len <= name.len) {
continue;
}
if (std.ascii.eqlIgnoreCase(name, entry[0..name.len]) == false) {
continue;
}
if (entry[name.len] != ':') {
continue;
}
return std.mem.trimLeft(u8, entry[name.len + 1 ..], " ");
}
return null;
}
pub fn getAllResponseHeaders(self: *const XMLHttpRequest, page: *Page) ![]const u8 {
if (self._ready_state != .done) {
// MDN says this should return null, but it seems to return an empty string
// in every browser. Specs are too hard for a dumbo like me to understand.
return "";
}
var buf = std.Io.Writer.Allocating.init(page.call_arena);
for (self._response_headers.items) |entry| {
try buf.writer.writeAll(entry);
try buf.writer.writeAll("\r\n");
}
return buf.written();
}
pub fn getResponseType(self: *const XMLHttpRequest) []const u8 {
if (self._ready_state != .done) {
return "";
}
return @tagName(self._response_type);
}
pub fn setResponseType(self: *XMLHttpRequest, value: []const u8) void {
if (std.meta.stringToEnum(ResponseType, value)) |rt| {
self._response_type = rt;
}
}
pub fn getResponseText(self: *const XMLHttpRequest) []const u8 {
return self._response_data.items;
}
pub fn getStatus(self: *const XMLHttpRequest) u16 {
return self._response_status;
}
pub fn getStatusText(self: *const XMLHttpRequest) []const u8 {
return std.http.Status.phrase(@enumFromInt(self._response_status)) orelse "";
}
pub fn getResponseURL(self: *XMLHttpRequest) []const u8 {
return self._response_url;
}
pub fn getResponse(self: *XMLHttpRequest, page: *Page) !?Response {
if (self._ready_state != .done) {
return null;
}
if (self._response) |res| {
// was already loaded
return res;
}
const data = self._response_data.items;
const res: Response = switch (self._response_type) {
.text => .{ .text = data },
.json => blk: {
const parsed = try std.json.parseFromSliceLeaky(std.json.Value, page.call_arena, data, .{});
break :blk .{ .json = parsed };
},
.document => blk: {
const document = try page._factory.node(Node.Document{ ._proto = undefined, ._type = .generic });
try page.parseHtmlAsChildren(document.asNode(), data);
break :blk .{ .document = document };
},
};
self._response = res;
return res;
}
pub fn getResponseXML(self: *XMLHttpRequest, page: *Page) !?*Node.Document {
const res = (try self.getResponse(page)) orelse return null;
return switch (res) {
.document => |doc| doc,
else => null,
};
}
fn httpStartCallback(transfer: *Http.Transfer) !void {
const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx));
@@ -211,8 +296,9 @@ fn httpHeaderDoneCallback(transfer: *Http.Transfer) !void {
self._response_status = header.status;
if (transfer.getContentLength()) |cl| {
self._response_len = cl;
try self._response.ensureTotalCapacity(self._arena, cl);
try self._response_data.ensureTotalCapacity(self._arena, cl);
}
self._response_url = try self._arena.dupeZ(u8, std.mem.span(header.url));
try self.stateChanged(.headers_received, self._page);
try self._proto.dispatch(.load_start, .{ .loaded = 0, .total = self._response_len orelse 0 }, self._page);
@@ -221,11 +307,11 @@ fn httpHeaderDoneCallback(transfer: *Http.Transfer) !void {
fn httpDataCallback(transfer: *Http.Transfer, data: []const u8) !void {
const self: *XMLHttpRequest = @ptrCast(@alignCast(transfer.ctx));
try self._response.appendSlice(self._arena, data);
try self._response_data.appendSlice(self._arena, data);
try self._proto.dispatch(.progress, .{
.total = self._response_len orelse 0,
.loaded = self._response.items.len,
.loaded = self._response_data.items.len,
}, self._page);
}
@@ -236,7 +322,7 @@ fn httpDoneCallback(ctx: *anyopaque) !void {
.source = "xhr",
.url = self._url,
.status = self._response_status,
.len = self._response.items.len,
.len = self._response_data.items.len,
});
// Not that the request is done, the http/client will free the transfer
@@ -244,7 +330,7 @@ fn httpDoneCallback(ctx: *anyopaque) !void {
self._transfer = null;
try self.stateChanged(.done, self._page);
const loaded = self._response.items.len;
const loaded = self._response_data.items.len;
try self._proto.dispatch(.load, .{
.total = loaded,
.loaded = loaded,
@@ -262,7 +348,7 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void {
self.handleError(err);
}
pub fn _abort(self: *XMLHttpRequest) void {
pub fn abort(self: *XMLHttpRequest) void {
self.handleError(error.Abort);
if (self._transfer) |transfer| {
transfer.abort();
@@ -281,13 +367,14 @@ fn handleError(self: *XMLHttpRequest, err: anyerror) void {
fn _handleError(self: *XMLHttpRequest, err: anyerror) !void {
const is_abort = err == error.Abort;
const new_state: State = if (is_abort) .unsent else .done;
if (new_state != self._state) {
const new_state: ReadyState = if (is_abort) .unsent else .done;
if (new_state != self._ready_state) {
const page = self._page;
try self.stateChanged(new_state, page);
if (is_abort) {
try self._proto.dispatch(.abort, null, page);
}
try self._proto.dispatch(.err, null, page);
try self._proto.dispatch(.load_end, null, page);
}
@@ -299,9 +386,10 @@ fn _handleError(self: *XMLHttpRequest, err: anyerror) !void {
});
}
fn stateChanged(self: *XMLHttpRequest, state: State, page: *Page) !void {
fn stateChanged(self: *XMLHttpRequest, state: ReadyState, page: *Page) !void {
// there are more rules than this, but it's a start
std.debug.assert(state != self._state);
std.debug.assert(state != self._ready_state);
self._ready_state = state;
const event = try Event.init("readystatechange", .{}, page);
try page._event_manager.dispatchWithFunction(
@@ -328,11 +416,6 @@ fn parseMethod(method: []const u8) !Http.Method {
return error.InvalidMethod;
}
const Response = union(enum) {
text: []const u8,
json: std.json.Value,
};
pub const JsApi = struct {
pub const bridge = js.Bridge(XMLHttpRequest);
@@ -343,19 +426,27 @@ pub const JsApi = struct {
};
pub const constructor = bridge.constructor(XMLHttpRequest.init, .{});
pub const UNSENT = bridge.property(@intFromEnum(XMLHttpRequest.State.unsent));
pub const OPENED = bridge.property(@intFromEnum(XMLHttpRequest.State.opened));
pub const HEADERS_RECEIVED = bridge.property(@intFromEnum(XMLHttpRequest.State.headers_received));
pub const LOADING = bridge.property(@intFromEnum(XMLHttpRequest.State.loading));
pub const DONE = bridge.property(@intFromEnum(XMLHttpRequest.State.done));
pub const UNSENT = bridge.property(@intFromEnum(XMLHttpRequest.ReadyState.unsent));
pub const OPENED = bridge.property(@intFromEnum(XMLHttpRequest.ReadyState.opened));
pub const HEADERS_RECEIVED = bridge.property(@intFromEnum(XMLHttpRequest.ReadyState.headers_received));
pub const LOADING = bridge.property(@intFromEnum(XMLHttpRequest.ReadyState.loading));
pub const DONE = bridge.property(@intFromEnum(XMLHttpRequest.ReadyState.done));
pub const onreadystatechange = bridge.accessor(XMLHttpRequest.getOnReadyStateChange, XMLHttpRequest.setOnReadyStateChange, .{});
pub const open = bridge.function(XMLHttpRequest.open, .{});
pub const send = bridge.function(XMLHttpRequest.send, .{});
pub const responseType = bridge.accessor(XMLHttpRequest.getResponseType, XMLHttpRequest.setResponseType, .{});
pub const status = bridge.accessor(XMLHttpRequest.getStatus, null, .{});
pub const statusText = bridge.accessor(XMLHttpRequest.getStatusText, null, .{});
pub const readyState = bridge.accessor(XMLHttpRequest.getReadyState, null, .{});
pub const response = bridge.accessor(XMLHttpRequest.getResponse, null, .{});
pub const responseText = bridge.accessor(XMLHttpRequest.getResponseText, null, .{});
pub const responseXML = bridge.accessor(XMLHttpRequest.getResponseXML, null, .{});
pub const responseURL = bridge.accessor(XMLHttpRequest.getResponseURL, null, .{});
pub const setRequestHeader = bridge.function(XMLHttpRequest.setRequestHeader, .{});
pub const getResponseHeader = bridge.function(XMLHttpRequest.getResponseHeader, .{});
pub const getAllResponseHeaders = bridge.function(XMLHttpRequest.getAllResponseHeaders, .{});
pub const abort = bridge.function(XMLHttpRequest.abort, .{});
};
const testing = @import("../../../testing.zig");

View File

@@ -170,6 +170,22 @@ const TestHTTPServer = struct {
fn handler(server: *TestHTTPServer, req: *std.http.Server.Request) !void {
const path = req.head.target;
if (std.mem.eql(u8, path, "/xhr")) {
return req.respond("1234567890" ** 10, .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/html; charset=utf-8" },
},
});
}
if (std.mem.eql(u8, path, "/xhr/json")) {
return req.respond("{\"over\":\"9000!!!\"}", .{
.extra_headers = &.{
.{ .name = "Content-Type", .value = "application/json" },
},
});
}
// strip out leading '/' to make the path relative
const file = try server.dir.openFile(path[1..], .{});
defer file.close();

View File

@@ -407,7 +407,6 @@ fn runWebApiTest(test_file: [:0]const u8) !void {
test_session.fetchWait(2000);
page._session.browser.runMicrotasks();
page._session.browser.runMessageLoop();
js_context.eval("testing.assertOk()", "testing.assertOk()") catch |err| {
const msg = try_catch.err(arena_allocator) catch @errorName(err) orelse "unknown";
@@ -508,12 +507,6 @@ fn serveCDP(wg: *std.Thread.WaitGroup) !void {
fn testHTTPHandler(req: *std.http.Server.Request) !void {
const path = req.head.target;
if (std.mem.eql(u8, path, "/loader")) {
return req.respond("Hello!", .{
.extra_headers = &.{.{ .name = "Connection", .value = "close" }},
});
}
if (std.mem.eql(u8, path, "/xhr")) {
return req.respond("1234567890" ** 10, .{
.extra_headers = &.{
@@ -530,6 +523,33 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void {
});
}
if (std.mem.eql(u8, path, "/xhr/redirect")) {
return req.respond("", .{
.status = .found,
.extra_headers = &.{
.{ .name = "Location", .value = "http://127.0.0.1:9582/xhr" },
},
});
}
if (std.mem.eql(u8, path, "/xhr/404")) {
return req.respond("Not Found", .{
.status = .not_found,
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/plain" },
},
});
}
if (std.mem.eql(u8, path, "/xhr/500")) {
return req.respond("Internal Server Error", .{
.status = .internal_server_error,
.extra_headers = &.{
.{ .name = "Content-Type", .value = "text/plain" },
},
});
}
if (std.mem.startsWith(u8, path, "/src/browser/tests/")) {
// strip off leading / so that it's relative to CWD
return TestHTTPServer.sendFile(req, path[1..]);