Merge branch 'main' into css-improvements

This commit is contained in:
Adrià Arrufat
2026-03-20 09:46:31 +09:00
101 changed files with 3211 additions and 1203 deletions

View File

@@ -13,7 +13,7 @@ inputs:
zig-v8: zig-v8:
description: 'zig v8 version to install' description: 'zig v8 version to install'
required: false required: false
default: 'v0.3.3' default: 'v0.3.4'
v8: v8:
description: 'v8 version to install' description: 'v8 version to install'
required: false required: false
@@ -46,7 +46,7 @@ runs:
- name: Cache v8 - name: Cache v8
id: cache-v8 id: cache-v8
uses: actions/cache@v4 uses: actions/cache@v5
env: env:
cache-name: cache-v8 cache-name: cache-v8
with: with:

View File

@@ -20,11 +20,9 @@ jobs:
if: github.event.pull_request.draft == false if: github.event.pull_request.draft == false
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install - 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 }}) 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 - name: upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: lightpanda-build-release name: lightpanda-build-release
path: | path: |
@@ -47,7 +45,7 @@ jobs:
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with: with:
repository: 'lightpanda-io/demo' repository: 'lightpanda-io/demo'
fetch-depth: 0 fetch-depth: 0
@@ -55,7 +53,7 @@ jobs:
- run: npm install - run: npm install
- name: download artifact - name: download artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v8
with: with:
name: lightpanda-build-release name: lightpanda-build-release

View File

@@ -9,15 +9,13 @@ env:
on: on:
push: push:
branches: branches: [main]
- main
paths: paths:
- "build.zig"
- "src/**/*.zig"
- "src/*.zig"
- "vendor/zig-js-runtime"
- ".github/**" - ".github/**"
- "vendor/**" - "src/**"
- "build.zig"
- "build.zig.zon"
pull_request: pull_request:
# By default GH trigger on types opened, synchronize and reopened. # By default GH trigger on types opened, synchronize and reopened.
@@ -29,12 +27,10 @@ on:
paths: paths:
- ".github/**" - ".github/**"
- "src/**"
- "build.zig" - "build.zig"
- "src/**/*.zig" - "build.zig.zon"
- "src/*.zig"
- "vendor/**"
- ".github/**"
- "vendor/**"
# Allows you to run this workflow manually from the Actions tab # Allows you to run this workflow manually from the Actions tab
workflow_dispatch: workflow_dispatch:
@@ -52,8 +48,6 @@ jobs:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install - 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 }}) 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 - name: upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: lightpanda-build-release name: lightpanda-build-release
path: | path: |
@@ -76,7 +70,7 @@ jobs:
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with: with:
repository: 'lightpanda-io/demo' repository: 'lightpanda-io/demo'
fetch-depth: 0 fetch-depth: 0
@@ -84,7 +78,7 @@ jobs:
- run: npm install - run: npm install
- name: download artifact - name: download artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v8
with: with:
name: lightpanda-build-release name: lightpanda-build-release
@@ -126,7 +120,7 @@ jobs:
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with: with:
repository: 'lightpanda-io/demo' repository: 'lightpanda-io/demo'
fetch-depth: 0 fetch-depth: 0
@@ -134,7 +128,7 @@ jobs:
- run: npm install - run: npm install
- name: download artifact - name: download artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v8
with: with:
name: lightpanda-build-release name: lightpanda-build-release
@@ -182,39 +176,41 @@ jobs:
name: wba-test name: wba-test
needs: zig-build-release needs: zig-build-release
env:
LIGHTPANDA_DISABLE_TELEMETRY: true
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with: with:
repository: 'lightpanda-io/demo' repository: 'lightpanda-io/demo'
fetch-depth: 0 fetch-depth: 0
- run: echo "${{ secrets.WBA_PRIVATE_KEY_PEM }}" > private_key.pem
- name: download artifact - name: download artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v8
with: with:
name: lightpanda-build-release name: lightpanda-build-release
- run: chmod a+x ./lightpanda - 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 - name: run wba test
shell: bash
run: | run: |
node webbotauth/validator.js & node webbotauth/validator.js &
VALIDATOR_PID=$! VALIDATOR_PID=$!
sleep 2 sleep 5
./lightpanda fetch http://127.0.0.1:8989/ \ exec 3<<< "${{ secrets.WBA_PRIVATE_KEY_PEM }}"
--web_bot_auth_key_file 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_keyid ${{ vars.WBA_KEY_ID }} \
--web_bot_auth_domain ${{ vars.WBA_DOMAIN }} --web_bot_auth_domain ${{ vars.WBA_DOMAIN }}
wait $VALIDATOR_PID wait $VALIDATOR_PID
exec 3>&-
cdp-and-hyperfine-bench: cdp-and-hyperfine-bench:
name: cdp-and-hyperfine-bench name: cdp-and-hyperfine-bench
@@ -224,7 +220,6 @@ jobs:
MAX_VmHWM: 28000 # 28MB (KB) MAX_VmHWM: 28000 # 28MB (KB)
MAX_CG_PEAK: 8000 # 8MB (KB) MAX_CG_PEAK: 8000 # 8MB (KB)
MAX_AVG_DURATION: 17 MAX_AVG_DURATION: 17
LIGHTPANDA_DISABLE_TELEMETRY: true
# How to give cgroups access to the user actions-runner on the host: # How to give cgroups access to the user actions-runner on the host:
# $ sudo apt install cgroup-tools # $ sudo apt install cgroup-tools
@@ -239,7 +234,7 @@ jobs:
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with: with:
repository: 'lightpanda-io/demo' repository: 'lightpanda-io/demo'
fetch-depth: 0 fetch-depth: 0
@@ -247,7 +242,7 @@ jobs:
- run: npm install - run: npm install
- name: download artifact - name: download artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v8
with: with:
name: lightpanda-build-release name: lightpanda-build-release
@@ -333,7 +328,7 @@ jobs:
echo "${{github.sha}}" > commit.txt echo "${{github.sha}}" > commit.txt
- name: upload artifact - name: upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: bench-results name: bench-results
path: | path: |
@@ -356,12 +351,12 @@ jobs:
container: container:
image: ghcr.io/lightpanda-io/perf-fmt:latest image: ghcr.io/lightpanda-io/perf-fmt:latest
credentials: credentials:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: download artifact - name: download artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v8
with: with:
name: bench-results name: bench-results
@@ -379,7 +374,7 @@ jobs:
steps: steps:
- name: download artifact - name: download artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v8
with: with:
name: lightpanda-build-release name: lightpanda-build-release

View File

@@ -5,7 +5,9 @@ env:
AWS_SECRET_ACCESS_KEY: ${{ secrets.NIGHTLY_BUILD_AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.NIGHTLY_BUILD_AWS_SECRET_ACCESS_KEY }}
AWS_BUCKET: ${{ vars.NIGHTLY_BUILD_AWS_BUCKET }} AWS_BUCKET: ${{ vars.NIGHTLY_BUILD_AWS_BUCKET }}
AWS_REGION: ${{ vars.NIGHTLY_BUILD_AWS_REGION }} AWS_REGION: ${{ vars.NIGHTLY_BUILD_AWS_REGION }}
RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }} RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }}
GIT_VERSION_FLAG: ${{ github.ref_type == 'tag' && format('-Dgit_version={0}', github.ref_name) || '' }}
on: on:
push: push:
@@ -33,8 +35,6 @@ jobs:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install - uses: ./.github/actions/install
with: with:
@@ -45,7 +45,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build - 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 - name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -72,11 +72,9 @@ jobs:
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install - uses: ./.github/actions/install
with: with:
@@ -87,7 +85,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build - 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 - name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -116,11 +114,9 @@ jobs:
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install - uses: ./.github/actions/install
with: with:
@@ -131,7 +127,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build - 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 - name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -158,11 +154,9 @@ jobs:
timeout-minutes: 20 timeout-minutes: 20
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install - uses: ./.github/actions/install
with: with:
@@ -173,7 +167,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build - 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 - name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}

View File

@@ -10,7 +10,7 @@ env:
on: on:
schedule: schedule:
- cron: "23 2 * * *" - cron: "21 2 * * *"
# Allows you to run this workflow manually from the Actions tab # Allows you to run this workflow manually from the Actions tab
workflow_dispatch: workflow_dispatch:
@@ -19,23 +19,31 @@ jobs:
wpt-build-release: wpt-build-release:
name: zig build release name: zig build release
runs-on: ubuntu-latest env:
timeout-minutes: 15 ARCH: aarch64
OS: linux
runs-on: ubuntu-24.04-arm
timeout-minutes: 20
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install - 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 - 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 - name: upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: lightpanda-build-release name: lightpanda-build-release
path: | path: |
@@ -45,7 +53,7 @@ jobs:
wpt-build-runner: wpt-build-runner:
name: build wpt runner name: build wpt runner
runs-on: ubuntu-latest runs-on: ubuntu-24.04-arm
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
@@ -59,7 +67,7 @@ jobs:
CGO_ENABLED=0 go build CGO_ENABLED=0 go build
- name: upload artifact - name: upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: wptrunner name: wptrunner
path: | path: |
@@ -73,8 +81,8 @@ jobs:
- wpt-build-runner - wpt-build-runner
# use a self host runner. # use a self host runner.
runs-on: lpd-bench-hetzner runs-on: lpd-wpt-aws
timeout-minutes: 180 timeout-minutes: 600
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
@@ -91,14 +99,14 @@ jobs:
run: ./wpt manifest run: ./wpt manifest
- name: download lightpanda release - name: download lightpanda release
uses: actions/download-artifact@v4 uses: actions/download-artifact@v8
with: with:
name: lightpanda-build-release name: lightpanda-build-release
- run: chmod a+x ./lightpanda - run: chmod a+x ./lightpanda
- name: download wptrunner - name: download wptrunner
uses: actions/download-artifact@v4 uses: actions/download-artifact@v8
with: with:
name: wptrunner name: wptrunner
@@ -107,8 +115,8 @@ jobs:
- name: run test with json output - name: run test with json output
run: | run: |
./wpt serve 2> /dev/null & echo $! > WPT.pid ./wpt serve 2> /dev/null & echo $! > WPT.pid
sleep 10s sleep 20s
./wptrunner -lpd-path ./lightpanda -json -concurrency 10 -pool 3 > wpt.json ./wptrunner -lpd-path ./lightpanda -json -concurrency 5 -pool 5 --mem-limit 400 > wpt.json
kill `cat WPT.pid` kill `cat WPT.pid`
- name: write commit - name: write commit
@@ -116,7 +124,7 @@ jobs:
echo "${{github.sha}}" > commit.txt echo "${{github.sha}}" > commit.txt
- name: upload artifact - name: upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: wpt-results name: wpt-results
path: | path: |
@@ -139,7 +147,7 @@ jobs:
steps: steps:
- name: download artifact - name: download artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v8
with: with:
name: wpt-results name: wpt-results

View File

@@ -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

View File

@@ -5,19 +5,18 @@ env:
AWS_SECRET_ACCESS_KEY: ${{ secrets.LPD_PERF_AWS_SECRET_ACCESS_KEY }} AWS_SECRET_ACCESS_KEY: ${{ secrets.LPD_PERF_AWS_SECRET_ACCESS_KEY }}
AWS_BUCKET: ${{ vars.LPD_PERF_AWS_BUCKET }} AWS_BUCKET: ${{ vars.LPD_PERF_AWS_BUCKET }}
AWS_REGION: ${{ vars.LPD_PERF_AWS_REGION }} AWS_REGION: ${{ vars.LPD_PERF_AWS_REGION }}
LIGHTPANDA_DISABLE_TELEMETRY: true
on: on:
push: push:
branches: branches: [main]
- main
paths: paths:
- "build.zig"
- "src/**"
- "vendor/zig-js-runtime"
- ".github/**" - ".github/**"
- "vendor/**" - "src/**"
pull_request: - "build.zig"
- "build.zig.zon"
pull_request:
# By default GH trigger on types opened, synchronize and reopened. # 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 # 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 # Since we skip the job when the PR is in draft state, we want to force CI
@@ -27,28 +26,63 @@ on:
paths: paths:
- ".github/**" - ".github/**"
- "src/**"
- "build.zig" - "build.zig"
- "src/**/*.zig" - "build.zig.zon"
- "src/*.zig"
- "vendor/**"
- ".github/**"
- "vendor/**"
# Allows you to run this workflow manually from the Actions tab # Allows you to run this workflow manually from the Actions tab
workflow_dispatch: workflow_dispatch:
jobs: jobs:
zig-test-debug: zig-fmt:
name: zig test using v8 in debug mode name: zig fmt
timeout-minutes: 15
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15
if: github.event.pull_request.draft == false
steps: 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: with:
fetch-depth: 0 fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install - uses: ./.github/actions/install
with: with:
@@ -57,21 +91,18 @@ jobs:
- name: zig build test - name: zig build test
run: zig build -Dprebuilt_v8_path=v8/libc_v8_debug.a -Dtsan=true test run: zig build -Dprebuilt_v8_path=v8/libc_v8_debug.a -Dtsan=true test
zig-test: zig-test-release:
name: zig test 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 runs-on: ubuntu-latest
timeout-minutes: 15
if: github.event.pull_request.draft == false
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
# fetch submodules recusively, to get zig-js-runtime submodules also.
submodules: recursive
- uses: ./.github/actions/install - uses: ./.github/actions/install
@@ -83,7 +114,7 @@ jobs:
echo "${{github.sha}}" > commit.txt echo "${{github.sha}}" > commit.txt
- name: upload artifact - name: upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: bench-results name: bench-results
path: | path: |
@@ -93,23 +124,22 @@ jobs:
bench-fmt: bench-fmt:
name: perf-fmt name: perf-fmt
needs: zig-test needs: zig-test-release
# Don't execute on PR
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
if: github.event_name != 'pull_request'
container: container:
image: ghcr.io/lightpanda-io/perf-fmt:latest image: ghcr.io/lightpanda-io/perf-fmt:latest
credentials: credentials:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: download artifact - name: download artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v8
with: with:
name: bench-results name: bench-results

View File

@@ -3,7 +3,7 @@ FROM debian:stable-slim
ARG MINISIG=0.12 ARG MINISIG=0.12
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
ARG V8=14.0.365.4 ARG V8=14.0.365.4
ARG ZIG_V8=v0.3.3 ARG ZIG_V8=v0.3.4
ARG TARGETPLATFORM ARG TARGETPLATFORM
RUN apt-get update -yq && \ RUN apt-get update -yq && \

View File

@@ -47,7 +47,7 @@ help:
# $(ZIG) commands # $(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
build-v8-snapshot: build-v8-snapshot:
@@ -77,11 +77,6 @@ run-debug: build-dev
@printf "\033[36mRunning...\033[0m\n" @printf "\033[36mRunning...\033[0m\n"
@./zig-out/bin/lightpanda || (printf "\033[33mRun ERROR\033[0m\n"; exit 1;) @./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 ## Test - `grep` is used to filter out the huge compile command on build
ifeq ($(OS), macos) ifeq ($(OS), macos)
test: test:
@@ -106,4 +101,3 @@ install: build
data: data:
cd src/data && go run public_suffix_list_gen.go > public_suffix_list.zig cd src/data && go run public_suffix_list_gen.go > public_suffix_list.zig

View File

