mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 09:35:59 -04:00
Merge branch 'main' into agent
This commit is contained in:
@@ -100,7 +100,10 @@ fn handleConnection(self: *TestHTTPServer, conn: std.net.Server.Connection) !voi
|
||||
pub fn sendFile(req: *std.http.Server.Request, file_path: []const u8) !void {
|
||||
var url_buf: [1024]u8 = undefined;
|
||||
var fba = std.heap.FixedBufferAllocator.init(&url_buf);
|
||||
const unescaped_file_path = try URL.unescape(fba.allocator(), file_path);
|
||||
var unescaped_file_path = try URL.unescape(fba.allocator(), file_path);
|
||||
if (std.mem.indexOfScalarPos(u8, unescaped_file_path, 0, '?')) |pos| {
|
||||
unescaped_file_path = unescaped_file_path[0..pos];
|
||||
}
|
||||
var file = std.fs.cwd().openFile(unescaped_file_path, .{}) catch |err| switch (err) {
|
||||
error.FileNotFound => return req.respond("server error", .{ .status = .not_found }),
|
||||
else => return err,
|
||||
@@ -114,7 +117,7 @@ pub fn sendFile(req: *std.http.Server.Request, file_path: []const u8) !void {
|
||||
.content_length = stat.size,
|
||||
.respond_options = .{
|
||||
.extra_headers = &.{
|
||||
.{ .name = "content-type", .value = getContentType(file_path) },
|
||||
.{ .name = "content-type", .value = getContentType(unescaped_file_path) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -86,7 +86,6 @@ pub fn closeSession(self: *Browser) void {
|
||||
if (self.session) |*session| {
|
||||
session.deinit();
|
||||
self.session = null;
|
||||
self.env.memoryPressureNotification(.critical);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -266,7 +266,11 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
|
||||
was_handled = true;
|
||||
event._current_target = target_et;
|
||||
|
||||
try ls.toLocal(inline_handler).callWithThis(void, target_et, .{event});
|
||||
// Inline handlers (e.g. onclick property) follow the same "report,
|
||||
// don't propagate" rule as addEventListener listeners — see Listener.run.
|
||||
ls.toLocal(inline_handler).callWithThis(void, target_et, .{event}) catch |err| {
|
||||
log.warn(.event, "inline handler", .{ .err = err });
|
||||
};
|
||||
|
||||
if (event._stop_propagation) {
|
||||
return;
|
||||
@@ -388,19 +392,7 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
|
||||
event._target = getAdjustedTarget(original_target, current_target);
|
||||
}
|
||||
|
||||
switch (listener.function) {
|
||||
.value => |value| try local.toLocal(value).callWithThis(void, current_target, .{event}),
|
||||
.string => |string| {
|
||||
const str = try frame.call_arena.dupeZ(u8, string.str());
|
||||
try local.eval(str, null);
|
||||
},
|
||||
.object => |obj_global| {
|
||||
const obj = local.toLocal(obj_global);
|
||||
if (try obj.getFunction("handleEvent")) |handleEvent| {
|
||||
try handleEvent.callWithThis(void, obj, .{event});
|
||||
}
|
||||
},
|
||||
}
|
||||
try listener.run(frame.call_arena, local, event, "listener");
|
||||
|
||||
// Restore original target (only if we changed it)
|
||||
if (event._needs_retargeting) {
|
||||
|
||||
@@ -305,19 +305,7 @@ pub fn dispatchDirect(
|
||||
|
||||
event._current_target = target;
|
||||
|
||||
switch (listener.function) {
|
||||
.value => |value| try ls.local.toLocal(value).callWithThis(void, target, .{event}),
|
||||
.string => |string| {
|
||||
const str = try arena.dupeZ(u8, string.str());
|
||||
try ls.local.eval(str, null);
|
||||
},
|
||||
.object => |obj_global| {
|
||||
const obj = ls.local.toLocal(obj_global);
|
||||
if (try obj.getFunction("handleEvent")) |handleEvent| {
|
||||
try handleEvent.callWithThis(void, obj, .{event});
|
||||
}
|
||||
},
|
||||
}
|
||||
try listener.run(arena, &ls.local, event, opts.context);
|
||||
|
||||
if (event._stop_immediate_propagation) {
|
||||
return;
|
||||
@@ -392,6 +380,46 @@ pub const Listener = struct {
|
||||
signal: ?*@import("webapi/AbortSignal.zig") = null,
|
||||
node: std.DoublyLinkedList.Node,
|
||||
removed: bool = false,
|
||||
|
||||
// Per DOM §2.9 step 4 substep 8 ("Inner invoke"), a listener callback that
|
||||
// throws must have its exception *reported* to the global error handler,
|
||||
// not propagated to the dispatch caller — subsequent listeners on the same
|
||||
// target and the rest of the propagation path must still run.
|
||||
//
|
||||
// Caller must set `event._current_target` before invoking — the function-
|
||||
// listener variant uses it as `this`, matching the spec contract that a
|
||||
// listener sees its current target via both `event.currentTarget` and `this`.
|
||||
pub fn run(
|
||||
self: *const Listener,
|
||||
arena: Allocator,
|
||||
local: *const js.Local,
|
||||
event: *Event,
|
||||
comptime context: []const u8,
|
||||
) error{OutOfMemory}!void {
|
||||
switch (self.function) {
|
||||
.value => |value| local.toLocal(value).callWithThis(void, event._current_target.?, .{event}) catch |err| {
|
||||
log.warn(.event, context, .{ .err = err });
|
||||
},
|
||||
.string => |string| {
|
||||
const str = try arena.dupeZ(u8, string.str());
|
||||
local.eval(str, null) catch |err| {
|
||||
log.warn(.event, context, .{ .err = err });
|
||||
};
|
||||
},
|
||||
.object => |obj_global| {
|
||||
const obj = local.toLocal(obj_global);
|
||||
const handle_event = obj.getFunction("handleEvent") catch |err| blk: {
|
||||
log.warn(.event, context, .{ .err = err });
|
||||
break :blk null;
|
||||
};
|
||||
if (handle_event) |handleEvent| {
|
||||
handleEvent.callWithThis(void, obj, .{event}) catch |err| {
|
||||
log.warn(.event, context, .{ .err = err });
|
||||
};
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pub const Function = union(enum) {
|
||||
|
||||
@@ -3007,7 +3007,7 @@ pub fn appendNode(self: *Frame, parent: *Node, child: *Node, opts: InsertNodeOpt
|
||||
}
|
||||
|
||||
pub fn appendAllChildren(self: *Frame, parent: *Node, target: *Node) !void {
|
||||
self.domChanged();
|
||||
target.bumpDomVersion(self);
|
||||
const dest_connected = target.isConnected();
|
||||
|
||||
// Use firstChild() instead of iterator to handle cases where callbacks
|
||||
@@ -3022,7 +3022,7 @@ pub fn appendAllChildren(self: *Frame, parent: *Node, target: *Node) !void {
|
||||
}
|
||||
|
||||
pub fn insertAllChildrenBefore(self: *Frame, fragment: *Node, parent: *Node, ref_node: *Node) !void {
|
||||
self.domChanged();
|
||||
parent.bumpDomVersion(self);
|
||||
const dest_connected = parent.isConnected();
|
||||
|
||||
// Use firstChild() instead of iterator to handle cases where callbacks
|
||||
|
||||
@@ -105,6 +105,12 @@ pub fn deinit(self: *Session) void {
|
||||
self.removePage();
|
||||
}
|
||||
self.cookie_jar.deinit();
|
||||
|
||||
// Force V8 to flush any remaining weak callbacks while
|
||||
// fc_identity_pool is still alive. Identity structs allocated from
|
||||
// this pool back V8 weak-callback parameters; freeing the pool first
|
||||
// would leave dangling pointers that segfault on the next GC.
|
||||
self.browser.env.memoryPressureNotification(.critical);
|
||||
self.fc_identity_pool.deinit();
|
||||
|
||||
self.storage_shed.deinit(self.browser.app.allocator);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<head></head>
|
||||
<body></body>
|
||||
<script src="../../../testing.js"></script>
|
||||
|
||||
<!--
|
||||
Per the HTML spec, the "already started" flag must be propagated when a script
|
||||
element is cloned. A clone of a script that has already run must NOT run again
|
||||
when inserted into a document. This guards a regression where cloneNode lost
|
||||
the flag and re-ran every cloned <script> on each insertion.
|
||||
-->
|
||||
|
||||
<script id=cloned_inline_does_not_rerun>
|
||||
window.cloned_inline_count = 0;
|
||||
const c1_original = document.createElement('script');
|
||||
c1_original.textContent = 'window.cloned_inline_count++;';
|
||||
document.head.appendChild(c1_original);
|
||||
testing.expectEqual(1, window.cloned_inline_count);
|
||||
|
||||
// Clone the executed script and insert the clone. The clone must not re-run.
|
||||
const c1_clone = c1_original.cloneNode(true);
|
||||
document.head.appendChild(c1_clone);
|
||||
testing.expectEqual(1, window.cloned_inline_count);
|
||||
|
||||
// A deep clone of a wrapper containing the script must keep the flag too.
|
||||
const c1_wrapper = document.createElement('div');
|
||||
c1_wrapper.appendChild(c1_original.cloneNode(true));
|
||||
document.body.appendChild(c1_wrapper);
|
||||
testing.expectEqual(1, window.cloned_inline_count);
|
||||
</script>
|
||||
@@ -762,3 +762,95 @@
|
||||
testing.expectEqual(2, calls.length);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id=throw_host><div id=throw_mid><span id=throw_leaf></span></div></div>
|
||||
<script id=listenerThrowDoesNotHaltDispatch>
|
||||
// Per DOM §2.9 step 4 substep 8 "Inner invoke", a listener callback that
|
||||
// throws must have its exception reported, not propagated — subsequent
|
||||
// listeners on the same target and the rest of the propagation path must
|
||||
// still run.
|
||||
{
|
||||
const leaf = $('#throw_leaf');
|
||||
const mid = $('#throw_mid');
|
||||
const host = $('#throw_host');
|
||||
const hits = [];
|
||||
|
||||
leaf.addEventListener('throw_test', () => {
|
||||
hits.push('leaf-throwing');
|
||||
throw new Error('listener-throw probe');
|
||||
});
|
||||
leaf.addEventListener('throw_test', () => hits.push('leaf-sibling'));
|
||||
mid.addEventListener ('throw_test', () => hits.push('mid'));
|
||||
host.addEventListener('throw_test', () => hits.push('host'));
|
||||
document.body.addEventListener('throw_test', () => hits.push('body'));
|
||||
|
||||
// dispatchEvent must NOT throw to the caller — if it did, this assignment
|
||||
// would be skipped and the next testing.expectEqual would never run.
|
||||
const result = leaf.dispatchEvent(new Event('throw_test', {bubbles: true}));
|
||||
|
||||
testing.expectEqual('leaf-throwing', hits[0]);
|
||||
testing.expectEqual('leaf-sibling', hits[1]);
|
||||
testing.expectEqual('mid', hits[2]);
|
||||
testing.expectEqual('host', hits[3]);
|
||||
testing.expectEqual('body', hits[4]);
|
||||
testing.expectEqual(5, hits.length);
|
||||
testing.expectEqual(true, result);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id=throw_capture_host><div id=throw_capture_mid><span id=throw_capture_leaf></span></div></div>
|
||||
<script id=listenerThrowDuringCapturePhase>
|
||||
// Capture-phase variant: a throwing capture listener on an ancestor must
|
||||
// not skip the rest of the capture walk, the at-target listener, or the
|
||||
// bubble walk.
|
||||
{
|
||||
const leaf = $('#throw_capture_leaf');
|
||||
const mid = $('#throw_capture_mid');
|
||||
const host = $('#throw_capture_host');
|
||||
const order = [];
|
||||
|
||||
host.addEventListener('cap_test', () => {
|
||||
order.push('host-capture-throwing');
|
||||
throw new Error('capture-throw probe');
|
||||
}, true);
|
||||
mid.addEventListener('cap_test', () => order.push('mid-capture'), true);
|
||||
leaf.addEventListener('cap_test', () => order.push('leaf-target'));
|
||||
mid.addEventListener('cap_test', () => order.push('mid-bubble'));
|
||||
host.addEventListener('cap_test', () => order.push('host-bubble'));
|
||||
|
||||
leaf.dispatchEvent(new Event('cap_test', {bubbles: true}));
|
||||
|
||||
testing.expectEqual('host-capture-throwing', order[0]);
|
||||
testing.expectEqual('mid-capture', order[1]);
|
||||
testing.expectEqual('leaf-target', order[2]);
|
||||
testing.expectEqual('mid-bubble', order[3]);
|
||||
testing.expectEqual('host-bubble', order[4]);
|
||||
testing.expectEqual(5, order.length);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=listenerThrowOnWindowTarget>
|
||||
// Direct-dispatch variant (non-DOM target — Window). Same contract: a
|
||||
// throwing listener must not stop later listeners from running.
|
||||
{
|
||||
const calls = [];
|
||||
const a = () => { calls.push('a-throwing'); throw new Error('window-throw probe'); };
|
||||
const b = () => calls.push('b');
|
||||
const c = () => calls.push('c');
|
||||
|
||||
window.addEventListener('win_throw', a);
|
||||
window.addEventListener('win_throw', b);
|
||||
window.addEventListener('win_throw', c);
|
||||
|
||||
window.dispatchEvent(new Event('win_throw'));
|
||||
|
||||
testing.expectEqual('a-throwing', calls[0]);
|
||||
testing.expectEqual('b', calls[1]);
|
||||
testing.expectEqual('c', calls[2]);
|
||||
testing.expectEqual(3, calls.length);
|
||||
|
||||
window.removeEventListener('win_throw', a);
|
||||
window.removeEventListener('win_throw', b);
|
||||
window.removeEventListener('win_throw', c);
|
||||
}
|
||||
</script>
|
||||
|
||||
58
src/browser/tests/frames/cross_realm_collection.html
Normal file
58
src/browser/tests/frames/cross_realm_collection.html
Normal file
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<head></head>
|
||||
<body>
|
||||
<script src="../testing.js"></script>
|
||||
|
||||
<!--
|
||||
Live collections (NodeList from .childNodes, HTMLCollection, NodeLive variants
|
||||
like getElementsByTagName) cache state and invalidate on the owning frame's
|
||||
DOM version. Cross-realm callers — parent JS reading `iframe.x.childNodes` —
|
||||
must still see mutations applied through the iframe's frame, even though the
|
||||
caller's own frame.version doesn't bump on those mutations.
|
||||
|
||||
This guards a regression where the cache was tied to the calling frame and
|
||||
returned stale results after iframe-side mutations.
|
||||
-->
|
||||
|
||||
<iframe id=if_collection src="support/cross_realm_collection.html"></iframe>
|
||||
|
||||
<script id=childNodes_invalidates_after_iframe_mutation>
|
||||
testing.onload(() => {
|
||||
const idoc = document.getElementById('if_collection').contentDocument;
|
||||
const iwin = document.getElementById('if_collection').contentWindow;
|
||||
const testDiv = idoc.querySelector('#test');
|
||||
const p0 = idoc.querySelector('#p0');
|
||||
const p1 = idoc.querySelector('#p1');
|
||||
|
||||
// Prime the parent-realm view of the live NodeList.
|
||||
const cn = testDiv.childNodes;
|
||||
testing.expectEqual(3, cn.length);
|
||||
testing.expectEqual(p0, cn[0]);
|
||||
|
||||
// Mutate the iframe's DOM through parent-realm code. The mutation only
|
||||
// bumps the iframe frame's version; the parent's stays unchanged. The
|
||||
// cached collection must still reflect the new state.
|
||||
testDiv.removeChild(p0);
|
||||
|
||||
testing.expectEqual(2, cn.length);
|
||||
testing.expectEqual(p1, cn[0]);
|
||||
testing.expectEqual(undefined, cn[2]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<script id=getElementsByTagName_invalidates_after_iframe_mutation>
|
||||
testing.onload(() => {
|
||||
const idoc = document.getElementById('if_collection').contentDocument;
|
||||
const ps = idoc.getElementsByTagName('p');
|
||||
const seen_initial = ps.length;
|
||||
|
||||
// Append a new <p> through the iframe's document.
|
||||
const fresh = idoc.createElement('p');
|
||||
fresh.id = 'pfresh';
|
||||
idoc.querySelector('#test').appendChild(fresh);
|
||||
|
||||
testing.expectEqual(seen_initial + 1, ps.length);
|
||||
testing.expectEqual(fresh, ps[ps.length - 1]);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@@ -0,0 +1,3 @@
|
||||
<!DOCTYPE html>
|
||||
<head></head>
|
||||
<body><div id=test><p id=p0>p0</p><p id=p1>p1</p><p id=p2>p2</p></div></body>
|
||||
@@ -65,3 +65,32 @@
|
||||
assertChildren(['a'], d3);
|
||||
testing.expectEqual(null, b.parentNode);
|
||||
</script>
|
||||
|
||||
<div id=d4></div>
|
||||
<div id=d4_stash></div>
|
||||
<script id=appendChild_disconnect_callback_reparents>
|
||||
// Moving a connected child into a disconnected target makes
|
||||
// will_be_reconnected=false, so disconnectedCallback fires synchronously
|
||||
// inside removeNode. The callback re-parents the child; appendChild
|
||||
// respects that placement instead of overriding it.
|
||||
const d4 = $('#d4');
|
||||
const stash = $('#d4_stash');
|
||||
|
||||
class ReparentOnDisconnect extends HTMLElement {
|
||||
disconnectedCallback() {
|
||||
if (this.parentNode === null) {
|
||||
stash.appendChild(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
customElements.define('reparent-on-disconnect', ReparentOnDisconnect);
|
||||
|
||||
const rpd = document.createElement('reparent-on-disconnect');
|
||||
rpd.id = 'rpd';
|
||||
d4.appendChild(rpd);
|
||||
|
||||
const detached = document.createElement('div');
|
||||
detached.appendChild(rpd);
|
||||
|
||||
testing.expectEqual(stash, rpd.parentNode);
|
||||
</script>
|
||||
|
||||
@@ -39,3 +39,34 @@
|
||||
assertChildren([], d1);
|
||||
assertChildren([c1, c2], d2);
|
||||
</script>
|
||||
|
||||
<div id=d3></div>
|
||||
<div id=d3_stash></div>
|
||||
<script id=insertBefore_disconnect_callback_reparents>
|
||||
// Same disconnectedCallback re-parenting pattern as in append_child.html,
|
||||
// exercised through insertBefore. insertBefore respects the callback's
|
||||
// placement instead of overriding it.
|
||||
const d3 = $('#d3');
|
||||
const stash = $('#d3_stash');
|
||||
|
||||
class IBReparentOnDisconnect extends HTMLElement {
|
||||
disconnectedCallback() {
|
||||
if (this.parentNode === null) {
|
||||
stash.appendChild(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
customElements.define('ib-reparent-on-disconnect', IBReparentOnDisconnect);
|
||||
|
||||
const moving = document.createElement('ib-reparent-on-disconnect');
|
||||
moving.id = 'ib_moving';
|
||||
d3.appendChild(moving);
|
||||
|
||||
const detached = document.createElement('div');
|
||||
const ref = document.createElement('span');
|
||||
detached.appendChild(ref);
|
||||
|
||||
detached.insertBefore(moving, ref);
|
||||
|
||||
testing.expectEqual(stash, moving.parentNode);
|
||||
</script>
|
||||
|
||||
@@ -42,4 +42,4 @@
|
||||
</script>
|
||||
|
||||
<!-- Leave it, it used to crash -->
|
||||
<script src='empty.js=["violated-directive=worker-src","TEST COMPLETE"]'></script>
|
||||
<script src='empty.js?x=["violated-directive=worker-src","TEST COMPLETE"]'></script>
|
||||
|
||||
@@ -488,8 +488,8 @@ pub fn importNode(_: *const Document, node: *Node, deep_: ?bool, frame: *Frame)
|
||||
pub fn append(self: *Document, nodes: []const Node.NodeOrText, frame: *Frame) !void {
|
||||
try validateDocumentNodes(self, nodes, false);
|
||||
|
||||
frame.domChanged();
|
||||
const parent = self.asNode();
|
||||
parent.bumpDomVersion(frame);
|
||||
const parent_is_connected = parent.isConnected();
|
||||
|
||||
for (nodes) |node_or_text| {
|
||||
@@ -513,8 +513,8 @@ pub fn append(self: *Document, nodes: []const Node.NodeOrText, frame: *Frame) !v
|
||||
pub fn prepend(self: *Document, nodes: []const Node.NodeOrText, frame: *Frame) !void {
|
||||
try validateDocumentNodes(self, nodes, false);
|
||||
|
||||
frame.domChanged();
|
||||
const parent = self.asNode();
|
||||
parent.bumpDomVersion(frame);
|
||||
const parent_is_connected = parent.isConnected();
|
||||
|
||||
var i = nodes.len;
|
||||
@@ -736,7 +736,7 @@ fn writeInternal(self: *Document, text: []const []const u8, append_newline: bool
|
||||
insert_after = child;
|
||||
}
|
||||
|
||||
frame.domChanged();
|
||||
parent.bumpDomVersion(frame);
|
||||
self._write_insertion_point = children_to_insert.getLast();
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ pub fn getInnerHTML(self: *DocumentFragment, writer: *std.Io.Writer, frame: *Fra
|
||||
pub fn setInnerHTML(self: *DocumentFragment, html: []const u8, frame: *Frame) !void {
|
||||
const parent = self.asNode();
|
||||
|
||||
frame.domChanged();
|
||||
parent.bumpDomVersion(frame);
|
||||
var it = parent.childrenIterator();
|
||||
while (it.next()) |child| {
|
||||
frame.removeNode(parent, child, .{ .will_be_reconnected = false });
|
||||
|
||||
@@ -467,7 +467,7 @@ pub fn setOuterHTML(self: *Element, html: []const u8, frame: *Frame) !void {
|
||||
const node = self.asNode();
|
||||
const parent = node._parent orelse return;
|
||||
|
||||
frame.domChanged();
|
||||
parent.bumpDomVersion(frame);
|
||||
if (html.len > 0) {
|
||||
const fragment = (try Node.DocumentFragment.init(frame)).asNode();
|
||||
try frame.parseHtmlAsChildren(fragment, html);
|
||||
@@ -493,7 +493,7 @@ pub fn setInnerHTML(self: *Element, html: []const u8, frame: *Frame) !void {
|
||||
// fires disconnectedCallback for custom elements, which can mutate
|
||||
// the child list and dangle any cached next-pointer the iterator
|
||||
// would otherwise hold.
|
||||
frame.domChanged();
|
||||
parent.bumpDomVersion(frame);
|
||||
while (parent.firstChild()) |child| {
|
||||
frame.removeNode(parent, child, .{ .will_be_reconnected = false });
|
||||
}
|
||||
@@ -879,10 +879,9 @@ pub fn replaceChildren(self: *Element, nodes: []const Node.NodeOrText, frame: *F
|
||||
}
|
||||
|
||||
pub fn replaceWith(self: *Element, nodes: []const Node.NodeOrText, frame: *Frame) !void {
|
||||
frame.domChanged();
|
||||
|
||||
const ref_node = self.asNode();
|
||||
const parent = ref_node._parent orelse return;
|
||||
parent.bumpDomVersion(frame);
|
||||
|
||||
const parent_is_connected = parent.isConnected();
|
||||
|
||||
@@ -919,9 +918,9 @@ pub fn replaceWith(self: *Element, nodes: []const Node.NodeOrText, frame: *Frame
|
||||
}
|
||||
|
||||
pub fn remove(self: *Element, frame: *Frame) void {
|
||||
frame.domChanged();
|
||||
const node = self.asNode();
|
||||
const parent = node._parent orelse return;
|
||||
parent.bumpDomVersion(frame);
|
||||
frame.removeNode(parent, node, .{ .will_be_reconnected = false });
|
||||
}
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@ pub fn appendChild(self: *Node, child: *Node, frame: *Frame) !*Node {
|
||||
|
||||
try validateNodeInsertion(self, child);
|
||||
|
||||
frame.domChanged();
|
||||
self.bumpDomVersion(frame);
|
||||
|
||||
// If the child is currently connected, and if its new parent is connected,
|
||||
// then we can remove + add a bit more efficiently (we don't have to fully
|
||||
@@ -253,6 +253,11 @@ pub fn appendChild(self: *Node, child: *Node, frame: *Frame) !*Node {
|
||||
try frame.adoptNodeTree(child, child_owner.?, parent_owner);
|
||||
}
|
||||
|
||||
// A custom element callback can re-parent the node. If it does, we're done
|
||||
if (child._parent != null) {
|
||||
return child;
|
||||
}
|
||||
|
||||
try frame.appendNode(self, child, .{
|
||||
.child_already_connected = child_connected,
|
||||
.adopting_to_new_document = adopting_to_new_document,
|
||||
@@ -512,11 +517,30 @@ pub fn ownerDocument(self: *const Node, frame: *const Frame) ?*Document {
|
||||
return frame.document;
|
||||
}
|
||||
|
||||
// Returns the Frame that owns this node's tree. Used to tie cached state of
|
||||
// "live" collections (NodeList, HTMLCollection, etc.) to the right frame's DOM
|
||||
// version: cross-realm callers must invalidate based on mutations through the
|
||||
// node's owning frame, not the caller's frame.
|
||||
//
|
||||
// Falls back to `default` when the node has no associated document yet (e.g.,
|
||||
// freshly created and detached) or its document has no frame.
|
||||
pub fn ownerFrame(self: *const Node, default: *Frame) *Frame {
|
||||
if (self._type == .document) {
|
||||
return self._type.document._frame orelse default;
|
||||
}
|
||||
const doc = self.ownerDocument(default) orelse return default;
|
||||
return doc._frame orelse default;
|
||||
}
|
||||
|
||||
// Tells the owning frame that this node's subtree has changed. Use this from
|
||||
// mutation paths instead of `frame.domChanged()` so cross-realm mutations
|
||||
// (e.g., parent JS mutating an iframe's DOM) bump the iframe's version, not
|
||||
// the caller's. Otherwise, live collections that key off the owning frame's
|
||||
// version won't see the change and will return stale cached state.
|
||||
pub fn bumpDomVersion(self: *const Node, default: *Frame) void {
|
||||
self.ownerFrame(default).domChanged();
|
||||
}
|
||||
|
||||
pub const ResolveURLOpts = struct {
|
||||
allocator: ?Allocator = null,
|
||||
};
|
||||
@@ -548,7 +572,7 @@ pub fn removeChild(self: *Node, child: *Node, frame: *Frame) !*Node {
|
||||
var it = self.childrenIterator();
|
||||
while (it.next()) |n| {
|
||||
if (n == child) {
|
||||
frame.domChanged();
|
||||
self.bumpDomVersion(frame);
|
||||
frame.removeNode(self, child, .{ .will_be_reconnected = false });
|
||||
return child;
|
||||
}
|
||||
@@ -563,7 +587,7 @@ pub fn insertBefore(self: *Node, new_node: *Node, ref_node_: ?*Node, frame: *Fra
|
||||
|
||||
// special case: if nodes are the same, ignore the change.
|
||||
if (new_node == ref_node_) {
|
||||
frame.domChanged();
|
||||
self.bumpDomVersion(frame);
|
||||
|
||||
if (frame.hasMutationObservers()) {
|
||||
const parent = new_node._parent.?;
|
||||
@@ -594,7 +618,7 @@ pub fn insertBefore(self: *Node, new_node: *Node, ref_node_: ?*Node, frame: *Fra
|
||||
const parent_owner = self.ownerDocument(frame) orelse self.as(Document);
|
||||
const adopting_to_new_document = child_owner != null and child_owner.? != parent_owner;
|
||||
|
||||
frame.domChanged();
|
||||
self.bumpDomVersion(frame);
|
||||
const will_be_reconnected = self.isConnected() and !adopting_to_new_document;
|
||||
if (new_node._parent) |parent| {
|
||||
frame.removeNode(parent, new_node, .{ .will_be_reconnected = will_be_reconnected });
|
||||
@@ -605,6 +629,22 @@ pub fn insertBefore(self: *Node, new_node: *Node, ref_node_: ?*Node, frame: *Fra
|
||||
try frame.adoptNodeTree(new_node, child_owner.?, parent_owner);
|
||||
}
|
||||
|
||||
// See Node.appendChild: a callback above (disconnectedCallback or
|
||||
// adoptedCallback) can re-parent new_node. Let that placement stand.
|
||||
if (new_node._parent != null) {
|
||||
return new_node;
|
||||
}
|
||||
|
||||
// The same callback could also have detached ref_node from self. Fall
|
||||
// back to append so new_node still lands in self.
|
||||
if (ref_node._parent != self) {
|
||||
try frame.appendNode(self, new_node, .{
|
||||
.child_already_connected = child_already_connected,
|
||||
.adopting_to_new_document = adopting_to_new_document,
|
||||
});
|
||||
return new_node;
|
||||
}
|
||||
|
||||
try frame.insertNodeRelative(
|
||||
self,
|
||||
new_node,
|
||||
@@ -1049,7 +1089,7 @@ pub fn replaceChildren(self: *Node, nodes: []const NodeOrText, frame: *Frame) !v
|
||||
}
|
||||
}
|
||||
|
||||
frame.domChanged();
|
||||
self.bumpDomVersion(frame);
|
||||
|
||||
// Remove all existing children
|
||||
var it = self.childrenIterator();
|
||||
|
||||
@@ -374,7 +374,7 @@ pub fn deleteContents(self: *Range, frame: *Frame) !void {
|
||||
if (self._proto.getCollapsed()) {
|
||||
return;
|
||||
}
|
||||
frame.domChanged();
|
||||
self._proto._start_container.bumpDomVersion(frame);
|
||||
|
||||
// Simple case: same container
|
||||
if (self._proto._start_container == self._proto._end_container) {
|
||||
|
||||
@@ -34,8 +34,11 @@ _arena: std.mem.Allocator,
|
||||
_last_index: usize,
|
||||
_last_length: ?u32,
|
||||
_last_node: ?*std.DoublyLinkedList.Node,
|
||||
// Version observed on `_owning_frame` the last time we refreshed our cache.
|
||||
// Compare against `_owning_frame.version` to detect DOM mutations.
|
||||
_cached_version: usize,
|
||||
_node: *Node,
|
||||
_owning_frame: *Frame,
|
||||
|
||||
pub const KeyIterator = GenericIterator(Iterator, "0");
|
||||
pub const ValueIterator = GenericIterator(Iterator, "1");
|
||||
@@ -45,6 +48,8 @@ pub fn init(node: *Node, frame: *Frame) !*ChildNodes {
|
||||
const arena = try frame.getArena(.small, "ChildNodes");
|
||||
errdefer frame.releaseArena(arena);
|
||||
|
||||
const owning_frame = node.ownerFrame(frame);
|
||||
|
||||
const self = try arena.create(ChildNodes);
|
||||
self.* = .{
|
||||
._node = node,
|
||||
@@ -52,7 +57,8 @@ pub fn init(node: *Node, frame: *Frame) !*ChildNodes {
|
||||
._last_index = 0,
|
||||
._last_node = null,
|
||||
._last_length = null,
|
||||
._cached_version = frame.version,
|
||||
._cached_version = owning_frame.version,
|
||||
._owning_frame = owning_frame,
|
||||
};
|
||||
return self;
|
||||
}
|
||||
@@ -61,8 +67,8 @@ pub fn deinit(self: *const ChildNodes, page: *Page) void {
|
||||
page.releaseArena(self._arena);
|
||||
}
|
||||
|
||||
pub fn length(self: *ChildNodes, frame: *Frame) !u32 {
|
||||
if (self.versionCheck(frame)) {
|
||||
pub fn length(self: *ChildNodes, _: *Frame) !u32 {
|
||||
if (self.versionCheck()) {
|
||||
if (self._last_length) |cached_length| {
|
||||
return cached_length;
|
||||
}
|
||||
@@ -75,16 +81,16 @@ pub fn length(self: *ChildNodes, frame: *Frame) !u32 {
|
||||
return len;
|
||||
}
|
||||
|
||||
pub fn getAtIndex(self: *ChildNodes, index: usize, frame: *Frame) !?*Node {
|
||||
_ = self.versionCheck(frame);
|
||||
pub fn getAtIndex(self: *ChildNodes, index: usize, _: *Frame) !?*Node {
|
||||
_ = self.versionCheck();
|
||||
|
||||
var current = self._last_index;
|
||||
var node: ?*std.DoublyLinkedList.Node = null;
|
||||
if (index < current) {
|
||||
if (index < current or self._last_node == null) {
|
||||
current = 0;
|
||||
node = self.first() orelse return null;
|
||||
} else {
|
||||
node = self._last_node orelse self.first() orelse return null;
|
||||
node = self._last_node;
|
||||
}
|
||||
defer self._last_index = current;
|
||||
|
||||
@@ -116,8 +122,8 @@ pub fn entries(self: *ChildNodes, frame: *Frame) !*EntryIterator {
|
||||
return .init(.{ .list = self }, frame);
|
||||
}
|
||||
|
||||
fn versionCheck(self: *ChildNodes, frame: *Frame) bool {
|
||||
const current = frame.version;
|
||||
fn versionCheck(self: *ChildNodes) bool {
|
||||
const current = self._owning_frame.version;
|
||||
if (current == self._cached_version) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -32,19 +32,23 @@ _tw: TreeWalker.FullExcludeSelf,
|
||||
_last_index: usize,
|
||||
_last_length: ?u32,
|
||||
_cached_version: usize,
|
||||
_owning_frame: *Frame,
|
||||
|
||||
pub fn init(root: *Node, frame: *Frame) HTMLAllCollection {
|
||||
const owning_frame = root.ownerFrame(frame);
|
||||
return .{
|
||||
._last_index = 0,
|
||||
._last_length = null,
|
||||
._tw = TreeWalker.FullExcludeSelf.init(root, .{}),
|
||||
._cached_version = frame.version,
|
||||
._cached_version = owning_frame.version,
|
||||
._owning_frame = owning_frame,
|
||||
};
|
||||
}
|
||||
|
||||
fn versionCheck(self: *HTMLAllCollection, frame: *const Frame) bool {
|
||||
if (self._cached_version != frame.version) {
|
||||
self._cached_version = frame.version;
|
||||
fn versionCheck(self: *HTMLAllCollection, _: *const Frame) bool {
|
||||
const current = self._owning_frame.version;
|
||||
if (self._cached_version != current) {
|
||||
self._cached_version = current;
|
||||
self._last_index = 0;
|
||||
self._last_length = null;
|
||||
self._tw.reset();
|
||||
|
||||
@@ -99,16 +99,19 @@ pub fn NodeLive(comptime mode: Mode) type {
|
||||
_last_index: usize,
|
||||
_last_length: ?u32,
|
||||
_cached_version: usize,
|
||||
_owning_frame: *Frame,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
pub fn init(root: *Node, filter: Filter, frame: *Frame) Self {
|
||||
const owning_frame = root.ownerFrame(frame);
|
||||
return .{
|
||||
._last_index = 0,
|
||||
._last_length = null,
|
||||
._filter = filter,
|
||||
._tw = TW.init(root, .{}),
|
||||
._cached_version = frame.version,
|
||||
._cached_version = owning_frame.version,
|
||||
._owning_frame = owning_frame,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -342,8 +345,8 @@ pub fn NodeLive(comptime mode: Mode) type {
|
||||
};
|
||||
}
|
||||
|
||||
fn versionCheck(self: *Self, frame: *const Frame) bool {
|
||||
const current = frame.version;
|
||||
fn versionCheck(self: *Self, _: *const Frame) bool {
|
||||
const current = self._owning_frame.version;
|
||||
if (current == self._cached_version) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ pub const List = struct {
|
||||
};
|
||||
try frame.addElementId(parent, element, entry._value.str());
|
||||
}
|
||||
frame.domChanged();
|
||||
element.asNode().bumpDomVersion(frame);
|
||||
frame.attributeChange(element, result.normalized, entry._value, old_value);
|
||||
return entry;
|
||||
}
|
||||
@@ -292,7 +292,7 @@ pub const List = struct {
|
||||
frame.removeElementId(element, entry._value.str());
|
||||
}
|
||||
|
||||
frame.domChanged();
|
||||
element.asNode().bumpDomVersion(frame);
|
||||
frame.attributeRemove(element, result.normalized, old_value);
|
||||
_ = frame._attribute_lookup.remove(@intFromPtr(entry));
|
||||
self._list.remove(&entry._node);
|
||||
|
||||
@@ -273,7 +273,7 @@ pub fn setInnerText(self: *HtmlElement, text: []const u8, frame: *Frame) !void {
|
||||
const parent = self.asElement().asNode();
|
||||
|
||||
// Remove all existing children
|
||||
frame.domChanged();
|
||||
parent.bumpDomVersion(frame);
|
||||
var it = parent.childrenIterator();
|
||||
while (it.next()) |child| {
|
||||
frame.removeNode(parent, child, .{ .will_be_reconnected = false });
|
||||
|
||||
@@ -80,7 +80,7 @@ pub fn setSelected(self: *Option, selected: bool, frame: *Frame) !void {
|
||||
// TODO: When setting selected=true, may need to unselect other options
|
||||
// in the parent <select> if it doesn't have multiple attribute
|
||||
self._selected = selected;
|
||||
frame.domChanged();
|
||||
self.asElement().asNode().bumpDomVersion(frame);
|
||||
}
|
||||
|
||||
pub fn getDefaultSelected(self: *const Option) bool {
|
||||
|
||||
@@ -151,6 +151,15 @@ pub const Build = struct {
|
||||
const element = self.asElement();
|
||||
self._src = element.getAttributeSafe(comptime .wrap("src")) orelse "";
|
||||
}
|
||||
|
||||
// Per the HTML spec, the "already started" flag must be propagated to the
|
||||
// clone so that re-inserting a cloned <script> doesn't run it again.
|
||||
pub fn cloned(source_element: *Element, cloned_element: *Element, _: *Frame) !void {
|
||||
const source = source_element.as(Script);
|
||||
const clone = cloned_element.as(Script);
|
||||
clone._executed = source._executed;
|
||||
clone._force_async = source._force_async;
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../../../testing.zig");
|
||||
|
||||
@@ -249,10 +249,17 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void {
|
||||
|
||||
var response = self._response;
|
||||
response._http_response = null;
|
||||
|
||||
// Capture this before we reject. Rejection could trigger httpShutdownCallback
|
||||
// (via a microtask callback). But if we're here, then we'll take care of
|
||||
// cleaning up when we're done.
|
||||
const owns_response = self._owns_response;
|
||||
self._owns_response = false;
|
||||
|
||||
// the response is only passed on v8 on success, if we're here, it's safe to
|
||||
// clear this. (defer since `self is in the response's arena).
|
||||
|
||||
defer if (self._owns_response) {
|
||||
defer if (owns_response) {
|
||||
response.deinit(self._exec.context.page);
|
||||
};
|
||||
|
||||
@@ -266,10 +273,6 @@ fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void {
|
||||
|
||||
fn httpShutdownCallback(ctx: *anyopaque) void {
|
||||
const self: *Fetch = @ptrCast(@alignCast(ctx));
|
||||
if (comptime IS_DEBUG) {
|
||||
// should always be true
|
||||
std.debug.assert(self._owns_response);
|
||||
}
|
||||
|
||||
if (self._owns_response) {
|
||||
var response = self._response;
|
||||
|
||||
@@ -22,10 +22,12 @@ const js = @import("../../js/js.zig");
|
||||
const http = @import("../../../network/http.zig");
|
||||
|
||||
const URL = @import("../URL.zig");
|
||||
const Headers = @import("Headers.zig");
|
||||
const Blob = @import("../Blob.zig");
|
||||
const AbortSignal = @import("../AbortSignal.zig");
|
||||
|
||||
const Headers = @import("Headers.zig");
|
||||
const BodyInit = @import("body_init.zig").BodyInit;
|
||||
|
||||
const Execution = js.Execution;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
@@ -48,7 +50,7 @@ pub const Input = union(enum) {
|
||||
pub const InitOpts = struct {
|
||||
method: ?[]const u8 = null,
|
||||
headers: ?Headers.InitOpts = null,
|
||||
body: ?[]const u8 = null,
|
||||
body: ?BodyInit = null,
|
||||
cache: Cache = .default,
|
||||
credentials: Credentials = .@"same-origin",
|
||||
signal: ?*AbortSignal = null,
|
||||
@@ -86,7 +88,7 @@ pub fn init(input: Input, opts_: ?InitOpts, exec: *const Execution) !*Request {
|
||||
.request => |r| r._method,
|
||||
};
|
||||
|
||||
const headers = if (opts.headers) |headers_init| switch (headers_init) {
|
||||
var headers = if (opts.headers) |headers_init| switch (headers_init) {
|
||||
.obj => |h| h,
|
||||
else => try Headers.init(headers_init, exec),
|
||||
} else switch (input) {
|
||||
@@ -94,9 +96,19 @@ pub fn init(input: Input, opts_: ?InitOpts, exec: *const Execution) !*Request {
|
||||
.request => |r| r._headers,
|
||||
};
|
||||
|
||||
const body = if (opts.body) |b|
|
||||
try arena.dupe(u8, b)
|
||||
else switch (input) {
|
||||
const body = if (opts.body) |b| blk: {
|
||||
const extracted = try b.extract(arena);
|
||||
// Per Fetch §6.5 step 11, the default Content-Type only applies if
|
||||
// the user has not already set one via the headers init dict.
|
||||
if (extracted.content_type) |ct| {
|
||||
const hs = headers orelse try Headers.init(null, exec);
|
||||
if (!hs.has("content-type", exec)) {
|
||||
try hs.append("content-type", ct, exec);
|
||||
}
|
||||
headers = hs;
|
||||
}
|
||||
break :blk extracted.bytes;
|
||||
} else switch (input) {
|
||||
.url => null,
|
||||
.request => |r| r._body,
|
||||
};
|
||||
|
||||
@@ -29,8 +29,11 @@ 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");
|
||||
|
||||
const Headers = @import("Headers.zig");
|
||||
const Request = @import("Request.zig");
|
||||
const BodyInit = @import("body_init.zig").BodyInit;
|
||||
const XMLHttpRequestEventTarget = @import("XMLHttpRequestEventTarget.zig");
|
||||
|
||||
const log = lp.log;
|
||||
@@ -221,7 +224,7 @@ pub fn setRequestHeader(self: *XMLHttpRequest, name: []const u8, value: []const
|
||||
return self._request_headers.append(name, value, exec);
|
||||
}
|
||||
|
||||
pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
|
||||
pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !void {
|
||||
if (comptime IS_DEBUG) {
|
||||
log.debug(.http, "XMLHttpRequest.send", .{ .url = self._url });
|
||||
}
|
||||
@@ -231,7 +234,16 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
|
||||
|
||||
if (body_) |b| {
|
||||
if (self._method != .GET and self._method != .HEAD) {
|
||||
self._request_body = try self._arena.dupe(u8, b);
|
||||
const extracted = try b.extract(self._arena);
|
||||
self._request_body = extracted.bytes;
|
||||
// Per XHR §4.7.6 "send()" step 4, the default Content-Type only
|
||||
// applies if the author hasn't already set one via
|
||||
// setRequestHeader.
|
||||
if (extracted.content_type) |ct| {
|
||||
if (!self._request_headers.has("content-type", exec_)) {
|
||||
try self._request_headers.append("content-type", ct, exec_);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
157
src/browser/webapi/net/body_init.zig
Normal file
157
src/browser/webapi/net/body_init.zig
Normal file
@@ -0,0 +1,157 @@
|
||||
// Copyright (C) 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/>.
|
||||
|
||||
// BodyInit — accepted body shapes for fetch(Request) / XHR.send().
|
||||
//
|
||||
// Per Fetch §6.5 "extract a body" (https://fetch.spec.whatwg.org/#concept-bodyinit-extract)
|
||||
// and XHR §4.7.6 "send()" (https://xhr.spec.whatwg.org/#dom-xmlhttprequest-send),
|
||||
// the runtime must serialize the body and select the matching default
|
||||
// Content-Type. Without this layer the JS→Zig bridge falls back to
|
||||
// toStringSmart() on the JSValue, which sends "[object FormData]" for
|
||||
// FormData (issue #2357) and skips the multipart encoding wired up at
|
||||
// FormData.multipartEncode (./FormData.zig:198).
|
||||
//
|
||||
// The union arms are ordered so the bridge's tagged-union prober matches
|
||||
// the most specific JsApi class first; the trailing `bytes: []const u8`
|
||||
// arm soaks up strings (and via .coerce, anything string-like) so plain
|
||||
// text bodies still work unchanged.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const Blob = @import("../Blob.zig");
|
||||
|
||||
const FormData = @import("FormData.zig");
|
||||
const URLSearchParams = @import("URLSearchParams.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
pub const BodyInit = union(enum) {
|
||||
blob: *Blob,
|
||||
form_data: *FormData,
|
||||
url_search_params: *URLSearchParams,
|
||||
bytes: []const u8, // must be last, js.Bridge will map anything to a string
|
||||
|
||||
pub fn extract(self: BodyInit, arena: Allocator) !Extracted {
|
||||
switch (self) {
|
||||
.bytes => |b| {
|
||||
// String bodies: dupe as-is. Per Fetch §6.5 step 4, the default
|
||||
// Content-Type for USVString is "text/plain;charset=UTF-8";
|
||||
// emit it so callers without an explicit header still pass spec
|
||||
// checks. Pre-fix behaviour also omitted this; tests that depend
|
||||
// on no Content-Type for string bodies should set one explicitly.
|
||||
return .{
|
||||
.bytes = try arena.dupe(u8, b),
|
||||
.content_type = "text/plain;charset=UTF-8",
|
||||
};
|
||||
},
|
||||
.url_search_params => |usp| {
|
||||
var buf = std.Io.Writer.Allocating.init(arena);
|
||||
try usp.toString(&buf.writer);
|
||||
return .{
|
||||
.bytes = buf.written(),
|
||||
.content_type = "application/x-www-form-urlencoded;charset=UTF-8",
|
||||
};
|
||||
},
|
||||
.form_data => |fd| {
|
||||
var rand_bytes: [10]u8 = undefined;
|
||||
std.crypto.random.bytes(&rand_bytes);
|
||||
const hex = std.fmt.bytesToHex(rand_bytes, .lower);
|
||||
|
||||
var boundary: [24]u8 = undefined;
|
||||
@memcpy(boundary[0..4], "----");
|
||||
@memcpy(boundary[4..], &hex);
|
||||
|
||||
var buf = std.Io.Writer.Allocating.init(arena);
|
||||
try fd.write(.{
|
||||
.allocator = arena,
|
||||
.encoding = .{ .formdata = &boundary },
|
||||
}, &buf.writer);
|
||||
|
||||
const ct = try std.fmt.allocPrint(arena, "multipart/form-data; boundary={s}", .{boundary});
|
||||
return .{
|
||||
.bytes = buf.written(),
|
||||
.content_type = ct,
|
||||
};
|
||||
},
|
||||
.blob => |blob| {
|
||||
return .{
|
||||
.bytes = try arena.dupe(u8, blob._slice),
|
||||
.content_type = if (blob._mime.len > 0) try arena.dupe(u8, blob._mime) else null,
|
||||
};
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Result of extracting a body. `bytes` is duped into the caller's arena.
|
||||
// `content_type`, when non-null, is the spec-mandated default Content-Type
|
||||
// for the body source — callers MUST only apply it if the user has not
|
||||
// already set a Content-Type header (per Fetch §6.5).
|
||||
pub const Extracted = struct {
|
||||
bytes: []const u8,
|
||||
content_type: ?[]const u8,
|
||||
};
|
||||
|
||||
const testing = @import("../../../testing.zig");
|
||||
test "BodyInit: bytes pass through with text/plain" {
|
||||
defer testing.reset();
|
||||
const r = try (BodyInit{ .bytes = "hello" }).extract(testing.arena_allocator);
|
||||
try testing.expectString("hello", r.bytes);
|
||||
try testing.expectString("text/plain;charset=UTF-8", r.content_type.?);
|
||||
}
|
||||
|
||||
test "BodyInit: URLSearchParams emit urlencoded body + content-type" {
|
||||
defer testing.reset();
|
||||
const arena = testing.arena_allocator;
|
||||
|
||||
const usp = try arena.create(URLSearchParams);
|
||||
usp.* = .{ ._arena = arena, ._params = .empty };
|
||||
try usp.append("a", "1");
|
||||
try usp.append("b", "2");
|
||||
|
||||
const r = try (BodyInit{ .url_search_params = usp }).extract(arena);
|
||||
try testing.expectString("a=1&b=2", r.bytes);
|
||||
try testing.expectString("application/x-www-form-urlencoded;charset=UTF-8", r.content_type.?);
|
||||
}
|
||||
|
||||
test "BodyInit: FormData emits multipart with random boundary" {
|
||||
defer testing.reset();
|
||||
const arena = testing.arena_allocator;
|
||||
|
||||
const fd = try arena.create(FormData);
|
||||
fd.* = .{ ._arena = arena, ._entries = .empty };
|
||||
try fd.append("username", "alice");
|
||||
try fd.append("email", "alice@example.com");
|
||||
|
||||
const r = try (BodyInit{ .form_data = fd }).extract(arena);
|
||||
|
||||
// Body must contain the entries' Content-Disposition lines and end with
|
||||
// the closing boundary marker.
|
||||
const boundary = r.content_type.?["multipart/form-data; boundary=".len..];
|
||||
try testing.expectEqual(true, std.mem.startsWith(u8, boundary, "----"));
|
||||
try testing.expectEqual(true, boundary.len > 10);
|
||||
|
||||
try testing.expect(std.mem.indexOf(u8, r.bytes, "Content-Disposition: form-data; name=\"username\"") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, r.bytes, "Content-Disposition: form-data; name=\"email\"") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, r.bytes, "alice") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, r.bytes, "alice@example.com") != null);
|
||||
const closer = try std.fmt.allocPrint(arena, "--{s}--\r\n", .{boundary});
|
||||
try testing.expect(std.mem.endsWith(u8, r.bytes, closer));
|
||||
}
|
||||
|
||||
// Blob.extract is exercised end-to-end by the Request/XHR HTML fixture
|
||||
// tests rather than constructed ad-hoc here — Blob owns `_type`, `_rc`,
|
||||
// and `_arena` fields that need a Page-backed allocator to initialise
|
||||
// safely.
|
||||
@@ -365,6 +365,16 @@ pub fn disposeBrowserContext(self: *CDP, browser_context_id: []const u8) bool {
|
||||
if (std.mem.eql(u8, bc.id, browser_context_id) == false) {
|
||||
return false;
|
||||
}
|
||||
// Reentrant teardown from a CDP message drained inside HttpClient.syncRequest.
|
||||
// Tearing down the browser context here would free Session/Page state
|
||||
// that the unwinding script-eval frame above us is about to dereference
|
||||
// (see Session.removePage's matching guard). Defer cleanup to
|
||||
// CDP.deinit at connection close, by which time eval has unwound.
|
||||
if (bc.session.currentPage()) |page| {
|
||||
if (page.frame._script_manager.base.is_evaluating) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
bc.deinit();
|
||||
self.browser.closeSession();
|
||||
self.browser_context = null;
|
||||
|
||||
Reference in New Issue
Block a user