mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 01:25:53 -04:00
Merge branch 'main' into agent
This commit is contained in:
79
.github/workflows/package-archlinux.yml
vendored
Normal file
79
.github/workflows/package-archlinux.yml
vendored
Normal 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
78
.github/workflows/package-debian.yml
vendored
Normal 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
|
||||
70
.github/workflows/release.yml
vendored
70
.github/workflows/release.yml
vendored
@@ -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
164
build.zig
@@ -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"),
|
||||
|
||||
@@ -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 = .{""},
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
810
src/browser/ScriptManagerBase.zig
Normal file
810
src/browser/ScriptManagerBase.zig
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
158
src/browser/tests/element/html/label_click.html
Normal file
158
src/browser/tests/element/html/label_click.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
109
src/browser/tests/window/open.html
Normal file
109
src/browser/tests/window/open.html
Normal 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>
|
||||
4
src/browser/tests/worker/import-module.js
Normal file
4
src/browser/tests/worker/import-module.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export const message = 'imported from module';
|
||||
export function multiply(a, b) {
|
||||
return a * b;
|
||||
}
|
||||
14
src/browser/tests/worker/import-worker.js
Normal file
14
src/browser/tests/worker/import-worker.js
Normal 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) });
|
||||
}
|
||||
})();
|
||||
49
src/browser/tests/worker/module-test-worker.js
Normal file
49
src/browser/tests/worker/module-test-worker.js
Normal 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 });
|
||||
}
|
||||
})();
|
||||
51
src/browser/tests/worker/module.html
Normal file
51
src/browser/tests/worker/module.html
Normal 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>
|
||||
1
src/browser/tests/worker/modules/base.js
Normal file
1
src/browser/tests/worker/modules/base.js
Normal file
@@ -0,0 +1 @@
|
||||
export const baseValue = 'from-base';
|
||||
7
src/browser/tests/worker/modules/circular-a.js
Normal file
7
src/browser/tests/worker/modules/circular-a.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { getBValue } from './circular-b.js';
|
||||
|
||||
export const aValue = 'a';
|
||||
|
||||
export function getFromB() {
|
||||
return getBValue();
|
||||
}
|
||||
11
src/browser/tests/worker/modules/circular-b.js
Normal file
11
src/browser/tests/worker/modules/circular-b.js
Normal 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;
|
||||
}
|
||||
4
src/browser/tests/worker/modules/importer.js
Normal file
4
src/browser/tests/worker/modules/importer.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { baseValue } from './base.js';
|
||||
|
||||
export const importedValue = baseValue;
|
||||
export const localValue = 'local';
|
||||
1
src/browser/tests/worker/modules/meta.js
Normal file
1
src/browser/tests/worker/modules/meta.js
Normal file
@@ -0,0 +1 @@
|
||||
export const moduleUrl = import.meta.url;
|
||||
2
src/browser/tests/worker/modules/re-exporter.js
Normal file
2
src/browser/tests/worker/modules/re-exporter.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { baseValue } from './base.js';
|
||||
export { importedValue, localValue } from './importer.js';
|
||||
9
src/browser/tests/worker/modules/shared.js
Normal file
9
src/browser/tests/worker/modules/shared.js
Normal file
@@ -0,0 +1,9 @@
|
||||
let counter = 0;
|
||||
|
||||
export function increment() {
|
||||
return ++counter;
|
||||
}
|
||||
|
||||
export function getCount() {
|
||||
return counter;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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" {
|
||||
// 炣 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());
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, .{});
|
||||
|
||||
|
||||
@@ -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| {
|
||||
|
||||
70
src/browser/webapi/crypto/AES.zig
Normal file
70
src/browser/webapi/crypto/AES.zig
Normal 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);
|
||||
}
|
||||
66
src/browser/webapi/crypto/EC.zig
Normal file
66
src/browser/webapi/crypto/EC.zig
Normal 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);
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
85
src/browser/webapi/crypto/RSA.zig
Normal file
85
src/browser/webapi/crypto/RSA.zig
Normal 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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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", .{});
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
76
src/sys/idna.zig
Normal 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
1907
vendor/libidn2/config.h
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user