diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml
index 4c0a28f9..0c4069fb 100644
--- a/.github/actions/install/action.yml
+++ b/.github/actions/install/action.yml
@@ -13,7 +13,7 @@ inputs:
zig-v8:
description: 'zig v8 version to install'
required: false
- default: 'v0.3.3'
+ default: 'v0.3.4'
v8:
description: 'v8 version to install'
required: false
@@ -46,7 +46,7 @@ runs:
- name: Cache v8
id: cache-v8
- uses: actions/cache@v4
+ uses: actions/cache@v5
env:
cache-name: cache-v8
with:
diff --git a/.github/workflows/e2e-integration-test.yml b/.github/workflows/e2e-integration-test.yml
index 1a0217bb..da4056ca 100644
--- a/.github/workflows/e2e-integration-test.yml
+++ b/.github/workflows/e2e-integration-test.yml
@@ -20,11 +20,9 @@ jobs:
if: github.event.pull_request.draft == false
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
- # fetch submodules recusively, to get zig-js-runtime submodules also.
- submodules: recursive
- uses: ./.github/actions/install
@@ -32,7 +30,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
- name: upload artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: lightpanda-build-release
path: |
@@ -47,7 +45,7 @@ jobs:
timeout-minutes: 15
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
repository: 'lightpanda-io/demo'
fetch-depth: 0
@@ -55,7 +53,7 @@ jobs:
- run: npm install
- name: download artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: lightpanda-build-release
diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml
index 675dd36b..0e59cd24 100644
--- a/.github/workflows/e2e-test.yml
+++ b/.github/workflows/e2e-test.yml
@@ -9,15 +9,13 @@ env:
on:
push:
- branches:
- - main
+ branches: [main]
paths:
- - "build.zig"
- - "src/**/*.zig"
- - "src/*.zig"
- - "vendor/zig-js-runtime"
- ".github/**"
- - "vendor/**"
+ - "src/**"
+ - "build.zig"
+ - "build.zig.zon"
+
pull_request:
# By default GH trigger on types opened, synchronize and reopened.
@@ -29,12 +27,10 @@ on:
paths:
- ".github/**"
+ - "src/**"
- "build.zig"
- - "src/**/*.zig"
- - "src/*.zig"
- - "vendor/**"
- - ".github/**"
- - "vendor/**"
+ - "build.zig.zon"
+
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
@@ -52,8 +48,6 @@ jobs:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- # fetch submodules recusively, to get zig-js-runtime submodules also.
- submodules: recursive
- uses: ./.github/actions/install
@@ -61,7 +55,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
- name: upload artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: lightpanda-build-release
path: |
@@ -76,7 +70,7 @@ jobs:
timeout-minutes: 15
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
repository: 'lightpanda-io/demo'
fetch-depth: 0
@@ -84,7 +78,7 @@ jobs:
- run: npm install
- name: download artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: lightpanda-build-release
@@ -126,7 +120,7 @@ jobs:
timeout-minutes: 15
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
repository: 'lightpanda-io/demo'
fetch-depth: 0
@@ -134,7 +128,7 @@ jobs:
- run: npm install
- name: download artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: lightpanda-build-release
@@ -182,39 +176,41 @@ jobs:
name: wba-test
needs: zig-build-release
- env:
- LIGHTPANDA_DISABLE_TELEMETRY: true
-
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
repository: 'lightpanda-io/demo'
fetch-depth: 0
- - run: echo "${{ secrets.WBA_PRIVATE_KEY_PEM }}" > private_key.pem
-
- name: download artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: lightpanda-build-release
- run: chmod a+x ./lightpanda
+ # force a wakup of the auth server before requesting it w/ the test itself
+ - run: curl https://${{ vars.WBA_DOMAIN }}
+
- name: run wba test
+ shell: bash
run: |
node webbotauth/validator.js &
VALIDATOR_PID=$!
- sleep 2
+ sleep 5
- ./lightpanda fetch http://127.0.0.1:8989/ \
- --web_bot_auth_key_file private_key.pem \
+ exec 3<<< "${{ secrets.WBA_PRIVATE_KEY_PEM }}"
+
+ ./lightpanda fetch --dump http://127.0.0.1:8989/ \
+ --web_bot_auth_key_file /proc/self/fd/3 \
--web_bot_auth_keyid ${{ vars.WBA_KEY_ID }} \
--web_bot_auth_domain ${{ vars.WBA_DOMAIN }}
wait $VALIDATOR_PID
+ exec 3>&-
cdp-and-hyperfine-bench:
name: cdp-and-hyperfine-bench
@@ -224,7 +220,6 @@ jobs:
MAX_VmHWM: 28000 # 28MB (KB)
MAX_CG_PEAK: 8000 # 8MB (KB)
MAX_AVG_DURATION: 17
- LIGHTPANDA_DISABLE_TELEMETRY: true
# How to give cgroups access to the user actions-runner on the host:
# $ sudo apt install cgroup-tools
@@ -239,7 +234,7 @@ jobs:
timeout-minutes: 15
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
repository: 'lightpanda-io/demo'
fetch-depth: 0
@@ -247,7 +242,7 @@ jobs:
- run: npm install
- name: download artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: lightpanda-build-release
@@ -333,7 +328,7 @@ jobs:
echo "${{github.sha}}" > commit.txt
- name: upload artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: bench-results
path: |
@@ -356,12 +351,12 @@ jobs:
container:
image: ghcr.io/lightpanda-io/perf-fmt:latest
credentials:
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: download artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: bench-results
@@ -379,7 +374,7 @@ jobs:
steps:
- name: download artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: lightpanda-build-release
diff --git a/.github/workflows/build.yml b/.github/workflows/nightly.yml
similarity index 88%
rename from .github/workflows/build.yml
rename to .github/workflows/nightly.yml
index d2bcde3b..1c1ece06 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/nightly.yml
@@ -5,7 +5,9 @@ env:
AWS_SECRET_ACCESS_KEY: ${{ secrets.NIGHTLY_BUILD_AWS_SECRET_ACCESS_KEY }}
AWS_BUCKET: ${{ vars.NIGHTLY_BUILD_AWS_BUCKET }}
AWS_REGION: ${{ vars.NIGHTLY_BUILD_AWS_REGION }}
+
RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }}
+ GIT_VERSION_FLAG: ${{ github.ref_type == 'tag' && format('-Dgit_version={0}', github.ref_name) || '' }}
on:
push:
@@ -33,8 +35,6 @@ jobs:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- # fetch submodules recusively, to get zig-js-runtime submodules also.
- submodules: recursive
- uses: ./.github/actions/install
with:
@@ -45,7 +45,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build
- run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
+ run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }}
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -72,11 +72,9 @@ jobs:
timeout-minutes: 20
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
- # fetch submodules recusively, to get zig-js-runtime submodules also.
- submodules: recursive
- uses: ./.github/actions/install
with:
@@ -87,7 +85,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build
- run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
+ run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }}
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -116,11 +114,9 @@ jobs:
timeout-minutes: 20
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
- # fetch submodules recusively, to get zig-js-runtime submodules also.
- submodules: recursive
- uses: ./.github/actions/install
with:
@@ -131,7 +127,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build
- run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
+ run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }}
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -158,11 +154,9 @@ jobs:
timeout-minutes: 20
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
- # fetch submodules recusively, to get zig-js-runtime submodules also.
- submodules: recursive
- uses: ./.github/actions/install
with:
@@ -173,7 +167,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build
- run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
+ run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }}
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
diff --git a/.github/workflows/wpt.yml b/.github/workflows/wpt.yml
index 71d485d0..01f367e8 100644
--- a/.github/workflows/wpt.yml
+++ b/.github/workflows/wpt.yml
@@ -10,7 +10,7 @@ env:
on:
schedule:
- - cron: "23 2 * * *"
+ - cron: "21 2 * * *"
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
@@ -19,23 +19,31 @@ jobs:
wpt-build-release:
name: zig build release
- runs-on: ubuntu-latest
- timeout-minutes: 15
+ env:
+ ARCH: aarch64
+ OS: linux
+
+ runs-on: ubuntu-24.04-arm
+ timeout-minutes: 20
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- # fetch submodules recusively, to get zig-js-runtime submodules also.
- submodules: recursive
- uses: ./.github/actions/install
+ with:
+ os: ${{env.OS}}
+ arch: ${{env.ARCH}}
+
+ - name: v8 snapshot
+ run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build release
- run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
+ run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
- name: upload artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: lightpanda-build-release
path: |
@@ -45,7 +53,7 @@ jobs:
wpt-build-runner:
name: build wpt runner
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
timeout-minutes: 15
steps:
@@ -59,7 +67,7 @@ jobs:
CGO_ENABLED=0 go build
- name: upload artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: wptrunner
path: |
@@ -73,8 +81,8 @@ jobs:
- wpt-build-runner
# use a self host runner.
- runs-on: lpd-bench-hetzner
- timeout-minutes: 180
+ runs-on: lpd-wpt-aws
+ timeout-minutes: 600
steps:
- uses: actions/checkout@v6
@@ -91,14 +99,14 @@ jobs:
run: ./wpt manifest
- name: download lightpanda release
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: lightpanda-build-release
- run: chmod a+x ./lightpanda
- name: download wptrunner
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: wptrunner
@@ -107,8 +115,8 @@ jobs:
- name: run test with json output
run: |
./wpt serve 2> /dev/null & echo $! > WPT.pid
- sleep 10s
- ./wptrunner -lpd-path ./lightpanda -json -concurrency 10 -pool 3 > wpt.json
+ sleep 20s
+ ./wptrunner -lpd-path ./lightpanda -json -concurrency 5 -pool 5 --mem-limit 400 > wpt.json
kill `cat WPT.pid`
- name: write commit
@@ -116,7 +124,7 @@ jobs:
echo "${{github.sha}}" > commit.txt
- name: upload artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: wpt-results
path: |
@@ -139,7 +147,7 @@ jobs:
steps:
- name: download artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: wpt-results
diff --git a/.github/workflows/zig-fmt.yml b/.github/workflows/zig-fmt.yml
deleted file mode 100644
index a6df7e1f..00000000
--- a/.github/workflows/zig-fmt.yml
+++ /dev/null
@@ -1,60 +0,0 @@
-name: zig-fmt
-
-on:
- pull_request:
-
- # By default GH trigger on types opened, synchronize and reopened.
- # see https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
- # Since we skip the job when the PR is in draft state, we want to force CI
- # running when the PR is marked ready_for_review w/o other change.
- # see https://github.com/orgs/community/discussions/25722#discussioncomment-3248917
- types: [opened, synchronize, reopened, ready_for_review]
-
- paths:
- - ".github/**"
- - "build.zig"
- - "src/**/*.zig"
- - "src/*.zig"
- # Allows you to run this workflow manually from the Actions tab
- workflow_dispatch:
-
-jobs:
- zig-fmt:
- name: zig fmt
-
- # Don't run the CI with draft PR.
- if: github.event.pull_request.draft == false
-
- runs-on: ubuntu-latest
- timeout-minutes: 15
-
- steps:
- - uses: actions/checkout@v6
- with:
- fetch-depth: 0
-
- # Zig version used from the `minimum_zig_version` field in build.zig.zon
- - uses: mlugg/setup-zig@v2
-
- - name: Run zig fmt
- id: fmt
- run: |
- zig fmt --check ./*.zig ./**/*.zig 2> zig-fmt.err > zig-fmt.err2 || echo "Failed"
- delimiter="$(openssl rand -hex 8)"
- echo "zig_fmt_errs<<${delimiter}" >> "${GITHUB_OUTPUT}"
-
- if [ -s zig-fmt.err ]; then
- echo "// The following errors occurred:" >> "${GITHUB_OUTPUT}"
- cat zig-fmt.err >> "${GITHUB_OUTPUT}"
- fi
-
- if [ -s zig-fmt.err2 ]; then
- echo "// The following files were not formatted:" >> "${GITHUB_OUTPUT}"
- cat zig-fmt.err2 >> "${GITHUB_OUTPUT}"
- fi
-
- echo "${delimiter}" >> "${GITHUB_OUTPUT}"
-
- - name: Fail the job
- if: steps.fmt.outputs.zig_fmt_errs != ''
- run: exit 1
diff --git a/.github/workflows/zig-test.yml b/.github/workflows/zig-test.yml
index db2f362d..a96ec0bf 100644
--- a/.github/workflows/zig-test.yml
+++ b/.github/workflows/zig-test.yml
@@ -5,19 +5,18 @@ env:
AWS_SECRET_ACCESS_KEY: ${{ secrets.LPD_PERF_AWS_SECRET_ACCESS_KEY }}
AWS_BUCKET: ${{ vars.LPD_PERF_AWS_BUCKET }}
AWS_REGION: ${{ vars.LPD_PERF_AWS_REGION }}
+ LIGHTPANDA_DISABLE_TELEMETRY: true
on:
push:
- branches:
- - main
+ branches: [main]
paths:
- - "build.zig"
- - "src/**"
- - "vendor/zig-js-runtime"
- ".github/**"
- - "vendor/**"
- pull_request:
+ - "src/**"
+ - "build.zig"
+ - "build.zig.zon"
+ pull_request:
# By default GH trigger on types opened, synchronize and reopened.
# see https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
# Since we skip the job when the PR is in draft state, we want to force CI
@@ -27,28 +26,63 @@ on:
paths:
- ".github/**"
+ - "src/**"
- "build.zig"
- - "src/**/*.zig"
- - "src/*.zig"
- - "vendor/**"
- - ".github/**"
- - "vendor/**"
+ - "build.zig.zon"
+
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
jobs:
- zig-test-debug:
- name: zig test using v8 in debug mode
- timeout-minutes: 15
+ zig-fmt:
+ name: zig fmt
runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ if: github.event.pull_request.draft == false
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
+
+ # Zig version used from the `minimum_zig_version` field in build.zig.zon
+ - uses: mlugg/setup-zig@v2
+
+ - name: Run zig fmt
+ id: fmt
+ run: |
+ zig fmt --check ./*.zig ./**/*.zig 2> zig-fmt.err > zig-fmt.err2 || echo "Failed"
+ delimiter="$(openssl rand -hex 8)"
+ echo "zig_fmt_errs<<${delimiter}" >> "${GITHUB_OUTPUT}"
+
+ if [ -s zig-fmt.err ]; then
+ echo "// The following errors occurred:" >> "${GITHUB_OUTPUT}"
+ cat zig-fmt.err >> "${GITHUB_OUTPUT}"
+ fi
+
+ if [ -s zig-fmt.err2 ]; then
+ echo "// The following files were not formatted:" >> "${GITHUB_OUTPUT}"
+ cat zig-fmt.err2 >> "${GITHUB_OUTPUT}"
+ fi
+
+ echo "${delimiter}" >> "${GITHUB_OUTPUT}"
+
+ - name: Fail the job
+ if: steps.fmt.outputs.zig_fmt_errs != ''
+ run: exit 1
+
+ zig-test-debug:
+ name: zig test using v8 in debug mode
+
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ if: github.event.pull_request.draft == false
+
+ steps:
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
- # fetch submodules recusively, to get zig-js-runtime submodules also.
- submodules: recursive
- uses: ./.github/actions/install
with:
@@ -57,21 +91,18 @@ jobs:
- name: zig build test
run: zig build -Dprebuilt_v8_path=v8/libc_v8_debug.a -Dtsan=true test
- zig-test:
+ zig-test-release:
name: zig test
- timeout-minutes: 15
-
- # Don't run the CI with draft PR.
- if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ if: github.event.pull_request.draft == false
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
- # fetch submodules recusively, to get zig-js-runtime submodules also.
- submodules: recursive
- uses: ./.github/actions/install
@@ -83,7 +114,7 @@ jobs:
echo "${{github.sha}}" > commit.txt
- name: upload artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: bench-results
path: |
@@ -93,23 +124,22 @@ jobs:
bench-fmt:
name: perf-fmt
- needs: zig-test
-
- # Don't execute on PR
- if: github.event_name != 'pull_request'
+ needs: zig-test-release
runs-on: ubuntu-latest
timeout-minutes: 15
+ if: github.event_name != 'pull_request'
+
container:
image: ghcr.io/lightpanda-io/perf-fmt:latest
credentials:
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: download artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v8
with:
name: bench-results
diff --git a/Dockerfile b/Dockerfile
index f106905a..f5cd202d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,7 +3,7 @@ FROM debian:stable-slim
ARG MINISIG=0.12
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
ARG V8=14.0.365.4
-ARG ZIG_V8=v0.3.3
+ARG ZIG_V8=v0.3.4
ARG TARGETPLATFORM
RUN apt-get update -yq && \
diff --git a/Makefile b/Makefile
index a85d4e69..56910568 100644
--- a/Makefile
+++ b/Makefile
@@ -47,7 +47,7 @@ help:
# $(ZIG) commands
# ------------
-.PHONY: build build-v8-snapshot build-dev run run-release shell test bench data end2end
+.PHONY: build build-v8-snapshot build-dev run run-release test bench data end2end
## Build v8 snapshot
build-v8-snapshot:
@@ -77,11 +77,6 @@ run-debug: build-dev
@printf "\033[36mRunning...\033[0m\n"
@./zig-out/bin/lightpanda || (printf "\033[33mRun ERROR\033[0m\n"; exit 1;)
-## Run a JS shell in debug mode
-shell:
- @printf "\033[36mBuilding shell...\033[0m\n"
- @$(ZIG) build shell || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
-
## Test - `grep` is used to filter out the huge compile command on build
ifeq ($(OS), macos)
test:
@@ -106,4 +101,3 @@ install: build
data:
cd src/data && go run public_suffix_list_gen.go > public_suffix_list.zig
-
diff --git a/README.md b/README.md
index 456499ce..1ec482ba 100644
--- a/README.md
+++ b/README.md
@@ -190,8 +190,6 @@ Here are the key features we have implemented:
NOTE: There are hundreds of Web APIs. Developing a browser (even just for headless mode) is a huge task. Coverage will increase over time.
-You can also follow the progress of our Javascript support in our dedicated [zig-js-runtime](https://github.com/lightpanda-io/zig-js-runtime#development) project.
-
## Build from sources
### Prerequisites
@@ -200,10 +198,10 @@ Lightpanda is written with [Zig](https://ziglang.org/) `0.15.2`. You have to
install it with the right version in order to build the project.
Lightpanda also depends on
-[zig-js-runtime](https://github.com/lightpanda-io/zig-js-runtime/) (with v8),
+[v8](https://chromium.googlesource.com/v8/v8.git),
[Libcurl](https://curl.se/libcurl/) and [html5ever](https://github.com/servo/html5ever).
-To be able to build the v8 engine for zig-js-runtime, you have to install some libs:
+To be able to build the v8 engine, you have to install some libs:
For **Debian/Ubuntu based Linux**:
diff --git a/build.zig b/build.zig
index 1f9ae5ef..0d917b75 100644
--- a/build.zig
+++ b/build.zig
@@ -27,12 +27,14 @@ pub fn build(b: *Build) !void {
const manifest = Manifest.init(b);
const git_commit = b.option([]const u8, "git_commit", "Current git commit");
+ const git_version = b.option([]const u8, "git_version", "Current git version (from tag)");
const prebuilt_v8_path = b.option([]const u8, "prebuilt_v8_path", "Path to prebuilt libc_v8.a");
const snapshot_path = b.option([]const u8, "snapshot_path", "Path to v8 snapshot");
var opts = b.addOptions();
opts.addOption([]const u8, "version", manifest.version);
opts.addOption([]const u8, "git_commit", git_commit orelse "dev");
+ opts.addOption(?[]const u8, "git_version", git_version orelse null);
opts.addOption(?[]const u8, "snapshot_path", snapshot_path);
const enable_tsan = b.option(bool, "tsan", "Enable Thread Sanitizer") orelse false;
diff --git a/build.zig.zon b/build.zig.zon
index 9a28408b..cee52057 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -5,8 +5,8 @@
.minimum_zig_version = "0.15.2",
.dependencies = .{
.v8 = .{
- .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.3.tar.gz",
- .hash = "v8-0.0.0-xddH6yx3BAAGD9jSoq_ttt_bk9MectTU44s_HZxxE5LD",
+ .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.4.tar.gz",
+ .hash = "v8-0.0.0-xddH6_F3BAAiFvKY6R1H-gkuQlk19BkDQ0--uZuTrSup",
},
// .v8 = .{ .path = "../zig-v8-fork" },
.brotli = .{
diff --git a/src/App.zig b/src/App.zig
index 9039cec5..d3ba2fec 100644
--- a/src/App.zig
+++ b/src/App.zig
@@ -67,7 +67,7 @@ pub fn init(allocator: Allocator, config: *const Config) !*App {
app.app_dir_path = getAndMakeAppDir(allocator);
app.telemetry = try Telemetry.init(app, config.mode);
- errdefer app.telemetry.deinit();
+ errdefer app.telemetry.deinit(allocator);
app.arena_pool = ArenaPool.init(allocator, 512, 1024 * 16);
errdefer app.arena_pool.deinit();
@@ -85,7 +85,7 @@ pub fn deinit(self: *App) void {
allocator.free(app_dir_path);
self.app_dir_path = null;
}
- self.telemetry.deinit();
+ self.telemetry.deinit(allocator);
self.network.deinit();
self.snapshot.deinit();
self.platform.deinit();
diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig
index 95561c9f..f6b2fca5 100644
--- a/src/SemanticTree.zig
+++ b/src/SemanticTree.zig
@@ -36,7 +36,9 @@ dom_node: *Node,
registry: *CDPNode.Registry,
page: *Page,
arena: std.mem.Allocator,
-prune: bool = false,
+prune: bool = true,
+interactive_only: bool = false,
+max_depth: u32 = std.math.maxInt(u32) - 1,
pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!void {
var visitor = JsonVisitor{ .jw = jw, .tree = self };
@@ -47,7 +49,7 @@ pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!
};
var visibility_cache: Element.VisibilityCache = .empty;
var pointer_events_cache: Element.PointerEventsCache = .empty;
- self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, &visibility_cache, &pointer_events_cache) catch |err| {
+ self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, &visibility_cache, &pointer_events_cache, 0) catch |err| {
log.err(.app, "semantic tree json dump failed", .{ .err = err });
return error.WriteFailed;
};
@@ -62,7 +64,7 @@ pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!v
};
var visibility_cache: Element.VisibilityCache = .empty;
var pointer_events_cache: Element.PointerEventsCache = .empty;
- self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, &visibility_cache, &pointer_events_cache) catch |err| {
+ self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, &visibility_cache, &pointer_events_cache, 0) catch |err| {
log.err(.app, "semantic tree text dump failed", .{ .err = err });
return error.WriteFailed;
};
@@ -75,7 +77,7 @@ const OptionData = struct {
};
const NodeData = struct {
- id: u32,
+ id: CDPNode.Id,
axn: AXNode,
role: []const u8,
name: ?[]const u8,
@@ -86,7 +88,9 @@ const NodeData = struct {
node_name: []const u8,
};
-fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_name: ?[]const u8, visitor: anytype, index: usize, listener_targets: interactive.ListenerTargetMap, visibility_cache: ?*Element.VisibilityCache, pointer_events_cache: ?*Element.PointerEventsCache) !void {
+fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_name: ?[]const u8, visitor: anytype, index: usize, listener_targets: interactive.ListenerTargetMap, visibility_cache: ?*Element.VisibilityCache, pointer_events_cache: ?*Element.PointerEventsCache, current_depth: u32) !void {
+ if (current_depth > self.max_depth) return;
+
// 1. Skip non-content nodes
if (node.is(Element)) |el| {
const tag = el.getTag();
@@ -178,7 +182,23 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
};
var should_visit = true;
- if (self.prune) {
+ if (self.interactive_only) {
+ var keep = false;
+ if (interactive.isInteractiveRole(role)) {
+ keep = true;
+ } else if (interactive.isContentRole(role)) {
+ if (name != null and name.?.len > 0) {
+ keep = true;
+ }
+ } else if (std.mem.eql(u8, role, "RootWebArea")) {
+ keep = true;
+ } else if (is_interactive) {
+ keep = true;
+ }
+ if (!keep) {
+ should_visit = false;
+ }
+ } else if (self.prune) {
if (structural and !is_interactive and !has_explicit_label) {
should_visit = false;
}
@@ -217,7 +237,7 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
}
gop.value_ptr.* += 1;
- try self.walk(child, xpath_buffer, name, visitor, gop.value_ptr.*, listener_targets, visibility_cache, pointer_events_cache);
+ try self.walk(child, xpath_buffer, name, visitor, gop.value_ptr.*, listener_targets, visibility_cache, pointer_events_cache, current_depth + 1);
}
}
@@ -393,36 +413,45 @@ const TextVisitor = struct {
depth: usize,
pub fn visit(self: *TextVisitor, node: *Node, data: *NodeData) !bool {
- // Format: " [12] link: Hacker News (value)"
- for (0..(self.depth * 2)) |_| {
+ for (0..self.depth) |_| {
try self.writer.writeByte(' ');
}
- try self.writer.print("[{d}] {s}: ", .{ data.id, data.role });
+ var name_to_print: ?[]const u8 = null;
if (data.name) |n| {
if (n.len > 0) {
- try self.writer.writeAll(n);
+ name_to_print = n;
}
} else if (node.is(CData.Text)) |text_node| {
const trimmed = std.mem.trim(u8, text_node.getWholeText(), " \t\r\n");
if (trimmed.len > 0) {
- try self.writer.writeAll(trimmed);
+ name_to_print = trimmed;
}
}
+ const is_text_only = std.mem.eql(u8, data.role, "StaticText") or std.mem.eql(u8, data.role, "none") or std.mem.eql(u8, data.role, "generic");
+
+ try self.writer.print("{d}", .{data.id});
+ if (!is_text_only) {
+ try self.writer.print(" {s}", .{data.role});
+ }
+ if (name_to_print) |n| {
+ try self.writer.print(" '{s}'", .{n});
+ }
+
if (data.value) |v| {
if (v.len > 0) {
- try self.writer.print(" (value: {s})", .{v});
+ try self.writer.print(" value='{s}'", .{v});
}
}
if (data.options) |options| {
- try self.writer.writeAll(" options: [");
+ try self.writer.writeAll(" options=[");
for (options, 0..) |opt, i| {
- if (i > 0) try self.writer.writeAll(", ");
+ if (i > 0) try self.writer.writeAll(",");
try self.writer.print("'{s}'", .{opt.value});
if (opt.selected) {
- try self.writer.writeAll(" (selected)");
+ try self.writer.writeAll("*");
}
}
try self.writer.writeAll("]\n");
@@ -452,3 +481,56 @@ const TextVisitor = struct {
}
}
};
+
+const testing = @import("testing.zig");
+
+test "SemanticTree backendDOMNodeId" {
+ var registry: CDPNode.Registry = .init(testing.allocator);
+ defer registry.deinit();
+
+ var page = try testing.pageTest("cdp/registry1.html");
+ defer testing.reset();
+ defer page._session.removePage();
+
+ const st: Self = .{
+ .dom_node = page.window._document.asNode(),
+ .registry = ®istry,
+ .page = page,
+ .arena = testing.arena_allocator,
+ .prune = false,
+ .interactive_only = false,
+ .max_depth = std.math.maxInt(u32) - 1,
+ };
+
+ const json_str = try std.json.Stringify.valueAlloc(testing.allocator, st, .{});
+ defer testing.allocator.free(json_str);
+
+ try testing.expect(std.mem.indexOf(u8, json_str, "\"backendDOMNodeId\":") != null);
+}
+
+test "SemanticTree max_depth" {
+ var registry: CDPNode.Registry = .init(testing.allocator);
+ defer registry.deinit();
+
+ var page = try testing.pageTest("cdp/registry1.html");
+ defer testing.reset();
+ defer page._session.removePage();
+
+ const st: Self = .{
+ .dom_node = page.window._document.asNode(),
+ .registry = ®istry,
+ .page = page,
+ .arena = testing.arena_allocator,
+ .prune = false,
+ .interactive_only = false,
+ .max_depth = 1,
+ };
+
+ var aw: std.Io.Writer.Allocating = .init(testing.allocator);
+ defer aw.deinit();
+
+ try st.textStringify(&aw.writer);
+ const text_str = aw.written();
+
+ try testing.expect(std.mem.indexOf(u8, text_str, "other") == null);
+}
diff --git a/src/Server.zig b/src/Server.zig
index f899d43c..d172f6dd 100644
--- a/src/Server.zig
+++ b/src/Server.zig
@@ -64,17 +64,17 @@ pub fn init(app: *App, address: net.Address) !*Server {
return self;
}
-pub fn deinit(self: *Server) void {
- // Stop all active clients
- {
- self.client_mutex.lock();
- defer self.client_mutex.unlock();
+pub fn shutdown(self: *Server) void {
+ self.client_mutex.lock();
+ defer self.client_mutex.unlock();
- for (self.clients.items) |client| {
- client.stop();
- }
+ for (self.clients.items) |client| {
+ client.stop();
}
+}
+pub fn deinit(self: *Server) void {
+ self.shutdown();
self.joinThreads();
self.clients.deinit(self.allocator);
self.clients_pool.deinit();
@@ -242,7 +242,10 @@ pub const Client = struct {
fn stop(self: *Client) void {
switch (self.mode) {
.http => {},
- .cdp => |*cdp| cdp.browser.env.terminate(),
+ .cdp => |*cdp| {
+ cdp.browser.env.terminate();
+ self.ws.sendClose();
+ },
}
self.ws.shutdown();
}
diff --git a/src/browser/Browser.zig b/src/browser/Browser.zig
index 8f8c4aa2..50a7c037 100644
--- a/src/browser/Browser.zig
+++ b/src/browser/Browser.zig
@@ -91,25 +91,32 @@ pub fn runMicrotasks(self: *Browser) void {
self.env.runMicrotasks();
}
-pub fn runMacrotasks(self: *Browser) !?u64 {
+pub fn runMacrotasks(self: *Browser) !void {
const env = &self.env;
- const time_to_next = try self.env.runMacrotasks();
+ try self.env.runMacrotasks();
env.pumpMessageLoop();
// either of the above could have queued more microtasks
env.runMicrotasks();
-
- return time_to_next;
}
pub fn hasBackgroundTasks(self: *Browser) bool {
return self.env.hasBackgroundTasks();
}
+
pub fn waitForBackgroundTasks(self: *Browser) void {
self.env.waitForBackgroundTasks();
}
+pub fn msToNextMacrotask(self: *Browser) ?u64 {
+ return self.env.msToNextMacrotask();
+}
+
+pub fn msTo(self: *Browser) bool {
+ return self.env.hasBackgroundTasks();
+}
+
pub fn runIdleTasks(self: *const Browser) void {
self.env.runIdleTasks();
}
diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig
index 5588b704..247a298e 100644
--- a/src/browser/EventManager.zig
+++ b/src/browser/EventManager.zig
@@ -233,6 +233,12 @@ const DispatchDirectOptions = struct {
pub fn dispatchDirect(self: *EventManager, target: *EventTarget, event: *Event, handler: anytype, comptime opts: DispatchDirectOptions) !void {
const page = self.page;
+ // Set window.event to the currently dispatching event (WHATWG spec)
+ const window = page.window;
+ const prev_event = window._current_event;
+ window._current_event = event;
+ defer window._current_event = prev_event;
+
event.acquireRef();
defer event.deinit(false, page._session);
@@ -398,6 +404,13 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
}
const page = self.page;
+
+ // Set window.event to the currently dispatching event (WHATWG spec)
+ const window = page.window;
+ const prev_event = window._current_event;
+ window._current_event = event;
+ defer window._current_event = prev_event;
+
var was_handled = false;
// Create a single scope for all event handlers in this dispatch.
diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig
index 1e74c046..136b578b 100644
--- a/src/browser/HttpClient.zig
+++ b/src/browser/HttpClient.zig
@@ -110,6 +110,8 @@ use_proxy: bool,
// Current TLS verification state, applied per-connection in makeRequest.
tls_verify: bool = true,
+obey_robots: bool,
+
cdp_client: ?CDPClient = null,
// libcurl can monitor arbitrary sockets, this lets us use libcurl to poll
@@ -154,6 +156,7 @@ pub fn init(allocator: Allocator, network: *Network) !*Client {
.http_proxy = http_proxy,
.use_proxy = http_proxy != null,
.tls_verify = network.config.tlsVerifyHost(),
+ .obey_robots = network.config.obeyRobots(),
.transfer_pool = transfer_pool,
};
@@ -257,34 +260,33 @@ pub fn tick(self: *Client, timeout_ms: u32) !PerformStatus {
}
pub fn request(self: *Client, req: Request) !void {
- if (self.network.config.obeyRobots()) {
- const robots_url = try URL.getRobotsUrl(self.allocator, req.url);
- errdefer self.allocator.free(robots_url);
-
- // If we have this robots cached, we can take a fast path.
- if (self.network.robot_store.get(robots_url)) |robot_entry| {
- defer self.allocator.free(robots_url);
-
- switch (robot_entry) {
- // If we have a found robots entry, we check it.
- .present => |robots| {
- const path = URL.getPathname(req.url);
- if (!robots.isAllowed(path)) {
- req.error_callback(req.ctx, error.RobotsBlocked);
- return;
- }
- },
- // Otherwise, we assume we won't find it again.
- .absent => {},
- }
-
- return self.processRequest(req);
- }
-
- return self.fetchRobotsThenProcessRequest(robots_url, req);
+ if (self.obey_robots == false) {
+ return self.processRequest(req);
}
- return self.processRequest(req);
+ const robots_url = try URL.getRobotsUrl(self.allocator, req.url);
+ errdefer self.allocator.free(robots_url);
+
+ // If we have this robots cached, we can take a fast path.
+ if (self.network.robot_store.get(robots_url)) |robot_entry| {
+ defer self.allocator.free(robots_url);
+
+ switch (robot_entry) {
+ // If we have a found robots entry, we check it.
+ .present => |robots| {
+ const path = URL.getPathname(req.url);
+ if (!robots.isAllowed(path)) {
+ req.error_callback(req.ctx, error.RobotsBlocked);
+ return;
+ }
+ },
+ // Otherwise, we assume we won't find it again.
+ .absent => {},
+ }
+
+ return self.processRequest(req);
+ }
+ return self.fetchRobotsThenProcessRequest(robots_url, req);
}
fn processRequest(self: *Client, req: Request) !void {
diff --git a/src/browser/Mime.zig b/src/browser/Mime.zig
index 43ca3632..e23d48a2 100644
--- a/src/browser/Mime.zig
+++ b/src/browser/Mime.zig
@@ -25,6 +25,7 @@ params: []const u8 = "",
// We keep 41 for null-termination since HTML parser expects in this format.
charset: [41]u8 = default_charset,
charset_len: usize = default_charset_len,
+is_default_charset: bool = true,
/// String "UTF-8" continued by null characters.
const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36;
@@ -130,6 +131,7 @@ pub fn parse(input: []u8) !Mime {
var charset: [41]u8 = default_charset;
var charset_len: usize = default_charset_len;
+ var has_explicit_charset = false;
var it = std.mem.splitScalar(u8, params, ';');
while (it.next()) |attr| {
@@ -156,6 +158,7 @@ pub fn parse(input: []u8) !Mime {
// Null-terminate right after attribute value.
charset[attribute_value.len] = 0;
charset_len = attribute_value.len;
+ has_explicit_charset = true;
},
}
}
@@ -165,9 +168,137 @@ pub fn parse(input: []u8) !Mime {
.charset = charset,
.charset_len = charset_len,
.content_type = content_type,
+ .is_default_charset = !has_explicit_charset,
};
}
+/// Prescan the first 1024 bytes of an HTML document for a charset declaration.
+/// Looks for `` and ``.
+/// Returns the charset value or null if none found.
+/// See: https://www.w3.org/International/questions/qa-html-encoding-declarations
+pub fn prescanCharset(html: []const u8) ?[]const u8 {
+ const limit = @min(html.len, 1024);
+ const data = html[0..limit];
+
+ // Scan for = data.len) return null;
+
+ // Check for "meta" (case-insensitive)
+ if (pos + 4 >= data.len) return null;
+ var tag_buf: [4]u8 = undefined;
+ _ = std.ascii.lowerString(&tag_buf, data[pos..][0..4]);
+ if (!std.mem.eql(u8, &tag_buf, "meta")) {
+ continue;
+ }
+ pos += 4;
+
+ // Must be followed by whitespace or end of tag
+ if (pos >= data.len) return null;
+ if (data[pos] != ' ' and data[pos] != '\t' and data[pos] != '\n' and
+ data[pos] != '\r' and data[pos] != '/')
+ {
+ continue;
+ }
+
+ // Scan attributes within this meta tag
+ const tag_end = std.mem.indexOfScalarPos(u8, data, pos, '>') orelse return null;
+ const attrs = data[pos..tag_end];
+
+ // Look for charset= attribute directly
+ if (findAttrValue(attrs, "charset")) |charset| {
+ if (charset.len > 0 and charset.len <= 40) return charset;
+ }
+
+ // Look for http-equiv="content-type" with content="...;charset=X"
+ if (findAttrValue(attrs, "http-equiv")) |he| {
+ if (std.ascii.eqlIgnoreCase(he, "content-type")) {
+ if (findAttrValue(attrs, "content")) |content| {
+ if (extractCharsetFromContentType(content)) |charset| {
+ return charset;
+ }
+ }
+ }
+ }
+
+ pos = tag_end + 1;
+ }
+ return null;
+}
+
+fn findAttrValue(attrs: []const u8, name: []const u8) ?[]const u8 {
+ var pos: usize = 0;
+ while (pos < attrs.len) {
+ // Skip whitespace
+ while (pos < attrs.len and (attrs[pos] == ' ' or attrs[pos] == '\t' or
+ attrs[pos] == '\n' or attrs[pos] == '\r'))
+ {
+ pos += 1;
+ }
+ if (pos >= attrs.len) return null;
+
+ // Read attribute name
+ const attr_start = pos;
+ while (pos < attrs.len and attrs[pos] != '=' and attrs[pos] != ' ' and
+ attrs[pos] != '\t' and attrs[pos] != '>' and attrs[pos] != '/')
+ {
+ pos += 1;
+ }
+ const attr_name = attrs[attr_start..pos];
+
+ // Skip whitespace around =
+ while (pos < attrs.len and (attrs[pos] == ' ' or attrs[pos] == '\t')) pos += 1;
+ if (pos >= attrs.len or attrs[pos] != '=') {
+ // No '=' found - skip this token. Advance at least one byte to avoid infinite loop.
+ if (pos == attr_start) pos += 1;
+ continue;
+ }
+ pos += 1; // skip '='
+ while (pos < attrs.len and (attrs[pos] == ' ' or attrs[pos] == '\t')) pos += 1;
+ if (pos >= attrs.len) return null;
+
+ // Read attribute value
+ const value = blk: {
+ if (attrs[pos] == '"' or attrs[pos] == '\'') {
+ const quote = attrs[pos];
+ pos += 1;
+ const val_start = pos;
+ while (pos < attrs.len and attrs[pos] != quote) pos += 1;
+ const val = attrs[val_start..pos];
+ if (pos < attrs.len) pos += 1; // skip closing quote
+ break :blk val;
+ } else {
+ const val_start = pos;
+ while (pos < attrs.len and attrs[pos] != ' ' and attrs[pos] != '\t' and
+ attrs[pos] != '>' and attrs[pos] != '/')
+ {
+ pos += 1;
+ }
+ break :blk attrs[val_start..pos];
+ }
+ };
+
+ if (std.ascii.eqlIgnoreCase(attr_name, name)) return value;
+ }
+ return null;
+}
+
+fn extractCharsetFromContentType(content: []const u8) ?[]const u8 {
+ var it = std.mem.splitScalar(u8, content, ';');
+ while (it.next()) |part| {
+ const trimmed = std.mem.trimLeft(u8, part, &.{ ' ', '\t' });
+ if (trimmed.len > 8 and std.ascii.eqlIgnoreCase(trimmed[0..8], "charset=")) {
+ const val = std.mem.trim(u8, trimmed[8..], &.{ ' ', '\t', '"', '\'' });
+ if (val.len > 0 and val.len <= 40) return val;
+ }
+ }
+ return null;
+}
+
pub fn sniff(body: []const u8) ?Mime {
// 0x0C is form feed
const content = std.mem.trimLeft(u8, body, &.{ ' ', '\t', '\n', '\r', 0x0C });
@@ -178,15 +309,30 @@ pub fn sniff(body: []const u8) ?Mime {
if (content[0] != '<') {
if (std.mem.startsWith(u8, content, &.{ 0xEF, 0xBB, 0xBF })) {
// UTF-8 BOM
- return .{ .content_type = .{ .text_plain = {} } };
+ return .{
+ .content_type = .{ .text_plain = {} },
+ .charset = default_charset,
+ .charset_len = default_charset_len,
+ .is_default_charset = false,
+ };
}
if (std.mem.startsWith(u8, content, &.{ 0xFE, 0xFF })) {
// UTF-16 big-endian BOM
- return .{ .content_type = .{ .text_plain = {} } };
+ return .{
+ .content_type = .{ .text_plain = {} },
+ .charset = .{ 'U', 'T', 'F', '-', '1', '6', 'B', 'E' } ++ .{0} ** 33,
+ .charset_len = 8,
+ .is_default_charset = false,
+ };
}
if (std.mem.startsWith(u8, content, &.{ 0xFF, 0xFE })) {
// UTF-16 little-endian BOM
- return .{ .content_type = .{ .text_plain = {} } };
+ return .{
+ .content_type = .{ .text_plain = {} },
+ .charset = .{ 'U', 'T', 'F', '-', '1', '6', 'L', 'E' } ++ .{0} ** 33,
+ .charset_len = 8,
+ .is_default_charset = false,
+ };
}
return null;
}
@@ -540,6 +686,24 @@ test "Mime: sniff" {
try expectHTML("");
try expectHTML(" \n\t ");
+
+ {
+ const mime = Mime.sniff(&.{ 0xEF, 0xBB, 0xBF }).?;
+ try testing.expectEqual(.text_plain, std.meta.activeTag(mime.content_type));
+ try testing.expectEqual("UTF-8", mime.charsetString());
+ }
+
+ {
+ const mime = Mime.sniff(&.{ 0xFE, 0xFF }).?;
+ try testing.expectEqual(.text_plain, std.meta.activeTag(mime.content_type));
+ try testing.expectEqual("UTF-16BE", mime.charsetString());
+ }
+
+ {
+ const mime = Mime.sniff(&.{ 0xFF, 0xFE }).?;
+ try testing.expectEqual(.text_plain, std.meta.activeTag(mime.content_type));
+ try testing.expectEqual("UTF-16LE", mime.charsetString());
+ }
}
const Expectation = struct {
@@ -576,3 +740,35 @@ fn expect(expected: Expectation, input: []const u8) !void {
try testing.expectEqual(m.charsetStringZ(), actual.charsetStringZ());
}
}
+
+test "Mime: prescanCharset" {
+ //
+ try testing.expectEqual("utf-8", Mime.prescanCharset("
").?);
+ try testing.expectEqual("iso-8859-1", Mime.prescanCharset("").?);
+ try testing.expectEqual("shift_jis", Mime.prescanCharset("").?);
+
+ // Case-insensitive tag matching
+ try testing.expectEqual("utf-8", Mime.prescanCharset("").?);
+ try testing.expectEqual("utf-8", Mime.prescanCharset("").?);
+
+ //
+ try testing.expectEqual(
+ "iso-8859-1",
+ Mime.prescanCharset("").?,
+ );
+
+ // No charset found
+ try testing.expectEqual(null, Mime.prescanCharset("Test"));
+ try testing.expectEqual(null, Mime.prescanCharset(""));
+ try testing.expectEqual(null, Mime.prescanCharset("no html here"));
+
+ // Self-closing meta without charset must not loop forever
+ try testing.expectEqual(null, Mime.prescanCharset(""));
+
+ // Charset after 1024 bytes should not be found
+ var long_html: [1100]u8 = undefined;
+ @memset(&long_html, ' ');
+ const suffix = "";
+ @memcpy(long_html[1050 .. 1050 + suffix.len], suffix);
+ try testing.expectEqual(null, Mime.prescanCharset(&long_html));
+}
diff --git a/src/browser/Page.zig b/src/browser/Page.zig
index e8df5ab0..f9f8bb61 100644
--- a/src/browser/Page.zig
+++ b/src/browser/Page.zig
@@ -63,6 +63,7 @@ const storage = @import("webapi/storage/storage.zig");
const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig");
const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind;
const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig");
+const MouseEvent = @import("webapi/event/MouseEvent.zig");
const HttpClient = @import("HttpClient.zig");
const ArenaPool = App.ArenaPool;
@@ -313,14 +314,16 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void
document._page = self;
if (comptime builtin.is_test == false) {
- // HTML test runner manually calls these as necessary
- try self.js.scheduler.add(session.browser, struct {
- fn runIdleTasks(ctx: *anyopaque) !?u32 {
- const b: *@import("Browser.zig") = @ptrCast(@alignCast(ctx));
- b.runIdleTasks();
- return 200;
- }
- }.runIdleTasks, 200, .{ .name = "page.runIdleTasks", .low_priority = true });
+ if (parent == null) {
+ // HTML test runner manually calls these as necessary
+ try self.js.scheduler.add(session.browser, struct {
+ fn runIdleTasks(ctx: *anyopaque) !?u32 {
+ const b: *@import("Browser.zig") = @ptrCast(@alignCast(ctx));
+ b.runIdleTasks();
+ return 200;
+ }
+ }.runIdleTasks, 200, .{ .name = "page.runIdleTasks", .low_priority = true });
+ }
}
}
@@ -414,16 +417,9 @@ pub fn isSameOrigin(self: *const Page, url: [:0]const u8) !bool {
return std.mem.startsWith(u8, url, current_origin);
}
-/// Look up a blob URL in this page's registry, walking up the parent chain.
+/// Look up a blob URL in this page's registry.
pub fn lookupBlobUrl(self: *Page, url: []const u8) ?*Blob {
- var current: ?*Page = self;
- while (current) |page| {
- if (page._blob_urls.get(url)) |blob| {
- return blob;
- }
- current = page.parent;
- }
- return null;
+ return self._blob_urls.get(url);
}
pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !void {
@@ -464,7 +460,14 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
// Content injection
if (is_blob) {
- const blob = self.lookupBlobUrl(request_url) orelse {
+ // For navigation, walk up the parent chain to find blob URLs
+ // (e.g., parent creates blob URL and sets iframe.src to it)
+ const blob = blk: {
+ var current: ?*Page = self.parent;
+ while (current) |page| {
+ if (page._blob_urls.get(request_url)) |b| break :blk b;
+ current = page.parent;
+ }
log.warn(.js, "invalid blob", .{ .url = request_url });
return error.BlobNotFound;
};
@@ -716,11 +719,14 @@ pub fn scriptsCompletedLoading(self: *Page) void {
}
pub fn iframeCompletedLoading(self: *Page, iframe: *IFrame) void {
- blk: {
- var ls: JS.Local.Scope = undefined;
- self.js.localScope(&ls);
- defer ls.deinit();
+ var ls: JS.Local.Scope = undefined;
+ self.js.localScope(&ls);
+ defer ls.deinit();
+ const entered = self.js.enter(&ls.handle_scope);
+ defer entered.exit();
+
+ blk: {
const event = Event.initTrusted(comptime .wrap("load"), .{}, self) catch |err| {
log.err(.page, "iframe event init", .{ .err = err, .url = iframe._src });
break :blk;
@@ -729,6 +735,7 @@ pub fn iframeCompletedLoading(self: *Page, iframe: *IFrame) void {
log.warn(.js, "iframe onload", .{ .err = err, .url = iframe._src });
};
}
+
self.pendingLoadCompleted();
}
@@ -855,13 +862,25 @@ fn pageDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
if (self._parse_state == .pre) {
// we lazily do this, because we might need the first chunk of data
// to sniff the content type
- const mime: Mime = blk: {
+ var mime: Mime = blk: {
if (transfer.response_header.?.contentType()) |ct| {
break :blk try Mime.parse(ct);
}
break :blk Mime.sniff(data);
} orelse .unknown;
+ // If the HTTP Content-Type header didn't specify a charset and this is HTML,
+ // prescan the first 1024 bytes for a declaration.
+ if (mime.content_type == .text_html and mime.is_default_charset) {
+ if (Mime.prescanCharset(data)) |charset| {
+ if (charset.len <= 40) {
+ @memcpy(mime.charset[0..charset.len], charset);
+ mime.charset[charset.len] = 0;
+ mime.charset_len = charset.len;
+ }
+ }
+ }
+
if (comptime IS_DEBUG) {
log.debug(.page, "navigate first chunk", .{
.content_type = mime.content_type,
@@ -3273,14 +3292,14 @@ pub fn triggerMouseClick(self: *Page, x: f64, y: f64) !void {
.type = self._type,
});
}
- const event = (try @import("webapi/event/MouseEvent.zig").init("click", .{
+ const mouse_event: *MouseEvent = try .initTrusted(comptime .wrap("click"), .{
.bubbles = true,
.cancelable = true,
.composed = true,
.clientX = x,
.clientY = y,
- }, self)).asEvent();
- try self._event_manager.dispatch(target.asEventTarget(), event);
+ }, self);
+ try self._event_manager.dispatch(target.asEventTarget(), mouse_event.asEvent());
}
// callback when the "click" event reaches the pages.
@@ -3524,13 +3543,16 @@ fn asUint(comptime string: anytype) std.meta.Int(
const testing = @import("../testing.zig");
test "WebApi: Page" {
- const filter: testing.LogFilter = .init(.http);
+ const filter: testing.LogFilter = .init(&.{ .http, .js });
defer filter.deinit();
try testing.htmlRunner("page", .{});
}
test "WebApi: Frames" {
+ const filter: testing.LogFilter = .init(&.{.js});
+ defer filter.deinit();
+
try testing.htmlRunner("frames", .{});
}
diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig
index 2baeef8d..751ba58b 100644
--- a/src/browser/ScriptManager.zig
+++ b/src/browser/ScriptManager.zig
@@ -63,9 +63,6 @@ shutdown: bool = false,
client: *HttpClient,
allocator: Allocator,
-buffer_pool: BufferPool,
-
-script_pool: std.heap.MemoryPool(Script),
// 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,
@@ -101,18 +98,14 @@ pub fn init(allocator: Allocator, http_client: *HttpClient, page: *Page) ScriptM
.imported_modules = .empty,
.client = http_client,
.static_scripts_done = false,
- .buffer_pool = BufferPool.init(allocator, 5),
.page_notified_of_completion = false,
- .script_pool = std.heap.MemoryPool(Script).init(allocator),
};
}
pub fn deinit(self: *ScriptManager) void {
- // necessary to free any buffers scripts may be referencing
+ // necessary to free any arenas scripts may be referencing
self.reset();
- self.buffer_pool.deinit();
- self.script_pool.deinit();
self.imported_modules.deinit(self.allocator);
// we don't deinit self.importmap b/c we use the page's arena for its
// allocations.
@@ -121,7 +114,10 @@ pub fn deinit(self: *ScriptManager) void {
pub fn reset(self: *ScriptManager) void {
var it = self.imported_modules.valueIterator();
while (it.next()) |value_ptr| {
- self.buffer_pool.release(value_ptr.buffer);
+ switch (value_ptr.state) {
+ .done => |script| script.deinit(),
+ else => {},
+ }
}
self.imported_modules.clearRetainingCapacity();
@@ -138,13 +134,13 @@ pub fn reset(self: *ScriptManager) void {
fn clearList(list: *std.DoublyLinkedList) void {
while (list.popFirst()) |n| {
const script: *Script = @fieldParentPtr("node", n);
- script.deinit(true);
+ script.deinit();
}
}
-pub fn getHeaders(self: *ScriptManager, url: [:0]const u8) !net_http.Headers {
+fn getHeaders(self: *ScriptManager, arena: Allocator, url: [:0]const u8) !net_http.Headers {
var headers = try self.client.newHeaders();
- try self.page.headersForRequest(self.page.arena, url, &headers);
+ try self.page.headersForRequest(arena, url, &headers);
return headers;
}
@@ -191,19 +187,26 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
return;
};
+ var handover = false;
const page = self.page;
+
+ const arena = try page.getArena(.{ .debug = "addFromElement" });
+ errdefer if (!handover) {
+ page.releaseArena(arena);
+ };
+
var source: Script.Source = undefined;
var remote_url: ?[:0]const u8 = null;
const base_url = page.base();
if (element.getAttributeSafe(comptime .wrap("src"))) |src| {
- if (try parseDataURI(page.arena, src)) |data_uri| {
+ if (try parseDataURI(arena, src)) |data_uri| {
source = .{ .@"inline" = data_uri };
} else {
- remote_url = try URL.resolve(page.arena, base_url, src, .{});
+ remote_url = try URL.resolve(arena, base_url, src, .{});
source = .{ .remote = .{} };
}
} else {
- var buf = std.Io.Writer.Allocating.init(page.arena);
+ var buf = std.Io.Writer.Allocating.init(arena);
try element.asNode().getChildTextContent(&buf.writer);
try buf.writer.writeByte(0);
const data = buf.written();
@@ -211,6 +214,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
if (inline_source.len == 0) {
// we haven't set script_element._executed = true yet, which is good.
// If content is appended to the script, we will execute it then.
+ page.releaseArena(arena);
return;
}
source = .{ .@"inline" = inline_source };
@@ -218,15 +222,13 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
// Only set _executed (already-started) when we actually have content to execute
script_element._executed = true;
-
- const script = try self.script_pool.create();
- errdefer self.script_pool.destroy(script);
-
const is_inline = source == .@"inline";
+ const script = try arena.create(Script);
script.* = .{
.kind = kind,
.node = .{},
+ .arena = arena,
.manager = self,
.source = source,
.script_element = script_element,
@@ -270,7 +272,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
if (is_blocking == false) {
self.scriptList(script).remove(&script.node);
}
- script.deinit(true);
+ // Let the outer errdefer handle releasing the arena if client.request fails
}
try self.client.request(.{
@@ -278,7 +280,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
.ctx = script,
.method = .GET,
.frame_id = page._frame_id,
- .headers = try self.getHeaders(url),
+ .headers = try self.getHeaders(arena, url),
.blocking = is_blocking,
.cookie_jar = &page._session.cookie_jar,
.resource_type = .script,
@@ -289,6 +291,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
.done_callback = Script.doneCallback,
.error_callback = Script.errorCallback,
});
+ handover = true;
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
@@ -318,7 +321,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
}
if (script.status == 0) {
// an error (that we already logged)
- script.deinit(true);
+ script.deinit();
return;
}
@@ -327,7 +330,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
self.is_evaluating = true;
defer {
self.is_evaluating = was_evaluating;
- script.deinit(true);
+ script.deinit();
}
return script.eval(page);
}
@@ -359,11 +362,14 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
}
errdefer _ = self.imported_modules.remove(url);
- const script = try self.script_pool.create();
- errdefer self.script_pool.destroy(script);
+ const page = self.page;
+ const arena = try page.getArena(.{ .debug = "preloadImport" });
+ errdefer page.releaseArena(arena);
+ const script = try arena.create(Script);
script.* = .{
.kind = .module,
+ .arena = arena,
.url = url,
.node = .{},
.manager = self,
@@ -373,11 +379,7 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
.mode = .import,
};
- gop.value_ptr.* = ImportedModule{
- .manager = self,
- };
-
- const page = self.page;
+ gop.value_ptr.* = ImportedModule{};
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
@@ -392,12 +394,18 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
});
}
- try self.client.request(.{
+ // 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(.{
.url = url,
.ctx = script,
.method = .GET,
.frame_id = page._frame_id,
- .headers = try self.getHeaders(url),
+ .headers = try self.getHeaders(arena, url),
.cookie_jar = &page._session.cookie_jar,
.resource_type = .script,
.notification = page._session.notification,
@@ -406,13 +414,10 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
.data_callback = Script.dataCallback,
.done_callback = Script.doneCallback,
.error_callback = Script.errorCallback,
- });
-
- // 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);
+ }) catch |err| {
+ self.async_scripts.remove(&script.node);
+ return err;
+ };
}
pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource {
@@ -433,12 +438,12 @@ pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource {
_ = try client.tick(200);
continue;
},
- .done => {
+ .done => |script| {
var shared = false;
const buffer = entry.value_ptr.buffer;
const waiters = entry.value_ptr.waiters;
- if (waiters == 0) {
+ if (waiters == 1) {
self.imported_modules.removeByPtr(entry.key_ptr);
} else {
shared = true;
@@ -447,7 +452,7 @@ pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource {
return .{
.buffer = buffer,
.shared = shared,
- .buffer_pool = &self.buffer_pool,
+ .script = script,
};
},
.err => return error.Failed,
@@ -456,11 +461,14 @@ pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource {
}
pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.Callback, cb_data: *anyopaque, referrer: []const u8) !void {
- const script = try self.script_pool.create();
- errdefer self.script_pool.destroy(script);
+ const page = self.page;
+ const arena = try page.getArena(.{ .debug = "getAsyncImport" });
+ errdefer page.releaseArena(arena);
+ const script = try arena.create(Script);
script.* = .{
.kind = .module,
+ .arena = arena,
.url = url,
.node = .{},
.manager = self,
@@ -473,7 +481,6 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C
} },
};
- const page = self.page;
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
@@ -496,11 +503,12 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C
self.is_evaluating = true;
defer self.is_evaluating = was_evaluating;
- try self.client.request(.{
+ self.async_scripts.append(&script.node);
+ self.client.request(.{
.url = url,
.method = .GET,
.frame_id = page._frame_id,
- .headers = try self.getHeaders(url),
+ .headers = try self.getHeaders(arena, url),
.ctx = script,
.resource_type = .script,
.cookie_jar = &page._session.cookie_jar,
@@ -510,9 +518,10 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C
.data_callback = Script.dataCallback,
.done_callback = Script.doneCallback,
.error_callback = Script.errorCallback,
- });
-
- self.async_scripts.append(&script.node);
+ }) 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
@@ -537,18 +546,18 @@ fn evaluate(self: *ScriptManager) void {
var script: *Script = @fieldParentPtr("node", n);
switch (script.mode) {
.async => {
- defer script.deinit(true);
+ defer script.deinit();
script.eval(page);
},
.import_async => |ia| {
- defer script.deinit(false);
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,
- .buffer_pool = &self.buffer_pool,
});
}
},
@@ -574,7 +583,7 @@ fn evaluate(self: *ScriptManager) void {
}
defer {
_ = self.defer_scripts.popFirst();
- script.deinit(true);
+ script.deinit();
}
script.eval(page);
}
@@ -625,11 +634,12 @@ fn parseImportmap(self: *ScriptManager, script: *const Script) !void {
}
pub const Script = struct {
- complete: bool,
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,
@@ -680,11 +690,8 @@ pub const Script = struct {
import_async: ImportAsync,
};
- fn deinit(self: *Script, comptime release_buffer: bool) void {
- if ((comptime release_buffer) and self.source == .remote) {
- self.manager.buffer_pool.release(self.source.remote);
- }
- self.manager.script_pool.destroy(self);
+ fn deinit(self: *Script) void {
+ self.manager.page.releaseArena(self.arena);
}
fn startCallback(transfer: *HttpClient.Transfer) !void {
@@ -750,9 +757,9 @@ pub const Script = struct {
}
lp.assert(self.source.remote.capacity == 0, "ScriptManager.Header buffer", .{ .capacity = self.source.remote.capacity });
- var buffer = self.manager.buffer_pool.get();
+ var buffer: std.ArrayList(u8) = .empty;
if (transfer.getContentLength()) |cl| {
- try buffer.ensureTotalCapacity(self.manager.allocator, cl);
+ try buffer.ensureTotalCapacity(self.arena, cl);
}
self.source = .{ .remote = buffer };
return true;
@@ -766,7 +773,7 @@ pub const Script = struct {
};
}
fn _dataCallback(self: *Script, _: *HttpClient.Transfer, data: []const u8) !void {
- try self.source.remote.appendSlice(self.manager.allocator, data);
+ try self.source.remote.appendSlice(self.arena, data);
}
fn doneCallback(ctx: *anyopaque) !void {
@@ -783,9 +790,8 @@ pub const Script = struct {
} else if (self.mode == .import) {
manager.async_scripts.remove(&self.node);
const entry = manager.imported_modules.getPtr(self.url).?;
- entry.state = .done;
+ entry.state = .{ .done = self };
entry.buffer = self.source.remote;
- self.deinit(false);
}
manager.evaluate();
}
@@ -811,7 +817,7 @@ pub const Script = struct {
const manager = self.manager;
manager.scriptList(self).remove(&self.node);
if (manager.shutdown) {
- self.deinit(true);
+ self.deinit();
return;
}
@@ -823,7 +829,7 @@ pub const Script = struct {
},
else => {},
}
- self.deinit(true);
+ self.deinit();
manager.evaluate();
}
@@ -951,76 +957,6 @@ pub const Script = struct {
}
};
-const BufferPool = struct {
- count: usize,
- available: List = .{},
- allocator: Allocator,
- max_concurrent_transfers: u8,
- mem_pool: std.heap.MemoryPool(Container),
-
- const List = std.SinglyLinkedList;
-
- const Container = struct {
- node: List.Node,
- buf: std.ArrayList(u8),
- };
-
- fn init(allocator: Allocator, max_concurrent_transfers: u8) BufferPool {
- return .{
- .available = .{},
- .count = 0,
- .allocator = allocator,
- .max_concurrent_transfers = max_concurrent_transfers,
- .mem_pool = std.heap.MemoryPool(Container).init(allocator),
- };
- }
-
- fn deinit(self: *BufferPool) void {
- const allocator = self.allocator;
-
- var node = self.available.first;
- while (node) |n| {
- const container: *Container = @fieldParentPtr("node", n);
- container.buf.deinit(allocator);
- node = n.next;
- }
- self.mem_pool.deinit();
- }
-
- fn get(self: *BufferPool) std.ArrayList(u8) {
- const node = self.available.popFirst() orelse {
- // return a new buffer
- return .{};
- };
-
- self.count -= 1;
- const container: *Container = @fieldParentPtr("node", node);
- defer self.mem_pool.destroy(container);
- return container.buf;
- }
-
- fn release(self: *BufferPool, buffer: ArrayList(u8)) void {
- // create mutable copy
- var b = buffer;
-
- if (self.count == self.max_concurrent_transfers) {
- b.deinit(self.allocator);
- return;
- }
-
- const container = self.mem_pool.create() catch |err| {
- b.deinit(self.allocator);
- log.err(.http, "SM BufferPool release", .{ .err = err });
- return;
- };
-
- b.clearRetainingCapacity();
- container.* = .{ .buf = b, .node = .{} };
- self.count += 1;
- self.available.prepend(&container.node);
- }
-};
-
const ImportAsync = struct {
data: *anyopaque,
callback: ImportAsync.Callback,
@@ -1030,12 +966,12 @@ const ImportAsync = struct {
pub const ModuleSource = struct {
shared: bool,
- buffer_pool: *BufferPool,
+ script: *Script,
buffer: std.ArrayList(u8),
pub fn deinit(self: *ModuleSource) void {
if (self.shared == false) {
- self.buffer_pool.release(self.buffer);
+ self.script.deinit();
}
}
@@ -1045,15 +981,14 @@ pub const ModuleSource = struct {
};
const ImportedModule = struct {
- manager: *ScriptManager,
+ waiters: u16 = 1,
state: State = .loading,
buffer: std.ArrayList(u8) = .{},
- waiters: u16 = 1,
- const State = enum {
+ const State = union(enum) {
err,
- done,
loading,
+ done: *Script,
};
};
diff --git a/src/browser/Session.zig b/src/browser/Session.zig
index 404a8bc4..73b6b26e 100644
--- a/src/browser/Session.zig
+++ b/src/browser/Session.zig
@@ -401,7 +401,7 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
// scheduler.run could trigger new http transfers, so do not
// store http_client.active BEFORE this call and then use
// it AFTER.
- const ms_to_next_task = try browser.runMacrotasks();
+ try browser.runMacrotasks();
// Each call to this runs scheduled load events.
try page.dispatchLoad();
@@ -423,16 +423,16 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
std.debug.assert(http_client.intercepted == 0);
}
- var ms: u64 = ms_to_next_task orelse blk: {
- if (wait_ms - ms_remaining < 100) {
- if (comptime builtin.is_test) {
- return .done;
- }
- // Look, we want to exit ASAP, but we don't want
- // to exit so fast that we've run none of the
- // background jobs.
- break :blk 50;
- }
+ var ms = blk: {
+ // if (wait_ms - ms_remaining < 100) {
+ // if (comptime builtin.is_test) {
+ // return .done;
+ // }
+ // // Look, we want to exit ASAP, but we don't want
+ // // to exit so fast that we've run none of the
+ // // background jobs.
+ // break :blk 50;
+ // }
if (browser.hasBackgroundTasks()) {
// _we_ have nothing to run, but v8 is working on
@@ -441,9 +441,7 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
break :blk 20;
}
- // No http transfers, no cdp extra socket, no
- // scheduled tasks, we're done.
- return .done;
+ break :blk browser.msToNextMacrotask() orelse return .done;
};
if (ms > ms_remaining) {
@@ -470,9 +468,9 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
// We're here because we either have active HTTP
// connections, or exit_when_done == false (aka, there's
// an cdp_socket registered with the http client).
- // We should continue to run lowPriority tasks, so we
- // minimize how long we'll poll for network I/O.
- var ms_to_wait = @min(200, ms_to_next_task orelse 200);
+ // We should continue to run tasks, so we minimize how long
+ // we'll poll for network I/O.
+ var ms_to_wait = @min(200, browser.msToNextMacrotask() orelse 200);
if (ms_to_wait > 10 and browser.hasBackgroundTasks()) {
// if we have background tasks, we don't want to wait too
// long for a message from the client. We want to go back
diff --git a/src/browser/actions.zig b/src/browser/actions.zig
new file mode 100644
index 00000000..951f2b1e
--- /dev/null
+++ b/src/browser/actions.zig
@@ -0,0 +1,104 @@
+// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// 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 .
+
+const std = @import("std");
+const lp = @import("../lightpanda.zig");
+const DOMNode = @import("webapi/Node.zig");
+const Element = @import("webapi/Element.zig");
+const Event = @import("webapi/Event.zig");
+const MouseEvent = @import("webapi/event/MouseEvent.zig");
+const Page = @import("Page.zig");
+
+pub fn click(node: *DOMNode, page: *Page) !void {
+ const el = node.is(Element) orelse return error.InvalidNodeType;
+
+ const mouse_event: *MouseEvent = try .initTrusted(comptime .wrap("click"), .{
+ .bubbles = true,
+ .cancelable = true,
+ .composed = true,
+ .clientX = 0,
+ .clientY = 0,
+ }, page);
+
+ page._event_manager.dispatch(el.asEventTarget(), mouse_event.asEvent()) catch |err| {
+ lp.log.err(.app, "click failed", .{ .err = err });
+ return error.ActionFailed;
+ };
+}
+
+pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void {
+ const el = node.is(Element) orelse return error.InvalidNodeType;
+
+ if (el.is(Element.Html.Input)) |input| {
+ input.setValue(text, page) catch |err| {
+ lp.log.err(.app, "fill input failed", .{ .err = err });
+ return error.ActionFailed;
+ };
+ } else if (el.is(Element.Html.TextArea)) |textarea| {
+ textarea.setValue(text, page) catch |err| {
+ lp.log.err(.app, "fill textarea failed", .{ .err = err });
+ return error.ActionFailed;
+ };
+ } else if (el.is(Element.Html.Select)) |select| {
+ select.setValue(text, page) catch |err| {
+ lp.log.err(.app, "fill select failed", .{ .err = err });
+ return error.ActionFailed;
+ };
+ } else {
+ return error.InvalidNodeType;
+ }
+
+ const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, page);
+ page._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| {
+ lp.log.err(.app, "dispatch input event failed", .{ .err = err });
+ };
+
+ const change_evt: *Event = try .initTrusted(comptime .wrap("change"), .{ .bubbles = true }, page);
+ page._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| {
+ lp.log.err(.app, "dispatch change event failed", .{ .err = err });
+ };
+}
+
+pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void {
+ if (node) |n| {
+ const el = n.is(Element) orelse return error.InvalidNodeType;
+
+ if (x) |val| {
+ el.setScrollLeft(val, page) catch |err| {
+ lp.log.err(.app, "setScrollLeft failed", .{ .err = err });
+ return error.ActionFailed;
+ };
+ }
+ if (y) |val| {
+ el.setScrollTop(val, page) catch |err| {
+ lp.log.err(.app, "setScrollTop failed", .{ .err = err });
+ return error.ActionFailed;
+ };
+ }
+
+ const scroll_evt: *Event = try .initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, page);
+ page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch |err| {
+ lp.log.err(.app, "dispatch scroll event failed", .{ .err = err });
+ };
+ } else {
+ page.window.scrollTo(.{ .x = x orelse 0 }, y, page) catch |err| {
+ lp.log.err(.app, "scroll failed", .{ .err = err });
+ return error.ActionFailed;
+ };
+ }
+}
diff --git a/src/browser/interactive.zig b/src/browser/interactive.zig
index b3325e97..944205bd 100644
--- a/src/browser/interactive.zig
+++ b/src/browser/interactive.zig
@@ -259,17 +259,52 @@ pub fn classifyInteractivity(
return null;
}
-fn isInteractiveRole(role: []const u8) bool {
- const interactive_roles = [_][]const u8{
- "button", "link", "tab", "menuitem",
- "menuitemcheckbox", "menuitemradio", "switch", "checkbox",
- "radio", "slider", "spinbutton", "searchbox",
- "combobox", "option", "treeitem",
- };
- for (interactive_roles) |r| {
- if (std.ascii.eqlIgnoreCase(role, r)) return true;
- }
- return false;
+pub fn isInteractiveRole(role: []const u8) bool {
+ const MAX_LEN = "menuitemcheckbox".len;
+ if (role.len > MAX_LEN) return false;
+ var buf: [MAX_LEN]u8 = undefined;
+ const lowered = std.ascii.lowerString(&buf, role);
+ const interactive_roles = std.StaticStringMap(void).initComptime(.{
+ .{ "button", {} },
+ .{ "checkbox", {} },
+ .{ "combobox", {} },
+ .{ "iframe", {} },
+ .{ "link", {} },
+ .{ "listbox", {} },
+ .{ "menuitem", {} },
+ .{ "menuitemcheckbox", {} },
+ .{ "menuitemradio", {} },
+ .{ "option", {} },
+ .{ "radio", {} },
+ .{ "searchbox", {} },
+ .{ "slider", {} },
+ .{ "spinbutton", {} },
+ .{ "switch", {} },
+ .{ "tab", {} },
+ .{ "textbox", {} },
+ .{ "treeitem", {} },
+ });
+ return interactive_roles.has(lowered);
+}
+
+pub fn isContentRole(role: []const u8) bool {
+ const MAX_LEN = "columnheader".len;
+ if (role.len > MAX_LEN) return false;
+ var buf: [MAX_LEN]u8 = undefined;
+ const lowered = std.ascii.lowerString(&buf, role);
+ const content_roles = std.StaticStringMap(void).initComptime(.{
+ .{ "article", {} },
+ .{ "cell", {} },
+ .{ "columnheader", {} },
+ .{ "gridcell", {} },
+ .{ "heading", {} },
+ .{ "listitem", {} },
+ .{ "main", {} },
+ .{ "navigation", {} },
+ .{ "region", {} },
+ .{ "rowheader", {} },
+ });
+ return content_roles.has(lowered);
}
fn getRole(el: *Element) ?[]const u8 {
diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig
index 297d4ac1..9ecd65b2 100644
--- a/src/browser/js/Caller.zig
+++ b/src/browser/js/Caller.zig
@@ -40,8 +40,8 @@ prev_context: *Context,
// Takes the raw v8 isolate and extracts the context from it.
pub fn init(self: *Caller, v8_isolate: *v8.Isolate) void {
- const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate).?;
- initWithContext(self, Context.fromC(v8_context), v8_context);
+ const ctx, const v8_context = Context.fromIsolate(.{ .handle = v8_isolate });
+ initWithContext(self, ctx, v8_context);
}
fn initWithContext(self: *Caller, ctx: *Context, v8_context: *const v8.Context) void {
@@ -537,9 +537,7 @@ pub const Function = struct {
pub fn call(comptime T: type, info_handle: *const v8.FunctionCallbackInfo, func: anytype, comptime opts: Opts) void {
const v8_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(info_handle).?;
- const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate).?;
-
- const ctx = Context.fromC(v8_context);
+ const ctx, const v8_context = Context.fromIsolate(.{ .handle = v8_isolate });
const info = FunctionCallbackInfo{ .handle = info_handle };
var hs: js.HandleScope = undefined;
diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig
index 70af9d24..da7362aa 100644
--- a/src/browser/js/Context.zig
+++ b/src/browser/js/Context.zig
@@ -119,12 +119,22 @@ const ModuleEntry = struct {
resolver_promise: ?js.Promise.Global = null,
};
-pub fn fromC(c_context: *const v8.Context) *Context {
+pub fn fromC(c_context: *const v8.Context) ?*Context {
return @ptrCast(@alignCast(v8.v8__Context__GetAlignedPointerFromEmbedderData(c_context, 1)));
}
-pub fn fromIsolate(isolate: js.Isolate) *Context {
- return fromC(v8.v8__Isolate__GetCurrentContext(isolate.handle).?);
+/// Returns the Context and v8::Context for the given isolate.
+/// If the current context is from a destroyed Context (e.g., navigated-away iframe),
+/// falls back to the incumbent context (the calling context).
+pub fn fromIsolate(isolate: js.Isolate) struct { *Context, *const v8.Context } {
+ const v8_context = v8.v8__Isolate__GetCurrentContext(isolate.handle).?;
+ if (fromC(v8_context)) |ctx| {
+ return .{ ctx, v8_context };
+ }
+ // The current context's Context struct has been freed (e.g., iframe navigated away).
+ // Fall back to the incumbent context (the calling context).
+ const v8_incumbent = v8.v8__Isolate__GetIncumbentContext(isolate.handle).?;
+ return .{ fromC(v8_incumbent).?, v8_incumbent };
}
pub fn deinit(self: *Context) void {
@@ -155,6 +165,11 @@ pub fn deinit(self: *Context) void {
self.session.releaseOrigin(self.origin);
+ // Clear the embedder data so that if V8 keeps this context alive
+ // (because objects created in it are still referenced), we don't
+ // have a dangling pointer to our freed Context struct.
+ v8.v8__Context__SetAlignedPointerInEmbedderData(entered.handle, 1, null);
+
v8.v8__Global__Reset(&self.handle);
env.isolate.notifyContextDisposed();
// There can be other tasks associated with this context that we need to
@@ -167,12 +182,11 @@ pub fn setOrigin(self: *Context, key: ?[]const u8) !void {
const env = self.env;
const isolate = env.isolate;
+ lp.assert(self.origin.rc == 1, "Ref opaque origin", .{ .rc = self.origin.rc });
+
const origin = try self.session.getOrCreateOrigin(key);
errdefer self.session.releaseOrigin(origin);
-
- try self.origin.transferTo(origin);
- lp.assert(self.origin.rc == 1, "Ref opaque origin", .{ .rc = self.origin.rc });
- self.origin.deinit(env.app);
+ try origin.takeover(self.origin);
self.origin = origin;
@@ -255,6 +269,10 @@ pub fn toLocal(self: *Context, global: anytype) js.Local.ToLocalReturnType(@Type
return l.toLocal(global);
}
+pub fn getIncumbent(self: *Context) *Page {
+ return fromC(v8.v8__Isolate__GetIncumbentContext(self.env.isolate.handle).?).?.page;
+}
+
pub fn stringToPersistedFunction(
self: *Context,
function_body: []const u8,
@@ -306,15 +324,15 @@ pub fn module(self: *Context, comptime want_result: bool, local: *const js.Local
}
const owned_url = try arena.dupeZ(u8, url);
+ if (cacheable and !gop.found_existing) {
+ gop.key_ptr.* = owned_url;
+ }
const m = try compileModule(local, src, owned_url);
if (cacheable) {
// compileModule is synchronous - nothing can modify the cache during compilation
lp.assert(gop.value_ptr.module == null, "Context.module has module", .{});
gop.value_ptr.module = try m.persist();
- if (!gop.found_existing) {
- gop.key_ptr.* = owned_url;
- }
}
break :blk .{ m, owned_url };
@@ -476,7 +494,7 @@ fn resolveModuleCallback(
) callconv(.c) ?*const v8.Module {
_ = import_attributes;
- const self = fromC(c_context.?);
+ const self = fromC(c_context.?).?;
const local = js.Local{
.ctx = self,
.handle = c_context.?,
@@ -509,7 +527,7 @@ pub fn dynamicModuleCallback(
_ = host_defined_options;
_ = import_attrs;
- const self = fromC(c_context.?);
+ const self = fromC(c_context.?).?;
const local = js.Local{
.ctx = self,
.handle = c_context.?,
@@ -556,7 +574,7 @@ pub fn dynamicModuleCallback(
pub fn metaObjectCallback(c_context: ?*v8.Context, c_module: ?*v8.Module, c_meta: ?*v8.Value) callconv(.c) void {
// @HandleScope implement this without a fat context/local..
- const self = fromC(c_context.?);
+ const self = fromC(c_context.?).?;
var local = js.Local{
.ctx = self,
.handle = c_context.?,
diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig
index ba2e3e5a..d1aed2cc 100644
--- a/src/browser/js/Env.zig
+++ b/src/browser/js/Env.zig
@@ -382,8 +382,7 @@ pub fn runMicrotasks(self: *Env) void {
}
}
-pub fn runMacrotasks(self: *Env) !?u64 {
- var ms_to_next_task: ?u64 = null;
+pub fn runMacrotasks(self: *Env) !void {
for (self.contexts[0..self.context_count]) |ctx| {
if (comptime builtin.is_test == false) {
// I hate this comptime check as much as you do. But we have tests
@@ -398,13 +397,17 @@ pub fn runMacrotasks(self: *Env) !?u64 {
var hs: js.HandleScope = undefined;
const entered = ctx.enter(&hs);
defer entered.exit();
-
- const ms = (try ctx.scheduler.run()) orelse continue;
- if (ms_to_next_task == null or ms < ms_to_next_task.?) {
- ms_to_next_task = ms;
- }
+ try ctx.scheduler.run();
}
- return ms_to_next_task;
+}
+
+pub fn msToNextMacrotask(self: *Env) ?u64 {
+ var next_task: u64 = std.math.maxInt(u64);
+ for (self.contexts[0..self.context_count]) |ctx| {
+ const candidate = ctx.scheduler.msToNextHigh() orelse continue;
+ next_task = @min(candidate, next_task);
+ }
+ return if (next_task == std.math.maxInt(u64)) null else next_task;
}
pub fn pumpMessageLoop(self: *const Env) void {
@@ -492,20 +495,25 @@ pub fn terminate(self: *const Env) void {
}
fn promiseRejectCallback(message_handle: v8.PromiseRejectMessage) callconv(.c) void {
+ const promise_event = v8.v8__PromiseRejectMessage__GetEvent(&message_handle);
+ if (promise_event != v8.kPromiseRejectWithNoHandler and promise_event != v8.kPromiseHandlerAddedAfterReject) {
+ return;
+ }
+
const promise_handle = v8.v8__PromiseRejectMessage__GetPromise(&message_handle).?;
const v8_isolate = v8.v8__Object__GetIsolate(@ptrCast(promise_handle)).?;
- const js_isolate = js.Isolate{ .handle = v8_isolate };
- const ctx = Context.fromIsolate(js_isolate);
+ const isolate = js.Isolate{ .handle = v8_isolate };
+ const ctx, const v8_context = Context.fromIsolate(isolate);
const local = js.Local{
.ctx = ctx,
- .isolate = js_isolate,
- .handle = v8.v8__Isolate__GetCurrentContext(v8_isolate).?,
+ .isolate = isolate,
+ .handle = v8_context,
.call_arena = ctx.call_arena,
};
const page = ctx.page;
- page.window.unhandledPromiseRejection(.{
+ page.window.unhandledPromiseRejection(promise_event == v8.kPromiseRejectWithNoHandler, .{
.local = &local,
.handle = &message_handle,
}, page) catch |err| {
diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig
index a45b35df..f913dafd 100644
--- a/src/browser/js/Local.zig
+++ b/src/browser/js/Local.zig
@@ -1212,6 +1212,12 @@ pub fn rejectPromise(self: *const Local, value: anytype) !js.Promise {
return resolver.promise();
}
+pub fn rejectErrorPromise(self: *const Local, value: js.PromiseResolver.RejectError) !js.Promise {
+ var resolver = js.PromiseResolver.init(self);
+ resolver.rejectError("Local.rejectPromise", value);
+ return resolver.promise();
+}
+
pub fn resolvePromise(self: *const Local, value: anytype) !js.Promise {
var resolver = js.PromiseResolver.init(self);
resolver.resolve("Local.resolvePromise", value);
diff --git a/src/browser/js/Origin.zig b/src/browser/js/Origin.zig
index d7e74e4f..9dc5857b 100644
--- a/src/browser/js/Origin.zig
+++ b/src/browser/js/Origin.zig
@@ -68,6 +68,8 @@ temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
// if v8 hasn't called the finalizer directly itself.
finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty,
+taken_over: std.ArrayList(*Origin),
+
pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin {
const arena = try app.arena_pool.acquire();
errdefer app.arena_pool.release(arena);
@@ -86,14 +88,19 @@ pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin {
.rc = 1,
.arena = arena,
.key = owned_key,
- .globals = .empty,
.temps = .empty,
+ .globals = .empty,
+ .taken_over = .empty,
.security_token = token_global,
};
return self;
}
pub fn deinit(self: *Origin, app: *App) void {
+ for (self.taken_over.items) |o| {
+ o.deinit(app);
+ }
+
// Call finalizers before releasing anything
{
var it = self.finalizer_callbacks.valueIterator();
@@ -196,42 +203,44 @@ pub fn createFinalizerCallback(
return fc;
}
-pub fn transferTo(self: *Origin, dest: *Origin) !void {
- const arena = dest.arena;
+pub fn takeover(self: *Origin, original: *Origin) !void {
+ const arena = self.arena;
- try dest.globals.ensureUnusedCapacity(arena, self.globals.items.len);
- for (self.globals.items) |obj| {
- dest.globals.appendAssumeCapacity(obj);
+ try self.globals.ensureUnusedCapacity(arena, original.globals.items.len);
+ for (original.globals.items) |obj| {
+ self.globals.appendAssumeCapacity(obj);
}
- self.globals.clearRetainingCapacity();
+ original.globals.clearRetainingCapacity();
{
- try dest.temps.ensureUnusedCapacity(arena, self.temps.count());
- var it = self.temps.iterator();
+ try self.temps.ensureUnusedCapacity(arena, original.temps.count());
+ var it = original.temps.iterator();
while (it.next()) |kv| {
- try dest.temps.put(arena, kv.key_ptr.*, kv.value_ptr.*);
+ try self.temps.put(arena, kv.key_ptr.*, kv.value_ptr.*);
}
- self.temps.clearRetainingCapacity();
+ original.temps.clearRetainingCapacity();
}
{
- try dest.finalizer_callbacks.ensureUnusedCapacity(arena, self.finalizer_callbacks.count());
- var it = self.finalizer_callbacks.iterator();
+ try self.finalizer_callbacks.ensureUnusedCapacity(arena, original.finalizer_callbacks.count());
+ var it = original.finalizer_callbacks.iterator();
while (it.next()) |kv| {
- kv.value_ptr.*.origin = dest;
- try dest.finalizer_callbacks.put(arena, kv.key_ptr.*, kv.value_ptr.*);
+ kv.value_ptr.*.origin = self;
+ try self.finalizer_callbacks.put(arena, kv.key_ptr.*, kv.value_ptr.*);
}
- self.finalizer_callbacks.clearRetainingCapacity();
+ original.finalizer_callbacks.clearRetainingCapacity();
}
{
- try dest.identity_map.ensureUnusedCapacity(arena, self.identity_map.count());
- var it = self.identity_map.iterator();
+ try self.identity_map.ensureUnusedCapacity(arena, original.identity_map.count());
+ var it = original.identity_map.iterator();
while (it.next()) |kv| {
- try dest.identity_map.put(arena, kv.key_ptr.*, kv.value_ptr.*);
+ try self.identity_map.put(arena, kv.key_ptr.*, kv.value_ptr.*);
}
- self.identity_map.clearRetainingCapacity();
+ original.identity_map.clearRetainingCapacity();
}
+
+ try self.taken_over.append(self.arena, original);
}
// A type that has a finalizer can have its finalizer called one of two ways.
diff --git a/src/browser/js/PromiseResolver.zig b/src/browser/js/PromiseResolver.zig
index 67f04311..6386569a 100644
--- a/src/browser/js/PromiseResolver.zig
+++ b/src/browser/js/PromiseResolver.zig
@@ -18,7 +18,9 @@
const js = @import("js.zig");
const v8 = js.v8;
+
const log = @import("../../log.zig");
+const DOMException = @import("../webapi/DOMException.zig");
const PromiseResolver = @This();
@@ -63,14 +65,19 @@ pub fn reject(self: PromiseResolver, comptime source: []const u8, value: anytype
};
}
-const RejectError = union(enum) {
+pub const RejectError = union(enum) {
generic: []const u8,
type_error: []const u8,
+ dom_exception: anyerror,
};
pub fn rejectError(self: PromiseResolver, comptime source: []const u8, err: RejectError) void {
const handle = switch (err) {
.type_error => |str| self.local.isolate.createTypeError(str),
.generic => |str| self.local.isolate.createError(str),
+ .dom_exception => |exception| {
+ self.reject(source, DOMException.fromError(exception));
+ return;
+ },
};
self._reject(js.Value{ .handle = handle, .local = self.local }) catch |reject_err| {
log.err(.bug, "rejectError", .{ .source = source, .err = reject_err, .persistent = false });
diff --git a/src/browser/js/Scheduler.zig b/src/browser/js/Scheduler.zig
index e667a872..322351f3 100644
--- a/src/browser/js/Scheduler.zig
+++ b/src/browser/js/Scheduler.zig
@@ -74,9 +74,10 @@ pub fn add(self: *Scheduler, ctx: *anyopaque, cb: Callback, run_in_ms: u32, opts
});
}
-pub fn run(self: *Scheduler) !?u64 {
- _ = try self.runQueue(&self.low_priority);
- return self.runQueue(&self.high_priority);
+pub fn run(self: *Scheduler) !void {
+ const now = milliTimestamp(.monotonic);
+ try self.runQueue(&self.low_priority, now);
+ try self.runQueue(&self.high_priority, now);
}
pub fn hasReadyTasks(self: *Scheduler) bool {
@@ -84,16 +85,23 @@ pub fn hasReadyTasks(self: *Scheduler) bool {
return queueuHasReadyTask(&self.low_priority, now) or queueuHasReadyTask(&self.high_priority, now);
}
-fn runQueue(self: *Scheduler, queue: *Queue) !?u64 {
- if (queue.count() == 0) {
- return null;
- }
-
+pub fn msToNextHigh(self: *Scheduler) ?u64 {
+ const task = self.high_priority.peek() orelse return null;
const now = milliTimestamp(.monotonic);
+ if (task.run_at <= now) {
+ return 0;
+ }
+ return @intCast(task.run_at - now);
+}
+
+fn runQueue(self: *Scheduler, queue: *Queue, now: u64) !void {
+ if (queue.count() == 0) {
+ return;
+ }
while (queue.peek()) |*task_| {
if (task_.run_at > now) {
- return @intCast(task_.run_at - now);
+ return;
}
var task = queue.remove();
if (comptime IS_DEBUG) {
@@ -114,7 +122,7 @@ fn runQueue(self: *Scheduler, queue: *Queue) !?u64 {
try self.low_priority.add(task);
}
}
- return null;
+ return;
}
fn queueuHasReadyTask(queue: *Queue, now: u64) bool {
diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig
index cf6e999e..2f5d0bb9 100644
--- a/src/browser/js/bridge.zig
+++ b/src/browser/js/bridge.zig
@@ -725,6 +725,8 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/collections.zig"),
@import("../webapi/Console.zig"),
@import("../webapi/Crypto.zig"),
+ @import("../webapi/Permissions.zig"),
+ @import("../webapi/StorageManager.zig"),
@import("../webapi/CSS.zig"),
@import("../webapi/css/CSSRule.zig"),
@import("../webapi/css/CSSRuleList.zig"),
@@ -848,6 +850,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/event/FocusEvent.zig"),
@import("../webapi/event/WheelEvent.zig"),
@import("../webapi/event/TextEvent.zig"),
+ @import("../webapi/event/InputEvent.zig"),
@import("../webapi/event/PromiseRejectionEvent.zig"),
@import("../webapi/MessageChannel.zig"),
@import("../webapi/MessagePort.zig"),
diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig
index 8a4984a4..f4a0f874 100644
--- a/src/browser/markdown.zig
+++ b/src/browser/markdown.zig
@@ -124,352 +124,362 @@ fn hasVisibleContent(root: *Node) bool {
return false;
}
-fn ensureNewline(state: *State, writer: *std.Io.Writer) !void {
- if (!state.last_char_was_newline) {
- try writer.writeByte('\n');
- state.last_char_was_newline = true;
+const Context = struct {
+ state: State,
+ writer: *std.Io.Writer,
+ page: *Page,
+
+ fn ensureNewline(self: *Context) !void {
+ if (!self.state.last_char_was_newline) {
+ try self.writer.writeByte('\n');
+ self.state.last_char_was_newline = true;
+ }
}
-}
+
+ fn render(self: *Context, node: *Node) error{WriteFailed}!void {
+ switch (node._type) {
+ .document, .document_fragment => {
+ try self.renderChildren(node);
+ },
+ .element => |el| {
+ try self.renderElement(el);
+ },
+ .cdata => |cd| {
+ if (node.is(Node.CData.Text)) |_| {
+ var text = cd.getData().str();
+ if (self.state.pre_node) |pre| {
+ if (node.parentNode() == pre and node.nextSibling() == null) {
+ text = std.mem.trimRight(u8, text, " \t\r\n");
+ }
+ }
+ try self.renderText(text);
+ }
+ },
+ else => {},
+ }
+ }
+
+ fn renderChildren(self: *Context, parent: *Node) !void {
+ var it = parent.childrenIterator();
+ while (it.next()) |child| {
+ try self.render(child);
+ }
+ }
+
+ fn renderElement(self: *Context, el: *Element) !void {
+ const tag = el.getTag();
+
+ if (!isVisibleElement(el)) return;
+
+ // --- Opening Tag Logic ---
+
+ // Ensure block elements start on a new line (double newline for paragraphs etc)
+ if (tag.isBlock() and !self.state.in_table) {
+ try self.ensureNewline();
+ if (shouldAddSpacing(tag)) {
+ try self.writer.writeByte('\n');
+ }
+ } else if (tag == .li or tag == .tr) {
+ try self.ensureNewline();
+ }
+
+ // Prefixes
+ switch (tag) {
+ .h1 => try self.writer.writeAll("# "),
+ .h2 => try self.writer.writeAll("## "),
+ .h3 => try self.writer.writeAll("### "),
+ .h4 => try self.writer.writeAll("#### "),
+ .h5 => try self.writer.writeAll("##### "),
+ .h6 => try self.writer.writeAll("###### "),
+ .ul => {
+ if (self.state.list_depth < self.state.list_stack.len) {
+ self.state.list_stack[self.state.list_depth] = .{ .type = .unordered, .index = 0 };
+ self.state.list_depth += 1;
+ }
+ },
+ .ol => {
+ if (self.state.list_depth < self.state.list_stack.len) {
+ self.state.list_stack[self.state.list_depth] = .{ .type = .ordered, .index = 1 };
+ self.state.list_depth += 1;
+ }
+ },
+ .li => {
+ const indent = if (self.state.list_depth > 0) self.state.list_depth - 1 else 0;
+ for (0..indent) |_| try self.writer.writeAll(" ");
+
+ if (self.state.list_depth > 0 and self.state.list_stack[self.state.list_depth - 1].type == .ordered) {
+ const current_list = &self.state.list_stack[self.state.list_depth - 1];
+ try self.writer.print("{d}. ", .{current_list.index});
+ current_list.index += 1;
+ } else {
+ try self.writer.writeAll("- ");
+ }
+ self.state.last_char_was_newline = false;
+ },
+ .table => {
+ self.state.in_table = true;
+ self.state.table_row_index = 0;
+ self.state.table_col_count = 0;
+ },
+ .tr => {
+ self.state.table_col_count = 0;
+ try self.writer.writeByte('|');
+ },
+ .td, .th => {
+ // Note: leading pipe handled by previous cell closing or tr opening
+ self.state.last_char_was_newline = false;
+ try self.writer.writeByte(' ');
+ },
+ .blockquote => {
+ try self.writer.writeAll("> ");
+ self.state.last_char_was_newline = false;
+ },
+ .pre => {
+ try self.writer.writeAll("```\n");
+ self.state.pre_node = el.asNode();
+ self.state.last_char_was_newline = true;
+ },
+ .code => {
+ if (self.state.pre_node == null) {
+ try self.writer.writeByte('`');
+ self.state.in_code = true;
+ self.state.last_char_was_newline = false;
+ }
+ },
+ .b, .strong => {
+ try self.writer.writeAll("**");
+ self.state.last_char_was_newline = false;
+ },
+ .i, .em => {
+ try self.writer.writeAll("*");
+ self.state.last_char_was_newline = false;
+ },
+ .s, .del => {
+ try self.writer.writeAll("~~");
+ self.state.last_char_was_newline = false;
+ },
+ .hr => {
+ try self.writer.writeAll("---\n");
+ self.state.last_char_was_newline = true;
+ return;
+ },
+ .br => {
+ if (self.state.in_table) {
+ try self.writer.writeByte(' ');
+ } else {
+ try self.writer.writeByte('\n');
+ self.state.last_char_was_newline = true;
+ }
+ return;
+ },
+ .img => {
+ try self.writer.writeAll(";
+ if (el.getAttributeSafe(comptime .wrap("src"))) |src| {
+ const absolute_src = URL.resolve(self.page.call_arena, self.page.base(), src, .{ .encode = true }) catch src;
+ try self.writer.writeAll(absolute_src);
+ }
+ try self.writer.writeAll(")");
+ self.state.last_char_was_newline = false;
+ return;
+ },
+ .anchor => {
+ const has_content = hasVisibleContent(el.asNode());
+ const label = getAnchorLabel(el);
+ const href_raw = el.getAttributeSafe(comptime .wrap("href"));
+
+ if (!has_content and label == null and href_raw == null) return;
+
+ const has_block = hasBlockDescendant(el.asNode());
+ const href = if (href_raw) |h| URL.resolve(self.page.call_arena, self.page.base(), h, .{ .encode = true }) catch h else null;
+
+ if (has_block) {
+ try self.renderChildren(el.asNode());
+ if (href) |h| {
+ if (!self.state.last_char_was_newline) try self.writer.writeByte('\n');
+ try self.writer.writeAll("([](");
+ try self.writer.writeAll(h);
+ try self.writer.writeAll("))\n");
+ self.state.last_char_was_newline = true;
+ }
+ return;
+ }
+
+ if (isStandaloneAnchor(el)) {
+ if (!self.state.last_char_was_newline) try self.writer.writeByte('\n');
+ try self.writer.writeByte('[');
+ if (has_content) {
+ try self.renderChildren(el.asNode());
+ } else {
+ try self.writer.writeAll(label orelse "");
+ }
+ try self.writer.writeAll("](");
+ if (href) |h| {
+ try self.writer.writeAll(h);
+ }
+ try self.writer.writeAll(")\n");
+ self.state.last_char_was_newline = true;
+ return;
+ }
+
+ try self.writer.writeByte('[');
+ if (has_content) {
+ try self.renderChildren(el.asNode());
+ } else {
+ try self.writer.writeAll(label orelse "");
+ }
+ try self.writer.writeAll("](");
+ if (href) |h| {
+ try self.writer.writeAll(h);
+ }
+ try self.writer.writeByte(')');
+ self.state.last_char_was_newline = false;
+ return;
+ },
+ .input => {
+ const type_attr = el.getAttributeSafe(comptime .wrap("type")) orelse return;
+ if (std.ascii.eqlIgnoreCase(type_attr, "checkbox")) {
+ const checked = el.getAttributeSafe(comptime .wrap("checked")) != null;
+ try self.writer.writeAll(if (checked) "[x] " else "[ ] ");
+ self.state.last_char_was_newline = false;
+ }
+ return;
+ },
+ else => {},
+ }
+
+ // --- Render Children ---
+ try self.renderChildren(el.asNode());
+
+ // --- Closing Tag Logic ---
+
+ // Suffixes
+ switch (tag) {
+ .pre => {
+ if (!self.state.last_char_was_newline) {
+ try self.writer.writeByte('\n');
+ }
+ try self.writer.writeAll("```\n");
+ self.state.pre_node = null;
+ self.state.last_char_was_newline = true;
+ },
+ .code => {
+ if (self.state.pre_node == null) {
+ try self.writer.writeByte('`');
+ self.state.in_code = false;
+ self.state.last_char_was_newline = false;
+ }
+ },
+ .b, .strong => {
+ try self.writer.writeAll("**");
+ self.state.last_char_was_newline = false;
+ },
+ .i, .em => {
+ try self.writer.writeAll("*");
+ self.state.last_char_was_newline = false;
+ },
+ .s, .del => {
+ try self.writer.writeAll("~~");
+ self.state.last_char_was_newline = false;
+ },
+ .blockquote => {},
+ .ul, .ol => {
+ if (self.state.list_depth > 0) self.state.list_depth -= 1;
+ },
+ .table => {
+ self.state.in_table = false;
+ },
+ .tr => {
+ try self.writer.writeByte('\n');
+ if (self.state.table_row_index == 0) {
+ try self.writer.writeByte('|');
+ for (0..self.state.table_col_count) |_| {
+ try self.writer.writeAll("---|");
+ }
+ try self.writer.writeByte('\n');
+ }
+ self.state.table_row_index += 1;
+ self.state.last_char_was_newline = true;
+ },
+ .td, .th => {
+ try self.writer.writeAll(" |");
+ self.state.table_col_count += 1;
+ self.state.last_char_was_newline = false;
+ },
+ else => {},
+ }
+
+ // Post-block newlines
+ if (tag.isBlock() and !self.state.in_table) {
+ try self.ensureNewline();
+ }
+ }
+
+ fn renderText(self: *Context, text: []const u8) !void {
+ if (text.len == 0) return;
+
+ if (self.state.pre_node) |_| {
+ try self.writer.writeAll(text);
+ self.state.last_char_was_newline = text[text.len - 1] == '\n';
+ return;
+ }
+
+ // Check for pure whitespace
+ if (isAllWhitespace(text)) {
+ if (!self.state.last_char_was_newline) {
+ try self.writer.writeByte(' ');
+ }
+ return;
+ }
+
+ // Collapse whitespace
+ var it = std.mem.tokenizeAny(u8, text, " \t\n\r");
+ var first = true;
+ while (it.next()) |word| {
+ if (!first or (!self.state.last_char_was_newline and std.ascii.isWhitespace(text[0]))) {
+ try self.writer.writeByte(' ');
+ }
+
+ try self.escape(word);
+ self.state.last_char_was_newline = false;
+ first = false;
+ }
+
+ // Handle trailing whitespace from the original text
+ if (!first and !self.state.last_char_was_newline and std.ascii.isWhitespace(text[text.len - 1])) {
+ try self.writer.writeByte(' ');
+ }
+ }
+
+ fn escape(self: *Context, text: []const u8) !void {
+ for (text) |c| {
+ switch (c) {
+ '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '!', '|' => {
+ try self.writer.writeByte('\\');
+ try self.writer.writeByte(c);
+ },
+ else => try self.writer.writeByte(c),
+ }
+ }
+ }
+};
pub fn dump(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) !void {
_ = opts;
- var state = State{};
- try render(node, &state, writer, page);
- if (!state.last_char_was_newline) {
+ var ctx: Context = .{
+ .state = .{},
+ .writer = writer,
+ .page = page,
+ };
+ try ctx.render(node);
+ if (!ctx.state.last_char_was_newline) {
try writer.writeByte('\n');
}
}
-fn render(node: *Node, state: *State, writer: *std.Io.Writer, page: *Page) error{WriteFailed}!void {
- switch (node._type) {
- .document, .document_fragment => {
- try renderChildren(node, state, writer, page);
- },
- .element => |el| {
- try renderElement(el, state, writer, page);
- },
- .cdata => |cd| {
- if (node.is(Node.CData.Text)) |_| {
- var text = cd.getData().str();
- if (state.pre_node) |pre| {
- if (node.parentNode() == pre and node.nextSibling() == null) {
- text = std.mem.trimRight(u8, text, " \t\r\n");
- }
- }
- try renderText(text, state, writer);
- }
- },
- else => {},
- }
-}
-
-fn renderChildren(parent: *Node, state: *State, writer: *std.Io.Writer, page: *Page) !void {
- var it = parent.childrenIterator();
- while (it.next()) |child| {
- try render(child, state, writer, page);
- }
-}
-
-fn renderElement(el: *Element, state: *State, writer: *std.Io.Writer, page: *Page) !void {
- const tag = el.getTag();
-
- if (!isVisibleElement(el)) return;
-
- // --- Opening Tag Logic ---
-
- // Ensure block elements start on a new line (double newline for paragraphs etc)
- if (tag.isBlock() and !state.in_table) {
- try ensureNewline(state, writer);
- if (shouldAddSpacing(tag)) {
- try writer.writeByte('\n');
- }
- } else if (tag == .li or tag == .tr) {
- try ensureNewline(state, writer);
- }
-
- // Prefixes
- switch (tag) {
- .h1 => try writer.writeAll("# "),
- .h2 => try writer.writeAll("## "),
- .h3 => try writer.writeAll("### "),
- .h4 => try writer.writeAll("#### "),
- .h5 => try writer.writeAll("##### "),
- .h6 => try writer.writeAll("###### "),
- .ul => {
- if (state.list_depth < state.list_stack.len) {
- state.list_stack[state.list_depth] = .{ .type = .unordered, .index = 0 };
- state.list_depth += 1;
- }
- },
- .ol => {
- if (state.list_depth < state.list_stack.len) {
- state.list_stack[state.list_depth] = .{ .type = .ordered, .index = 1 };
- state.list_depth += 1;
- }
- },
- .li => {
- const indent = if (state.list_depth > 0) state.list_depth - 1 else 0;
- for (0..indent) |_| try writer.writeAll(" ");
-
- if (state.list_depth > 0 and state.list_stack[state.list_depth - 1].type == .ordered) {
- const current_list = &state.list_stack[state.list_depth - 1];
- try writer.print("{d}. ", .{current_list.index});
- current_list.index += 1;
- } else {
- try writer.writeAll("- ");
- }
- state.last_char_was_newline = false;
- },
- .table => {
- state.in_table = true;
- state.table_row_index = 0;
- state.table_col_count = 0;
- },
- .tr => {
- state.table_col_count = 0;
- try writer.writeByte('|');
- },
- .td, .th => {
- // Note: leading pipe handled by previous cell closing or tr opening
- state.last_char_was_newline = false;
- try writer.writeByte(' ');
- },
- .blockquote => {
- try writer.writeAll("> ");
- state.last_char_was_newline = false;
- },
- .pre => {
- try writer.writeAll("```\n");
- state.pre_node = el.asNode();
- state.last_char_was_newline = true;
- },
- .code => {
- if (state.pre_node == null) {
- try writer.writeByte('`');
- state.in_code = true;
- state.last_char_was_newline = false;
- }
- },
- .b, .strong => {
- try writer.writeAll("**");
- state.last_char_was_newline = false;
- },
- .i, .em => {
- try writer.writeAll("*");
- state.last_char_was_newline = false;
- },
- .s, .del => {
- try writer.writeAll("~~");
- state.last_char_was_newline = false;
- },
- .hr => {
- try writer.writeAll("---\n");
- state.last_char_was_newline = true;
- return;
- },
- .br => {
- if (state.in_table) {
- try writer.writeByte(' ');
- } else {
- try writer.writeByte('\n');
- state.last_char_was_newline = true;
- }
- return;
- },
- .img => {
- try writer.writeAll(";
- if (el.getAttributeSafe(comptime .wrap("src"))) |src| {
- const absolute_src = URL.resolve(page.call_arena, page.base(), src, .{ .encode = true }) catch src;
- try writer.writeAll(absolute_src);
- }
- try writer.writeAll(")");
- state.last_char_was_newline = false;
- return;
- },
- .anchor => {
- const has_content = hasVisibleContent(el.asNode());
- const label = getAnchorLabel(el);
- const href_raw = el.getAttributeSafe(comptime .wrap("href"));
-
- if (!has_content and label == null and href_raw == null) return;
-
- const has_block = hasBlockDescendant(el.asNode());
- const href = if (href_raw) |h| URL.resolve(page.call_arena, page.base(), h, .{ .encode = true }) catch h else null;
-
- if (has_block) {
- try renderChildren(el.asNode(), state, writer, page);
- if (href) |h| {
- if (!state.last_char_was_newline) try writer.writeByte('\n');
- try writer.writeAll("([](");
- try writer.writeAll(h);
- try writer.writeAll("))\n");
- state.last_char_was_newline = true;
- }
- return;
- }
-
- if (isStandaloneAnchor(el)) {
- if (!state.last_char_was_newline) try writer.writeByte('\n');
- try writer.writeByte('[');
- if (has_content) {
- try renderChildren(el.asNode(), state, writer, page);
- } else {
- try writer.writeAll(label orelse "");
- }
- try writer.writeAll("](");
- if (href) |h| {
- try writer.writeAll(h);
- }
- try writer.writeAll(")\n");
- state.last_char_was_newline = true;
- return;
- }
-
- try writer.writeByte('[');
- if (has_content) {
- try renderChildren(el.asNode(), state, writer, page);
- } else {
- try writer.writeAll(label orelse "");
- }
- try writer.writeAll("](");
- if (href) |h| {
- try writer.writeAll(h);
- }
- try writer.writeByte(')');
- state.last_char_was_newline = false;
- return;
- },
- .input => {
- const type_attr = el.getAttributeSafe(comptime .wrap("type")) orelse return;
- if (std.ascii.eqlIgnoreCase(type_attr, "checkbox")) {
- const checked = el.getAttributeSafe(comptime .wrap("checked")) != null;
- try writer.writeAll(if (checked) "[x] " else "[ ] ");
- state.last_char_was_newline = false;
- }
- return;
- },
- else => {},
- }
-
- // --- Render Children ---
- try renderChildren(el.asNode(), state, writer, page);
-
- // --- Closing Tag Logic ---
-
- // Suffixes
- switch (tag) {
- .pre => {
- if (!state.last_char_was_newline) {
- try writer.writeByte('\n');
- }
- try writer.writeAll("```\n");
- state.pre_node = null;
- state.last_char_was_newline = true;
- },
- .code => {
- if (state.pre_node == null) {
- try writer.writeByte('`');
- state.in_code = false;
- state.last_char_was_newline = false;
- }
- },
- .b, .strong => {
- try writer.writeAll("**");
- state.last_char_was_newline = false;
- },
- .i, .em => {
- try writer.writeAll("*");
- state.last_char_was_newline = false;
- },
- .s, .del => {
- try writer.writeAll("~~");
- state.last_char_was_newline = false;
- },
- .blockquote => {},
- .ul, .ol => {
- if (state.list_depth > 0) state.list_depth -= 1;
- },
- .table => {
- state.in_table = false;
- },
- .tr => {
- try writer.writeByte('\n');
- if (state.table_row_index == 0) {
- try writer.writeByte('|');
- for (0..state.table_col_count) |_| {
- try writer.writeAll("---|");
- }
- try writer.writeByte('\n');
- }
- state.table_row_index += 1;
- state.last_char_was_newline = true;
- },
- .td, .th => {
- try writer.writeAll(" |");
- state.table_col_count += 1;
- state.last_char_was_newline = false;
- },
- else => {},
- }
-
- // Post-block newlines
- if (tag.isBlock() and !state.in_table) {
- try ensureNewline(state, writer);
- }
-}
-
-fn renderText(text: []const u8, state: *State, writer: *std.Io.Writer) !void {
- if (text.len == 0) return;
-
- if (state.pre_node) |_| {
- try writer.writeAll(text);
- state.last_char_was_newline = text[text.len - 1] == '\n';
- return;
- }
-
- // Check for pure whitespace
- if (isAllWhitespace(text)) {
- if (!state.last_char_was_newline) {
- try writer.writeByte(' ');
- }
- return;
- }
-
- // Collapse whitespace
- var it = std.mem.tokenizeAny(u8, text, " \t\n\r");
- var first = true;
- while (it.next()) |word| {
- if (!first or (!state.last_char_was_newline and std.ascii.isWhitespace(text[0]))) {
- try writer.writeByte(' ');
- }
-
- try escapeMarkdown(writer, word);
- state.last_char_was_newline = false;
- first = false;
- }
-
- // Handle trailing whitespace from the original text
- if (!first and !state.last_char_was_newline and std.ascii.isWhitespace(text[text.len - 1])) {
- try writer.writeByte(' ');
- }
-}
-
-fn escapeMarkdown(writer: *std.Io.Writer, text: []const u8) !void {
- for (text) |c| {
- switch (c) {
- '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '!', '|' => {
- try writer.writeByte('\\');
- try writer.writeByte(c);
- },
- else => try writer.writeByte(c),
- }
- }
-}
-
fn testMarkdownHTML(html: []const u8, expected: []const u8) !void {
const testing = @import("../testing.zig");
const page = try testing.test_session.createPage();
diff --git a/src/browser/tests/document/query_selector.html b/src/browser/tests/document/query_selector.html
index b333069e..0837999e 100644
--- a/src/browser/tests/document/query_selector.html
+++ b/src/browser/tests/document/query_selector.html
@@ -24,11 +24,10 @@
diff --git a/src/browser/tests/domexception.html b/src/browser/tests/domexception.html
index 1ed43e8d..05bdc837 100644
--- a/src/browser/tests/domexception.html
+++ b/src/browser/tests/domexception.html
@@ -127,7 +127,7 @@
testing.withError((err) => {
testing.expectEqual(3, err.code);
- testing.expectEqual('Hierarchy Error', err.message);
+ testing.expectEqual('HierarchyRequestError', err.name);
testing.expectEqual(true, err instanceof DOMException);
testing.expectEqual(true, err instanceof Error);
}, () => link.appendChild(content));
diff --git a/src/browser/tests/element/attributes.html b/src/browser/tests/element/attributes.html
index 9b8c29d3..0929a3d9 100644
--- a/src/browser/tests/element/attributes.html
+++ b/src/browser/tests/element/attributes.html
@@ -36,7 +36,6 @@
testing.withError((err) => {
testing.expectEqual(8, err.code);
testing.expectEqual("NotFoundError", err.name);
- testing.expectEqual("Not Found", err.message);
}, () => el1.removeAttributeNode(script_id_node));
testing.expectEqual(an1, el1.removeAttributeNode(an1));
diff --git a/src/browser/tests/element/html/anchor.html b/src/browser/tests/element/html/anchor.html
index 0522163f..74bf486c 100644
--- a/src/browser/tests/element/html/anchor.html
+++ b/src/browser/tests/element/html/anchor.html
@@ -12,7 +12,7 @@
testing.expectEqual('', $('#a0').href);
testing.expectEqual(testing.BASE_URL + 'element/anchor1.html', $('#a1').href);
- testing.expectEqual(testing.ORIGIN + 'hello/world/anchor2.html', $('#a2').href);
+ testing.expectEqual(testing.ORIGIN + '/hello/world/anchor2.html', $('#a2').href);
testing.expectEqual('https://www.openmymind.net/Elixirs-With-Statement/', $('#a3').href);
testing.expectEqual(testing.BASE_URL + 'element/html/foo', $('#link').href);
diff --git a/src/browser/tests/element/html/form.html b/src/browser/tests/element/html/form.html
index f62cb221..d3249856 100644
--- a/src/browser/tests/element/html/form.html
+++ b/src/browser/tests/element/html/form.html
@@ -32,7 +32,7 @@
testing.expectEqual(testing.BASE_URL + 'element/html/hello', form.action)
form.action = '/hello';
- testing.expectEqual(testing.ORIGIN + 'hello', form.action)
+ testing.expectEqual(testing.ORIGIN + '/hello', form.action)
form.action = 'https://lightpanda.io/hello';
testing.expectEqual('https://lightpanda.io/hello', form.action)
@@ -343,3 +343,123 @@
testing.expectEqual('', form.elements['choice'].value)
}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/element/html/image.html b/src/browser/tests/element/html/image.html
index 92cd947d..baa09918 100644
--- a/src/browser/tests/element/html/image.html
+++ b/src/browser/tests/element/html/image.html
@@ -37,7 +37,7 @@
testing.expectEqual('test.png', img.getAttribute('src'));
img.src = '/absolute/path.png';
- testing.expectEqual(testing.ORIGIN + 'absolute/path.png', img.src);
+ testing.expectEqual(testing.ORIGIN + '/absolute/path.png', img.src);
testing.expectEqual('/absolute/path.png', img.getAttribute('src'));
img.src = 'https://example.com/image.png';
diff --git a/src/browser/tests/element/html/input.html b/src/browser/tests/element/html/input.html
index 79cd0c6d..2b8576b5 100644
--- a/src/browser/tests/element/html/input.html
+++ b/src/browser/tests/element/html/input.html
@@ -191,14 +191,14 @@
let eventCount = 0;
let lastEvent = null;
-
+
input.addEventListener('selectionchange', (e) => {
eventCount++;
lastEvent = e;
});
-
+
testing.expectEqual(0, eventCount);
-
+
input.setSelectionRange(0, 5);
input.select();
input.selectionStart = 3;
diff --git a/src/browser/tests/element/html/link.html b/src/browser/tests/element/html/link.html
index bed5e6ab..40d45c2f 100644
--- a/src/browser/tests/element/html/link.html
+++ b/src/browser/tests/element/html/link.html
@@ -8,7 +8,7 @@
testing.expectEqual('https://lightpanda.io/opensource-browser/15', l2.href);
l2.href = '/over/9000';
- testing.expectEqual(testing.ORIGIN + 'over/9000', l2.href);
+ testing.expectEqual(testing.ORIGIN + '/over/9000', l2.href);
l2.crossOrigin = 'nope';
testing.expectEqual('anonymous', l2.crossOrigin);
@@ -84,3 +84,24 @@
testing.eventually(() => testing.expectEqual(true, result));
}
+
+
diff --git a/src/browser/tests/element/matches.html b/src/browser/tests/element/matches.html
index 5e1721b5..f28d7a71 100644
--- a/src/browser/tests/element/matches.html
+++ b/src/browser/tests/element/matches.html
@@ -66,11 +66,10 @@
{
const container = $('#test-container');
- testing.expectError("SyntaxError: Syntax Error", () => container.matches(''));
+ testing.expectError("SyntaxError", () => container.matches(''));
testing.withError((err) => {
testing.expectEqual(12, err.code);
testing.expectEqual("SyntaxError", err.name);
- testing.expectEqual("Syntax Error", err.message);
}, () => container.matches(''));
}
diff --git a/src/browser/tests/element/query_selector.html b/src/browser/tests/element/query_selector.html
index 9564ca6d..203524b6 100644
--- a/src/browser/tests/element/query_selector.html
+++ b/src/browser/tests/element/query_selector.html
@@ -12,11 +12,10 @@
const p1 = $('#p1');
testing.expectEqual(null, p1.querySelector('#p1'));
- testing.expectError("SyntaxError: Syntax Error", () => p1.querySelector(''));
+ testing.expectError("SyntaxError", () => p1.querySelector(''));
testing.withError((err) => {
testing.expectEqual(12, err.code);
testing.expectEqual("SyntaxError", err.name);
- testing.expectEqual("Syntax Error", err.message);
}, () => p1.querySelector(''));
testing.expectEqual($('#c2'), p1.querySelector('#c2'));
diff --git a/src/browser/tests/element/query_selector_all.html b/src/browser/tests/element/query_selector_all.html
index eeedc876..3b4013c2 100644
--- a/src/browser/tests/element/query_selector_all.html
+++ b/src/browser/tests/element/query_selector_all.html
@@ -24,11 +24,10 @@
diff --git a/src/browser/tests/element/selector_invalid.html b/src/browser/tests/element/selector_invalid.html
index 35409c19..c0d16d59 100644
--- a/src/browser/tests/element/selector_invalid.html
+++ b/src/browser/tests/element/selector_invalid.html
@@ -43,8 +43,8 @@
const container = $('#container');
// Empty selectors
- testing.expectError("SyntaxError: Syntax Error", () => container.querySelector(''));
- testing.expectError("SyntaxError: Syntax Error", () => document.querySelectorAll(''));
+ testing.expectError("SyntaxError", () => container.querySelector(''));
+ testing.expectError("SyntaxError", () => document.querySelectorAll(''));
}
diff --git a/src/browser/tests/event/report_error.html b/src/browser/tests/event/report_error.html
new file mode 100644
index 00000000..353dc29e
--- /dev/null
+++ b/src/browser/tests/event/report_error.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/frames/post_message.html b/src/browser/tests/frames/post_message.html
new file mode 100644
index 00000000..6d206b74
--- /dev/null
+++ b/src/browser/tests/frames/post_message.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+
diff --git a/src/browser/tests/frames/support/message_receiver.html b/src/browser/tests/frames/support/message_receiver.html
new file mode 100644
index 00000000..55612a7c
--- /dev/null
+++ b/src/browser/tests/frames/support/message_receiver.html
@@ -0,0 +1,9 @@
+
+
diff --git a/src/browser/tests/history.html b/src/browser/tests/history.html
index 1508e232..e2aa0d35 100644
--- a/src/browser/tests/history.html
+++ b/src/browser/tests/history.html
@@ -2,37 +2,17 @@
+
diff --git a/src/browser/tests/mcp_actions.html b/src/browser/tests/mcp_actions.html
new file mode 100644
index 00000000..88cb70b1
--- /dev/null
+++ b/src/browser/tests/mcp_actions.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/window/navigator.html b/src/browser/tests/navigator/navigator.html
similarity index 56%
rename from src/browser/tests/window/navigator.html
rename to src/browser/tests/navigator/navigator.html
index df9f436a..b82c6e38 100644
--- a/src/browser/tests/window/navigator.html
+++ b/src/browser/tests/navigator/navigator.html
@@ -27,3 +27,44 @@
testing.expectEqual(false, navigator.javaEnabled());
testing.expectEqual(false, navigator.webdriver);
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/net/fetch.html b/src/browser/tests/net/fetch.html
index a545a452..229d8469 100644
--- a/src/browser/tests/net/fetch.html
+++ b/src/browser/tests/net/fetch.html
@@ -203,3 +203,39 @@
testing.expectEqual(true, response.body !== null);
});
+
+
+
+
diff --git a/src/browser/tests/net/xhr.html b/src/browser/tests/net/xhr.html
index 64fac5c3..a8683142 100644
--- a/src/browser/tests/net/xhr.html
+++ b/src/browser/tests/net/xhr.html
@@ -283,3 +283,26 @@
testing.expectEqual(XMLHttpRequest.UNSENT, req.readyState);
});
+
+
diff --git a/src/browser/tests/node/insert_before.html b/src/browser/tests/node/insert_before.html
index 8be48e56..50dff07c 100644
--- a/src/browser/tests/node/insert_before.html
+++ b/src/browser/tests/node/insert_before.html
@@ -19,7 +19,6 @@
testing.withError((err) => {
testing.expectEqual(8, err.code);
testing.expectEqual("NotFoundError", err.name);
- testing.expectEqual("Not Found", err.message);
}, () => d1.insertBefore(document.createElement('div'), d2));
let c1 = document.createElement('div');
diff --git a/src/browser/tests/node/remove_child.html b/src/browser/tests/node/remove_child.html
index fdf0b813..1118e4cf 100644
--- a/src/browser/tests/node/remove_child.html
+++ b/src/browser/tests/node/remove_child.html
@@ -7,7 +7,6 @@
testing.withError((err) => {
testing.expectEqual(8, err.code);
testing.expectEqual("NotFoundError", err.name);
- testing.expectEqual("Not Found", err.message);
}, () => $('#d1').removeChild($('#p1')));
const p1 = $('#p1');
diff --git a/src/browser/tests/node/replace_child.html b/src/browser/tests/node/replace_child.html
index 45ed1bc5..51b0a173 100644
--- a/src/browser/tests/node/replace_child.html
+++ b/src/browser/tests/node/replace_child.html
@@ -25,7 +25,6 @@
testing.withError((err) => {
testing.expectEqual(3, err.code);
testing.expectEqual("HierarchyRequestError", err.name);
- testing.expectEqual("Hierarchy Error", err.message);
}, () => d1.replaceChild(c4, c3));
testing.expectEqual(c2, d1.replaceChild(c4, c2));
diff --git a/src/browser/tests/range.html b/src/browser/tests/range.html
index d9a8637b..8440c187 100644
--- a/src/browser/tests/range.html
+++ b/src/browser/tests/range.html
@@ -451,12 +451,12 @@
const p1 = $('#p1');
// Test setStart with offset beyond node length
- testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
+ testing.expectError('IndexSizeError:', () => {
range.setStart(p1, 999);
});
// Test with negative offset (wraps to large u32)
- testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
+ testing.expectError('IndexSizeError:', () => {
range.setStart(p1.firstChild, -1);
});
}
@@ -468,12 +468,12 @@
const p1 = $('#p1');
// Test setEnd with offset beyond node length
- testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
+ testing.expectError('IndexSizeError:', () => {
range.setEnd(p1, 999);
});
// Test with text node
- testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
+ testing.expectError('IndexSizeError:', () => {
range.setEnd(p1.firstChild, 9999);
});
}
@@ -525,11 +525,11 @@
range.setEnd(p1, 1);
// Test comparePoint with invalid offset
- testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
+ testing.expectError('IndexSizeError:', () => {
range.comparePoint(p1, 20);
});
- testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
+ testing.expectError('IndexSizeError:', () => {
range.comparePoint(p1.firstChild, -1);
});
}
@@ -650,11 +650,11 @@
range.setEnd(p1, 1);
// Invalid offset should throw IndexSizeError
- testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
+ testing.expectError('IndexSizeError:', () => {
range.isPointInRange(p1, 999);
});
- testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
+ testing.expectError('IndexSizeError:', () => {
range.isPointInRange(p1.firstChild, 9999);
});
}
@@ -854,11 +854,11 @@
range2.setStart(p, 0);
// Invalid how parameter should throw NotSupportedError
- testing.expectError('NotSupportedError: Not Supported', () => {
+ testing.expectError('NotSupportedError:', () => {
range1.compareBoundaryPoints(4, range2);
});
- testing.expectError('NotSupportedError: Not Supported', () => {
+ testing.expectError('NotSupportedError:', () => {
range1.compareBoundaryPoints(99, range2);
});
}
@@ -883,7 +883,7 @@
range2.setEnd(foreignP, 1);
// Comparing ranges in different documents should throw WrongDocumentError
- testing.expectError('WrongDocumentError: wrong_document_error', () => {
+ testing.expectError('WrongDocumentError:', () => {
range1.compareBoundaryPoints(Range.START_TO_START, range2);
});
}
diff --git a/src/browser/tests/shadowroot/basic.html b/src/browser/tests/shadowroot/basic.html
index ed82ab18..3516fa79 100644
--- a/src/browser/tests/shadowroot/basic.html
+++ b/src/browser/tests/shadowroot/basic.html
@@ -5,7 +5,7 @@
-
+
+
+
+
diff --git a/src/browser/tests/support/history.html b/src/browser/tests/support/history.html
new file mode 100644
index 00000000..d3356de3
--- /dev/null
+++ b/src/browser/tests/support/history.html
@@ -0,0 +1,33 @@
+
+
+
+
diff --git a/src/browser/tests/testing.js b/src/browser/tests/testing.js
index 987ba042..2e33c1d3 100644
--- a/src/browser/tests/testing.js
+++ b/src/browser/tests/testing.js
@@ -99,8 +99,7 @@
}
}
- // our test runner sets this to true
- const IS_TEST_RUNNER = window._lightpanda_skip_auto_assert === true;
+ const IS_TEST_RUNNER = window.navigator.userAgent.startsWith("Lightpanda/");
window.testing = {
fail: fail,
@@ -114,17 +113,17 @@
eventually: eventually,
IS_TEST_RUNNER: IS_TEST_RUNNER,
HOST: '127.0.0.1',
- ORIGIN: 'http://127.0.0.1:9582/',
+ ORIGIN: 'http://127.0.0.1:9582',
BASE_URL: 'http://127.0.0.1:9582/src/browser/tests/',
};
- if (window.navigator.userAgent.startsWith("Lightpanda/") == false) {
+ if (IS_TEST_RUNNER === false) {
// The page is running in a different browser. Probably a developer making sure
// a test is correct. There are a few tweaks we need to do to make this a
// seemless, namely around adapting paths/urls.
console.warn(`The page is not being executed in the test runner, certain behavior has been adjusted`);
window.testing.HOST = location.hostname;
- window.testing.ORIGIN = location.origin + '/';
+ window.testing.ORIGIN = location.origin;
window.testing.BASE_URL = location.origin + '/src/browser/tests/';
window.addEventListener('load', testing.assertOk);
}
diff --git a/src/browser/tests/window/window.html b/src/browser/tests/window/window.html
index 01025b86..e4094f9b 100644
--- a/src/browser/tests/window/window.html
+++ b/src/browser/tests/window/window.html
@@ -82,7 +82,7 @@
testing.expectEqual('ceil', atob('Y2VpbA')); // 6 chars, len%4==2, needs '=='
// length % 4 == 1 must still throw
- testing.expectError('InvalidCharacterError: Invalid Character', () => {
+ testing.expectError('InvalidCharacterError', () => {
atob('Y');
});
diff --git a/src/browser/tests/window/window_event.html b/src/browser/tests/window/window_event.html
new file mode 100644
index 00000000..971651f8
--- /dev/null
+++ b/src/browser/tests/window/window_event.html
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
diff --git a/src/browser/webapi/DOMException.zig b/src/browser/webapi/DOMException.zig
index 3e0da288..46294b8d 100644
--- a/src/browser/webapi/DOMException.zig
+++ b/src/browser/webapi/DOMException.zig
@@ -104,13 +104,27 @@ pub fn getMessage(self: *const DOMException) []const u8 {
}
return switch (self._code) {
.none => "",
- .invalid_character_error => "Invalid Character",
.index_size_error => "Index or size is negative or greater than the allowed amount",
- .syntax_error => "Syntax Error",
- .not_supported => "Not Supported",
- .not_found => "Not Found",
- .hierarchy_error => "Hierarchy Error",
- else => @tagName(self._code),
+ .hierarchy_error => "The operation would yield an incorrect node tree",
+ .wrong_document_error => "The object is in the wrong document",
+ .invalid_character_error => "The string contains invalid characters",
+ .no_modification_allowed_error => "The object can not be modified",
+ .not_found => "The object can not be found here",
+ .not_supported => "The operation is not supported",
+ .inuse_attribute_error => "The attribute already in use",
+ .invalid_state_error => "The object is in an invalid state",
+ .syntax_error => "The string did not match the expected pattern",
+ .invalid_modification_error => "The object can not be modified in this way",
+ .namespace_error => "The operation is not allowed by Namespaces in XML",
+ .invalid_access_error => "The object does not support the operation or argument",
+ .security_error => "The operation is insecure",
+ .network_error => "A network error occurred",
+ .abort_error => "The operation was aborted",
+ .url_mismatch_error => "The given URL does not match another URL",
+ .quota_exceeded_error => "The quota has been exceeded",
+ .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",
};
}
diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig
index 33e29952..acaab925 100644
--- a/src/browser/webapi/Document.zig
+++ b/src/browser/webapi/Document.zig
@@ -365,6 +365,11 @@ pub fn createEvent(_: *const Document, event_type: []const u8, page: *Page) !*@i
return (try KeyboardEvent.init("", null, page)).asEvent();
}
+ if (std.mem.eql(u8, normalized, "inputevent")) {
+ const InputEvent = @import("event/InputEvent.zig");
+ return (try InputEvent.init("", null, page)).asEvent();
+ }
+
if (std.mem.eql(u8, normalized, "mouseevent") or std.mem.eql(u8, normalized, "mouseevents")) {
const MouseEvent = @import("event/MouseEvent.zig");
return (try MouseEvent.init("", null, page)).asEvent();
diff --git a/src/browser/webapi/IntersectionObserver.zig b/src/browser/webapi/IntersectionObserver.zig
index 74a5d79e..8586a11d 100644
--- a/src/browser/webapi/IntersectionObserver.zig
+++ b/src/browser/webapi/IntersectionObserver.zig
@@ -93,12 +93,12 @@ pub fn init(callback: js.Function.Temp, options: ?ObserverInit, page: *Page) !*I
}
pub fn deinit(self: *IntersectionObserver, shutdown: bool, session: *Session) void {
- self._callback.release();
- if ((comptime IS_DEBUG) and !shutdown) {
- std.debug.assert(self._observing.items.len == 0);
+ if (shutdown) {
+ self._callback.release();
+ session.releaseArena(self._arena);
+ } else if (comptime IS_DEBUG) {
+ std.debug.assert(false);
}
-
- session.releaseArena(self._arena);
}
pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void {
@@ -111,7 +111,6 @@ pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void
// Register with page if this is our first observation
if (self._observing.items.len == 0) {
- page.js.strongRef(self);
try page.registerIntersectionObserver(self);
}
@@ -146,22 +145,18 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, page: *Page) voi
break;
}
}
-
- if (self._observing.items.len == 0) {
- page.js.safeWeakRef(self);
- }
}
pub fn disconnect(self: *IntersectionObserver, page: *Page) void {
- page.unregisterIntersectionObserver(self);
- self._observing.clearRetainingCapacity();
self._previous_states.clearRetainingCapacity();
for (self._pending_entries.items) |entry| {
entry.deinit(false, page._session);
}
self._pending_entries.clearRetainingCapacity();
- page.js.safeWeakRef(self);
+
+ self._observing.clearRetainingCapacity();
+ page.unregisterIntersectionObserver(self);
}
pub fn takeRecords(self: *IntersectionObserver, page: *Page) ![]*IntersectionObserverEntry {
@@ -363,7 +358,6 @@ pub const JsApi = struct {
pub const name = "IntersectionObserver";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
- pub const weak = true;
pub const finalizer = bridge.finalizer(IntersectionObserver.deinit);
};
diff --git a/src/browser/webapi/MutationObserver.zig b/src/browser/webapi/MutationObserver.zig
index b8608381..df86d1e1 100644
--- a/src/browser/webapi/MutationObserver.zig
+++ b/src/browser/webapi/MutationObserver.zig
@@ -86,12 +86,12 @@ pub fn init(callback: js.Function.Temp, page: *Page) !*MutationObserver {
}
pub fn deinit(self: *MutationObserver, shutdown: bool, session: *Session) void {
- self._callback.release();
- if ((comptime IS_DEBUG) and !shutdown) {
- std.debug.assert(self._observing.items.len == 0);
+ if (shutdown) {
+ self._callback.release();
+ session.releaseArena(self._arena);
+ } else if (comptime IS_DEBUG) {
+ std.debug.assert(false);
}
-
- session.releaseArena(self._arena);
}
pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions, page: *Page) !void {
@@ -158,7 +158,6 @@ pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions,
// Register with page if this is our first observation
if (self._observing.items.len == 0) {
- page.js.strongRef(self);
try page.registerMutationObserver(self);
}
@@ -169,13 +168,13 @@ pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions,
}
pub fn disconnect(self: *MutationObserver, page: *Page) void {
- page.unregisterMutationObserver(self);
- self._observing.clearRetainingCapacity();
for (self._pending_records.items) |record| {
record.deinit(false, page._session);
}
self._pending_records.clearRetainingCapacity();
- page.js.safeWeakRef(self);
+
+ self._observing.clearRetainingCapacity();
+ page.unregisterMutationObserver(self);
}
pub fn takeRecords(self: *MutationObserver, page: *Page) ![]*MutationRecord {
@@ -441,7 +440,6 @@ pub const JsApi = struct {
pub const name = "MutationObserver";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
- pub const weak = true;
pub const finalizer = bridge.finalizer(MutationObserver.deinit);
};
diff --git a/src/browser/webapi/Navigator.zig b/src/browser/webapi/Navigator.zig
index 5e69db07..218110c9 100644
--- a/src/browser/webapi/Navigator.zig
+++ b/src/browser/webapi/Navigator.zig
@@ -18,13 +18,21 @@
const std = @import("std");
const builtin = @import("builtin");
+
+const log = @import("../../log.zig");
+
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
+
const PluginArray = @import("PluginArray.zig");
+const Permissions = @import("Permissions.zig");
+const StorageManager = @import("StorageManager.zig");
const Navigator = @This();
_pad: bool = false,
_plugins: PluginArray = .{},
+_permissions: Permissions = .{},
+_storage: StorageManager = .{},
pub const init: Navigator = .{};
@@ -55,6 +63,19 @@ pub fn getPlugins(self: *Navigator) *PluginArray {
return &self._plugins;
}
+pub fn getPermissions(self: *Navigator) *Permissions {
+ return &self._permissions;
+}
+
+pub fn getStorage(self: *Navigator) *StorageManager {
+ return &self._storage;
+}
+
+pub fn getBattery(_: *const Navigator, page: *Page) !js.Promise {
+ log.info(.not_implemented, "navigator.getBattery", .{});
+ return page.js.local.?.rejectErrorPromise(.{ .dom_exception = error.NotSupported });
+}
+
pub fn registerProtocolHandler(_: *const Navigator, scheme: []const u8, url: [:0]const u8, page: *const Page) !void {
try validateProtocolHandlerScheme(scheme);
try validateProtocolHandlerURL(url, page);
@@ -144,6 +165,7 @@ pub const JsApi = struct {
pub const onLine = bridge.property(true, .{ .template = false });
pub const cookieEnabled = bridge.property(true, .{ .template = false });
pub const hardwareConcurrency = bridge.property(4, .{ .template = false });
+ pub const deviceMemory = bridge.property(@as(f64, 8.0), .{ .template = false });
pub const maxTouchPoints = bridge.property(0, .{ .template = false });
pub const vendor = bridge.property("", .{ .template = false });
pub const product = bridge.property("Gecko", .{ .template = false });
@@ -156,4 +178,12 @@ pub const JsApi = struct {
// Methods
pub const javaEnabled = bridge.function(Navigator.javaEnabled, .{});
+ pub const getBattery = bridge.function(Navigator.getBattery, .{});
+ pub const permissions = bridge.accessor(Navigator.getPermissions, null, .{});
+ pub const storage = bridge.accessor(Navigator.getStorage, null, .{});
};
+
+const testing = @import("../../testing.zig");
+test "WebApi: Navigator" {
+ try testing.htmlRunner("navigator", .{});
+}
diff --git a/src/browser/webapi/Permissions.zig b/src/browser/webapi/Permissions.zig
new file mode 100644
index 00000000..ee197d3f
--- /dev/null
+++ b/src/browser/webapi/Permissions.zig
@@ -0,0 +1,94 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// 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 .
+
+const std = @import("std");
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+const Session = @import("../Session.zig");
+
+const Allocator = std.mem.Allocator;
+
+pub fn registerTypes() []const type {
+ return &.{ Permissions, PermissionStatus };
+}
+
+const Permissions = @This();
+
+// Padding to avoid zero-size struct pointer collisions
+_pad: bool = false,
+
+const QueryDescriptor = struct {
+ name: []const u8,
+};
+// We always report 'prompt' (the default safe value — neither granted nor denied).
+pub fn query(_: *const Permissions, qd: QueryDescriptor, page: *Page) !js.Promise {
+ const arena = try page.getArena(.{ .debug = "PermissionStatus" });
+ errdefer page.releaseArena(arena);
+
+ const status = try arena.create(PermissionStatus);
+ status.* = .{
+ ._arena = arena,
+ ._state = "prompt",
+ ._name = try arena.dupe(u8, qd.name),
+ };
+ return page.js.local.?.resolvePromise(status);
+}
+
+const PermissionStatus = struct {
+ _arena: Allocator,
+ _name: []const u8,
+ _state: []const u8,
+
+ pub fn deinit(self: *PermissionStatus, _: bool, session: *Session) void {
+ session.releaseArena(self._arena);
+ }
+
+ fn getName(self: *const PermissionStatus) []const u8 {
+ return self._name;
+ }
+
+ fn getState(self: *const PermissionStatus) []const u8 {
+ return self._state;
+ }
+
+ pub const JsApi = struct {
+ pub const bridge = js.Bridge(PermissionStatus);
+ pub const Meta = struct {
+ pub const name = "PermissionStatus";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ pub const weak = true;
+ pub const finalizer = bridge.finalizer(PermissionStatus.deinit);
+ };
+ pub const name = bridge.accessor(getName, null, .{});
+ pub const state = bridge.accessor(getState, null, .{});
+ };
+};
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Permissions);
+
+ pub const Meta = struct {
+ pub const name = "Permissions";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ pub const empty_with_no_proto = true;
+ };
+
+ pub const query = bridge.function(Permissions.query, .{ .dom_exception = true });
+};
diff --git a/src/browser/webapi/ShadowRoot.zig b/src/browser/webapi/ShadowRoot.zig
index 09bfa2d9..a5e54923 100644
--- a/src/browser/webapi/ShadowRoot.zig
+++ b/src/browser/webapi/ShadowRoot.zig
@@ -40,6 +40,7 @@ _mode: Mode,
_host: *Element,
_elements_by_id: std.StringHashMapUnmanaged(*Element) = .{},
_removed_ids: std.StringHashMapUnmanaged(void) = .{},
+_adopted_style_sheets: ?js.Object.Global = null,
pub fn init(host: *Element, mode: Mode, page: *Page) !*ShadowRoot {
return page._factory.documentFragment(ShadowRoot{
@@ -99,6 +100,20 @@ pub fn getElementById(self: *ShadowRoot, id: []const u8, page: *Page) ?*Element
return null;
}
+pub fn getAdoptedStyleSheets(self: *ShadowRoot, page: *Page) !js.Object.Global {
+ if (self._adopted_style_sheets) |ass| {
+ return ass;
+ }
+ const js_arr = page.js.local.?.newArray(0);
+ const js_obj = js_arr.toObject();
+ self._adopted_style_sheets = try js_obj.persist();
+ return self._adopted_style_sheets.?;
+}
+
+pub fn setAdoptedStyleSheets(self: *ShadowRoot, sheets: js.Object) !void {
+ self._adopted_style_sheets = try sheets.persist();
+}
+
pub const JsApi = struct {
pub const bridge = js.Bridge(ShadowRoot);
@@ -121,6 +136,7 @@ pub const JsApi = struct {
}
return self.getElementById(try value.toZig([]const u8), page);
}
+ pub const adoptedStyleSheets = bridge.accessor(ShadowRoot.getAdoptedStyleSheets, ShadowRoot.setAdoptedStyleSheets, .{});
};
const testing = @import("../../testing.zig");
diff --git a/src/browser/webapi/StorageManager.zig b/src/browser/webapi/StorageManager.zig
new file mode 100644
index 00000000..e7b95cc4
--- /dev/null
+++ b/src/browser/webapi/StorageManager.zig
@@ -0,0 +1,71 @@
+// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// 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 .
+
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+
+pub fn registerTypes() []const type {
+ return &.{ StorageManager, StorageEstimate };
+}
+
+const StorageManager = @This();
+
+_pad: bool = false,
+
+pub fn estimate(_: *const StorageManager, page: *Page) !js.Promise {
+ const est = try page._factory.create(StorageEstimate{
+ ._usage = 0,
+ ._quota = 1024 * 1024 * 1024, // 1 GiB
+ });
+ return page.js.local.?.resolvePromise(est);
+}
+
+const StorageEstimate = struct {
+ _quota: u64,
+ _usage: u64,
+
+ fn getUsage(self: *const StorageEstimate) u64 {
+ return self._usage;
+ }
+
+ fn getQuota(self: *const StorageEstimate) u64 {
+ return self._quota;
+ }
+
+ pub const JsApi = struct {
+ pub const bridge = js.Bridge(StorageEstimate);
+ pub const Meta = struct {
+ pub const name = "StorageEstimate";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+ pub const quota = bridge.accessor(getQuota, null, .{});
+ pub const usage = bridge.accessor(getUsage, null, .{});
+ };
+};
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(StorageManager);
+ pub const Meta = struct {
+ pub const name = "StorageManager";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ pub const empty_with_no_proto = true;
+ };
+ pub const estimate = bridge.function(StorageManager.estimate, .{});
+};
diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig
index 0f288398..bbed1010 100644
--- a/src/browser/webapi/Window.zig
+++ b/src/browser/webapi/Window.zig
@@ -66,7 +66,10 @@ _on_load: ?js.Function.Global = null,
_on_pageshow: ?js.Function.Global = null,
_on_popstate: ?js.Function.Global = null,
_on_error: ?js.Function.Global = null,
-_on_unhandled_rejection: ?js.Function.Global = null, // TODO: invoke on error
+_on_message: ?js.Function.Global = null,
+_on_rejection_handled: ?js.Function.Global = null,
+_on_unhandled_rejection: ?js.Function.Global = null,
+_current_event: ?*Event = null,
_location: *Location,
_timer_id: u30 = 0,
_timers: std.AutoHashMapUnmanaged(u32, *ScheduleCallback) = .{},
@@ -89,6 +92,10 @@ pub fn asEventTarget(self: *Window) *EventTarget {
return self._proto;
}
+pub fn getEvent(self: *const Window) ?*Event {
+ return self._current_event;
+}
+
pub fn getSelf(self: *Window) *Window {
return self;
}
@@ -208,6 +215,22 @@ pub fn setOnError(self: *Window, setter: ?FunctionSetter) void {
self._on_error = getFunctionFromSetter(setter);
}
+pub fn getOnMessage(self: *const Window) ?js.Function.Global {
+ return self._on_message;
+}
+
+pub fn setOnMessage(self: *Window, setter: ?FunctionSetter) void {
+ self._on_message = getFunctionFromSetter(setter);
+}
+
+pub fn getOnRejectionHandled(self: *const Window) ?js.Function.Global {
+ return self._on_rejection_handled;
+}
+
+pub fn setOnRejectionHandled(self: *Window, setter: ?FunctionSetter) void {
+ self._on_rejection_handled = getFunctionFromSetter(setter);
+}
+
pub fn getOnUnhandledRejection(self: *const Window) ?js.Function.Global {
return self._on_unhandled_rejection;
}
@@ -334,7 +357,11 @@ pub fn reportError(self: *Window, err: js.Value, page: *Page) !void {
const event = error_event.asEvent();
event._prevent_default = prevent_default;
- try page._event_manager.dispatch(self.asEventTarget(), event);
+ // Pass null as handler: onerror was already called above with 5 args.
+ // We still dispatch so that addEventListener('error', ...) listeners fire.
+ try page._event_manager.dispatchDirect(self.asEventTarget(), event, null, .{
+ .context = "window.reportError",
+ });
if (comptime builtin.is_test == false) {
if (!event._prevent_default) {
@@ -369,19 +396,26 @@ pub fn postMessage(self: *Window, message: js.Value.Temp, target_origin: ?[]cons
// In a full implementation, we would validate the origin
_ = target_origin;
- // postMessage queues a task (not a microtask), so use the scheduler
- const arena = try page.getArena(.{ .debug = "Window.schedule" });
- errdefer page.releaseArena(arena);
+ // self = the window that will get the message
+ // page = the context calling postMessage
+ const target_page = self._page;
+ const source_window = target_page.js.getIncumbent().window;
- const origin = try self._location.getOrigin(page);
+ const arena = try target_page.getArena(.{ .debug = "Window.postMessage" });
+ errdefer target_page.releaseArena(arena);
+
+ // Origin should be the source window's origin (where the message came from)
+ const origin = try source_window._location.getOrigin(page);
const callback = try arena.create(PostMessageCallback);
callback.* = .{
- .page = page,
.arena = arena,
.message = message,
+ .page = target_page,
+ .source = source_window,
.origin = try arena.dupe(u8, origin),
};
- try page.js.scheduler.add(callback, PostMessageCallback.run, 0, .{
+
+ try target_page.js.scheduler.add(callback, PostMessageCallback.run, 0, .{
.name = "postMessage",
.low_priority = false,
.finalizer = PostMessageCallback.cancelled,
@@ -547,7 +581,7 @@ pub fn scrollBy(self: *Window, opts: ScrollToOpts, y: ?i32, page: *Page) !void {
return self.scrollTo(.{ .x = absx }, absy, page);
}
-pub fn unhandledPromiseRejection(self: *Window, rejection: js.PromiseRejection, page: *Page) !void {
+pub fn unhandledPromiseRejection(self: *Window, no_handler: bool, rejection: js.PromiseRejection, page: *Page) !void {
if (comptime IS_DEBUG) {
log.debug(.js, "unhandled rejection", .{
.value = rejection.reason(),
@@ -555,13 +589,20 @@ pub fn unhandledPromiseRejection(self: *Window, rejection: js.PromiseRejection,
});
}
+ const event_name, const attribute_callback = blk: {
+ if (no_handler) {
+ break :blk .{ "unhandledrejection", self._on_unhandled_rejection };
+ }
+ break :blk .{ "rejectionhandled", self._on_rejection_handled };
+ };
+
const target = self.asEventTarget();
- if (page._event_manager.hasDirectListeners(target, "unhandledrejection", self._on_unhandled_rejection)) {
- const event = (try @import("event/PromiseRejectionEvent.zig").init("unhandledrejection", .{
+ if (page._event_manager.hasDirectListeners(target, event_name, attribute_callback)) {
+ const event = (try @import("event/PromiseRejectionEvent.zig").init(event_name, .{
.reason = if (rejection.reason()) |r| try r.temp() else null,
.promise = try rejection.promise().temp(),
}, page)).asEvent();
- try page._event_manager.dispatchDirect(target, event, self._on_unhandled_rejection, .{ .context = "window.unhandledrejection" });
+ try page._event_manager.dispatchDirect(target, event, attribute_callback, .{ .context = "window.unhandledrejection" });
}
}
@@ -702,6 +743,7 @@ const ScheduleCallback = struct {
const PostMessageCallback = struct {
page: *Page,
+ source: *Window,
arena: Allocator,
origin: []const u8,
message: js.Value.Temp,
@@ -712,7 +754,7 @@ const PostMessageCallback = struct {
fn cancelled(ctx: *anyopaque) void {
const self: *PostMessageCallback = @ptrCast(@alignCast(ctx));
- self.page.releaseArena(self.arena);
+ self.deinit();
}
fn run(ctx: *anyopaque) !?u32 {
@@ -722,14 +764,17 @@ const PostMessageCallback = struct {
const page = self.page;
const window = page.window;
- const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{
- .data = self.message,
- .origin = self.origin,
- .source = window,
- .bubbles = false,
- .cancelable = false,
- }, page)).asEvent();
- try page._event_manager.dispatch(window.asEventTarget(), event);
+ const event_target = window.asEventTarget();
+ if (page._event_manager.hasDirectListeners(event_target, "message", window._on_message)) {
+ const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{
+ .data = self.message,
+ .origin = self.origin,
+ .source = self.source,
+ .bubbles = false,
+ .cancelable = false,
+ }, page)).asEvent();
+ try page._event_manager.dispatchDirect(event_target, event, window._on_message, .{ .context = "window.postMessage" });
+ }
return null;
}
@@ -783,7 +828,10 @@ pub const JsApi = struct {
pub const onpageshow = bridge.accessor(Window.getOnPageShow, Window.setOnPageShow, .{});
pub const onpopstate = bridge.accessor(Window.getOnPopState, Window.setOnPopState, .{});
pub const onerror = bridge.accessor(Window.getOnError, Window.setOnError, .{});
+ pub const onmessage = bridge.accessor(Window.getOnMessage, Window.setOnMessage, .{});
+ pub const onrejectionhandled = bridge.accessor(Window.getOnRejectionHandled, Window.setOnRejectionHandled, .{});
pub const onunhandledrejection = bridge.accessor(Window.getOnUnhandledRejection, Window.setOnUnhandledRejection, .{});
+ pub const event = bridge.accessor(Window.getEvent, null, .{ .null_as_undefined = true });
pub const fetch = bridge.function(Window.fetch, .{});
pub const queueMicrotask = bridge.function(Window.queueMicrotask, .{});
pub const setTimeout = bridge.function(Window.setTimeout, .{});
@@ -853,3 +901,7 @@ test "WebApi: Window" {
test "WebApi: Window scroll" {
try testing.htmlRunner("window_scroll.html", .{});
}
+
+test "WebApi: Window.onerror" {
+ try testing.htmlRunner("event/report_error.html", .{});
+}
diff --git a/src/browser/webapi/css/CSSStyleSheet.zig b/src/browser/webapi/css/CSSStyleSheet.zig
index 2b41fffa..6a37ecb5 100644
--- a/src/browser/webapi/css/CSSStyleSheet.zig
+++ b/src/browser/webapi/css/CSSStyleSheet.zig
@@ -135,7 +135,7 @@ pub const JsApi = struct {
const testing = @import("../../../testing.zig");
test "WebApi: CSSStyleSheet" {
- const filter: testing.LogFilter = .init(.js);
+ const filter: testing.LogFilter = .init(&.{.js});
defer filter.deinit();
try testing.htmlRunner("css/stylesheet.html", .{});
}
diff --git a/src/browser/webapi/element/html/Form.zig b/src/browser/webapi/element/html/Form.zig
index 24e8433e..b86744da 100644
--- a/src/browser/webapi/element/html/Form.zig
+++ b/src/browser/webapi/element/html/Form.zig
@@ -117,6 +117,47 @@ pub fn submit(self: *Form, page: *Page) !void {
return page.submitForm(null, self, .{ .fire_event = false });
}
+/// https://html.spec.whatwg.org/multipage/forms.html#dom-form-requestsubmit
+/// Like submit(), but fires the submit event and validates the form.
+pub fn requestSubmit(self: *Form, submitter: ?*Element, page: *Page) !void {
+ const submitter_element = if (submitter) |s| blk: {
+ // The submitter must be a submit button.
+ if (!isSubmitButton(s)) return error.TypeError;
+
+ // The submitter's form owner must be this form element.
+ const submitter_form = getFormOwner(s, page);
+ if (submitter_form == null or submitter_form.? != self) return error.NotFound;
+
+ break :blk s;
+ } else self.asElement();
+
+ return page.submitForm(submitter_element, self, .{});
+}
+
+/// Returns true if the element is a submit button per the HTML spec:
+/// - or
+/// -