@@ -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. 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 ## Build from sources
### Prerequisites ### 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. install it with the right version in order to build the project.
Lightpanda also depends on 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). [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**: For **Debian/Ubuntu based Linux**:

View File

@@ -27,12 +27,14 @@ pub fn build(b: *Build) !void {
const manifest = Manifest.init(b); const manifest = Manifest.init(b);
const git_commit = b.option([]const u8, "git_commit", "Current git commit"); 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 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"); const snapshot_path = b.option([]const u8, "snapshot_path", "Path to v8 snapshot");
var opts = b.addOptions(); var opts = b.addOptions();
opts.addOption([]const u8, "version", manifest.version); opts.addOption([]const u8, "version", manifest.version);
opts.addOption([]const u8, "git_commit", git_commit orelse "dev"); 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); opts.addOption(?[]const u8, "snapshot_path", snapshot_path);
const enable_tsan = b.option(bool, "tsan", "Enable Thread Sanitizer") orelse false; const enable_tsan = b.option(bool, "tsan", "Enable Thread Sanitizer") orelse false;

View File

@@ -5,8 +5,8 @@
.minimum_zig_version = "0.15.2", .minimum_zig_version = "0.15.2",
.dependencies = .{ .dependencies = .{
.v8 = .{ .v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.3.tar.gz", .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.4.tar.gz",
.hash = "v8-0.0.0-xddH6yx3BAAGD9jSoq_ttt_bk9MectTU44s_HZxxE5LD", .hash = "v8-0.0.0-xddH6_F3BAAiFvKY6R1H-gkuQlk19BkDQ0--uZuTrSup",
}, },
// .v8 = .{ .path = "../zig-v8-fork" }, // .v8 = .{ .path = "../zig-v8-fork" },
.brotli = .{ .brotli = .{

View File

@@ -67,7 +67,7 @@ pub fn init(allocator: Allocator, config: *const Config) !*App {
app.app_dir_path = getAndMakeAppDir(allocator); app.app_dir_path = getAndMakeAppDir(allocator);
app.telemetry = try Telemetry.init(app, config.mode); 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); app.arena_pool = ArenaPool.init(allocator, 512, 1024 * 16);
errdefer app.arena_pool.deinit(); errdefer app.arena_pool.deinit();
@@ -85,7 +85,7 @@ pub fn deinit(self: *App) void {
allocator.free(app_dir_path); allocator.free(app_dir_path);
self.app_dir_path = null; self.app_dir_path = null;
} }
self.telemetry.deinit(); self.telemetry.deinit(allocator);
self.network.deinit(); self.network.deinit();
self.snapshot.deinit(); self.snapshot.deinit();
self.platform.deinit(); self.platform.deinit();

View File

@@ -36,7 +36,9 @@ dom_node: *Node,
registry: *CDPNode.Registry, registry: *CDPNode.Registry,
page: *Page, page: *Page,
arena: std.mem.Allocator, 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 { pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!void {
var visitor = JsonVisitor{ .jw = jw, .tree = self }; 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 visibility_cache: Element.VisibilityCache = .empty;
var pointer_events_cache: Element.PointerEventsCache = .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 }); log.err(.app, "semantic tree json dump failed", .{ .err = err });
return error.WriteFailed; 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 visibility_cache: Element.VisibilityCache = .empty;
var pointer_events_cache: Element.PointerEventsCache = .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 }); log.err(.app, "semantic tree text dump failed", .{ .err = err });
return error.WriteFailed; return error.WriteFailed;
}; };
@@ -75,7 +77,7 @@ const OptionData = struct {
}; };
const NodeData = struct { const NodeData = struct {
id: u32, id: CDPNode.Id,
axn: AXNode, axn: AXNode,
role: []const u8, role: []const u8,
name: ?[]const u8, name: ?[]const u8,
@@ -86,7 +88,9 @@ const NodeData = struct {
node_name: []const u8, 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 // 1. Skip non-content nodes
if (node.is(Element)) |el| { if (node.is(Element)) |el| {
const tag = el.getTag(); 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; 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) { if (structural and !is_interactive and !has_explicit_label) {
should_visit = false; should_visit = false;
} }
@@ -217,7 +237,7 @@ fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_nam
} }
gop.value_ptr.* += 1; 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, depth: usize,
pub fn visit(self: *TextVisitor, node: *Node, data: *NodeData) !bool { pub fn visit(self: *TextVisitor, node: *Node, data: *NodeData) !bool {
// Format: " [12] link: Hacker News (value)" for (0..self.depth) |_| {
for (0..(self.depth * 2)) |_| {
try self.writer.writeByte(' '); 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 (data.name) |n| {
if (n.len > 0) { if (n.len > 0) {
try self.writer.writeAll(n); name_to_print = n;
} }
} else if (node.is(CData.Text)) |text_node| { } else if (node.is(CData.Text)) |text_node| {
const trimmed = std.mem.trim(u8, text_node.getWholeText(), " \t\r\n"); const trimmed = std.mem.trim(u8, text_node.getWholeText(), " \t\r\n");
if (trimmed.len > 0) { 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 (data.value) |v| {
if (v.len > 0) { if (v.len > 0) {
try self.writer.print(" (value: {s})", .{v}); try self.writer.print(" value='{s}'", .{v});
} }
} }
if (data.options) |options| { if (data.options) |options| {
try self.writer.writeAll(" options: ["); try self.writer.writeAll(" options=[");
for (options, 0..) |opt, i| { 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}); try self.writer.print("'{s}'", .{opt.value});
if (opt.selected) { if (opt.selected) {
try self.writer.writeAll(" (selected)"); try self.writer.writeAll("*");
} }
} }
try self.writer.writeAll("]\n"); 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 = &registry,
.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 = &registry,
.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);
}

View File

@@ -64,17 +64,17 @@ pub fn init(app: *App, address: net.Address) !*Server {
return self; return self;
} }
pub fn deinit(self: *Server) void { pub fn shutdown(self: *Server) void {
// Stop all active clients self.client_mutex.lock();
{ defer self.client_mutex.unlock();
self.client_mutex.lock();
defer self.client_mutex.unlock();
for (self.clients.items) |client| { for (self.clients.items) |client| {
client.stop(); client.stop();
}
} }
}
pub fn deinit(self: *Server) void {
self.shutdown();
self.joinThreads(); self.joinThreads();
self.clients.deinit(self.allocator); self.clients.deinit(self.allocator);
self.clients_pool.deinit(); self.clients_pool.deinit();
@@ -242,7 +242,10 @@ pub const Client = struct {
fn stop(self: *Client) void { fn stop(self: *Client) void {
switch (self.mode) { switch (self.mode) {
.http => {}, .http => {},
.cdp => |*cdp| cdp.browser.env.terminate(), .cdp => |*cdp| {
cdp.browser.env.terminate();
self.ws.sendClose();
},
} }
self.ws.shutdown(); self.ws.shutdown();
} }

View File

@@ -91,25 +91,32 @@ pub fn runMicrotasks(self: *Browser) void {
self.env.runMicrotasks(); self.env.runMicrotasks();
} }
pub fn runMacrotasks(self: *Browser) !?u64 { pub fn runMacrotasks(self: *Browser) !void {
const env = &self.env; const env = &self.env;
const time_to_next = try self.env.runMacrotasks(); try self.env.runMacrotasks();
env.pumpMessageLoop(); env.pumpMessageLoop();
// either of the above could have queued more microtasks // either of the above could have queued more microtasks
env.runMicrotasks(); env.runMicrotasks();
return time_to_next;
} }
pub fn hasBackgroundTasks(self: *Browser) bool { pub fn hasBackgroundTasks(self: *Browser) bool {
return self.env.hasBackgroundTasks(); return self.env.hasBackgroundTasks();
} }
pub fn waitForBackgroundTasks(self: *Browser) void { pub fn waitForBackgroundTasks(self: *Browser) void {
self.env.waitForBackgroundTasks(); 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 { pub fn runIdleTasks(self: *const Browser) void {
self.env.runIdleTasks(); self.env.runIdleTasks();
} }

View File

@@ -233,6 +233,12 @@ const DispatchDirectOptions = struct {
pub fn dispatchDirect(self: *EventManager, target: *EventTarget, event: *Event, handler: anytype, comptime opts: DispatchDirectOptions) !void { pub fn dispatchDirect(self: *EventManager, target: *EventTarget, event: *Event, handler: anytype, comptime opts: DispatchDirectOptions) !void {
const page = self.page; 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(); event.acquireRef();
defer event.deinit(false, page._session); defer event.deinit(false, page._session);
@@ -398,6 +404,13 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
} }
const page = self.page; 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; var was_handled = false;
// Create a single scope for all event handlers in this dispatch. // Create a single scope for all event handlers in this dispatch.

View File

@@ -110,6 +110,8 @@ use_proxy: bool,
// Current TLS verification state, applied per-connection in makeRequest. // Current TLS verification state, applied per-connection in makeRequest.
tls_verify: bool = true, tls_verify: bool = true,
obey_robots: bool,
cdp_client: ?CDPClient = null, cdp_client: ?CDPClient = null,
// libcurl can monitor arbitrary sockets, this lets us use libcurl to poll // 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, .http_proxy = http_proxy,
.use_proxy = http_proxy != null, .use_proxy = http_proxy != null,
.tls_verify = network.config.tlsVerifyHost(), .tls_verify = network.config.tlsVerifyHost(),
.obey_robots = network.config.obeyRobots(),
.transfer_pool = transfer_pool, .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 { pub fn request(self: *Client, req: Request) !void {
if (self.network.config.obeyRobots()) { if (self.obey_robots == false) {
const robots_url = try URL.getRobotsUrl(self.allocator, req.url); return self.processRequest(req);
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);
} }
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 { fn processRequest(self: *Client, req: Request) !void {

View File

@@ -25,6 +25,7 @@ params: []const u8 = "",
// We keep 41 for null-termination since HTML parser expects in this format. // We keep 41 for null-termination since HTML parser expects in this format.
charset: [41]u8 = default_charset, charset: [41]u8 = default_charset,
charset_len: usize = default_charset_len, charset_len: usize = default_charset_len,
is_default_charset: bool = true,
/// String "UTF-8" continued by null characters. /// String "UTF-8" continued by null characters.
const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36; 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: [41]u8 = default_charset;
var charset_len: usize = default_charset_len; var charset_len: usize = default_charset_len;
var has_explicit_charset = false;
var it = std.mem.splitScalar(u8, params, ';'); var it = std.mem.splitScalar(u8, params, ';');
while (it.next()) |attr| { while (it.next()) |attr| {
@@ -156,6 +158,7 @@ pub fn parse(input: []u8) !Mime {
// Null-terminate right after attribute value. // Null-terminate right after attribute value.
charset[attribute_value.len] = 0; charset[attribute_value.len] = 0;
charset_len = attribute_value.len; charset_len = attribute_value.len;
has_explicit_charset = true;
}, },
} }
} }
@@ -165,9 +168,137 @@ pub fn parse(input: []u8) !Mime {
.charset = charset, .charset = charset,
.charset_len = charset_len, .charset_len = charset_len,
.content_type = content_type, .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 `<meta charset="X">` and `<meta http-equiv="Content-Type" content="...;charset=X">`.
/// 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 <meta tags
var pos: usize = 0;
while (pos < data.len) {
// Find next '<'
pos = std.mem.indexOfScalarPos(u8, data, pos, '<') orelse return null;
pos += 1;
if (pos >= 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 { pub fn sniff(body: []const u8) ?Mime {
// 0x0C is form feed // 0x0C is form feed
const content = std.mem.trimLeft(u8, body, &.{ ' ', '\t', '\n', '\r', 0x0C }); 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 (content[0] != '<') {
if (std.mem.startsWith(u8, content, &.{ 0xEF, 0xBB, 0xBF })) { if (std.mem.startsWith(u8, content, &.{ 0xEF, 0xBB, 0xBF })) {
// UTF-8 BOM // 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 })) { if (std.mem.startsWith(u8, content, &.{ 0xFE, 0xFF })) {
// UTF-16 big-endian BOM // 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 })) { if (std.mem.startsWith(u8, content, &.{ 0xFF, 0xFE })) {
// UTF-16 little-endian BOM // 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; return null;
} }
@@ -540,6 +686,24 @@ test "Mime: sniff" {
try expectHTML("<!-->"); try expectHTML("<!-->");
try expectHTML(" \n\t <!-->"); 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 { const Expectation = struct {
@@ -576,3 +740,35 @@ fn expect(expected: Expectation, input: []const u8) !void {
try testing.expectEqual(m.charsetStringZ(), actual.charsetStringZ()); try testing.expectEqual(m.charsetStringZ(), actual.charsetStringZ());
} }
} }
test "Mime: prescanCharset" {
// <meta charset="X">
try testing.expectEqual("utf-8", Mime.prescanCharset("<html><head><meta charset=\"utf-8\">").?);
try testing.expectEqual("iso-8859-1", Mime.prescanCharset("<html><head><meta charset=\"iso-8859-1\">").?);
try testing.expectEqual("shift_jis", Mime.prescanCharset("<meta charset='shift_jis'>").?);
// Case-insensitive tag matching
try testing.expectEqual("utf-8", Mime.prescanCharset("<META charset=\"utf-8\">").?);
try testing.expectEqual("utf-8", Mime.prescanCharset("<Meta charset=\"utf-8\">").?);
// <meta http-equiv="Content-Type" content="text/html; charset=X">
try testing.expectEqual(
"iso-8859-1",
Mime.prescanCharset("<meta http-equiv=\"Content-Type\" content=\"text/html; charset=iso-8859-1\">").?,
);
// No charset found
try testing.expectEqual(null, Mime.prescanCharset("<html><head><title>Test</title>"));
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("<meta foo=\"bar\"/>"));
// Charset after 1024 bytes should not be found
var long_html: [1100]u8 = undefined;
@memset(&long_html, ' ');
const suffix = "<meta charset=\"windows-1252\">";
@memcpy(long_html[1050 .. 1050 + suffix.len], suffix);
try testing.expectEqual(null, Mime.prescanCharset(&long_html));
}

View File

@@ -63,6 +63,7 @@ const storage = @import("webapi/storage/storage.zig");
const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig"); const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig");
const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind; const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind;
const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig"); const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig");
const MouseEvent = @import("webapi/event/MouseEvent.zig");
const HttpClient = @import("HttpClient.zig"); const HttpClient = @import("HttpClient.zig");
const ArenaPool = App.ArenaPool; const ArenaPool = App.ArenaPool;
@@ -313,14 +314,16 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void
document._page = self; document._page = self;
if (comptime builtin.is_test == false) { if (comptime builtin.is_test == false) {
// HTML test runner manually calls these as necessary if (parent == null) {
try self.js.scheduler.add(session.browser, struct { // HTML test runner manually calls these as necessary
fn runIdleTasks(ctx: *anyopaque) !?u32 { try self.js.scheduler.add(session.browser, struct {
const b: *@import("Browser.zig") = @ptrCast(@alignCast(ctx)); fn runIdleTasks(ctx: *anyopaque) !?u32 {
b.runIdleTasks(); const b: *@import("Browser.zig") = @ptrCast(@alignCast(ctx));
return 200; b.runIdleTasks();
} return 200;
}.runIdleTasks, 200, .{ .name = "page.runIdleTasks", .low_priority = true }); }
}.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); 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 { pub fn lookupBlobUrl(self: *Page, url: []const u8) ?*Blob {
var current: ?*Page = self; return self._blob_urls.get(url);
while (current) |page| {
if (page._blob_urls.get(url)) |blob| {
return blob;
}
current = page.parent;
}
return null;
} }
pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !void { 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 // Content injection
if (is_blob) { 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 }); log.warn(.js, "invalid blob", .{ .url = request_url });
return error.BlobNotFound; return error.BlobNotFound;
}; };
@@ -716,11 +719,14 @@ pub fn scriptsCompletedLoading(self: *Page) void {
} }
pub fn iframeCompletedLoading(self: *Page, iframe: *IFrame) void { pub fn iframeCompletedLoading(self: *Page, iframe: *IFrame) void {
blk: { var ls: JS.Local.Scope = undefined;
var ls: JS.Local.Scope = undefined; self.js.localScope(&ls);
self.js.localScope(&ls); defer ls.deinit();
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| { const event = Event.initTrusted(comptime .wrap("load"), .{}, self) catch |err| {
log.err(.page, "iframe event init", .{ .err = err, .url = iframe._src }); log.err(.page, "iframe event init", .{ .err = err, .url = iframe._src });
break :blk; break :blk;
@@ -729,6 +735,7 @@ pub fn iframeCompletedLoading(self: *Page, iframe: *IFrame) void {
log.warn(.js, "iframe onload", .{ .err = err, .url = iframe._src }); log.warn(.js, "iframe onload", .{ .err = err, .url = iframe._src });
}; };
} }
self.pendingLoadCompleted(); self.pendingLoadCompleted();
} }
@@ -855,13 +862,25 @@ fn pageDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
if (self._parse_state == .pre) { if (self._parse_state == .pre) {
// we lazily do this, because we might need the first chunk of data // we lazily do this, because we might need the first chunk of data
// to sniff the content type // to sniff the content type
const mime: Mime = blk: { var mime: Mime = blk: {
if (transfer.response_header.?.contentType()) |ct| { if (transfer.response_header.?.contentType()) |ct| {
break :blk try Mime.parse(ct); break :blk try Mime.parse(ct);
} }
break :blk Mime.sniff(data); break :blk Mime.sniff(data);
} orelse .unknown; } orelse .unknown;
// If the HTTP Content-Type header didn't specify a charset and this is HTML,
// prescan the first 1024 bytes for a <meta charset> 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) { if (comptime IS_DEBUG) {
log.debug(.page, "navigate first chunk", .{ log.debug(.page, "navigate first chunk", .{
.content_type = mime.content_type, .content_type = mime.content_type,
@@ -3273,14 +3292,14 @@ pub fn triggerMouseClick(self: *Page, x: f64, y: f64) !void {
.type = self._type, .type = self._type,
}); });
} }
const event = (try @import("webapi/event/MouseEvent.zig").init("click", .{ const mouse_event: *MouseEvent = try .initTrusted(comptime .wrap("click"), .{
.bubbles = true, .bubbles = true,
.cancelable = true, .cancelable = true,
.composed = true, .composed = true,
.clientX = x, .clientX = x,
.clientY = y, .clientY = y,
}, self)).asEvent(); }, self);
try self._event_manager.dispatch(target.asEventTarget(), event); try self._event_manager.dispatch(target.asEventTarget(), mouse_event.asEvent());
} }
// callback when the "click" event reaches the pages. // 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"); const testing = @import("../testing.zig");
test "WebApi: Page" { test "WebApi: Page" {
const filter: testing.LogFilter = .init(.http); const filter: testing.LogFilter = .init(&.{ .http, .js });
defer filter.deinit(); defer filter.deinit();
try testing.htmlRunner("page", .{}); try testing.htmlRunner("page", .{});
} }
test "WebApi: Frames" { test "WebApi: Frames" {
const filter: testing.LogFilter = .init(&.{.js});
defer filter.deinit();
try testing.htmlRunner("frames", .{}); try testing.htmlRunner("frames", .{});
} }

View File

@@ -63,9 +63,6 @@ shutdown: bool = false,
client: *HttpClient, client: *HttpClient,
allocator: Allocator, allocator: Allocator,
buffer_pool: BufferPool,
script_pool: std.heap.MemoryPool(Script),
// We can download multiple sync modules in parallel, but we want to process // 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, // 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, .imported_modules = .empty,
.client = http_client, .client = http_client,
.static_scripts_done = false, .static_scripts_done = false,
.buffer_pool = BufferPool.init(allocator, 5),
.page_notified_of_completion = false, .page_notified_of_completion = false,
.script_pool = std.heap.MemoryPool(Script).init(allocator),
}; };
} }
pub fn deinit(self: *ScriptManager) void { 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.reset();
self.buffer_pool.deinit();
self.script_pool.deinit();
self.imported_modules.deinit(self.allocator); self.imported_modules.deinit(self.allocator);
// we don't deinit self.importmap b/c we use the page's arena for its // we don't deinit self.importmap b/c we use the page's arena for its
// allocations. // allocations.
@@ -121,7 +114,10 @@ pub fn deinit(self: *ScriptManager) void {
pub fn reset(self: *ScriptManager) void { pub fn reset(self: *ScriptManager) void {
var it = self.imported_modules.valueIterator(); var it = self.imported_modules.valueIterator();
while (it.next()) |value_ptr| { 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(); self.imported_modules.clearRetainingCapacity();
@@ -138,13 +134,13 @@ pub fn reset(self: *ScriptManager) void {
fn clearList(list: *std.DoublyLinkedList) void { fn clearList(list: *std.DoublyLinkedList) void {
while (list.popFirst()) |n| { while (list.popFirst()) |n| {
const script: *Script = @fieldParentPtr("node", 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(); var headers = try self.client.newHeaders();
try self.page.headersForRequest(self.page.arena, url, &headers); try self.page.headersForRequest(arena, url, &headers);
return headers; return headers;
} }
@@ -191,19 +187,26 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
return; return;
}; };
var handover = false;
const page = self.page; const page = self.page;
const arena = try page.getArena(.{ .debug = "addFromElement" });
errdefer if (!handover) {
page.releaseArena(arena);
};
var source: Script.Source = undefined; var source: Script.Source = undefined;
var remote_url: ?[:0]const u8 = null; var remote_url: ?[:0]const u8 = null;
const base_url = page.base(); const base_url = page.base();
if (element.getAttributeSafe(comptime .wrap("src"))) |src| { 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 }; source = .{ .@"inline" = data_uri };
} else { } else {
remote_url = try URL.resolve(page.arena, base_url, src, .{}); remote_url = try URL.resolve(arena, base_url, src, .{});
source = .{ .remote = .{} }; source = .{ .remote = .{} };
} }
} else { } 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 element.asNode().getChildTextContent(&buf.writer);
try buf.writer.writeByte(0); try buf.writer.writeByte(0);
const data = buf.written(); const data = buf.written();
@@ -211,6 +214,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
if (inline_source.len == 0) { if (inline_source.len == 0) {
// we haven't set script_element._executed = true yet, which is good. // we haven't set script_element._executed = true yet, which is good.
// If content is appended to the script, we will execute it then. // If content is appended to the script, we will execute it then.
page.releaseArena(arena);
return; return;
} }
source = .{ .@"inline" = inline_source }; 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 // Only set _executed (already-started) when we actually have content to execute
script_element._executed = true; script_element._executed = true;
const script = try self.script_pool.create();
errdefer self.script_pool.destroy(script);
const is_inline = source == .@"inline"; const is_inline = source == .@"inline";
const script = try arena.create(Script);
script.* = .{ script.* = .{
.kind = kind, .kind = kind,
.node = .{}, .node = .{},
.arena = arena,
.manager = self, .manager = self,
.source = source, .source = source,
.script_element = script_element, .script_element = script_element,
@@ -270,7 +272,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
if (is_blocking == false) { if (is_blocking == false) {
self.scriptList(script).remove(&script.node); 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(.{ try self.client.request(.{
@@ -278,7 +280,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
.ctx = script, .ctx = script,
.method = .GET, .method = .GET,
.frame_id = page._frame_id, .frame_id = page._frame_id,
.headers = try self.getHeaders(url), .headers = try self.getHeaders(arena, url),
.blocking = is_blocking, .blocking = is_blocking,
.cookie_jar = &page._session.cookie_jar, .cookie_jar = &page._session.cookie_jar,
.resource_type = .script, .resource_type = .script,
@@ -289,6 +291,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
.done_callback = Script.doneCallback, .done_callback = Script.doneCallback,
.error_callback = Script.errorCallback, .error_callback = Script.errorCallback,
}); });
handover = true;
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined; 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) { if (script.status == 0) {
// an error (that we already logged) // an error (that we already logged)
script.deinit(true); script.deinit();
return; return;
} }
@@ -327,7 +330,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
self.is_evaluating = true; self.is_evaluating = true;
defer { defer {
self.is_evaluating = was_evaluating; self.is_evaluating = was_evaluating;
script.deinit(true); script.deinit();
} }
return script.eval(page); 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); errdefer _ = self.imported_modules.remove(url);
const script = try self.script_pool.create(); const page = self.page;
errdefer self.script_pool.destroy(script); const arena = try page.getArena(.{ .debug = "preloadImport" });
errdefer page.releaseArena(arena);
const script = try arena.create(Script);
script.* = .{ script.* = .{
.kind = .module, .kind = .module,
.arena = arena,
.url = url, .url = url,
.node = .{}, .node = .{},
.manager = self, .manager = self,
@@ -373,11 +379,7 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
.mode = .import, .mode = .import,
}; };
gop.value_ptr.* = ImportedModule{ gop.value_ptr.* = ImportedModule{};
.manager = self,
};
const page = self.page;
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined; 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, .url = url,
.ctx = script, .ctx = script,
.method = .GET, .method = .GET,
.frame_id = page._frame_id, .frame_id = page._frame_id,
.headers = try self.getHeaders(url), .headers = try self.getHeaders(arena, url),
.cookie_jar = &page._session.cookie_jar, .cookie_jar = &page._session.cookie_jar,
.resource_type = .script, .resource_type = .script,
.notification = page._session.notification, .notification = page._session.notification,
@@ -406,13 +414,10 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
.data_callback = Script.dataCallback, .data_callback = Script.dataCallback,
.done_callback = Script.doneCallback, .done_callback = Script.doneCallback,
.error_callback = Script.errorCallback, .error_callback = Script.errorCallback,
}); }) catch |err| {
self.async_scripts.remove(&script.node);
// This seems wrong since we're not dealing with an async import (unlike return err;
// 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);
} }
pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource { 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); _ = try client.tick(200);
continue; continue;
}, },
.done => { .done => |script| {
var shared = false; var shared = false;
const buffer = entry.value_ptr.buffer; const buffer = entry.value_ptr.buffer;
const waiters = entry.value_ptr.waiters; const waiters = entry.value_ptr.waiters;
if (waiters == 0) { if (waiters == 1) {
self.imported_modules.removeByPtr(entry.key_ptr); self.imported_modules.removeByPtr(entry.key_ptr);
} else { } else {
shared = true; shared = true;
@@ -447,7 +452,7 @@ pub fn waitForImport(self: *ScriptManager, url: [:0]const u8) !ModuleSource {
return .{ return .{
.buffer = buffer, .buffer = buffer,
.shared = shared, .shared = shared,
.buffer_pool = &self.buffer_pool, .script = script,
}; };
}, },
.err => return error.Failed, .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 { 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(); const page = self.page;
errdefer self.script_pool.destroy(script); const arena = try page.getArena(.{ .debug = "getAsyncImport" });
errdefer page.releaseArena(arena);
const script = try arena.create(Script);
script.* = .{ script.* = .{
.kind = .module, .kind = .module,
.arena = arena,
.url = url, .url = url,
.node = .{}, .node = .{},
.manager = self, .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) { if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined; var ls: js.Local.Scope = undefined;
page.js.localScope(&ls); page.js.localScope(&ls);
@@ -496,11 +503,12 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C
self.is_evaluating = true; self.is_evaluating = true;
defer self.is_evaluating = was_evaluating; defer self.is_evaluating = was_evaluating;
try self.client.request(.{ self.async_scripts.append(&script.node);
self.client.request(.{
.url = url, .url = url,
.method = .GET, .method = .GET,
.frame_id = page._frame_id, .frame_id = page._frame_id,
.headers = try self.getHeaders(url), .headers = try self.getHeaders(arena, url),
.ctx = script, .ctx = script,
.resource_type = .script, .resource_type = .script,
.cookie_jar = &page._session.cookie_jar, .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, .data_callback = Script.dataCallback,
.done_callback = Script.doneCallback, .done_callback = Script.doneCallback,
.error_callback = Script.errorCallback, .error_callback = Script.errorCallback,
}); }) catch |err| {
self.async_scripts.remove(&script.node);
self.async_scripts.append(&script.node); return err;
};
} }
// Called from the Page to let us know it's done parsing the HTML. Necessary that // 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); var script: *Script = @fieldParentPtr("node", n);
switch (script.mode) { switch (script.mode) {
.async => { .async => {
defer script.deinit(true); defer script.deinit();
script.eval(page); script.eval(page);
}, },
.import_async => |ia| { .import_async => |ia| {
defer script.deinit(false);
if (script.status < 200 or script.status > 299) { if (script.status < 200 or script.status > 299) {
script.deinit();
ia.callback(ia.data, error.FailedToLoad); ia.callback(ia.data, error.FailedToLoad);
} else { } else {
ia.callback(ia.data, .{ ia.callback(ia.data, .{
.shared = false, .shared = false,
.script = script,
.buffer = script.source.remote, .buffer = script.source.remote,
.buffer_pool = &self.buffer_pool,
}); });
} }
}, },
@@ -574,7 +583,7 @@ fn evaluate(self: *ScriptManager) void {
} }
defer { defer {
_ = self.defer_scripts.popFirst(); _ = self.defer_scripts.popFirst();
script.deinit(true); script.deinit();
} }
script.eval(page); script.eval(page);
} }
@@ -625,11 +634,12 @@ fn parseImportmap(self: *ScriptManager, script: *const Script) !void {
} }
pub const Script = struct { pub const Script = struct {
complete: bool,
kind: Kind, kind: Kind,
complete: bool,
status: u16 = 0, status: u16 = 0,
source: Source, source: Source,
url: []const u8, url: []const u8,
arena: Allocator,
mode: ExecutionMode, mode: ExecutionMode,
node: std.DoublyLinkedList.Node, node: std.DoublyLinkedList.Node,
script_element: ?*Element.Html.Script, script_element: ?*Element.Html.Script,
@@ -680,11 +690,8 @@ pub const Script = struct {
import_async: ImportAsync, import_async: ImportAsync,
}; };
fn deinit(self: *Script, comptime release_buffer: bool) void { fn deinit(self: *Script) void {
if ((comptime release_buffer) and self.source == .remote) { self.manager.page.releaseArena(self.arena);
self.manager.buffer_pool.release(self.source.remote);
}
self.manager.script_pool.destroy(self);
} }
fn startCallback(transfer: *HttpClient.Transfer) !void { 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 }); 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| { if (transfer.getContentLength()) |cl| {
try buffer.ensureTotalCapacity(self.manager.allocator, cl); try buffer.ensureTotalCapacity(self.arena, cl);
} }
self.source = .{ .remote = buffer }; self.source = .{ .remote = buffer };
return true; return true;
@@ -766,7 +773,7 @@ pub const Script = struct {
}; };
} }
fn _dataCallback(self: *Script, _: *HttpClient.Transfer, data: []const u8) !void { 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 { fn doneCallback(ctx: *anyopaque) !void {
@@ -783,9 +790,8 @@ pub const Script = struct {
} else if (self.mode == .import) { } else if (self.mode == .import) {
manager.async_scripts.remove(&self.node); manager.async_scripts.remove(&self.node);
const entry = manager.imported_modules.getPtr(self.url).?; const entry = manager.imported_modules.getPtr(self.url).?;
entry.state = .done; entry.state = .{ .done = self };
entry.buffer = self.source.remote; entry.buffer = self.source.remote;
self.deinit(false);
} }
manager.evaluate(); manager.evaluate();
} }
@@ -811,7 +817,7 @@ pub const Script = struct {
const manager = self.manager; const manager = self.manager;
manager.scriptList(self).remove(&self.node); manager.scriptList(self).remove(&self.node);
if (manager.shutdown) { if (manager.shutdown) {
self.deinit(true); self.deinit();
return; return;
} }
@@ -823,7 +829,7 @@ pub const Script = struct {
}, },
else => {}, else => {},
} }
self.deinit(true); self.deinit();
manager.evaluate(); 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 { const ImportAsync = struct {
data: *anyopaque, data: *anyopaque,
callback: ImportAsync.Callback, callback: ImportAsync.Callback,
@@ -1030,12 +966,12 @@ const ImportAsync = struct {
pub const ModuleSource = struct { pub const ModuleSource = struct {
shared: bool, shared: bool,
buffer_pool: *BufferPool, script: *Script,
buffer: std.ArrayList(u8), buffer: std.ArrayList(u8),
pub fn deinit(self: *ModuleSource) void { pub fn deinit(self: *ModuleSource) void {
if (self.shared == false) { if (self.shared == false) {
self.buffer_pool.release(self.buffer); self.script.deinit();
} }
} }
@@ -1045,15 +981,14 @@ pub const ModuleSource = struct {
}; };
const ImportedModule = struct { const ImportedModule = struct {
manager: *ScriptManager, waiters: u16 = 1,
state: State = .loading, state: State = .loading,
buffer: std.ArrayList(u8) = .{}, buffer: std.ArrayList(u8) = .{},
waiters: u16 = 1,
const State = enum { const State = union(enum) {
err, err,
done,
loading, loading,
done: *Script,
}; };
}; };

View File

@@ -401,7 +401,7 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
// scheduler.run could trigger new http transfers, so do not // scheduler.run could trigger new http transfers, so do not
// store http_client.active BEFORE this call and then use // store http_client.active BEFORE this call and then use
// it AFTER. // it AFTER.
const ms_to_next_task = try browser.runMacrotasks(); try browser.runMacrotasks();
// Each call to this runs scheduled load events. // Each call to this runs scheduled load events.
try page.dispatchLoad(); try page.dispatchLoad();
@@ -423,16 +423,16 @@ fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
std.debug.assert(http_client.intercepted == 0); std.debug.assert(http_client.intercepted == 0);
} }
var ms: u64 = ms_to_next_task orelse blk: { var ms = blk: {
if (wait_ms - ms_remaining < 100) { // if (wait_ms - ms_remaining < 100) {
if (comptime builtin.is_test) { // if (comptime builtin.is_test) {
return .done; // return .done;
} // }
// Look, we want to exit ASAP, but we don't want // // Look, we want to exit ASAP, but we don't want
// to exit so fast that we've run none of the // // to exit so fast that we've run none of the
// background jobs. // // background jobs.
break :blk 50; // break :blk 50;
} // }
if (browser.hasBackgroundTasks()) { if (browser.hasBackgroundTasks()) {
// _we_ have nothing to run, but v8 is working on // _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; break :blk 20;
} }
// No http transfers, no cdp extra socket, no break :blk browser.msToNextMacrotask() orelse return .done;
// scheduled tasks, we're done.
return .done;
}; };
if (ms > ms_remaining) { 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 // We're here because we either have active HTTP
// connections, or exit_when_done == false (aka, there's // connections, or exit_when_done == false (aka, there's
// an cdp_socket registered with the http client). // an cdp_socket registered with the http client).
// We should continue to run lowPriority tasks, so we // We should continue to run tasks, so we minimize how long
// minimize how long we'll poll for network I/O. // we'll poll for network I/O.
var ms_to_wait = @min(200, ms_to_next_task orelse 200); var ms_to_wait = @min(200, browser.msToNextMacrotask() orelse 200);
if (ms_to_wait > 10 and browser.hasBackgroundTasks()) { if (ms_to_wait > 10 and browser.hasBackgroundTasks()) {
// if we have background tasks, we don't want to wait too // if we have background tasks, we don't want to wait too
// long for a message from the client. We want to go back // long for a message from the client. We want to go back

104
src/browser/actions.zig Normal file
View File

@@ -0,0 +1,104 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("../lightpanda.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;
};
}
}

View File

@@ -259,17 +259,52 @@ pub fn classifyInteractivity(
return null; return null;
} }
fn isInteractiveRole(role: []const u8) bool { pub fn isInteractiveRole(role: []const u8) bool {
const interactive_roles = [_][]const u8{ const MAX_LEN = "menuitemcheckbox".len;
"button", "link", "tab", "menuitem", if (role.len > MAX_LEN) return false;
"menuitemcheckbox", "menuitemradio", "switch", "checkbox", var buf: [MAX_LEN]u8 = undefined;
"radio", "slider", "spinbutton", "searchbox", const lowered = std.ascii.lowerString(&buf, role);
"combobox", "option", "treeitem", const interactive_roles = std.StaticStringMap(void).initComptime(.{
}; .{ "button", {} },
for (interactive_roles) |r| { .{ "checkbox", {} },
if (std.ascii.eqlIgnoreCase(role, r)) return true; .{ "combobox", {} },
} .{ "iframe", {} },
return false; .{ "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 { fn getRole(el: *Element) ?[]const u8 {

View File

@@ -40,8 +40,8 @@ prev_context: *Context,
// Takes the raw v8 isolate and extracts the context from it. // Takes the raw v8 isolate and extracts the context from it.
pub fn init(self: *Caller, v8_isolate: *v8.Isolate) void { pub fn init(self: *Caller, v8_isolate: *v8.Isolate) void {
const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate).?; const ctx, const v8_context = Context.fromIsolate(.{ .handle = v8_isolate });
initWithContext(self, Context.fromC(v8_context), v8_context); initWithContext(self, ctx, v8_context);
} }
fn initWithContext(self: *Caller, ctx: *Context, v8_context: *const v8.Context) void { 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 { 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_isolate = v8.v8__FunctionCallbackInfo__GetIsolate(info_handle).?;
const v8_context = v8.v8__Isolate__GetCurrentContext(v8_isolate).?; const ctx, const v8_context = Context.fromIsolate(.{ .handle = v8_isolate });
const ctx = Context.fromC(v8_context);
const info = FunctionCallbackInfo{ .handle = info_handle }; const info = FunctionCallbackInfo{ .handle = info_handle };
var hs: js.HandleScope = undefined; var hs: js.HandleScope = undefined;

View File

@@ -119,12 +119,22 @@ const ModuleEntry = struct {
resolver_promise: ?js.Promise.Global = null, 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))); return @ptrCast(@alignCast(v8.v8__Context__GetAlignedPointerFromEmbedderData(c_context, 1)));
} }
pub fn fromIsolate(isolate: js.Isolate) *Context { /// Returns the Context and v8::Context for the given isolate.
return fromC(v8.v8__Isolate__GetCurrentContext(isolate.handle).?); /// 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 { pub fn deinit(self: *Context) void {
@@ -155,6 +165,11 @@ pub fn deinit(self: *Context) void {
self.session.releaseOrigin(self.origin); 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); v8.v8__Global__Reset(&self.handle);
env.isolate.notifyContextDisposed(); env.isolate.notifyContextDisposed();
// There can be other tasks associated with this context that we need to // 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 env = self.env;
const isolate = env.isolate; const isolate = env.isolate;
lp.assert(self.origin.rc == 1, "Ref opaque origin", .{ .rc = self.origin.rc });
const origin = try self.session.getOrCreateOrigin(key); const origin = try self.session.getOrCreateOrigin(key);
errdefer self.session.releaseOrigin(origin); errdefer self.session.releaseOrigin(origin);
try origin.takeover(self.origin);
try self.origin.transferTo(origin);
lp.assert(self.origin.rc == 1, "Ref opaque origin", .{ .rc = self.origin.rc });
self.origin.deinit(env.app);
self.origin = origin; self.origin = origin;
@@ -255,6 +269,10 @@ pub fn toLocal(self: *Context, global: anytype) js.Local.ToLocalReturnType(@Type
return l.toLocal(global); return l.toLocal(global);
} }
pub fn getIncumbent(self: *Context) *Page {
return fromC(v8.v8__Isolate__GetIncumbentContext(self.env.isolate.handle).?).?.page;
}
pub fn stringToPersistedFunction( pub fn stringToPersistedFunction(
self: *Context, self: *Context,
function_body: []const u8, 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); 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); const m = try compileModule(local, src, owned_url);
if (cacheable) { if (cacheable) {
// compileModule is synchronous - nothing can modify the cache during compilation // compileModule is synchronous - nothing can modify the cache during compilation
lp.assert(gop.value_ptr.module == null, "Context.module has module", .{}); lp.assert(gop.value_ptr.module == null, "Context.module has module", .{});
gop.value_ptr.module = try m.persist(); gop.value_ptr.module = try m.persist();
if (!gop.found_existing) {
gop.key_ptr.* = owned_url;
}
} }
break :blk .{ m, owned_url }; break :blk .{ m, owned_url };
@@ -476,7 +494,7 @@ fn resolveModuleCallback(
) callconv(.c) ?*const v8.Module { ) callconv(.c) ?*const v8.Module {
_ = import_attributes; _ = import_attributes;
const self = fromC(c_context.?); const self = fromC(c_context.?).?;
const local = js.Local{ const local = js.Local{
.ctx = self, .ctx = self,
.handle = c_context.?, .handle = c_context.?,
@@ -509,7 +527,7 @@ pub fn dynamicModuleCallback(
_ = host_defined_options; _ = host_defined_options;
_ = import_attrs; _ = import_attrs;
const self = fromC(c_context.?); const self = fromC(c_context.?).?;
const local = js.Local{ const local = js.Local{
.ctx = self, .ctx = self,
.handle = c_context.?, .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 { 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.. // @HandleScope implement this without a fat context/local..
const self = fromC(c_context.?); const self = fromC(c_context.?).?;
var local = js.Local{ var local = js.Local{
.ctx = self, .ctx = self,
.handle = c_context.?, .handle = c_context.?,

View File

@@ -382,8 +382,7 @@ pub fn runMicrotasks(self: *Env) void {
} }
} }
pub fn runMacrotasks(self: *Env) !?u64 { pub fn runMacrotasks(self: *Env) !void {
var ms_to_next_task: ?u64 = null;
for (self.contexts[0..self.context_count]) |ctx| { for (self.contexts[0..self.context_count]) |ctx| {
if (comptime builtin.is_test == false) { if (comptime builtin.is_test == false) {
// I hate this comptime check as much as you do. But we have tests // 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; var hs: js.HandleScope = undefined;
const entered = ctx.enter(&hs); const entered = ctx.enter(&hs);
defer entered.exit(); defer entered.exit();
try ctx.scheduler.run();
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;
}
} }
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 { 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 { 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 promise_handle = v8.v8__PromiseRejectMessage__GetPromise(&message_handle).?;
const v8_isolate = v8.v8__Object__GetIsolate(@ptrCast(promise_handle)).?; const v8_isolate = v8.v8__Object__GetIsolate(@ptrCast(promise_handle)).?;
const js_isolate = js.Isolate{ .handle = v8_isolate }; const isolate = js.Isolate{ .handle = v8_isolate };
const ctx = Context.fromIsolate(js_isolate); const ctx, const v8_context = Context.fromIsolate(isolate);
const local = js.Local{ const local = js.Local{
.ctx = ctx, .ctx = ctx,
.isolate = js_isolate, .isolate = isolate,
.handle = v8.v8__Isolate__GetCurrentContext(v8_isolate).?, .handle = v8_context,
.call_arena = ctx.call_arena, .call_arena = ctx.call_arena,
}; };
const page = ctx.page; const page = ctx.page;
page.window.unhandledPromiseRejection(.{ page.window.unhandledPromiseRejection(promise_event == v8.kPromiseRejectWithNoHandler, .{
.local = &local, .local = &local,
.handle = &message_handle, .handle = &message_handle,
}, page) catch |err| { }, page) catch |err| {

View File

@@ -1212,6 +1212,12 @@ pub fn rejectPromise(self: *const Local, value: anytype) !js.Promise {
return resolver.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 { pub fn resolvePromise(self: *const Local, value: anytype) !js.Promise {
var resolver = js.PromiseResolver.init(self); var resolver = js.PromiseResolver.init(self);
resolver.resolve("Local.resolvePromise", value); resolver.resolve("Local.resolvePromise", value);

View File

@@ -68,6 +68,8 @@ temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
// if v8 hasn't called the finalizer directly itself. // if v8 hasn't called the finalizer directly itself.
finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty, finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *FinalizerCallback) = .empty,
taken_over: std.ArrayList(*Origin),
pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin { pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin {
const arena = try app.arena_pool.acquire(); const arena = try app.arena_pool.acquire();
errdefer app.arena_pool.release(arena); errdefer app.arena_pool.release(arena);
@@ -86,14 +88,19 @@ pub fn init(app: *App, isolate: js.Isolate, key: []const u8) !*Origin {
.rc = 1, .rc = 1,
.arena = arena, .arena = arena,
.key = owned_key, .key = owned_key,
.globals = .empty,
.temps = .empty, .temps = .empty,
.globals = .empty,
.taken_over = .empty,
.security_token = token_global, .security_token = token_global,
}; };
return self; return self;
} }
pub fn deinit(self: *Origin, app: *App) void { pub fn deinit(self: *Origin, app: *App) void {
for (self.taken_over.items) |o| {
o.deinit(app);
}
// Call finalizers before releasing anything // Call finalizers before releasing anything
{ {
var it = self.finalizer_callbacks.valueIterator(); var it = self.finalizer_callbacks.valueIterator();
@@ -196,42 +203,44 @@ pub fn createFinalizerCallback(
return fc; return fc;
} }
pub fn transferTo(self: *Origin, dest: *Origin) !void { pub fn takeover(self: *Origin, original: *Origin) !void {
const arena = dest.arena; const arena = self.arena;
try dest.globals.ensureUnusedCapacity(arena, self.globals.items.len); try self.globals.ensureUnusedCapacity(arena, original.globals.items.len);
for (self.globals.items) |obj| { for (original.globals.items) |obj| {
dest.globals.appendAssumeCapacity(obj); self.globals.appendAssumeCapacity(obj);
} }
self.globals.clearRetainingCapacity(); original.globals.clearRetainingCapacity();
{ {
try dest.temps.ensureUnusedCapacity(arena, self.temps.count()); try self.temps.ensureUnusedCapacity(arena, original.temps.count());
var it = self.temps.iterator(); var it = original.temps.iterator();
while (it.next()) |kv| { 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()); try self.finalizer_callbacks.ensureUnusedCapacity(arena, original.finalizer_callbacks.count());
var it = self.finalizer_callbacks.iterator(); var it = original.finalizer_callbacks.iterator();
while (it.next()) |kv| { while (it.next()) |kv| {
kv.value_ptr.*.origin = dest; kv.value_ptr.*.origin = self;
try dest.finalizer_callbacks.put(arena, kv.key_ptr.*, kv.value_ptr.*); 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()); try self.identity_map.ensureUnusedCapacity(arena, original.identity_map.count());
var it = self.identity_map.iterator(); var it = original.identity_map.iterator();
while (it.next()) |kv| { 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. // A type that has a finalizer can have its finalizer called one of two ways.

View File

@@ -18,7 +18,9 @@
const js = @import("js.zig"); const js = @import("js.zig");
const v8 = js.v8; const v8 = js.v8;
const log = @import("../../log.zig"); const log = @import("../../log.zig");
const DOMException = @import("../webapi/DOMException.zig");
const PromiseResolver = @This(); 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, generic: []const u8,
type_error: []const u8, type_error: []const u8,
dom_exception: anyerror,
}; };
pub fn rejectError(self: PromiseResolver, comptime source: []const u8, err: RejectError) void { pub fn rejectError(self: PromiseResolver, comptime source: []const u8, err: RejectError) void {
const handle = switch (err) { const handle = switch (err) {
.type_error => |str| self.local.isolate.createTypeError(str), .type_error => |str| self.local.isolate.createTypeError(str),
.generic => |str| self.local.isolate.createError(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| { self._reject(js.Value{ .handle = handle, .local = self.local }) catch |reject_err| {
log.err(.bug, "rejectError", .{ .source = source, .err = reject_err, .persistent = false }); log.err(.bug, "rejectError", .{ .source = source, .err = reject_err, .persistent = false });

View File

@@ -74,9 +74,10 @@ pub fn add(self: *Scheduler, ctx: *anyopaque, cb: Callback, run_in_ms: u32, opts
}); });
} }
pub fn run(self: *Scheduler) !?u64 { pub fn run(self: *Scheduler) !void {
_ = try self.runQueue(&self.low_priority); const now = milliTimestamp(.monotonic);
return self.runQueue(&self.high_priority); try self.runQueue(&self.low_priority, now);
try self.runQueue(&self.high_priority, now);
} }
pub fn hasReadyTasks(self: *Scheduler) bool { 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); return queueuHasReadyTask(&self.low_priority, now) or queueuHasReadyTask(&self.high_priority, now);
} }
fn runQueue(self: *Scheduler, queue: *Queue) !?u64 { pub fn msToNextHigh(self: *Scheduler) ?u64 {
if (queue.count() == 0) { const task = self.high_priority.peek() orelse return null;
return null;
}
const now = milliTimestamp(.monotonic); 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_| { while (queue.peek()) |*task_| {
if (task_.run_at > now) { if (task_.run_at > now) {
return @intCast(task_.run_at - now); return;
} }
var task = queue.remove(); var task = queue.remove();
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
@@ -114,7 +122,7 @@ fn runQueue(self: *Scheduler, queue: *Queue) !?u64 {
try self.low_priority.add(task); try self.low_priority.add(task);
} }
} }
return null; return;
} }
fn queueuHasReadyTask(queue: *Queue, now: u64) bool { fn queueuHasReadyTask(queue: *Queue, now: u64) bool {

View File

@@ -725,6 +725,8 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/collections.zig"), @import("../webapi/collections.zig"),
@import("../webapi/Console.zig"), @import("../webapi/Console.zig"),
@import("../webapi/Crypto.zig"), @import("../webapi/Crypto.zig"),
@import("../webapi/Permissions.zig"),
@import("../webapi/StorageManager.zig"),
@import("../webapi/CSS.zig"), @import("../webapi/CSS.zig"),
@import("../webapi/css/CSSRule.zig"), @import("../webapi/css/CSSRule.zig"),
@import("../webapi/css/CSSRuleList.zig"), @import("../webapi/css/CSSRuleList.zig"),
@@ -848,6 +850,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/event/FocusEvent.zig"), @import("../webapi/event/FocusEvent.zig"),
@import("../webapi/event/WheelEvent.zig"), @import("../webapi/event/WheelEvent.zig"),
@import("../webapi/event/TextEvent.zig"), @import("../webapi/event/TextEvent.zig"),
@import("../webapi/event/InputEvent.zig"),
@import("../webapi/event/PromiseRejectionEvent.zig"), @import("../webapi/event/PromiseRejectionEvent.zig"),
@import("../webapi/MessageChannel.zig"), @import("../webapi/MessageChannel.zig"),
@import("../webapi/MessagePort.zig"), @import("../webapi/MessagePort.zig"),

View File

@@ -124,352 +124,362 @@ fn hasVisibleContent(root: *Node) bool {
return false; return false;
} }
fn ensureNewline(state: *State, writer: *std.Io.Writer) !void { const Context = struct {
if (!state.last_char_was_newline) { state: State,
try writer.writeByte('\n'); writer: *std.Io.Writer,
state.last_char_was_newline = true; 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("alt"))) |alt| {
try self.escape(alt);
}
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 { pub fn dump(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) !void {
_ = opts; _ = opts;
var state = State{}; var ctx: Context = .{
try render(node, &state, writer, page); .state = .{},
if (!state.last_char_was_newline) { .writer = writer,
.page = page,
};
try ctx.render(node);
if (!ctx.state.last_char_was_newline) {
try writer.writeByte('\n'); 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("alt"))) |alt| {
try escapeMarkdown(writer, alt);
}
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 { fn testMarkdownHTML(html: []const u8, expected: []const u8) !void {
const testing = @import("../testing.zig"); const testing = @import("../testing.zig");
const page = try testing.test_session.createPage(); const page = try testing.test_session.createPage();

View File

@@ -24,11 +24,10 @@
<script id=byId name="test1"> <script id=byId name="test1">
testing.expectEqual(1, document.querySelector.length); testing.expectEqual(1, document.querySelector.length);
testing.expectError("SyntaxError: Syntax Error", () => document.querySelector('')); testing.expectError("SyntaxError", () => document.querySelector(''));
testing.withError((err) => { testing.withError((err) => {
testing.expectEqual(12, err.code); testing.expectEqual(12, err.code);
testing.expectEqual("SyntaxError", err.name); testing.expectEqual("SyntaxError", err.name);
testing.expectEqual("Syntax Error", err.message);
}, () => document.querySelector('')); }, () => document.querySelector(''));
testing.expectEqual('test1', document.querySelector('#byId').getAttribute('name')); testing.expectEqual('test1', document.querySelector('#byId').getAttribute('name'));

View File

@@ -34,11 +34,10 @@
</script> </script>
<script id=script1 name="test1"> <script id=script1 name="test1">
testing.expectError("SyntaxError: Syntax Error", () => document.querySelectorAll('')); testing.expectError("SyntaxError", () => document.querySelectorAll(''));
testing.withError((err) => { testing.withError((err) => {
testing.expectEqual(12, err.code); testing.expectEqual(12, err.code);
testing.expectEqual("SyntaxError", err.name); testing.expectEqual("SyntaxError", err.name);
testing.expectEqual("Syntax Error", err.message);
}, () => document.querySelectorAll('')); }, () => document.querySelectorAll(''));
</script> </script>

View File

@@ -127,7 +127,7 @@
testing.withError((err) => { testing.withError((err) => {
testing.expectEqual(3, err.code); 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 DOMException);
testing.expectEqual(true, err instanceof Error); testing.expectEqual(true, err instanceof Error);
}, () => link.appendChild(content)); }, () => link.appendChild(content));

View File

@@ -36,7 +36,6 @@
testing.withError((err) => { testing.withError((err) => {
testing.expectEqual(8, err.code); testing.expectEqual(8, err.code);
testing.expectEqual("NotFoundError", err.name); testing.expectEqual("NotFoundError", err.name);
testing.expectEqual("Not Found", err.message);
}, () => el1.removeAttributeNode(script_id_node)); }, () => el1.removeAttributeNode(script_id_node));
testing.expectEqual(an1, el1.removeAttributeNode(an1)); testing.expectEqual(an1, el1.removeAttributeNode(an1));

View File

@@ -12,7 +12,7 @@
testing.expectEqual('', $('#a0').href); testing.expectEqual('', $('#a0').href);
testing.expectEqual(testing.BASE_URL + 'element/anchor1.html', $('#a1').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('https://www.openmymind.net/Elixirs-With-Statement/', $('#a3').href);
testing.expectEqual(testing.BASE_URL + 'element/html/foo', $('#link').href); testing.expectEqual(testing.BASE_URL + 'element/html/foo', $('#link').href);

View File

@@ -32,7 +32,7 @@
testing.expectEqual(testing.BASE_URL + 'element/html/hello', form.action) testing.expectEqual(testing.BASE_URL + 'element/html/hello', form.action)
form.action = '/hello'; form.action = '/hello';
testing.expectEqual(testing.ORIGIN + 'hello', form.action) testing.expectEqual(testing.ORIGIN + '/hello', form.action)
form.action = 'https://lightpanda.io/hello'; form.action = 'https://lightpanda.io/hello';
testing.expectEqual('https://lightpanda.io/hello', form.action) testing.expectEqual('https://lightpanda.io/hello', form.action)
@@ -343,3 +343,123 @@
testing.expectEqual('', form.elements['choice'].value) testing.expectEqual('', form.elements['choice'].value)
} }
</script> </script>
<!-- Test: requestSubmit() fires the submit event (unlike submit()) -->
<form id="test_form2" action="/should-not-navigate2" method="get">
<input name="q" value="test2">
</form>
<script id="requestSubmit_fires_submit_event">
{
const form = $('#test_form2');
let submitFired = false;
form.addEventListener('submit', (e) => {
e.preventDefault();
submitFired = true;
});
form.requestSubmit();
testing.expectEqual(true, submitFired);
}
</script>
<!-- Test: requestSubmit() with preventDefault stops navigation -->
<form id="test_form3" action="/should-not-navigate3" method="get">
<input name="q" value="test3">
</form>
<script id="requestSubmit_respects_preventDefault">
{
const form = $('#test_form3');
form.addEventListener('submit', (e) => {
e.preventDefault();
});
form.requestSubmit();
// Form submission was prevented, so no navigation should be scheduled
testing.expectEqual(true, true);
}
</script>
<!-- Test: requestSubmit() with non-submit-button submitter throws TypeError -->
<form id="test_form_rs1" action="/should-not-navigate4" method="get">
<input id="rs1_text" type="text" name="q" value="test">
<input id="rs1_submit" type="submit" value="Go">
<input id="rs1_image" type="image" src="x.png">
<button id="rs1_btn_submit" type="submit">Submit</button>
<button id="rs1_btn_reset" type="reset">Reset</button>
<button id="rs1_btn_button" type="button">Button</button>
</form>
<script id="requestSubmit_rejects_non_submit_button">
{
const form = $('#test_form_rs1');
form.addEventListener('submit', (e) => e.preventDefault());
// A text input is not a submit button — should throw TypeError
testing.expectError('TypeError', () => {
form.requestSubmit($('#rs1_text'));
});
// A reset button is not a submit button — should throw TypeError
testing.expectError('TypeError', () => {
form.requestSubmit($('#rs1_btn_reset'));
});
// A <button type="button"> is not a submit button — should throw TypeError
testing.expectError('TypeError', () => {
form.requestSubmit($('#rs1_btn_button'));
});
// A <div> is not a submit button — should throw TypeError
const div = document.createElement('div');
form.appendChild(div);
testing.expectError('TypeError', () => {
form.requestSubmit(div);
});
}
</script>
<!-- Test: requestSubmit() accepts valid submit buttons -->
<script id="requestSubmit_accepts_submit_buttons">
{
const form = $('#test_form_rs1');
let submitCount = 0;
form.addEventListener('submit', (e) => { e.preventDefault(); submitCount++; });
// <input type="submit"> is a valid submitter
form.requestSubmit($('#rs1_submit'));
testing.expectEqual(1, submitCount);
// <input type="image"> is a valid submitter
form.requestSubmit($('#rs1_image'));
testing.expectEqual(2, submitCount);
// <button type="submit"> is a valid submitter
form.requestSubmit($('#rs1_btn_submit'));
testing.expectEqual(3, submitCount);
}
</script>
<!-- Test: requestSubmit() with submitter not owned by form throws NotFoundError -->
<form id="test_form_rs2" action="/should-not-navigate5" method="get">
<input type="text" name="q" value="test">
</form>
<form id="test_form_rs3">
<input id="rs3_submit" type="submit" value="Other Submit">
</form>
<script id="requestSubmit_rejects_wrong_form_submitter">
{
const form = $('#test_form_rs2');
// Submit button belongs to a different form — should throw NotFoundError
testing.expectError('NotFoundError', () => {
form.requestSubmit($('#rs3_submit'));
});
}
</script>

View File

@@ -37,7 +37,7 @@
testing.expectEqual('test.png', img.getAttribute('src')); testing.expectEqual('test.png', img.getAttribute('src'));
img.src = '/absolute/path.png'; 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')); testing.expectEqual('/absolute/path.png', img.getAttribute('src'));
img.src = 'https://example.com/image.png'; img.src = 'https://example.com/image.png';

View File

@@ -8,7 +8,7 @@
testing.expectEqual('https://lightpanda.io/opensource-browser/15', l2.href); testing.expectEqual('https://lightpanda.io/opensource-browser/15', l2.href);
l2.href = '/over/9000'; l2.href = '/over/9000';
testing.expectEqual(testing.ORIGIN + 'over/9000', l2.href); testing.expectEqual(testing.ORIGIN + '/over/9000', l2.href);
l2.crossOrigin = 'nope'; l2.crossOrigin = 'nope';
testing.expectEqual('anonymous', l2.crossOrigin); testing.expectEqual('anonymous', l2.crossOrigin);
@@ -84,3 +84,24 @@
testing.eventually(() => testing.expectEqual(true, result)); testing.eventually(() => testing.expectEqual(true, result));
} }
</script> </script>
<script id="refs">
{
const rels = ['stylesheet', 'preload', 'modulepreload'];
const results = rels.map(() => false);
rels.forEach((rel, i) => {
let link = document.createElement('link')
link.rel = rel;
link.href = '/nope';
link.onload = () => results[i] = true;
document.documentElement.appendChild(link);
});
testing.eventually(() => {
results.forEach((r) => {
testing.expectEqual(true, r);
});
});
}
</script>

View File

@@ -66,11 +66,10 @@
{ {
const container = $('#test-container'); const container = $('#test-container');
testing.expectError("SyntaxError: Syntax Error", () => container.matches('')); testing.expectError("SyntaxError", () => container.matches(''));
testing.withError((err) => { testing.withError((err) => {
testing.expectEqual(12, err.code); testing.expectEqual(12, err.code);
testing.expectEqual("SyntaxError", err.name); testing.expectEqual("SyntaxError", err.name);
testing.expectEqual("Syntax Error", err.message);
}, () => container.matches('')); }, () => container.matches(''));
} }
</script> </script>

View File

@@ -12,11 +12,10 @@
const p1 = $('#p1'); const p1 = $('#p1');
testing.expectEqual(null, p1.querySelector('#p1')); testing.expectEqual(null, p1.querySelector('#p1'));
testing.expectError("SyntaxError: Syntax Error", () => p1.querySelector('')); testing.expectError("SyntaxError", () => p1.querySelector(''));
testing.withError((err) => { testing.withError((err) => {
testing.expectEqual(12, err.code); testing.expectEqual(12, err.code);
testing.expectEqual("SyntaxError", err.name); testing.expectEqual("SyntaxError", err.name);
testing.expectEqual("Syntax Error", err.message);
}, () => p1.querySelector('')); }, () => p1.querySelector(''));
testing.expectEqual($('#c2'), p1.querySelector('#c2')); testing.expectEqual($('#c2'), p1.querySelector('#c2'));

View File

@@ -24,11 +24,10 @@
<script id=errors> <script id=errors>
{ {
const root = $('#root'); const root = $('#root');
testing.expectError("SyntaxError: Syntax Error", () => root.querySelectorAll('')); testing.expectError("SyntaxError", () => root.querySelectorAll(''));
testing.withError((err) => { testing.withError((err) => {
testing.expectEqual(12, err.code); testing.expectEqual(12, err.code);
testing.expectEqual("SyntaxError", err.name); testing.expectEqual("SyntaxError", err.name);
testing.expectEqual("Syntax Error", err.message);
}, () => root.querySelectorAll('')); }, () => root.querySelectorAll(''));
} }
</script> </script>

View File

@@ -43,8 +43,8 @@
const container = $('#container'); const container = $('#container');
// Empty selectors // Empty selectors
testing.expectError("SyntaxError: Syntax Error", () => container.querySelector('')); testing.expectError("SyntaxError", () => container.querySelector(''));
testing.expectError("SyntaxError: Syntax Error", () => document.querySelectorAll('')); testing.expectError("SyntaxError", () => document.querySelectorAll(''));
} }
</script> </script>

View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=onerrorFiveArguments>
let called = false;
let argCount = 0;
window.onerror = function() {
called = true;
argCount = arguments.length;
return true; // suppress default
};
try { undefinedVariable; } catch(e) { window.reportError(e); }
testing.expectEqual(true, called);
testing.expectEqual(5, argCount);
window.onerror = null;
</script>
<script id=onerrorCalledBeforeEventListener>
let callOrder = [];
window.onerror = function() { callOrder.push('onerror'); return true; };
window.addEventListener('error', function() { callOrder.push('listener'); });
try { undefinedVariable; } catch(e) { window.reportError(e); }
testing.expectEqual('onerror', callOrder[0]);
testing.expectEqual('listener', callOrder[1]);
window.onerror = null;
</script>
<script id=onerrorReturnTrueSuppresses>
let listenerCalled = false;
window.onerror = function() { return true; };
window.addEventListener('error', function(e) {
// listener still fires even when onerror returns true
listenerCalled = true;
});
try { undefinedVariable; } catch(e) { window.reportError(e); }
testing.expectEqual(true, listenerCalled);
window.onerror = null;
</script>

View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<iframe id="receiver"></iframe>
<script id="messages">
{
let reply = null;
window.addEventListener('message', (e) => {
console.warn('reply')
reply = e.data;
});
const iframe = $('#receiver');
iframe.src = 'support/message_receiver.html';
iframe.addEventListener('load', () => {
iframe.contentWindow.postMessage('ping', '*');
});
testing.eventually(() => {
testing.expectEqual('pong', reply.data);
testing.expectEqual(testing.ORIGIN, reply.origin);
});
}
</script>

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<script>
window.addEventListener('message', (e) => {
console.warn('Frame Message', e.data);
if (e.data === 'ping') {
window.top.postMessage({data: 'pong', origin: e.origin}, '*');
}
});
</script>

View File

@@ -2,37 +2,17 @@
<script src="testing.js"></script> <script src="testing.js"></script>
<script id=history> <script id=history>
testing.expectEqual('auto', history.scrollRestoration); // This test is a bit wonky. But it's trying to test navigation, which is
// something we can't do in the main page (we can't navigate away from this
history.scrollRestoration = 'manual'; // page and still assertOk in the test runner).
testing.expectEqual('manual', history.scrollRestoration); // If support/history.html has a failed assertion, it'll log the error and
// stop the script. If it succeeds, it'll set support_history_completed
history.scrollRestoration = 'auto'; // which we can use here to assume everything passed.
testing.expectEqual('auto', history.scrollRestoration);
testing.expectEqual(null, history.state)
history.pushState({ testInProgress: true }, null, 'http://127.0.0.1:9582/src/browser/tests/history_after_nav.skip.html');
testing.expectEqual({ testInProgress: true }, history.state);
history.pushState({ testInProgress: false }, null, 'http://127.0.0.1:9582/xhr/json');
history.replaceState({ "new": "field", testComplete: true }, null);
let state = { "new": "field", testComplete: true };
testing.expectEqual(state, history.state);
let popstateEventFired = false;
let popstateEventState = null;
window.addEventListener('popstate', (event) => {
popstateEventFired = true;
popstateEventState = event.state;
});
testing.eventually(() => { testing.eventually(() => {
testing.expectEqual(true, popstateEventFired); testing.expectEqual(true, window.support_history_completed);
testing.expectEqual({testInProgress: true }, popstateEventState); testing.expectEqual(true, window.support_history_popstateEventFired);
}) testing.expectEqual({testInProgress: true }, window.support_history_popstateEventState);
});
history.back();
</script> </script>
<iframe id=frame src="support/history.html"></iframe>

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<body>
<button id="btn" onclick="window.clicked = true;">Click Me</button>
<input id="inp" oninput="window.inputVal = this.value" onchange="window.changed = true;">
<select id="sel" onchange="window.selChanged = this.value">
<option value="opt1">Option 1</option>
<option value="opt2">Option 2</option>
</select>
<div id="scrollbox" style="width: 100px; height: 100px; overflow: scroll;" onscroll="window.scrolled = true;">
<div style="height: 500px;">Long content</div>
</div>
</body>
</html>

View File

@@ -27,3 +27,44 @@
testing.expectEqual(false, navigator.javaEnabled()); testing.expectEqual(false, navigator.javaEnabled());
testing.expectEqual(false, navigator.webdriver); testing.expectEqual(false, navigator.webdriver);
</script> </script>
<script id=permission_query>
testing.async(async (restore) => {
const p = navigator.permissions.query({ name: 'notifications' });
testing.expectTrue(p instanceof Promise);
const status = await p;
restore();
testing.expectEqual('prompt', status.state);
testing.expectEqual('notifications', status.name);
});
</script>
<script id=storage_estimate>
testing.async(async (restore) => {
const p = navigator.storage.estimate();
testing.expectTrue(p instanceof Promise);
const estimate = await p;
restore();
testing.expectEqual(0, estimate.usage);
testing.expectEqual(1024 * 1024 * 1024, estimate.quota);
});
</script>
<script id=deviceMemory>
testing.expectEqual(8, navigator.deviceMemory);
</script>
<script id=getBattery>
testing.async(async (restore) => {
const p = navigator.getBattery();
try {
await p;
testing.fail('getBattery should reject');
} catch (err) {
restore();
testing.expectEqual('NotSupportedError', err.name);
}
});
</script>

View File

@@ -203,3 +203,39 @@
testing.expectEqual(true, response.body !== null); testing.expectEqual(true, response.body !== null);
}); });
</script> </script>
<script id=fetch_blob_url>
testing.async(async (restore) => {
// Create a blob and get its URL
const blob = new Blob(['Hello from blob!'], { type: 'text/plain' });
const blobUrl = URL.createObjectURL(blob);
const response = await fetch(blobUrl);
restore();
testing.expectEqual(200, response.status);
testing.expectEqual(true, response.ok);
testing.expectEqual(blobUrl, response.url);
testing.expectEqual('text/plain', response.headers.get('Content-Type'));
const text = await response.text();
testing.expectEqual('Hello from blob!', text);
// Clean up
URL.revokeObjectURL(blobUrl);
});
</script>
<script id=abort>
testing.async(async (restore) => {
const controller = new AbortController();
controller.abort();
try {
await fetch('http://127.0.0.1:9582/xhr', { signal: controller.signal });
testain.fail('fetch should have been aborted');
} catch (e) {
restore();
testing.expectEqual("AbortError", e.name);
}
});
</script>

View File

@@ -283,3 +283,26 @@
testing.expectEqual(XMLHttpRequest.UNSENT, req.readyState); testing.expectEqual(XMLHttpRequest.UNSENT, req.readyState);
}); });
</script> </script>
<script id=xhr_blob_url>
testing.async(async (restore) => {
// Create a blob and get its URL
const blob = new Blob(['Hello from blob!'], { type: 'text/plain' });
const blobUrl = URL.createObjectURL(blob);
const req = new XMLHttpRequest();
await new Promise((resolve) => {
req.onload = resolve;
req.open('GET', blobUrl);
req.send();
});
restore();
testing.expectEqual(200, req.status);
testing.expectEqual('Hello from blob!', req.responseText);
testing.expectEqual(blobUrl, req.responseURL);
// Clean up
URL.revokeObjectURL(blobUrl);
});
</script>

View File

@@ -19,7 +19,6 @@
testing.withError((err) => { testing.withError((err) => {
testing.expectEqual(8, err.code); testing.expectEqual(8, err.code);
testing.expectEqual("NotFoundError", err.name); testing.expectEqual("NotFoundError", err.name);
testing.expectEqual("Not Found", err.message);
}, () => d1.insertBefore(document.createElement('div'), d2)); }, () => d1.insertBefore(document.createElement('div'), d2));
let c1 = document.createElement('div'); let c1 = document.createElement('div');

View File

@@ -7,7 +7,6 @@
testing.withError((err) => { testing.withError((err) => {
testing.expectEqual(8, err.code); testing.expectEqual(8, err.code);
testing.expectEqual("NotFoundError", err.name); testing.expectEqual("NotFoundError", err.name);
testing.expectEqual("Not Found", err.message);
}, () => $('#d1').removeChild($('#p1'))); }, () => $('#d1').removeChild($('#p1')));
const p1 = $('#p1'); const p1 = $('#p1');

View File

@@ -25,7 +25,6 @@
testing.withError((err) => { testing.withError((err) => {
testing.expectEqual(3, err.code); testing.expectEqual(3, err.code);
testing.expectEqual("HierarchyRequestError", err.name); testing.expectEqual("HierarchyRequestError", err.name);
testing.expectEqual("Hierarchy Error", err.message);
}, () => d1.replaceChild(c4, c3)); }, () => d1.replaceChild(c4, c3));
testing.expectEqual(c2, d1.replaceChild(c4, c2)); testing.expectEqual(c2, d1.replaceChild(c4, c2));

View File

@@ -451,12 +451,12 @@
const p1 = $('#p1'); const p1 = $('#p1');
// Test setStart with offset beyond node length // 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); range.setStart(p1, 999);
}); });
// Test with negative offset (wraps to large u32) // 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); range.setStart(p1.firstChild, -1);
}); });
} }
@@ -468,12 +468,12 @@
const p1 = $('#p1'); const p1 = $('#p1');
// Test setEnd with offset beyond node length // 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); range.setEnd(p1, 999);
}); });
// Test with text node // 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); range.setEnd(p1.firstChild, 9999);
}); });
} }
@@ -525,11 +525,11 @@
range.setEnd(p1, 1); range.setEnd(p1, 1);
// Test comparePoint with invalid offset // 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); 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); range.comparePoint(p1.firstChild, -1);
}); });
} }
@@ -650,11 +650,11 @@
range.setEnd(p1, 1); range.setEnd(p1, 1);
// Invalid offset should throw IndexSizeError // 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); 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); range.isPointInRange(p1.firstChild, 9999);
}); });
} }
@@ -854,11 +854,11 @@
range2.setStart(p, 0); range2.setStart(p, 0);
// Invalid how parameter should throw NotSupportedError // Invalid how parameter should throw NotSupportedError
testing.expectError('NotSupportedError: Not Supported', () => { testing.expectError('NotSupportedError:', () => {
range1.compareBoundaryPoints(4, range2); range1.compareBoundaryPoints(4, range2);
}); });
testing.expectError('NotSupportedError: Not Supported', () => { testing.expectError('NotSupportedError:', () => {
range1.compareBoundaryPoints(99, range2); range1.compareBoundaryPoints(99, range2);
}); });
} }
@@ -883,7 +883,7 @@
range2.setEnd(foreignP, 1); range2.setEnd(foreignP, 1);
// Comparing ranges in different documents should throw WrongDocumentError // Comparing ranges in different documents should throw WrongDocumentError
testing.expectError('WrongDocumentError: wrong_document_error', () => { testing.expectError('WrongDocumentError:', () => {
range1.compareBoundaryPoints(Range.START_TO_START, range2); range1.compareBoundaryPoints(Range.START_TO_START, range2);
}); });
} }

