Merge branch 'main' into agent

This commit is contained in:
Adrià Arrufat
2026-04-30 07:36:48 +02:00
51 changed files with 5024 additions and 949 deletions

79
.github/workflows/package-archlinux.yml vendored Normal file
View File

@@ -0,0 +1,79 @@
name: package archlinux
on:
workflow_call:
permissions:
contents: write
env:
RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }}
jobs:
package:
strategy:
fail-fast: false
matrix:
arch: [x86_64, aarch64]
env:
ARCH: ${{ matrix.arch }}
OS: linux
runs-on: ubuntu-22.04
container: archlinux:latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- name: Install packaging deps
run: pacman -Syu --noconfirm --needed base-devel sudo
- name: Download linux binary
uses: actions/download-artifact@v4
with:
name: lightpanda-${{ env.ARCH }}-${{ env.OS }}
path: .
- name: Build Arch package
run: |
useradd -m builder
echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
RAW_VERSION="${{ env.RELEASE }}"
PKGVER="${RAW_VERSION#v}"
PKGREL="1"
echo "PKGVER=${PKGVER}" >> "$GITHUB_ENV"
echo "PKGREL=${PKGREL}" >> "$GITHUB_ENV"
mkdir -p pkg
cp lightpanda-${{ env.ARCH }}-${{ env.OS }} pkg/
cp LICENSE pkg/
cat > pkg/PKGBUILD <<EOF
pkgname=lightpanda
pkgver=${PKGVER}
pkgrel=${PKGREL}
pkgdesc="Lightpanda, headless browser built for AI and automation"
arch=('x86_64' 'aarch64')
url="https://lightpanda.io"
license=('AGPL-3.0-or-later')
options=(!strip !debug)
package() {
install -Dm755 "\$startdir/lightpanda-\$CARCH-linux" "\$pkgdir/usr/bin/lightpanda"
install -Dm644 "\$startdir/LICENSE" "\$pkgdir/usr/share/licenses/\$pkgname/LICENSE"
}
EOF
chown -R builder:builder pkg
cd pkg
sudo -u builder env CARCH=${{ env.ARCH }} makepkg -f --noconfirm -A
- name: Upload Arch package to release
uses: ncipollo/release-action@v1
with:
allowUpdates: true
artifacts: pkg/lightpanda-${{ env.PKGVER }}-${{ env.PKGREL }}-${{ env.ARCH }}.pkg.tar.zst
tag: ${{ env.RELEASE }}
makeLatest: true

78
.github/workflows/package-debian.yml vendored Normal file
View File

@@ -0,0 +1,78 @@
name: package debian
on:
workflow_call:
permissions:
contents: write
env:
RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }}
jobs:
package:
strategy:
fail-fast: false
matrix:
include:
- arch: x86_64
deb_arch: amd64
- arch: aarch64
deb_arch: arm64
env:
ARCH: ${{ matrix.arch }}
DEB_ARCH: ${{ matrix.deb_arch }}
OS: linux
runs-on: ubuntu-22.04
container: debian:stable-slim
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- name: Install packaging deps
run: |
apt-get update
apt-get install -y --no-install-recommends dpkg-dev
- name: Download linux binary
uses: actions/download-artifact@v4
with:
name: lightpanda-${{ env.ARCH }}-${{ env.OS }}
path: .
- name: Build Debian package
run: |
RAW_VERSION="${{ env.RELEASE }}"
PKGVER="${RAW_VERSION#v}"
echo "PKGVER=${PKGVER}" >> "$GITHUB_ENV"
ROOT="lightpanda_${PKGVER}_${DEB_ARCH}"
mkdir -p "$ROOT/DEBIAN" "$ROOT/usr/bin" "$ROOT/usr/share/doc/lightpanda"
install -m755 "lightpanda-${ARCH}-${OS}" "$ROOT/usr/bin/lightpanda"
install -m644 LICENSE "$ROOT/usr/share/doc/lightpanda/copyright"
cat > "$ROOT/DEBIAN/control" <<EOF
Package: lightpanda
Version: ${PKGVER}
Section: web
Priority: optional
Architecture: ${DEB_ARCH}
Depends: libc6 (>= 2.35)
Maintainer: Lightpanda <hello@lightpanda.io>
Homepage: https://lightpanda.io
Description: Lightpanda, headless browser built for AI and automation
EOF
dpkg-deb --build --root-owner-group "$ROOT"
- name: Upload Debian package to release
uses: ncipollo/release-action@v1
with:
allowUpdates: true
artifacts: lightpanda_${{ env.PKGVER }}_${{ env.DEB_ARCH }}.deb
tag: ${{ env.RELEASE }}
makeLatest: true

View File

@@ -134,70 +134,10 @@ jobs:
package-archlinux:
if: github.ref_type == 'tag'
strategy:
fail-fast: false
matrix:
arch: [x86_64, aarch64]
env:
ARCH: ${{ matrix.arch }}
OS: linux
needs: build-linux
runs-on: ubuntu-22.04
container: archlinux:latest
timeout-minutes: 10
uses: ./.github/workflows/package-archlinux.yml
steps:
- uses: actions/checkout@v6
- name: Install packaging deps
run: pacman -Syu --noconfirm --needed base-devel sudo
- name: Download linux binary
uses: actions/download-artifact@v4
with:
name: lightpanda-${{ env.ARCH }}-${{ env.OS }}
path: .
- name: Build Arch package
run: |
useradd -m builder
echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
RAW_VERSION="${{ env.RELEASE }}"
PKGVER="${RAW_VERSION#v}"
PKGREL="1"
echo "PKGVER=${PKGVER}" >> "$GITHUB_ENV"
echo "PKGREL=${PKGREL}" >> "$GITHUB_ENV"
mkdir -p pkg
cp lightpanda-${{ env.ARCH }}-${{ env.OS }} pkg/
cp LICENSE pkg/
cat > pkg/PKGBUILD <<EOF
pkgname=lightpanda
pkgver=${PKGVER}
pkgrel=${PKGREL}
pkgdesc="Lightpanda, headless browser built for AI and automation"
arch=('x86_64' 'aarch64')
url="https://lightpanda.io"
license=('AGPL-3.0-or-later')
options=(!strip !debug)
package() {
install -Dm755 "\$startdir/lightpanda-\$CARCH-linux" "\$pkgdir/usr/bin/lightpanda"
install -Dm644 "\$startdir/LICENSE" "\$pkgdir/usr/share/licenses/\$pkgname/LICENSE"
}
EOF
chown -R builder:builder pkg
cd pkg
sudo -u builder env CARCH=${{ env.ARCH }} makepkg -f --noconfirm -A
- name: Upload Arch package to release
uses: ncipollo/release-action@v1
with:
allowUpdates: true
artifacts: pkg/lightpanda-${{ env.PKGVER }}-${{ env.PKGREL }}-${{ env.ARCH }}.pkg.tar.zst
tag: ${{ env.RELEASE }}
makeLatest: true
package-debian:
if: github.ref_type == 'tag'
needs: build-linux
uses: ./.github/workflows/package-debian.yml

164
build.zig
View File

