From 1639ff1b98a7a69562856e9f43727b26a9fa212c Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 15 Dec 2025 17:56:23 +0800 Subject: [PATCH] improve XMLHTTPRequest. Legacy xhr.html pass --- src/browser/js/Env.zig | 6 +- src/browser/tests/legacy/xhr/xhr.html | 12 +- src/browser/tests/net/xhr.html | 191 ++++++++++++++++++++++ src/browser/tests/testing.js | 6 +- src/browser/webapi/net/XMLHttpRequest.zig | 185 +++++++++++++++------ src/main_legacy_test.zig | 16 ++ src/testing.zig | 34 +++- 7 files changed, 384 insertions(+), 66 deletions(-) diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 505eb6e8..5bc1f7fc 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -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. diff --git a/src/browser/tests/legacy/xhr/xhr.html b/src/browser/tests/legacy/xhr/xhr.html index 13ab6216..2ff428b7 100644 --- a/src/browser/tests/legacy/xhr/xhr.html +++ b/src/browser/tests/legacy/xhr/xhr.html @@ -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); }); @@ -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(); }); diff --git a/src/browser/tests/net/xhr.html b/src/browser/tests/net/xhr.html index bb73aa73..82e9b6d1 100644 --- a/src/browser/tests/net/xhr.html +++ b/src/browser/tests/net/xhr.html @@ -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); + }); + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/testing.js b/src/browser/tests/testing.js index 5cb6220a..afc1fa69 100644 --- a/src/browser/tests/testing.js +++ b/src/browser/tests/testing.js @@ -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; } diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index 4959d563..f8181e10 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -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"); diff --git a/src/main_legacy_test.zig b/src/main_legacy_test.zig index 5b1cf862..0690d898 100644 --- a/src/main_legacy_test.zig +++ b/src/main_legacy_test.zig @@ -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(); diff --git a/src/testing.zig b/src/testing.zig index f3c1aeac..256d9728 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -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..]);