View File

@@ -5,7 +5,7 @@
<div id="host2"></div> <div id="host2"></div>
<div id="host3"></div> <div id="host3"></div>
<!-- <script id="attachShadow_open"> <script id="attachShadow_open">
{ {
const host = $('#host1'); const host = $('#host1');
const shadow = host.attachShadow({ mode: 'open' }); const shadow = host.attachShadow({ mode: 'open' });
@@ -140,7 +140,7 @@
shadow.replaceChildren('New content'); shadow.replaceChildren('New content');
testing.expectEqual('New content', shadow.innerHTML); testing.expectEqual('New content', shadow.innerHTML);
} }
</script> --> </script>
<script id="getElementById"> <script id="getElementById">
{ {
@@ -154,3 +154,16 @@
testing.expectEqual(null, shadow.getElementById('nonexistent')); testing.expectEqual(null, shadow.getElementById('nonexistent'));
} }
</script> </script>
<script id=adoptedStyleSheets>
{
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
const acss = shadow.adoptedStyleSheets;
testing.expectEqual(0, acss.length);
acss.push(new CSSStyleSheet());
testing.expectEqual(1, acss.length);
}
</script>

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=history>
testing.expectEqual('auto', history.scrollRestoration);
history.scrollRestoration = 'manual';
testing.expectEqual('manual', history.scrollRestoration);
history.scrollRestoration = 'auto';
testing.expectEqual('auto', history.scrollRestoration);
testing.expectEqual(null, history.state)
history.pushState({ testInProgress: true }, null, testing.BASE_URL + 'history_after_nav.skip.html');
testing.expectEqual({ testInProgress: true }, history.state);
history.pushState({ testInProgress: false }, null, testing.ORIGIN + '/xhr/json');
history.replaceState({ "new": "field", testComplete: true }, null);
let state = { "new": "field", testComplete: true };
testing.expectEqual(state, history.state);
let popstateEventFired = false;
let popstateEventState = null;
window.top.support_history_completed = true;
window.addEventListener('popstate', (event) => {
window.top.window.support_history_popstateEventFired = true;
window.top.window.support_history_popstateEventState = event.state;
});
history.back();
</script>

