Unify Data URLs

Previously, the logic for data/blob URLs were spread out at every http_client
caller. fetch/XHR/ScriptManager all had their own "if this is a data url, ..."
logic.

This causes two issues.
1 - Duplication, particularly as we try to cover more edge cases that need to
    be handled in all places.

2 - Correctness because data/blob URLs are still URLs and still need to be
    "fetched" (from memory). They should still fire with the same timing as any
    other URL. That means that for fetch/XHR, they should fire asynchronously
    (i.e. on the next tick). And for ScriptManager they should fire depending
    on the type of script (normal/defer/async).

This PR relies on the infrastructure added to:
https://github.com/lightpanda-io/browser/pull/2506 in order to fulfilled a
synthetic response on the next tick.

Frame.navigate is excluded from this refactor. For one, about:blank must be
special-cased and run synchronously (one of the few places where this is
strictly required) and even blob URLs are a bit different: the blob URL list
is the parent frame, not self, and there's more we need to do (set origin).
Potentially there _is_ some improvement here, but it's both less significant
and less simple.
This commit is contained in:
Karl Seguin
2026-05-26 13:41:03 +08:00
parent 9fb631bd4d
commit 02df0dc287
12 changed files with 377 additions and 156 deletions

View File

@@ -294,6 +294,7 @@ pub fn init(self: *Frame, frame_id: u32, page: *Page, parent: ?*Frame) !void {
._event_manager = EventManager.init(arena, self),
};
self._to_load = &self._to_load_1;
self._http_owner.blob_urls = &self._blob_urls;
var screen: *Screen = undefined;
var visual_viewport: *VisualViewport = undefined;
@@ -507,11 +508,6 @@ pub fn isSameOrigin(self: *const Frame, url: [:0]const u8) bool {
return std.mem.eql(u8, URL.getHost(url), URL.getHost(current_origin));
}
/// Look up a blob URL in this frame's registry.
pub fn lookupBlobUrl(self: *Frame, url: []const u8) ?*Blob {
return self._blob_urls.get(url);
}
pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !void {
lp.assert(self._load_state == .waiting, "frame.renavigate", .{});
const session = self._session;

View File

@@ -567,6 +567,21 @@ fn requestT(self: *Client, req: Request, owner: ?*Owner) !*Transfer {
// via transfer.abort which fires error_callback and deinits. `.created`
// means no commit happened — anything else is held by an owner that
// will clean up.
// Synthetic schemes never touch the network or the layer chain — they skip
// robots/cache/interception and deliver on the next tick
if (Synthetic.isSynthetic(req.url)) {
// The 2nd transfer is the callback context. We don't actually use it,
// we're just sticking transfer in there to have something.
self.runNextTick(transfer, transfer, .{ .run = Synthetic.run }) catch |err| {
if (transfer.state == .created) {
transfer.abort(err);
}
return err;
};
return transfer;
}
self.entry_layer.request(transfer) catch |err| {
if (transfer.state == .created) {
transfer.abort(err);
@@ -577,6 +592,80 @@ fn requestT(self: *Client, req: Request, owner: ?*Owner) !*Transfer {
return transfer;
}
// Non-network URL schemes whose response is synthesized in-process rather than
// fetched, think blob data URLs.
const Synthetic = struct {
const data_url = @import("data_url.zig");
fn isSynthetic(url: []const u8) bool {
return std.mem.startsWith(u8, url, "data:") or std.mem.startsWith(u8, url, "blob:");
}
fn run(transfer: *Transfer, _: *anyopaque) void {
defer transfer.deinit();
const fulfilled = build(transfer) catch |err| {
transfer.req.error_callback(transfer.req.ctx, err);
return;
};
deliver(&transfer.req, &fulfilled) catch |err| {
transfer.req.error_callback(transfer.req.ctx, err);
};
}
fn build(transfer: *Transfer) !FulfilledResponse {
const arena = transfer.arena;
const url = transfer.req.url;
var body: []const u8 = "";
var content_type: []const u8 = "";
if (std.mem.startsWith(u8, url, "data:")) {
const parsed = try data_url.parse(arena, url);
content_type = parsed.content_type;
body = parsed.body;
} else {
// blob: — resolved against the owning frame's registry.
const owner = transfer.owner orelse return error.BlobNotFound;
const blob_urls = owner.blob_urls orelse return error.BlobNotFound;
const blob = blob_urls.get(url) orelse return error.BlobNotFound;
content_type = blob._mime;
body = blob._slice;
}
// A blob with no type yields no Content-Type header.
const headers = if (content_type.len > 0) blk: {
const h = try arena.alloc(http.Header, 1);
h[0] = .{ .name = "content-type", .value = content_type };
break :blk h;
} else &[_]http.Header{};
return .{
.url = url,
.body = body,
.status = 200,
.headers = headers,
};
}
fn deliver(req: *Request, fulfilled: *const FulfilledResponse) !void {
const response = Response.fromFulfilled(req.ctx, fulfilled);
if (req.start_callback) |cb| {
try cb(response);
}
const proceed = try req.header_callback(response);
if (!proceed) {
return error.Abort;
}
if (fulfilled.body) |b| {
if (b.len > 0) {
try req.data_callback(response, b);
}
}
try req.done_callback(req.ctx);
}
};
const SyncContext = struct {
allocator: Allocator,
completion: union(enum) {
@@ -1909,7 +1998,11 @@ pub const Owner = struct {
transfers: std.DoublyLinkedList = .{},
websockets: std.DoublyLinkedList = .{},
// The owning Frame's / WorkerGlobalScope's blob: registry,
blob_urls: ?*const std.StringHashMapUnmanaged(*Blob) = null,
const WebSocket = @import("webapi/net/WebSocket.zig");
const Blob = @import("webapi/Blob.zig");
pub fn addTransfer(self: *Owner, t: *Transfer) void {
self.transfers.append(&t.owner_node);

View File

@@ -141,12 +141,11 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
var remote_url: ?[:0]const u8 = null;
const base_url = frame.base();
if (element.getAttributeSafe(comptime .wrap("src"))) |src| {
if (try parseDataURI(arena, src)) |data_uri| {
source = .{ .@"inline" = data_uri };
} else {
remote_url = try URL.resolve(arena, base_url, src, .{ .encoding = frame.charset });
source = .{ .remote = .{} };
}
// data: and blob: srcs flow through the normal request path; HttpClient
// synthesizes the response. Execution mode (blocking vs async/defer) is
// attribute-driven, the same as any other src.
remote_url = try URL.resolve(arena, base_url, src, .{ .encoding = frame.charset });
source = .{ .remote = .{} };
} else {
var buf = std.Io.Writer.Allocating.init(arena);
try element.asNode().getChildTextContent(&buf.writer);
@@ -333,65 +332,3 @@ pub fn parseImportmap(self: *ScriptManager, script: *const Script) !void {
pub fn staticScriptsDone(self: *ScriptManager) void {
self.base.staticScriptsDone();
}
// Parses data:[<media-type>][;base64],<data>
fn parseDataURI(allocator: Allocator, src: []const u8) !?[]const u8 {
if (!std.mem.startsWith(u8, src, "data:")) {
return null;
}
const uri = src[5..];
const data_starts = std.mem.indexOfScalar(u8, uri, ',') orelse return null;
const data = uri[data_starts + 1 ..];
const unescaped = try URL.unescape(allocator, data);
const metadata = uri[0..data_starts];
if (std.mem.endsWith(u8, metadata, ";base64") == false) {
return unescaped;
}
// Forgiving base64 decode per WHATWG spec:
// https://infra.spec.whatwg.org/#forgiving-base64-decode
// Step 1: Remove all ASCII whitespace
var stripped = try std.ArrayList(u8).initCapacity(allocator, unescaped.len);
for (unescaped) |c| {
if (!std.ascii.isWhitespace(c)) {
stripped.appendAssumeCapacity(c);
}
}
const trimmed = std.mem.trimRight(u8, stripped.items, "=");
// Length % 4 == 1 is invalid
if (trimmed.len % 4 == 1) {
return error.InvalidCharacterError;
}
const decoded_size = std.base64.standard_no_pad.Decoder.calcSizeForSlice(trimmed) catch return error.InvalidCharacterError;
const buffer = try allocator.alloc(u8, decoded_size);
std.base64.standard_no_pad.Decoder.decode(buffer, trimmed) catch return error.InvalidCharacterError;
return buffer;
}
const testing = @import("../testing.zig");
test "DataURI: parse valid" {
try assertValidDataURI("data:text/javascript; charset=utf-8;base64,Zm9v", "foo");
try assertValidDataURI("data:text/javascript; charset=utf-8;,foo", "foo");
try assertValidDataURI("data:,foo", "foo");
}
test "DataURI: parse invalid" {
try assertInvalidDataURI("atad:,foo");
try assertInvalidDataURI("data:foo");
try assertInvalidDataURI("data:");
}
fn assertValidDataURI(uri: []const u8, expected: []const u8) !void {
defer testing.reset();
const data_uri = try parseDataURI(testing.arena_allocator, uri) orelse return error.TestFailed;
try testing.expectEqual(expected, data_uri);
}
fn assertInvalidDataURI(uri: []const u8) !void {
try testing.expectEqual(null, parseDataURI(undefined, uri));
}

202
src/browser/data_url.zig Normal file
View File

@@ -0,0 +1,202 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// "data: URL processor" — https://fetch.spec.whatwg.org/#data-url-processor.
// The single home for data: parsing: the HttpClient synthetic-scheme path and
// ScriptManager (<script src="data:...">) both go through here.
const std = @import("std");
const URL = @import("URL.zig");
const Allocator = std.mem.Allocator;
pub const Parsed = struct {
body: []const u8,
content_type: []const u8,
};
pub fn parse(arena: Allocator, url: []const u8) !Parsed {
if (std.mem.startsWith(u8, url, "data:") == false) {
return error.InvalidDataUrl;
}
const after = url["data:".len..];
const comma = std.mem.indexOfScalarPos(u8, after, 0, ',') orelse return error.InvalidDataUrl;
var meta = std.mem.trim(u8, after[0..comma], &std.ascii.whitespace);
const encoded_body = after[comma + 1 ..];
// A trailing ";" + optional spaces + "base64" selects base64 decoding.
const is_base64 = blk: {
if (meta.len < "base64".len) break :blk false;
const tail = meta[meta.len - "base64".len ..];
if (!std.ascii.eqlIgnoreCase(tail, "base64")) break :blk false;
const head = std.mem.trimRight(u8, meta[0 .. meta.len - "base64".len], " ");
if (head.len == 0 or head[head.len - 1] != ';') break :blk false;
meta = head[0 .. head.len - 1];
break :blk true;
};
var content_type: []const u8 = meta;
if (content_type.len == 0) {
content_type = "text/plain;charset=US-ASCII";
} else if (content_type[0] == ';') {
// e.g. "data:;charset=utf-8,x" -> "text/plain;charset=utf-8"
content_type = try std.fmt.allocPrint(arena, "text/plain{s}", .{content_type});
}
const body_text = try URL.unescape(arena, encoded_body);
const body = if (is_base64) try base64Decode(arena, body_text) else body_text;
return .{ .content_type = content_type, .body = body };
}
fn base64Decode(arena: Allocator, input: []const u8) ![]u8 {
// Forgiving-base64 decode — https://infra.spec.whatwg.org/#forgiving-base64-decode.
// std's decoders reject non-canonical trailing bits (e.g. "ab"), which
// forgiving-base64 tolerates, so decode by hand after validating padding.
const buf = try arena.alloc(u8, input.len);
var n: usize = 0;
for (input) |c| switch (c) {
' ', '\t', '\n', '\r', std.ascii.control_code.ff => {},
else => {
buf[n] = c;
n += 1;
},
};
var src = buf[0..n];
// Only a multiple-of-4 length may carry (and shed) up to two "=" of padding.
if (src.len % 4 == 0) {
if (std.mem.endsWith(u8, src, "==")) {
src = src[0 .. src.len - 2];
} else if (std.mem.endsWith(u8, src, "=")) {
src = src[0 .. src.len - 1];
}
}
if (src.len % 4 == 1) return error.InvalidBase64;
// Any "=" still present is misplaced padding.
if (std.mem.indexOfScalar(u8, src, '=') != null) return error.InvalidBase64;
const out_len = src.len / 4 * 3 + switch (src.len % 4) {
0 => @as(usize, 0),
2 => 1,
3 => 2,
else => unreachable,
};
const out = try arena.alloc(u8, out_len);
var oi: usize = 0;
var i: usize = 0;
while (i + 4 <= src.len) : (i += 4) {
const a = try b64Val(src[i]);
const b = try b64Val(src[i + 1]);
const c = try b64Val(src[i + 2]);
const d = try b64Val(src[i + 3]);
out[oi] = (a << 2) | (b >> 4);
out[oi + 1] = (b << 4) | (c >> 2);
out[oi + 2] = (c << 6) | d;
oi += 3;
}
switch (src.len - i) {
0 => {},
2 => {
const a = try b64Val(src[i]);
const b = try b64Val(src[i + 1]);
out[oi] = (a << 2) | (b >> 4);
},
3 => {
const a = try b64Val(src[i]);
const b = try b64Val(src[i + 1]);
const c = try b64Val(src[i + 2]);
out[oi] = (a << 2) | (b >> 4);
out[oi + 1] = (b << 4) | (c >> 2);
},
else => unreachable,
}
return out;
}
fn b64Val(c: u8) !u8 {
return switch (c) {
'A'...'Z' => c - 'A',
'a'...'z' => c - 'a' + 26,
'0'...'9' => c - '0' + 52,
'+' => 62,
'/' => 63,
else => error.InvalidBase64,
};
}
const testing = @import("../testing.zig");
test "data_url: plain text, default content-type" {
defer testing.reset();
const r = try parse(testing.arena_allocator, "data:,Hello%2C%20World");
try testing.expectString("text/plain;charset=US-ASCII", r.content_type);
try testing.expectString("Hello, World", r.body);
}
test "data_url: explicit mediatype" {
defer testing.reset();
const r = try parse(testing.arena_allocator, "data:text/html,<b>hi</b>");
try testing.expectString("text/html", r.content_type);
try testing.expectString("<b>hi</b>", r.body);
}
test "data_url: base64" {
defer testing.reset();
const r = try parse(testing.arena_allocator, "data:text/plain;base64,SGVsbG8=");
try testing.expectString("text/plain", r.content_type);
try testing.expectString("Hello", r.body);
}
test "data_url: base64 without padding decodes (forgiving)" {
defer testing.reset();
const r = try parse(testing.arena_allocator, "data:application/octet-stream;base64,SGVsbG8");
try testing.expectString("Hello", r.body);
// 2- and 3-char unpadded tails decode (non-canonical trailing bits are ok).
try testing.expectString("i", (try parse(testing.arena_allocator, "data:;base64,ab")).body);
try testing.expectString("a", (try parse(testing.arena_allocator, "data:;base64,YR")).body);
// ASCII whitespace inside the payload is ignored.
try testing.expectString("Hello", (try parse(testing.arena_allocator, "data:;base64,SGVs bG8=")).body);
}
test "data_url: forgiving-base64 rejects misplaced/over-padding" {
defer testing.reset();
const arena = testing.arena_allocator;
try std.testing.expectError(error.InvalidBase64, parse(arena, "data:;base64,abcd=")); // len % 4 == 1
try std.testing.expectError(error.InvalidBase64, parse(arena, "data:;base64,="));
try std.testing.expectError(error.InvalidBase64, parse(arena, "data:;base64,ab=c")); // interior "="
try std.testing.expectError(error.InvalidBase64, parse(arena, "data:;base64,==")); // no data
}
test "data_url: bare charset gets text/plain prefix" {
defer testing.reset();
const r = try parse(testing.arena_allocator, "data:;charset=utf-8,x");
try testing.expectString("text/plain;charset=utf-8", r.content_type);
}
test "data_url: empty body" {
defer testing.reset();
const r = try parse(testing.arena_allocator, "data:text/plain,");
try testing.expectString("", r.body);
}
test "data_url: missing comma is an error" {
defer testing.reset();
try std.testing.expectError(error.InvalidDataUrl, parse(testing.arena_allocator, "data:text/plain"));
}

View File

@@ -34,7 +34,6 @@ const Factory = @import("../Factory.zig");
const HttpClient = @import("../HttpClient.zig");
const EventManagerBase = @import("../EventManagerBase.zig");
const Blob = @import("../webapi/Blob.zig");
const Event = @import("../webapi/Event.zig");
const EventTarget = @import("../webapi/EventTarget.zig");
const Performance = @import("../webapi/Performance.zig");
@@ -91,12 +90,6 @@ pub fn isSameOrigin(self: *const Execution, url: [:0]const u8) bool {
};
}
pub fn lookupBlobUrl(self: *const Execution, url: []const u8) ?*Blob {
return switch (self.context.global) {
inline else => |g| g.lookupBlobUrl(url),
};
}
pub fn makeRequest(self: *const Execution, req: HttpClient.Request) !void {
return switch (self.context.global) {
inline else => |g| g.makeRequest(req),

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<script src="../../../testing.js"></script>
<!--
data: <script src> resolves through the HTTP client's synthetic-scheme path
(no special-casing in ScriptManager). Execution mode is attribute-driven,
exactly like any other src: bare = blocking, defer/async = deferred.
-->
<!-- Blocking, parser-inserted: run in document order, before the check below. -->
<script src="data:text/javascript,window.__plain = 3;"></script>
<script src="data:text/javascript;base64,d2luZG93Ll9fYjY0ID0gNDI7"></script>
<script src="data:text/javascript,window.__enc%20%3D%207%3B"></script>
<script id=blocking_data_urls>
{
testing.expectEqual(3, window.__plain);
testing.expectEqual(42, window.__b64);
testing.expectEqual(7, window.__enc);
}
</script>
<!-- A deferred data: src must still run; proves mode follows the attribute. -->
<script src="data:text/javascript,window.__deferred = 'ran';" defer></script>
<script id=deferred_check>
testing.onload(() => {
testing.expectEqual('ran', window.__deferred);
});
</script>

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=data_url_navigation type=module>
// data: navigation flows through the HTTP client's synthetic-scheme path
// (resource_type=.document -> serveSynthetic), like any URL. The data:
// document is cross-origin (opaque origin), so we can't read its
// contentDocument — instead it reports its own parsed content back via
// postMessage, which proves serveSynthetic delivered the bytes and they
// parsed + ran.
{
const state = await testing.async();
window.addEventListener('message', (e) => state.resolve(e.data));
const f = document.createElement('iframe');
f.src = "data:text/html,<p>hello</p><script>parent.postMessage(document.querySelector('p').textContent, '*')<\/script>";
document.documentElement.appendChild(f);
await state.done((data) => {
testing.expectEqual('hello', data);
});
}
</script>

View File

@@ -360,3 +360,25 @@
});
}
</script>
<script id="worker_from_blob_url" type=module>
// A blob: worker resolves its initial script through the HTTP client's
// synthetic-scheme path against the frame's blob registry, like any URL.
{
const state = await testing.async();
const blob = new Blob(["onmessage = (e) => postMessage({ echo: e.data, from: 'blob-worker' });"], { type: 'text/javascript' });
const blobUrl = URL.createObjectURL(blob);
const worker = new Worker(blobUrl);
worker.onmessage = function(event) {
state.resolve(event.data);
};
worker.postMessage({ greeting: 'from-blob' });
await state.done((response) => {
testing.expectEqual('from-blob', response.echo.greeting);
testing.expectEqual('blob-worker', response.from);
URL.revokeObjectURL(blobUrl);
});
}
</script>

View File

@@ -25,7 +25,6 @@ const URL = @import("../URL.zig");
const Frame = @import("../Frame.zig");
const HttpClient = @import("../HttpClient.zig");
const Blob = @import("Blob.zig");
const EventTarget = @import("EventTarget.zig");
const MessageEvent = @import("event/MessageEvent.zig");
const ErrorEvent = @import("event/ErrorEvent.zig");
@@ -89,16 +88,6 @@ pub fn init(url: []const u8, frame: *Frame) !*Worker {
return self;
}
if (std.mem.startsWith(u8, url, "blob:")) {
errdefer frame.removeWorker(self);
const blob: *Blob = frame.lookupBlobUrl(url) orelse {
log.warn(.js, "invalid blob", .{ .target = "worker" });
return error.BlobNotFound;
};
try self.loadInitialScript(blob._slice);
return self;
}
const headers = try session.browser.http_client.newHeaders();
frame.makeRequest(.{
.ctx = self,

View File

@@ -144,6 +144,8 @@ pub fn init(worker: *Worker, url: [:0]const u8) !*WorkerGlobalScope {
});
errdefer factory.destroy(self);
self._http_owner.blob_urls = &self._blob_urls;
self._script_manager = ScriptManagerBase.init(
arena,
&session.browser.http_client,
@@ -231,10 +233,6 @@ pub fn isSameOrigin(self: *const WorkerGlobalScope, url: [:0]const u8) bool {
return std.mem.eql(u8, URL.getHost(url), URL.getHost(current_origin));
}
pub fn lookupBlobUrl(self: *WorkerGlobalScope, url: []const u8) ?*Blob {
return self._blob_urls.get(url);
}
pub fn makeRequest(self: *WorkerGlobalScope, req: HttpClient.Request) !void {
return self._session.browser.http_client.request(req, &self._http_owner);
}

View File

@@ -24,7 +24,6 @@ const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const URL = @import("../../URL.zig");
const Blob = @import("../Blob.zig");
const Request = @import("Request.zig");
const Response = @import("Response.zig");
const AbortSignal = @import("../AbortSignal.zig");
@@ -58,10 +57,6 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis
}
}
if (std.mem.startsWith(u8, request._url, "blob:")) {
return handleBlobUrl(request._url, resolver, exec);
}
const response = try Response.init(null, .{ .status = 0 }, exec);
errdefer response.deinit(exec.context.page);
@@ -121,26 +116,6 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis
return resolver.promise();
}
fn handleBlobUrl(url: []const u8, resolver: js.PromiseResolver, exec: *const Execution) !js.Promise {
const blob: *Blob = exec.lookupBlobUrl(url) orelse {
resolver.rejectError("fetch blob error", .{ .type_error = "BlobNotFound" });
return resolver.promise();
};
const response = try Response.init(null, .{ .status = 200 }, exec);
response._body = .{ .bytes = try response._arena.dupe(u8, blob._slice) };
response._url = try response._arena.dupeZ(u8, url);
response._type = .basic;
if (blob._mime.len > 0) {
try response._headers.append("Content-Type", blob._mime, exec);
}
const js_val = try exec.context.local.?.zigValueToJs(response, .{});
resolver.resolve("fetch blob done", js_val);
return resolver.promise();
}
fn httpStartCallback(response: HttpClient.Response) !void {
const self: *Fetch = @ptrCast(@alignCast(response.ctx));
if (comptime IS_DEBUG) {

View File

@@ -249,10 +249,6 @@ pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !v
const exec = self._exec;
if (std.mem.startsWith(u8, self._url, "blob:")) {
return self.handleBlobUrl(exec);
}
const session = exec.context.page.session;
const http_client = &session.browser.http_client;
var headers = try http_client.newHeaders();
@@ -293,38 +289,6 @@ pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !v
};
}
fn handleBlobUrl(self: *XMLHttpRequest, exec: *const Execution) !void {
const blob = exec.lookupBlobUrl(self._url) orelse {
self.handleError(error.BlobNotFound);
return;
};
self._response_status = 200;
self._response_url = self._url;
try self._response_data.appendSlice(self._arena, blob._slice);
self._response_len = blob._slice.len;
try self.stateChanged(.headers_received, exec);
try self._proto.dispatch(.load_start, .{ .loaded = 0, .total = self._response_len orelse 0 }, exec);
try self.stateChanged(.loading, exec);
try self._proto.dispatch(.progress, .{
.total = self._response_len orelse 0,
.loaded = self._response_data.items.len,
}, exec);
try self.stateChanged(.done, exec);
const loaded = self._response_data.items.len;
try self._proto.dispatch(.load, .{
.total = loaded,
.loaded = loaded,
}, exec);
try self._proto.dispatch(.load_end, .{
.total = loaded,
.loaded = loaded,
}, exec);
}
pub fn getReadyState(self: *const XMLHttpRequest) u32 {
return @intFromEnum(self._ready_state);
}