@@ -344,6 +344,13 @@ fn linkCurl(b: *Build, mod: *Build.Module, is_tsan: bool) !void {
const boringssl = buildBoringSsl(b, target, mod.optimize.?);
for (boringssl) |lib| curl.root_module.linkLibrary(lib);
const libidn2 = buildLibidn2(b, target, mod.optimize.?, is_tsan);
curl.root_module.linkLibrary(libidn2);
// Also expose libidn2 to the consuming module so src/sys/idna.zig's
// @cImport of <idn2.h> resolves. Without this, lightpanda_module only
// sees idn2.h transitively if a system libidn2 happens to be installed.
mod.linkLibrary(libidn2);
switch (target.result.os.tag) {
.macos => {
// needed for proxying on mac
@@ -498,6 +505,158 @@ fn buildNghttp2(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.O
return lib;
}
fn buildLibidn2(
b: *Build,
target: Build.ResolvedTarget,
optimize: std.builtin.OptimizeMode,
is_tsan: bool,
) *Build.Step.Compile {
const dep = b.dependency("libidn2", .{});
const os = target.result.os.tag;
const is_darwin = os.isDarwin();
const mod = b.createModule(.{
.target = target,
.optimize = optimize,
.link_libc = true,
.sanitize_thread = is_tsan,
});
// libidn2's autoconf+gnulib stack expects a config.h with hundreds of
// HAVE_*/_GL_ATTRIBUTE_* defines — including ~800 lines of attribute-
// detection macros emitted from gnulib-common.m4 via AH_VERBATIM. We
// vendor a single autoconf-generated config.h rather than try to
// reproduce that machinery in the Zig build system.
mod.addIncludePath(b.path("vendor/libidn2"));
// Substitute the gnulib-style .in.h templates. All @VAR@ in them are
// either DLL-visibility markers (empty for static POSIX) or
// HAVE_UNISTRING_WOE32DLL_H (0).
inline for (.{ "unitypes", "unistr", "uniconv", "unictype", "uninorm" }) |name| {
mod.addConfigHeader(renderUnistringHeader(b, dep, name));
}
mod.addIncludePath(dep.path("lib"));
mod.addIncludePath(dep.path("unistring"));
// gl/ holds gnulib helpers — only malloca and version-etc headers are
// referenced from the sources we compile; we don't need the full gl/ shim
// layer (system header replacements).
mod.addIncludePath(dep.path("gl"));
const lib = b.addLibrary(.{ .name = "idn2", .root_module = mod });
lib.installHeader(dep.path("lib/idn2.h"), "idn2.h");
if (is_darwin) {
// unistring's striconveh.c calls real iconv_*, which on macOS lives
// in libiconv (separate from libSystem). On glibc Linux iconv is in
// libc itself; on musl it would also need a separate -liconv.
mod.linkSystemLibrary("iconv", .{});
}
lib.addCSourceFiles(.{
.root = dep.path("lib"),
.flags = &.{ "-DHAVE_CONFIG_H", "-DIDN2_STATIC" },
.files = &.{
"bidi.c", "context.c", "data.c", "decode.c",
"error.c", "free.c", "idna.c", "lookup.c",
"punycode.c", "register.c", "tables.c", "tr46map.c",
"version.c",
},
});
lib.addCSourceFiles(.{
.root = dep.path("gl"),
.flags = &.{"-DHAVE_CONFIG_H"},
// malloca.c provides striconveha's stack-or-heap allocator; strverscmp
// is a glibc extension absent on macOS that lib/version.c needs.
.files = &.{ "malloca.c", "strverscmp.c" },
});
lib.addCSourceFiles(.{
.root = dep.path("unistring"),
.flags = &.{"-DHAVE_CONFIG_H"},
.files = &.{
"c-ctype.c", "c-strcasecmp.c", "c-strncasecmp.c",
"free.c", "iconv.c", "iconv_close.c",
"iconv_open.c", "localcharset.c", "stdlib.c",
"striconveh.c", "striconveha.c", "unistd.c",
"uniconv/u8-conv-from-enc.c", "uniconv/u8-strconv-from-enc.c", "uniconv/u8-strconv-from-locale.c",
"uniconv/u8-strconv-to-enc.c", "uniconv/u8-strconv-to-locale.c", "unictype/bidi_of.c",
"unictype/categ_M.c", "unictype/categ_none.c", "unictype/categ_of.c",
"unictype/categ_test.c", "unictype/combiningclass.c", "unictype/joiningtype_of.c",
"unictype/scripts.c", "uninorm/canonical-decomposition.c", "uninorm/composition.c",
"uninorm/decompose-internal.c", "uninorm/decomposition-table.c", "uninorm/nfc.c",
"uninorm/nfd.c", "uninorm/u32-normalize.c", "unistr/u32-cmp.c",
"unistr/u32-cpy-alloc.c", "unistr/u32-cpy.c", "unistr/u32-mbtouc-unsafe.c",
"unistr/u32-strlen.c", "unistr/u32-to-u8.c", "unistr/u32-uctomb.c",
"unistr/u8-check.c", "unistr/u8-mblen.c", "unistr/u8-mbtouc.c",
"unistr/u8-mbtouc-aux.c", "unistr/u8-mbtouc-unsafe.c", "unistr/u8-mbtouc-unsafe-aux.c",
"unistr/u8-mbtoucr.c", "unistr/u8-prev.c", "unistr/u8-strlen.c",
"unistr/u8-to-u32.c", "unistr/u8-uctomb.c", "unistr/u8-uctomb-aux.c",
},
});
return lib;
}
/// Process one of unistring's `.in.h` template headers into a real `.h`.
/// All `@VAR@` substitutions in these headers are either DLL-visibility markers
/// (empty for static POSIX builds) or `HAVE_UNISTRING_WOE32DLL_H` (0).
fn renderUnistringHeader(b: *Build, dep: *Build.Dependency, name: []const u8) *Build.Step.ConfigHeader {
const in_rel = b.fmt("unistring/{s}.in.h", .{name});
const out_name = b.fmt("{s}.h", .{name});
const lazy = dep.path(in_rel);
const path = lazy.getPath3(b, null);
const file = path.root_dir.handle.openFile(path.sub_path, .{}) catch |e| {
std.debug.panic("openFile {s}: {s}", .{ path.sub_path, @errorName(e) });
};
defer file.close();
const contents = file.readToEndAlloc(b.allocator, 4 << 20) catch @panic("OOM");
const ch = b.addConfigHeader(.{
.include_path = out_name,
.style = .{ .autoconf_at = lazy },
}, .{});
var seen = std.StringHashMap(void).init(b.allocator);
var i: usize = 0;
while (std.mem.indexOfScalarPos(u8, contents, i, '@')) |s| {
const a = s + 1;
const e = std.mem.indexOfScalarPos(u8, contents, a, '@') orelse break;
const var_name = contents[a..e];
if (!isAtConfigName(var_name)) {
// Stray '@' (e.g. an email address in a comment); advance past it
// alone so we don't mis-pair with a later '@'.
i = s + 1;
continue;
}
const owned = b.allocator.dupe(u8, var_name) catch @panic("OOM");
const gop = seen.getOrPut(owned) catch @panic("OOM");
if (!gop.found_existing) {
if (std.mem.eql(u8, var_name, "HAVE_UNISTRING_WOE32DLL_H")) {
ch.addValue(owned, c_int, 0);
} else {
ch.addValue(owned, []const u8, "");
}
}
i = e + 1;
}
return ch;
}
fn isAtConfigName(s: []const u8) bool {
if (s.len == 0) return false;
for (s, 0..) |c, idx| {
const ok = switch (c) {
'A'...'Z', '_' => true,
'0'...'9' => idx > 0,
else => false,
};
if (!ok) return false;
}
return true;
}
fn buildCurl(
b: *Build,
target: Build.ResolvedTarget,
@@ -574,6 +733,11 @@ fn buildCurl(
._FILE_OFFSET_BITS = 64,
.USE_IPV6 = true,
// Route IDN hostnames through libidn2 (vendored, see buildLibidn2).
// Without this, libcurl ships UTF-8 host bytes to SNI/cert validation
// and breaks for non-ASCII hostnames like räksmörgås.se.
.HAVE_LIBIDN2 = true,
.HAVE_IDN2_H = true,
.CURL_OS = switch (os) {
.linux => if (is_android) "\"android\"" else "\"linux\"",
else => std.fmt.allocPrint(b.allocator, "\"{s}\"", .{@tagName(os)}) catch @panic("OOM"),

View File

@@ -42,6 +42,10 @@
.url = "git+https://github.com/lightpanda-io/zenai.git#3f61d6a21574a8edfc8ccd599e865db10bf80207",
.hash = "zenai-0.0.0-iOY_VIhAAwBZsH_XAUZWE_BcxMUE3-Yf0lM-9DYd4Pyd",
},
.libidn2 = .{
.url = "https://ftp.gnu.org/gnu/libidn/libidn2-2.3.8.tar.gz",
.hash = "N-V-__8AABGOuAC_dhAN07kfoP4dycCFi8Bka4O-tuhriNH8",
},
},
.paths = .{""},
}

View File

@@ -412,7 +412,7 @@ pub fn deinit(self: *Frame, abort_http: bool) void {
const browser = page.session.browser;
browser.env.destroyContext(self.js);
self._script_manager.shutdown = true;
self._script_manager.base.shutdown = true;
if (self.parent == null) {
browser.http_client.abort();
@@ -535,6 +535,8 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
self.origin = try URL.getOrigin(self.arena, request_url[5.. :0]);
} else if (self.parent) |parent| {
self.origin = parent.origin;
} else if (self.window._opener) |opener| {
self.origin = opener._frame.origin;
} else {
self.origin = null;
}
@@ -1339,6 +1341,69 @@ pub fn iframeAddedCallback(self: *Frame, iframe: *IFrame) !void {
}
}
const OpenPopupOpts = struct {
url: []const u8,
name: []const u8,
opener: ?*Window,
};
// Create a new top-level browsing context as a sibling of the root frame.
// The popup shares the Page's arena, factory, and identity map, but has no
// parent and is not attached to the frame tree — it lives in page.popups.
pub fn openPopup(self: *Frame, opts: OpenPopupOpts) !*Frame {
const page = self._page;
const session = self._session;
const resolved_url: [:0]const u8 = blk: {
if (opts.url.len == 0) {
break :blk "about:blank";
}
if (std.mem.eql(u8, opts.url, "about:blank")) {
break :blk "about:blank";
}
const frame_base = base_blk: {
var frame = self;
while (true) {
const maybe_base = frame.base();
if (!std.mem.eql(u8, maybe_base, "about:blank")) {
break :base_blk maybe_base;
}
frame = frame.parent orelse break :base_blk "";
}
};
break :blk try URL.resolve(self.call_arena, frame_base, opts.url, .{ .always_dupe = true, .encoding = self.charset });
};
const popup = try page.frame_arena.create(Frame);
errdefer page.frame_arena.destroy(popup);
const frame_id = session.nextFrameId();
try Frame.init(popup, frame_id, page, null);
errdefer popup.deinit(true);
popup.window._opener = opts.opener;
if (opts.name.len > 0 and
!std.ascii.eqlIgnoreCase(opts.name, "_blank") and
!std.ascii.eqlIgnoreCase(opts.name, "_self") and
!std.ascii.eqlIgnoreCase(opts.name, "_parent") and
!std.ascii.eqlIgnoreCase(opts.name, "_top"))
{
popup.window._name = try page.frame_arena.dupe(u8, opts.name);
}
const popup_index = page.popups.items.len;
try page.popups.append(page.frame_arena, popup);
// not impossible that navigate adds popups, so remove by index
errdefer _ = page.popups.swapRemove(popup_index);
popup.navigate(resolved_url, .{ .reason = .script }) catch |err| {
log.warn(.frame, "popup navigate failure", .{ .url = resolved_url, .err = err });
return err;
};
return popup;
}
pub fn domChanged(self: *Frame) void {
self.version += 1;
@@ -3555,7 +3620,7 @@ pub const QueuedNavigation = struct {
/// to the appropriateFrame to navigate.
/// Returns null if the target is "_blank" (which would open a new window/tab).
/// Note: Callers should handle empty target separately (for owner document resolution).
fn resolveTargetFrame(self: *Frame, target_name: []const u8) ?*Frame {
pub fn resolveTargetFrame(self: *Frame, target_name: []const u8) ?*Frame {
if (std.ascii.eqlIgnoreCase(target_name, "_self")) {
return self;
}
@@ -3691,6 +3756,14 @@ pub fn handleClick(self: *Frame, target: *Node) !void {
}
},
.select, .textarea => try element.focus(self),
.label => |label| {
// Per HTML §4.10.4 "The label element", a label's activation
// behavior is to run the synthetic click activation steps on the
// labeled control. Mirrors Chrome's HTMLLabelElement::DefaultEventHandler.
const control = label.getControl(self) orelse return;
const control_html = control.is(Element.Html) orelse return;
try control_html.click(self);
},
else => {},
}
}
@@ -3769,9 +3842,14 @@ pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.For
const form_element = form.asElement();
const submit_button: ?*Element = blk: {
const s = submitter_ orelse break :blk null;
break :blk if (Element.Html.Form.isSubmitButton(s)) s else null;
};
const target_name_: ?[]const u8 = blk: {
if (submitter_) |submitter| {
if (submitter.getAttributeSafe(comptime .wrap("formtarget"))) |ft| {
if (submit_button) |s| {
if (s.getAttributeSafe(comptime .wrap("formtarget"))) |ft| {
break :blk ft;
}
}
@@ -3824,12 +3902,19 @@ pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.For
// button, its formaction/formmethod/formenctype attributes override the
// form's corresponding attributes (matching how formtarget is honored above).
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#concept-form-submit
const enctype = blk: {
if (submitter_) |s| {
const enctype_attr = blk: {
if (submit_button) |s| {
if (s.getAttributeSafe(comptime .wrap("formenctype"))) |fe| break :blk fe;
}
break :blk form_element.getAttributeSafe(comptime .wrap("enctype"));
};
const method = blk: {
if (submit_button) |s| {
if (s.getAttributeSafe(comptime .wrap("formmethod"))) |fm| break :blk fm;
}
break :blk form_element.getAttributeSafe(comptime .wrap("method")) orelse "";
};
const is_post = std.ascii.eqlIgnoreCase(method, "post");
// Get charset from accept-charset attribute or fall back to document charset
const charset: []const u8 = blk: {
@@ -3843,17 +3928,28 @@ pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.For
break :blk self.charset;
};
var buf = std.Io.Writer.Allocating.init(arena);
try form_data.write(.{ .enctype = enctype, .charset = charset, .allocator = arena }, &buf.writer);
const method = blk: {
if (submitter_) |s| {
if (s.getAttributeSafe(comptime .wrap("formmethod"))) |fm| break :blk fm;
var boundary_buf: [36]u8 = undefined;
// GET ignores enctype per HTML spec; only resolve the union for POST.
const encoding: FormData.EncType = blk: {
if (is_post) {
if (enctype_attr) |attr| {
if (std.ascii.eqlIgnoreCase(attr, "multipart/form-data")) {
@import("../id.zig").uuidv4(&boundary_buf);
break :blk .{ .formdata = &boundary_buf };
}
if (!std.ascii.eqlIgnoreCase(attr, "application/x-www-form-urlencoded")) {
log.warn(.not_implemented, "FormData.encoding", .{ .encoding = attr });
}
}
}
break :blk form_element.getAttributeSafe(comptime .wrap("method")) orelse "";
break :blk .urlencode;
};
var buf = std.Io.Writer.Allocating.init(arena);
try form_data.write(.{ .encoding = encoding, .charset = charset, .allocator = arena }, &buf.writer);
var action = blk: {
if (submitter_) |s| {
if (submit_button) |s| {
if (s.getAttributeSafe(comptime .wrap("formaction"))) |fa| break :blk fa;
}
break :blk form_element.getAttributeSafe(comptime .wrap("action")) orelse self.url;
@@ -3863,11 +3959,13 @@ pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.For
.reason = .form,
.kind = .{ .push = null },
};
if (std.ascii.eqlIgnoreCase(method, "post")) {
if (is_post) {
opts.method = .POST;
opts.body = buf.written();
// form_data.write currently only supports this encoding, so we know this has to be the content type
opts.header = "Content-Type: application/x-www-form-urlencoded";
opts.header = switch (encoding) {
.urlencode => "Content-Type: application/x-www-form-urlencoded",
.formdata => |b| try std.fmt.allocPrintSentinel(arena, "Content-Type: multipart/form-data; boundary={s}", .{b}, 0),
};
} else {
action = try URL.concatQueryString(arena, action, buf.written());
}

View File

@@ -88,6 +88,14 @@ queued_queued_navigation: std.ArrayList(*Frame) = .empty,
// The root Frame of this Page. Non-optional — a Page always has a root frame.
frame: Frame,
// Popup Frames opened by window.open. They are top-level browsing contexts
// (parent == null, no iframe element) but share this Page's factory, arena,
// and identity map.
// Their lifetime is bound to the Page: on Page.deinit they
// are torn down. TODO: this is far from correct. An new window shouldn't be tied
// to the original page like this.
popups: std.ArrayList(*Frame) = .empty,
// Initialize a Page and its root Frame.
pub fn init(self: *Page, session: *Session, frame_id: u32) !void {
const frame_arena = try session.arena_pool.acquire(.large, "Page.frame_arena");
@@ -107,6 +115,11 @@ pub fn init(self: *Page, session: *Session, frame_id: u32) !void {
// Tear down the Page and its root Frame. Equivalent to the old
// Session.removePage + Session.resetFrameResources.
pub fn deinit(self: *Page, abort_http: bool) void {
for (self.popups.items) |popup| {
popup.deinit(abort_http);
}
self.popups = .empty;
self.frame.deinit(abort_http);
const session = self.session;
@@ -217,6 +230,16 @@ pub fn findFrameByFrameId(self: *Page, frame_id: u32) ?*Frame {
return findFrameBy(&self.frame, "_frame_id", frame_id);
}
// Returns the popup Frame registered under `name`, or null.
pub fn findPopupByName(self: *Page, name: []const u8) ?*Frame {
for (self.popups.items) |popup| {
if (std.mem.eql(u8, popup.window._name, name)) {
return popup;
}
}
return null;
}
pub fn findFrameByLoaderId(self: *Page, loader_id: u32) ?*Frame {
return findFrameBy(&self.frame, "_loader_id", loader_id);
}

View File

@@ -21,124 +21,69 @@ const lp = @import("lightpanda");
const builtin = @import("builtin");
const HttpClient = @import("HttpClient.zig");
const http = @import("../network/http.zig");
const js = @import("js/js.zig");
const URL = @import("URL.zig");
const Frame = @import("Frame.zig");
const ScriptManagerBase = @import("ScriptManagerBase.zig");
const Element = @import("webapi/Element.zig");
const log = lp.log;
const String = lp.String;
const Allocator = std.mem.Allocator;
const IS_DEBUG = builtin.mode == .Debug;
const ScriptManager = @This();
// Re-exports so Frame / Context callers don't need to import Base directly.
pub const Script = ScriptManagerBase.Script;
pub const ModuleSource = ScriptManagerBase.ModuleSource;
base: ScriptManagerBase,
frame: *Frame,
// used to prevent recursive evaluation
is_evaluating: bool,
// Only once this is true can deferred scripts be run
static_scripts_done: bool,
// List of async scripts. We don't care about the execution order of these, but
// on shutdown/abort, we need to cleanup any pending ones.
async_scripts: std.DoublyLinkedList,
// List of deferred scripts. These must be executed in order, but only once
// dom_loaded == true,
defer_scripts: std.DoublyLinkedList,
// When an async script is ready, it's queued here. We played with executing
// them as they complete, but it can cause timing issues with v8 module loading.
ready_scripts: std.DoublyLinkedList,
shutdown: bool = false,
client: *HttpClient,
allocator: Allocator,
// We can download multiple sync modules in parallel, but we want to process
// them in order. We can't use an std.DoublyLinkedList, like the other script types,
// because the order we load them might not be the order we want to process
// them in (I'm not sure this is true, but as far as I can tell, v8 doesn't
// make any guarantees about the list of sub-module dependencies it gives us
// So this is more like a cache. When an imported module is completed, its
// source is placed here (keyed by the full url) for some point in the future
// when v8 asks for it.
// The type is confusing (too confusing? move to a union). Starts of as `null`
// then transitions to either an error (from errorCallback) or the completed
// buffer from doneCallback
imported_modules: std.StringHashMapUnmanaged(ImportedModule),
// Mapping between module specifier and resolution.
// see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap
// importmap contains resolved urls.
importmap: std.StringHashMapUnmanaged([:0]const u8),
// have we notified the frame that all scripts are loaded (used to fire the "load"
// event).
// have we notified the frame that all scripts are loaded (used to fire the
// "load" event).
frame_notified_of_completion: bool,
pub fn init(allocator: Allocator, http_client: *HttpClient, frame: *Frame) ScriptManager {
var base = ScriptManagerBase.init(allocator, http_client, .{ .frame = frame });
base.tail_hook = tailHook;
return .{
.frame = frame,
.async_scripts = .{},
.defer_scripts = .{},
.ready_scripts = .{},
.importmap = .empty,
.is_evaluating = false,
.allocator = allocator,
.imported_modules = .empty,
.client = http_client,
.static_scripts_done = false,
.base = base,
.frame_notified_of_completion = false,
};
}
pub fn deinit(self: *ScriptManager) void {
// necessary to free any arenas scripts may be referencing
self.reset();
self.imported_modules.deinit(self.allocator);
// we don't deinit self.importmap b/c we use the frame's arena for its
// allocations.
self.base.deinit();
}
pub fn reset(self: *ScriptManager) void {
var it = self.imported_modules.valueIterator();
while (it.next()) |value_ptr| {
switch (value_ptr.state) {
.done => |script| script.deinit(),
else => {},
}
}
self.imported_modules.clearRetainingCapacity();
// Our allocator is the frame arena, it's been reset. We cannot use
// clearAndRetainCapacity, since that space is no longer ours
self.importmap = .empty;
clearList(&self.defer_scripts);
clearList(&self.async_scripts);
clearList(&self.ready_scripts);
self.static_scripts_done = false;
self.base.reset();
self.frame_notified_of_completion = false;
}
fn clearList(list: *std.DoublyLinkedList) void {
while (list.popFirst()) |n| {
const script: *Script = @fieldParentPtr("node", n);
script.deinit();
// Frame wrapper uses this to fire documentIsLoaded and scriptsCompletedLoading
// once Base has finished processing its ready / defer queues.
pub fn tailHook(base: *ScriptManagerBase) void {
const self: *ScriptManager = @fieldParentPtr("base", base);
const frame = self.frame;
// When all scripts (normal and deferred) are done loading, the document
// state changes (this ultimately triggers the DOMContentLoaded event).
// Page makes this safe to call multiple times.
frame.documentIsLoaded();
if (base.async_scripts.first == null and self.frame_notified_of_completion == false) {
self.frame_notified_of_completion = true;
frame.scriptsCompletedLoading();
}
}
fn getHeaders(self: *ScriptManager) !http.Headers {
var headers = try self.client.newHeaders();
try self.frame.headersForRequest(&headers);
return headers;
fn getHeaders(self: *ScriptManager) !HttpClient.Headers {
return self.base.getHeaders();
}
pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_element: *Element.Html.Script, comptime ctx: []const u8) !void {
@@ -226,7 +171,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
.kind = kind,
.node = .{},
.arena = arena,
.manager = self,
.manager = &self.base,
.source = source,
.script_element = script_element,
.complete = is_inline,
@@ -261,7 +206,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
const is_blocking = script.mode == .normal;
if (is_blocking == false) {
self.scriptList(script).append(&script.node);
self.base.scriptList(script).append(&script.node);
}
if (remote_url) |url| {
@@ -278,15 +223,15 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
});
}
const was_evaluating = self.is_evaluating;
self.is_evaluating = true;
defer self.is_evaluating = was_evaluating;
const was_evaluating = self.base.is_evaluating;
self.base.is_evaluating = true;
defer self.base.is_evaluating = was_evaluating;
const headers = try self.getHeaders();
errdefer headers.deinit();
if (is_blocking) {
const response = try self.client.syncRequest(arena, .{
const response = try self.base.client.syncRequest(arena, .{
.url = url,
.method = .GET,
.frame_id = frame._frame_id,
@@ -303,11 +248,11 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
script.complete = true;
} else {
errdefer {
self.scriptList(script).remove(&script.node);
self.base.scriptList(script).remove(&script.node);
// Let the outer errdefer handle releasing the arena if client.request fails
}
try self.client.request(.{
try self.base.client.request(.{
.ctx = script,
.params = .{
.url = url,
@@ -342,292 +287,17 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
}
// could have already been evaluating if this is dynamically added
const was_evaluating = self.is_evaluating;
self.is_evaluating = true;
const was_evaluating = self.base.is_evaluating;
self.base.is_evaluating = true;
defer {
self.is_evaluating = was_evaluating;
self.base.is_evaluating = was_evaluating;
script.deinit();
}
script.eval(frame);
}
fn scriptList(self: *ScriptManager, script: *const Script) *std.DoublyLinkedList {
return switch (script.mode) {
.normal => unreachable, // not added to a list, executed immediately
.@"defer" => &self.defer_scripts,
.async, .import_async, .import => &self.async_scripts,
};
}
// Resolve a module specifier to an valid URL.
pub fn resolveSpecifier(self: *ScriptManager, arena: Allocator, base: [:0]const u8, specifier: [:0]const u8) ![:0]const u8 {
// If the specifier is mapped in the importmap, return the pre-resolved value.
if (self.importmap.get(specifier)) |s| {
return s;
}
return URL.resolve(arena, base, specifier, .{ .always_dupe = true });
}
pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const u8) !void {
const gop = try self.imported_modules.getOrPut(self.allocator, url);
if (gop.found_existing) {
gop.value_ptr.waiters += 1;
return;
}
errdefer _ = self.imported_modules.remove(url);
const frame = self.frame;
const arena = try frame.getArena(.large, "SM.preloadImport");
errdefer frame.releaseArena(arena);
const script = try arena.create(Script);
script.* = .{
.kind = .module,
.arena = arena,
.url = url,
.node = .{},
.manager = self,
.complete = false,
.script_element = null,
.source = .{ .remote = .{} },
.mode = .import,
};
gop.value_ptr.* = ImportedModule{};
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
log.debug(.http, "script queue", .{
.url = url,
.ctx = "module",
.referrer = referrer,
.stack = ls.local.stackTrace() catch "???",
});
}
// This seems wrong since we're not dealing with an async import (unlike
// getAsyncModule below), but all we're trying to do here is pre-load the
// script for execution at some point in the future (when waitForImport is
// called).
self.async_scripts.append(&script.node);
self.client.request(.{
.ctx = script,
.params = .{
.url = url,
.method = .GET,
.frame_id = frame._frame_id,
.loader_id = frame._loader_id,
.headers = try self.getHeaders(),
.cookie_jar = &frame._session.cookie_jar,
.cookie_origin = frame.url,
.resource_type = .script,
.notification = frame._session.notification,
},
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback,
.data_callback = Script.dataCallback,
.done_callback = Script.doneCallback,
.error_callback = Script.errorCallback,
}) catch |err| {
self.async_scripts.remove(&script.node);
return err;
};
}
pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource {
const entry = self.imported_modules.getEntry(url) orelse {
// It shouldn't be possible for v8 to ask for a module that we didn't
// `preloadImport` above.
return error.UnknownModule;
};
const was_evaluating = self.is_evaluating;
self.is_evaluating = true;
defer self.is_evaluating = was_evaluating;
var client = self.client;
while (true) {
switch (entry.value_ptr.state) {
.loading => {
_ = try client.tick(200);
continue;
},
.done => |script| {
var shared = false;
const buffer = entry.value_ptr.buffer;
const waiters = entry.value_ptr.waiters;
if (waiters == 1) {
self.imported_modules.removeByPtr(entry.key_ptr);
} else {
shared = true;
entry.value_ptr.waiters = waiters - 1;
}
return .{
.buffer = buffer,
.shared = shared,
.script = script,
};
},
.err => return error.Failed,
}
}
}
pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.Callback, cb_data: *anyopaque, referrer: []const u8) !void {
const frame = self.frame;
const arena = try frame.getArena(.large, "SM.getAsyncImport");
errdefer frame.releaseArena(arena);
const script = try arena.create(Script);
script.* = .{
.kind = .module,
.arena = arena,
.url = url,
.node = .{},
.manager = self,
.complete = false,
.script_element = null,
.source = .{ .remote = .{} },
.mode = .{ .import_async = .{
.callback = cb,
.data = cb_data,
} },
};
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
log.debug(.http, "script queue", .{
.url = url,
.ctx = "dynamic module",
.referrer = referrer,
.stack = ls.local.stackTrace() catch "???",
});
}
// It's possible, but unlikely, for client.request to immediately finish
// a request, thus calling our callback. We generally don't want a call
// from v8 (which is why we're here), to result in a new script evaluation.
// So we block even the slightest change that `client.request` immediately
// executes a callback.
const was_evaluating = self.is_evaluating;
self.is_evaluating = true;
defer self.is_evaluating = was_evaluating;
self.async_scripts.append(&script.node);
self.client.request(.{
.ctx = script,
.params = .{
.url = url,
.method = .GET,
.frame_id = frame._frame_id,
.loader_id = frame._loader_id,
.headers = try self.getHeaders(),
.resource_type = .script,
.cookie_jar = &frame._session.cookie_jar,
.cookie_origin = frame.url,
.notification = frame._session.notification,
},
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback,
.data_callback = Script.dataCallback,
.done_callback = Script.doneCallback,
.error_callback = Script.errorCallback,
}) catch |err| {
self.async_scripts.remove(&script.node);
return err;
};
}
// Called from the Page to let us know it's done parsing the HTML. Necessary that
// we know this so that we know that we can start evaluating deferred scripts.
pub fn staticScriptsDone(self: *ScriptManager) void {
lp.assert(self.static_scripts_done == false, "ScriptManager.staticScriptsDone", .{});
self.static_scripts_done = true;
self.evaluate();
}
fn evaluate(self: *ScriptManager) void {
if (self.is_evaluating) {
// It's possible for a script.eval to cause evaluate to be called again.
return;
}
const frame = self.frame;
self.is_evaluating = true;
defer self.is_evaluating = false;
while (self.ready_scripts.popFirst()) |n| {
var script: *Script = @fieldParentPtr("node", n);
switch (script.mode) {
.async => {
defer script.deinit();
script.eval(frame);
},
.import_async => |ia| {
if (script.status < 200 or script.status > 299) {
script.deinit();
ia.callback(ia.data, error.FailedToLoad);
} else {
ia.callback(ia.data, .{
.shared = false,
.script = script,
.buffer = script.source.remote,
});
}
},
else => unreachable, // no other script is put in this list
}
}
if (self.static_scripts_done == false) {
// We can only execute deferred scripts if
// 1 - all the normal scripts are done
// 2 - we've finished parsing the HTML and at least queued all the scripts
// The last one isn't obvious, but it's possible for self.scripts to
// be empty not because we're done executing all the normal scripts
// but because we're done executing some (or maybe none), but we're still
// parsing the HTML.
return;
}
while (self.defer_scripts.first) |n| {
var script: *Script = @fieldParentPtr("node", n);
if (script.complete == false) {
return;
}
defer {
_ = self.defer_scripts.popFirst();
script.deinit();
}
script.eval(frame);
}
// At this point all normal scripts and deferred scripts are done, PLUS
// the frame has signaled that it's done parsing HTML (static_scripts_done == true).
//
// When all scripts (normal and deferred) are done loading, the document
// state changes (this ultimately triggers the DOMContentLoaded event).
// Page makes this safe to call multiple times.
frame.documentIsLoaded();
if (self.async_scripts.first == null and self.frame_notified_of_completion == false) {
self.frame_notified_of_completion = true;
frame.scriptsCompletedLoading();
}
}
fn parseImportmap(self: *ScriptManager, script: *const Script) !void {
pub fn parseImportmap(self: *ScriptManager, script: *const Script) !void {
const content = script.source.content();
const Imports = struct {
@@ -653,364 +323,13 @@ fn parseImportmap(self: *ScriptManager, script: *const Script) !void {
.{},
);
try self.importmap.put(self.frame.arena, entry.key_ptr.*, resolved_url);
try self.base.importmap.put(self.frame.arena, entry.key_ptr.*, resolved_url);
}
}
pub const Script = struct {
kind: Kind,
complete: bool,
status: u16 = 0,
source: Source,
url: []const u8,
arena: Allocator,
mode: ExecutionMode,
node: std.DoublyLinkedList.Node,
script_element: ?*Element.Html.Script,
manager: *ScriptManager,
// for debugging a rare production issue
header_callback_called: bool = false,
// for debugging a rare production issue
debug_transfer_id: u32 = 0,
debug_transfer_tries: u8 = 0,
debug_transfer_aborted: bool = false,
debug_transfer_bytes_received: usize = 0,
debug_transfer_notified_fail: bool = false,
debug_transfer_auth_challenge: bool = false,
debug_transfer_easy_id: usize = 0,
const Kind = enum {
module,
javascript,
importmap,
};
const Callback = union(enum) {
string: []const u8,
function: js.Function,
};
const Source = union(enum) {
@"inline": []const u8,
remote: std.ArrayList(u8),
fn content(self: Source) []const u8 {
return switch (self) {
.remote => |buf| buf.items,
.@"inline" => |c| c,
};
}
};
const ExecutionMode = union(enum) {
normal,
@"defer",
async,
import,
import_async: ImportAsync,
};
fn deinit(self: *Script) void {
self.manager.frame.releaseArena(self.arena);
}
fn startCallback(response: HttpClient.Response) !void {
log.debug(.http, "script fetch start", .{ .req = response });
}
fn headerCallback(response: HttpClient.Response) !bool {
const self: *Script = @ptrCast(@alignCast(response.ctx));
self.status = response.status().?;
if (response.status() != 200) {
log.info(.http, "script header", .{
.req = response,
.status = response.status(),
.content_type = response.contentType(),
});
return false;
}
if (comptime IS_DEBUG) {
log.debug(.http, "script header", .{
.req = response,
.status = response.status(),
.content_type = response.contentType(),
});
}
switch (response.inner) {
.transfer => |transfer| {
// temp debug, trying to figure out why the next assert sometimes
// fails. Is the buffer just corrupt or is headerCallback really
// being called twice?
lp.assert(self.header_callback_called == false, "ScriptManager.Header recall", .{
.m = @tagName(std.meta.activeTag(self.mode)),
.a1 = self.debug_transfer_id,
.a2 = self.debug_transfer_tries,
.a3 = self.debug_transfer_aborted,
.a4 = self.debug_transfer_bytes_received,
.a5 = self.debug_transfer_notified_fail,
.a8 = self.debug_transfer_auth_challenge,
.a9 = self.debug_transfer_easy_id,
.b1 = transfer.id,
.b2 = transfer._tries,
.b3 = transfer.aborted,
.b4 = transfer.bytes_received,
.b5 = transfer._notified_fail,
.b8 = transfer._auth_challenge != null,
.b9 = if (transfer._conn) |c| @intFromPtr(c._easy) else 0,
});
self.header_callback_called = true;
self.debug_transfer_id = transfer.id;
self.debug_transfer_tries = transfer._tries;
self.debug_transfer_aborted = transfer.aborted;
self.debug_transfer_bytes_received = transfer.bytes_received;
self.debug_transfer_notified_fail = transfer._notified_fail;
self.debug_transfer_auth_challenge = transfer._auth_challenge != null;
self.debug_transfer_easy_id = if (transfer._conn) |c| @intFromPtr(c._easy) else 0;
},
else => {},
}
lp.assert(self.source.remote.capacity == 0, "ScriptManager.Header buffer", .{ .capacity = self.source.remote.capacity });
var buffer: std.ArrayList(u8) = .empty;
if (response.contentLength()) |cl| {
try buffer.ensureTotalCapacity(self.arena, cl);
}
self.source = .{ .remote = buffer };
return true;
}
fn dataCallback(response: HttpClient.Response, data: []const u8) !void {
const self: *Script = @ptrCast(@alignCast(response.ctx));
self._dataCallback(response, data) catch |err| {
log.err(.http, "SM.dataCallback", .{ .err = err, .transfer = response, .len = data.len });
return err;
};
}
fn _dataCallback(self: *Script, _: HttpClient.Response, data: []const u8) !void {
try self.source.remote.appendSlice(self.arena, data);
}
fn doneCallback(ctx: *anyopaque) !void {
const self: *Script = @ptrCast(@alignCast(ctx));
self.complete = true;
if (comptime IS_DEBUG) {
log.debug(.http, "script fetch complete", .{ .req = self.url });
}
const manager = self.manager;
if (self.mode == .async or self.mode == .import_async) {
manager.async_scripts.remove(&self.node);
manager.ready_scripts.append(&self.node);
} else if (self.mode == .import) {
manager.async_scripts.remove(&self.node);
const entry = manager.imported_modules.getPtr(self.url).?;
entry.state = .{ .done = self };
entry.buffer = self.source.remote;
}
manager.evaluate();
}
fn errorCallback(ctx: *anyopaque, err: anyerror) void {
const self: *Script = @ptrCast(@alignCast(ctx));
log.warn(.http, "script fetch error", .{
.err = err,
.req = self.url,
.mode = std.meta.activeTag(self.mode),
.kind = self.kind,
.status = self.status,
});
if (self.mode == .normal) {
// This is blocked in a loop at the end of addFromElement, setting
// it to complete with a status of 0 will signal the error.
self.status = 0;
self.complete = true;
return;
}
const manager = self.manager;
manager.scriptList(self).remove(&self.node);
if (manager.shutdown) {
self.deinit();
return;
}
switch (self.mode) {
.import_async => |ia| ia.callback(ia.data, error.FailedToLoad),
.import => {
const entry = manager.imported_modules.getPtr(self.url).?;
entry.state = .err;
},
else => {},
}
self.deinit();
manager.evaluate();
}
fn eval(self: *Script, frame: *Frame) void {
// never evaluated, source is passed back to v8, via callbacks.
if (comptime IS_DEBUG) {
std.debug.assert(self.mode != .import_async);
// never evaluated, source is passed back to v8 when asked for it.
std.debug.assert(self.mode != .import);
}
if (frame.isGoingAway()) {
// don't evaluate scripts for a dying frame.
return;
}
const script_element = self.script_element.?;
const previous_script = frame.document._current_script;
frame.document._current_script = script_element;
defer frame.document._current_script = previous_script;
// Clear the document.write insertion point for this script
const previous_write_insertion_point = frame.document._write_insertion_point;
frame.document._write_insertion_point = null;
defer frame.document._write_insertion_point = previous_write_insertion_point;
// inline scripts aren't cached. remote ones are.
const cacheable = self.source == .remote;
const url = self.url;
log.info(.browser, "executing script", .{
.src = url,
.kind = self.kind,
.cacheable = cacheable,
});
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
const local = &ls.local;
// Handle importmap special case here: the content is a JSON containing
// imports.
if (self.kind == .importmap) {
frame._script_manager.parseImportmap(self) catch |err| {
log.err(.browser, "parse importmap script", .{
.err = err,
.src = url,
.kind = self.kind,
.cacheable = cacheable,
});
self.executeCallback(comptime .wrap("error"), frame);
return;
};
self.executeCallback(comptime .wrap("load"), frame);
return;
}
defer frame._event_manager.clearIgnoreList();
var try_catch: js.TryCatch = undefined;
try_catch.init(local);
defer try_catch.deinit();
const success = blk: {
const content = self.source.content();
switch (self.kind) {
.javascript => _ = local.eval(content, url) catch break :blk false,
.module => {
// We don't care about waiting for the evaluation here.
frame.js.module(false, local, content, url, cacheable) catch break :blk false;
},
.importmap => unreachable, // handled before the try/catch.
}
break :blk true;
};
if (comptime IS_DEBUG) {
log.debug(.browser, "executed script", .{ .src = url, .success = success });
}
defer {
local.runMacrotasks(); // also runs microtasks
_ = frame.js.scheduler.run() catch |err| {
log.err(.frame, "scheduler", .{ .err = err });
};
}
if (success) {
self.executeCallback(comptime .wrap("load"), frame);
return;
}
const caught = try_catch.caughtOrError(frame.call_arena, error.Unknown);
log.warn(.js, "eval script", .{
.url = url,
.caught = caught,
.cacheable = cacheable,
});
self.executeCallback(comptime .wrap("error"), frame);
}
fn executeCallback(self: *const Script, typ: String, frame: *Frame) void {
const Event = @import("webapi/Event.zig");
const event = Event.initTrusted(typ, .{}, frame._page) catch |err| {
log.warn(.js, "script internal callback", .{
.url = self.url,
.type = typ,
.err = err,
});
return;
};
frame._event_manager.dispatchOpts(self.script_element.?.asNode().asEventTarget(), event, .{ .apply_ignore = true }) catch |err| {
log.warn(.js, "script callback", .{
.url = self.url,
.type = typ,
.err = err,
});
};
}
};
const ImportAsync = struct {
data: *anyopaque,
callback: ImportAsync.Callback,
pub const Callback = *const fn (ptr: *anyopaque, result: anyerror!ModuleSource) void;
};
pub const ModuleSource = struct {
shared: bool,
script: *Script,
buffer: std.ArrayList(u8),
pub fn deinit(self: *ModuleSource) void {
if (self.shared == false) {
self.script.deinit();
}
}
pub fn src(self: *const ModuleSource) []const u8 {
return self.buffer.items;
}
};
const ImportedModule = struct {
waiters: u16 = 1,
state: State = .loading,
buffer: std.ArrayList(u8) = .{},
const State = union(enum) {
err,
loading,
done: *Script,
};
};
pub fn staticScriptsDone(self: *ScriptManager) void {
self.base.staticScriptsDone();
}
// Parses data:[<media-type>][;base64],<data>
fn parseDataURI(allocator: Allocator, src: []const u8) !?[]const u8 {

View File

@@ -0,0 +1,810 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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/>.
const std = @import("std");
const lp = @import("lightpanda");
const builtin = @import("builtin");
const HttpClient = @import("HttpClient.zig");
const http = @import("../network/http.zig");
const js = @import("js/js.zig");
const URL = @import("URL.zig");
const Session = @import("Session.zig");
const Frame = @import("Frame.zig");
const WorkerGlobalScope = @import("webapi/WorkerGlobalScope.zig");
const Element = @import("webapi/Element.zig");
const log = lp.log;
const String = lp.String;
const Allocator = std.mem.Allocator;
const IS_DEBUG = builtin.mode == .Debug;
const ScriptManagerBase = @This();
// Either a *Frame (for page ScriptManagers) or *WorkerGlobalScope (for workers).
// Used from HTTP callbacks that only have a *Script in hand; the Script reaches
// the owner through its manager pointer.
pub const Owner = union(enum) {
frame: *Frame,
worker: *WorkerGlobalScope,
pub fn url(self: Owner) [:0]const u8 {
return switch (self) {
.frame => |f| f.url,
.worker => |w| w.url,
};
}
pub fn frameId(self: Owner) u32 {
return switch (self) {
.frame => |f| f._frame_id,
.worker => |w| w._worker._frame_id,
};
}
pub fn loaderId(self: Owner) u32 {
return switch (self) {
.frame => |f| f._loader_id,
.worker => |w| w._worker._loader_id,
};
}
pub fn session(self: Owner) *Session {
return switch (self) {
.frame => |f| f._session,
.worker => |w| w._session,
};
}
pub fn jsContext(self: Owner) *js.Context {
return switch (self) {
.frame => |f| f.js,
.worker => |w| w.js,
};
}
pub fn addHeaders(self: Owner, headers: *HttpClient.Headers) !void {
switch (self) {
.frame => |f| try f.headersForRequest(headers),
.worker => {},
}
}
};
owner: Owner,
// used to prevent recursive evaluation
is_evaluating: bool,
// Only once this is true can deferred scripts be run
static_scripts_done: bool,
// List of async scripts. We don't care about the execution order of these, but
// on shutdown/abort, we need to cleanup any pending ones. Used for both
// frame-side .async scripts and .import / .import_async modules.
async_scripts: std.DoublyLinkedList,
// List of deferred scripts. These must be executed in order, but only once
// dom_loaded == true. Workers never populate this list.
defer_scripts: std.DoublyLinkedList,
// When an async script is ready, it's queued here.
ready_scripts: std.DoublyLinkedList,
shutdown: bool = false,
client: *HttpClient,
allocator: Allocator,
// See ScriptManager.zig for the type's documentation.
imported_modules: std.StringHashMapUnmanaged(ImportedModule),
// Mapping between module specifier and resolution.
// see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap
// For workers this stays empty (only Frame authors importmaps via
// ScriptManager.parseImportmap).
importmap: std.StringHashMapUnmanaged([:0]const u8),
// Called at the end of evaluate() after all Base-owned work has run. Frame
// wrapper uses this to drain defer_scripts and fire documentIsLoaded /
// scriptsCompletedLoading. Null for workers.
tail_hook: ?*const fn (*ScriptManagerBase) void,
pub fn init(allocator: Allocator, http_client: *HttpClient, owner: Owner) ScriptManagerBase {
return .{
.owner = owner,
.async_scripts = .{},
.defer_scripts = .{},
.ready_scripts = .{},
.importmap = .empty,
.is_evaluating = false,
.allocator = allocator,
.imported_modules = .empty,
.client = http_client,
.static_scripts_done = false,
.tail_hook = null,
};
}
pub fn deinit(self: *ScriptManagerBase) void {
// necessary to free any arenas scripts may be referencing
self.reset();
self.imported_modules.deinit(self.allocator);
// we don't deinit self.importmap b/c we use the owner's arena for its
// allocations.
}
pub fn reset(self: *ScriptManagerBase) void {
var it = self.imported_modules.valueIterator();
while (it.next()) |value_ptr| {
switch (value_ptr.state) {
.done => |script| script.deinit(),
else => {},
}
}
self.imported_modules.clearRetainingCapacity();
// The importmap's keys/values were allocated from the owner's arena, which
// has been reset. Can't use clearAndRetainCapacity — that space is no
// longer ours.
self.importmap = .empty;
clearList(&self.defer_scripts);
clearList(&self.async_scripts);
clearList(&self.ready_scripts);
self.static_scripts_done = false;
}
fn clearList(list: *std.DoublyLinkedList) void {
while (list.popFirst()) |n| {
const script: *Script = @fieldParentPtr("node", n);
script.deinit();
}
}
pub fn getHeaders(self: *ScriptManagerBase) !http.Headers {
var headers = try self.client.newHeaders();
try self.owner.addHeaders(&headers);
return headers;
}
fn acquireArena(self: *ScriptManagerBase, size_or_bucket: anytype, debug: []const u8) !Allocator {
return self.owner.session().getArena(size_or_bucket, debug);
}
fn releaseArena(self: *ScriptManagerBase, arena: Allocator) void {
self.owner.session().releaseArena(arena);
}
pub fn scriptList(self: *ScriptManagerBase, script: *const Script) *std.DoublyLinkedList {
return switch (script.mode) {
.normal => unreachable, // not added to a list, executed immediately
.@"defer" => &self.defer_scripts,
.async, .import_async, .import => &self.async_scripts,
};
}
// Resolve a module specifier to a valid URL.
pub fn resolveSpecifier(self: *ScriptManagerBase, arena: Allocator, base: [:0]const u8, specifier: [:0]const u8) ![:0]const u8 {
// If the specifier is mapped in the importmap, return the pre-resolved
// value. For workers this map is empty.
if (self.importmap.get(specifier)) |s| {
return s;
}
return URL.resolve(arena, base, specifier, .{ .always_dupe = true });
}
pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []const u8) !void {
const gop = try self.imported_modules.getOrPut(self.allocator, url);
if (gop.found_existing) {
gop.value_ptr.waiters += 1;
return;
}
errdefer _ = self.imported_modules.remove(url);
const arena = try self.acquireArena(.large, "SM.preloadImport");
errdefer self.releaseArena(arena);
const script = try arena.create(Script);
script.* = .{
.kind = .module,
.arena = arena,
.url = url,
.node = .{},
.manager = self,
.complete = false,
.script_element = null,
.source = .{ .remote = .{} },
.mode = .import,
};
gop.value_ptr.* = ImportedModule{};
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
self.owner.jsContext().localScope(&ls);
defer ls.deinit();
log.debug(.http, "script queue", .{
.url = url,
.ctx = "module",
.referrer = referrer,
.stack = ls.local.stackTrace() catch "???",
});
}
// This seems wrong since we're not dealing with an async import (unlike
// getAsyncModule below), but all we're trying to do here is pre-load the
// script for execution at some point in the future (when waitForImport is
// called).
self.async_scripts.append(&script.node);
const session = self.owner.session();
self.client.request(.{
.ctx = script,
.params = .{
.url = url,
.method = .GET,
.frame_id = self.owner.frameId(),
.loader_id = self.owner.loaderId(),
.headers = try self.getHeaders(),
.cookie_jar = &session.cookie_jar,
.cookie_origin = self.owner.url(),
.resource_type = .script,
.notification = session.notification,
},
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback,
.data_callback = Script.dataCallback,
.done_callback = Script.doneCallback,
.error_callback = Script.errorCallback,
}) catch |err| {
self.async_scripts.remove(&script.node);
return err;
};
}
pub fn waitForImport(self: *ScriptManagerBase, url: [:0]const u8) !ModuleSource {
const entry = self.imported_modules.getEntry(url) orelse {
// It shouldn't be possible for v8 to ask for a module that we didn't
// `preloadImport` above.
return error.UnknownModule;
};
const was_evaluating = self.is_evaluating;
self.is_evaluating = true;
defer self.is_evaluating = was_evaluating;
var client = self.client;
while (true) {
switch (entry.value_ptr.state) {
.loading => {
_ = try client.tick(200);
continue;
},
.done => |script| {
var shared = false;
const buffer = entry.value_ptr.buffer;
const waiters = entry.value_ptr.waiters;
if (waiters == 1) {
self.imported_modules.removeByPtr(entry.key_ptr);
} else {
shared = true;
entry.value_ptr.waiters = waiters - 1;
}
return .{
.buffer = buffer,
.shared = shared,
.script = script,
};
},
.err => return error.Failed,
}
}
}
pub fn getAsyncImport(self: *ScriptManagerBase, url: [:0]const u8, cb: ImportAsync.Callback, cb_data: *anyopaque, referrer: []const u8) !void {
const arena = try self.acquireArena(.large, "SM.getAsyncImport");
errdefer self.releaseArena(arena);
const script = try arena.create(Script);
script.* = .{
.kind = .module,
.arena = arena,
.url = url,
.node = .{},
.manager = self,
.complete = false,
.script_element = null,
.source = .{ .remote = .{} },
.mode = .{ .import_async = .{
.callback = cb,
.data = cb_data,
} },
};
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
self.owner.jsContext().localScope(&ls);
defer ls.deinit();
log.debug(.http, "script queue", .{
.url = url,
.ctx = "dynamic module",
.referrer = referrer,
.stack = ls.local.stackTrace() catch "???",
});
}
// It's possible, but unlikely, for client.request to immediately finish
// a request, thus calling our callback. We generally don't want a call
// from v8 (which is why we're here), to result in a new script evaluation.
// So we block even the slightest change that `client.request` immediately
// executes a callback.
const was_evaluating = self.is_evaluating;
self.is_evaluating = true;
defer self.is_evaluating = was_evaluating;
const session = self.owner.session();
self.async_scripts.append(&script.node);
self.client.request(.{
.ctx = script,
.params = .{
.url = url,
.method = .GET,
.frame_id = self.owner.frameId(),
.loader_id = self.owner.loaderId(),
.headers = try self.getHeaders(),
.resource_type = .script,
.cookie_jar = &session.cookie_jar,
.cookie_origin = self.owner.url(),
.notification = session.notification,
},
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback,
.data_callback = Script.dataCallback,
.done_callback = Script.doneCallback,
.error_callback = Script.errorCallback,
}) catch |err| {
self.async_scripts.remove(&script.node);
return err;
};
}
// Called from the Page / Frame to signal it's done parsing the HTML, so
// deferred scripts can start evaluating. Workers never call this.
pub fn staticScriptsDone(self: *ScriptManagerBase) void {
lp.assert(self.static_scripts_done == false, "ScriptManagerBase.staticScriptsDone", .{});
self.static_scripts_done = true;
self.evaluate();
}
pub fn evaluate(self: *ScriptManagerBase) void {
if (self.is_evaluating) {
// It's possible for a script.eval to cause evaluate to be called again.
return;
}
self.is_evaluating = true;
defer self.is_evaluating = false;
while (self.ready_scripts.popFirst()) |n| {
var script: *Script = @fieldParentPtr("node", n);
switch (script.mode) {
.async => {
defer script.deinit();
// Workers never create .async mode scripts.
script.eval(self.owner.frame);
},
.import_async => |ia| {
if (script.status < 200 or script.status > 299) {
script.deinit();
ia.callback(ia.data, error.FailedToLoad);
} else {
ia.callback(ia.data, .{
.shared = false,
.script = script,
.buffer = script.source.remote,
});
}
},
else => unreachable, // no other script is put in this list
}
}
if (self.static_scripts_done == false) {
// We can only execute deferred scripts if
// 1 - all the normal scripts are done
// 2 - we've finished parsing the HTML and at least queued all the scripts
// The last one isn't obvious, but it's possible for self.scripts to
// be empty not because we're done executing all the normal scripts
// but because we're done executing some (or maybe none), but we're still
// parsing the HTML.
return;
}
while (self.defer_scripts.first) |n| {
var script: *Script = @fieldParentPtr("node", n);
if (script.complete == false) return;
defer {
_ = self.defer_scripts.popFirst();
script.deinit();
}
// Only Frames populate defer_scripts.
script.eval(self.owner.frame);
}
// Frame wrapper uses this to fire documentIsLoaded and
// scriptsCompletedLoading. Null for workers.
if (self.tail_hook) |hook| hook(self);
}
pub const Script = struct {
kind: Kind,
complete: bool,
status: u16 = 0,
source: Source,
url: []const u8,
arena: Allocator,
mode: ExecutionMode,
node: std.DoublyLinkedList.Node,
script_element: ?*Element.Html.Script,
manager: *ScriptManagerBase,
// for debugging a rare production issue
header_callback_called: bool = false,
// for debugging a rare production issue
debug_transfer_id: u32 = 0,
debug_transfer_tries: u8 = 0,
debug_transfer_aborted: bool = false,
debug_transfer_bytes_received: usize = 0,
debug_transfer_notified_fail: bool = false,
debug_transfer_auth_challenge: bool = false,
debug_transfer_easy_id: usize = 0,
pub const Kind = enum {
module,
javascript,
importmap,
};
pub const Source = union(enum) {
@"inline": []const u8,
remote: std.ArrayList(u8),
pub fn content(self: Source) []const u8 {
return switch (self) {
.remote => |buf| buf.items,
.@"inline" => |c| c,
};
}
};
pub const ExecutionMode = union(enum) {
normal,
@"defer",
async,
import,
import_async: ImportAsync,
};
pub fn deinit(self: *Script) void {
self.manager.releaseArena(self.arena);
}
pub fn startCallback(response: HttpClient.Response) !void {
log.debug(.http, "script fetch start", .{ .req = response });
}
pub fn headerCallback(response: HttpClient.Response) !bool {
const self: *Script = @ptrCast(@alignCast(response.ctx));
self.status = response.status().?;
if (response.status() != 200) {
log.info(.http, "script header", .{
.req = response,
.status = response.status(),
.content_type = response.contentType(),
});
return false;
}
if (comptime IS_DEBUG) {
log.debug(.http, "script header", .{
.req = response,
.status = response.status(),
.content_type = response.contentType(),
});
}
switch (response.inner) {
.transfer => |transfer| {
// temp debug, trying to figure out why the next assert sometimes
// fails. Is the buffer just corrupt or is headerCallback really
// being called twice?
lp.assert(self.header_callback_called == false, "ScriptManagerBase.Header recall", .{
.m = @tagName(std.meta.activeTag(self.mode)),
.a1 = self.debug_transfer_id,
.a2 = self.debug_transfer_tries,
.a3 = self.debug_transfer_aborted,
.a4 = self.debug_transfer_bytes_received,
.a5 = self.debug_transfer_notified_fail,
.a8 = self.debug_transfer_auth_challenge,
.a9 = self.debug_transfer_easy_id,
.b1 = transfer.id,
.b2 = transfer._tries,
.b3 = transfer.aborted,
.b4 = transfer.bytes_received,
.b5 = transfer._notified_fail,
.b8 = transfer._auth_challenge != null,
.b9 = if (transfer._conn) |c| @intFromPtr(c._easy) else 0,
});
self.header_callback_called = true;
self.debug_transfer_id = transfer.id;
self.debug_transfer_tries = transfer._tries;
self.debug_transfer_aborted = transfer.aborted;
self.debug_transfer_bytes_received = transfer.bytes_received;
self.debug_transfer_notified_fail = transfer._notified_fail;
self.debug_transfer_auth_challenge = transfer._auth_challenge != null;
self.debug_transfer_easy_id = if (transfer._conn) |c| @intFromPtr(c._easy) else 0;
},
else => {},
}
lp.assert(self.source.remote.capacity == 0, "ScriptManagerBase.Header buffer", .{ .capacity = self.source.remote.capacity });
var buffer: std.ArrayList(u8) = .empty;
if (response.contentLength()) |cl| {
try buffer.ensureTotalCapacity(self.arena, cl);
}
self.source = .{ .remote = buffer };
return true;
}
pub fn dataCallback(response: HttpClient.Response, data: []const u8) !void {
const self: *Script = @ptrCast(@alignCast(response.ctx));
self._dataCallback(response, data) catch |err| {
log.err(.http, "SM.dataCallback", .{ .err = err, .transfer = response, .len = data.len });
return err;
};
}
fn _dataCallback(self: *Script, _: HttpClient.Response, data: []const u8) !void {
try self.source.remote.appendSlice(self.arena, data);
}
pub fn doneCallback(ctx: *anyopaque) !void {
const self: *Script = @ptrCast(@alignCast(ctx));
self.complete = true;
if (comptime IS_DEBUG) {
log.debug(.http, "script fetch complete", .{ .req = self.url });
}
const manager = self.manager;
if (self.mode == .async or self.mode == .import_async) {
manager.async_scripts.remove(&self.node);
manager.ready_scripts.append(&self.node);
} else if (self.mode == .import) {
manager.async_scripts.remove(&self.node);
const entry = manager.imported_modules.getPtr(self.url).?;
entry.state = .{ .done = self };
entry.buffer = self.source.remote;
}
manager.evaluate();
}
pub fn errorCallback(ctx: *anyopaque, err: anyerror) void {
const self: *Script = @ptrCast(@alignCast(ctx));
log.warn(.http, "script fetch error", .{
.err = err,
.req = self.url,
.mode = std.meta.activeTag(self.mode),
.kind = self.kind,
.status = self.status,
});
if (self.mode == .normal) {
// This is blocked in a loop at the end of addFromElement, setting
// it to complete with a status of 0 will signal the error.
self.status = 0;
self.complete = true;
return;
}
const manager = self.manager;
manager.scriptList(self).remove(&self.node);
if (manager.shutdown) {
self.deinit();
return;
}
switch (self.mode) {
.import_async => |ia| ia.callback(ia.data, error.FailedToLoad),
.import => {
const entry = manager.imported_modules.getPtr(self.url).?;
entry.state = .err;
},
else => {},
}
self.deinit();
manager.evaluate();
}
pub fn eval(self: *Script, frame: *Frame) void {
// never evaluated, source is passed back to v8, via callbacks.
if (comptime IS_DEBUG) {
std.debug.assert(self.mode != .import_async);
// never evaluated, source is passed back to v8 when asked for it.
std.debug.assert(self.mode != .import);
}
if (frame.isGoingAway()) {
// don't evaluate scripts for a dying frame.
return;
}
const script_element = self.script_element.?;
const previous_script = frame.document._current_script;
frame.document._current_script = script_element;
defer frame.document._current_script = previous_script;
// Clear the document.write insertion point for this script
const previous_write_insertion_point = frame.document._write_insertion_point;
frame.document._write_insertion_point = null;
defer frame.document._write_insertion_point = previous_write_insertion_point;
// inline scripts aren't cached. remote ones are.
const cacheable = self.source == .remote;
const url = self.url;
log.info(.browser, "executing script", .{
.src = url,
.kind = self.kind,
.cacheable = cacheable,
});
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
const local = &ls.local;
// Handle importmap special case here: the content is a JSON containing
// imports.
if (self.kind == .importmap) {
frame._script_manager.parseImportmap(self) catch |err| {
log.err(.browser, "parse importmap script", .{
.err = err,
.src = url,
.kind = self.kind,
.cacheable = cacheable,
});
self.executeCallback(comptime .wrap("error"), frame);
return;
};
self.executeCallback(comptime .wrap("load"), frame);
return;
}
defer frame._event_manager.clearIgnoreList();
var try_catch: js.TryCatch = undefined;
try_catch.init(local);
defer try_catch.deinit();
const success = blk: {
const content = self.source.content();
switch (self.kind) {
.javascript => _ = local.eval(content, url) catch break :blk false,
.module => {
// We don't care about waiting for the evaluation here.
frame.js.module(false, local, content, url, cacheable) catch break :blk false;
},
.importmap => unreachable, // handled before the try/catch.
}
break :blk true;
};
if (comptime IS_DEBUG) {
log.debug(.browser, "executed script", .{ .src = url, .success = success });
}
defer {
local.runMacrotasks(); // also runs microtasks
_ = frame.js.scheduler.run() catch |err| {
log.err(.frame, "scheduler", .{ .err = err });
};
}
if (success) {
self.executeCallback(comptime .wrap("load"), frame);
return;
}
const caught = try_catch.caughtOrError(frame.call_arena, error.Unknown);
log.warn(.js, "eval script", .{
.url = url,
.caught = caught,
.cacheable = cacheable,
});
self.executeCallback(comptime .wrap("error"), frame);
}
fn executeCallback(self: *const Script, typ: String, frame: *Frame) void {
const Event = @import("webapi/Event.zig");
const event = Event.initTrusted(typ, .{}, frame._page) catch |err| {
log.warn(.js, "script internal callback", .{
.url = self.url,
.type = typ,
.err = err,
});
return;
};
frame._event_manager.dispatchOpts(self.script_element.?.asNode().asEventTarget(), event, .{ .apply_ignore = true }) catch |err| {
log.warn(.js, "script callback", .{
.url = self.url,
.type = typ,
.err = err,
});
};
}
};
pub const ImportAsync = struct {
data: *anyopaque,
callback: ImportAsync.Callback,
pub const Callback = *const fn (ptr: *anyopaque, result: anyerror!ModuleSource) void;
};
pub const ModuleSource = struct {
shared: bool,
script: *Script,
buffer: std.ArrayList(u8),
pub fn deinit(self: *ModuleSource) void {
if (self.shared == false) {
self.script.deinit();
}
}
pub fn src(self: *const ModuleSource) []const u8 {
return self.buffer.items;
}
};
pub const ImportedModule = struct {
waiters: u16 = 1,
state: State = .loading,
buffer: std.ArrayList(u8) = .{},
pub const State = union(enum) {
err,
loading,
done: *Script,
};
};

View File

@@ -133,9 +133,15 @@ pub fn createPage(self: *Session) !*Frame {
}
pub fn removePage(self: *Session) void {
lp.assert(self.page != null, "Session.removePage - page is null", .{});
if (self.page.?.frame._script_manager.base.is_evaluating) {
// Reentrant teardown from a CDP message drained inside syncRequest;
// Session.deinit reclaims the page when the connection closes.
return;
}
// Inform CDP the frame is going to be removed, allowing other worlds to remove themselves before the main one
self.notification.dispatch(.frame_remove, .{});
lp.assert(self.page != null, "Session.removePage - page is null", .{});
self.page.?.deinit(false);
self.page = null;
@@ -287,6 +293,13 @@ pub fn processQueuedNavigation(self: *Session) !void {
}
fn processFrameNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation) !void {
// Popups live on the Page as top-level browsing contexts without a
// parent or iframe element. Their re-navigation path is simpler than
// iframes — no parent bookkeeping to patch.
if (frame.parent == null and frame.iframe == null) {
return self.processPopupNavigation(frame, qn);
}
lp.assert(frame.parent != null, "root queued navigation", .{});
const iframe = frame.iframe.?;
@@ -337,6 +350,45 @@ fn processFrameNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation)
};
}
// Re-navigates a popup Frame in place. The Frame pointer stays stable
// (scripts in the opener may hold a cached Window ref — though the Window
// object inside is replaced, matching how iframes behave on navigation).
fn processPopupNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation) !void {
frame._queued_navigation = null;
defer self.releaseArena(qn.arena);
// Preserve popup identity fields. _name lives in the Page arena and
// survives Frame.deinit; _opener is just a pointer.
const saved_name = frame.window._name;
const saved_opener = frame.window._opener;
const frame_id = frame._frame_id;
const page = self.currentPage().?;
frame.deinit(true);
frame.* = undefined;
errdefer {
// If re-init fails, drop from popups so we don't leave a corpse.
for (page.popups.items, 0..) |p, i| {
if (p == frame) {
_ = page.popups.swapRemove(i);
break;
}
}
}
try Frame.init(frame, frame_id, page, null);
errdefer frame.deinit(true);
frame.window._name = saved_name;
frame.window._opener = saved_opener;
frame.navigate(qn.url, qn.opts) catch |err| {
log.err(.browser, "queued popup navigation error", .{ .err = err });
return err;
};
}
fn processRootQueuedNavigation(self: *Session) !void {
const current_frame = &self.page.?.frame;
const frame_id = current_frame._frame_id;

View File

@@ -17,6 +17,8 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const idna = @import("../sys/idna.zig");
const Allocator = std.mem.Allocator;
pub const ResolveOpts = struct {
@@ -190,11 +192,35 @@ pub fn resolve(allocator: Allocator, base: [:0]const u8, source_path: anytype, o
}
fn processResolved(allocator: Allocator, url: [:0]const u8, opts: ResolveOpts) ![:0]const u8 {
const encoding = opts.encoding orelse return url;
const encoding = opts.encoding orelse return ensureHostAscii(allocator, url);
return ensureEncoded(allocator, url, encoding);
}
pub fn ensureEncoded(allocator: Allocator, url: [:0]const u8, encoding: []const u8) ![:0]const u8 {
/// IDNA-only pass: converts a non-ASCII host (`räksmörgås.se`) to its
/// punycode form (`xn--rksmrgs-5wao1o.se`) and leaves everything else alone.
fn ensureHostAscii(allocator: Allocator, url: [:0]const u8) ![:0]const u8 {
const hostname = getHostname(url);
if (hostname.len == 0 or !idna.needsAscii(hostname)) {
return url;
}
const ascii = try idna.toAscii(allocator, hostname);
// hostname is a slice of url, so its start offset is just pointer arithmetic.
const start = @intFromPtr(hostname.ptr) - @intFromPtr(url.ptr);
const end = start + hostname.len;
var buf = try std.ArrayList(u8).initCapacity(allocator, url.len - hostname.len + ascii.len + 1);
buf.appendSliceAssumeCapacity(url[0..start]);
buf.appendSliceAssumeCapacity(ascii);
buf.appendSliceAssumeCapacity(url[end..]);
buf.appendAssumeCapacity(0);
return buf.items[0 .. buf.items.len - 1 :0];
}
pub fn ensureEncoded(allocator: Allocator, url_in: [:0]const u8, encoding: []const u8) ![:0]const u8 {
// Resolve any IDN host first; everything below operates on the ASCII form.
const url = try ensureHostAscii(allocator, url_in);
const scheme_end = std.mem.indexOf(u8, url, "://");
const authority_start = if (scheme_end) |end| end + 3 else 0;
const path_start = std.mem.indexOfScalarPos(u8, url, authority_start, '/') orelse return url;

View File

@@ -28,7 +28,7 @@ const Execution = @import("Execution.zig");
const Frame = @import("../Frame.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const ScriptManager = @import("../ScriptManager.zig");
const ScriptManagerBase = @import("../ScriptManagerBase.zig");
const WorkerGlobalScope = @import("../webapi/WorkerGlobalScope.zig");
const v8 = js.v8;
@@ -138,8 +138,9 @@ module_cache: std.StringHashMapUnmanaged(ModuleEntry) = .empty,
// necessary to lookup/store the dependent module in the module_cache.
module_identifier: std.AutoHashMapUnmanaged(u32, [:0]const u8) = .empty,
// the frame's script manager
script_manager: ?*ScriptManager,
// Module-loading plumbing. Frame contexts point at the ScriptManager's
// embedded Base; worker contexts point at WorkerGlobalScope's Base directly.
script_manager: *ScriptManagerBase,
// Our macrotasks
scheduler: Scheduler,
@@ -484,7 +485,7 @@ fn postCompileModule(self: *Context, mod: js.Module, url: [:0]const u8, local: *
// dependent modules this module has and start downloading them asap.
const requests = mod.getModuleRequests();
const request_len = requests.len();
const script_manager = self.script_manager.?;
const script_manager = self.script_manager;
for (0..request_len) |i| {
const specifier = requests.get(i).specifier(local);
const normalized_specifier = try script_manager.resolveSpecifier(
@@ -590,7 +591,7 @@ pub fn dynamicModuleCallback(
return @constCast(local.rejectPromise(.{ .generic_error = "Out of memory" }).handle);
};
const normalized_specifier = self.script_manager.?.resolveSpecifier(
const normalized_specifier = self.script_manager.resolveSpecifier(
self.arena, // might need to survive until the module is loaded
resource,
specifier,
@@ -643,7 +644,7 @@ fn _resolveModuleCallback(self: *Context, referrer: js.Module, specifier: [:0]co
return error.UnknownModuleReferrer;
};
const normalized_specifier = try self.script_manager.?.resolveSpecifier(
const normalized_specifier = try self.script_manager.resolveSpecifier(
self.arena,
referrer_path,
specifier,
@@ -654,12 +655,12 @@ fn _resolveModuleCallback(self: *Context, referrer: js.Module, specifier: [:0]co
return local.toLocal(m).handle;
}
var source = self.script_manager.?.waitForImport(normalized_specifier) catch |err| switch (err) {
var source = self.script_manager.waitForImport(normalized_specifier) catch |err| switch (err) {
error.UnknownModule => blk: {
// Module is in cache but was consumed from imported_modules
// (e.g., by a previous failed resolution). Re-preload and retry.
try self.script_manager.?.preloadImport(normalized_specifier, referrer_path);
break :blk try self.script_manager.?.waitForImport(normalized_specifier);
try self.script_manager.preloadImport(normalized_specifier, referrer_path);
break :blk try self.script_manager.waitForImport(normalized_specifier);
},
else => return err,
};
@@ -728,7 +729,7 @@ fn _dynamicModuleCallback(self: *Context, specifier: [:0]const u8, referrer: []c
};
// Next, we need to actually load it.
self.script_manager.?.getAsyncImport(specifier, dynamicModuleSourceCallback, state, referrer) catch |err| {
self.script_manager.getAsyncImport(specifier, dynamicModuleSourceCallback, state, referrer) catch |err| {
const error_msg = local.newString(@errorName(err));
_ = resolver.reject("dynamic module get async", error_msg);
};
@@ -797,7 +798,7 @@ fn _dynamicModuleCallback(self: *Context, specifier: [:0]const u8, referrer: []c
return promise;
}
fn dynamicModuleSourceCallback(ctx: *anyopaque, module_source_: anyerror!ScriptManager.ModuleSource) void {
fn dynamicModuleSourceCallback(ctx: *anyopaque, module_source_: anyerror!ScriptManagerBase.ModuleSource) void {
const state: *DynamicModuleResolveState = @ptrCast(@alignCast(ctx));
var self = state.context;

View File

@@ -296,7 +296,7 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context
.templates = self.templates,
.call_arena = params.call_arena,
.microtask_queue = microtask_queue,
.script_manager = if (comptime is_frame) &global._script_manager else null,
.script_manager = if (comptime is_frame) &global._script_manager.base else &global._script_manager,
.scheduler = .init(context_arena),
.identity = params.identity,
.identity_arena = params.identity_arena,

View File

@@ -120,6 +120,135 @@
});
</script>
<script>
// helper
window.expectGenKeyRejects = async function(algo, extractable, usages, expected) {
try {
await crypto.subtle.generateKey(algo, extractable, usages);
testing.fail(`expected ${expected}, but generateKey resolved for ${JSON.stringify(algo)}`);
} catch (err) {
testing.expectEqual(expected, err.name);
}
};
</script>
<script id=generateKey-unrecognized-algorithm>
testing.async(async () => {
// Unknown name (string and object form) -> NotSupportedError.
await expectGenKeyRejects("AES", true, ["encrypt"], "NotSupportedError");
await expectGenKeyRejects({ name: "AES" }, true, ["encrypt"], "NotSupportedError");
await expectGenKeyRejects({ name: "AES-CMAC", length: 128 }, true, ["encrypt"], "NotSupportedError");
await expectGenKeyRejects({ name: "EC", namedCurve: "P-256" }, true, ["sign"], "NotSupportedError");
// Empty algorithm dictionary -> TypeError (missing required `name`).
await expectGenKeyRejects({}, true, ["encrypt"], "TypeError");
});
</script>
<script id=generateKey-aes-validation>
testing.async(async () => {
// Bad usages: SyntaxError.
await expectGenKeyRejects({ name: "AES-CBC", length: 128 }, true, ["sign"], "SyntaxError");
await expectGenKeyRejects({ name: "AES-GCM", length: 128 }, true, ["encrypt", "deriveBits"], "SyntaxError");
// AES-KW only allows wrapKey / unwrapKey.
await expectGenKeyRejects({ name: "AES-KW", length: 128 }, true, ["encrypt"], "SyntaxError");
// Bad length: OperationError.
for (const length of [64, 127, 129, 255, 257, 512]) {
await expectGenKeyRejects({ name: "AES-CBC", length }, true, ["encrypt"], "OperationError");
}
// Empty usages on a secret key: SyntaxError.
await expectGenKeyRejects({ name: "AES-CBC", length: 128 }, true, [], "SyntaxError");
await expectGenKeyRejects({ name: "AES-KW", length: 256 }, true, [], "SyntaxError");
});
</script>
<script id=generateKey-ec-validation>
testing.async(async () => {
// Bad usages: SyntaxError.
await expectGenKeyRejects({ name: "ECDSA", namedCurve: "P-256" }, true, ["encrypt"], "SyntaxError");
await expectGenKeyRejects({ name: "ECDH", namedCurve: "P-256" }, true, ["sign"], "SyntaxError");
// Unknown curve: NotSupportedError (not OperationError, per spec).
await expectGenKeyRejects({ name: "ECDSA", namedCurve: "P-512" }, true, ["sign"], "NotSupportedError");
await expectGenKeyRejects({ name: "ECDH", namedCurve: "Curve25519" }, true, ["deriveBits"], "NotSupportedError");
// Empty usages: SyntaxError (mandatoryUsages aside, the spec rejects empty for private keys).
await expectGenKeyRejects({ name: "ECDSA", namedCurve: "P-256" }, true, [], "SyntaxError");
});
</script>
<script id=generateKey-rsa-validation>
testing.async(async () => {
const goodExp = new Uint8Array([1, 0, 1]); // 65537
// Bad name: NotSupportedError.
await expectGenKeyRejects(
{ name: "RSA", hash: "SHA-256", modulusLength: 2048, publicExponent: goodExp },
true, ["sign"], "NotSupportedError",
);
// Bad hash: NotSupportedError.
await expectGenKeyRejects(
{ name: "RSA-PSS", hash: "SHA", modulusLength: 2048, publicExponent: goodExp },
true, ["sign"], "NotSupportedError",
);
// Bad usages: SyntaxError. RSASSA only allows sign/verify.
await expectGenKeyRejects(
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256", modulusLength: 2048, publicExponent: goodExp },
true, ["encrypt"], "SyntaxError",
);
// RSA-OAEP only allows encrypt/decrypt/wrapKey/unwrapKey.
await expectGenKeyRejects(
{ name: "RSA-OAEP", hash: "SHA-256", modulusLength: 2048, publicExponent: goodExp },
true, ["sign"], "SyntaxError",
);
// Bad publicExponent: OperationError.
await expectGenKeyRejects(
{ name: "RSA-OAEP", hash: "SHA-256", modulusLength: 2048, publicExponent: new Uint8Array([1]) },
true, ["encrypt"], "OperationError",
);
await expectGenKeyRejects(
{ name: "RSA-OAEP", hash: "SHA-256", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 0]) },
true, ["encrypt"], "OperationError",
);
// Empty usages: SyntaxError.
await expectGenKeyRejects(
{ name: "RSA-OAEP", hash: "SHA-256", modulusLength: 2048, publicExponent: goodExp },
true, [], "SyntaxError",
);
});
</script>
<script id=generateKey-hmac-validation>
testing.async(async () => {
// Unknown hash: NotSupportedError.
await expectGenKeyRejects({ name: "HMAC", hash: "MD5" }, true, ["sign"], "NotSupportedError");
// Bad usages: SyntaxError. HMAC only allows sign/verify.
await expectGenKeyRejects({ name: "HMAC", hash: "SHA-256" }, true, ["encrypt"], "SyntaxError");
await expectGenKeyRejects({ name: "HMAC", hash: "SHA-256" }, true, ["sign", "deriveBits"], "SyntaxError");
// Empty usages: SyntaxError.
await expectGenKeyRejects({ name: "HMAC", hash: "SHA-256" }, true, [], "SyntaxError");
});
</script>
<script id=generateKey-edxxx-validation>
testing.async(async () => {
// Ed25519 / Ed448 only allow sign/verify; X448 only allows deriveKey/deriveBits.
await expectGenKeyRejects("Ed25519", true, ["encrypt"], "SyntaxError");
await expectGenKeyRejects({ name: "Ed448" }, true, ["deriveBits"], "SyntaxError");
await expectGenKeyRejects("X448", true, ["sign"], "SyntaxError");
// Empty usages: SyntaxError.
await expectGenKeyRejects("Ed25519", true, [], "SyntaxError");
});
</script>
<script id="digest">
testing.async(async () => {
async function hash(algo, data) {

View File

@@ -0,0 +1,158 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<!-- HTMLLabelElement activation behavior (HTML §4.10.4 "The label element"):
calling .click() on a <label> must run the synthetic click activation
steps on the labeled control. -->
<input id="cb_for" type="checkbox">
<label id="lab_for" for="cb_for">Toggle by for=</label>
<label id="lab_wrap"><input id="cb_wrap" type="checkbox"><span>Toggle wrapped</span></label>
<input id="r_a" type="radio" name="g1" checked>
<input id="r_b" type="radio" name="g1">
<label id="lab_radio" for="r_b">Pick r_b</label>
<input id="cb_disabled" type="checkbox" disabled>
<label id="lab_disabled" for="cb_disabled">Disabled</label>
<input id="cb_for_missing" type="checkbox">
<label id="lab_missing" for="does-not-exist">No such control</label>
<input id="cb_no_control" type="checkbox">
<label id="lab_empty">No for, no descendant control</label>
<input id="cb_outer_only" type="checkbox">
<label id="lab_outer">No for, with non-labelable child <span>x</span></label>
<form id="f_submit" action="javascript:void(0)">
<label id="lab_submit"><button id="btn_submit" type="submit">Submit</button></label>
</form>
<script id="for_attr_toggles_checkbox">
{
const cb = $('#cb_for');
const lab = $('#lab_for');
testing.expectEqual(false, cb.checked);
lab.click();
testing.expectEqual(true, cb.checked);
lab.click();
testing.expectEqual(false, cb.checked);
}
</script>
<script id="wrapping_label_toggles_checkbox">
{
const cb = $('#cb_wrap');
const lab = $('#lab_wrap');
testing.expectEqual(false, cb.checked);
lab.click();
testing.expectEqual(true, cb.checked);
lab.click();
testing.expectEqual(false, cb.checked);
}
</script>
<script id="for_attr_checks_radio">
{
const a = $('#r_a');
const b = $('#r_b');
testing.expectEqual(true, a.checked);
testing.expectEqual(false, b.checked);
$('#lab_radio').click();
testing.expectEqual(false, a.checked);
testing.expectEqual(true, b.checked);
}
</script>
<script id="disabled_control_does_not_toggle">
{
const cb = $('#cb_disabled');
testing.expectEqual(false, cb.checked);
$('#lab_disabled').click();
testing.expectEqual(false, cb.checked, 'disabled control must not activate');
}
</script>
<script id="missing_for_target_no_op">
{
// No exception, no side-effect.
$('#lab_missing').click();
testing.expectEqual(false, $('#cb_for_missing').checked);
}
</script>
<script id="no_for_no_descendant_no_op">
{
$('#lab_empty').click();
testing.expectEqual(false, $('#cb_no_control').checked);
}
</script>
<script id="no_labelable_descendant_no_op">
{
$('#lab_outer').click();
testing.expectEqual(false, $('#cb_outer_only').checked);
}
</script>
<script id="label_click_dispatches_click_on_control">
{
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.id = 'cb_evt';
document.body.appendChild(cb);
const lab = document.createElement('label');
lab.htmlFor = 'cb_evt';
document.body.appendChild(lab);
const events = [];
lab.addEventListener('click', e => events.push(['label', e.target.tagName]));
cb.addEventListener('click', e => events.push(['cb', e.target.tagName]));
cb.addEventListener('input', () => events.push(['cb', 'input']));
cb.addEventListener('change', () => events.push(['cb', 'change']));
lab.click();
// The label's own click fires first, then the synthetic click on the
// control fires + bubbles, then input/change fire on the control.
testing.expectEqual(true, cb.checked);
testing.expectEqual('label', events[0][0]);
testing.expectEqual('LABEL', events[0][1]);
testing.expectEqual('cb', events[1][0]);
testing.expectEqual('INPUT', events[1][1]);
testing.expectEqual('input', events[2][1]);
testing.expectEqual('change', events[3][1]);
document.body.removeChild(lab);
document.body.removeChild(cb);
}
</script>
<script id="label_click_preventDefault_on_label_blocks_activation">
{
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.id = 'cb_pd';
document.body.appendChild(cb);
const lab = document.createElement('label');
lab.htmlFor = 'cb_pd';
lab.addEventListener('click', (e) => e.preventDefault());
document.body.appendChild(lab);
lab.click();
testing.expectEqual(false, cb.checked, 'preventDefault on label must skip activation');
document.body.removeChild(lab);
document.body.removeChild(cb);
}
</script>

View File

@@ -871,3 +871,52 @@
testing.expectEqual('', url.search);
}
</script>
<script id=idna>
// WHATWG "domain to ASCII": non-ASCII hosts are converted to punycode at
// parse time, so getters always return the ASCII form.
{
const url = new URL('https://räksmörgås.se/');
testing.expectEqual('xn--rksmrgs-5wao1o.se', url.hostname);
testing.expectEqual('xn--rksmrgs-5wao1o.se', url.host);
testing.expectEqual('https://xn--rksmrgs-5wao1o.se/', url.href);
}
// UTS#46 non-transitional processing preserves ß rather than mapping to ss.
{
const url = new URL('https://faß.de/');
testing.expectEqual('xn--fa-hia.de', url.hostname);
}
// Pure-ASCII hosts must not be touched.
{
const url = new URL('https://example.com/');
testing.expectEqual('example.com', url.hostname);
testing.expectEqual('https://example.com/', url.href);
}
// IDN preserved alongside port, userinfo, path, query, and fragment.
{
const url = new URL('https://räksmörgås.se:8443/p?q=1#h');
testing.expectEqual('xn--rksmrgs-5wao1o.se', url.hostname);
testing.expectEqual('xn--rksmrgs-5wao1o.se:8443', url.host);
testing.expectEqual('8443', url.port);
testing.expectEqual('/p', url.pathname);
testing.expectEqual('?q=1', url.search);
testing.expectEqual('#h', url.hash);
}
{
const url = new URL('https://user:pass@räksmörgås.se/');
testing.expectEqual('xn--rksmrgs-5wao1o.se', url.hostname);
testing.expectEqual('user', url.username);
testing.expectEqual('pass', url.password);
}
// Resolving a relative path against an IDN base preserves the punycode host.
{
const url = new URL('/about', 'https://räksmörgås.se/');
testing.expectEqual('xn--rksmrgs-5wao1o.se', url.hostname);
testing.expectEqual('https://xn--rksmrgs-5wao1o.se/about', url.href);
}
</script>

View File

@@ -0,0 +1,109 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<body></body>
<script id=basic>
{
// window.open returns a Window-like object.
const w = window.open('about:blank');
testing.expectEqual(true, w != null);
testing.expectEqual(false, w.closed);
// The popup is a top-level browsing context.
testing.expectEqual(w, w.self);
testing.expectEqual(w, w.window);
testing.expectEqual(w, w.top);
testing.expectEqual(w, w.parent);
// Opener points back at us.
testing.expectEqual(window, w.opener);
w.close();
testing.expectEqual(true, w.closed);
}
</script>
<script id=main_opener_is_null>
testing.expectEqual(null, window.opener);
</script>
<script id=about_blank_default>
{
// No-arg open defaults to about:blank.
const w = window.open();
testing.expectEqual(true, w != null);
testing.expectEqual(false, w.closed);
w.close();
}
</script>
<script id=noopener>
{
// noopener returns null.
const w = window.open('about:blank', '_blank', 'noopener');
testing.expectEqual(null, w);
}
</script>
<script id=noreferrer>
{
// noreferrer also returns null (and implies noopener).
const w = window.open('about:blank', '_blank', 'noreferrer');
testing.expectEqual(null, w);
}
</script>
<script id=named_reuse>
{
// Second open with the same name reuses the popup — same Window identity.
const a = window.open('about:blank', 'myPopup');
testing.expectEqual('myPopup', a.name);
const b = window.open('about:blank', 'myPopup');
testing.expectEqual(a, b);
a.close();
}
</script>
<script id=close_noop_on_main>
// close() on a non-popup window is a no-op.
window.close();
testing.expectEqual(false, window.closed);
</script>
<script id=double_close>
{
const w = window.open('about:blank');
w.close();
testing.expectEqual(true, w.closed);
// Second close is safe.
w.close();
testing.expectEqual(true, w.closed);
}
</script>
<script id=name_setter>
{
const w = window.open('about:blank');
testing.expectEqual('', w.name);
w.name = 'renamed';
testing.expectEqual('renamed', w.name);
// Now it can be looked up by name.
const again = window.open('about:blank', 'renamed');
testing.expectEqual(w, again);
w.close();
}
</script>
<script id=close_drops_opener>
{
// Closing a popup that was opened from main shouldn't touch window.opener.
const w = window.open('about:blank', 'outer');
testing.expectEqual(window, w.opener);
w.close();
testing.expectEqual(true, w.closed);
testing.expectEqual(null, window.opener);
}
</script>

View File

@@ -0,0 +1,4 @@
export const message = 'imported from module';
export function multiply(a, b) {
return a * b;
}

View File

@@ -0,0 +1,14 @@
// Dynamic import() in a classic worker — before the ScriptManagerBase
// split, this path crashed on a null script_manager unwrap.
(async function() {
try {
const mod = await import('./import-module.js');
postMessage({
ok: true,
message: mod.message,
product: mod.multiply(6, 7),
});
} catch (e) {
postMessage({ ok: false, err: String(e) });
}
})();

View File

@@ -0,0 +1,49 @@
// Exercises module imports inside a worker. Classic workers can't use
// top-level `import`, so all imports go through dynamic import() — which
// is the path the ScriptManagerBase split was made to enable.
(async function () {
const results = {};
try {
const m1 = await import('./modules/base.js');
results.basic_baseValue = m1.baseValue;
const m2 = await import('./modules/importer.js');
results.transitive_importedValue = m2.importedValue;
results.transitive_localValue = m2.localValue;
const m3 = await import('./modules/re-exporter.js');
results.reexport_baseValue = m3.baseValue;
results.reexport_importedValue = m3.importedValue;
results.reexport_localValue = m3.localValue;
const m4a = await import('./modules/shared.js');
results.shared_first_inc = m4a.increment();
results.shared_first_count = m4a.getCount();
const m4b = await import('./modules/shared.js');
results.shared_second_inc = m4b.increment();
results.shared_second_count = m4b.getCount();
results.shared_same_module = m4a === m4b;
const ma = await import('./modules/circular-a.js');
const mb = await import('./modules/circular-b.js');
results.circular_aValue = ma.aValue;
results.circular_bValue = mb.bValue;
results.circular_getFromB = ma.getFromB();
results.circular_getFromA = mb.getFromA();
const mm = await import('./modules/meta.js');
results.meta_url_endsWith = mm.moduleUrl.endsWith('/tests/worker/modules/meta.js');
let import_404_threw = false;
try {
await import('./modules/nonexistent.js');
} catch (e) {
import_404_threw = e.toString().includes('FailedToLoad');
}
results.import_404_threw = import_404_threw;
postMessage({ ok: true, results });
} catch (e) {
postMessage({ ok: false, err: String(e), stack: e.stack });
}
})();

View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<body></body>
<script src="../testing.js"></script>
<script id="worker_module_imports" type=module>
{
const state = await testing.async();
const worker = new Worker('./module-test-worker.js');
worker.onmessage = function(event) {
state.resolve(event.data);
};
await state.done((response) => {
testing.expectTrue(response.ok, 'worker module error: ' + response.err);
const r = response.results;
// basic dynamic import
testing.expectEqual('from-base', r.basic_baseValue);
// transitive (static-from-dynamic) imports
testing.expectEqual('from-base', r.transitive_importedValue);
testing.expectEqual('local', r.transitive_localValue);
// re-exports
testing.expectEqual('from-base', r.reexport_baseValue);
testing.expectEqual('from-base', r.reexport_importedValue);
testing.expectEqual('local', r.reexport_localValue);
// module identity is preserved across two import() calls in the
// same worker — counter state persists.
testing.expectEqual(1, r.shared_first_inc);
testing.expectEqual(1, r.shared_first_count);
testing.expectEqual(2, r.shared_second_inc);
testing.expectEqual(2, r.shared_second_count);
testing.expectTrue(r.shared_same_module);
// circular static imports between two dynamically-imported modules
testing.expectEqual('a', r.circular_aValue);
testing.expectEqual('b', r.circular_bValue);
testing.expectEqual('b', r.circular_getFromB);
testing.expectEqual('a', r.circular_getFromA);
// import.meta.url resolves to the imported module's URL
testing.expectTrue(r.meta_url_endsWith);
// missing module surfaces FailedToLoad to the import() caller
testing.expectTrue(r.import_404_threw);
});
}
</script>

View File

@@ -0,0 +1 @@
export const baseValue = 'from-base';

View File

@@ -0,0 +1,7 @@
import { getBValue } from './circular-b.js';
export const aValue = 'a';
export function getFromB() {
return getBValue();
}

View File

@@ -0,0 +1,11 @@
import { aValue } from './circular-a.js';
export const bValue = 'b';
export function getBValue() {
return bValue;
}
export function getFromA() {
return aValue;
}

View File

@@ -0,0 +1,4 @@
import { baseValue } from './base.js';
export const importedValue = baseValue;
export const localValue = 'local';

View File

@@ -0,0 +1 @@
export const moduleUrl = import.meta.url;

View File

@@ -0,0 +1,2 @@
export { baseValue } from './base.js';
export { importedValue, localValue } from './importer.js';

View File

@@ -0,0 +1,9 @@
let counter = 0;
export function increment() {
return ++counter;
}
export function getCount() {
return counter;
}

View File

@@ -224,6 +224,23 @@
}
</script>
<script id="worker_dynamic_import" type=module>
{
const state = await testing.async();
const worker = new Worker('./import-worker.js');
worker.onmessage = function(event) {
state.resolve(event.data);
};
await state.done((response) => {
testing.expectTrue(response.ok, 'worker import error: ' + response.err);
testing.expectEqual('imported from module', response.message);
testing.expectEqual(42, response.product);
});
}
</script>
<script id="worker_structured_clone_nested" type=module>
{
const state = await testing.async();

View File

@@ -59,12 +59,17 @@ pub fn fromError(err: anyerror) ?DOMException {
error.InvalidNodeType => .{ ._code = .invalid_node_type_error },
error.DataClone => .{ ._code = .data_clone_error },
error.InvalidAccessError => .{ ._code = .invalid_access_error },
error.OperationError => .{ ._code = .operation_error },
else => null,
};
}
pub fn getCode(self: *const DOMException) u8 {
return @intFromEnum(self._code);
return switch (self._code) {
// WebCrypto-only error: no legacy numeric code.
.operation_error => 0,
else => @intFromEnum(self._code),
};
}
pub fn getName(self: *const DOMException) []const u8 {
@@ -95,6 +100,7 @@ pub fn getName(self: *const DOMException) []const u8 {
.timeout_error => "TimeoutError",
.invalid_node_type_error => "InvalidNodeTypeError",
.data_clone_error => "DataCloneError",
.operation_error => "OperationError",
};
}
@@ -125,6 +131,7 @@ pub fn getMessage(self: *const DOMException) []const u8 {
.timeout_error => "The operation timed out",
.invalid_node_type_error => "The supplied node is incorrect or has an incorrect ancestor for this operation",
.data_clone_error => "The object can not be cloned",
.operation_error => "The operation failed for an operation-specific reason",
};
}
@@ -164,6 +171,8 @@ const Code = enum(u8) {
timeout_error = 23,
invalid_node_type_error = 24,
data_clone_error = 25,
/// Defined by WebCrypto; no legacy code, exposed via name only.
operation_error = 0xFF,
/// Maps a standard error name to its legacy code
/// Returns .none (code 0) for non-legacy error names
@@ -190,6 +199,7 @@ const Code = enum(u8) {
.{ "TimeoutError", .timeout_error },
.{ "InvalidNodeTypeError", .invalid_node_type_error },
.{ "DataCloneError", .data_clone_error },
.{ "OperationError", .operation_error },
});
return lookup.get(name) orelse .none;
}

View File

@@ -255,7 +255,7 @@ pub fn setCookie(_: *HTMLDocument, cookie_str: []const u8, frame: *Frame) ![]con
c.deinit();
return ""; // HttpOnly cookies cannot be set from JS
}
try frame._session.cookie_jar.add(c, std.time.timestamp());
try frame._session.cookie_jar.add(c, std.time.timestamp(), false);
return cookie_str;
}

View File

@@ -206,6 +206,11 @@ fn urlEncodeEntry(entry: Entry, comptime mode: URLEncodeMode, allocator_: ?Alloc
try urlEncodeValue(entry.value.str(), mode, allocator_, charset, writer);
}
// Exposed so FormData (which keeps its own entry list) can reuse the charset/NCR-aware encoder.
pub fn urlEncodeFormValue(value: []const u8, allocator_: ?Allocator, charset: []const u8, writer: *std.Io.Writer) !void {
return urlEncodeValue(value, .form, allocator_, charset, writer);
}
fn urlEncodeValue(value: []const u8, comptime mode: URLEncodeMode, allocator_: ?Allocator, charset: []const u8, writer: *std.Io.Writer) !void {
// For UTF-8, do standard percent encoding
if (std.mem.eql(u8, charset, "UTF-8")) {
@@ -259,7 +264,12 @@ fn urlEncodeValueUtf8(value: []const u8, comptime mode: URLEncodeMode, writer: *
return writer.writeAll(value);
}
for (value) |b| {
var i: usize = 0;
while (i < value.len) : (i += 1) {
const b = value[i];
if (comptime mode == .form) {
if (try writeFormLineEnd(value, &i, b, writer)) continue;
}
if (urlEncodeUnreserved(b, mode)) {
try writer.writeByte(b);
} else if (b == ' ') {
@@ -272,7 +282,12 @@ fn urlEncodeValueUtf8(value: []const u8, comptime mode: URLEncodeMode, writer: *
/// Percent-encode a legacy-encoded value - must also encode & and ; to preserve NCRs.
fn urlEncodeValueLegacy(value: []const u8, comptime mode: URLEncodeMode, writer: *std.Io.Writer) !void {
for (value) |b| {
var i: usize = 0;
while (i < value.len) : (i += 1) {
const b = value[i];
if (comptime mode == .form) {
if (try writeFormLineEnd(value, &i, b, writer)) continue;
}
if (urlEncodeUnreserved(b, mode)) {
try writer.writeByte(b);
} else if (b == ' ') {
@@ -286,6 +301,25 @@ fn urlEncodeValueLegacy(value: []const u8, comptime mode: URLEncodeMode, writer:
}
}
// HTML form-data set encoding algorithm: every U+000D (CR) not followed by
// U+000A (LF), and every U+000A (LF) not preceded by U+000D (CR), is replaced
// with the two-byte sequence CR+LF before percent-encoding. Returns true (and
// emits "%0D%0A") when `b` is CR or LF; on CR, advances the caller's index
// past a following LF so existing CRLF pairs aren't doubled.
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#url-encoded-form-data
fn writeFormLineEnd(value: []const u8, i: *usize, b: u8, writer: *std.Io.Writer) !bool {
if (b == '\r') {
try writer.writeAll("%0D%0A");
if (i.* + 1 < value.len and value[i.* + 1] == '\n') i.* += 1;
return true;
}
if (b == '\n') {
try writer.writeAll("%0D%0A");
return true;
}
return false;
}
fn urlEncodeShouldEscape(value: []const u8, comptime mode: URLEncodeMode) bool {
for (value) |b| {
if (!urlEncodeUnreserved(b, mode)) {
@@ -409,3 +443,90 @@ test "KeyValueList: urlEncode Big5 unmappable character" {
// &#28835; percent-encoded is %26%2328835%3B
try testing.expectString("q=%26%2328835%3B", buf.written());
}
// HTML form-data set encoding algorithm: line endings in entry names and values
// are normalized to CRLF — every stray LF (not preceded by CR) and every stray
// CR (not followed by LF) is replaced with CR+LF before percent-encoding. The
// normalization applies only to .form mode; URLSearchParams (.query) follows
// the URL standard's serializer, which doesn't normalize.
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#url-encoded-form-data
test "KeyValueList: urlEncode .form normalizes stray LF to CRLF" {
const allocator = testing.arena_allocator;
var list = KeyValueList.init();
try list.append(allocator, "msg", "line1\nline2\nline3");
var buf = std.Io.Writer.Allocating.init(allocator);
try list.urlEncode(.form, null, "UTF-8", &buf.writer);
try testing.expectString("msg=line1%0D%0Aline2%0D%0Aline3", buf.written());
}
test "KeyValueList: urlEncode .form normalizes stray CR to CRLF" {
const allocator = testing.arena_allocator;
var list = KeyValueList.init();
try list.append(allocator, "msg", "line1\rline2");
var buf = std.Io.Writer.Allocating.init(allocator);
try list.urlEncode(.form, null, "UTF-8", &buf.writer);
try testing.expectString("msg=line1%0D%0Aline2", buf.written());
}
test "KeyValueList: urlEncode .form preserves existing CRLF" {
const allocator = testing.arena_allocator;
var list = KeyValueList.init();
try list.append(allocator, "msg", "line1\r\nline2");
var buf = std.Io.Writer.Allocating.init(allocator);
try list.urlEncode(.form, null, "UTF-8", &buf.writer);
try testing.expectString("msg=line1%0D%0Aline2", buf.written());
}
test "KeyValueList: urlEncode .form handles mixed line endings" {
const allocator = testing.arena_allocator;
var list = KeyValueList.init();
// CR LF, then bare LF, then bare CR -> three CRLF sequences.
try list.append(allocator, "msg", "a\r\nb\nc\rd");
var buf = std.Io.Writer.Allocating.init(allocator);
try list.urlEncode(.form, null, "UTF-8", &buf.writer);
try testing.expectString("msg=a%0D%0Ab%0D%0Ac%0D%0Ad", buf.written());
}
test "KeyValueList: urlEncode .form normalizes line endings in entry names" {
const allocator = testing.arena_allocator;
var list = KeyValueList.init();
try list.append(allocator, "n\nm", "v");
var buf = std.Io.Writer.Allocating.init(allocator);
try list.urlEncode(.form, null, "UTF-8", &buf.writer);
try testing.expectString("n%0D%0Am=v", buf.written());
}
test "KeyValueList: urlEncode .form normalizes legacy charsets too" {
const allocator = testing.arena_allocator;
var list = KeyValueList.init();
try list.append(allocator, "msg", "a\nb");
var buf = std.Io.Writer.Allocating.init(allocator);
try list.urlEncode(.form, allocator, "GBK", &buf.writer);
try testing.expectString("msg=a%0D%0Ab", buf.written());
}
test "KeyValueList: urlEncode .query does NOT normalize line endings" {
// URL standard's application/x-www-form-urlencoded serializer (used by
// URLSearchParams) does not perform CRLF normalization — only the HTML
// form-data set encoding wrapper does. https://url.spec.whatwg.org/#concept-urlencoded-serializer
const allocator = testing.arena_allocator;
var list = KeyValueList.init();
try list.append(allocator, "msg", "a\nb\rc");
var buf = std.Io.Writer.Allocating.init(allocator);
try list.urlEncode(.query, null, "UTF-8", &buf.writer);
try testing.expectString("msg=a%0Ab%0Dc", buf.written());
}

View File

@@ -763,6 +763,7 @@ const CloneError = error{
NotImplemented,
InvalidCharacterError,
CloneError,
Idna,
IFrameLoadError,
TooManyContexts,
LinkLoadError,
@@ -989,7 +990,7 @@ pub fn getElementsByTagName(self: *Node, tag_name: []const u8, frame: *Frame) !G
}
const arena = frame.arena;
const filter = try String.init(arena, lower, .{});
const filter = try String.init(arena, tag_name, .{});
return .{ .tag_name = collections.NodeLive(.tag_name).init(self, filter, frame) };
}

View File

@@ -26,10 +26,14 @@ const js = @import("../js/js.zig");
const CryptoKey = @import("CryptoKey.zig");
const algorithm = @import("crypto/algorithm.zig");
const AES = @import("crypto/AES.zig");
const EC = @import("crypto/EC.zig");
const HMAC = @import("crypto/HMAC.zig");
const RSA = @import("crypto/RSA.zig");
const X25519 = @import("crypto/X25519.zig");
const log = lp.log;
const String = lp.String;
/// The SubtleCrypto interface of the Web Crypto API provides a number of low-level
/// cryptographic functions.
@@ -47,28 +51,92 @@ pub fn generateKey(
key_usages: []const []const u8,
frame: *Frame,
) !js.Promise {
const local = frame.js.local.?;
switch (algo) {
.hmac_key_gen => |params| return HMAC.init(params, extractable, key_usages, frame),
.name => |name| {
if (std.mem.eql(u8, "X25519", name)) {
return X25519.init(extractable, key_usages, frame);
}
log.warn(.not_implemented, "generateKey", .{ .name = name });
.aes_key_gen => |params| {
AES.validate(params, key_usages) catch |err| {
return local.rejectPromise(.{ .dom_exception = .{ .err = err } });
};
log.warn(.not_implemented, "generateKey", .{ .name = params.name });
},
.object => |object| {
// Ditto.
const name = object.name;
if (std.mem.eql(u8, "X25519", name)) {
return X25519.init(extractable, key_usages, frame);
}
log.warn(.not_implemented, "generateKey", .{ .name = name });
.ec_key_gen => |params| {
EC.validate(params, key_usages) catch |err| {
return local.rejectPromise(.{ .dom_exception = .{ .err = err } });
};
log.warn(.not_implemented, "generateKey", .{ .name = params.name });
},
else => log.warn(.not_implemented, "generateKey", .{}),
.rsa_hashed_key_gen => |params| {
RSA.validate(params, key_usages) catch |err| {
return local.rejectPromise(.{ .dom_exception = .{ .err = err } });
};
log.warn(.not_implemented, "generateKey", .{ .name = params.name });
},
.name => |js_name| return generateKeyFromName(try js_name.toSSO(false), extractable, key_usages, frame),
.object => |object| return generateKeyFromName(try object.name.toSSO(false), extractable, key_usages, frame),
.invalid => return local.rejectPromise(.{ .type_error = "invalid algorithm" }),
}
return frame.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = error.SyntaxError } });
return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
}
fn generateKeyFromName(
name: String,
extractable: bool,
key_usages: []const []const u8,
frame: *Frame,
) !js.Promise {
return _generateKeyFromName(name, extractable, key_usages, frame) catch |err| {
return frame.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = err } });
};
}
fn _generateKeyFromName(
name: String,
extractable: bool,
key_usages: []const []const u8,
frame: *Frame,
) !js.Promise {
if (name.eql(comptime .wrap("X25519"))) {
return X25519.init(extractable, key_usages, frame);
}
{
// Algorithms whose `generateKey` parameters are just `{name}` — Ed25519,
// Ed448, X448. Validates usages so failure-path tests get the spec-mandated
// error name; leaves real key generation to a future change.
const allowed: []const []const u8 = blk: {
const str = name.str();
if (std.ascii.eqlIgnoreCase(str, "Ed25519") or std.ascii.eqlIgnoreCase(str, "Ed448")) {
break :blk &.{ "sign", "verify" };
}
if (std.ascii.eqlIgnoreCase(str, "X448")) {
break :blk &.{ "deriveKey", "deriveBits" };
}
return error.NotSupported;
};
for (key_usages) |usage| {
var ok = false;
for (allowed) |a| {
if (std.mem.eql(u8, a, usage)) {
ok = true;
break;
}
}
if (!ok) {
return error.SyntaxError;
}
}
if (key_usages.len == 0) {
return error.SyntaxError;
}
}
log.warn(.not_implemented, "generateKey", .{ .name = name });
return error.NotSupported;
}
/// Exports a key: that is, it takes as input a CryptoKey object and gives you

View File

@@ -96,6 +96,18 @@ _scroll_pos: struct {
// A cross origin wrapper for this window
_cross_origin_wrapper: CrossOriginWindow,
// The Window that called window.open to create this one. Null for the root
// window, for noopener popups, and cleared if the opener is torn down while
// we're still alive. Only valid if `!_opener.?._closed`.
_opener: ?*Window = null,
// True after our Frame has been deinit'd by window.close. Many things on the
// window become invalid once this is true.
_closed: bool = false,
// Popup name (owned by page.arena)
_name: []const u8 = "",
pub fn asEventTarget(self: *Window) *EventTarget {
return self._proto;
}
@@ -112,6 +124,25 @@ pub fn getWindow(self: *Window) *Window {
return self;
}
pub fn getOpener(self: *Window, frame: *Frame) ?Access {
const opener = self._opener orelse return null;
if (opener._closed) return null;
return Access.init(frame.window, opener);
}
pub fn getClosed(self: *const Window) bool {
return self._closed;
}
pub fn getName(self: *const Window) []const u8 {
return self._name;
}
pub fn setName(self: *Window, name: []const u8, frame: *Frame) !void {
// Store in the Page's frame arena so the slice outlives any call_arena.
self._name = try frame.arena.dupe(u8, name);
}
pub fn getTop(self: *Window, frame: *Frame) Access {
var p = self._frame;
while (p.parent) |parent| {
@@ -425,6 +456,121 @@ pub fn getComputedStyle(_: *const Window, element: *Element, pseudo_element: ?[]
return CSSStyleProperties.init(element, true, frame);
}
// window.open(url?, target?, features?) — v1 scope:
// * Always creates a new popup Frame on the Page (sibling to the root).
// * Honors `noopener` / `noreferrer` tokens in `features` (opener=null,
// return value=null). Geometry (width, height, ...) ignored.
// * `target` values `_self` / `_parent` / `_top` navigate the current frame.
// Any other value is treated as a popup name; reusing a live name
// navigates the existing popup instead of spawning a new one.
// * `url` empty or missing opens about:blank.
pub fn open(self: *Window, url_: ?[]const u8, target_: ?[]const u8, features_: ?[]const u8, frame: *Frame) !?Access {
const raw_url = url_ orelse "";
const target = target_ orelse "";
const features = features_ orelse "";
const no_opener = hasFeatureToken(features, "noopener") or hasFeatureToken(features, "noreferrer");
// _self / _parent / _top navigate the current browsing context.
if (std.ascii.eqlIgnoreCase(target, "_self") or
std.ascii.eqlIgnoreCase(target, "_parent") or
std.ascii.eqlIgnoreCase(target, "_top"))
{
const nav_target = frame.resolveTargetFrame(target) orelse frame;
const nav_url = if (raw_url.len == 0) "about:blank" else raw_url;
try frame.scheduleNavigation(nav_url, .{
.reason = .script,
.kind = .{ .push = null },
}, .{ .script = nav_target });
if (no_opener) {
return null;
}
return Access.init(frame.window, nav_target.window);
}
const page = frame._page;
// Name-based reuse: if a popup with this name already exists, reuse it.
// `_blank` is reserved and never reuses.
const is_named = target.len > 0 and !std.ascii.eqlIgnoreCase(target, "_blank");
if (is_named) {
if (page.findPopupByName(target)) |existing| {
if (raw_url.len > 0) {
try existing.scheduleNavigation(raw_url, .{
.reason = .script,
.kind = .{ .push = null },
}, .{ .script = existing });
}
if (no_opener) {
return null;
}
return Access.init(frame.window, existing.window);
}
}
// Spawn a new popup Frame as a sibling of the root.
const popup = try frame.openPopup(.{
.url = raw_url,
.name = target,
.opener = if (no_opener) null else self,
});
if (no_opener) {
return null;
}
return Access.init(frame.window, popup.window);
}
pub fn close(self: *Window) void {
if (self._closed) {
return;
}
// Per spec, close() is only honored on script-opened windows. That
// maps exactly to membership in page.popups.
const frame = self._frame;
const page = frame._page;
var popup_index: usize = 0;
while (popup_index < page.popups.items.len) : (popup_index += 1) {
if (page.popups.items[popup_index] == frame) {
break;
}
} else return;
self._closed = true;
// Any live Window holding us as its opener must drop the reference —
// our Frame is about to go away, and a stale _frame deref on their
// side would crash.
for (page.popups.items) |popup| {
if (popup.window._opener == self) {
popup.window._opener = null;
}
}
if (page.frame.window._opener == self) {
page.frame.window._opener = null;
}
_ = page.popups.swapRemove(popup_index);
// Drop any pending queued navigation for this frame. Frame.deinit will
// release the QueuedNavigation arena, but the entry in page.queued_navigation
// would otherwise have processQueuedNavigation re-deinit the popup.
if (frame._queued_navigation != null) {
for (page.queued_navigation.items, 0..) |f, i| {
if (f == frame) {
_ = page.queued_navigation.swapRemove(i);
break;
}
}
}
frame.deinit(true);
}
pub fn postMessage(self: *Window, message: js.Value.Temp, target_origin: ?[]const u8, frame: *Frame) !void {
// For now, we ignore targetOrigin checking and just dispatch the message
// In a full implementation, we would validate the origin
@@ -834,6 +980,20 @@ fn getFunctionFromSetter(setter_: ?FunctionSetter) ?js.Function.Global {
};
}
// Checks whether a window.open features string contains a token, matched
// case-insensitively on whole-token boundaries (comma or whitespace separated).
// The features syntax is legacy and loose; the only tokens we interpret are
// noopener and noreferrer.
fn hasFeatureToken(features: []const u8, token: []const u8) bool {
var it = std.mem.tokenizeAny(u8, features, " \t\r\n,");
while (it.next()) |raw| {
// Trim a trailing =value if present — we only need the key.
const key = if (std.mem.indexOfScalarPos(u8, raw, 0, '=')) |eq| raw[0..eq] else raw;
if (std.ascii.eqlIgnoreCase(key, token)) return true;
}
return false;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(Window);
@@ -913,9 +1073,11 @@ pub const JsApi = struct {
pub const innerHeight = bridge.property(1080, .{ .template = false });
pub const devicePixelRatio = bridge.property(1, .{ .template = false });
// This should return a window-like object in specific conditions. Would be
// pretty complicated to properly support I think.
pub const opener = bridge.property(null, .{ .template = false });
pub const opener = bridge.accessor(Window.getOpener, null, .{});
pub const closed = bridge.accessor(Window.getClosed, null, .{});
pub const name = bridge.accessor(Window.getName, Window.setName, .{});
pub const open = bridge.function(Window.open, .{});
pub const close = bridge.function(Window.close, .{});
pub const alert = bridge.function(struct {
fn alert(_: *const Window, message: ?[]const u8, frame: *Frame) void {
@@ -943,7 +1105,7 @@ pub const JsApi = struct {
}
}.confirm, .{});
pub const prompt = bridge.function(struct {
fn prompt(_: *const Window, message: ?[]const u8, _: ?[]const u8, frame: *Frame) ?[]const u8 {
fn prompt(_: *const Window, message: ?[]const u8, default_text: ?[]const u8, frame: *Frame) ?[]const u8 {
var response: Notification.DialogResponse = .{};
frame._session.notification.dispatch(.javascript_dialog_opening, &.{
.url = frame.url,
@@ -952,9 +1114,12 @@ pub const JsApi = struct {
.response = &response,
});
if (!response.accept) return null;
// promptText omitted with accept=true is "" per CDP spec
// Pre-armed promptText wins when present. Otherwise fall back to
// the dialog's defaultText (second arg to window.prompt) — Chrome's
// accept-without-typing behavior. If both are absent, return ""
// per CDP spec
// (https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-handleJavaScriptDialog).
return response.prompt_text orelse "";
return response.prompt_text orelse default_text orelse "";
}
}.prompt, .{});

View File

@@ -28,6 +28,7 @@ const Page = @import("../Page.zig");
const Factory = @import("../Factory.zig");
const Session = @import("../Session.zig");
const EventManagerBase = @import("../EventManagerBase.zig");
const ScriptManagerBase = @import("../ScriptManagerBase.zig");
const Blob = @import("Blob.zig");
const Worker = @import("Worker.zig");
@@ -71,6 +72,10 @@ _worker: *Worker,
// Event management for non-DOM targets in worker context
_event_manager: EventManagerBase,
// Handles module imports (static + dynamic). No parser integration since
// workers don't have <script> tags.
_script_manager: ScriptManagerBase,
// These fields represent the "Window"-like component of the WGS
_closed: bool = false,
_proto: *EventTarget,
@@ -104,9 +109,16 @@ pub fn init(worker: *Worker, url: [:0]const u8) !*WorkerGlobalScope {
._factory = factory,
._worker = worker,
._event_manager = .init(arena),
._script_manager = undefined,
});
errdefer factory.destroy(self);
self._script_manager = ScriptManagerBase.init(
arena,
session.browser.http_client,
.{ .worker = self },
);
self.js = try session.browser.env.createWorkerContext(self, .{
.call_arena = call_arena,
.identity_arena = arena,
@@ -118,6 +130,8 @@ pub fn init(worker: *Worker, url: [:0]const u8) !*WorkerGlobalScope {
pub fn deinit(self: *WorkerGlobalScope) void {
self._identity.deinit();
self._script_manager.deinit();
const page = self._page;
var it = self._blob_urls.valueIterator();
while (it.next()) |blob| {

View File

@@ -0,0 +1,70 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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/>.
//! AES generateKey parameter validation.
//!
//! Key generation itself is not implemented; this module rejects malformed
//! input with the spec-mandated error name so the failure-path WPT tests
//! match. Successful inputs fall through to the caller's `not_implemented`
//! warning + `NotSupportedError`.
const std = @import("std");
const algorithm = @import("algorithm.zig");
/// Per WebCrypto: "Generate Key" operation for AES-CBC/CTR/GCM/KW.
/// Validation order matches the spec: usages → length → empty usages.
pub fn validate(params: algorithm.Init.AesKeyGen, key_usages: []const []const u8) !void {
const allowed: []const []const u8 = blk: {
if (eql(params.name, "AES-CBC") or
eql(params.name, "AES-CTR") or
eql(params.name, "AES-GCM"))
{
break :blk &.{ "encrypt", "decrypt", "wrapKey", "unwrapKey" };
}
if (eql(params.name, "AES-KW")) {
break :blk &.{ "wrapKey", "unwrapKey" };
}
return error.NotSupported;
};
for (key_usages) |usage| {
var ok = false;
for (allowed) |a| {
if (std.mem.eql(u8, a, usage)) {
ok = true;
break;
}
}
if (!ok) {
return error.SyntaxError;
}
}
if (params.length != 128 and params.length != 192 and params.length != 256) {
return error.OperationError;
}
if (key_usages.len == 0) {
return error.SyntaxError;
}
}
fn eql(a: []const u8, b: []const u8) bool {
return std.ascii.eqlIgnoreCase(a, b);
}

View File

@@ -0,0 +1,66 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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/>.
//! ECDSA / ECDH generateKey parameter validation. See AES.zig for
//! the rationale on validate-without-generate.
const std = @import("std");
const algorithm = @import("algorithm.zig");
pub fn validate(params: algorithm.Init.EcKeyGen, key_usages: []const []const u8) !void {
const allowed: []const []const u8 = blk: {
if (eql(params.name, "ECDSA")) {
break :blk &.{ "sign", "verify" };
}
if (eql(params.name, "ECDH")) {
break :blk &.{ "deriveKey", "deriveBits" };
}
return error.NotSupported;
};
for (key_usages) |usage| {
var ok = false;
for (allowed) |a| {
if (std.mem.eql(u8, a, usage)) {
ok = true;
break;
}
}
if (!ok) {
return error.SyntaxError;
}
}
// Per spec, an unsupported `namedCurve` is NotSupportedError, not OperationError —
// unlike AES length, where the algorithm registers the value as invalid.
if (!eql(params.namedCurve, "P-256") and
!eql(params.namedCurve, "P-384") and
!eql(params.namedCurve, "P-521"))
{
return error.NotSupported;
}
if (key_usages.len == 0) {
return error.SyntaxError;
}
}
fn eql(a: []const u8, b: []const u8) bool {
return std.ascii.eqlIgnoreCase(a, b);
}

View File

@@ -35,30 +35,30 @@ pub fn init(
frame: *Frame,
) !js.Promise {
const local = frame.js.local.?;
// Find digest.
// Per spec, an unrecognized hash is caught during algorithm normalization
// and surfaces as NotSupportedError.
const digest = crypto.findDigest(switch (params.hash) {
.string => |str| str,
.object => |obj| obj.name,
}) catch return local.rejectPromise(.{
.dom_exception = .{ .err = error.SyntaxError },
.dom_exception = .{ .err = error.NotSupported },
});
// Calculate usages mask.
if (key_usages.len == 0) {
return local.rejectPromise(.{
.dom_exception = .{ .err = error.SyntaxError },
});
}
const decls = @typeInfo(CryptoKey.Usages).@"struct".decls;
// HMAC only accepts sign / verify; any other usage is a SyntaxError per
// the spec, even when the entry exists elsewhere in CryptoKey.Usages.
var mask: u8 = 0;
iter_usages: for (key_usages) |usage| {
inline for (decls) |decl| {
if (std.mem.eql(u8, decl.name, usage)) {
mask |= @field(CryptoKey.Usages, decl.name);
continue :iter_usages;
}
for (key_usages) |usage| {
if (std.mem.eql(u8, usage, "sign")) {
mask |= CryptoKey.Usages.sign;
} else if (std.mem.eql(u8, usage, "verify")) {
mask |= CryptoKey.Usages.verify;
} else {
return local.rejectPromise(.{
.dom_exception = .{ .err = error.SyntaxError },
});
}
// Unknown usage if got here.
}
if (key_usages.len == 0) {
return local.rejectPromise(.{
.dom_exception = .{ .err = error.SyntaxError },
});

View File

@@ -0,0 +1,85 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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/>.
//! RSA generateKey parameter validation. See AES.zig for the rationale.
const std = @import("std");
const algorithm = @import("algorithm.zig");
pub fn validate(params: algorithm.Init.RsaHashedKeyGen, key_usages: []const []const u8) !void {
const allowed: []const []const u8 = blk: {
if (eql(params.name, "RSASSA-PKCS1-v1_5") or eql(params.name, "RSA-PSS")) {
break :blk &.{ "sign", "verify" };
}
if (eql(params.name, "RSA-OAEP")) {
break :blk &.{ "encrypt", "decrypt", "wrapKey", "unwrapKey" };
}
return error.NotSupported;
};
const hash_name = switch (params.hash) {
.string => |s| s,
.object => |o| o.name,
};
if (!eql(hash_name, "SHA-1") and
!eql(hash_name, "SHA-256") and
!eql(hash_name, "SHA-384") and
!eql(hash_name, "SHA-512"))
{
return error.NotSupported;
}
for (key_usages) |usage| {
var ok = false;
for (allowed) |a| {
if (std.mem.eql(u8, a, usage)) {
ok = true;
break;
}
}
if (!ok) {
return error.SyntaxError;
}
}
if (!isValidPublicExponent(params.publicExponent.values)) {
return error.OperationError;
}
if (key_usages.len == 0) {
return error.SyntaxError;
}
}
// WebCrypto only mandates rejection on key-generation failure, but in
// practice browsers accept the standard exponents 3 and 65537 and reject
// the rest. Match that.
fn isValidPublicExponent(bytes: []const u8) bool {
if (bytes.len == 0) return false;
var i: usize = 0;
while (i + 1 < bytes.len and bytes[i] == 0) : (i += 1) {}
const trimmed = bytes[i..];
if (trimmed.len == 1 and trimmed[0] == 3) return true;
if (trimmed.len == 3 and trimmed[0] == 0x01 and trimmed[1] == 0x00 and trimmed[2] == 0x01) return true;
return false;
}
fn eql(a: []const u8, b: []const u8) bool {
return std.ascii.eqlIgnoreCase(a, b);
}

View File

@@ -28,10 +28,19 @@ pub const Init = union(enum) {
rsa_hashed_key_gen: RsaHashedKeyGen,
/// For HMAC: pass an HmacKeyGenParams object.
hmac_key_gen: HmacKeyGen,
/// For AES variants: pass an AesKeyGenParams object.
aes_key_gen: AesKeyGen,
/// For ECDSA / ECDH: pass an EcKeyGenParams object.
ec_key_gen: EcKeyGen,
/// don't use []const u8 here, we don't want non-strings coerced. Let those
/// fall to the invalid case
/// Can be Ed25519 or X25519.
name: []const u8,
object: struct { name: js.String },
/// Can be Ed25519 or X25519.
object: struct { name: []const u8 },
name: js.String,
invalid: js.Value,
/// https://developer.mozilla.org/en-US/docs/Web/API/RsaHashedKeyGenParams
pub const RsaHashedKeyGen = struct {
@@ -46,6 +55,18 @@ pub const Init = union(enum) {
},
};
/// https://developer.mozilla.org/en-US/docs/Web/API/AesKeyGenParams
pub const AesKeyGen = struct {
name: []const u8,
length: u32,
};
/// https://developer.mozilla.org/en-US/docs/Web/API/EcKeyGenParams
pub const EcKeyGen = struct {
name: []const u8,
namedCurve: []const u8,
};
/// https://developer.mozilla.org/en-US/docs/Web/API/HmacKeyGenParams
pub const HmacKeyGen = struct {
/// Always HMAC.

View File

@@ -148,7 +148,7 @@ pub fn requestSubmit(self: *Form, submitter: ?*Element, frame: *Frame) !void {
/// Returns true if the element is a submit button per the HTML spec:
/// - <input type="submit"> or <input type="image">
/// - <button type="submit"> (including default, since button's default type is "submit")
fn isSubmitButton(element: *Element) bool {
pub fn isSubmitButton(element: *Element) bool {
if (element.is(Input)) |input| {
return input._input_type == .submit or input._input_type == .image;
}

View File

@@ -145,4 +145,5 @@ pub const JsApi = struct {
const testing = @import("../../../../testing.zig");
test "WebApi: HTML.Label" {
try testing.htmlRunner("element/html/label.html", .{});
try testing.htmlRunner("element/html/label_click.html", .{});
}

View File

@@ -23,22 +23,41 @@ const js = @import("../../js/js.zig");
const Frame = @import("../../Frame.zig");
const Form = @import("../element/html/Form.zig");
const Element = @import("../Element.zig");
const File = @import("../File.zig");
const KeyValueList = @import("../KeyValueList.zig");
const log = lp.log;
const String = lp.String;
const Execution = js.Execution;
const Allocator = std.mem.Allocator;
const FormData = @This();
_arena: Allocator,
_list: KeyValueList,
_entries: std.ArrayList(Entry),
pub const Entry = struct {
name: String,
value: Value,
const Value = union(enum) {
file: *File,
string: String,
fn asString(self: *const Value) []const u8 {
return switch (self.*) {
.string => |*s| s.str(),
.file => unreachable, // nothing currently creates this type of value
};
}
};
};
pub fn init(form_: ?*Form, submitter: ?*Element, exec: *const Execution) !*FormData {
const form = form_ orelse {
return try exec._factory.create(FormData{
._arena = exec.arena,
._list = KeyValueList.init(),
._entries = .empty,
});
};
@@ -49,7 +68,7 @@ pub fn init(form_: ?*Form, submitter: ?*Element, exec: *const Execution) !*FormD
const form_data = try exec._factory.create(FormData{
._arena = exec.arena,
._list = try collectForm(frame.arena, form, submitter, frame),
._entries = try collectForm(frame.arena, form, submitter, frame),
});
const form_data_event = try (@import("../event/FormDataEvent.zig")).initTrusted(
@@ -62,94 +81,204 @@ pub fn init(form_: ?*Form, submitter: ?*Element, exec: *const Execution) !*FormD
return form_data;
}
pub fn get(self: *const FormData, name: []const u8) ?[]const u8 {
return self._list.get(name);
pub fn get(self: *const FormData, name: String) ?[]const u8 {
for (self._entries.items) |*entry| {
if (entry.name.eql(name)) {
return entry.value.asString();
}
}
return null;
}
pub fn getAll(self: *const FormData, name: []const u8, exec: *const Execution) ![]const []const u8 {
return self._list.getAll(exec.call_arena, name);
pub fn getAll(self: *const FormData, name: String, exec: *const Execution) ![]const []const u8 {
var arr: std.ArrayList([]const u8) = .empty;
for (self._entries.items) |*entry| {
if (entry.name.eql(name)) {
try arr.append(exec.call_arena, entry.value.asString());
}
}
return arr.items;
}
pub fn has(self: *const FormData, name: []const u8) bool {
return self._list.has(name);
pub fn has(self: *const FormData, name: String) bool {
for (self._entries.items) |*entry| {
if (entry.name.eql(name)) {
return true;
}
}
return false;
}
pub fn set(self: *FormData, name: []const u8, value: []const u8) !void {
return self._list.set(self._arena, name, value);
pub fn set(self: *FormData, name: String, value: []const u8) !void {
self.deleteByName(name);
return self.append(name.str(), value);
}
pub fn append(self: *FormData, name: []const u8, value: []const u8) !void {
return self._list.append(self._arena, name, value);
try self._entries.append(self._arena, .{
.name = try String.init(self._arena, name, .{}),
.value = .{ .string = try String.init(self._arena, value, .{}) },
});
}
pub fn delete(self: *FormData, name: []const u8) void {
self._list.delete(name, null);
pub fn delete(self: *FormData, name: String) void {
self.deleteByName(name);
}
pub fn keys(self: *FormData, exec: *const js.Execution) !*KeyValueList.KeyIterator {
return KeyValueList.KeyIterator.init(.{ .list = self, .kv = &self._list }, exec);
fn deleteByName(self: *FormData, name: String) void {
var i: usize = 0;
while (i < self._entries.items.len) {
if (self._entries.items[i].name.eql(name)) {
_ = self._entries.swapRemove(i);
continue;
}
i += 1;
}
}
pub fn values(self: *FormData, exec: *const js.Execution) !*KeyValueList.ValueIterator {
return KeyValueList.ValueIterator.init(.{ .list = self, .kv = &self._list }, exec);
pub fn keys(self: *FormData, exec: *const js.Execution) !*KeyIterator {
return KeyIterator.init(.{ .fd = self, .list = self }, exec);
}
pub fn entries(self: *FormData, exec: *const js.Execution) !*KeyValueList.EntryIterator {
return KeyValueList.EntryIterator.init(.{ .list = self, .kv = &self._list }, exec);
pub fn values(self: *FormData, exec: *const js.Execution) !*ValueIterator {
return ValueIterator.init(.{ .fd = self, .list = self }, exec);
}
pub fn entries(self: *FormData, exec: *const js.Execution) !*EntryIterator {
return EntryIterator.init(.{ .fd = self, .list = self }, exec);
}
pub fn forEach(self: *FormData, cb_: js.Function, js_this_: ?js.Object) !void {
const cb = if (js_this_) |js_this| try cb_.withThis(js_this) else cb_;
for (self._list._entries.items) |entry| {
cb.call(void, .{ entry.value.str(), entry.name.str(), self }) catch |err| {
for (self._entries.items) |*entry| {
cb.call(void, .{ entry.value.asString(), entry.name.str(), self }) catch |err| {
// this is a non-JS error
log.warn(.js, "FormData.forEach", .{ .err = err });
};
}
}
pub const EncType = union(enum) {
urlencode,
// Boundary delimiter; caller owns the bytes (must outlive the write).
formdata: []const u8,
};
pub const WriteOpts = struct {
enctype: ?[]const u8 = null,
encoding: EncType = .urlencode,
charset: []const u8 = "UTF-8",
allocator: ?std.mem.Allocator = null,
};
pub fn write(self: *const FormData, opts: WriteOpts, writer: *std.Io.Writer) !void {
const enctype = opts.enctype orelse {
return self._list.urlEncode(.form, opts.allocator, opts.charset, writer);
};
if (std.ascii.eqlIgnoreCase(enctype, "application/x-www-form-urlencoded")) {
return self._list.urlEncode(.form, opts.allocator, opts.charset, writer);
switch (opts.encoding) {
.urlencode => return self.urlEncode(opts, writer),
.formdata => |boundary| return self.multipartEncode(boundary, writer),
}
}
log.warn(.not_implemented, "FormData.encoding", .{
.encoding = enctype,
});
fn urlEncode(self: *const FormData, opts: WriteOpts, writer: *std.Io.Writer) !void {
const items = self._entries.items;
if (items.len == 0) return;
try urlEncodeEntry(&items[0], opts, writer);
for (items[1..]) |*entry| {
try writer.writeByte('&');
try urlEncodeEntry(entry, opts, writer);
}
}
fn urlEncodeEntry(entry: *const Entry, opts: WriteOpts, writer: *std.Io.Writer) !void {
try KeyValueList.urlEncodeFormValue(entry.name.str(), opts.allocator, opts.charset, writer);
try writer.writeByte('=');
try KeyValueList.urlEncodeFormValue(entry.value.asString(), opts.allocator, opts.charset, writer);
}
fn multipartEncode(self: *const FormData, boundary: []const u8, writer: *std.Io.Writer) !void {
for (self._entries.items) |*entry| {
try multipartEncodeEntry(entry, boundary, writer);
}
try writer.print("--{s}--\r\n", .{boundary});
}
fn multipartEncodeEntry(entry: *const Entry, boundary: []const u8, writer: *std.Io.Writer) !void {
try writer.print("--{s}\r\n", .{boundary});
const value_ptr = &entry.value;
switch (value_ptr.*) {
.string => |*s| {
try writer.writeAll("Content-Disposition: form-data; name=\"");
try writeMultipartName(writer, entry.name.str());
try writer.writeAll("\"\r\n\r\n");
try writer.writeAll(s.str());
try writer.writeAll("\r\n");
},
// File entries need a real payload (filename + bytes + Content-Type) — not yet wired.
.file => log.warn(.not_implemented, "FormData.multipart.file", .{}),
}
}
// Per RFC 7578 §4.2, Content-Disposition names are quoted-string form;
// CR/LF/" must be escaped.
fn writeMultipartName(writer: *std.Io.Writer, name: []const u8) !void {
for (name) |c| {
switch (c) {
'"' => try writer.writeAll("%22"),
'\r' => try writer.writeAll("%0D"),
'\n' => try writer.writeAll("%0A"),
else => try writer.writeByte(c),
}
}
}
// Used by URLSearchParams to ingest a FormData; file entries collapse via Value.asString.
pub fn toKeyValueList(self: *const FormData, arena: Allocator) !KeyValueList {
var list: KeyValueList = .empty;
try list.ensureTotalCapacity(arena, self._entries.items.len);
for (self._entries.items) |*entry| {
try list.appendAssumeCapacity(arena, entry.name.str(), entry.value.asString());
}
return list;
}
pub const Iterator = struct {
index: u32 = 0,
list: *const FormData,
fd: *FormData,
const Entry = struct { []const u8, []const u8 };
// See KeyValueList.Iterator.list — required by the GenericIterator wrapper.
list: *anyopaque,
pub fn next(self: *Iterator, _: *Frame) !?Iterator.Entry {
pub const Entry = struct { []const u8, []const u8 };
pub fn next(self: *Iterator, _: *const Execution) ?Iterator.Entry {
const index = self.index;
const items = self.list._list.items();
const items = self.fd._entries.items;
if (index >= items.len) {
return null;
}
self.index = index + 1;
const e = &items[index];
return .{ e.name.str(), e.value.str() };
return .{ e.name.str(), e.value.asString() };
}
};
fn collectForm(arena: Allocator, form_: ?*Form, submitter_: ?*Element, frame: *Frame) !KeyValueList {
var list: KeyValueList = .empty;
const GenericIterator = @import("../collections/iterator.zig").Entry;
pub const KeyIterator = GenericIterator(Iterator, "0");
pub const ValueIterator = GenericIterator(Iterator, "1");
pub const EntryIterator = GenericIterator(Iterator, null);
pub fn registerTypes() []const type {
return &.{
FormData,
KeyIterator,
ValueIterator,
EntryIterator,
};
}
fn collectForm(arena: Allocator, form_: ?*Form, submitter_: ?*Element, frame: *Frame) !std.ArrayList(Entry) {
var list: std.ArrayList(Entry) = .empty;
const form = form_ orelse return list;
var elements = try form.getElements(frame);
@@ -170,8 +299,8 @@ fn collectForm(arena: Allocator, form_: ?*Form, submitter_: ?*Element, frame: *F
const name = element.getAttributeSafe(comptime .wrap("name"));
const x_key = if (name) |n| try std.fmt.allocPrint(arena, "{s}.x", .{n}) else "x";
const y_key = if (name) |n| try std.fmt.allocPrint(arena, "{s}.y", .{n}) else "y";
try list.append(arena, x_key, "0");
try list.append(arena, y_key, "0");
try appendString(&list, arena, x_key, "0");
try appendString(&list, arena, y_key, "0");
continue;
}
}
@@ -206,7 +335,7 @@ fn collectForm(arena: Allocator, form_: ?*Form, submitter_: ?*Element, frame: *F
var options = try select.getSelectedOptions(frame);
while (options.next()) |option| {
try list.append(arena, name, option.as(Form.Select.Option).getValue(frame));
try appendString(&list, arena, name, option.as(Form.Select.Option).getValue(frame));
}
continue;
}
@@ -224,11 +353,18 @@ fn collectForm(arena: Allocator, form_: ?*Form, submitter_: ?*Element, frame: *F
}
continue;
};
try list.append(arena, name, value);
try appendString(&list, arena, name, value);
}
return list;
}
fn appendString(list: *std.ArrayList(Entry), arena: Allocator, name: []const u8, value: []const u8) !void {
try list.append(arena, .{
.name = try String.init(arena, name, .{}),
.value = .{ .string = try String.init(arena, value, .{}) },
});
}
pub const JsApi = struct {
pub const bridge = js.Bridge(FormData);
@@ -256,3 +392,72 @@ const testing = @import("../../../testing.zig");
test "WebApi: FormData" {
try testing.htmlRunner("net/form_data.html", .{});
}
test "FormData: multipart write" {
const allocator = testing.arena_allocator;
var fd = FormData{
._arena = allocator,
._entries = .empty,
};
try fd.append("name", "John");
try fd.append("note", "two\r\nlines");
var buf = std.Io.Writer.Allocating.init(allocator);
try fd.write(.{
.encoding = .{ .formdata = "BOUNDARY" },
.allocator = allocator,
}, &buf.writer);
try testing.expectString(
"--BOUNDARY\r\n" ++
"Content-Disposition: form-data; name=\"name\"\r\n\r\n" ++
"John\r\n" ++
"--BOUNDARY\r\n" ++
"Content-Disposition: form-data; name=\"note\"\r\n\r\n" ++
"two\r\nlines\r\n" ++
"--BOUNDARY--\r\n",
buf.written(),
);
}
test "FormData: multipart escapes name CR/LF/quote" {
const allocator = testing.arena_allocator;
var fd = FormData{
._arena = allocator,
._entries = .empty,
};
try fd.append("a\"b\r\nc", "v");
var buf = std.Io.Writer.Allocating.init(allocator);
try fd.write(.{
.encoding = .{ .formdata = "B" },
.allocator = allocator,
}, &buf.writer);
try testing.expectString(
"--B\r\n" ++
"Content-Disposition: form-data; name=\"a%22b%0D%0Ac\"\r\n\r\n" ++
"v\r\n" ++
"--B--\r\n",
buf.written(),
);
}
test "FormData: multipart empty body" {
const allocator = testing.arena_allocator;
var fd = FormData{
._arena = allocator,
._entries = .empty,
};
var buf = std.Io.Writer.Allocating.init(allocator);
try fd.write(.{
.encoding = .{ .formdata = "B" },
.allocator = allocator,
}, &buf.writer);
try testing.expectString("--B--\r\n", buf.written());
}

View File

@@ -46,7 +46,7 @@ pub fn init(opts_: ?InitOpts, exec: *const Execution) !*URLSearchParams {
const opts = opts_ orelse break :blk .empty;
switch (opts) {
.query_string => |qs| break :blk try paramsFromString(arena, qs, exec.buf),
.form_data => |fd| break :blk try KeyValueList.copy(arena, fd._list),
.form_data => |fd| break :blk try fd.toKeyValueList(arena),
.value => |js_val| {
// Order matters here; Array is also an Object.
if (js_val.isArray()) {

View File

@@ -477,6 +477,8 @@ pub const Jar = struct {
self: *Jar,
cookie: Cookie,
request_time: i64,
/// Checks if addition comes from HTTP request or JS context.
comptime is_http: bool,
) !void {
const is_expired = isCookieExpired(&cookie, request_time);
defer if (is_expired) {
@@ -491,15 +493,25 @@ pub const Jar = struct {
}
for (self.cookies.items, 0..) |*c, i| {
if (areCookiesEqual(&cookie, c)) {
c.deinit();
if (is_expired) {
_ = self.cookies.swapRemove(i);
} else {
self.cookies.items[i] = cookie;
}
// We're only looking for the equal one.
if (areCookiesEqual(&cookie, c) == false) {
continue;
}
// RFC 6265bis 5.7.2: a non-HTTP API (e.g. document.cookie) must
// not replace an HttpOnly cookie.
if (c.http_only and is_http == false) {
if (is_expired == false) cookie.deinit();
return;
}
c.deinit();
if (is_expired) {
_ = self.cookies.swapRemove(i);
} else {
self.cookies.items[i] = cookie;
}
return;
}
if (!is_expired) {
@@ -563,7 +575,7 @@ pub const Jar = struct {
};
const now = std.time.timestamp();
try self.add(c, now);
try self.add(c, now, true);
}
fn writeCookie(cookie: *const Cookie, writer: anytype) !void {
@@ -685,31 +697,50 @@ test "Jar: add" {
defer jar.deinit();
try expectCookies(&.{}, jar);
try jar.add(try Cookie.parse(testing.allocator, test_url, "over=9000;Max-Age=0"), now);
try jar.add(try Cookie.parse(testing.allocator, test_url, "over=9000;Max-Age=0"), now, true);
try expectCookies(&.{}, jar);
try jar.add(try Cookie.parse(testing.allocator, test_url, "over=9000"), now);
try jar.add(try Cookie.parse(testing.allocator, test_url, "over=9000"), now, true);
try expectCookies(&.{.{ "over", "9000" }}, jar);
try jar.add(try Cookie.parse(testing.allocator, test_url, "over=9000!!"), now);
try jar.add(try Cookie.parse(testing.allocator, test_url, "over=9000!!"), now, true);
try expectCookies(&.{.{ "over", "9000!!" }}, jar);
try jar.add(try Cookie.parse(testing.allocator, test_url, "spice=flow"), now);
try jar.add(try Cookie.parse(testing.allocator, test_url, "spice=flow"), now, true);
try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flow" } }, jar);
try jar.add(try Cookie.parse(testing.allocator, test_url, "spice=flows;Path=/"), now);
try jar.add(try Cookie.parse(testing.allocator, test_url, "spice=flows;Path=/"), now, true);
try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flows" } }, jar);
try jar.add(try Cookie.parse(testing.allocator, test_url, "over=9001;Path=/other"), now);
try jar.add(try Cookie.parse(testing.allocator, test_url, "over=9001;Path=/other"), now, true);
try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flows" }, .{ "over", "9001" } }, jar);
try jar.add(try Cookie.parse(testing.allocator, test_url, "over=9002;Path=/;Domain=lightpanda.io"), now);
try jar.add(try Cookie.parse(testing.allocator, test_url, "over=9002;Path=/;Domain=lightpanda.io"), now, true);
try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flows" }, .{ "over", "9001" }, .{ "over", "9002" } }, jar);
try jar.add(try Cookie.parse(testing.allocator, test_url, "over=x;Path=/other;Max-Age=-200"), now);
try jar.add(try Cookie.parse(testing.allocator, test_url, "over=x;Path=/other;Max-Age=-200"), now, true);
try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flows" }, .{ "over", "9002" } }, jar);
}
test "Jar: non-HTTP add must not replace or duplicate an HttpOnly cookie" {
const now = std.time.timestamp();
var jar = Jar.init(testing.allocator);
defer jar.deinit();
try jar.add(try Cookie.parse(testing.allocator, test_url, "session=REAL;Path=/;HttpOnly"), now, true);
try testing.expectEqual(@as(usize, 1), jar.cookies.items.len);
try jar.add(try Cookie.parse(testing.allocator, test_url, "session=ATTACKER;Path=/"), now, false);
try testing.expectEqual(@as(usize, 1), jar.cookies.items.len);
try testing.expectEqual("REAL", jar.cookies.items[0].value);
try testing.expectEqual(true, jar.cookies.items[0].http_only);
try jar.add(try Cookie.parse(testing.allocator, test_url, "session=REFRESHED;Path=/;HttpOnly"), now, true);
try testing.expectEqual(@as(usize, 1), jar.cookies.items.len);
try testing.expectEqual("REFRESHED", jar.cookies.items[0].value);
}
test "Jar: add limit" {
var jar = Jar.init(testing.allocator);
defer jar.deinit();
@@ -724,7 +755,7 @@ test "Jar: add limit" {
.path = "/",
.expires = null,
.value = "v" ** 4096 ++ "v",
}, now));
}, now, true));
// generate unique names.
const names = comptime blk: {
@@ -748,7 +779,7 @@ test "Jar: add limit" {
.value = "v",
};
try jar.add(c, now);
try jar.add(c, now, true);
}
try testing.expectError(error.CookieJarQuotaExceeded, jar.add(.{
@@ -758,7 +789,7 @@ test "Jar: add limit" {
.path = "/",
.expires = null,
.value = "v",
}, now));
}, now, true));
}
test "Jar: forRequest" {
@@ -783,15 +814,15 @@ test "Jar: forRequest" {
try expectCookies("", &jar, test_url, .{ .is_http = true });
}
try jar.add(try Cookie.parse(testing.allocator, test_url, "global1=1"), now);
try jar.add(try Cookie.parse(testing.allocator, test_url, "global2=2;Max-Age=30;domain=lightpanda.io"), now);
try jar.add(try Cookie.parse(testing.allocator, test_url, "path1=3;Path=/about"), now);
try jar.add(try Cookie.parse(testing.allocator, test_url, "path2=4;Path=/docs/"), now);
try jar.add(try Cookie.parse(testing.allocator, test_url, "secure=5;Secure"), now);
try jar.add(try Cookie.parse(testing.allocator, test_url, "sitenone=6;SameSite=None;Path=/x/;Secure"), now);
try jar.add(try Cookie.parse(testing.allocator, test_url, "sitelax=7;SameSite=Lax;Path=/x/"), now);
try jar.add(try Cookie.parse(testing.allocator, test_url, "sitestrict=8;SameSite=Strict;Path=/x/"), now);
try jar.add(try Cookie.parse(testing.allocator, url2, "domain1=9;domain=test.lightpanda.io"), now);
try jar.add(try Cookie.parse(testing.allocator, test_url, "global1=1"), now, true);
try jar.add(try Cookie.parse(testing.allocator, test_url, "global2=2;Max-Age=30;domain=lightpanda.io"), now, true);
try jar.add(try Cookie.parse(testing.allocator, test_url, "path1=3;Path=/about"), now, true);
try jar.add(try Cookie.parse(testing.allocator, test_url, "path2=4;Path=/docs/"), now, true);
try jar.add(try Cookie.parse(testing.allocator, test_url, "secure=5;Secure"), now, true);
try jar.add(try Cookie.parse(testing.allocator, test_url, "sitenone=6;SameSite=None;Path=/x/;Secure"), now, true);
try jar.add(try Cookie.parse(testing.allocator, test_url, "sitelax=7;SameSite=Lax;Path=/x/"), now, true);
try jar.add(try Cookie.parse(testing.allocator, test_url, "sitestrict=8;SameSite=Strict;Path=/x/"), now, true);
try jar.add(try Cookie.parse(testing.allocator, url2, "domain1=9;domain=test.lightpanda.io"), now, true);
// nothing fancy here
try expectCookies("global1=1; global2=2", &jar, test_url, .{ .is_http = true });

View File

@@ -569,7 +569,7 @@ test "cdp.lp: handleJavaScriptDialog controls confirm/prompt/alert return values
const p_text_str = try p_text.toStringSlice();
try testing.expectEqualSlices(u8, "hello", p_text_str);
// ---- prompt: accept=true without promptText returns "" per CDP spec ----
// ---- prompt: accept=true without promptText AND no dialog defaultText returns "" ----
try ctx.processMessage(.{ .id = 4, .method = "LP.handleJavaScriptDialog", .params = .{ .accept = true } });
try ctx.expectSentResult(null, .{ .id = 4 });
@@ -577,10 +577,35 @@ test "cdp.lp: handleJavaScriptDialog controls confirm/prompt/alert return values
const p_empty_str = try p_empty.toStringSlice();
try testing.expectEqualSlices(u8, "", p_empty_str);
// ---- prompt: accept=false makes prompt() return null ----
try ctx.processMessage(.{ .id = 5, .method = "LP.handleJavaScriptDialog", .params = .{ .accept = false } });
// ---- prompt: accept=true without promptText falls back to dialog defaultText ----
// Mirrors Chrome's accept-without-typing behavior: with no client-supplied
// promptText, the prompt's return value is the second arg to window.prompt.
try ctx.processMessage(.{ .id = 5, .method = "LP.handleJavaScriptDialog", .params = .{ .accept = true } });
try ctx.expectSentResult(null, .{ .id = 5 });
const p_default_text = try ls.local.exec("prompt('name?', 'John Smith')", null);
const p_default_text_str = try p_default_text.toStringSlice();
try testing.expectEqualSlices(u8, "John Smith", p_default_text_str);
// ---- prompt: pre-armed promptText overrides the dialog defaultText ----
try ctx.processMessage(.{ .id = 6, .method = "LP.handleJavaScriptDialog", .params = .{ .accept = true, .promptText = "typed" } });
try ctx.expectSentResult(null, .{ .id = 6 });
const p_override = try ls.local.exec("prompt('name?', 'John Smith')", null);
const p_override_str = try p_override.toStringSlice();
try testing.expectEqualSlices(u8, "typed", p_override_str);
// ---- prompt: accept=false returns null regardless of dialog defaultText ----
try ctx.processMessage(.{ .id = 7, .method = "LP.handleJavaScriptDialog", .params = .{ .accept = false } });
try ctx.expectSentResult(null, .{ .id = 7 });
const p_dismiss_with_default = try ls.local.exec("prompt('cancel?', 'John Smith')", null);
try testing.expect(p_dismiss_with_default.isNull());
// ---- prompt: accept=false makes prompt() return null ----
try ctx.processMessage(.{ .id = 8, .method = "LP.handleJavaScriptDialog", .params = .{ .accept = false } });
try ctx.expectSentResult(null, .{ .id = 8 });
const p_dismiss = try ls.local.exec("prompt('cancel?')", null);
try testing.expect(p_dismiss.isNull());
@@ -589,8 +614,8 @@ test "cdp.lp: handleJavaScriptDialog controls confirm/prompt/alert return values
try testing.expect(p_default.isNull());
// ---- alert: dispatches the event but has no return value to override ----
try ctx.processMessage(.{ .id = 6, .method = "LP.handleJavaScriptDialog", .params = .{ .accept = true } });
try ctx.expectSentResult(null, .{ .id = 6 });
try ctx.processMessage(.{ .id = 9, .method = "LP.handleJavaScriptDialog", .params = .{ .accept = true } });
try ctx.expectSentResult(null, .{ .id = 9 });
_ = try ls.local.exec("alert('important')", null);
try ctx.expectSentEvent("Page.javascriptDialogOpening", .{
.message = "important",

View File

@@ -167,7 +167,7 @@ pub fn setCdpCookie(cookie_jar: *CookieJar, param: CdpCookie) !void {
.None => .none,
},
};
try cookie_jar.add(cookie, std.time.timestamp());
try cookie_jar.add(cookie, std.time.timestamp(), true);
}
pub const CookieWriter = struct {

View File

@@ -76,7 +76,7 @@ fn _loadFromFile(session: *Session, path: []const u8) !void {
.same_site = jc.sameSite,
};
jar.add(cookie, now) catch |err| {
jar.add(cookie, now, true) catch |err| {
cookie.deinit();
log.warn(.app, "invalid cookie", .{ .name = jc.name, .err = err });
continue;

76
src/sys/idna.zig Normal file
View File

@@ -0,0 +1,76 @@
// 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/>.
const std = @import("std");
const c = @cImport({
@cInclude("idn2.h");
});
const Allocator = std.mem.Allocator;
pub const Error = error{Idna} || Allocator.Error;
/// True if `host` contains any non-ASCII byte and therefore needs IDNA
/// processing. Pure-ASCII hostnames are returned unchanged by `toAscii`,
/// so callers can use this as a fast path to skip the C call entirely.
pub fn needsAscii(host: []const u8) bool {
for (host) |byte| {
if (byte >= 0x80) {
return true;
}
}
return false;
}
/// Convert a UTF-8 hostname to its ASCII (Punycode) form per UTS#46
/// IDNA 2008 with non-transitional processing — the algorithm WHATWG URL
/// invokes as "domain to ASCII". Returns an allocator-owned slice.
pub fn toAscii(allocator: Allocator, host: []const u8) Error![]u8 {
const host_z = try allocator.dupeZ(u8, host);
defer allocator.free(host_z);
var out_ptr: [*c]u8 = undefined;
const flags: c_int = c.IDN2_NFC_INPUT | c.IDN2_NONTRANSITIONAL;
const rc = c.idn2_to_ascii_8z(host_z.ptr, &out_ptr, flags);
if (rc != c.IDN2_OK) {
return error.Idna;
}
defer c.idn2_free(out_ptr);
return try allocator.dupe(u8, std.mem.span(@as([*:0]const u8, @ptrCast(out_ptr))));
}
const testing = @import("../testing.zig");
test "idna: ASCII passthrough" {
try testing.expectEqual(false, needsAscii("example.com"));
const out = try toAscii(testing.allocator, "example.com");
defer testing.allocator.free(out);
try testing.expectString("example.com", out);
}
test "idna: non-ASCII to punycode" {
try testing.expectEqual(true, needsAscii("räksmörgås.se"));
const out = try toAscii(testing.allocator, "räksmörgås.se");
defer testing.allocator.free(out);
try testing.expectString("xn--rksmrgs-5wao1o.se", out);
}
test "idna: German sharp s with non-transitional processing" {
// UTS#46 non-transitional preserves ß rather than mapping to ss.
const out = try toAscii(testing.allocator, "faß.de");
defer testing.allocator.free(out);
try testing.expectString("xn--fa-hia.de", out);
}

1907
vendor/libidn2/config.h vendored Normal file
View File

File diff suppressed because it is too large Load Diff