View File

@@ -99,8 +99,7 @@
} }
} }
// our test runner sets this to true const IS_TEST_RUNNER = window.navigator.userAgent.startsWith("Lightpanda/");
const IS_TEST_RUNNER = window._lightpanda_skip_auto_assert === true;
window.testing = { window.testing = {
fail: fail, fail: fail,
@@ -114,17 +113,17 @@
eventually: eventually, eventually: eventually,
IS_TEST_RUNNER: IS_TEST_RUNNER, IS_TEST_RUNNER: IS_TEST_RUNNER,
HOST: '127.0.0.1', 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/', 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 // 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 // a test is correct. There are a few tweaks we need to do to make this a
// seemless, namely around adapting paths/urls. // seemless, namely around adapting paths/urls.
console.warn(`The page is not being executed in the test runner, certain behavior has been adjusted`); console.warn(`The page is not being executed in the test runner, certain behavior has been adjusted`);
window.testing.HOST = location.hostname; 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.testing.BASE_URL = location.origin + '/src/browser/tests/';
window.addEventListener('load', testing.assertOk); window.addEventListener('load', testing.assertOk);
} }

View File

@@ -82,7 +82,7 @@
testing.expectEqual('ceil', atob('Y2VpbA')); // 6 chars, len%4==2, needs '==' testing.expectEqual('ceil', atob('Y2VpbA')); // 6 chars, len%4==2, needs '=='
// length % 4 == 1 must still throw // length % 4 == 1 must still throw
testing.expectError('InvalidCharacterError: Invalid Character', () => { testing.expectError('InvalidCharacterError', () => {
atob('Y'); atob('Y');
}); });
</script> </script>

View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=windowEventUndefinedOutsideHandler>
testing.expectEqual(undefined, window.event);
</script>
<script id=windowEventSetDuringWindowHandler>
var capturedEvent = null;
window.addEventListener('test-event', function(e) {
capturedEvent = window.event;
});
var ev = new Event('test-event');
window.dispatchEvent(ev);
testing.expectEqual(ev, capturedEvent);
testing.expectEqual(undefined, window.event);
</script>
<script id=windowEventRestoredAfterHandler>
var captured2 = null;
window.addEventListener('test-event-2', function(e) {
captured2 = window.event;
});
var ev2 = new Event('test-event-2');
window.dispatchEvent(ev2);
testing.expectEqual(ev2, captured2);
testing.expectEqual(undefined, window.event);
</script>

View File

@@ -104,13 +104,27 @@ pub fn getMessage(self: *const DOMException) []const u8 {
} }
return switch (self._code) { return switch (self._code) {
.none => "", .none => "",
.invalid_character_error => "Invalid Character",
.index_size_error => "Index or size is negative or greater than the allowed amount", .index_size_error => "Index or size is negative or greater than the allowed amount",
.syntax_error => "Syntax Error", .hierarchy_error => "The operation would yield an incorrect node tree",
.not_supported => "Not Supported", .wrong_document_error => "The object is in the wrong document",
.not_found => "Not Found", .invalid_character_error => "The string contains invalid characters",
.hierarchy_error => "Hierarchy Error", .no_modification_allowed_error => "The object can not be modified",
else => @tagName(self._code), .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",
}; };
} }

View File

@@ -365,6 +365,11 @@ pub fn createEvent(_: *const Document, event_type: []const u8, page: *Page) !*@i
return (try KeyboardEvent.init("", null, page)).asEvent(); 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")) { if (std.mem.eql(u8, normalized, "mouseevent") or std.mem.eql(u8, normalized, "mouseevents")) {
const MouseEvent = @import("event/MouseEvent.zig"); const MouseEvent = @import("event/MouseEvent.zig");
return (try MouseEvent.init("", null, page)).asEvent(); return (try MouseEvent.init("", null, page)).asEvent();

View File

@@ -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 { pub fn deinit(self: *IntersectionObserver, shutdown: bool, session: *Session) void {
self._callback.release(); if (shutdown) {
if ((comptime IS_DEBUG) and !shutdown) { self._callback.release();
std.debug.assert(self._observing.items.len == 0); 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 { 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 // Register with page if this is our first observation
if (self._observing.items.len == 0) { if (self._observing.items.len == 0) {
page.js.strongRef(self);
try page.registerIntersectionObserver(self); try page.registerIntersectionObserver(self);
} }
@@ -146,22 +145,18 @@ pub fn unobserve(self: *IntersectionObserver, target: *Element, page: *Page) voi
break; break;
} }
} }
if (self._observing.items.len == 0) {
page.js.safeWeakRef(self);
}
} }
pub fn disconnect(self: *IntersectionObserver, page: *Page) void { pub fn disconnect(self: *IntersectionObserver, page: *Page) void {
page.unregisterIntersectionObserver(self);
self._observing.clearRetainingCapacity();
self._previous_states.clearRetainingCapacity(); self._previous_states.clearRetainingCapacity();
for (self._pending_entries.items) |entry| { for (self._pending_entries.items) |entry| {
entry.deinit(false, page._session); entry.deinit(false, page._session);
} }
self._pending_entries.clearRetainingCapacity(); self._pending_entries.clearRetainingCapacity();
page.js.safeWeakRef(self);
self._observing.clearRetainingCapacity();
page.unregisterIntersectionObserver(self);
} }
pub fn takeRecords(self: *IntersectionObserver, page: *Page) ![]*IntersectionObserverEntry { pub fn takeRecords(self: *IntersectionObserver, page: *Page) ![]*IntersectionObserverEntry {
@@ -363,7 +358,6 @@ pub const JsApi = struct {
pub const name = "IntersectionObserver"; pub const name = "IntersectionObserver";
pub const prototype_chain = bridge.prototypeChain(); pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined; pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(IntersectionObserver.deinit); pub const finalizer = bridge.finalizer(IntersectionObserver.deinit);
}; };

View File

@@ -86,12 +86,12 @@ pub fn init(callback: js.Function.Temp, page: *Page) !*MutationObserver {
} }
pub fn deinit(self: *MutationObserver, shutdown: bool, session: *Session) void { pub fn deinit(self: *MutationObserver, shutdown: bool, session: *Session) void {
self._callback.release(); if (shutdown) {
if ((comptime IS_DEBUG) and !shutdown) { self._callback.release();
std.debug.assert(self._observing.items.len == 0); 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 { 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 // Register with page if this is our first observation
if (self._observing.items.len == 0) { if (self._observing.items.len == 0) {
page.js.strongRef(self);
try page.registerMutationObserver(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 { pub fn disconnect(self: *MutationObserver, page: *Page) void {
page.unregisterMutationObserver(self);
self._observing.clearRetainingCapacity();
for (self._pending_records.items) |record| { for (self._pending_records.items) |record| {
record.deinit(false, page._session); record.deinit(false, page._session);
} }
self._pending_records.clearRetainingCapacity(); self._pending_records.clearRetainingCapacity();
page.js.safeWeakRef(self);
self._observing.clearRetainingCapacity();
page.unregisterMutationObserver(self);
} }
pub fn takeRecords(self: *MutationObserver, page: *Page) ![]*MutationRecord { pub fn takeRecords(self: *MutationObserver, page: *Page) ![]*MutationRecord {
@@ -441,7 +440,6 @@ pub const JsApi = struct {
pub const name = "MutationObserver"; pub const name = "MutationObserver";
pub const prototype_chain = bridge.prototypeChain(); pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined; pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(MutationObserver.deinit); pub const finalizer = bridge.finalizer(MutationObserver.deinit);
}; };

View File

@@ -18,13 +18,21 @@
const std = @import("std"); const std = @import("std");
const builtin = @import("builtin"); const builtin = @import("builtin");
const log = @import("../../log.zig");
const js = @import("../js/js.zig"); const js = @import("../js/js.zig");
const Page = @import("../Page.zig"); const Page = @import("../Page.zig");
const PluginArray = @import("PluginArray.zig"); const PluginArray = @import("PluginArray.zig");
const Permissions = @import("Permissions.zig");
const StorageManager = @import("StorageManager.zig");
const Navigator = @This(); const Navigator = @This();
_pad: bool = false, _pad: bool = false,
_plugins: PluginArray = .{}, _plugins: PluginArray = .{},
_permissions: Permissions = .{},
_storage: StorageManager = .{},
pub const init: Navigator = .{}; pub const init: Navigator = .{};
@@ -55,6 +63,19 @@ pub fn getPlugins(self: *Navigator) *PluginArray {
return &self._plugins; 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 { pub fn registerProtocolHandler(_: *const Navigator, scheme: []const u8, url: [:0]const u8, page: *const Page) !void {
try validateProtocolHandlerScheme(scheme); try validateProtocolHandlerScheme(scheme);
try validateProtocolHandlerURL(url, page); try validateProtocolHandlerURL(url, page);
@@ -144,6 +165,7 @@ pub const JsApi = struct {
pub const onLine = bridge.property(true, .{ .template = false }); pub const onLine = bridge.property(true, .{ .template = false });
pub const cookieEnabled = bridge.property(true, .{ .template = false }); pub const cookieEnabled = bridge.property(true, .{ .template = false });
pub const hardwareConcurrency = bridge.property(4, .{ .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 maxTouchPoints = bridge.property(0, .{ .template = false });
pub const vendor = bridge.property("", .{ .template = false }); pub const vendor = bridge.property("", .{ .template = false });
pub const product = bridge.property("Gecko", .{ .template = false }); pub const product = bridge.property("Gecko", .{ .template = false });
@@ -156,4 +178,12 @@ pub const JsApi = struct {
// Methods // Methods
pub const javaEnabled = bridge.function(Navigator.javaEnabled, .{}); 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", .{});
}

View File

@@ -0,0 +1,94 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const 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 });
};

View File

@@ -40,6 +40,7 @@ _mode: Mode,
_host: *Element, _host: *Element,
_elements_by_id: std.StringHashMapUnmanaged(*Element) = .{}, _elements_by_id: std.StringHashMapUnmanaged(*Element) = .{},
_removed_ids: std.StringHashMapUnmanaged(void) = .{}, _removed_ids: std.StringHashMapUnmanaged(void) = .{},
_adopted_style_sheets: ?js.Object.Global = null,
pub fn init(host: *Element, mode: Mode, page: *Page) !*ShadowRoot { pub fn init(host: *Element, mode: Mode, page: *Page) !*ShadowRoot {
return page._factory.documentFragment(ShadowRoot{ return page._factory.documentFragment(ShadowRoot{
@@ -99,6 +100,20 @@ pub fn getElementById(self: *ShadowRoot, id: []const u8, page: *Page) ?*Element
return null; 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 JsApi = struct {
pub const bridge = js.Bridge(ShadowRoot); pub const bridge = js.Bridge(ShadowRoot);
@@ -121,6 +136,7 @@ pub const JsApi = struct {
} }
return self.getElementById(try value.toZig([]const u8), page); return self.getElementById(try value.toZig([]const u8), page);
} }
pub const adoptedStyleSheets = bridge.accessor(ShadowRoot.getAdoptedStyleSheets, ShadowRoot.setAdoptedStyleSheets, .{});
}; };
const testing = @import("../../testing.zig"); const testing = @import("../../testing.zig");

View File

@@ -0,0 +1,71 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const 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, .{});
};

View File

@@ -66,7 +66,10 @@ _on_load: ?js.Function.Global = null,
_on_pageshow: ?js.Function.Global = null, _on_pageshow: ?js.Function.Global = null,
_on_popstate: ?js.Function.Global = null, _on_popstate: ?js.Function.Global = null,
_on_error: ?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, _location: *Location,
_timer_id: u30 = 0, _timer_id: u30 = 0,
_timers: std.AutoHashMapUnmanaged(u32, *ScheduleCallback) = .{}, _timers: std.AutoHashMapUnmanaged(u32, *ScheduleCallback) = .{},
@@ -89,6 +92,10 @@ pub fn asEventTarget(self: *Window) *EventTarget {
return self._proto; return self._proto;
} }
pub fn getEvent(self: *const Window) ?*Event {
return self._current_event;
}
pub fn getSelf(self: *Window) *Window { pub fn getSelf(self: *Window) *Window {
return self; return self;
} }
@@ -208,6 +215,22 @@ pub fn setOnError(self: *Window, setter: ?FunctionSetter) void {
self._on_error = getFunctionFromSetter(setter); 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 { pub fn getOnUnhandledRejection(self: *const Window) ?js.Function.Global {
return self._on_unhandled_rejection; 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(); const event = error_event.asEvent();
event._prevent_default = prevent_default; 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 (comptime builtin.is_test == false) {
if (!event._prevent_default) { 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 // In a full implementation, we would validate the origin
_ = target_origin; _ = target_origin;
// postMessage queues a task (not a microtask), so use the scheduler // self = the window that will get the message
const arena = try page.getArena(.{ .debug = "Window.schedule" }); // page = the context calling postMessage
errdefer page.releaseArena(arena); 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); const callback = try arena.create(PostMessageCallback);
callback.* = .{ callback.* = .{
.page = page,
.arena = arena, .arena = arena,
.message = message, .message = message,
.page = target_page,
.source = source_window,
.origin = try arena.dupe(u8, origin), .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", .name = "postMessage",
.low_priority = false, .low_priority = false,
.finalizer = PostMessageCallback.cancelled, .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); 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) { if (comptime IS_DEBUG) {
log.debug(.js, "unhandled rejection", .{ log.debug(.js, "unhandled rejection", .{
.value = rejection.reason(), .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(); const target = self.asEventTarget();
if (page._event_manager.hasDirectListeners(target, "unhandledrejection", self._on_unhandled_rejection)) { if (page._event_manager.hasDirectListeners(target, event_name, attribute_callback)) {
const event = (try @import("event/PromiseRejectionEvent.zig").init("unhandledrejection", .{ const event = (try @import("event/PromiseRejectionEvent.zig").init(event_name, .{
.reason = if (rejection.reason()) |r| try r.temp() else null, .reason = if (rejection.reason()) |r| try r.temp() else null,
.promise = try rejection.promise().temp(), .promise = try rejection.promise().temp(),
}, page)).asEvent(); }, 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 { const PostMessageCallback = struct {
page: *Page, page: *Page,
source: *Window,
arena: Allocator, arena: Allocator,
origin: []const u8, origin: []const u8,
message: js.Value.Temp, message: js.Value.Temp,
@@ -712,7 +754,7 @@ const PostMessageCallback = struct {
fn cancelled(ctx: *anyopaque) void { fn cancelled(ctx: *anyopaque) void {
const self: *PostMessageCallback = @ptrCast(@alignCast(ctx)); const self: *PostMessageCallback = @ptrCast(@alignCast(ctx));
self.page.releaseArena(self.arena); self.deinit();
} }
fn run(ctx: *anyopaque) !?u32 { fn run(ctx: *anyopaque) !?u32 {
@@ -722,14 +764,17 @@ const PostMessageCallback = struct {
const page = self.page; const page = self.page;
const window = page.window; const window = page.window;
const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{ const event_target = window.asEventTarget();
.data = self.message, if (page._event_manager.hasDirectListeners(event_target, "message", window._on_message)) {
.origin = self.origin, const event = (try MessageEvent.initTrusted(comptime .wrap("message"), .{
.source = window, .data = self.message,
.bubbles = false, .origin = self.origin,
.cancelable = false, .source = self.source,
}, page)).asEvent(); .bubbles = false,
try page._event_manager.dispatch(window.asEventTarget(), event); .cancelable = false,
}, page)).asEvent();
try page._event_manager.dispatchDirect(event_target, event, window._on_message, .{ .context = "window.postMessage" });
}
return null; return null;
} }
@@ -783,7 +828,10 @@ pub const JsApi = struct {
pub const onpageshow = bridge.accessor(Window.getOnPageShow, Window.setOnPageShow, .{}); pub const onpageshow = bridge.accessor(Window.getOnPageShow, Window.setOnPageShow, .{});
pub const onpopstate = bridge.accessor(Window.getOnPopState, Window.setOnPopState, .{}); pub const onpopstate = bridge.accessor(Window.getOnPopState, Window.setOnPopState, .{});
pub const onerror = bridge.accessor(Window.getOnError, Window.setOnError, .{}); 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 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 fetch = bridge.function(Window.fetch, .{});
pub const queueMicrotask = bridge.function(Window.queueMicrotask, .{}); pub const queueMicrotask = bridge.function(Window.queueMicrotask, .{});
pub const setTimeout = bridge.function(Window.setTimeout, .{}); pub const setTimeout = bridge.function(Window.setTimeout, .{});
@@ -853,3 +901,7 @@ test "WebApi: Window" {
test "WebApi: Window scroll" { test "WebApi: Window scroll" {
try testing.htmlRunner("window_scroll.html", .{}); try testing.htmlRunner("window_scroll.html", .{});
} }
test "WebApi: Window.onerror" {
try testing.htmlRunner("event/report_error.html", .{});
}

View File

@@ -135,7 +135,7 @@ pub const JsApi = struct {
const testing = @import("../../../testing.zig"); const testing = @import("../../../testing.zig");
test "WebApi: CSSStyleSheet" { test "WebApi: CSSStyleSheet" {
const filter: testing.LogFilter = .init(.js); const filter: testing.LogFilter = .init(&.{.js});
defer filter.deinit(); defer filter.deinit();
try testing.htmlRunner("css/stylesheet.html", .{}); try testing.htmlRunner("css/stylesheet.html", .{});
} }

View File

@@ -117,6 +117,47 @@ pub fn submit(self: *Form, page: *Page) !void {
return page.submitForm(null, self, .{ .fire_event = false }); 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:
/// - <input type="submit"> or <input type="image">
/// - <button type="submit"> (including default, since button's default type is "submit")
fn isSubmitButton(element: *Element) bool {
if (element.is(Input)) |input| {
return input._input_type == .submit or input._input_type == .image;
}
if (element.is(Button)) |button| {
return std.mem.eql(u8, button.getType(), "submit");
}
return false;
}
/// Returns the form owner of a submittable element (Input or Button).
fn getFormOwner(element: *Element, page: *Page) ?*Form {
if (element.is(Input)) |input| {
return input.getForm(page);
}
if (element.is(Button)) |button| {
return button.getForm(page);
}
return null;
}
pub const JsApi = struct { pub const JsApi = struct {
pub const bridge = js.Bridge(Form); pub const bridge = js.Bridge(Form);
pub const Meta = struct { pub const Meta = struct {
@@ -132,6 +173,7 @@ pub const JsApi = struct {
pub const elements = bridge.accessor(Form.getElements, null, .{}); pub const elements = bridge.accessor(Form.getElements, null, .{});
pub const length = bridge.accessor(Form.getLength, null, .{}); pub const length = bridge.accessor(Form.getLength, null, .{});
pub const submit = bridge.function(Form.submit, .{}); pub const submit = bridge.function(Form.submit, .{});
pub const requestSubmit = bridge.function(Form.requestSubmit, .{ .dom_exception = true });
}; };
const testing = @import("../../../../testing.zig"); const testing = @import("../../../../testing.zig");

View File

@@ -28,6 +28,7 @@ const HtmlElement = @import("../Html.zig");
const Form = @import("Form.zig"); const Form = @import("Form.zig");
const Selection = @import("../../Selection.zig"); const Selection = @import("../../Selection.zig");
const Event = @import("../../Event.zig"); const Event = @import("../../Event.zig");
const InputEvent = @import("../../event/InputEvent.zig");
const Input = @This(); const Input = @This();
@@ -103,6 +104,11 @@ fn dispatchSelectionChangeEvent(self: *Input, page: *Page) !void {
try page._event_manager.dispatch(self.asElement().asEventTarget(), event); try page._event_manager.dispatch(self.asElement().asEventTarget(), event);
} }
fn dispatchInputEvent(self: *Input, data: ?[]const u8, input_type: []const u8, page: *Page) !void {
const event = try InputEvent.initTrusted(comptime .wrap("input"), .{ .data = data, .inputType = input_type }, page);
try page._event_manager.dispatch(self.asElement().asEventTarget(), event.asEvent());
}
pub fn asElement(self: *Input) *Element { pub fn asElement(self: *Input) *Element {
return self._proto._proto; return self._proto._proto;
} }
@@ -425,6 +431,7 @@ pub fn innerInsert(self: *Input, str: []const u8, page: *Page) !void {
try self.setValue(new_value, page); try self.setValue(new_value, page);
}, },
} }
try self.dispatchInputEvent(str, "insertText", page);
} }
pub fn getSelectionDirection(self: *const Input) []const u8 { pub fn getSelectionDirection(self: *const Input) []const u8 {

View File

@@ -93,13 +93,21 @@ pub fn linkAddedCallback(self: *Link, page: *Page) !void {
} }
const element = self.asElement(); const element = self.asElement();
// Exit if rel not set.
const rel = element.getAttributeSafe(comptime .wrap("rel")) orelse return; const rel = element.getAttributeSafe(comptime .wrap("rel")) orelse return;
// Exit if rel is not stylesheet. const loadable_rels = std.StaticStringMap(void).initComptime(.{
if (!std.mem.eql(u8, rel, "stylesheet")) return; .{ "stylesheet", {} },
// Exit if href not set. .{ "preload", {} },
.{ "modulepreload", {} },
});
if (loadable_rels.has(rel) == false) {
return;
}
const href = element.getAttributeSafe(comptime .wrap("href")) orelse return; const href = element.getAttributeSafe(comptime .wrap("href")) orelse return;
if (href.len == 0) return; if (href.len == 0) {
return;
}
try page._to_load.append(page.arena, self._proto); try page._to_load.append(page.arena, self._proto);
} }

View File

@@ -26,6 +26,7 @@ const HtmlElement = @import("../Html.zig");
const Form = @import("Form.zig"); const Form = @import("Form.zig");
const Selection = @import("../../Selection.zig"); const Selection = @import("../../Selection.zig");
const Event = @import("../../Event.zig"); const Event = @import("../../Event.zig");
const InputEvent = @import("../../event/InputEvent.zig");
const TextArea = @This(); const TextArea = @This();
@@ -55,6 +56,11 @@ fn dispatchSelectionChangeEvent(self: *TextArea, page: *Page) !void {
try page._event_manager.dispatch(self.asElement().asEventTarget(), event); try page._event_manager.dispatch(self.asElement().asEventTarget(), event);
} }
fn dispatchInputEvent(self: *TextArea, data: ?[]const u8, input_type: []const u8, page: *Page) !void {
const event = try InputEvent.initTrusted(comptime .wrap("input"), .{ .data = data, .inputType = input_type }, page);
try page._event_manager.dispatch(self.asElement().asEventTarget(), event.asEvent());
}
pub fn asElement(self: *TextArea) *Element { pub fn asElement(self: *TextArea) *Element {
return self._proto._proto; return self._proto._proto;
} }
@@ -189,6 +195,7 @@ pub fn innerInsert(self: *TextArea, str: []const u8, page: *Page) !void {
try self.setValue(new_value, page); try self.setValue(new_value, page);
}, },
} }
try self.dispatchInputEvent(str, "insertText", page);
} }
pub fn getSelectionDirection(self: *const TextArea) []const u8 { pub fn getSelectionDirection(self: *const TextArea) []const u8 {

View File

@@ -0,0 +1,121 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const String = @import("../../../string.zig").String;
const Page = @import("../../Page.zig");
const Session = @import("../../Session.zig");
const js = @import("../../js/js.zig");
const Event = @import("../Event.zig");
const UIEvent = @import("UIEvent.zig");
const Allocator = std.mem.Allocator;
const InputEvent = @This();
_proto: *UIEvent,
_data: ?[]const u8,
// TODO: add dataTransfer
_input_type: []const u8,
_is_composing: bool,
pub const InputEventOptions = struct {
data: ?[]const u8 = null,
inputType: ?[]const u8 = null,
isComposing: bool = false,
};
const Options = Event.inheritOptions(
InputEvent,
InputEventOptions,
);
pub fn initTrusted(typ: String, _opts: ?Options, page: *Page) !*InputEvent {
const arena = try page.getArena(.{ .debug = "InputEvent.trusted" });
errdefer page.releaseArena(arena);
return initWithTrusted(arena, typ, _opts, true, page);
}
pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*InputEvent {
const arena = try page.getArena(.{ .debug = "InputEvent" });
errdefer page.releaseArena(arena);
const type_string = try String.init(arena, typ, .{});
return initWithTrusted(arena, type_string, _opts, false, page);
}
fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool, page: *Page) !*InputEvent {
const opts = _opts orelse Options{};
const event = try page._factory.uiEvent(
arena,
typ,
InputEvent{
._proto = undefined,
._data = if (opts.data) |d| try arena.dupe(u8, d) else null,
._input_type = if (opts.inputType) |it| try arena.dupe(u8, it) else "",
._is_composing = opts.isComposing,
},
);
Event.populatePrototypes(event, opts, trusted);
// https://developer.mozilla.org/en-US/docs/Web/API/Element/input_event
const rootevt = event._proto._proto;
rootevt._bubbles = true;
rootevt._cancelable = false;
rootevt._composed = true;
return event;
}
pub fn deinit(self: *InputEvent, shutdown: bool, session: *Session) void {
self._proto.deinit(shutdown, session);
}
pub fn asEvent(self: *InputEvent) *Event {
return self._proto.asEvent();
}
pub fn getData(self: *const InputEvent) ?[]const u8 {
return self._data;
}
pub fn getInputType(self: *const InputEvent) []const u8 {
return self._input_type;
}
pub fn getIsComposing(self: *const InputEvent) bool {
return self._is_composing;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(InputEvent);
pub const Meta = struct {
pub const name = "InputEvent";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(InputEvent.deinit);
};
pub const constructor = bridge.constructor(InputEvent.init, .{});
pub const data = bridge.accessor(InputEvent.getData, null, .{});
pub const inputType = bridge.accessor(InputEvent.getInputType, null, .{});
pub const isComposing = bridge.accessor(InputEvent.getIsComposing, null, .{});
};

View File

@@ -219,6 +219,13 @@ fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool
); );
Event.populatePrototypes(event, opts, trusted); Event.populatePrototypes(event, opts, trusted);
// https://w3c.github.io/uievents/#event-type-keyup
const rootevt = event._proto._proto;
rootevt._bubbles = true;
rootevt._cancelable = true;
rootevt._composed = true;
return event; return event;
} }

View File

@@ -28,6 +28,8 @@ const EventTarget = @import("../EventTarget.zig");
const UIEvent = @import("UIEvent.zig"); const UIEvent = @import("UIEvent.zig");
const PointerEvent = @import("PointerEvent.zig"); const PointerEvent = @import("PointerEvent.zig");
const Allocator = std.mem.Allocator;
const MouseEvent = @This(); const MouseEvent = @This();
pub const MouseButton = enum(u8) { pub const MouseButton = enum(u8) {
@@ -83,12 +85,21 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*MouseEvent {
const arena = try page.getArena(.{ .debug = "MouseEvent" }); const arena = try page.getArena(.{ .debug = "MouseEvent" });
errdefer page.releaseArena(arena); errdefer page.releaseArena(arena);
const type_string = try String.init(arena, typ, .{}); const type_string = try String.init(arena, typ, .{});
return initWithTrusted(arena, type_string, _opts, false, page);
}
pub fn initTrusted(typ: String, _opts: ?Options, page: *Page) !*MouseEvent {
const arena = try page.getArena(.{ .debug = "MouseEvent.trusted" });
errdefer page.releaseArena(arena);
return initWithTrusted(arena, typ, _opts, true, page);
}
fn initWithTrusted(arena: Allocator, typ: String, _opts: ?Options, trusted: bool, page: *Page) !*MouseEvent {
const opts = _opts orelse Options{}; const opts = _opts orelse Options{};
const event = try page._factory.uiEvent( const event = try page._factory.uiEvent(
arena, arena,
type_string, typ,
MouseEvent{ MouseEvent{
._type = .generic, ._type = .generic,
._proto = undefined, ._proto = undefined,
@@ -106,7 +117,7 @@ pub fn init(typ: []const u8, _opts: ?Options, page: *Page) !*MouseEvent {
}, },
); );
Event.populatePrototypes(event, opts, false); Event.populatePrototypes(event, opts, trusted);
return event; return event;
} }

View File

@@ -37,6 +37,7 @@ pub const Type = union(enum) {
keyboard_event: *@import("KeyboardEvent.zig"), keyboard_event: *@import("KeyboardEvent.zig"),
focus_event: *@import("FocusEvent.zig"), focus_event: *@import("FocusEvent.zig"),
text_event: *@import("TextEvent.zig"), text_event: *@import("TextEvent.zig"),
input_event: *@import("InputEvent.zig"),
}; };
pub const UIEventOptions = struct { pub const UIEventOptions = struct {
@@ -88,6 +89,7 @@ pub fn is(self: *UIEvent, comptime T: type) ?*T {
.keyboard_event => |e| return if (T == @import("KeyboardEvent.zig")) e else null, .keyboard_event => |e| return if (T == @import("KeyboardEvent.zig")) e else null,
.focus_event => |e| return if (T == @import("FocusEvent.zig")) e else null, .focus_event => |e| return if (T == @import("FocusEvent.zig")) e else null,
.text_event => |e| return if (T == @import("TextEvent.zig")) e else null, .text_event => |e| return if (T == @import("TextEvent.zig")) e else null,
.input_event => |e| return if (T == @import("InputEvent.zig")) e else null,
} }
return null; return null;
} }

View File

@@ -25,8 +25,11 @@ const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig"); const Page = @import("../../Page.zig");
const URL = @import("../../URL.zig"); const URL = @import("../../URL.zig");
const Blob = @import("../Blob.zig");
const Request = @import("Request.zig"); const Request = @import("Request.zig");
const Response = @import("Response.zig"); const Response = @import("Response.zig");
const AbortSignal = @import("../AbortSignal.zig");
const DOMException = @import("../DOMException.zig");
const IS_DEBUG = @import("builtin").mode == .Debug; const IS_DEBUG = @import("builtin").mode == .Debug;
@@ -38,17 +41,29 @@ _buf: std.ArrayList(u8),
_response: *Response, _response: *Response,
_resolver: js.PromiseResolver.Global, _resolver: js.PromiseResolver.Global,
_owns_response: bool, _owns_response: bool,
_signal: ?*AbortSignal,
pub const Input = Request.Input; pub const Input = Request.Input;
pub const InitOpts = Request.InitOpts; pub const InitOpts = Request.InitOpts;
pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise { pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise {
const request = try Request.init(input, options, page); const request = try Request.init(input, options, page);
const resolver = page.js.local.?.createPromiseResolver();
if (request._signal) |signal| {
if (signal._aborted) {
resolver.reject("fetch aborted", DOMException.init("The operation was aborted.", "AbortError"));
return resolver.promise();
}
}
if (std.mem.startsWith(u8, request._url, "blob:")) {
return handleBlobUrl(request._url, resolver, page);
}
const response = try Response.init(null, .{ .status = 0 }, page); const response = try Response.init(null, .{ .status = 0 }, page);
errdefer response.deinit(true, page._session); errdefer response.deinit(true, page._session);
const resolver = page.js.local.?.createPromiseResolver();
const fetch = try response._arena.create(Fetch); const fetch = try response._arena.create(Fetch);
fetch.* = .{ fetch.* = .{
._page = page, ._page = page,
@@ -57,6 +72,7 @@ pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise {
._resolver = try resolver.persist(), ._resolver = try resolver.persist(),
._response = response, ._response = response,
._owns_response = true, ._owns_response = true,
._signal = request._signal,
}; };
const http_client = page._session.browser.http_client; const http_client = page._session.browser.http_client;
@@ -90,6 +106,26 @@ pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise {
return resolver.promise(); return resolver.promise();
} }
fn handleBlobUrl(url: []const u8, resolver: js.PromiseResolver, page: *Page) !js.Promise {
const blob: *Blob = page.lookupBlobUrl(url) orelse {
resolver.rejectError("fetch blob error", .{ .type_error = "BlobNotFound" });
return resolver.promise();
};
const response = try Response.init(null, .{ .status = 200 }, page);
response._body = try response._arena.dupe(u8, blob._slice);
response._url = try response._arena.dupeZ(u8, url);
response._type = .basic;
if (blob._mime.len > 0) {
try response._headers.append("Content-Type", blob._mime, page);
}
const js_val = try page.js.local.?.zigValueToJs(response, .{});
resolver.resolve("fetch blob done", js_val);
return resolver.promise();
}
fn httpStartCallback(transfer: *HttpClient.Transfer) !void { fn httpStartCallback(transfer: *HttpClient.Transfer) !void {
const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); const self: *Fetch = @ptrCast(@alignCast(transfer.ctx));
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
@@ -101,6 +137,12 @@ fn httpStartCallback(transfer: *HttpClient.Transfer) !void {
fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool { fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); const self: *Fetch = @ptrCast(@alignCast(transfer.ctx));
if (self._signal) |signal| {
if (signal._aborted) {
return false;
}
}
const arena = self._response._arena; const arena = self._response._arena;
if (transfer.getContentLength()) |cl| { if (transfer.getContentLength()) |cl| {
try self._buf.ensureTotalCapacity(arena, cl); try self._buf.ensureTotalCapacity(arena, cl);
@@ -150,6 +192,14 @@ fn httpHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
fn httpDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { fn httpDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
const self: *Fetch = @ptrCast(@alignCast(transfer.ctx)); const self: *Fetch = @ptrCast(@alignCast(transfer.ctx));
// Check if aborted
if (self._signal) |signal| {
if (signal._aborted) {
return error.Abort;
}
}
try self._buf.appendSlice(self._response._arena, data); try self._buf.appendSlice(self._response._arena, data);
} }

View File

@@ -25,6 +25,7 @@ const URL = @import("../URL.zig");
const Page = @import("../../Page.zig"); const Page = @import("../../Page.zig");
const Headers = @import("Headers.zig"); const Headers = @import("Headers.zig");
const Blob = @import("../Blob.zig"); const Blob = @import("../Blob.zig");
const AbortSignal = @import("../AbortSignal.zig");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const Request = @This(); const Request = @This();
@@ -36,6 +37,7 @@ _body: ?[]const u8,
_arena: Allocator, _arena: Allocator,
_cache: Cache, _cache: Cache,
_credentials: Credentials, _credentials: Credentials,
_signal: ?*AbortSignal,
pub const Input = union(enum) { pub const Input = union(enum) {
request: *Request, request: *Request,
@@ -48,6 +50,7 @@ pub const InitOpts = struct {
body: ?[]const u8 = null, body: ?[]const u8 = null,
cache: Cache = .default, cache: Cache = .default,
credentials: Credentials = .@"same-origin", credentials: Credentials = .@"same-origin",
signal: ?*AbortSignal = null,
}; };
const Credentials = enum { const Credentials = enum {
@@ -97,6 +100,13 @@ pub fn init(input: Input, opts_: ?InitOpts, page: *Page) !*Request {
.request => |r| r._body, .request => |r| r._body,
}; };
const signal = if (opts.signal) |s|
s
else switch (input) {
.url => null,
.request => |r| r._signal,
};
return page._factory.create(Request{ return page._factory.create(Request{
._url = url, ._url = url,
._arena = arena, ._arena = arena,
@@ -105,6 +115,7 @@ pub fn init(input: Input, opts_: ?InitOpts, page: *Page) !*Request {
._cache = opts.cache, ._cache = opts.cache,
._credentials = opts.credentials, ._credentials = opts.credentials,
._body = body, ._body = body,
._signal = signal,
}); });
} }
@@ -144,6 +155,10 @@ pub fn getCredentials(self: *const Request) []const u8 {
return @tagName(self._credentials); return @tagName(self._credentials);
} }
pub fn getSignal(self: *const Request) ?*AbortSignal {
return self._signal;
}
pub fn getHeaders(self: *Request, page: *Page) !*Headers { pub fn getHeaders(self: *Request, page: *Page) !*Headers {
if (self._headers) |headers| { if (self._headers) |headers| {
return headers; return headers;
@@ -200,6 +215,7 @@ pub fn clone(self: *const Request, page: *Page) !*Request {
._cache = self._cache, ._cache = self._cache,
._credentials = self._credentials, ._credentials = self._credentials,
._body = self._body, ._body = self._body,
._signal = self._signal,
}); });
} }
@@ -218,6 +234,7 @@ pub const JsApi = struct {
pub const headers = bridge.accessor(Request.getHeaders, null, .{}); pub const headers = bridge.accessor(Request.getHeaders, null, .{});
pub const cache = bridge.accessor(Request.getCache, null, .{}); pub const cache = bridge.accessor(Request.getCache, null, .{});
pub const credentials = bridge.accessor(Request.getCredentials, null, .{}); pub const credentials = bridge.accessor(Request.getCredentials, null, .{});
pub const signal = bridge.accessor(Request.getSignal, null, .{});
pub const blob = bridge.function(Request.blob, .{}); pub const blob = bridge.function(Request.blob, .{});
pub const text = bridge.function(Request.text, .{}); pub const text = bridge.function(Request.text, .{});
pub const json = bridge.function(Request.json, .{}); pub const json = bridge.function(Request.json, .{});

View File

@@ -29,6 +29,7 @@ const Page = @import("../../Page.zig");
const Session = @import("../../Session.zig"); const Session = @import("../../Session.zig");
const Node = @import("../Node.zig"); const Node = @import("../Node.zig");
const Blob = @import("../Blob.zig");
const Event = @import("../Event.zig"); const Event = @import("../Event.zig");
const Headers = @import("Headers.zig"); const Headers = @import("Headers.zig");
const EventTarget = @import("../EventTarget.zig"); const EventTarget = @import("../EventTarget.zig");
@@ -211,6 +212,11 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
} }
const page = self._page; const page = self._page;
if (std.mem.startsWith(u8, self._url, "blob:")) {
return self.handleBlobUrl(page);
}
const http_client = page._session.browser.http_client; const http_client = page._session.browser.http_client;
var headers = try http_client.newHeaders(); var headers = try http_client.newHeaders();
@@ -242,6 +248,39 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
page.js.strongRef(self); page.js.strongRef(self);
} }
fn handleBlobUrl(self: *XMLHttpRequest, page: *Page) !void {
const blob = page.lookupBlobUrl(self._url) orelse {
self.handleError(error.BlobNotFound);
return;
};
self._response_status = 200;
self._response_url = self._url;
try self._response_data.appendSlice(self._arena, blob._slice);
self._response_len = blob._slice.len;
try self.stateChanged(.headers_received, page);
try self._proto.dispatch(.load_start, .{ .loaded = 0, .total = self._response_len orelse 0 }, page);
try self.stateChanged(.loading, page);
try self._proto.dispatch(.progress, .{
.total = self._response_len orelse 0,
.loaded = self._response_data.items.len,
}, page);
try self.stateChanged(.done, page);
const loaded = self._response_data.items.len;
try self._proto.dispatch(.load, .{
.total = loaded,
.loaded = loaded,
}, page);
try self._proto.dispatch(.load_end, .{
.total = loaded,
.loaded = loaded,
}, page);
}
pub fn getReadyState(self: *const XMLHttpRequest) u32 { pub fn getReadyState(self: *const XMLHttpRequest) u32 {
return @intFromEnum(self._ready_state); return @intFromEnum(self._ready_state);
} }

View File

@@ -17,6 +17,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std"); const std = @import("std");
const log = @import("../../log.zig");
pub fn processMessage(cmd: anytype) !void { pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum { const action = std.meta.stringToEnum(enum {
@@ -24,6 +25,7 @@ pub fn processMessage(cmd: anytype) !void {
setFocusEmulationEnabled, setFocusEmulationEnabled,
setDeviceMetricsOverride, setDeviceMetricsOverride,
setTouchEmulationEnabled, setTouchEmulationEnabled,
setUserAgentOverride,
}, cmd.input.action) orelse return error.UnknownMethod; }, cmd.input.action) orelse return error.UnknownMethod;
switch (action) { switch (action) {
@@ -31,6 +33,7 @@ pub fn processMessage(cmd: anytype) !void {
.setFocusEmulationEnabled => return setFocusEmulationEnabled(cmd), .setFocusEmulationEnabled => return setFocusEmulationEnabled(cmd),
.setDeviceMetricsOverride => return setDeviceMetricsOverride(cmd), .setDeviceMetricsOverride => return setDeviceMetricsOverride(cmd),
.setTouchEmulationEnabled => return setTouchEmulationEnabled(cmd), .setTouchEmulationEnabled => return setTouchEmulationEnabled(cmd),
.setUserAgentOverride => return setUserAgentOverride(cmd),
} }
} }
@@ -64,3 +67,8 @@ fn setDeviceMetricsOverride(cmd: anytype) !void {
fn setTouchEmulationEnabled(cmd: anytype) !void { fn setTouchEmulationEnabled(cmd: anytype) !void {
return cmd.sendResult(null, .{}); return cmd.sendResult(null, .{});
} }
fn setUserAgentOverride(cmd: anytype) !void {
log.info(.app, "setUserAgentOverride ignored", .{});
return cmd.sendResult(null, .{});
}

View File

@@ -21,9 +21,11 @@ const std = @import("std");
pub fn processMessage(cmd: anytype) !void { pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum { const action = std.meta.stringToEnum(enum {
enable, enable,
disable,
}, cmd.input.action) orelse return error.UnknownMethod; }, cmd.input.action) orelse return error.UnknownMethod;
switch (action) { switch (action) {
.enable => return cmd.sendResult(null, .{}), .enable => return cmd.sendResult(null, .{}),
.disable => return cmd.sendResult(null, .{}),
} }
} }

View File

@@ -32,6 +32,9 @@ pub fn processMessage(cmd: anytype) !void {
getSemanticTree, getSemanticTree,
getInteractiveElements, getInteractiveElements,
getStructuredData, getStructuredData,
clickNode,
fillNode,
scrollNode,
}, cmd.input.action) orelse return error.UnknownMethod; }, cmd.input.action) orelse return error.UnknownMethod;
switch (action) { switch (action) {
@@ -39,6 +42,9 @@ pub fn processMessage(cmd: anytype) !void {
.getSemanticTree => return getSemanticTree(cmd), .getSemanticTree => return getSemanticTree(cmd),
.getInteractiveElements => return getInteractiveElements(cmd), .getInteractiveElements => return getInteractiveElements(cmd),
.getStructuredData => return getStructuredData(cmd), .getStructuredData => return getStructuredData(cmd),
.clickNode => return clickNode(cmd),
.fillNode => return fillNode(cmd),
.scrollNode => return scrollNode(cmd),
} }
} }
@@ -46,24 +52,32 @@ fn getSemanticTree(cmd: anytype) !void {
const Params = struct { const Params = struct {
format: ?enum { text } = null, format: ?enum { text } = null,
prune: ?bool = null, prune: ?bool = null,
interactiveOnly: ?bool = null,
backendNodeId: ?Node.Id = null,
maxDepth: ?u32 = null,
}; };
const params = (try cmd.params(Params)) orelse Params{}; const params = (try cmd.params(Params)) orelse Params{};
const bc = cmd.browser_context orelse return error.NoBrowserContext; const bc = cmd.browser_context orelse return error.NoBrowserContext;
const page = bc.session.currentPage() orelse return error.PageNotLoaded; const page = bc.session.currentPage() orelse return error.PageNotLoaded;
const dom_node = page.document.asNode();
const dom_node = if (params.backendNodeId) |nodeId|
(bc.node_registry.lookup_by_id.get(nodeId) orelse return error.InvalidNodeId).dom
else
page.document.asNode();
var st = SemanticTree{ var st = SemanticTree{
.dom_node = dom_node, .dom_node = dom_node,
.registry = &bc.node_registry, .registry = &bc.node_registry,
.page = page, .page = page,
.arena = cmd.arena, .arena = cmd.arena,
.prune = params.prune orelse false, .prune = params.prune orelse true,
.interactive_only = params.interactiveOnly orelse false,
.max_depth = params.maxDepth orelse std.math.maxInt(u32) - 1,
}; };
if (params.format) |format| { if (params.format) |format| {
if (format == .text) { if (format == .text) {
st.prune = params.prune orelse true;
var aw: std.Io.Writer.Allocating = .init(cmd.arena); var aw: std.Io.Writer.Allocating = .init(cmd.arena);
defer aw.deinit(); defer aw.deinit();
try st.textStringify(&aw.writer); try st.textStringify(&aw.writer);
@@ -146,6 +160,76 @@ fn getStructuredData(cmd: anytype) !void {
}, .{}); }, .{});
} }
fn clickNode(cmd: anytype) !void {
const Params = struct {
nodeId: ?Node.Id = null,
backendNodeId: ?Node.Id = null,
};
const params = (try cmd.params(Params)) orelse return error.InvalidParam;
const bc = cmd.browser_context orelse return error.NoBrowserContext;
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
const node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam;
const node = bc.node_registry.lookup_by_id.get(node_id) orelse return error.InvalidNodeId;
lp.actions.click(node.dom, page) catch |err| {
if (err == error.InvalidNodeType) return error.InvalidParam;
return error.InternalError;
};
return cmd.sendResult(.{}, .{});
}
fn fillNode(cmd: anytype) !void {
const Params = struct {
nodeId: ?Node.Id = null,
backendNodeId: ?Node.Id = null,
text: []const u8,
};
const params = (try cmd.params(Params)) orelse return error.InvalidParam;
const bc = cmd.browser_context orelse return error.NoBrowserContext;
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
const node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam;
const node = bc.node_registry.lookup_by_id.get(node_id) orelse return error.InvalidNodeId;
lp.actions.fill(node.dom, params.text, page) catch |err| {
if (err == error.InvalidNodeType) return error.InvalidParam;
return error.InternalError;
};
return cmd.sendResult(.{}, .{});
}
fn scrollNode(cmd: anytype) !void {
const Params = struct {
nodeId: ?Node.Id = null,
backendNodeId: ?Node.Id = null,
x: ?i32 = null,
y: ?i32 = null,
};
const params = (try cmd.params(Params)) orelse return error.InvalidParam;
const bc = cmd.browser_context orelse return error.NoBrowserContext;
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
const maybe_node_id = params.nodeId orelse params.backendNodeId;
var target_node: ?*DOMNode = null;
if (maybe_node_id) |node_id| {
const node = bc.node_registry.lookup_by_id.get(node_id) orelse return error.InvalidNodeId;
target_node = node.dom;
}
lp.actions.scroll(target_node, params.x, params.y, page) catch |err| {
if (err == error.InvalidNodeType) return error.InvalidParam;
return error.InternalError;
};
return cmd.sendResult(.{}, .{});
}
const testing = @import("../testing.zig"); const testing = @import("../testing.zig");
test "cdp.lp: getMarkdown" { test "cdp.lp: getMarkdown" {
var ctx = testing.context(); var ctx = testing.context();
@@ -195,3 +279,63 @@ test "cdp.lp: getStructuredData" {
const result = ctx.client.?.sent.items[0].object.get("result").?.object; const result = ctx.client.?.sent.items[0].object.get("result").?.object;
try testing.expect(result.get("structuredData") != null); try testing.expect(result.get("structuredData") != null);
} }
test "cdp.lp: action tools" {
var ctx = testing.context();
defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{});
const page = try bc.session.createPage();
const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
_ = bc.session.wait(5000);
// Test Click
const btn = page.document.getElementById("btn", page).?.asNode();
const btn_id = (try bc.node_registry.register(btn)).id;
try ctx.processMessage(.{
.id = 1,
.method = "LP.clickNode",
.params = .{ .backendNodeId = btn_id },
});
// Test Fill Input
const inp = page.document.getElementById("inp", page).?.asNode();
const inp_id = (try bc.node_registry.register(inp)).id;
try ctx.processMessage(.{
.id = 2,
.method = "LP.fillNode",
.params = .{ .backendNodeId = inp_id, .text = "hello" },
});
// Test Fill Select
const sel = page.document.getElementById("sel", page).?.asNode();
const sel_id = (try bc.node_registry.register(sel)).id;
try ctx.processMessage(.{
.id = 3,
.method = "LP.fillNode",
.params = .{ .backendNodeId = sel_id, .text = "opt2" },
});
// Test Scroll
const scrollbox = page.document.getElementById("scrollbox", page).?.asNode();
const scrollbox_id = (try bc.node_registry.register(scrollbox)).id;
try ctx.processMessage(.{
.id = 4,
.method = "LP.scrollNode",
.params = .{ .backendNodeId = scrollbox_id, .y = 50 },
});
// Evaluate assertions
var ls: lp.js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
var try_catch: lp.js.TryCatch = undefined;
try_catch.init(&ls.local);
defer try_catch.deinit();
const result = try ls.local.compileAndRun("window.clicked === true && window.inputVal === 'hello' && window.changed === true && window.selChanged === 'opt2' && window.scrolled === true", null);
try testing.expect(result.isTrue());
}

View File

@@ -663,7 +663,7 @@ test "cdp.page: getFrameTree" {
test "cdp.page: captureScreenshot" { test "cdp.page: captureScreenshot" {
const LogFilter = @import("../../testing.zig").LogFilter; const LogFilter = @import("../../testing.zig").LogFilter;
const filter: LogFilter = .init(.not_implemented); const filter: LogFilter = .init(&.{.not_implemented});
defer filter.deinit(); defer filter.deinit();
var ctx = testing.context(); var ctx = testing.context();

View File

@@ -21,9 +21,11 @@ const std = @import("std");
pub fn processMessage(cmd: anytype) !void { pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum { const action = std.meta.stringToEnum(enum {
enable, enable,
disable,
}, cmd.input.action) orelse return error.UnknownMethod; }, cmd.input.action) orelse return error.UnknownMethod;
switch (action) { switch (action) {
.enable => return cmd.sendResult(null, .{}), .enable => return cmd.sendResult(null, .{}),
.disable => return cmd.sendResult(null, .{}),
} }
} }

View File

@@ -21,11 +21,13 @@ const std = @import("std");
pub fn processMessage(cmd: anytype) !void { pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum { const action = std.meta.stringToEnum(enum {
enable, enable,
disable,
setIgnoreCertificateErrors, setIgnoreCertificateErrors,
}, cmd.input.action) orelse return error.UnknownMethod; }, cmd.input.action) orelse return error.UnknownMethod;
switch (action) { switch (action) {
.enable => return cmd.sendResult(null, .{}), .enable => return cmd.sendResult(null, .{}),
.disable => return cmd.sendResult(null, .{}),
.setIgnoreCertificateErrors => return setIgnoreCertificateErrors(cmd), .setIgnoreCertificateErrors => return setIgnoreCertificateErrors(cmd),
} }
} }

View File

@@ -35,6 +35,7 @@ pub const markdown = @import("browser/markdown.zig");
pub const SemanticTree = @import("SemanticTree.zig"); pub const SemanticTree = @import("SemanticTree.zig");
pub const CDPNode = @import("cdp/Node.zig"); pub const CDPNode = @import("cdp/Node.zig");
pub const interactive = @import("browser/interactive.zig"); pub const interactive = @import("browser/interactive.zig");
pub const actions = @import("browser/actions.zig");
pub const structured_data = @import("browser/structured_data.zig"); pub const structured_data = @import("browser/structured_data.zig");
pub const mcp = @import("mcp.zig"); pub const mcp = @import("mcp.zig");
pub const build_config = @import("build_config"); pub const build_config = @import("build_config");

View File

@@ -59,7 +59,11 @@ fn run(allocator: Allocator, main_arena: Allocator) !void {
return std.process.cleanExit(); return std.process.cleanExit();
}, },
.version => { .version => {
std.debug.print("{s}\n", .{lp.build_config.git_commit}); if (lp.build_config.git_version) |version| {
std.debug.print("{s} ({s})\n", .{ version, lp.build_config.git_commit });
} else {
std.debug.print("{s}\n", .{lp.build_config.git_commit});
}
return std.process.cleanExit(); return std.process.cleanExit();
}, },
else => {}, else => {},
@@ -75,18 +79,21 @@ fn run(allocator: Allocator, main_arena: Allocator) !void {
log.opts.filter_scopes = lfs; log.opts.filter_scopes = lfs;
} }
// must be installed before any other threads
const sighandler = try main_arena.create(SigHandler);
sighandler.* = .{ .arena = main_arena };
try sighandler.install();
// _app is global to handle graceful shutdown. // _app is global to handle graceful shutdown.
var app = try App.init(allocator, &args); var app = try App.init(allocator, &args);
defer app.deinit(); defer app.deinit();
try sighandler.on(lp.Network.stop, .{&app.network});
app.telemetry.record(.{ .run = {} }); app.telemetry.record(.{ .run = {} });
switch (args.mode) { switch (args.mode) {
.serve => |opts| { .serve => |opts| {
const sighandler = try main_arena.create(SigHandler);
sighandler.* = .{ .arena = main_arena };
try sighandler.install();
log.debug(.app, "startup", .{ .mode = "serve", .snapshot = app.snapshot.fromEmbedded() }); log.debug(.app, "startup", .{ .mode = "serve", .snapshot = app.snapshot.fromEmbedded() });
const address = std.net.Address.parseIp(opts.host, opts.port) catch |err| { const address = std.net.Address.parseIp(opts.host, opts.port) catch |err| {
log.fatal(.app, "invalid server address", .{ .err = err, .host = opts.host, .port = opts.port }); log.fatal(.app, "invalid server address", .{ .err = err, .host = opts.host, .port = opts.port });
@@ -94,12 +101,22 @@ fn run(allocator: Allocator, main_arena: Allocator) !void {
}; };
var server = lp.Server.init(app, address) catch |err| { var server = lp.Server.init(app, address) catch |err| {
log.fatal(.app, "server run error", .{ .err = err }); if (err == error.AddressInUse) {
log.fatal(.app, "address already in use", .{
.host = opts.host,
.port = opts.port,
.hint = "Another process is already listening on this address. " ++
"Stop the other process or use --port to choose a different port.",
});
} else {
log.fatal(.app, "server run error", .{ .err = err });
}
return err; return err;
}; };
defer server.deinit(); defer server.deinit();
try sighandler.on(lp.Network.stop, .{&app.network}); try sighandler.on(lp.Server.shutdown, .{server});
app.network.run(); app.network.run();
}, },
.fetch => |opts| { .fetch => |opts| {
@@ -122,10 +139,10 @@ fn run(allocator: Allocator, main_arena: Allocator) !void {
fetch_opts.writer = &writer.interface; fetch_opts.writer = &writer.interface;
} }
lp.fetch(app, url, fetch_opts) catch |err| { var worker_thread = try std.Thread.spawn(.{}, fetchThread, .{ app, url, fetch_opts });
log.fatal(.app, "fetch error", .{ .err = err, .url = url }); defer worker_thread.join();
return err;
}; app.network.run();
}, },
.mcp => { .mcp => {
log.info(.mcp, "starting server", .{}); log.info(.mcp, "starting server", .{});
@@ -137,11 +154,27 @@ fn run(allocator: Allocator, main_arena: Allocator) !void {
var mcp_server: *lp.mcp.Server = try .init(allocator, app, &stdout.interface); var mcp_server: *lp.mcp.Server = try .init(allocator, app, &stdout.interface);
defer mcp_server.deinit(); defer mcp_server.deinit();
var stdin_buf: [64 * 1024]u8 = undefined; var worker_thread = try std.Thread.spawn(.{}, mcpThread, .{ mcp_server, app });
var stdin = std.fs.File.stdin().reader(&stdin_buf); defer worker_thread.join();
try lp.mcp.router.processRequests(mcp_server, &stdin.interface); app.network.run();
}, },
else => unreachable, else => unreachable,
} }
} }
fn fetchThread(app: *App, url: [:0]const u8, fetch_opts: lp.FetchOpts) void {
defer app.network.stop();
lp.fetch(app, url, fetch_opts) catch |err| {
log.fatal(.app, "fetch error", .{ .err = err, .url = url });
};
}
fn mcpThread(mcp_server: *lp.mcp.Server, app: *App) void {
defer app.network.stop();
var stdin_buf: [64 * 1024]u8 = undefined;
var stdin = std.fs.File.stdin().reader(&stdin_buf);
lp.mcp.router.processRequests(mcp_server, &stdin.interface) catch |err| {
log.fatal(.mcp, "mcp error", .{ .err = err });
};
}

View File

@@ -148,7 +148,7 @@ test "MCP.router - handleMessage - synchronous unit tests" {
// 5. Parse error // 5. Parse error
{ {
const filter: testing.LogFilter = .init(.mcp); const filter: testing.LogFilter = .init(&.{.mcp});
defer filter.deinit(); defer filter.deinit();
try handleMessage(server, aa, "invalid json"); try handleMessage(server, aa, "invalid json");

View File

@@ -5,6 +5,7 @@ const log = lp.log;
const js = lp.js; const js = lp.js;
const Element = @import("../browser/webapi/Element.zig"); const Element = @import("../browser/webapi/Element.zig");
const DOMNode = @import("../browser/webapi/Node.zig");
const Selector = @import("../browser/webapi/selector/Selector.zig"); const Selector = @import("../browser/webapi/selector/Selector.zig");
const protocol = @import("protocol.zig"); const protocol = @import("protocol.zig");
const Server = @import("Server.zig"); const Server = @import("Server.zig");
@@ -69,7 +70,9 @@ pub const tool_list = [_]protocol.Tool{
\\{ \\{
\\ "type": "object", \\ "type": "object",
\\ "properties": { \\ "properties": {
\\ "url": { "type": "string", "description": "Optional URL to navigate to before fetching the semantic tree." } \\ "url": { "type": "string", "description": "Optional URL to navigate to before fetching the semantic tree." },
\\ "backendNodeId": { "type": "integer", "description": "Optional backend node ID to get the tree for a specific element instead of the document root." },
\\ "maxDepth": { "type": "integer", "description": "Optional maximum depth of the tree to return. Useful for exploring high-level structure first." }
\\ } \\ }
\\} \\}
), ),
@@ -98,6 +101,47 @@ pub const tool_list = [_]protocol.Tool{
\\} \\}
), ),
}, },
.{
.name = "click",
.description = "Click on an interactive element.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the element to click." }
\\ },
\\ "required": ["backendNodeId"]
\\}
),
},
.{
.name = "fill",
.description = "Fill text into an input element.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "backendNodeId": { "type": "integer", "description": "The backend node ID of the input element to fill." },
\\ "text": { "type": "string", "description": "The text to fill into the input element." }
\\ },
\\ "required": ["backendNodeId", "text"]
\\}
),
},
.{
.name = "scroll",
.description = "Scroll the page or a specific element.",
.inputSchema = protocol.minify(
\\{
\\ "type": "object",
\\ "properties": {
\\ "backendNodeId": { "type": "integer", "description": "Optional: The backend node ID of the element to scroll. If omitted, scrolls the window." },
\\ "x": { "type": "integer", "description": "Optional: The horizontal scroll offset." },
\\ "y": { "type": "integer", "description": "Optional: The vertical scroll offset." }
\\ }
\\}
),
},
}; };
pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void {
@@ -119,6 +163,8 @@ const ToolStreamingText = struct {
action: enum { markdown, links, semantic_tree }, action: enum { markdown, links, semantic_tree },
registry: ?*CDPNode.Registry = null, registry: ?*CDPNode.Registry = null,
arena: ?std.mem.Allocator = null, arena: ?std.mem.Allocator = null,
backendNodeId: ?u32 = null,
maxDepth: ?u32 = null,
pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) !void { pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) !void {
try jw.beginWriteRaw(); try jw.beginWriteRaw();
@@ -154,12 +200,24 @@ const ToolStreamingText = struct {
} }
}, },
.semantic_tree => { .semantic_tree => {
var root_node = self.page.document.asNode();
if (self.backendNodeId) |node_id| {
if (self.registry) |registry| {
if (registry.lookup_by_id.get(node_id)) |n| {
root_node = n.dom;
} else {
log.warn(.mcp, "semantic_tree id missing", .{ .id = node_id });
}
}
}
const st = lp.SemanticTree{ const st = lp.SemanticTree{
.dom_node = self.page.document.asNode(), .dom_node = root_node,
.registry = self.registry.?, .registry = self.registry.?,
.page = self.page, .page = self.page,
.arena = self.arena.?, .arena = self.arena.?,
.prune = true, .prune = true,
.max_depth = self.maxDepth orelse std.math.maxInt(u32) - 1,
}; };
st.textStringify(w) catch |err| { st.textStringify(w) catch |err| {
@@ -182,6 +240,9 @@ const ToolAction = enum {
structuredData, structuredData,
evaluate, evaluate,
semantic_tree, semantic_tree,
click,
fill,
scroll,
}; };
const tool_map = std.StaticStringMap(ToolAction).initComptime(.{ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{
@@ -193,6 +254,9 @@ const tool_map = std.StaticStringMap(ToolAction).initComptime(.{
.{ "structuredData", .structuredData }, .{ "structuredData", .structuredData },
.{ "evaluate", .evaluate }, .{ "evaluate", .evaluate },
.{ "semantic_tree", .semantic_tree }, .{ "semantic_tree", .semantic_tree },
.{ "click", .click },
.{ "fill", .fill },
.{ "scroll", .scroll },
}); });
pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void {
@@ -221,6 +285,9 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
.structuredData => try handleStructuredData(server, arena, req.id.?, call_params.arguments), .structuredData => try handleStructuredData(server, arena, req.id.?, call_params.arguments),
.evaluate => try handleEvaluate(server, arena, req.id.?, call_params.arguments), .evaluate => try handleEvaluate(server, arena, req.id.?, call_params.arguments),
.semantic_tree => try handleSemanticTree(server, arena, req.id.?, call_params.arguments), .semantic_tree => try handleSemanticTree(server, arena, req.id.?, call_params.arguments),
.click => try handleClick(server, arena, req.id.?, call_params.arguments),
.fill => try handleFill(server, arena, req.id.?, call_params.arguments),
.scroll => try handleScroll(server, arena, req.id.?, call_params.arguments),
} }
} }
@@ -277,9 +344,13 @@ fn handleLinks(server: *Server, arena: std.mem.Allocator, id: std.json.Value, ar
fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const TreeParams = struct { const TreeParams = struct {
url: ?[:0]const u8 = null, url: ?[:0]const u8 = null,
backendNodeId: ?u32 = null,
maxDepth: ?u32 = null,
}; };
var tree_args: TreeParams = .{};
if (arguments) |args_raw| { if (arguments) |args_raw| {
if (std.json.parseFromValueLeaky(TreeParams, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| { if (std.json.parseFromValueLeaky(TreeParams, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| {
tree_args = args;
if (args.url) |u| { if (args.url) |u| {
try performGoto(server, u, id); try performGoto(server, u, id);
} }
@@ -290,7 +361,7 @@ fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Va
}; };
const content = [_]protocol.TextContent(ToolStreamingText){.{ const content = [_]protocol.TextContent(ToolStreamingText){.{
.text = .{ .page = page, .action = .semantic_tree, .registry = &server.node_registry, .arena = arena }, .text = .{ .page = page, .action = .semantic_tree, .registry = &server.node_registry, .arena = arena, .backendNodeId = tree_args.backendNodeId, .maxDepth = tree_args.maxDepth },
}}; }};
try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }); try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content });
} }
@@ -380,6 +451,87 @@ fn handleEvaluate(server: *Server, arena: std.mem.Allocator, id: std.json.Value,
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
} }
fn handleClick(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const ClickParams = struct {
backendNodeId: CDPNode.Id,
};
const args = try parseArguments(ClickParams, arena, arguments, server, id, "click");
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse {
return server.sendError(id, .InvalidParams, "Node not found");
};
lp.actions.click(node.dom, page) catch |err| {
if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not an HTML element");
}
return server.sendError(id, .InternalError, "Failed to click element");
};
const content = [_]protocol.TextContent([]const u8){.{ .text = "Clicked successfully." }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handleFill(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const FillParams = struct {
backendNodeId: CDPNode.Id,
text: []const u8,
};
const args = try parseArguments(FillParams, arena, arguments, server, id, "fill");
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const node = server.node_registry.lookup_by_id.get(args.backendNodeId) orelse {
return server.sendError(id, .InvalidParams, "Node not found");
};
lp.actions.fill(node.dom, args.text, page) catch |err| {
if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not an input, textarea or select");
}
return server.sendError(id, .InternalError, "Failed to fill element");
};
const content = [_]protocol.TextContent([]const u8){.{ .text = "Filled successfully." }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn handleScroll(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const ScrollParams = struct {
backendNodeId: ?CDPNode.Id = null,
x: ?i32 = null,
y: ?i32 = null,
};
const args = try parseArguments(ScrollParams, arena, arguments, server, id, "scroll");
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
var target_node: ?*DOMNode = null;
if (args.backendNodeId) |node_id| {
const node = server.node_registry.lookup_by_id.get(node_id) orelse {
return server.sendError(id, .InvalidParams, "Node not found");
};
target_node = node.dom;
}
lp.actions.scroll(target_node, args.x, args.y, page) catch |err| {
if (err == error.InvalidNodeType) {
return server.sendError(id, .InvalidParams, "Node is not an element");
}
return server.sendError(id, .InternalError, "Failed to scroll");
};
const content = [_]protocol.TextContent([]const u8){.{ .text = "Scrolled successfully." }};
try server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
}
fn parseArguments(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value, server: *Server, id: std.json.Value, tool_name: []const u8) !T { fn parseArguments(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value, server: *Server, id: std.json.Value, tool_name: []const u8) !T {
if (arguments == null) { if (arguments == null) {
try server.sendError(id, .InvalidParams, "Missing arguments"); try server.sendError(id, .InvalidParams, "Missing arguments");
@@ -455,3 +607,66 @@ test "MCP - evaluate error reporting" {
\\} \\}
, out_alloc.writer.buffered()); , out_alloc.writer.buffered());
} }
test "MCP - Actions: click, fill, scroll" {
defer testing.reset();
const allocator = testing.allocator;
const app = testing.test_app;
var out_alloc: std.io.Writer.Allocating = .init(testing.arena_allocator);
defer out_alloc.deinit();
var server = try Server.init(allocator, app, &out_alloc.writer);
defer server.deinit();
const aa = testing.arena_allocator;
const page = try server.session.createPage();
const url = "http://localhost:9582/src/browser/tests/mcp_actions.html";
try page.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } });
_ = server.session.wait(5000);
// Test Click
const btn = page.document.getElementById("btn", page).?.asNode();
const btn_id = (try server.node_registry.register(btn)).id;
var btn_id_buf: [12]u8 = undefined;
const btn_id_str = std.fmt.bufPrint(&btn_id_buf, "{d}", .{btn_id}) catch unreachable;
const click_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"click\",\"arguments\":{\"backendNodeId\":", btn_id_str, "}}}" });
try router.handleMessage(server, aa, click_msg);
// Test Fill Input
const inp = page.document.getElementById("inp", page).?.asNode();
const inp_id = (try server.node_registry.register(inp)).id;
var inp_id_buf: [12]u8 = undefined;
const inp_id_str = std.fmt.bufPrint(&inp_id_buf, "{d}", .{inp_id}) catch unreachable;
const fill_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", inp_id_str, ",\"text\":\"hello\"}}}" });
try router.handleMessage(server, aa, fill_msg);
// Test Fill Select
const sel = page.document.getElementById("sel", page).?.asNode();
const sel_id = (try server.node_registry.register(sel)).id;
var sel_id_buf: [12]u8 = undefined;
const sel_id_str = std.fmt.bufPrint(&sel_id_buf, "{d}", .{sel_id}) catch unreachable;
const fill_sel_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", sel_id_str, ",\"text\":\"opt2\"}}}" });
try router.handleMessage(server, aa, fill_sel_msg);
// Test Scroll
const scrollbox = page.document.getElementById("scrollbox", page).?.asNode();
const scrollbox_id = (try server.node_registry.register(scrollbox)).id;
var scroll_id_buf: [12]u8 = undefined;
const scroll_id_str = std.fmt.bufPrint(&scroll_id_buf, "{d}", .{scrollbox_id}) catch unreachable;
const scroll_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"scroll\",\"arguments\":{\"backendNodeId\":", scroll_id_str, ",\"y\":50}}}" });
try router.handleMessage(server, aa, scroll_msg);
// Evaluate assertions
var ls: js.Local.Scope = undefined;
page.js.localScope(&ls);
defer ls.deinit();
var try_catch: js.TryCatch = undefined;
try_catch.init(&ls.local);
defer try_catch.deinit();
const result = try ls.local.compileAndRun("window.clicked === true && window.inputVal === 'hello' && window.changed === true && window.selChanged === 'opt2' && window.scrolled === true", null);
try testing.expect(result.isTrue());
}

View File

@@ -38,6 +38,11 @@ const Listener = struct {
onAccept: *const fn (ctx: *anyopaque, socket: posix.socket_t) void, onAccept: *const fn (ctx: *anyopaque, socket: posix.socket_t) void,
}; };
// Number of fixed pollfds entries (wakeup pipe + listener).
const PSEUDO_POLLFDS = 2;
const MAX_TICK_CALLBACKS = 16;
allocator: Allocator, allocator: Allocator,
config: *const Config, config: *const Config,
@@ -57,6 +62,22 @@ wakeup_pipe: [2]posix.fd_t = .{ -1, -1 },
shutdown: std.atomic.Value(bool) = .init(false), shutdown: std.atomic.Value(bool) = .init(false),
// Multi is a heavy structure that can consume up to 2MB of RAM.
// Currently, Runtime is used sparingly, and we only create it on demand.
// When Runtime becomes truly shared, it should become a regular field.
multi: ?*libcurl.CurlM = null,
submission_mutex: std.Thread.Mutex = .{},
submission_queue: std.DoublyLinkedList = .{},
callbacks: [MAX_TICK_CALLBACKS]TickCallback = undefined,
callbacks_len: usize = 0,
callbacks_mutex: std.Thread.Mutex = .{},
const TickCallback = struct {
ctx: *anyopaque,
fun: *const fn (*anyopaque) void,
};
const ZigToCurlAllocator = struct { const ZigToCurlAllocator = struct {
// C11 requires malloc to return memory aligned to max_align_t (16 bytes on x86_64). // C11 requires malloc to return memory aligned to max_align_t (16 bytes on x86_64).
// We match this guarantee since libcurl expects malloc-compatible alignment. // We match this guarantee since libcurl expects malloc-compatible alignment.
@@ -185,8 +206,8 @@ pub fn init(allocator: Allocator, config: *const Config) !Runtime {
const pipe = try posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); const pipe = try posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true });
// 0 is wakeup, 1 is listener // 0 is wakeup, 1 is listener, rest for curl fds
const pollfds = try allocator.alloc(posix.pollfd, 2); const pollfds = try allocator.alloc(posix.pollfd, PSEUDO_POLLFDS + config.httpMaxConcurrent());
errdefer allocator.free(pollfds); errdefer allocator.free(pollfds);
@memset(pollfds, .{ .fd = -1, .events = 0, .revents = 0 }); @memset(pollfds, .{ .fd = -1, .events = 0, .revents = 0 });
@@ -216,16 +237,23 @@ pub fn init(allocator: Allocator, config: *const Config) !Runtime {
.allocator = allocator, .allocator = allocator,
.config = config, .config = config,
.ca_blob = ca_blob, .ca_blob = ca_blob,
.robot_store = RobotStore.init(allocator),
.connections = connections,
.available = available,
.web_bot_auth = web_bot_auth,
.pollfds = pollfds, .pollfds = pollfds,
.wakeup_pipe = pipe, .wakeup_pipe = pipe,
.available = available,
.connections = connections,
.robot_store = RobotStore.init(allocator),
.web_bot_auth = web_bot_auth,
}; };
} }
pub fn deinit(self: *Runtime) void { pub fn deinit(self: *Runtime) void {
if (self.multi) |multi| {
libcurl.curl_multi_cleanup(multi) catch {};
}
for (&self.wakeup_pipe) |*fd| { for (&self.wakeup_pipe) |*fd| {
if (fd.* >= 0) { if (fd.* >= 0) {
posix.close(fd.*); posix.close(fd.*);
@@ -285,42 +313,105 @@ pub fn bind(
}; };
} }
pub fn run(self: *Runtime) void { pub fn onTick(self: *Runtime, ctx: *anyopaque, callback: *const fn (*anyopaque) void) void {
while (!self.shutdown.load(.acquire)) { self.callbacks_mutex.lock();
const listener = self.listener orelse return; defer self.callbacks_mutex.unlock();
_ = posix.poll(self.pollfds, -1) catch |err| { lp.assert(self.callbacks_len < MAX_TICK_CALLBACKS, "too many ticks", .{});
self.callbacks[self.callbacks_len] = .{
.ctx = ctx,
.fun = callback,
};
self.callbacks_len += 1;
self.wakeupPoll();
}
pub fn fireTicks(self: *Runtime) void {
self.callbacks_mutex.lock();
defer self.callbacks_mutex.unlock();
for (self.callbacks[0..self.callbacks_len]) |*callback| {
callback.fun(callback.ctx);
}
}
pub fn run(self: *Runtime) void {
var drain_buf: [64]u8 = undefined;
var running_handles: c_int = 0;
const poll_fd = &self.pollfds[0];
const listen_fd = &self.pollfds[1];
// Please note that receiving a shutdown command does not terminate all connections.
// When gracefully shutting down a server, we at least want to send the remaining
// telemetry, but we stop accepting new connections. It is the responsibility
// of external code to terminate its requests upon shutdown.
while (true) {
self.drainQueue();
if (self.multi) |multi| {
// Kickstart newly added handles (DNS/connect) so that
// curl registers its sockets before we poll.
libcurl.curl_multi_perform(multi, &running_handles) catch |err| {
lp.log.err(.app, "curl perform", .{ .err = err });
};
self.preparePollFds(multi);
}
// for ontick to work, you need to wake up periodically
const timeout = blk: {
const min_timeout = 250; // 250ms
if (self.multi == null) {
break :blk min_timeout;
}
const curl_timeout = self.getCurlTimeout();
if (curl_timeout == 0) {
break :blk min_timeout;
}
break :blk @min(min_timeout, curl_timeout);
};
_ = posix.poll(self.pollfds, timeout) catch |err| {
lp.log.err(.app, "poll", .{ .err = err }); lp.log.err(.app, "poll", .{ .err = err });
continue; continue;
}; };
// check wakeup socket // check wakeup pipe
if (self.pollfds[0].revents != 0) { if (poll_fd.revents != 0) {
self.pollfds[0].revents = 0; poll_fd.revents = 0;
while (true)
// If we were woken up, perhaps everything was cancelled and the iteration can be completed. _ = posix.read(self.wakeup_pipe[0], &drain_buf) catch break;
if (self.shutdown.load(.acquire)) break;
} }
// check new connections; // accept new connections
if (self.pollfds[1].revents == 0) continue; if (listen_fd.revents != 0) {
self.pollfds[1].revents = 0; listen_fd.revents = 0;
self.acceptConnections();
}
const socket = posix.accept(listener.socket, null, null, posix.SOCK.NONBLOCK) catch |err| { if (self.multi) |multi| {
switch (err) { // Drive transfers and process completions.
error.SocketNotListening, error.ConnectionAborted => { libcurl.curl_multi_perform(multi, &running_handles) catch |err| {
self.pollfds[1] = .{ .fd = -1, .events = 0, .revents = 0 }; lp.log.err(.app, "curl perform", .{ .err = err });
self.listener = null; };
}, self.processCompletions(multi);
error.WouldBlock => {}, }
else => {
lp.log.err(.app, "accept", .{ .err = err });
},
}
continue;
};
listener.onAccept(listener.ctx, socket); self.fireTicks();
if (self.shutdown.load(.acquire) and running_handles == 0) {
// Check if fireTicks submitted new requests (e.g. telemetry flush).
// If so, continue the loop to drain and send them before exiting.
self.submission_mutex.lock();
const has_pending = self.submission_queue.first != null;
self.submission_mutex.unlock();
if (!has_pending) break;
}
} }
if (self.listener) |listener| { if (self.listener) |listener| {
@@ -337,9 +428,132 @@ pub fn run(self: *Runtime) void {
} }
} }
pub fn submitRequest(self: *Runtime, conn: *net_http.Connection) void {
self.submission_mutex.lock();
self.submission_queue.append(&conn.node);
self.submission_mutex.unlock();
self.wakeupPoll();
}
fn wakeupPoll(self: *Runtime) void {
_ = posix.write(self.wakeup_pipe[1], &.{1}) catch {};
}
fn drainQueue(self: *Runtime) void {
self.submission_mutex.lock();
defer self.submission_mutex.unlock();
if (self.submission_queue.first == null) return;
const multi = self.multi orelse blk: {
const m = libcurl.curl_multi_init() orelse {
lp.assert(false, "curl multi init failed", .{});
unreachable;
};
self.multi = m;
break :blk m;
};
while (self.submission_queue.popFirst()) |node| {
const conn: *net_http.Connection = @fieldParentPtr("node", node);
conn.setPrivate(conn) catch |err| {
lp.log.err(.app, "curl set private", .{ .err = err });
self.releaseConnection(conn);
continue;
};
libcurl.curl_multi_add_handle(multi, conn.easy) catch |err| {
lp.log.err(.app, "curl multi add", .{ .err = err });
self.releaseConnection(conn);
};
}
}
pub fn stop(self: *Runtime) void { pub fn stop(self: *Runtime) void {
self.shutdown.store(true, .release); self.shutdown.store(true, .release);
_ = posix.write(self.wakeup_pipe[1], &.{1}) catch {}; self.wakeupPoll();
}
fn acceptConnections(self: *Runtime) void {
if (self.shutdown.load(.acquire)) {
return;
}
const listener = self.listener orelse return;
while (true) {
const socket = posix.accept(listener.socket, null, null, posix.SOCK.NONBLOCK) catch |err| {
switch (err) {
error.WouldBlock => break,
error.SocketNotListening => {
self.pollfds[1] = .{ .fd = -1, .events = 0, .revents = 0 };
self.listener = null;
return;
},
error.ConnectionAborted => {
lp.log.warn(.app, "accept connection aborted", .{});
continue;
},
else => {
lp.log.err(.app, "accept error", .{ .err = err });
continue;
},
}
};
listener.onAccept(listener.ctx, socket);
}
}
fn preparePollFds(self: *Runtime, multi: *libcurl.CurlM) void {
const curl_fds = self.pollfds[PSEUDO_POLLFDS..];
@memset(curl_fds, .{ .fd = -1, .events = 0, .revents = 0 });
var fd_count: c_uint = 0;
const wait_fds: []libcurl.CurlWaitFd = @ptrCast(curl_fds);
libcurl.curl_multi_waitfds(multi, wait_fds, &fd_count) catch |err| {
lp.log.err(.app, "curl waitfds", .{ .err = err });
};
}
fn getCurlTimeout(self: *Runtime) i32 {
const multi = self.multi orelse return -1;
var timeout_ms: c_long = -1;
libcurl.curl_multi_timeout(multi, &timeout_ms) catch return -1;
return @intCast(@min(timeout_ms, std.math.maxInt(i32)));
}
fn processCompletions(self: *Runtime, multi: *libcurl.CurlM) void {
var msgs_in_queue: c_int = 0;
while (libcurl.curl_multi_info_read(multi, &msgs_in_queue)) |msg| {
switch (msg.data) {
.done => |maybe_err| {
if (maybe_err) |err| {
lp.log.warn(.app, "curl transfer error", .{ .err = err });
}
},
else => continue,
}
const easy: *libcurl.Curl = msg.easy_handle;
var ptr: *anyopaque = undefined;
libcurl.curl_easy_getinfo(easy, .private, &ptr) catch
lp.assert(false, "curl getinfo private", .{});
const conn: *net_http.Connection = @ptrCast(@alignCast(ptr));
libcurl.curl_multi_remove_handle(multi, easy) catch {};
self.releaseConnection(conn);
}
}
comptime {
if (@sizeOf(posix.pollfd) != @sizeOf(libcurl.CurlWaitFd)) {
@compileError("pollfd and CurlWaitFd size mismatch");
}
if (@offsetOf(posix.pollfd, "fd") != @offsetOf(libcurl.CurlWaitFd, "fd") or
@offsetOf(posix.pollfd, "events") != @offsetOf(libcurl.CurlWaitFd, "events") or
@offsetOf(posix.pollfd, "revents") != @offsetOf(libcurl.CurlWaitFd, "revents"))
{
@compileError("pollfd and CurlWaitFd layout mismatch");
}
} }
pub fn getConnection(self: *Runtime) ?*net_http.Connection { pub fn getConnection(self: *Runtime) ?*net_http.Connection {

View File

@@ -386,11 +386,18 @@ pub const Connection = struct {
} }
pub fn reset(self: *const Connection) !void { pub fn reset(self: *const Connection) !void {
try libcurl.curl_easy_setopt(self.easy, .proxy, null);
try libcurl.curl_easy_setopt(self.easy, .http_header, null);
try libcurl.curl_easy_setopt(self.easy, .header_data, null); try libcurl.curl_easy_setopt(self.easy, .header_data, null);
try libcurl.curl_easy_setopt(self.easy, .header_function, null); try libcurl.curl_easy_setopt(self.easy, .header_function, null);
try libcurl.curl_easy_setopt(self.easy, .write_data, null); try libcurl.curl_easy_setopt(self.easy, .write_data, null);
try libcurl.curl_easy_setopt(self.easy, .write_function, null); try libcurl.curl_easy_setopt(self.easy, .write_function, discardBody);
try libcurl.curl_easy_setopt(self.easy, .proxy, null); }
fn discardBody(_: [*]const u8, count: usize, len: usize, _: ?*anyopaque) usize {
return count * len;
} }
pub fn setProxy(self: *const Connection, proxy: ?[:0]const u8) !void { pub fn setProxy(self: *const Connection, proxy: ?[:0]const u8) !void {

View File

@@ -308,6 +308,7 @@ pub fn Reader(comptime EXPECT_MASK: bool) type {
pub const WsConnection = struct { pub const WsConnection = struct {
// CLOSE, 2 length, code // CLOSE, 2 length, code
const CLOSE_NORMAL = [_]u8{ 136, 2, 3, 232 }; // code: 1000 const CLOSE_NORMAL = [_]u8{ 136, 2, 3, 232 }; // code: 1000
const CLOSE_GOING_AWAY = [_]u8{ 136, 2, 3, 233 }; // code: 1001
const CLOSE_TOO_BIG = [_]u8{ 136, 2, 3, 241 }; // 1009 const CLOSE_TOO_BIG = [_]u8{ 136, 2, 3, 241 }; // 1009
const CLOSE_PROTOCOL_ERROR = [_]u8{ 136, 2, 3, 234 }; //code: 1002 const CLOSE_PROTOCOL_ERROR = [_]u8{ 136, 2, 3, 234 }; //code: 1002
// "private-use" close codes must be from 4000-49999 // "private-use" close codes must be from 4000-49999
@@ -583,6 +584,10 @@ pub const WsConnection = struct {
return address; return address;
} }
pub fn sendClose(self: *WsConnection) void {
self.send(&CLOSE_GOING_AWAY) catch {};
}
pub fn shutdown(self: *WsConnection) void { pub fn shutdown(self: *WsConnection) void {
posix.shutdown(self.socket, .recv) catch {}; posix.shutdown(self.socket, .recv) catch {};
} }

View File

@@ -629,10 +629,13 @@ pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype
.write_function => blk: { .write_function => blk: {
const cb: c.curl_write_callback = switch (@typeInfo(@TypeOf(value))) { const cb: c.curl_write_callback = switch (@typeInfo(@TypeOf(value))) {
.null => null, .null => null,
.@"fn" => struct { .@"fn" => |info| struct {
fn cb(buffer: [*c]u8, count: usize, len: usize, user: ?*anyopaque) callconv(.c) usize { fn cb(buffer: [*c]u8, count: usize, len: usize, user: ?*anyopaque) callconv(.c) usize {
const u = user orelse unreachable; const user_arg = if (@typeInfo(info.params[3].type.?) == .optional)
return value(@ptrCast(buffer), count, len, u); user
else
user orelse unreachable;
return value(@ptrCast(buffer), count, len, user_arg);
} }
}.cb, }.cb,
else => @compileError("expected Zig function or null for " ++ @tagName(option) ++ ", got " ++ @typeName(@TypeOf(value))), else => @compileError("expected Zig function or null for " ++ @tagName(option) ++ ", got " ++ @typeName(@TypeOf(value))),
@@ -753,6 +756,15 @@ pub fn curl_multi_poll(
try errorMCheck(c.curl_multi_poll(multi, raw_fds, @intCast(extra_fds.len), timeout_ms, numfds)); try errorMCheck(c.curl_multi_poll(multi, raw_fds, @intCast(extra_fds.len), timeout_ms, numfds));
} }
pub fn curl_multi_waitfds(multi: *CurlM, ufds: []CurlWaitFd, fd_count: *c_uint) ErrorMulti!void {
const raw_fds: [*c]c.curl_waitfd = if (ufds.len == 0) null else @ptrCast(ufds.ptr);
try errorMCheck(c.curl_multi_waitfds(multi, raw_fds, @intCast(ufds.len), fd_count));
}
pub fn curl_multi_timeout(multi: *CurlM, timeout_ms: *c_long) ErrorMulti!void {
try errorMCheck(c.curl_multi_timeout(multi, timeout_ms));
}
pub fn curl_multi_info_read(multi: *CurlM, msgs_in_queue: *c_int) ?CurlMsg { pub fn curl_multi_info_read(multi: *CurlM, msgs_in_queue: *c_int) ?CurlMsg {
const ptr = c.curl_multi_info_read(multi, msgs_in_queue); const ptr = c.curl_multi_info_read(multi, msgs_in_queue);
if (ptr == null) return null; if (ptr == null) return null;

View File

@@ -2,140 +2,132 @@ const std = @import("std");
const builtin = @import("builtin"); const builtin = @import("builtin");
const build_config = @import("build_config"); const build_config = @import("build_config");
const Thread = std.Thread;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const log = @import("../log.zig"); const log = @import("../log.zig");
const App = @import("../App.zig"); const App = @import("../App.zig");
const Config = @import("../Config.zig"); const Config = @import("../Config.zig");
const telemetry = @import("telemetry.zig"); const telemetry = @import("telemetry.zig");
const Runtime = @import("../network/Runtime.zig");
const Connection = @import("../network/http.zig").Connection; const Connection = @import("../network/http.zig").Connection;
const URL = "https://telemetry.lightpanda.io"; const URL = "https://telemetry.lightpanda.io";
const MAX_BATCH_SIZE = 20; const BUFFER_SIZE = 1024;
const MAX_BODY_SIZE = 500 * 1024; // 500KB server limit
pub const LightPanda = struct { const LightPanda = @This();
running: bool,
thread: ?std.Thread,
allocator: Allocator,
mutex: std.Thread.Mutex,
cond: Thread.Condition,
connection: Connection,
config: *const Config,
pending: std.DoublyLinkedList,
mem_pool: std.heap.MemoryPool(LightPandaEvent),
pub fn init(app: *App) !LightPanda { allocator: Allocator,
const connection = try app.network.newConnection(); runtime: *Runtime,
errdefer connection.deinit(); writer: std.Io.Writer.Allocating,
try connection.setURL(URL); /// Protects concurrent producers in send().
try connection.setMethod(.POST); mutex: std.Thread.Mutex = .{},
const allocator = app.allocator; iid: ?[36]u8 = null,
return .{ run_mode: Config.RunMode = .serve,
.cond = .{},
.mutex = .{}, head: std.atomic.Value(usize) = .init(0),
.pending = .{}, tail: std.atomic.Value(usize) = .init(0),
.thread = null, dropped: std.atomic.Value(usize) = .init(0),
.running = true, buffer: [BUFFER_SIZE]telemetry.Event = undefined,
.allocator = allocator,
.connection = connection, pub fn init(self: *LightPanda, app: *App, iid: ?[36]u8, run_mode: Config.RunMode) !void {
.config = app.config, self.* = .{
.mem_pool = std.heap.MemoryPool(LightPandaEvent).init(allocator), .iid = iid,
}; .run_mode = run_mode,
.allocator = app.allocator,
.runtime = &app.network,
.writer = std.Io.Writer.Allocating.init(app.allocator),
};
self.runtime.onTick(@ptrCast(self), flushCallback);
}
pub fn deinit(self: *LightPanda) void {
self.writer.deinit();
}
pub fn send(self: *LightPanda, raw_event: telemetry.Event) !void {
self.mutex.lock();
defer self.mutex.unlock();
const t = self.tail.load(.monotonic);
const h = self.head.load(.acquire);
if (t - h >= BUFFER_SIZE) {
_ = self.dropped.fetchAdd(1, .monotonic);
return;
} }
pub fn deinit(self: *LightPanda) void { self.buffer[t % BUFFER_SIZE] = raw_event;
if (self.thread) |*thread| { self.tail.store(t + 1, .release);
self.mutex.lock(); }
self.running = false;
self.mutex.unlock(); fn flushCallback(ctx: *anyopaque) void {
self.cond.signal(); const self: *LightPanda = @ptrCast(@alignCast(ctx));
thread.join(); self.postEvent() catch |err| {
} log.warn(.telemetry, "flush error", .{ .err = err });
self.mem_pool.deinit(); };
self.connection.deinit(); }
fn postEvent(self: *LightPanda) !void {
const conn = self.runtime.getConnection() orelse {
return;
};
errdefer self.runtime.releaseConnection(conn);
const h = self.head.load(.monotonic);
const t = self.tail.load(.acquire);
const dropped = self.dropped.swap(0, .monotonic);
if (h == t and dropped == 0) {
self.runtime.releaseConnection(conn);
return;
}
errdefer _ = self.dropped.fetchAdd(dropped, .monotonic);
self.writer.clearRetainingCapacity();
if (dropped > 0) {
_ = try self.writeEvent(.{ .buffer_overflow = .{ .dropped = dropped } });
} }
pub fn send(self: *LightPanda, iid: ?[]const u8, run_mode: Config.RunMode, raw_event: telemetry.Event) !void { var sent: usize = 0;
const event = try self.mem_pool.create(); for (h..t) |i| {
event.* = .{ const fit = try self.writeEvent(self.buffer[i % BUFFER_SIZE]);
.iid = iid, if (!fit) break;
.mode = run_mode,
.event = raw_event,
.node = .{},
};
self.mutex.lock(); sent += 1;
defer self.mutex.unlock();
if (self.thread == null) {
self.thread = try std.Thread.spawn(.{}, run, .{self});
}
self.pending.append(&event.node);
self.cond.signal();
} }
fn run(self: *LightPanda) void { try conn.setURL(URL);
var aw = std.Io.Writer.Allocating.init(self.allocator); try conn.setMethod(.POST);
defer aw.deinit(); try conn.setBody(self.writer.written());
var batch: [MAX_BATCH_SIZE]*LightPandaEvent = undefined; self.head.store(h + sent, .release);
self.mutex.lock(); self.runtime.submitRequest(conn);
while (true) { }
while (self.pending.first != null) {
const b = self.collectBatch(&batch); fn writeEvent(self: *LightPanda, event: telemetry.Event) !bool {
self.mutex.unlock(); const iid: ?[]const u8 = if (self.iid) |*id| id else null;
self.postEvent(b, &aw) catch |err| { const wrapped = LightPandaEvent{ .iid = iid, .mode = self.run_mode, .event = event };
log.warn(.telemetry, "post error", .{ .err = err });
}; const checkpoint = self.writer.written().len;
self.mutex.lock();
} try std.json.Stringify.value(&wrapped, .{ .emit_null_optional_fields = false }, &self.writer.writer);
if (self.running == false) { try self.writer.writer.writeByte('\n');
return;
} if (self.writer.written().len > MAX_BODY_SIZE) {
self.cond.wait(&self.mutex); self.writer.shrinkRetainingCapacity(checkpoint);
} return false;
} }
return true;
fn postEvent(self: *LightPanda, events: []*LightPandaEvent, aw: *std.Io.Writer.Allocating) !void { }
defer for (events) |e| {
self.mem_pool.destroy(e);
};
defer aw.clearRetainingCapacity();
for (events) |event| {
try std.json.Stringify.value(event, .{ .emit_null_optional_fields = false }, &aw.writer);
try aw.writer.writeByte('\n');
}
try self.connection.setBody(aw.written());
const status = try self.connection.request(&self.config.http_headers);
if (status != 200) {
log.warn(.telemetry, "server error", .{ .status = status });
}
}
fn collectBatch(self: *LightPanda, into: []*LightPandaEvent) []*LightPandaEvent {
var i: usize = 0;
while (self.pending.popFirst()) |node| {
into[i] = @fieldParentPtr("node", node);
i += 1;
if (i == MAX_BATCH_SIZE) {
break;
}
}
return into[0..i];
}
};
const LightPandaEvent = struct { const LightPandaEvent = struct {
iid: ?[]const u8, iid: ?[]const u8,
mode: Config.RunMode, mode: Config.RunMode,
event: telemetry.Event, event: telemetry.Event,
node: std.DoublyLinkedList.Node,
pub fn jsonStringify(self: *const LightPandaEvent, writer: anytype) !void { pub fn jsonStringify(self: *const LightPandaEvent, writer: anytype) !void {
try writer.beginObject(); try writer.beginObject();
@@ -153,7 +145,7 @@ const LightPandaEvent = struct {
try writer.write(builtin.cpu.arch); try writer.write(builtin.cpu.arch);
try writer.objectField("version"); try writer.objectField("version");
try writer.write(build_config.git_commit); try writer.write(build_config.git_version orelse build_config.git_commit);
try writer.objectField("event"); try writer.objectField("event");
try writer.write(@tagName(std.meta.activeTag(self.event))); try writer.write(@tagName(std.meta.activeTag(self.event)));

View File

@@ -11,26 +11,21 @@ const uuidv4 = @import("../id.zig").uuidv4;
const IID_FILE = "iid"; const IID_FILE = "iid";
pub fn isDisabled() bool { pub fn isDisabled() bool {
if (builtin.mode == .Debug or builtin.is_test) {
return true;
}
return std.process.hasEnvVarConstant("LIGHTPANDA_DISABLE_TELEMETRY"); return std.process.hasEnvVarConstant("LIGHTPANDA_DISABLE_TELEMETRY");
} }
pub const Telemetry = TelemetryT(blk: { pub const Telemetry = TelemetryT(@import("lightpanda.zig"));
if (builtin.mode == .Debug or builtin.is_test) break :blk NoopProvider;
break :blk @import("lightpanda.zig").LightPanda;
});
fn TelemetryT(comptime P: type) type { fn TelemetryT(comptime P: type) type {
return struct { return struct {
// an "install" id that we [try to] persist and re-use between runs provider: *P,
// null on IO error
iid: ?[36]u8,
provider: P,
disabled: bool, disabled: bool,
run_mode: Config.RunMode,
const Self = @This(); const Self = @This();
pub fn init(app: *App, run_mode: Config.RunMode) !Self { pub fn init(app: *App, run_mode: Config.RunMode) !Self {
@@ -39,27 +34,29 @@ fn TelemetryT(comptime P: type) type {
log.info(.telemetry, "telemetry status", .{ .disabled = disabled }); log.info(.telemetry, "telemetry status", .{ .disabled = disabled });
} }
const provider = try P.init(app); const iid: ?[36]u8 = if (disabled) null else getOrCreateId(app.app_dir_path);
errdefer provider.deinit();
const provider = try app.allocator.create(P);
errdefer app.allocator.destroy(provider);
try P.init(provider, app, iid, run_mode);
return .{ return .{
.disabled = disabled, .disabled = disabled,
.run_mode = run_mode,
.provider = provider, .provider = provider,
.iid = if (disabled) null else getOrCreateId(app.app_dir_path),
}; };
} }
pub fn deinit(self: *Self) void { pub fn deinit(self: *Self, allocator: Allocator) void {
self.provider.deinit(); self.provider.deinit();
allocator.destroy(self.provider);
} }
pub fn record(self: *Self, event: Event) void { pub fn record(self: *Self, event: Event) void {
if (self.disabled) { if (self.disabled) {
return; return;
} }
const iid: ?[]const u8 = if (self.iid) |*iid| iid else null; self.provider.send(event) catch |err| {
self.provider.send(iid, self.run_mode, event) catch |err| {
log.warn(.telemetry, "record error", .{ .err = err, .type = @tagName(std.meta.activeTag(event)) }); log.warn(.telemetry, "record error", .{ .err = err, .type = @tagName(std.meta.activeTag(event)) });
}; };
} }
@@ -105,6 +102,7 @@ fn getOrCreateId(app_dir_path_: ?[]const u8) ?[36]u8 {
pub const Event = union(enum) { pub const Event = union(enum) {
run: void, run: void,
navigate: Navigate, navigate: Navigate,
buffer_overflow: BufferOverflow,
flag: []const u8, // used for testing flag: []const u8, // used for testing
const Navigate = struct { const Navigate = struct {
@@ -112,36 +110,35 @@ pub const Event = union(enum) {
proxy: bool, proxy: bool,
driver: []const u8 = "cdp", driver: []const u8 = "cdp",
}; };
};
const NoopProvider = struct { const BufferOverflow = struct {
fn init(_: *App) !NoopProvider { dropped: usize,
return .{}; };
}
fn deinit(_: NoopProvider) void {}
pub fn send(_: NoopProvider, _: ?[]const u8, _: Config.RunMode, _: Event) !void {}
}; };
extern fn setenv(name: [*:0]u8, value: [*:0]u8, override: c_int) c_int; extern fn setenv(name: [*:0]u8, value: [*:0]u8, override: c_int) c_int;
extern fn unsetenv(name: [*:0]u8) c_int; extern fn unsetenv(name: [*:0]u8) c_int;
const testing = @import("../testing.zig"); const testing = @import("../testing.zig");
test "telemetry: disabled by environment" { test "telemetry: always disabled in debug builds" {
// Must be disabled regardless of environment variable.
_ = unsetenv(@constCast("LIGHTPANDA_DISABLE_TELEMETRY"));
try testing.expectEqual(true, isDisabled());
_ = setenv(@constCast("LIGHTPANDA_DISABLE_TELEMETRY"), @constCast(""), 0); _ = setenv(@constCast("LIGHTPANDA_DISABLE_TELEMETRY"), @constCast(""), 0);
defer _ = unsetenv(@constCast("LIGHTPANDA_DISABLE_TELEMETRY")); defer _ = unsetenv(@constCast("LIGHTPANDA_DISABLE_TELEMETRY"));
try testing.expectEqual(true, isDisabled());
const FailingProvider = struct { const FailingProvider = struct {
fn init(_: *App) !@This() { fn init(_: *@This(), _: *App, _: ?[36]u8, _: Config.RunMode) !void {}
return .{}; fn deinit(_: *@This()) void {}
} pub fn send(_: *@This(), _: Event) !void {
fn deinit(_: @This()) void {}
pub fn send(_: @This(), _: ?[]const u8, _: Config.RunMode, _: Event) !void {
unreachable; unreachable;
} }
}; };
var telemetry = try TelemetryT(FailingProvider).init(undefined, .serve); var telemetry = try TelemetryT(FailingProvider).init(testing.test_app, .serve);
defer telemetry.deinit(); defer telemetry.deinit(testing.test_app.allocator);
telemetry.record(.{ .run = {} }); telemetry.record(.{ .run = {} });
} }
@@ -165,8 +162,9 @@ test "telemetry: getOrCreateId" {
test "telemetry: sends event to provider" { test "telemetry: sends event to provider" {
var telemetry = try TelemetryT(MockProvider).init(testing.test_app, .serve); var telemetry = try TelemetryT(MockProvider).init(testing.test_app, .serve);
defer telemetry.deinit(); defer telemetry.deinit(testing.test_app.allocator);
const mock = &telemetry.provider; telemetry.disabled = false;
const mock = telemetry.provider;
telemetry.record(.{ .flag = "1" }); telemetry.record(.{ .flag = "1" });
telemetry.record(.{ .flag = "2" }); telemetry.record(.{ .flag = "2" });
@@ -179,15 +177,11 @@ test "telemetry: sends event to provider" {
} }
const MockProvider = struct { const MockProvider = struct {
iid: ?[]const u8,
run_mode: ?Config.RunMode,
allocator: Allocator, allocator: Allocator,
events: std.ArrayList(Event), events: std.ArrayList(Event),
fn init(app: *App) !@This() { fn init(self: *MockProvider, app: *App, _: ?[36]u8, _: Config.RunMode) !void {
return .{ self.* = .{
.iid = null,
.run_mode = null,
.events = .{}, .events = .{},
.allocator = app.allocator, .allocator = app.allocator,
}; };
@@ -195,15 +189,7 @@ const MockProvider = struct {
fn deinit(self: *MockProvider) void { fn deinit(self: *MockProvider) void {
self.events.deinit(self.allocator); self.events.deinit(self.allocator);
} }
pub fn send(self: *MockProvider, iid: ?[]const u8, run_mode: Config.RunMode, events: Event) !void { pub fn send(self: *MockProvider, event: Event) !void {
if (self.iid == null) { try self.events.append(self.allocator, event);
try testing.expectEqual(null, self.run_mode);
self.iid = iid.?;
self.run_mode = run_mode;
} else {
try testing.expectEqual(self.iid.?, iid.?);
try testing.expectEqual(self.run_mode.?, run_mode);
}
try self.events.append(self.allocator, events);
} }
}; };

View File

@@ -616,12 +616,12 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void {
pub const LogFilter = struct { pub const LogFilter = struct {
old_filter: []const log.Scope, old_filter: []const log.Scope,
/// Sets the log filter to only include the specified scope. /// Sets the log filter to suppress the specified scope(s).
/// Returns a LogFilter that should be deinitialized to restore previous filters. /// Returns a LogFilter that should be deinitialized to restore previous filters.
pub fn init(comptime scope: log.Scope) LogFilter { pub fn init(comptime scopes: []const log.Scope) LogFilter {
comptime std.debug.assert(@TypeOf(scopes) == []const log.Scope);
const old_filter = log.opts.filter_scopes; const old_filter = log.opts.filter_scopes;
const new_filter = comptime &[_]log.Scope{scope}; log.opts.filter_scopes = scopes;
log.opts.filter_scopes = new_filter;
return .{ .old_filter = old_filter }; return .{ .old_filter = old_filter };
} }