mirror of
https://github.com/lightpanda-io/browser.git
synced 2025-10-29 15:13:28 +00:00
Compare commits
185 Commits
custom_ele
...
url-set-pa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dab607369 | ||
|
|
889c29a163 | ||
|
|
886c1370e7 | ||
|
|
febcc0a673 | ||
|
|
da3fe6f7ea | ||
|
|
f612ce262f | ||
|
|
7f732c94da | ||
|
|
bdc49a65aa | ||
|
|
73d82dd0ba | ||
|
|
dfa4403c8a | ||
|
|
b8f3b19499 | ||
|
|
448718d112 | ||
|
|
6de55df4bc | ||
|
|
189fe26667 | ||
|
|
7230884116 | ||
|
|
d7fba81f8f | ||
|
|
29ac13185c | ||
|
|
3a49ee83ce | ||
|
|
95cbbc3b45 | ||
|
|
2a5c7d139f | ||
|
|
b74863873b | ||
|
|
7b46fe9cc8 | ||
|
|
afc8c69a82 | ||
|
|
38bbad6e88 | ||
|
|
1df47fd415 | ||
|
|
faf21c5fff | ||
|
|
2aee580795 | ||
|
|
404c027546 | ||
|
|
04e59c6df2 | ||
|
|
835042b794 | ||
|
|
907490e266 | ||
|
|
80fe167646 | ||
|
|
d30631f991 | ||
|
|
8956ab85f9 | ||
|
|
07693e54af | ||
|
|
b6132f2497 | ||
|
|
b3fe3d02c9 | ||
|
|
e880b18bb1 | ||
|
|
74a299eef7 | ||
|
|
300428ddfb | ||
|
|
1c27f8251e | ||
|
|
92badd3722 | ||
|
|
8a80f0b3dd | ||
|
|
fcc74b63d3 | ||
|
|
d7155e6662 | ||
|
|
42c3841639 | ||
|
|
c331713401 | ||
|
|
002d9c1747 | ||
|
|
2885ceceb1 | ||
|
|
22a644ba01 | ||
|
|
bab120a75d | ||
|
|
7a07c82f06 | ||
|
|
e881d2f6cf | ||
|
|
c8d003a08f | ||
|
|
e2cc404571 | ||
|
|
be71eaae47 | ||
|
|
ed31a452b2 | ||
|
|
f51ee7f3a0 | ||
|
|
9d1dc97766 | ||
|
|
b78729f685 | ||
|
|
44a76e59f9 | ||
|
|
1504e36a68 | ||
|
|
80348ef190 | ||
|
|
a3c14748d3 | ||
|
|
3c0143af92 | ||
|
|
22a93a9c39 | ||
|
|
e8866a6431 | ||
|
|
455ed79872 | ||
|
|
3d17c531d7 | ||
|
|
dfe90243d6 | ||
|
|
bf1db50667 | ||
|
|
a2565a7c83 | ||
|
|
947d01a3c0 | ||
|
|
be11d82c9c | ||
|
|
7a0e7fff13 | ||
|
|
81fb71b7f7 | ||
|
|
b10f5ec99f | ||
|
|
5abe7bdeef | ||
|
|
54be651415 | ||
|
|
cdbf6d7ae7 | ||
|
|
50349edf4d | ||
|
|
da307c1b40 | ||
|
|
b50b96bd1d | ||
|
|
92654fc5aa | ||
|
|
36b2de216b | ||
|
|
8745c1016e | ||
|
|
f5a58c1ff0 | ||
|
|
d9e72049ae | ||
|
|
927ca01161 | ||
|
|
3ea8d0b01c | ||
|
|
c52d33e331 | ||
|
|
fd36606acc | ||
|
|
1c6f4a79e0 | ||
|
|
7896d274a3 | ||
|
|
6937c8ecb4 | ||
|
|
f02b9566c5 | ||
|
|
c9936c2b7e | ||
|
|
bbd9e5e07c | ||
|
|
476fb7ec4e | ||
|
|
7435274be2 | ||
|
|
08d2ea6a10 | ||
|
|
41b7ed6938 | ||
|
|
7a311a181b | ||
|
|
ddcb597710 | ||
|
|
9c75f29875 | ||
|
|
343f3885f7 | ||
|
|
ed7dfeab84 | ||
|
|
8de27b3674 | ||
|
|
f56b0a5f6d | ||
|
|
0a27e1254f | ||
|
|
3f9b256fcb | ||
|
|
9ea9859150 | ||
|
|
03e3f95d2e | ||
|
|
e721b0af92 | ||
|
|
e18c589de3 | ||
|
|
aea34264a9 | ||
|
|
8d3a04235d | ||
|
|
9c4088b24c | ||
|
|
1e7ee4e0a1 | ||
|
|
ec92f110b3 | ||
|
|
2aa5eb85ad | ||
|
|
2815f02382 | ||
|
|
8bd7c8dd41 | ||
|
|
269dcf071f | ||
|
|
997ec7f0bc | ||
|
|
d9c26bb77f | ||
|
|
c0fc3a19c8 | ||
|
|
ce638c39e3 | ||
|
|
6b651cd5e4 | ||
|
|
4560f31010 | ||
|
|
c97a32e24b | ||
|
|
8a005bc5a1 | ||
|
|
20aabee72e | ||
|
|
a00c2345ee | ||
|
|
cb35b3624a | ||
|
|
c6f59a7aa6 | ||
|
|
bf296ad797 | ||
|
|
256540934b | ||
|
|
3c07c0818d | ||
|
|
a01d18ace1 | ||
|
|
55e02f01dc | ||
|
|
fe6ccad485 | ||
|
|
11fe79312d | ||
|
|
bdb2338b5b | ||
|
|
bbafb048d0 | ||
|
|
9fc2fa51bd | ||
|
|
d8ec50345a | ||
|
|
9f1cc09ca8 | ||
|
|
5dcc3db36b | ||
|
|
898b73ffc8 | ||
|
|
c5d49a9d34 | ||
|
|
ef9f828d35 | ||
|
|
c691764205 | ||
|
|
2c940d4fd6 | ||
|
|
54bd55d45d | ||
|
|
0b846b15b1 | ||
|
|
269eb7e154 | ||
|
|
97bc19e4ae | ||
|
|
2656cc7842 | ||
|
|
ba94818415 | ||
|
|
ac759a6eed | ||
|
|
1839b346a6 | ||
|
|
c1ffe7f8e6 | ||
|
|
833b4d10bd | ||
|
|
ce98c336c9 | ||
|
|
d05619990a | ||
|
|
8033e41d4a | ||
|
|
60f4eab759 | ||
|
|
d7656ea985 | ||
|
|
e402998577 | ||
|
|
073f75efa3 | ||
|
|
da414f7eb3 | ||
|
|
270b89830a | ||
|
|
74ce7ca416 | ||
|
|
3f4338cb51 | ||
|
|
30ee41fd0e | ||
|
|
4965fec55c | ||
|
|
18dff8455c | ||
|
|
fe16f06aee | ||
|
|
48c1c05a93 | ||
|
|
38dee1166d | ||
|
|
0c6fc68eae | ||
|
|
223611d89e | ||
|
|
6f5141d5fb | ||
|
|
a6ac7d9c4e |
2
.github/actions/install/action.yml
vendored
2
.github/actions/install/action.yml
vendored
@@ -17,7 +17,7 @@ inputs:
|
||||
zig-v8:
|
||||
description: 'zig v8 version to install'
|
||||
required: false
|
||||
default: 'v0.1.24'
|
||||
default: 'v0.1.27'
|
||||
v8:
|
||||
description: 'v8 version to install'
|
||||
required: false
|
||||
|
||||
8
.github/workflows/build.yml
vendored
8
.github/workflows/build.yml
vendored
@@ -60,7 +60,7 @@ jobs:
|
||||
ARCH: aarch64
|
||||
OS: linux
|
||||
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ubuntu-22.04-arm
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
arch: ${{env.ARCH}}
|
||||
|
||||
- name: zig build
|
||||
run: zig build --release=safe -Doptimize=ReleaseSafe -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
|
||||
run: zig build --release=safe -Doptimize=ReleaseSafe -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
|
||||
|
||||
- name: Rename binary
|
||||
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
ARCH: aarch64
|
||||
OS: macos
|
||||
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
@@ -136,7 +136,7 @@ jobs:
|
||||
ARCH: x86_64
|
||||
OS: macos
|
||||
|
||||
runs-on: macos-13
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
|
||||
71
.github/workflows/e2e-test.yml
vendored
71
.github/workflows/e2e-test.yml
vendored
@@ -45,6 +45,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
# Don't run the CI with draft PR.
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -65,56 +68,6 @@ jobs:
|
||||
zig-out/bin/lightpanda
|
||||
retention-days: 1
|
||||
|
||||
puppeteer-perf:
|
||||
name: puppeteer-perf
|
||||
needs: zig-build-release
|
||||
|
||||
env:
|
||||
MAX_MEMORY: 30000
|
||||
MAX_AVG_DURATION: 24
|
||||
LIGHTPANDA_DISABLE_TELEMETRY: true
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: 'lightpanda-io/demo'
|
||||
fetch-depth: 0
|
||||
|
||||
- run: npm install
|
||||
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: lightpanda-build-release
|
||||
|
||||
- run: chmod a+x ./lightpanda
|
||||
|
||||
- name: run puppeteer
|
||||
run: |
|
||||
python3 -m http.server 1234 -d ./public & echo $! > PYTHON.pid
|
||||
./lightpanda serve & echo $! > LPD.pid
|
||||
RUNS=100 npm run bench-puppeteer-cdp > puppeteer.out || exit 1
|
||||
cat /proc/`cat LPD.pid`/status |grep VmHWM|grep -oP '\d+' > LPD.VmHWM
|
||||
kill `cat LPD.pid` `cat PYTHON.pid`
|
||||
|
||||
- name: puppeteer result
|
||||
run: cat puppeteer.out
|
||||
|
||||
- name: memory regression
|
||||
run: |
|
||||
export LPD_VmHWM=`cat LPD.VmHWM`
|
||||
echo "Peak resident set size: $LPD_VmHWM"
|
||||
test "$LPD_VmHWM" -le "$MAX_MEMORY"
|
||||
|
||||
- name: duration regression
|
||||
run: |
|
||||
export PUPPETEER_AVG_DURATION=`cat puppeteer.out|grep 'avg run'|sed 's/avg run duration (ms) //'`
|
||||
echo "puppeteer avg duration: $PUPPETEER_AVG_DURATION"
|
||||
test "$PUPPETEER_AVG_DURATION" -le "$MAX_AVG_DURATION"
|
||||
|
||||
demo-scripts:
|
||||
name: demo-scripts
|
||||
needs: zig-build-release
|
||||
@@ -147,8 +100,10 @@ jobs:
|
||||
name: cdp-and-hyperfine-bench
|
||||
needs: zig-build-release
|
||||
|
||||
# Don't execute on PR
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
MAX_MEMORY: 27000
|
||||
MAX_AVG_DURATION: 23
|
||||
LIGHTPANDA_DISABLE_TELEMETRY: true
|
||||
|
||||
# use a self host runner.
|
||||
runs-on: lpd-bench-hetzner
|
||||
@@ -185,6 +140,18 @@ jobs:
|
||||
- name: puppeteer result
|
||||
run: cat puppeteer.out
|
||||
|
||||
- name: memory regression
|
||||
run: |
|
||||
export LPD_VmHWM=`cat LPD.VmHWM`
|
||||
echo "Peak resident set size: $LPD_VmHWM"
|
||||
test "$LPD_VmHWM" -le "$MAX_MEMORY"
|
||||
|
||||
- name: duration regression
|
||||
run: |
|
||||
export PUPPETEER_AVG_DURATION=`cat puppeteer.out|grep 'avg run'|sed 's/avg run duration (ms) //'`
|
||||
echo "puppeteer avg duration: $PUPPETEER_AVG_DURATION"
|
||||
test "$PUPPETEER_AVG_DURATION" -le "$MAX_AVG_DURATION"
|
||||
|
||||
- name: json output
|
||||
run: |
|
||||
export AVG_DURATION=`cat puppeteer.out|grep 'avg run'|sed 's/avg run duration (ms) //'`
|
||||
|
||||
43
Dockerfile
43
Dockerfile
@@ -1,11 +1,11 @@
|
||||
FROM ubuntu:24.04
|
||||
FROM debian:stable
|
||||
|
||||
ARG MINISIG=0.12
|
||||
ARG ZIG=0.14.1
|
||||
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
|
||||
ARG ARCH=x86_64
|
||||
ARG V8=13.6.233.8
|
||||
ARG ZIG_V8=v0.1.24
|
||||
ARG ZIG_V8=v0.1.27
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
RUN apt-get update -yq && \
|
||||
apt-get install -yq xz-utils \
|
||||
@@ -20,30 +20,19 @@ RUN curl --fail -L -O https://github.com/jedisct1/minisign/releases/download/${M
|
||||
tar xvzf minisign-${MINISIG}-linux.tar.gz
|
||||
|
||||
# install zig
|
||||
RUN curl --fail -L -O https://ziglang.org/download/${ZIG}/zig-${ARCH}-linux-${ZIG}.tar.xz
|
||||
RUN curl --fail -L -O https://ziglang.org/download/${ZIG}/zig-${ARCH}-linux-${ZIG}.tar.xz.minisig
|
||||
|
||||
RUN minisign-linux/${ARCH}/minisign -Vm zig-${ARCH}-linux-${ZIG}.tar.xz -P ${ZIG_MINISIG}
|
||||
|
||||
# clean minisg
|
||||
RUN rm -fr minisign-0.11-linux.tar.gz minisign-linux
|
||||
|
||||
# install zig
|
||||
RUN tar xvf zig-${ARCH}-linux-${ZIG}.tar.xz && \
|
||||
RUN case $TARGETPLATFORM in \
|
||||
"linux/arm64") ARCH="aarch64" ;; \
|
||||
*) ARCH="x86_64" ;; \
|
||||
esac && \
|
||||
curl --fail -L -O https://ziglang.org/download/${ZIG}/zig-${ARCH}-linux-${ZIG}.tar.xz && \
|
||||
curl --fail -L -O https://ziglang.org/download/${ZIG}/zig-${ARCH}-linux-${ZIG}.tar.xz.minisig && \
|
||||
minisign-linux/${ARCH}/minisign -Vm zig-${ARCH}-linux-${ZIG}.tar.xz -P ${ZIG_MINISIG} && \
|
||||
tar xvf zig-${ARCH}-linux-${ZIG}.tar.xz && \
|
||||
mv zig-${ARCH}-linux-${ZIG} /usr/local/lib && \
|
||||
ln -s /usr/local/lib/zig-${ARCH}-linux-${ZIG}/zig /usr/local/bin/zig
|
||||
|
||||
# clean up zig install
|
||||
RUN rm -fr zig-${ARCH}-linux-${ZIG}.tar.xz zig-${ARCH}-linux-${ZIG}.tar.xz.minisig
|
||||
|
||||
# force use of http instead of ssh with github
|
||||
RUN cat <<EOF > /root/.gitconfig
|
||||
[url "https://github.com/"]
|
||||
insteadOf="git@github.com:"
|
||||
EOF
|
||||
|
||||
# clone lightpanda
|
||||
RUN git clone git@github.com:lightpanda-io/browser.git
|
||||
RUN git clone https://github.com/lightpanda-io/browser.git
|
||||
|
||||
WORKDIR /browser
|
||||
|
||||
@@ -56,14 +45,18 @@ RUN make install-libiconv && \
|
||||
make install-mimalloc
|
||||
|
||||
# download and install v8
|
||||
RUN curl --fail -L -o libc_v8.a https://github.com/lightpanda-io/zig-v8-fork/releases/download/${ZIG_V8}/libc_v8_${V8}_linux_${ARCH}.a && \
|
||||
RUN case $TARGETPLATFORM in \
|
||||
"linux/arm64") ARCH="aarch64" ;; \
|
||||
*) ARCH="x86_64" ;; \
|
||||
esac && \
|
||||
curl --fail -L -o libc_v8.a https://github.com/lightpanda-io/zig-v8-fork/releases/download/${ZIG_V8}/libc_v8_${V8}_linux_${ARCH}.a && \
|
||||
mkdir -p v8/out/linux/release/obj/zig/ && \
|
||||
mv libc_v8.a v8/out/linux/release/obj/zig/libc_v8.a
|
||||
|
||||
# build release
|
||||
RUN make build
|
||||
|
||||
FROM ubuntu:24.04
|
||||
FROM debian:stable-slim
|
||||
|
||||
# copy ca certificates
|
||||
COPY --from=0 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
34
README.md
34
README.md
@@ -18,7 +18,7 @@ Lightpanda is the open-source browser made for headless usage:
|
||||
|
||||
- Javascript execution
|
||||
- Support of Web APIs (partial, WIP)
|
||||
- Compatible with Playwright[^1], Puppeteer through CDP (WIP)
|
||||
- Compatible with Playwright[^1], Puppeteer, chromedp through CDP
|
||||
|
||||
Fast web automation for AI agents, LLM training, scraping and testing:
|
||||
|
||||
@@ -41,7 +41,8 @@ Due to the nature of Playwright, a script that works with the current version of
|
||||
|
||||
## Quick start
|
||||
|
||||
### Install from the nightly builds
|
||||
### Install
|
||||
**Install from the nightly builds**
|
||||
|
||||
You can download the last binary from the [nightly
|
||||
builds](https://github.com/lightpanda-io/browser/releases/tag/nightly) for
|
||||
@@ -64,6 +65,17 @@ chmod a+x ./lightpanda
|
||||
The Lightpanda browser is compatible to run on windows inside WSL. Follow the Linux instruction for installation from a WSL terminal.
|
||||
It is recommended to install clients like Puppeteer on the Windows host.
|
||||
|
||||
**Install from Docker**
|
||||
|
||||
Lightpanda provides [official Docker
|
||||
images](https://hub.docker.com/r/lightpanda/browser) for both Linux amd64 and
|
||||
arm64 architectures.
|
||||
The following command fetches the Docker image and starts a new container exposing Lightpanda's CDP server on port `9222`.
|
||||
The `--privileged` option is required because the browser requires `io_uring` syscalls which are blocked by default by Docker.
|
||||
```console
|
||||
docker run -d --name lightpanda -p 9222:9222 --privileged lightpanda/browser:nightly
|
||||
```
|
||||
|
||||
### Dump a URL
|
||||
|
||||
```console
|
||||
@@ -124,21 +136,27 @@ By default, Lightpanda collects and sends usage telemetry. This can be disabled
|
||||
|
||||
## Status
|
||||
|
||||
Lightpanda is still a work in progress and is currently at a Beta stage.
|
||||
|
||||
:warning: You should expect most websites to fail or crash.
|
||||
Lightpanda is in Beta and currently a work in progress. Stability and coverage are improving and many websites now work.
|
||||
You may still encounter errors or crashes. Please open an issue with specifics if so.
|
||||
|
||||
Here are the key features we have implemented:
|
||||
|
||||
- [x] HTTP loader
|
||||
- [x] HTTP loader
|
||||
- [x] HTML parser and DOM tree (based on Netsurf libs)
|
||||
- [x] Javascript support (v8)
|
||||
- [x] Basic DOM APIs
|
||||
- [x] DOM APIs
|
||||
- [x] Ajax
|
||||
- [x] XHR API
|
||||
- [x] Fetch API
|
||||
- [x] Fetch API (polyfill)
|
||||
- [x] DOM dump
|
||||
- [x] Basic CDP/websockets server
|
||||
- [x] CDP/websockets server
|
||||
- [x] Click
|
||||
- [x] Input form
|
||||
- [x] Cookies
|
||||
- [x] Custom HTTP headers
|
||||
- [ ] Proxy support
|
||||
- [ ] Network interception
|
||||
|
||||
NOTE: There are hundreds of Web APIs. Developing a browser (even just for headless mode) is a huge task. Coverage will increase over time.
|
||||
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
.fingerprint = 0xda130f3af836cea0,
|
||||
.dependencies = .{
|
||||
.tls = .{
|
||||
.url = "https://github.com/ianic/tls.zig/archive/b29a8b45fc59fc2d202769c4f54509bb9e17d0a2.tar.gz",
|
||||
.hash = "tls-0.1.0-ER2e0uAxBQDm_TmSDdbiiyvAZoh4ejlDD4hW8Fl813xE",
|
||||
.url = "https://github.com/ianic/tls.zig/archive/55845f755d9e2e821458ea55693f85c737cd0c7a.tar.gz",
|
||||
.hash = "tls-0.1.0-ER2e0m43BQAshi8ixj1qf3w2u2lqKtXtkrxUJ4AGZDcl",
|
||||
},
|
||||
.tigerbeetle_io = .{
|
||||
.url = "https://github.com/lightpanda-io/tigerbeetle-io/archive/61d9652f1a957b7f4db723ea6aa0ce9635e840ce.tar.gz",
|
||||
.hash = "tigerbeetle_io-0.0.0-ViLgxpyRBAB5BMfIcj3KMXfbJzwARs9uSl8aRy2OXULd",
|
||||
},
|
||||
.v8 = .{
|
||||
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/1d25fcf3ced688adca3c7a95a138771e4ebba692.tar.gz",
|
||||
.hash = "v8-0.0.0-xddH61eyAwDICIkLAkfQcxsX4TMCKY80QiSUgNBQqx-u",
|
||||
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/dd087771378ea854452bcb010309fa9ffe5a9cac.tar.gz",
|
||||
.hash = "v8-0.0.0-xddH66e8AwBL3O_A8yWQYQIyfMbKHFNVQr_NqM6YjU11",
|
||||
},
|
||||
//.v8 = .{ .path = "../zig-v8-fork" },
|
||||
//.tigerbeetle_io = .{ .path = "../tigerbeetle-io" },
|
||||
|
||||
15
src/app.zig
15
src/app.zig
@@ -3,7 +3,9 @@ const Allocator = std.mem.Allocator;
|
||||
|
||||
const log = @import("log.zig");
|
||||
const Loop = @import("runtime/loop.zig").Loop;
|
||||
const HttpClient = @import("http/client.zig").Client;
|
||||
const http = @import("http/client.zig");
|
||||
const Platform = @import("runtime/js.zig").Platform;
|
||||
|
||||
const Telemetry = @import("telemetry/telemetry.zig").Telemetry;
|
||||
const Notification = @import("notification.zig").Notification;
|
||||
|
||||
@@ -12,9 +14,10 @@ const Notification = @import("notification.zig").Notification;
|
||||
pub const App = struct {
|
||||
loop: *Loop,
|
||||
config: Config,
|
||||
platform: ?*const Platform,
|
||||
allocator: Allocator,
|
||||
telemetry: Telemetry,
|
||||
http_client: HttpClient,
|
||||
http_client: http.Client,
|
||||
app_dir_path: ?[]const u8,
|
||||
notification: *Notification,
|
||||
|
||||
@@ -27,8 +30,11 @@ pub const App = struct {
|
||||
|
||||
pub const Config = struct {
|
||||
run_mode: RunMode,
|
||||
platform: ?*const Platform = null,
|
||||
tls_verify_host: bool = true,
|
||||
http_proxy: ?std.Uri = null,
|
||||
proxy_type: ?http.ProxyType = null,
|
||||
proxy_auth: ?http.ProxyAuth = null,
|
||||
};
|
||||
|
||||
pub fn init(allocator: Allocator, config: Config) !*App {
|
||||
@@ -50,11 +56,14 @@ pub const App = struct {
|
||||
.loop = loop,
|
||||
.allocator = allocator,
|
||||
.telemetry = undefined,
|
||||
.platform = config.platform,
|
||||
.app_dir_path = app_dir_path,
|
||||
.notification = notification,
|
||||
.http_client = try HttpClient.init(allocator, .{
|
||||
.http_client = try http.Client.init(allocator, loop, .{
|
||||
.max_concurrent = 3,
|
||||
.http_proxy = config.http_proxy,
|
||||
.proxy_type = config.proxy_type,
|
||||
.proxy_auth = config.proxy_auth,
|
||||
.tls_verify_host = config.tls_verify_host,
|
||||
}),
|
||||
.config = config,
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
const Env = @import("env.zig").Env;
|
||||
const parser = @import("netsurf.zig");
|
||||
const DataSet = @import("html/DataSet.zig");
|
||||
const CSSStyleDeclaration = @import("cssom/css_style_declaration.zig").CSSStyleDeclaration;
|
||||
|
||||
// for HTMLScript (but probably needs to be added to more)
|
||||
@@ -36,6 +37,7 @@ onerror: ?Env.Function = null,
|
||||
|
||||
// for HTMLElement
|
||||
style: CSSStyleDeclaration = .empty,
|
||||
dataset: ?DataSet = null,
|
||||
|
||||
// for html/document
|
||||
ready_state: ReadyState = .loading,
|
||||
@@ -58,6 +60,8 @@ active_element: ?*parser.Element = null,
|
||||
// default (by returning selectedIndex == 0).
|
||||
explicit_index_set: bool = false,
|
||||
|
||||
template_content: ?*parser.DocumentFragment = null,
|
||||
|
||||
const ReadyState = enum {
|
||||
loading,
|
||||
interactive,
|
||||
|
||||
@@ -27,6 +27,8 @@ const App = @import("../app.zig").App;
|
||||
const Session = @import("session.zig").Session;
|
||||
const Notification = @import("../notification.zig").Notification;
|
||||
|
||||
const log = @import("../log.zig");
|
||||
|
||||
const http = @import("../http/client.zig");
|
||||
|
||||
// Browser is an instance of the browser.
|
||||
@@ -47,7 +49,7 @@ pub const Browser = struct {
|
||||
pub fn init(app: *App) !Browser {
|
||||
const allocator = app.allocator;
|
||||
|
||||
const env = try Env.init(allocator, .{});
|
||||
const env = try Env.init(allocator, app.platform, .{});
|
||||
errdefer env.deinit();
|
||||
|
||||
const notification = try Notification.init(allocator, app.notification);
|
||||
@@ -95,7 +97,14 @@ pub const Browser = struct {
|
||||
}
|
||||
|
||||
pub fn runMicrotasks(self: *const Browser) void {
|
||||
return self.env.runMicrotasks();
|
||||
self.env.runMicrotasks();
|
||||
}
|
||||
|
||||
pub fn runMessageLoop(self: *const Browser) void {
|
||||
while (self.env.pumpMessageLoop()) {
|
||||
log.debug(.browser, "pumpMessageLoop", .{});
|
||||
}
|
||||
self.env.runIdleTasks();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -30,39 +30,39 @@ pub const Console = struct {
|
||||
timers: std.StringHashMapUnmanaged(u32) = .{},
|
||||
counts: std.StringHashMapUnmanaged(u32) = .{},
|
||||
|
||||
pub fn static_lp(values: []JsObject, page: *Page) !void {
|
||||
pub fn _lp(values: []JsObject, page: *Page) !void {
|
||||
if (values.len == 0) {
|
||||
return;
|
||||
}
|
||||
log.fatal(.console, "lightpanda", .{ .args = try serializeValues(values, page) });
|
||||
}
|
||||
|
||||
pub fn static_log(values: []JsObject, page: *Page) !void {
|
||||
pub fn _log(values: []JsObject, page: *Page) !void {
|
||||
if (values.len == 0) {
|
||||
return;
|
||||
}
|
||||
log.info(.console, "info", .{ .args = try serializeValues(values, page) });
|
||||
}
|
||||
|
||||
pub fn static_info(values: []JsObject, page: *Page) !void {
|
||||
return static_log(values, page);
|
||||
pub fn _info(values: []JsObject, page: *Page) !void {
|
||||
return _log(values, page);
|
||||
}
|
||||
|
||||
pub fn static_debug(values: []JsObject, page: *Page) !void {
|
||||
pub fn _debug(values: []JsObject, page: *Page) !void {
|
||||
if (values.len == 0) {
|
||||
return;
|
||||
}
|
||||
log.debug(.console, "debug", .{ .args = try serializeValues(values, page) });
|
||||
}
|
||||
|
||||
pub fn static_warn(values: []JsObject, page: *Page) !void {
|
||||
pub fn _warn(values: []JsObject, page: *Page) !void {
|
||||
if (values.len == 0) {
|
||||
return;
|
||||
}
|
||||
log.warn(.console, "warn", .{ .args = try serializeValues(values, page) });
|
||||
}
|
||||
|
||||
pub fn static_error(values: []JsObject, page: *Page) !void {
|
||||
pub fn _error(values: []JsObject, page: *Page) !void {
|
||||
if (values.len == 0) {
|
||||
return;
|
||||
}
|
||||
@@ -73,7 +73,7 @@ pub const Console = struct {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn static_clear() void {}
|
||||
pub fn _clear() void {}
|
||||
|
||||
pub fn _count(self: *Console, label_: ?[]const u8, page: *Page) !void {
|
||||
const label = label_ orelse "default";
|
||||
@@ -134,7 +134,7 @@ pub const Console = struct {
|
||||
log.warn(.console, "timer stop", .{ .label = label, .elapsed = elapsed - kv.value });
|
||||
}
|
||||
|
||||
pub fn static_assert(assertion: JsObject, values: []JsObject, page: *Page) !void {
|
||||
pub fn _assert(assertion: JsObject, values: []JsObject, page: *Page) !void {
|
||||
if (assertion.isTruthy()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -17,16 +17,21 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const Env = @import("../env.zig").Env;
|
||||
const uuidv4 = @import("../../id.zig").uuidv4;
|
||||
|
||||
// https://w3c.github.io/webcrypto/#crypto-interface
|
||||
pub const Crypto = struct {
|
||||
pub fn _getRandomValues(_: *const Crypto, into: RandomValues) !void {
|
||||
_not_empty: bool = true,
|
||||
|
||||
pub fn _getRandomValues(_: *const Crypto, js_obj: Env.JsObject) !Env.JsObject {
|
||||
var into = try js_obj.toZig(Crypto, "getRandomValues", RandomValues);
|
||||
const buf = into.asBuffer();
|
||||
if (buf.len > 65_536) {
|
||||
return error.QuotaExceededError;
|
||||
}
|
||||
std.crypto.random.bytes(buf);
|
||||
return js_obj;
|
||||
}
|
||||
|
||||
pub fn _randomUUID(_: *const Crypto) [36]u8 {
|
||||
@@ -47,16 +52,16 @@ const RandomValues = union(enum) {
|
||||
uint64: []u64,
|
||||
|
||||
fn asBuffer(self: RandomValues) []u8 {
|
||||
switch (self) {
|
||||
.int8 => |b| return (@as([]u8, @ptrCast(b)))[0..b.len],
|
||||
.uint8 => |b| return (@as([]u8, @ptrCast(b)))[0..b.len],
|
||||
.int16 => |b| return (@as([]u8, @ptrCast(b)))[0 .. b.len * 2],
|
||||
.uint16 => |b| return (@as([]u8, @ptrCast(b)))[0 .. b.len * 2],
|
||||
.int32 => |b| return (@as([]u8, @ptrCast(b)))[0 .. b.len * 4],
|
||||
.uint32 => |b| return (@as([]u8, @ptrCast(b)))[0 .. b.len * 4],
|
||||
.int64 => |b| return (@as([]u8, @ptrCast(b)))[0 .. b.len * 8],
|
||||
.uint64 => |b| return (@as([]u8, @ptrCast(b)))[0 .. b.len * 8],
|
||||
}
|
||||
return switch (self) {
|
||||
.int8 => |b| (@as([]u8, @ptrCast(b)))[0..b.len],
|
||||
.uint8 => |b| (@as([]u8, @ptrCast(b)))[0..b.len],
|
||||
.int16 => |b| (@as([]u8, @ptrCast(b)))[0 .. b.len * 2],
|
||||
.uint16 => |b| (@as([]u8, @ptrCast(b)))[0 .. b.len * 2],
|
||||
.int32 => |b| (@as([]u8, @ptrCast(b)))[0 .. b.len * 4],
|
||||
.uint32 => |b| (@as([]u8, @ptrCast(b)))[0 .. b.len * 4],
|
||||
.int64 => |b| (@as([]u8, @ptrCast(b)))[0 .. b.len * 8],
|
||||
.uint64 => |b| (@as([]u8, @ptrCast(b)))[0 .. b.len * 8],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -69,14 +74,24 @@ test "Browser.Crypto" {
|
||||
.{ "const a = crypto.randomUUID();", "undefined" },
|
||||
.{ "const b = crypto.randomUUID();", "undefined" },
|
||||
.{ "a.length;", "36" },
|
||||
.{ "a.length;", "36" },
|
||||
.{ "b.length;", "36" },
|
||||
.{ "a == b;", "false" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "try { crypto.getRandomValues(new BigUint64Array(8193)) } catch(e) { e.message == 'QuotaExceededError' }", "true" },
|
||||
.{ "let r1 = new Int32Array(5)", "undefined" },
|
||||
.{ "crypto.getRandomValues(r1)", "undefined" },
|
||||
.{ "let r2 = crypto.getRandomValues(r1)", "undefined" },
|
||||
.{ "new Set(r1).size", "5" },
|
||||
.{ "new Set(r2).size", "5" },
|
||||
.{ "r1.every((v, i) => v === r2[i])", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "var r3 = new Uint8Array(16)", null },
|
||||
.{ "let r4 = crypto.getRandomValues(r3)", "undefined" },
|
||||
.{ "r4[6] = 10", null },
|
||||
.{ "r4[6]", "10" },
|
||||
.{ "r3[6]", "10" },
|
||||
}, .{});
|
||||
}
|
||||
|
||||
@@ -23,6 +23,20 @@ const std = @import("std");
|
||||
const Selector = @import("selector.zig").Selector;
|
||||
const parser = @import("parser.zig");
|
||||
|
||||
pub const Interfaces = .{
|
||||
Css,
|
||||
};
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/CSS
|
||||
pub const Css = struct {
|
||||
_not_empty: bool = true,
|
||||
|
||||
pub fn _supports(_: *Css, _: []const u8, _: ?[]const u8) bool {
|
||||
// TODO: Actually respond with which CSS features we support.
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// parse parse a selector string and returns the parsed result or an error.
|
||||
pub fn parse(alloc: std.mem.Allocator, s: []const u8, opts: parser.ParseOptions) parser.ParseError!Selector {
|
||||
var p = parser.Parser{ .s = s, .i = 0, .opts = opts };
|
||||
@@ -174,3 +188,14 @@ test "parse" {
|
||||
defer s.deinit(alloc);
|
||||
}
|
||||
}
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.HTML.CSS" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "CSS.supports('display: flex')", "true" },
|
||||
.{ "CSS.supports('text-decoration-style', 'blink')", "true" },
|
||||
}, .{});
|
||||
}
|
||||
|
||||
42
src/browser/cssom/css_rule.zig
Normal file
42
src/browser/cssom/css_rule.zig
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (C) 2023-2024 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 CSSStyleSheet = @import("css_stylesheet.zig").CSSStyleSheet;
|
||||
|
||||
pub const Interfaces = .{
|
||||
CSSRule,
|
||||
CSSImportRule,
|
||||
};
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/CSSRule
|
||||
pub const CSSRule = struct {
|
||||
css_text: []const u8,
|
||||
parent_rule: ?*CSSRule = null,
|
||||
parent_stylesheet: ?*CSSStyleSheet = null,
|
||||
};
|
||||
|
||||
pub const CSSImportRule = struct {
|
||||
pub const prototype = *CSSRule;
|
||||
href: []const u8,
|
||||
layer_name: ?[]const u8,
|
||||
media: void,
|
||||
style_sheet: CSSStyleSheet,
|
||||
supports_text: ?[]const u8,
|
||||
};
|
||||
60
src/browser/cssom/css_rule_list.zig
Normal file
60
src/browser/cssom/css_rule_list.zig
Normal file
@@ -0,0 +1,60 @@
|
||||
// Copyright (C) 2023-2024 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 StyleSheet = @import("stylesheet.zig").StyleSheet;
|
||||
const CSSRule = @import("css_rule.zig").CSSRule;
|
||||
const CSSImportRule = @import("css_rule.zig").CSSImportRule;
|
||||
|
||||
pub const CSSRuleList = struct {
|
||||
list: std.ArrayListUnmanaged([]const u8),
|
||||
|
||||
pub fn constructor() CSSRuleList {
|
||||
return .{ .list = .empty };
|
||||
}
|
||||
|
||||
pub fn _item(self: *CSSRuleList, _index: u32) ?CSSRule {
|
||||
const index: usize = @intCast(_index);
|
||||
|
||||
if (index > self.list.items.len) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// todo: for now, just return null.
|
||||
// this depends on properly parsing CSSRule
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn get_length(self: *CSSRuleList) u32 {
|
||||
return @intCast(self.list.items.len);
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.CSS.CSSRuleList" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let list = new CSSRuleList()", "undefined" },
|
||||
.{ "list instanceof CSSRuleList", "true" },
|
||||
.{ "list.length", "0" },
|
||||
.{ "list.item(0)", "null" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -20,15 +20,9 @@ const std = @import("std");
|
||||
|
||||
const CSSParser = @import("./css_parser.zig").CSSParser;
|
||||
const CSSValueAnalyzer = @import("./css_value_analyzer.zig").CSSValueAnalyzer;
|
||||
const CSSRule = @import("css_rule.zig").CSSRule;
|
||||
const Page = @import("../page.zig").Page;
|
||||
|
||||
pub const Interfaces = .{
|
||||
CSSStyleDeclaration,
|
||||
CSSRule,
|
||||
};
|
||||
|
||||
const CSSRule = struct {};
|
||||
|
||||
pub const CSSStyleDeclaration = struct {
|
||||
store: std.StringHashMapUnmanaged(Property),
|
||||
order: std.ArrayListUnmanaged([]const u8),
|
||||
|
||||
91
src/browser/cssom/css_stylesheet.zig
Normal file
91
src/browser/cssom/css_stylesheet.zig
Normal file
@@ -0,0 +1,91 @@
|
||||
// Copyright (C) 2023-2024 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 Page = @import("../page.zig").Page;
|
||||
const StyleSheet = @import("stylesheet.zig").StyleSheet;
|
||||
|
||||
const CSSRuleList = @import("css_rule_list.zig").CSSRuleList;
|
||||
const CSSImportRule = @import("css_rule.zig").CSSImportRule;
|
||||
|
||||
pub const CSSStyleSheet = struct {
|
||||
pub const prototype = *StyleSheet;
|
||||
|
||||
proto: StyleSheet,
|
||||
css_rules: CSSRuleList,
|
||||
owner_rule: ?*CSSImportRule,
|
||||
|
||||
const CSSStyleSheetOpts = struct {
|
||||
base_url: ?[]const u8 = null,
|
||||
// TODO: Suupport media
|
||||
disabled: bool = false,
|
||||
};
|
||||
|
||||
pub fn constructor(_opts: ?CSSStyleSheetOpts) !CSSStyleSheet {
|
||||
const opts = _opts orelse CSSStyleSheetOpts{};
|
||||
return .{
|
||||
.proto = StyleSheet{ .disabled = opts.disabled },
|
||||
.css_rules = .constructor(),
|
||||
.owner_rule = null,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn get_ownerRule(_: *CSSStyleSheet) ?*CSSImportRule {
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn get_cssRules(self: *CSSStyleSheet) *CSSRuleList {
|
||||
return &self.css_rules;
|
||||
}
|
||||
|
||||
pub fn _insertRule(self: *CSSStyleSheet, rule: []const u8, _index: ?usize, page: *Page) !usize {
|
||||
const index = _index orelse 0;
|
||||
if (index > self.css_rules.list.items.len) {
|
||||
return error.IndexSize;
|
||||
}
|
||||
|
||||
const arena = page.arena;
|
||||
try self.css_rules.list.insert(arena, index, try arena.dupe(u8, rule));
|
||||
return index;
|
||||
}
|
||||
|
||||
pub fn _deleteRule(self: *CSSStyleSheet, index: usize) !void {
|
||||
if (index > self.css_rules.list.items.len) {
|
||||
return error.IndexSize;
|
||||
}
|
||||
|
||||
_ = self.css_rules.list.orderedRemove(index);
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.CSS.StyleSheet" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let css = new CSSStyleSheet()", "undefined" },
|
||||
.{ "css instanceof CSSStyleSheet", "true" },
|
||||
.{ "css.cssRules.length", "0" },
|
||||
.{ "css.ownerRule", "null" },
|
||||
.{ "let index1 = css.insertRule('body { color: red; }', 0)", "undefined" },
|
||||
.{ "index1", "0" },
|
||||
.{ "css.cssRules.length", "1" },
|
||||
}, .{});
|
||||
}
|
||||
30
src/browser/cssom/cssom.zig
Normal file
30
src/browser/cssom/cssom.zig
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (C) 2023-2024 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/>.
|
||||
|
||||
pub const Stylesheet = @import("stylesheet.zig").StyleSheet;
|
||||
pub const CSSStylesheet = @import("css_stylesheet.zig").CSSStyleSheet;
|
||||
pub const CSSStyleDeclaration = @import("css_style_declaration.zig").CSSStyleDeclaration;
|
||||
pub const CSSRuleList = @import("css_rule_list.zig").CSSRuleList;
|
||||
|
||||
pub const Interfaces = .{
|
||||
Stylesheet,
|
||||
CSSStylesheet,
|
||||
CSSStyleDeclaration,
|
||||
CSSRuleList,
|
||||
@import("css_rule.zig").Interfaces,
|
||||
};
|
||||
55
src/browser/cssom/stylesheet.zig
Normal file
55
src/browser/cssom/stylesheet.zig
Normal file
@@ -0,0 +1,55 @@
|
||||
// Copyright (C) 2023-2024 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 parser = @import("../netsurf.zig");
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/StyleSheet#specifications
|
||||
pub const StyleSheet = struct {
|
||||
disabled: bool = false,
|
||||
href: []const u8 = "",
|
||||
owner_node: ?*parser.Node = null,
|
||||
parent_stylesheet: ?*StyleSheet = null,
|
||||
title: []const u8 = "",
|
||||
type: []const u8 = "text/css",
|
||||
|
||||
pub fn get_disabled(self: *const StyleSheet) bool {
|
||||
return self.disabled;
|
||||
}
|
||||
|
||||
pub fn get_href(self: *const StyleSheet) []const u8 {
|
||||
return self.href;
|
||||
}
|
||||
|
||||
// TODO: media
|
||||
|
||||
pub fn get_ownerNode(self: *const StyleSheet) ?*parser.Node {
|
||||
return self.owner_node;
|
||||
}
|
||||
|
||||
pub fn get_parentStyleSheet(self: *const StyleSheet) ?*StyleSheet {
|
||||
return self.parent_stylesheet;
|
||||
}
|
||||
|
||||
pub fn get_title(self: *const StyleSheet) []const u8 {
|
||||
return self.title;
|
||||
}
|
||||
|
||||
pub fn get_type(self: *const StyleSheet) []const u8 {
|
||||
return self.type;
|
||||
}
|
||||
};
|
||||
@@ -15,7 +15,6 @@
|
||||
//
|
||||
// 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 parser = @import("../netsurf.zig");
|
||||
|
||||
const Node = @import("node.zig").Node;
|
||||
@@ -47,7 +46,14 @@ pub const Attr = struct {
|
||||
}
|
||||
|
||||
pub fn set_value(self: *parser.Attribute, v: []const u8) !?[]const u8 {
|
||||
try parser.attributeSetValue(self, v);
|
||||
if (try parser.attributeGetOwnerElement(self)) |el| {
|
||||
// if possible, go through the element, as that triggers a
|
||||
// DOMAttrModified event (which MutationObserver cares about)
|
||||
const name = try parser.attributeGetName(self);
|
||||
try parser.elementSetAttribute(el, name, v);
|
||||
} else {
|
||||
try parser.attributeSetValue(self, v);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ const css = @import("css.zig");
|
||||
const Element = @import("element.zig").Element;
|
||||
const ElementUnion = @import("element.zig").Union;
|
||||
const TreeWalker = @import("tree_walker.zig").TreeWalker;
|
||||
const Range = @import("range.zig").Range;
|
||||
|
||||
const Env = @import("../env.zig").Env;
|
||||
|
||||
@@ -127,12 +128,22 @@ pub const Document = struct {
|
||||
};
|
||||
|
||||
pub fn _createElement(self: *parser.Document, tag_name: []const u8, page: *Page) !CreateElementResult {
|
||||
if (try page.window.custom_elements.newInstance(tag_name)) |ce| {
|
||||
return .{ .custom = ce };
|
||||
}
|
||||
const custom_element = page.window.custom_elements._get(tag_name) orelse {
|
||||
const e = try parser.documentCreateElement(self, tag_name);
|
||||
return .{ .element = try Element.toInterface(e) };
|
||||
};
|
||||
|
||||
const e = try parser.documentCreateElement(self, tag_name);
|
||||
return .{ .element = try Element.toInterface(e) };
|
||||
var result: Env.Function.Result = undefined;
|
||||
const js_obj = custom_element.newInstance(&result) catch |err| {
|
||||
log.fatal(.user_script, "newInstance error", .{
|
||||
.err = result.exception,
|
||||
.stack = result.stack,
|
||||
.tag_name = tag_name,
|
||||
.source = "createElement",
|
||||
});
|
||||
return err;
|
||||
};
|
||||
return .{ .custom = js_obj };
|
||||
}
|
||||
|
||||
pub fn _createElementNS(self: *parser.Document, ns: []const u8, tag_name: []const u8) !ElementUnion {
|
||||
@@ -226,7 +237,7 @@ pub const Document = struct {
|
||||
pub fn _querySelector(self: *parser.Document, selector: []const u8, page: *Page) !?ElementUnion {
|
||||
if (selector.len == 0) return null;
|
||||
|
||||
const n = try css.querySelector(page.arena, parser.documentToNode(self), selector);
|
||||
const n = try css.querySelector(page.call_arena, parser.documentToNode(self), selector);
|
||||
|
||||
if (n == null) return null;
|
||||
|
||||
@@ -280,6 +291,10 @@ pub const Document = struct {
|
||||
const state = try page.getOrCreateNodeState(@alignCast(@ptrCast(self)));
|
||||
state.active_element = @ptrCast(e);
|
||||
}
|
||||
|
||||
pub fn _createRange(_: *parser.Document, page: *Page) Range {
|
||||
return Range.constructor(page);
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
|
||||
@@ -16,8 +16,12 @@
|
||||
// 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 css = @import("css.zig");
|
||||
const parser = @import("../netsurf.zig");
|
||||
const Page = @import("../page.zig").Page;
|
||||
const NodeList = @import("nodelist.zig").NodeList;
|
||||
const Element = @import("element.zig").Element;
|
||||
const ElementUnion = @import("element.zig").Union;
|
||||
|
||||
const Node = @import("node.zig").Node;
|
||||
|
||||
@@ -53,6 +57,20 @@ pub const DocumentFragment = struct {
|
||||
pub fn _replaceChildren(self: *parser.DocumentFragment, nodes: []const Node.NodeOrText) !void {
|
||||
return Node.replaceChildren(parser.documentFragmentToNode(self), nodes);
|
||||
}
|
||||
|
||||
pub fn _querySelector(self: *parser.DocumentFragment, selector: []const u8, page: *Page) !?ElementUnion {
|
||||
if (selector.len == 0) return null;
|
||||
|
||||
const n = try css.querySelector(page.call_arena, parser.documentFragmentToNode(self), selector);
|
||||
|
||||
if (n == null) return null;
|
||||
|
||||
return try Element.toInterface(parser.nodeToElement(n.?));
|
||||
}
|
||||
|
||||
pub fn _querySelectorAll(self: *parser.DocumentFragment, selector: []const u8, page: *Page) !NodeList {
|
||||
return css.querySelectorAll(page.arena, parser.documentFragmentToNode(self), selector);
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
@@ -71,4 +89,23 @@ test "Browser.DOM.DocumentFragment" {
|
||||
.{ "dc1.isEqualNode(dc1)", "true" },
|
||||
.{ "dc1.isEqualNode(dc2)", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let f = document.createDocumentFragment()", null },
|
||||
.{ "let d = document.createElement('div');", null },
|
||||
.{ "d.id = 'x';", null },
|
||||
.{ "document.getElementById('x') == null;", "true" },
|
||||
|
||||
.{ "f.append(d);", null },
|
||||
.{ "document.getElementById('x') == null;", "true" },
|
||||
|
||||
.{ "document.getElementsByTagName('body')[0].append(f.cloneNode(true));", null },
|
||||
.{ "document.getElementById('x') != null;", "true" },
|
||||
|
||||
.{ "document.querySelector('.hello')", "null" },
|
||||
.{ "document.querySelectorAll('.hello').length", "0" },
|
||||
|
||||
.{ "document.querySelector('#x').id", "x" },
|
||||
.{ "document.querySelectorAll('#x')[0].id", "x" },
|
||||
}, .{});
|
||||
}
|
||||
|
||||
@@ -23,11 +23,13 @@ const NamedNodeMap = @import("namednodemap.zig").NamedNodeMap;
|
||||
const DOMTokenList = @import("token_list.zig");
|
||||
const NodeList = @import("nodelist.zig");
|
||||
const Node = @import("node.zig");
|
||||
const ResizeObserver = @import("resize_observer.zig");
|
||||
const MutationObserver = @import("mutation_observer.zig");
|
||||
const IntersectionObserver = @import("intersection_observer.zig");
|
||||
const DOMParser = @import("dom_parser.zig").DOMParser;
|
||||
const TreeWalker = @import("tree_walker.zig").TreeWalker;
|
||||
const NodeFilter = @import("node_filter.zig").NodeFilter;
|
||||
const PerformanceObserver = @import("performance_observer.zig").PerformanceObserver;
|
||||
|
||||
pub const Interfaces = .{
|
||||
DOMException,
|
||||
@@ -39,9 +41,13 @@ pub const Interfaces = .{
|
||||
NodeList.Interfaces,
|
||||
Node.Node,
|
||||
Node.Interfaces,
|
||||
ResizeObserver.Interfaces,
|
||||
MutationObserver.Interfaces,
|
||||
IntersectionObserver.Interfaces,
|
||||
DOMParser,
|
||||
TreeWalker,
|
||||
NodeFilter,
|
||||
@import("performance.zig").Interfaces,
|
||||
PerformanceObserver,
|
||||
@import("range.zig").Interfaces,
|
||||
};
|
||||
|
||||
@@ -43,6 +43,10 @@ pub const Element = struct {
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
bottom: f64,
|
||||
right: f64,
|
||||
top: f64,
|
||||
left: f64,
|
||||
};
|
||||
|
||||
pub fn toInterface(e: *parser.Element) !Union {
|
||||
@@ -331,7 +335,7 @@ pub const Element = struct {
|
||||
pub fn _querySelector(self: *parser.Element, selector: []const u8, page: *Page) !?Union {
|
||||
if (selector.len == 0) return null;
|
||||
|
||||
const n = try css.querySelector(page.arena, parser.elementToNode(self), selector);
|
||||
const n = try css.querySelector(page.call_arena, parser.elementToNode(self), selector);
|
||||
|
||||
if (n == null) return null;
|
||||
|
||||
@@ -369,7 +373,16 @@ pub const Element = struct {
|
||||
pub fn _getBoundingClientRect(self: *parser.Element, page: *Page) !DOMRect {
|
||||
// Since we are lazy rendering we need to do this check. We could store the renderer in a viewport such that it could cache these, but it would require tracking changes.
|
||||
if (!try page.isNodeAttached(parser.elementToNode(self))) {
|
||||
return DOMRect{ .x = 0, .y = 0, .width = 0, .height = 0 };
|
||||
return DOMRect{
|
||||
.x = 0,
|
||||
.y = 0,
|
||||
.width = 0,
|
||||
.height = 0,
|
||||
.bottom = 0,
|
||||
.right = 0,
|
||||
.top = 0,
|
||||
.left = 0,
|
||||
};
|
||||
}
|
||||
return page.renderer.getRect(self);
|
||||
}
|
||||
|
||||
@@ -23,26 +23,42 @@ const Page = @import("../page.zig").Page;
|
||||
const EventHandler = @import("../events/event.zig").EventHandler;
|
||||
|
||||
const DOMException = @import("exceptions.zig").DOMException;
|
||||
const Nod = @import("node.zig");
|
||||
const nod = @import("node.zig");
|
||||
|
||||
// EventTarget interfaces
|
||||
pub const Union = Nod.Union;
|
||||
pub const Union = union(enum) {
|
||||
node: nod.Union,
|
||||
xhr: *@import("../xhr/xhr.zig").XMLHttpRequest,
|
||||
};
|
||||
|
||||
// EventTarget implementation
|
||||
pub const EventTarget = struct {
|
||||
pub const Self = parser.EventTarget;
|
||||
pub const Exception = DOMException;
|
||||
|
||||
pub fn toInterface(et: *parser.EventTarget, page: *Page) !Union {
|
||||
// Not all targets are *parser.Nodes. page.zig emits a "load" event
|
||||
// where the target is a Window, which cannot be cast directly to a node.
|
||||
// Ideally, we'd remove this duality. Failing that, we'll need to embed
|
||||
// data into the *parser.EventTarget should we need this for other types.
|
||||
// For now, for the Window, which is a singleton, we can do this:
|
||||
pub fn toInterface(e: *parser.Event, et: *parser.EventTarget, page: *Page) !Union {
|
||||
// libdom assumes that all event targets are libdom nodes. They are not.
|
||||
|
||||
// The window is a common non-node target, but it's easy to handle as
|
||||
// its a singleton.
|
||||
if (@intFromPtr(et) == @intFromPtr(&page.window.base)) {
|
||||
return .{ .Window = &page.window };
|
||||
return .{ .node = .{ .Window = &page.window } };
|
||||
}
|
||||
|
||||
// AbortSignal is another non-node target. It has a distinct usage though
|
||||
// so we hijack the event internal type to identity if.
|
||||
switch (try parser.eventGetInternalType(e)) {
|
||||
.abort_signal => {
|
||||
return .{ .node = .{ .AbortSignal = @fieldParentPtr("proto", @as(*parser.EventTargetTBase, @ptrCast(et))) } };
|
||||
},
|
||||
.xhr_event => {
|
||||
const XMLHttpRequestEventTarget = @import("../xhr/event_target.zig").XMLHttpRequestEventTarget;
|
||||
const base: *XMLHttpRequestEventTarget = @fieldParentPtr("base", @as(*parser.EventTargetTBase, @ptrCast(et)));
|
||||
return .{ .xhr = @fieldParentPtr("proto", base) };
|
||||
},
|
||||
else => {
|
||||
return .{ .node = try nod.Node.toInterface(@as(*parser.Node, @ptrCast(et))) };
|
||||
},
|
||||
}
|
||||
return Nod.Node.toInterface(@as(*parser.Node, @ptrCast(et)));
|
||||
}
|
||||
|
||||
// JS funcs
|
||||
|
||||
@@ -20,10 +20,11 @@ const std = @import("std");
|
||||
const allocPrint = std.fmt.allocPrint;
|
||||
|
||||
const parser = @import("../netsurf.zig");
|
||||
const Page = @import("../page.zig").Page;
|
||||
|
||||
// https://webidl.spec.whatwg.org/#idl-DOMException
|
||||
pub const DOMException = struct {
|
||||
err: parser.DOMError,
|
||||
err: ?parser.DOMError,
|
||||
str: []const u8,
|
||||
|
||||
pub const ErrorSet = parser.DOMError;
|
||||
@@ -55,6 +56,17 @@ pub const DOMException = struct {
|
||||
pub const _INVALID_NODE_TYPE_ERR = 24;
|
||||
pub const _DATA_CLONE_ERR = 25;
|
||||
|
||||
pub fn constructor(message_: ?[]const u8, name_: ?[]const u8, page: *const Page) !DOMException {
|
||||
const message = message_ orelse "";
|
||||
const err = if (name_) |n| error_from_str(n) else null;
|
||||
const fixed_name = name(err);
|
||||
|
||||
if (message.len == 0) return .{ .err = err, .str = fixed_name };
|
||||
|
||||
const str = try allocPrint(page.arena, "{s}: {s}", .{ fixed_name, message });
|
||||
return .{ .err = err, .str = str };
|
||||
}
|
||||
|
||||
// TODO: deinit
|
||||
pub fn init(alloc: std.mem.Allocator, err: anyerror, callerName: []const u8) !DOMException {
|
||||
const errCast = @as(parser.DOMError, @errorCast(err));
|
||||
@@ -75,14 +87,52 @@ pub const DOMException = struct {
|
||||
return .{ .err = errCast, .str = str };
|
||||
}
|
||||
|
||||
fn name(err: parser.DOMError) []const u8 {
|
||||
fn error_from_str(name_: []const u8) ?parser.DOMError {
|
||||
// @speed: Consider length first, left as is for maintainability, awaiting switch on string support
|
||||
if (std.mem.eql(u8, name_, "IndexSizeError")) return error.IndexSize;
|
||||
if (std.mem.eql(u8, name_, "StringSizeError")) return error.StringSize;
|
||||
if (std.mem.eql(u8, name_, "HierarchyRequestError")) return error.HierarchyRequest;
|
||||
if (std.mem.eql(u8, name_, "WrongDocumentError")) return error.WrongDocument;
|
||||
if (std.mem.eql(u8, name_, "InvalidCharacterError")) return error.InvalidCharacter;
|
||||
if (std.mem.eql(u8, name_, "NoDataAllowedError")) return error.NoDataAllowed;
|
||||
if (std.mem.eql(u8, name_, "NoModificationAllowedError")) return error.NoModificationAllowed;
|
||||
if (std.mem.eql(u8, name_, "NotFoundError")) return error.NotFound;
|
||||
if (std.mem.eql(u8, name_, "NotSupportedError")) return error.NotSupported;
|
||||
if (std.mem.eql(u8, name_, "InuseAttributeError")) return error.InuseAttribute;
|
||||
if (std.mem.eql(u8, name_, "InvalidStateError")) return error.InvalidState;
|
||||
if (std.mem.eql(u8, name_, "SyntaxError")) return error.Syntax;
|
||||
if (std.mem.eql(u8, name_, "InvalidModificationError")) return error.InvalidModification;
|
||||
if (std.mem.eql(u8, name_, "NamespaceError")) return error.Namespace;
|
||||
if (std.mem.eql(u8, name_, "InvalidAccessError")) return error.InvalidAccess;
|
||||
if (std.mem.eql(u8, name_, "ValidationError")) return error.Validation;
|
||||
if (std.mem.eql(u8, name_, "TypeMismatchError")) return error.TypeMismatch;
|
||||
if (std.mem.eql(u8, name_, "SecurityError")) return error.Security;
|
||||
if (std.mem.eql(u8, name_, "NetworkError")) return error.Network;
|
||||
if (std.mem.eql(u8, name_, "AbortError")) return error.Abort;
|
||||
if (std.mem.eql(u8, name_, "URLismatchError")) return error.URLismatch;
|
||||
if (std.mem.eql(u8, name_, "QuotaExceededError")) return error.QuotaExceeded;
|
||||
if (std.mem.eql(u8, name_, "TimeoutError")) return error.Timeout;
|
||||
if (std.mem.eql(u8, name_, "InvalidNodeTypeError")) return error.InvalidNodeType;
|
||||
if (std.mem.eql(u8, name_, "DataCloneError")) return error.DataClone;
|
||||
|
||||
// custom netsurf error
|
||||
if (std.mem.eql(u8, name_, "UnspecifiedEventTypeError")) return error.UnspecifiedEventType;
|
||||
if (std.mem.eql(u8, name_, "DispatchRequestError")) return error.DispatchRequest;
|
||||
if (std.mem.eql(u8, name_, "NoMemoryError")) return error.NoMemory;
|
||||
if (std.mem.eql(u8, name_, "AttributeWrongTypeError")) return error.AttributeWrongType;
|
||||
return null;
|
||||
}
|
||||
|
||||
fn name(err_: ?parser.DOMError) []const u8 {
|
||||
const err = err_ orelse return "Error";
|
||||
|
||||
return switch (err) {
|
||||
error.IndexSize => "IndexSizeError",
|
||||
error.StringSize => "StringSizeError",
|
||||
error.StringSize => "StringSizeError", // Legacy: DOMSTRING_SIZE_ERR
|
||||
error.HierarchyRequest => "HierarchyRequestError",
|
||||
error.WrongDocument => "WrongDocumentError",
|
||||
error.InvalidCharacter => "InvalidCharacterError",
|
||||
error.NoDataAllowed => "NoDataAllowedError",
|
||||
error.NoDataAllowed => "NoDataAllowedError", // Legacy: NO_DATA_ALLOWED_ERR
|
||||
error.NoModificationAllowed => "NoModificationAllowedError",
|
||||
error.NotFound => "NotFoundError",
|
||||
error.NotSupported => "NotSupportedError",
|
||||
@@ -92,7 +142,7 @@ pub const DOMException = struct {
|
||||
error.InvalidModification => "InvalidModificationError",
|
||||
error.Namespace => "NamespaceError",
|
||||
error.InvalidAccess => "InvalidAccessError",
|
||||
error.Validation => "ValidationError",
|
||||
error.Validation => "ValidationError", // Legacy: VALIDATION_ERR
|
||||
error.TypeMismatch => "TypeMismatchError",
|
||||
error.Security => "SecurityError",
|
||||
error.Network => "NetworkError",
|
||||
@@ -115,7 +165,8 @@ pub const DOMException = struct {
|
||||
// JS properties and methods
|
||||
|
||||
pub fn get_code(self: *const DOMException) u8 {
|
||||
return switch (self.err) {
|
||||
const err = self.err orelse return 0;
|
||||
return switch (err) {
|
||||
error.IndexSize => 1,
|
||||
error.StringSize => 2,
|
||||
error.HierarchyRequest => 3,
|
||||
@@ -157,7 +208,8 @@ pub const DOMException = struct {
|
||||
|
||||
pub fn get_message(self: *const DOMException) []const u8 {
|
||||
const errName = DOMException.name(self.err);
|
||||
return self.str[errName.len + 2 ..];
|
||||
if (self.str.len <= errName.len + 2) return "";
|
||||
return self.str[errName.len + 2 ..]; // ! Requires str is formatted as "{name}: {message}"
|
||||
}
|
||||
|
||||
pub fn _toString(self: *const DOMException) []const u8 {
|
||||
@@ -188,4 +240,25 @@ test "Browser.DOM.Exception" {
|
||||
.{ "he instanceof DOMException", "true" },
|
||||
.{ "he instanceof Error", "true" },
|
||||
}, .{});
|
||||
|
||||
// Test DOMException constructor
|
||||
try runner.testCases(&.{
|
||||
.{ "let exc0 = new DOMException()", "undefined" },
|
||||
.{ "exc0.name", "Error" },
|
||||
.{ "exc0.code", "0" },
|
||||
.{ "exc0.message", "" },
|
||||
.{ "exc0.toString()", "Error" },
|
||||
|
||||
.{ "let exc1 = new DOMException('Sandwich malfunction')", "undefined" },
|
||||
.{ "exc1.name", "Error" },
|
||||
.{ "exc1.code", "0" },
|
||||
.{ "exc1.message", "Sandwich malfunction" },
|
||||
.{ "exc1.toString()", "Error: Sandwich malfunction" },
|
||||
|
||||
.{ "let exc2 = new DOMException('Caterpillar turned into a butterfly', 'NoModificationAllowedError')", "undefined" },
|
||||
.{ "exc2.name", "NoModificationAllowedError" },
|
||||
.{ "exc2.code", "7" },
|
||||
.{ "exc2.message", "Caterpillar turned into a butterfly" },
|
||||
.{ "exc2.toString()", "NoModificationAllowedError: Caterpillar turned into a butterfly" },
|
||||
}, .{});
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ const Allocator = std.mem.Allocator;
|
||||
const log = @import("../../log.zig");
|
||||
const parser = @import("../netsurf.zig");
|
||||
const Page = @import("../page.zig").Page;
|
||||
const Loop = @import("../../runtime/loop.zig").Loop;
|
||||
|
||||
const Env = @import("../env.zig").Env;
|
||||
const NodeList = @import("nodelist.zig").NodeList;
|
||||
@@ -35,25 +36,37 @@ const Walker = @import("../dom/walker.zig").WalkerChildren;
|
||||
|
||||
// WEB IDL https://dom.spec.whatwg.org/#interface-mutationobserver
|
||||
pub const MutationObserver = struct {
|
||||
loop: *Loop,
|
||||
cbk: Env.Function,
|
||||
arena: Allocator,
|
||||
connected: bool,
|
||||
scheduled: bool,
|
||||
loop_node: Loop.CallbackNode,
|
||||
|
||||
// List of records which were observed. When the call scope ends, we need to
|
||||
// execute our callback with it.
|
||||
observed: std.ArrayListUnmanaged(*MutationRecord),
|
||||
observed: std.ArrayListUnmanaged(MutationRecord),
|
||||
|
||||
pub fn constructor(cbk: Env.Function, page: *Page) !MutationObserver {
|
||||
return .{
|
||||
.cbk = cbk,
|
||||
.loop = page.loop,
|
||||
.observed = .{},
|
||||
.connected = true,
|
||||
.scheduled = false,
|
||||
.arena = page.arena,
|
||||
.loop_node = .{ .func = callback },
|
||||
};
|
||||
}
|
||||
|
||||
pub fn _observe(self: *MutationObserver, node: *parser.Node, options_: ?MutationObserverInit) !void {
|
||||
const options = options_ orelse MutationObserverInit{};
|
||||
pub fn _observe(self: *MutationObserver, node: *parser.Node, options_: ?Options) !void {
|
||||
const arena = self.arena;
|
||||
var options = options_ orelse Options{};
|
||||
if (options.attributeFilter.len > 0) {
|
||||
options.attributeFilter = try arena.dupe([]const u8, options.attributeFilter);
|
||||
}
|
||||
|
||||
const observer = try self.arena.create(Observer);
|
||||
const observer = try arena.create(Observer);
|
||||
observer.* = .{
|
||||
.node = node,
|
||||
.options = options,
|
||||
@@ -102,30 +115,34 @@ pub const MutationObserver = struct {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn jsCallScopeEnd(self: *MutationObserver) void {
|
||||
const record = self.observed.items;
|
||||
if (record.len == 0) {
|
||||
fn callback(node: *Loop.CallbackNode, _: *?u63) void {
|
||||
const self: *MutationObserver = @fieldParentPtr("loop_node", node);
|
||||
if (self.connected == false) {
|
||||
self.scheduled = true;
|
||||
return;
|
||||
}
|
||||
self.scheduled = false;
|
||||
|
||||
const records = self.observed.items;
|
||||
if (records.len == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
defer self.observed.clearRetainingCapacity();
|
||||
|
||||
for (record) |r| {
|
||||
const records = [_]MutationRecord{r.*};
|
||||
var result: Env.Function.Result = undefined;
|
||||
self.cbk.tryCall(void, .{records}, &result) catch {
|
||||
log.debug(.user_script, "callback error", .{
|
||||
.err = result.exception,
|
||||
.stack = result.stack,
|
||||
.source = "mutation observer",
|
||||
});
|
||||
};
|
||||
}
|
||||
var result: Env.Function.Result = undefined;
|
||||
self.cbk.tryCall(void, .{records}, &result) catch {
|
||||
log.debug(.user_script, "callback error", .{
|
||||
.err = result.exception,
|
||||
.stack = result.stack,
|
||||
.source = "mutation observer",
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// TODO
|
||||
pub fn _disconnect(_: *MutationObserver) !void {
|
||||
// TODO unregister listeners.
|
||||
pub fn _disconnect(self: *MutationObserver) !void {
|
||||
self.connected = false;
|
||||
}
|
||||
|
||||
// TODO
|
||||
@@ -182,31 +199,27 @@ pub const MutationRecord = struct {
|
||||
}
|
||||
};
|
||||
|
||||
const MutationObserverInit = struct {
|
||||
const Options = struct {
|
||||
childList: bool = false,
|
||||
attributes: bool = false,
|
||||
characterData: bool = false,
|
||||
subtree: bool = false,
|
||||
attributeOldValue: bool = false,
|
||||
characterDataOldValue: bool = false,
|
||||
// TODO
|
||||
// attributeFilter: [][]const u8,
|
||||
attributeFilter: [][]const u8 = &.{},
|
||||
|
||||
fn attr(self: MutationObserverInit) bool {
|
||||
return self.attributes or self.attributeOldValue;
|
||||
fn attr(self: Options) bool {
|
||||
return self.attributes or self.attributeOldValue or self.attributeFilter.len > 0;
|
||||
}
|
||||
|
||||
fn cdata(self: MutationObserverInit) bool {
|
||||
fn cdata(self: Options) bool {
|
||||
return self.characterData or self.characterDataOldValue;
|
||||
}
|
||||
};
|
||||
|
||||
const Observer = struct {
|
||||
node: *parser.Node,
|
||||
options: MutationObserverInit,
|
||||
|
||||
// record of the mutation, all observed changes in 1 call are batched
|
||||
record: ?MutationRecord = null,
|
||||
options: Options,
|
||||
|
||||
// reference back to the MutationObserver so that we can access the arena
|
||||
// and batch the mutation records.
|
||||
@@ -214,19 +227,34 @@ const Observer = struct {
|
||||
|
||||
event_node: parser.EventNode,
|
||||
|
||||
fn appliesTo(o: *const Observer, target: *parser.Node) bool {
|
||||
fn appliesTo(
|
||||
self: *const Observer,
|
||||
target: *parser.Node,
|
||||
event_type: MutationEventType,
|
||||
event: *parser.MutationEvent,
|
||||
) !bool {
|
||||
if (event_type == .DOMAttrModified and self.options.attributeFilter.len > 0) {
|
||||
const attribute_name = try parser.mutationEventAttributeName(event);
|
||||
for (self.options.attributeFilter) |needle| blk: {
|
||||
if (std.mem.eql(u8, attribute_name, needle)) {
|
||||
break :blk;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// mutation on any target is always ok.
|
||||
if (o.options.subtree) {
|
||||
if (self.options.subtree) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// if target equals node, alway ok.
|
||||
if (target == o.node) {
|
||||
if (target == self.node) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// no subtree, no same target and no childlist, always noky.
|
||||
if (!o.options.childList) {
|
||||
if (!self.options.childList) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -234,7 +262,7 @@ const Observer = struct {
|
||||
const walker = Walker{};
|
||||
var next: ?*parser.Node = null;
|
||||
while (true) {
|
||||
next = walker.get_next(o.node, next) catch break orelse break;
|
||||
next = walker.get_next(self.node, next) catch break orelse break;
|
||||
if (next.? == target) {
|
||||
return true;
|
||||
}
|
||||
@@ -258,27 +286,22 @@ const Observer = struct {
|
||||
break :blk parser.eventTargetToNode(event_target);
|
||||
};
|
||||
|
||||
if (self.appliesTo(node) == false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mutation_event = parser.eventToMutationEvent(event);
|
||||
const event_type = blk: {
|
||||
const t = try parser.eventType(event);
|
||||
break :blk std.meta.stringToEnum(MutationEventType, t) orelse return;
|
||||
};
|
||||
|
||||
const arena = mutation_observer.arena;
|
||||
if (self.record == null) {
|
||||
self.record = .{
|
||||
.target = self.node,
|
||||
.type = event_type.recordType(),
|
||||
};
|
||||
try mutation_observer.observed.append(arena, &self.record.?);
|
||||
if (try self.appliesTo(node, event_type, mutation_event) == false) {
|
||||
return;
|
||||
}
|
||||
|
||||
var record = &self.record.?;
|
||||
const mutation_event = parser.eventToMutationEvent(event);
|
||||
var record = MutationRecord{
|
||||
.target = self.node,
|
||||
.type = event_type.recordType(),
|
||||
};
|
||||
|
||||
const arena = mutation_observer.arena;
|
||||
switch (event_type) {
|
||||
.DOMAttrModified => {
|
||||
record.attribute_name = parser.mutationEventAttributeName(mutation_event) catch null;
|
||||
@@ -302,6 +325,13 @@ const Observer = struct {
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
try mutation_observer.observed.append(arena, record);
|
||||
|
||||
if (mutation_observer.scheduled == false) {
|
||||
mutation_observer.scheduled = true;
|
||||
_ = try mutation_observer.loop.timeout(0, &mutation_observer.loop_node);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -341,10 +371,10 @@ test "Browser.DOM.MutationObserver" {
|
||||
\\ document.firstElementChild.setAttribute("foo", "bar");
|
||||
\\ // ignored b/c it's about another target.
|
||||
\\ document.firstElementChild.firstChild.setAttribute("foo", "bar");
|
||||
\\ nb;
|
||||
,
|
||||
"1",
|
||||
null,
|
||||
},
|
||||
.{ "nb", "1" },
|
||||
.{ "mrs[0].type", "attributes" },
|
||||
.{ "mrs[0].target == document.firstElementChild", "true" },
|
||||
.{ "mrs[0].target.getAttribute('foo')", "bar" },
|
||||
@@ -362,10 +392,10 @@ test "Browser.DOM.MutationObserver" {
|
||||
\\ nb2++;
|
||||
\\ }).observe(node, { characterData: true, characterDataOldValue: true });
|
||||
\\ node.data = "foo";
|
||||
\\ nb2;
|
||||
,
|
||||
"1",
|
||||
null,
|
||||
},
|
||||
.{ "nb2", "1" },
|
||||
.{ "mrs2[0].type", "characterData" },
|
||||
.{ "mrs2[0].target == node", "true" },
|
||||
.{ "mrs2[0].target.data", "foo" },
|
||||
@@ -383,7 +413,24 @@ test "Browser.DOM.MutationObserver" {
|
||||
\\ }).observe(document, { subtree:true,childList:true });
|
||||
\\ node.innerText = "2";
|
||||
,
|
||||
"2",
|
||||
null,
|
||||
},
|
||||
.{ "node.innerText", "a" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{
|
||||
\\ var node = document.getElementById("para");
|
||||
\\ var attrWatch = 0;
|
||||
\\ new MutationObserver(() => {
|
||||
\\ attrWatch++;
|
||||
\\ }).observe(document, { attributeFilter: ["name"], subtree: true });
|
||||
\\ node.setAttribute("id", "1");
|
||||
,
|
||||
null,
|
||||
},
|
||||
.{ "attrWatch", "0" },
|
||||
.{ "node.setAttribute('name', 'other');", null },
|
||||
.{ "attrWatch", "1" },
|
||||
}, .{});
|
||||
}
|
||||
|
||||
@@ -134,5 +134,7 @@ test "Browser.DOM.NamedNodeMap" {
|
||||
.{ "a['id'].name", "id" },
|
||||
.{ "a['id'].value", "content" },
|
||||
.{ "a['other']", "undefined" },
|
||||
.{ "a[0].value = 'abc123'", null },
|
||||
.{ "a[0].value", "abc123" },
|
||||
}, .{});
|
||||
}
|
||||
|
||||
203
src/browser/dom/performance.zig
Normal file
203
src/browser/dom/performance.zig
Normal file
@@ -0,0 +1,203 @@
|
||||
// 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 parser = @import("../netsurf.zig");
|
||||
const EventTarget = @import("../dom/event_target.zig").EventTarget;
|
||||
const Env = @import("../env.zig").Env;
|
||||
const Page = @import("../page.zig").Page;
|
||||
|
||||
pub const Interfaces = .{
|
||||
Performance,
|
||||
PerformanceEntry,
|
||||
PerformanceMark,
|
||||
};
|
||||
|
||||
const MarkOptions = struct {
|
||||
detail: ?Env.JsObject = null,
|
||||
start_time: ?f64 = null,
|
||||
};
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Performance
|
||||
pub const Performance = struct {
|
||||
pub const prototype = *EventTarget;
|
||||
|
||||
// Extend libdom event target for pure zig struct.
|
||||
base: parser.EventTargetTBase = parser.EventTargetTBase{},
|
||||
|
||||
time_origin: std.time.Timer,
|
||||
// if (Window.crossOriginIsolated) -> Resolution in isolated contexts: 5 microseconds
|
||||
// else -> Resolution in non-isolated contexts: 100 microseconds
|
||||
const ms_resolution = 100;
|
||||
|
||||
fn limitedResolutionMs(nanoseconds: u64) f64 {
|
||||
const elapsed_at_resolution = ((nanoseconds / std.time.ns_per_us) + ms_resolution / 2) / ms_resolution * ms_resolution;
|
||||
const elapsed = @as(f64, @floatFromInt(elapsed_at_resolution));
|
||||
return elapsed / @as(f64, std.time.us_per_ms);
|
||||
}
|
||||
|
||||
pub fn get_timeOrigin(self: *const Performance) f64 {
|
||||
const is_posix = switch (@import("builtin").os.tag) { // From std.time.zig L125
|
||||
.windows, .uefi, .wasi => false,
|
||||
else => true,
|
||||
};
|
||||
const zero = std.time.Instant{ .timestamp = if (!is_posix) 0 else .{ .sec = 0, .nsec = 0 } };
|
||||
const started = self.time_origin.started.since(zero);
|
||||
return limitedResolutionMs(started);
|
||||
}
|
||||
|
||||
pub fn _now(self: *Performance) f64 {
|
||||
return limitedResolutionMs(self.time_origin.read());
|
||||
}
|
||||
|
||||
pub fn _mark(_: *Performance, name: []const u8, _options: ?MarkOptions, page: *Page) !PerformanceMark {
|
||||
const mark: PerformanceMark = try .constructor(name, _options, page);
|
||||
// TODO: Should store this in an entries list
|
||||
return mark;
|
||||
}
|
||||
};
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceEntry
|
||||
pub const PerformanceEntry = struct {
|
||||
const PerformanceEntryType = enum {
|
||||
element,
|
||||
event,
|
||||
first_input,
|
||||
largest_contentful_paint,
|
||||
layout_shift,
|
||||
long_animation_frame,
|
||||
longtask,
|
||||
mark,
|
||||
measure,
|
||||
navigation,
|
||||
paint,
|
||||
resource,
|
||||
taskattribution,
|
||||
visibility_state,
|
||||
|
||||
pub fn toString(self: PerformanceEntryType) []const u8 {
|
||||
return switch (self) {
|
||||
.first_input => "first-input",
|
||||
.largest_contentful_paint => "largest-contentful-paint",
|
||||
.layout_shift => "layout-shift",
|
||||
.long_animation_frame => "long-animation-frame",
|
||||
.visibility_state => "visibility-state",
|
||||
else => @tagName(self),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
duration: f64 = 0.0,
|
||||
entry_type: PerformanceEntryType,
|
||||
name: []const u8,
|
||||
start_time: f64 = 0.0,
|
||||
|
||||
pub fn get_duration(self: *const PerformanceEntry) f64 {
|
||||
return self.duration;
|
||||
}
|
||||
|
||||
pub fn get_entryType(self: *const PerformanceEntry) PerformanceEntryType {
|
||||
return self.entry_type;
|
||||
}
|
||||
|
||||
pub fn get_name(self: *const PerformanceEntry) []const u8 {
|
||||
return self.name;
|
||||
}
|
||||
|
||||
pub fn get_startTime(self: *const PerformanceEntry) f64 {
|
||||
return self.start_time;
|
||||
}
|
||||
};
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceMark
|
||||
pub const PerformanceMark = struct {
|
||||
pub const prototype = *PerformanceEntry;
|
||||
|
||||
proto: PerformanceEntry,
|
||||
detail: ?Env.JsObject,
|
||||
|
||||
pub fn constructor(name: []const u8, _options: ?MarkOptions, page: *Page) !PerformanceMark {
|
||||
const perf = &page.window.performance;
|
||||
|
||||
const options = _options orelse MarkOptions{};
|
||||
const start_time = options.start_time orelse perf._now();
|
||||
const detail = if (options.detail) |d| try d.persist() else null;
|
||||
|
||||
if (start_time < 0.0) {
|
||||
return error.TypeError;
|
||||
}
|
||||
|
||||
const duped_name = try page.arena.dupe(u8, name);
|
||||
const proto = PerformanceEntry{ .name = duped_name, .entry_type = .mark, .start_time = start_time };
|
||||
|
||||
return .{ .proto = proto, .detail = detail };
|
||||
}
|
||||
|
||||
pub fn get_detail(self: *const PerformanceMark) ?Env.JsObject {
|
||||
return self.detail;
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("./../../testing.zig");
|
||||
|
||||
test "Performance: get_timeOrigin" {
|
||||
var perf = Performance{ .time_origin = try std.time.Timer.start() };
|
||||
const time_origin = perf.get_timeOrigin();
|
||||
try testing.expect(time_origin >= 0);
|
||||
|
||||
// Check resolution
|
||||
try testing.expectDelta(@rem(time_origin * std.time.us_per_ms, 100.0), 0.0, 0.2);
|
||||
}
|
||||
|
||||
test "Performance: now" {
|
||||
var perf = Performance{ .time_origin = try std.time.Timer.start() };
|
||||
|
||||
// Monotonically increasing
|
||||
var now = perf._now();
|
||||
while (now <= 0) { // Loop for now to not be 0
|
||||
try testing.expectEqual(now, 0);
|
||||
now = perf._now();
|
||||
}
|
||||
// Check resolution
|
||||
try testing.expectDelta(@rem(now * std.time.us_per_ms, 100.0), 0.0, 0.1);
|
||||
|
||||
var after = perf._now();
|
||||
while (after <= now) { // Loop untill after > now
|
||||
try testing.expectEqual(after, now);
|
||||
after = perf._now();
|
||||
}
|
||||
// Check resolution
|
||||
try testing.expectDelta(@rem(after * std.time.us_per_ms, 100.0), 0.0, 0.1);
|
||||
}
|
||||
|
||||
test "Browser.Performance.Mark" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let performance = window.performance", "undefined" },
|
||||
.{ "performance instanceof Performance", "true" },
|
||||
.{ "let mark = performance.mark(\"start\")", "undefined" },
|
||||
.{ "mark instanceof PerformanceMark", "true" },
|
||||
.{ "mark.name", "start" },
|
||||
.{ "mark.entryType", "mark" },
|
||||
.{ "mark.duration", "0" },
|
||||
.{ "mark.detail", "null" },
|
||||
}, .{});
|
||||
}
|
||||
34
src/browser/dom/performance_observer.zig
Normal file
34
src/browser/dom/performance_observer.zig
Normal file
@@ -0,0 +1,34 @@
|
||||
// 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");
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver
|
||||
pub const PerformanceObserver = struct {
|
||||
pub const _supportedEntryTypes = [0][]const u8{};
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.DOM.PerformanceObserver" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "PerformanceObserver.supportedEntryTypes.length", "0" },
|
||||
}, .{});
|
||||
}
|
||||
178
src/browser/dom/range.zig
Normal file
178
src/browser/dom/range.zig
Normal file
@@ -0,0 +1,178 @@
|
||||
// Copyright (C) 2023-2024 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 parser = @import("../netsurf.zig");
|
||||
const Page = @import("../page.zig").Page;
|
||||
|
||||
const NodeUnion = @import("node.zig").Union;
|
||||
const Node = @import("node.zig").Node;
|
||||
|
||||
pub const Interfaces = .{
|
||||
AbstractRange,
|
||||
Range,
|
||||
};
|
||||
|
||||
pub const AbstractRange = struct {
|
||||
collapsed: bool,
|
||||
end_container: *parser.Node,
|
||||
end_offset: i32,
|
||||
start_container: *parser.Node,
|
||||
start_offset: i32,
|
||||
|
||||
pub fn updateCollapsed(self: *AbstractRange) void {
|
||||
// TODO: Eventually, compare properly.
|
||||
self.collapsed = false;
|
||||
}
|
||||
|
||||
pub fn get_collapsed(self: *const AbstractRange) bool {
|
||||
return self.collapsed;
|
||||
}
|
||||
|
||||
pub fn get_endContainer(self: *const AbstractRange) !NodeUnion {
|
||||
return Node.toInterface(self.end_container);
|
||||
}
|
||||
|
||||
pub fn get_endOffset(self: *const AbstractRange) i32 {
|
||||
return self.end_offset;
|
||||
}
|
||||
|
||||
pub fn get_startContainer(self: *const AbstractRange) !NodeUnion {
|
||||
return Node.toInterface(self.start_container);
|
||||
}
|
||||
|
||||
pub fn get_startOffset(self: *const AbstractRange) i32 {
|
||||
return self.start_offset;
|
||||
}
|
||||
};
|
||||
|
||||
pub const Range = struct {
|
||||
pub const prototype = *AbstractRange;
|
||||
|
||||
proto: AbstractRange,
|
||||
|
||||
// The Range() constructor returns a newly created Range object whose start
|
||||
// and end is the global Document object.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Range/Range
|
||||
pub fn constructor(page: *Page) Range {
|
||||
const proto: AbstractRange = .{
|
||||
.collapsed = true,
|
||||
.end_container = parser.documentHTMLToNode(page.window.document),
|
||||
.end_offset = 0,
|
||||
.start_container = parser.documentHTMLToNode(page.window.document),
|
||||
.start_offset = 0,
|
||||
};
|
||||
|
||||
return .{ .proto = proto };
|
||||
}
|
||||
|
||||
pub fn _setStart(self: *Range, node: *parser.Node, offset: i32) void {
|
||||
self.proto.start_container = node;
|
||||
self.proto.start_offset = offset;
|
||||
self.proto.updateCollapsed();
|
||||
}
|
||||
|
||||
pub fn _setEnd(self: *Range, node: *parser.Node, offset: i32) void {
|
||||
self.proto.end_container = node;
|
||||
self.proto.end_offset = offset;
|
||||
self.proto.updateCollapsed();
|
||||
}
|
||||
|
||||
pub fn _createContextualFragment(_: *Range, fragment: []const u8, page: *Page) !*parser.DocumentFragment {
|
||||
const document_html = page.window.document;
|
||||
const document = parser.documentHTMLToDocument(document_html);
|
||||
const doc_frag = try parser.documentParseFragmentFromStr(document, fragment);
|
||||
return doc_frag;
|
||||
}
|
||||
|
||||
pub fn _selectNodeContents(self: *Range, node: *parser.Node) !void {
|
||||
self.proto.start_container = node;
|
||||
self.proto.start_offset = 0;
|
||||
self.proto.end_container = node;
|
||||
|
||||
// Set end_offset
|
||||
switch (try parser.nodeType(node)) {
|
||||
.text, .cdata_section, .comment, .processing_instruction => {
|
||||
// For text-like nodes, end_offset should be the length of the text data
|
||||
if (try parser.nodeValue(node)) |text_data| {
|
||||
self.proto.end_offset = @intCast(text_data.len);
|
||||
} else {
|
||||
self.proto.end_offset = 0;
|
||||
}
|
||||
},
|
||||
else => {
|
||||
// For element and other nodes, end_offset is the number of children
|
||||
const child_nodes = try parser.nodeGetChildNodes(node);
|
||||
const child_count = try parser.nodeListLength(child_nodes);
|
||||
self.proto.end_offset = @intCast(child_count);
|
||||
},
|
||||
}
|
||||
|
||||
self.proto.updateCollapsed();
|
||||
}
|
||||
|
||||
// The Range.detach() method does nothing. It used to disable the Range
|
||||
// object and enable the browser to release associated resources. The
|
||||
// method has been kept for compatibility.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Range/detach
|
||||
pub fn _detach(_: *Range) void {}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.Range" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
// Test Range constructor
|
||||
.{ "let range = new Range()", "undefined" },
|
||||
.{ "range instanceof Range", "true" },
|
||||
.{ "range instanceof AbstractRange", "true" },
|
||||
|
||||
// Test initial state - collapsed range
|
||||
.{ "range.collapsed", "true" },
|
||||
.{ "range.startOffset", "0" },
|
||||
.{ "range.endOffset", "0" },
|
||||
.{ "range.startContainer instanceof HTMLDocument", "true" },
|
||||
.{ "range.endContainer instanceof HTMLDocument", "true" },
|
||||
|
||||
// Test document.createRange()
|
||||
.{ "let docRange = document.createRange()", "undefined" },
|
||||
.{ "docRange instanceof Range", "true" },
|
||||
.{ "docRange.collapsed", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "const container = document.getElementById('content');", null },
|
||||
|
||||
// Test text range
|
||||
.{ "const commentNode = container.childNodes[7];", null },
|
||||
.{ "commentNode.nodeValue", "comment" },
|
||||
.{ "const textRange = document.createRange();", null },
|
||||
.{ "textRange.selectNodeContents(commentNode)", "undefined" },
|
||||
.{ "textRange.startOffset", "0" },
|
||||
.{ "textRange.endOffset", "7" }, // length of `comment`
|
||||
|
||||
// Test Node range
|
||||
.{ "const nodeRange = document.createRange();", null },
|
||||
.{ "nodeRange.selectNodeContents(container)", "undefined" },
|
||||
.{ "nodeRange.startOffset", "0" },
|
||||
.{ "nodeRange.endOffset", "9" }, // length of container.childNodes
|
||||
}, .{});
|
||||
}
|
||||
54
src/browser/dom/resize_observer.zig
Normal file
54
src/browser/dom/resize_observer.zig
Normal file
@@ -0,0 +1,54 @@
|
||||
// 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 Env = @import("../env.zig").Env;
|
||||
const parser = @import("../netsurf.zig");
|
||||
|
||||
pub const Interfaces = .{
|
||||
ResizeObserver,
|
||||
};
|
||||
|
||||
// WEB IDL https://drafts.csswg.org/resize-observer/#resize-observer-interface
|
||||
pub const ResizeObserver = struct {
|
||||
pub fn constructor(cbk: Env.Function) ResizeObserver {
|
||||
_ = cbk;
|
||||
return .{};
|
||||
}
|
||||
|
||||
pub fn _observe(self: *const ResizeObserver, element: *parser.Element, options_: ?Options) void {
|
||||
_ = self;
|
||||
_ = element;
|
||||
_ = options_;
|
||||
return;
|
||||
}
|
||||
|
||||
pub fn _unobserve(self: *const ResizeObserver, element: *parser.Element) void {
|
||||
_ = self;
|
||||
_ = element;
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO
|
||||
pub fn _disconnect(self: *ResizeObserver) void {
|
||||
_ = self;
|
||||
}
|
||||
};
|
||||
|
||||
const Options = struct {
|
||||
box: []const u8,
|
||||
};
|
||||
@@ -82,9 +82,13 @@ pub fn writeNode(node: *parser.Node, writer: anytype) anyerror!void {
|
||||
// void elements can't have any content.
|
||||
if (try isVoid(parser.nodeToElement(node))) return;
|
||||
|
||||
// write the children
|
||||
// TODO avoid recursion
|
||||
try writeChildren(node, writer);
|
||||
if (try parser.elementHTMLGetTagType(@ptrCast(node)) == .script) {
|
||||
try writer.writeAll(try parser.nodeTextContent(node) orelse "");
|
||||
} else {
|
||||
// write the children
|
||||
// TODO avoid recursion
|
||||
try writeChildren(node, writer);
|
||||
}
|
||||
|
||||
// close the tag
|
||||
try writer.writeAll("</");
|
||||
@@ -211,6 +215,11 @@ test "dump.writeHTML" {
|
||||
\\</head><body>9000</body></html>
|
||||
\\
|
||||
, "<html><title>It's over what?</title><meta name=a value=\"b\">\n<body>9000");
|
||||
|
||||
try testWriteHTML(
|
||||
"<p>hi</p><script>alert(power > 9000)</script>",
|
||||
"<p>hi</p><script>alert(power > 9000)</script>",
|
||||
);
|
||||
}
|
||||
|
||||
fn testWriteHTML(comptime expected_body: []const u8, src: []const u8) !void {
|
||||
|
||||
@@ -22,7 +22,8 @@ const WebApis = struct {
|
||||
pub const Interfaces = generate.Tuple(.{
|
||||
@import("crypto/crypto.zig").Crypto,
|
||||
@import("console/console.zig").Console,
|
||||
@import("cssom/css_style_declaration.zig").Interfaces,
|
||||
@import("css/css.zig").Interfaces,
|
||||
@import("cssom/cssom.zig").Interfaces,
|
||||
@import("dom/dom.zig").Interfaces,
|
||||
@import("encoding/text_encoder.zig").Interfaces,
|
||||
@import("events/event.zig").Interfaces,
|
||||
|
||||
@@ -27,18 +27,15 @@ const Page = @import("../page.zig").Page;
|
||||
const DOMException = @import("../dom/exceptions.zig").DOMException;
|
||||
const EventTarget = @import("../dom/event_target.zig").EventTarget;
|
||||
const EventTargetUnion = @import("../dom/event_target.zig").Union;
|
||||
const AbortSignal = @import("../html/AbortController.zig").AbortSignal;
|
||||
|
||||
const CustomEvent = @import("custom_event.zig").CustomEvent;
|
||||
const ProgressEvent = @import("../xhr/progress_event.zig").ProgressEvent;
|
||||
const MouseEvent = @import("mouse_event.zig").MouseEvent;
|
||||
const ErrorEvent = @import("../html/error_event.zig").ErrorEvent;
|
||||
|
||||
// Event interfaces
|
||||
pub const Interfaces = .{
|
||||
Event,
|
||||
CustomEvent,
|
||||
ProgressEvent,
|
||||
MouseEvent,
|
||||
};
|
||||
pub const Interfaces = .{ Event, CustomEvent, ProgressEvent, MouseEvent, ErrorEvent };
|
||||
|
||||
pub const Union = generate.Union(Interfaces);
|
||||
|
||||
@@ -58,10 +55,11 @@ pub const Event = struct {
|
||||
|
||||
pub fn toInterface(evt: *parser.Event) !Union {
|
||||
return switch (try parser.eventGetInternalType(evt)) {
|
||||
.event => .{ .Event = evt },
|
||||
.event, .abort_signal, .xhr_event => .{ .Event = evt },
|
||||
.custom_event => .{ .CustomEvent = @as(*CustomEvent, @ptrCast(evt)).* },
|
||||
.progress_event => .{ .ProgressEvent = @as(*ProgressEvent, @ptrCast(evt)).* },
|
||||
.mouse_event => .{ .MouseEvent = @as(*parser.MouseEvent, @ptrCast(evt)) },
|
||||
.error_event => .{ .ErrorEvent = @as(*ErrorEvent, @ptrCast(evt)).* },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,13 +78,13 @@ pub const Event = struct {
|
||||
pub fn get_target(self: *parser.Event, page: *Page) !?EventTargetUnion {
|
||||
const et = try parser.eventTarget(self);
|
||||
if (et == null) return null;
|
||||
return try EventTarget.toInterface(et.?, page);
|
||||
return try EventTarget.toInterface(self, et.?, page);
|
||||
}
|
||||
|
||||
pub fn get_currentTarget(self: *parser.Event, page: *Page) !?EventTargetUnion {
|
||||
const et = try parser.eventCurrentTarget(self);
|
||||
if (et == null) return null;
|
||||
return try EventTarget.toInterface(et.?, page);
|
||||
return try EventTarget.toInterface(self, et.?, page);
|
||||
}
|
||||
|
||||
pub fn get_eventPhase(self: *parser.Event) !u8 {
|
||||
@@ -178,7 +176,7 @@ pub const EventHandler = struct {
|
||||
// that the listener won't call preventDefault() and thus can safely
|
||||
// run the default as needed).
|
||||
passive: ?bool,
|
||||
signal: ?bool, // currently does nothing
|
||||
signal: ?*AbortSignal, // currently does nothing
|
||||
};
|
||||
};
|
||||
|
||||
@@ -191,18 +189,14 @@ pub const EventHandler = struct {
|
||||
) !?*EventHandler {
|
||||
var once = false;
|
||||
var capture = false;
|
||||
var signal: ?*AbortSignal = null;
|
||||
|
||||
if (opts_) |opts| {
|
||||
switch (opts) {
|
||||
.capture => |c| capture = c,
|
||||
.flags => |f| {
|
||||
// Done this way so that, for common cases that _only_ set
|
||||
// capture, i.e. {captrue: true}, it works.
|
||||
// But for any case that sets any of the other flags, we
|
||||
// error. If we don't error, this function call would succeed
|
||||
// but the behavior might be wrong. At this point, it's
|
||||
// better to be explicit and error.
|
||||
if (f.signal orelse false) return error.NotImplemented;
|
||||
once = f.once orelse false;
|
||||
signal = f.signal orelse null;
|
||||
capture = f.capture orelse false;
|
||||
},
|
||||
}
|
||||
@@ -210,6 +204,28 @@ pub const EventHandler = struct {
|
||||
|
||||
const callback = (try listener.callback(target)) orelse return null;
|
||||
|
||||
if (signal) |s| {
|
||||
const signal_target = parser.toEventTarget(AbortSignal, s);
|
||||
|
||||
const scb = try allocator.create(SignalCallback);
|
||||
scb.* = .{
|
||||
.target = target,
|
||||
.capture = capture,
|
||||
.callback_id = callback.id,
|
||||
.typ = try allocator.dupe(u8, typ),
|
||||
.signal_target = signal_target,
|
||||
.signal_listener = undefined,
|
||||
.node = .{ .func = SignalCallback.handle },
|
||||
};
|
||||
|
||||
scb.signal_listener = try parser.eventTargetAddEventListener(
|
||||
signal_target,
|
||||
"abort",
|
||||
&scb.node,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
// check if event target has already this listener
|
||||
if (try parser.eventTargetHasListener(target, typ, capture, callback.id) != null) {
|
||||
return null;
|
||||
@@ -265,6 +281,50 @@ pub const EventHandler = struct {
|
||||
}
|
||||
};
|
||||
|
||||
const SignalCallback = struct {
|
||||
typ: []const u8,
|
||||
capture: bool,
|
||||
callback_id: usize,
|
||||
node: parser.EventNode,
|
||||
target: *parser.EventTarget,
|
||||
signal_target: *parser.EventTarget,
|
||||
signal_listener: *parser.EventListener,
|
||||
|
||||
fn handle(node: *parser.EventNode, _: *parser.Event) void {
|
||||
const self: *SignalCallback = @fieldParentPtr("node", node);
|
||||
self._handle() catch |err| {
|
||||
log.err(.app, "event signal handler", .{ .err = err });
|
||||
};
|
||||
}
|
||||
|
||||
fn _handle(self: *SignalCallback) !void {
|
||||
const lst = try parser.eventTargetHasListener(
|
||||
self.target,
|
||||
self.typ,
|
||||
self.capture,
|
||||
self.callback_id,
|
||||
);
|
||||
if (lst == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try parser.eventTargetRemoveEventListener(
|
||||
self.target,
|
||||
self.typ,
|
||||
lst.?,
|
||||
self.capture,
|
||||
);
|
||||
|
||||
// remove the abort signal listener itself
|
||||
try parser.eventTargetRemoveEventListener(
|
||||
self.signal_target,
|
||||
"abort",
|
||||
self.signal_listener,
|
||||
false,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.Event" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
@@ -370,5 +430,18 @@ test "Browser.Event" {
|
||||
.{ "document.dispatchEvent(new Event('count'))", "true" },
|
||||
.{ "document.dispatchEvent(new Event('count'))", "true" },
|
||||
.{ "nb", "1" },
|
||||
.{ "document.removeEventListener('count', cbk)", "undefined" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "nb = 0; function cbk(event) { nb ++; }", null },
|
||||
.{ "let ac = new AbortController()", null },
|
||||
.{ "document.addEventListener('count', cbk, {signal: ac.signal})", null },
|
||||
.{ "document.dispatchEvent(new Event('count'))", "true" },
|
||||
.{ "document.dispatchEvent(new Event('count'))", "true" },
|
||||
.{ "ac.abort()", null },
|
||||
.{ "document.dispatchEvent(new Event('count'))", "true" },
|
||||
.{ "nb", "2" },
|
||||
.{ "document.removeEventListener('count', cbk)", "undefined" },
|
||||
}, .{});
|
||||
}
|
||||
|
||||
188
src/browser/html/AbortController.zig
Normal file
188
src/browser/html/AbortController.zig
Normal file
@@ -0,0 +1,188 @@
|
||||
// Copyright (C) 2023-2024 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 log = @import("../../log.zig");
|
||||
const parser = @import("../netsurf.zig");
|
||||
const Env = @import("../env.zig").Env;
|
||||
const Page = @import("../page.zig").Page;
|
||||
const Loop = @import("../../runtime/loop.zig").Loop;
|
||||
const EventTarget = @import("../dom/event_target.zig").EventTarget;
|
||||
|
||||
pub const Interfaces = .{
|
||||
AbortController,
|
||||
AbortSignal,
|
||||
};
|
||||
|
||||
const AbortController = @This();
|
||||
|
||||
signal: *AbortSignal,
|
||||
|
||||
pub fn constructor(page: *Page) !AbortController {
|
||||
// Why do we allocate this rather than storing directly in the struct?
|
||||
// https://github.com/lightpanda-io/project/discussions/165
|
||||
const signal = try page.arena.create(AbortSignal);
|
||||
signal.* = .init;
|
||||
|
||||
return .{
|
||||
.signal = signal,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn get_signal(self: *AbortController) *AbortSignal {
|
||||
return self.signal;
|
||||
}
|
||||
|
||||
pub fn _abort(self: *AbortController, reason_: ?[]const u8) !void {
|
||||
return self.signal.abort(reason_);
|
||||
}
|
||||
|
||||
pub const AbortSignal = struct {
|
||||
const DEFAULT_REASON = "AbortError";
|
||||
|
||||
pub const prototype = *EventTarget;
|
||||
proto: parser.EventTargetTBase = .{},
|
||||
|
||||
aborted: bool,
|
||||
reason: ?[]const u8,
|
||||
|
||||
pub const init: AbortSignal = .{
|
||||
.proto = .{},
|
||||
.reason = null,
|
||||
.aborted = false,
|
||||
};
|
||||
|
||||
pub fn static_abort(reason_: ?[]const u8) AbortSignal {
|
||||
return .{
|
||||
.aborted = true,
|
||||
.reason = reason_ orelse DEFAULT_REASON,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn static_timeout(delay: u32, page: *Page) !*AbortSignal {
|
||||
const callback = try page.arena.create(TimeoutCallback);
|
||||
callback.* = .{
|
||||
.signal = .init,
|
||||
.node = .{ .func = TimeoutCallback.run },
|
||||
};
|
||||
|
||||
const delay_ms: u63 = @as(u63, delay) * std.time.ns_per_ms;
|
||||
_ = try page.loop.timeout(delay_ms, &callback.node);
|
||||
return &callback.signal;
|
||||
}
|
||||
|
||||
pub fn get_aborted(self: *const AbortSignal) bool {
|
||||
return self.aborted;
|
||||
}
|
||||
|
||||
fn abort(self: *AbortSignal, reason_: ?[]const u8) !void {
|
||||
self.aborted = true;
|
||||
self.reason = reason_ orelse DEFAULT_REASON;
|
||||
|
||||
const abort_event = try parser.eventCreate();
|
||||
try parser.eventSetInternalType(abort_event, .abort_signal);
|
||||
|
||||
defer parser.eventDestroy(abort_event);
|
||||
try parser.eventInit(abort_event, "abort", .{});
|
||||
_ = try parser.eventTargetDispatchEvent(
|
||||
parser.toEventTarget(AbortSignal, self),
|
||||
abort_event,
|
||||
);
|
||||
}
|
||||
|
||||
const Reason = union(enum) {
|
||||
reason: []const u8,
|
||||
undefined: void,
|
||||
};
|
||||
pub fn get_reason(self: *const AbortSignal) Reason {
|
||||
if (self.reason) |r| {
|
||||
return .{ .reason = r };
|
||||
}
|
||||
return .{ .undefined = {} };
|
||||
}
|
||||
|
||||
const ThrowIfAborted = union(enum) {
|
||||
exception: Env.Exception,
|
||||
undefined: void,
|
||||
};
|
||||
pub fn _throwIfAborted(self: *const AbortSignal, page: *Page) ThrowIfAborted {
|
||||
if (self.aborted) {
|
||||
const ex = page.main_context.throw(self.reason orelse DEFAULT_REASON);
|
||||
return .{ .exception = ex };
|
||||
}
|
||||
return .{ .undefined = {} };
|
||||
}
|
||||
};
|
||||
|
||||
const TimeoutCallback = struct {
|
||||
signal: AbortSignal,
|
||||
|
||||
// This is the internal data that the event loop tracks. We'll get this
|
||||
// back in run and, from it, can get our TimeoutCallback instance
|
||||
node: Loop.CallbackNode = undefined,
|
||||
|
||||
fn run(node: *Loop.CallbackNode, _: *?u63) void {
|
||||
const self: *TimeoutCallback = @fieldParentPtr("node", node);
|
||||
self.signal.abort("TimeoutError") catch |err| {
|
||||
log.warn(.app, "abort signal timeout", .{ .err = err });
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.HTML.AbortController" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "var called = 0", null },
|
||||
.{ "var a1 = new AbortController()", null },
|
||||
.{ "var s1 = a1.signal", null },
|
||||
.{ "s1.throwIfAborted()", "undefined" },
|
||||
.{ "s1.reason", "undefined" },
|
||||
.{ "var target;", null },
|
||||
.{
|
||||
\\ s1.addEventListener('abort', (e) => {
|
||||
\\ called += 1;
|
||||
\\ target = e.target;
|
||||
\\
|
||||
\\ });
|
||||
,
|
||||
null,
|
||||
},
|
||||
.{ "a1.abort()", null },
|
||||
.{ "s1.aborted", "true" },
|
||||
.{ "target == s1", "true" },
|
||||
.{ "s1.reason", "AbortError" },
|
||||
.{ "called", "1" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "var s2 = AbortSignal.abort('over 9000')", null },
|
||||
.{ "s2.aborted", "true" },
|
||||
.{ "s2.reason", "over 9000" },
|
||||
.{ "AbortSignal.abort().reason", "AbortError" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "var s3 = AbortSignal.timeout(10)", null },
|
||||
.{ "s3.aborted", "true" },
|
||||
.{ "s3.reason", "TimeoutError" },
|
||||
.{ "try { s3.throwIfAborted() } catch (e) { e }", "Error: TimeoutError" },
|
||||
}, .{});
|
||||
}
|
||||
97
src/browser/html/DataSet.zig
Normal file
97
src/browser/html/DataSet.zig
Normal file
@@ -0,0 +1,97 @@
|
||||
// Copyright (C) 2023-2024 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 parser = @import("../netsurf.zig");
|
||||
const Env = @import("../env.zig").Env;
|
||||
const Page = @import("../page.zig").Page;
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const DataSet = @This();
|
||||
|
||||
element: *parser.Element,
|
||||
|
||||
pub fn named_get(self: *const DataSet, name: []const u8, _: *bool, page: *Page) !Env.UndefinedOr([]const u8) {
|
||||
const normalized_name = try normalize(page.call_arena, name);
|
||||
if (try parser.elementGetAttribute(self.element, normalized_name)) |value| {
|
||||
return .{ .value = value };
|
||||
}
|
||||
return .undefined;
|
||||
}
|
||||
|
||||
pub fn named_set(self: *DataSet, name: []const u8, value: []const u8, _: *bool, page: *Page) !void {
|
||||
const normalized_name = try normalize(page.call_arena, name);
|
||||
try parser.elementSetAttribute(self.element, normalized_name, value);
|
||||
}
|
||||
|
||||
pub fn named_delete(self: *DataSet, name: []const u8, _: *bool, page: *Page) !void {
|
||||
const normalized_name = try normalize(page.call_arena, name);
|
||||
try parser.elementRemoveAttribute(self.element, normalized_name);
|
||||
}
|
||||
|
||||
fn normalize(allocator: Allocator, name: []const u8) ![]const u8 {
|
||||
var upper_count: usize = 0;
|
||||
for (name) |c| {
|
||||
if (std.ascii.isUpper(c)) {
|
||||
upper_count += 1;
|
||||
}
|
||||
}
|
||||
// for every upper-case letter, we'll probably need a dash before it
|
||||
// and we need the 'data-' prefix
|
||||
var normalized = try allocator.alloc(u8, name.len + upper_count + 5);
|
||||
|
||||
@memcpy(normalized[0..5], "data-");
|
||||
if (upper_count == 0) {
|
||||
@memcpy(normalized[5..], name);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
var pos: usize = 5;
|
||||
for (name) |c| {
|
||||
if (std.ascii.isUpper(c)) {
|
||||
normalized[pos] = '-';
|
||||
pos += 1;
|
||||
normalized[pos] = c + 32;
|
||||
} else {
|
||||
normalized[pos] = c;
|
||||
}
|
||||
pos += 1;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.HTML.DataSet" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{ .html = "" });
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let el1 = document.createElement('div')", null },
|
||||
.{ "el1.dataset.x", "undefined" },
|
||||
.{ "el1.dataset.x = '123'", "123" },
|
||||
.{ "delete el1.dataset.x", "true" },
|
||||
.{ "el1.dataset.x", "undefined" },
|
||||
.{ "delete el1.dataset.other", "true" }, // yes, this is right
|
||||
|
||||
.{ "let ds1 = el1.dataset", null },
|
||||
.{ "ds1.helloWorld = 'yes'", null },
|
||||
.{ "el1.getAttribute('data-hello-world')", "yes" },
|
||||
.{ "el1.setAttribute('data-this-will-work', 'positive')", null },
|
||||
.{ "ds1.thisWillWork", "positive" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -81,7 +81,7 @@ pub const HTMLDocument = struct {
|
||||
|
||||
pub fn get_cookie(_: *parser.DocumentHTML, page: *Page) ![]const u8 {
|
||||
var buf: std.ArrayListUnmanaged(u8) = .{};
|
||||
try page.cookie_jar.forRequest(&page.url.uri, buf.writer(page.arena), .{ .navigation = true });
|
||||
try page.cookie_jar.forRequest(&page.url.uri, buf.writer(page.arena), .{ .navigation = true, .is_http = false });
|
||||
return buf.items;
|
||||
}
|
||||
|
||||
@@ -90,6 +90,10 @@ pub const HTMLDocument = struct {
|
||||
// outlives the page's arena.
|
||||
const c = try Cookie.parse(page.cookie_jar.allocator, &page.url.uri, cookie_str);
|
||||
errdefer c.deinit();
|
||||
if (c.http_only) {
|
||||
c.deinit();
|
||||
return ""; // HttpOnly cookies cannot be set from JS
|
||||
}
|
||||
try page.cookie_jar.add(c, std.time.timestamp());
|
||||
return cookie_str;
|
||||
}
|
||||
@@ -229,19 +233,23 @@ pub const HTMLDocument = struct {
|
||||
// Since LightPanda requires the client to know what they are clicking on we do not return the underlying element at this moment
|
||||
// This can currenty only happen if the first pixel is clicked without having rendered any element. This will change when css properties are supported.
|
||||
// This returns an ElementUnion instead of a *Parser.Element in case the element somehow hasn't passed through the js runtime yet.
|
||||
pub fn _elementFromPoint(_: *parser.DocumentHTML, x: f32, y: f32, page: *Page) !?ElementUnion {
|
||||
const ix: i32 = @intFromFloat(@floor(x));
|
||||
const iy: i32 = @intFromFloat(@floor(y));
|
||||
const element = page.renderer.getElementAtPosition(ix, iy) orelse return null;
|
||||
// While x and y should be f32, here we take i32 since that's what our
|
||||
// "renderer" uses. By specifying i32 here, rather than f32 and doing the
|
||||
// conversion ourself, we rely on v8's type conversion which is both more
|
||||
// flexible (e.g. handles NaN) and will be more consistent with a browser.
|
||||
pub fn _elementFromPoint(_: *parser.DocumentHTML, x: i32, y: i32, page: *Page) !?ElementUnion {
|
||||
const element = page.renderer.getElementAtPosition(x, y) orelse return null;
|
||||
// TODO if pointer-events set to none the underlying element should be returned (parser.documentGetDocumentElement(self.document);?)
|
||||
return try Element.toInterface(element);
|
||||
}
|
||||
|
||||
// Returns an array of all elements at the specified coordinates (relative to the viewport). The elements are ordered from the topmost to the bottommost box of the viewport.
|
||||
pub fn _elementsFromPoint(_: *parser.DocumentHTML, x: f32, y: f32, page: *Page) ![]ElementUnion {
|
||||
const ix: i32 = @intFromFloat(@floor(x));
|
||||
const iy: i32 = @intFromFloat(@floor(y));
|
||||
const element = page.renderer.getElementAtPosition(ix, iy) orelse return &.{};
|
||||
// While x and y should be f32, here we take i32 since that's what our
|
||||
// "renderer" uses. By specifying i32 here, rather than f32 and doing the
|
||||
// conversion ourself, we rely on v8's type conversion which is both more
|
||||
// flexible (e.g. handles NaN) and will be more consistent with a browser.
|
||||
pub fn _elementsFromPoint(_: *parser.DocumentHTML, x: i32, y: i32, page: *Page) ![]ElementUnion {
|
||||
const element = page.renderer.getElementAtPosition(x, y) orelse return &.{};
|
||||
// TODO if pointer-events set to none the underlying element should be returned (parser.documentGetDocumentElement(self.document);?)
|
||||
|
||||
var list: std.ArrayListUnmanaged(ElementUnion) = .empty;
|
||||
@@ -333,6 +341,8 @@ test "Browser.HTML.Document" {
|
||||
.{ "document.cookie = 'name=Oeschger; SameSite=None; Secure'", "name=Oeschger; SameSite=None; Secure" },
|
||||
.{ "document.cookie = 'favorite_food=tripe; SameSite=None; Secure'", "favorite_food=tripe; SameSite=None; Secure" },
|
||||
.{ "document.cookie", "name=Oeschger; favorite_food=tripe" },
|
||||
.{ "document.cookie = 'IgnoreMy=Ghost; HttpOnly'", null }, // "" should be returned, but the framework overrules it atm
|
||||
.{ "document.cookie", "name=Oeschger; favorite_food=tripe" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
|
||||
@@ -27,6 +27,7 @@ const urlStitch = @import("../../url.zig").URL.stitch;
|
||||
const URL = @import("../url/url.zig").URL;
|
||||
const Node = @import("../dom/node.zig").Node;
|
||||
const Element = @import("../dom/element.zig").Element;
|
||||
const DataSet = @import("DataSet.zig");
|
||||
|
||||
const CSSStyleDeclaration = @import("../cssom/css_style_declaration.zig").CSSStyleDeclaration;
|
||||
|
||||
@@ -122,6 +123,15 @@ pub const HTMLElement = struct {
|
||||
return &state.style;
|
||||
}
|
||||
|
||||
pub fn get_dataset(e: *parser.ElementHTML, page: *Page) !*DataSet {
|
||||
const state = try page.getOrCreateNodeState(@ptrCast(e));
|
||||
if (state.dataset) |*ds| {
|
||||
return ds;
|
||||
}
|
||||
state.dataset = DataSet{ .element = @ptrCast(e) };
|
||||
return &state.dataset.?;
|
||||
}
|
||||
|
||||
pub fn get_innerText(e: *parser.ElementHTML) ![]const u8 {
|
||||
const n = @as(*parser.Node, @ptrCast(e));
|
||||
return try parser.nodeTextContent(n) orelse "";
|
||||
@@ -261,8 +271,18 @@ pub const HTMLAnchorElement = struct {
|
||||
return try parser.nodeSetTextContent(parser.anchorToNode(self), v);
|
||||
}
|
||||
|
||||
inline fn url(self: *parser.Anchor, page: *Page) !URL {
|
||||
return URL.constructor(.{ .element = @alignCast(@ptrCast(self)) }, null, page); // TODO inject base url
|
||||
fn url(self: *parser.Anchor, page: *Page) !URL {
|
||||
// Although the URL.constructor union accepts an .{.element = X}, we
|
||||
// can't use this here because the behavior is different.
|
||||
// URL.constructor(document.createElement('a')
|
||||
// should fail (a.href isn't a valid URL)
|
||||
// But
|
||||
// document.createElement('a').host
|
||||
// should not fail, it should return an empty string
|
||||
if (try parser.elementGetAttribute(@alignCast(@ptrCast(self)), "href")) |href| {
|
||||
return URL.constructor(.{ .string = href }, null, page); // TODO inject base url
|
||||
}
|
||||
return .empty;
|
||||
}
|
||||
|
||||
// TODO return a disposable string
|
||||
@@ -875,6 +895,15 @@ pub const HTMLLinkElement = struct {
|
||||
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
|
||||
return constructHtmlElement(page, js_this);
|
||||
}
|
||||
|
||||
pub fn get_href(self: *parser.Link) ![]const u8 {
|
||||
return try parser.linkGetHref(self);
|
||||
}
|
||||
|
||||
pub fn set_href(self: *parser.Link, href: []const u8, page: *const Page) !void {
|
||||
const full = try urlStitch(page.call_arena, href, page.url.raw, .{});
|
||||
return try parser.linkSetHref(self, full);
|
||||
}
|
||||
};
|
||||
|
||||
pub const HTMLMapElement = struct {
|
||||
@@ -1265,6 +1294,16 @@ pub const HTMLTemplateElement = struct {
|
||||
pub fn constructor(page: *Page, js_this: Env.JsThis) !*parser.Element {
|
||||
return constructHtmlElement(page, js_this);
|
||||
}
|
||||
|
||||
pub fn get_content(self: *parser.Template, page: *Page) !*parser.DocumentFragment {
|
||||
const state = try page.getOrCreateNodeState(@alignCast(@ptrCast(self)));
|
||||
if (state.template_content) |tc| {
|
||||
return tc;
|
||||
}
|
||||
const tc = try parser.documentCreateDocumentFragment(@ptrCast(page.window.document));
|
||||
state.template_content = tc;
|
||||
return tc;
|
||||
}
|
||||
};
|
||||
|
||||
pub const HTMLTextAreaElement = struct {
|
||||
@@ -1539,6 +1578,8 @@ test "Browser.HTML.Element" {
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let a = document.createElement('a');", null },
|
||||
.{ "a.href", "" },
|
||||
.{ "a.host", "" },
|
||||
.{ "a.href = 'about'", null },
|
||||
.{ "a.href", "https://lightpanda.io/opensource-browser/about" },
|
||||
}, .{});
|
||||
@@ -1549,8 +1590,26 @@ test "Browser.HTML.Element" {
|
||||
.{ "document.createElement('a').focus()", null },
|
||||
.{ "document.activeElement === focused", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let l2 = document.createElement('link');", null },
|
||||
.{ "l2.href", "" },
|
||||
.{ "l2.href = 'https://lightpanda.io/opensource-browser/15'", null },
|
||||
.{ "l2.href", "https://lightpanda.io/opensource-browser/15" },
|
||||
|
||||
.{ "l2.href = '/over/9000'", null },
|
||||
.{ "l2.href", "https://lightpanda.io/over/9000" },
|
||||
}, .{});
|
||||
}
|
||||
test "Browser.HTML.HtmlInputElement.propeties" {
|
||||
|
||||
test "Browser.HTML.Element.DataSet" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{ .html = "<div id=x data-power='over 9000' data-empty data-some-long-key=ok></div>" });
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{ .{ "let div = document.getElementById('x')", null }, .{ "div.dataset.nope", "undefined" }, .{ "div.dataset.power", "over 9000" }, .{ "div.dataset.empty", "" }, .{ "div.dataset.someLongKey", "ok" }, .{ "delete div.dataset.power", "true" }, .{ "div.dataset.power", "undefined" } }, .{});
|
||||
}
|
||||
|
||||
test "Browser.HTML.HtmlInputElement.properties" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{ .url = "https://lightpanda.io/noslashattheend" });
|
||||
defer runner.deinit();
|
||||
var alloc = std.heap.ArenaAllocator.init(runner.app.allocator);
|
||||
@@ -1634,7 +1693,8 @@ test "Browser.HTML.HtmlInputElement.propeties" {
|
||||
.{ "input_value.value", "mango" }, // Still mango
|
||||
}, .{});
|
||||
}
|
||||
test "Browser.HTML.HtmlInputElement.propeties.form" {
|
||||
|
||||
test "Browser.HTML.HtmlInputElement.properties.form" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{ .html =
|
||||
\\ <form action="test.php" target="_blank">
|
||||
\\ <p>
|
||||
@@ -1652,6 +1712,21 @@ test "Browser.HTML.HtmlInputElement.propeties.form" {
|
||||
}, .{});
|
||||
}
|
||||
|
||||
test "Browser.HTML.HTMLTemplateElement" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{ .html = "<div id=c></div>" });
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let t = document.createElement('template')", null },
|
||||
.{ "let d = document.createElement('div')", null },
|
||||
.{ "d.id = 'abc'", null },
|
||||
.{ "t.content.append(d)", null },
|
||||
.{ "document.getElementById('abc')", "null" },
|
||||
.{ "document.getElementById('c').appendChild(t.content.cloneNode(true))", null },
|
||||
.{ "document.getElementById('abc').id", "abc" },
|
||||
}, .{});
|
||||
}
|
||||
|
||||
const Check = struct {
|
||||
input: []const u8,
|
||||
expected: ?[]const u8 = null, // Needed when input != expected
|
||||
|
||||
114
src/browser/html/error_event.zig
Normal file
114
src/browser/html/error_event.zig
Normal file
@@ -0,0 +1,114 @@
|
||||
// Copyright (C) 2023-2024 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 Env = @import("../env.zig").Env;
|
||||
const parser = @import("../netsurf.zig");
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/ErrorEvent
|
||||
pub const ErrorEvent = struct {
|
||||
pub const prototype = *parser.Event;
|
||||
pub const union_make_copy = true;
|
||||
|
||||
proto: parser.Event,
|
||||
message: []const u8,
|
||||
filename: []const u8,
|
||||
lineno: i32,
|
||||
colno: i32,
|
||||
@"error": ?Env.JsObject,
|
||||
|
||||
const ErrorEventInit = struct {
|
||||
message: []const u8 = "",
|
||||
filename: []const u8 = "",
|
||||
lineno: i32 = 0,
|
||||
colno: i32 = 0,
|
||||
@"error": ?Env.JsObject = null,
|
||||
};
|
||||
|
||||
pub fn constructor(event_type: []const u8, opts: ?ErrorEventInit) !ErrorEvent {
|
||||
const event = try parser.eventCreate();
|
||||
defer parser.eventDestroy(event);
|
||||
try parser.eventInit(event, event_type, .{});
|
||||
try parser.eventSetInternalType(event, .event);
|
||||
|
||||
const o = opts orelse ErrorEventInit{};
|
||||
|
||||
return .{
|
||||
.proto = event.*,
|
||||
.message = o.message,
|
||||
.filename = o.filename,
|
||||
.lineno = o.lineno,
|
||||
.colno = o.colno,
|
||||
.@"error" = if (o.@"error") |e| try e.persist() else null,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn get_message(self: *const ErrorEvent) []const u8 {
|
||||
return self.message;
|
||||
}
|
||||
|
||||
pub fn get_filename(self: *const ErrorEvent) []const u8 {
|
||||
return self.filename;
|
||||
}
|
||||
|
||||
pub fn get_lineno(self: *const ErrorEvent) i32 {
|
||||
return self.lineno;
|
||||
}
|
||||
|
||||
pub fn get_colno(self: *const ErrorEvent) i32 {
|
||||
return self.colno;
|
||||
}
|
||||
|
||||
pub fn get_error(self: *const ErrorEvent) Env.UndefinedOr(Env.JsObject) {
|
||||
if (self.@"error") |e| {
|
||||
return .{ .value = e };
|
||||
}
|
||||
return .undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.HTML.ErrorEvent" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{ .html = "<div id=c></div>" });
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let e1 = new ErrorEvent('err1')", null },
|
||||
.{ "e1.message", "" },
|
||||
.{ "e1.filename", "" },
|
||||
.{ "e1.lineno", "0" },
|
||||
.{ "e1.colno", "0" },
|
||||
.{ "e1.error", "undefined" },
|
||||
|
||||
.{
|
||||
\\ let e2 = new ErrorEvent('err1', {
|
||||
\\ message: 'm1',
|
||||
\\ filename: 'fx19',
|
||||
\\ lineno: 443,
|
||||
\\ colno: 8999,
|
||||
\\ error: 'under 9000!',
|
||||
\\
|
||||
\\})
|
||||
,
|
||||
null,
|
||||
},
|
||||
.{ "e2.message", "m1" },
|
||||
.{ "e2.filename", "fx19" },
|
||||
.{ "e2.lineno", "443" },
|
||||
.{ "e2.colno", "8999" },
|
||||
.{ "e2.error", "under 9000!" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -24,7 +24,6 @@ const Navigator = @import("navigator.zig").Navigator;
|
||||
const History = @import("history.zig").History;
|
||||
const Location = @import("location.zig").Location;
|
||||
const MediaQueryList = @import("media_query_list.zig").MediaQueryList;
|
||||
const Performance = @import("performance.zig").Performance;
|
||||
|
||||
pub const Interfaces = .{
|
||||
HTMLDocument,
|
||||
@@ -37,5 +36,8 @@ pub const Interfaces = .{
|
||||
History,
|
||||
Location,
|
||||
MediaQueryList,
|
||||
Performance,
|
||||
@import("DataSet.zig"),
|
||||
@import("screen.zig").Interfaces,
|
||||
@import("error_event.zig").ErrorEvent,
|
||||
@import("AbortController.zig").Interfaces,
|
||||
};
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
// 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 parser = @import("../netsurf.zig");
|
||||
const EventTarget = @import("../dom/event_target.zig").EventTarget;
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Performance
|
||||
pub const Performance = struct {
|
||||
pub const prototype = *EventTarget;
|
||||
|
||||
// Extend libdom event target for pure zig struct.
|
||||
base: parser.EventTargetTBase = parser.EventTargetTBase{},
|
||||
|
||||
time_origin: std.time.Timer,
|
||||
// if (Window.crossOriginIsolated) -> Resolution in isolated contexts: 5 microseconds
|
||||
// else -> Resolution in non-isolated contexts: 100 microseconds
|
||||
const ms_resolution = 100;
|
||||
|
||||
fn limitedResolutionMs(nanoseconds: u64) f64 {
|
||||
const elapsed_at_resolution = ((nanoseconds / std.time.ns_per_us) + ms_resolution / 2) / ms_resolution * ms_resolution;
|
||||
const elapsed = @as(f64, @floatFromInt(elapsed_at_resolution));
|
||||
return elapsed / @as(f64, std.time.us_per_ms);
|
||||
}
|
||||
|
||||
pub fn get_timeOrigin(self: *const Performance) f64 {
|
||||
const is_posix = switch (@import("builtin").os.tag) { // From std.time.zig L125
|
||||
.windows, .uefi, .wasi => false,
|
||||
else => true,
|
||||
};
|
||||
const zero = std.time.Instant{ .timestamp = if (!is_posix) 0 else .{ .sec = 0, .nsec = 0 } };
|
||||
const started = self.time_origin.started.since(zero);
|
||||
return limitedResolutionMs(started);
|
||||
}
|
||||
|
||||
pub fn _now(self: *Performance) f64 {
|
||||
return limitedResolutionMs(self.time_origin.read());
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("./../../testing.zig");
|
||||
|
||||
test "Performance: get_timeOrigin" {
|
||||
var perf = Performance{ .time_origin = try std.time.Timer.start() };
|
||||
const time_origin = perf.get_timeOrigin();
|
||||
try testing.expect(time_origin >= 0);
|
||||
|
||||
// Check resolution
|
||||
try testing.expectDelta(@rem(time_origin * std.time.us_per_ms, 100.0), 0.0, 0.1);
|
||||
}
|
||||
|
||||
test "Performance: now" {
|
||||
var perf = Performance{ .time_origin = try std.time.Timer.start() };
|
||||
|
||||
// Monotonically increasing
|
||||
var now = perf._now();
|
||||
while (now <= 0) { // Loop for now to not be 0
|
||||
try testing.expectEqual(now, 0);
|
||||
now = perf._now();
|
||||
}
|
||||
// Check resolution
|
||||
try testing.expectDelta(@rem(now * std.time.us_per_ms, 100.0), 0.0, 0.1);
|
||||
|
||||
var after = perf._now();
|
||||
while (after <= now) { // Loop untill after > now
|
||||
try testing.expectEqual(after, now);
|
||||
after = perf._now();
|
||||
}
|
||||
// Check resolution
|
||||
try testing.expectDelta(@rem(after * std.time.us_per_ms, 100.0), 0.0, 0.1);
|
||||
}
|
||||
109
src/browser/html/screen.zig
Normal file
109
src/browser/html/screen.zig
Normal file
@@ -0,0 +1,109 @@
|
||||
// Copyright (C) 2023-2024 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 EventTarget = @import("../dom/event_target.zig").EventTarget;
|
||||
|
||||
pub const Interfaces = .{
|
||||
Screen,
|
||||
ScreenOrientation,
|
||||
};
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Screen
|
||||
pub const Screen = struct {
|
||||
pub const prototype = *EventTarget;
|
||||
|
||||
height: u32 = 1080,
|
||||
width: u32 = 1920,
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Screen/colorDepth
|
||||
color_depth: u32 = 8,
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Screen/pixelDepth
|
||||
pixel_depth: u32 = 8,
|
||||
orientation: ScreenOrientation = .{ .type = .landscape_primary },
|
||||
|
||||
pub fn get_availHeight(self: *const Screen) u32 {
|
||||
return self.height;
|
||||
}
|
||||
|
||||
pub fn get_availWidth(self: *const Screen) u32 {
|
||||
return self.width;
|
||||
}
|
||||
|
||||
pub fn get_height(self: *const Screen) u32 {
|
||||
return self.height;
|
||||
}
|
||||
|
||||
pub fn get_width(self: *const Screen) u32 {
|
||||
return self.width;
|
||||
}
|
||||
|
||||
pub fn get_pixelDepth(self: *const Screen) u32 {
|
||||
return self.pixel_depth;
|
||||
}
|
||||
|
||||
pub fn get_orientation(self: *const Screen) ScreenOrientation {
|
||||
return self.orientation;
|
||||
}
|
||||
};
|
||||
|
||||
const ScreenOrientationType = enum {
|
||||
portrait_primary,
|
||||
portrait_secondary,
|
||||
landscape_primary,
|
||||
landscape_secondary,
|
||||
|
||||
pub fn toString(self: ScreenOrientationType) []const u8 {
|
||||
return switch (self) {
|
||||
.portrait_primary => "portrait-primary",
|
||||
.portrait_secondary => "portrait-secondary",
|
||||
.landscape_primary => "landscape-primary",
|
||||
.landscape_secondary => "landscape-secondary",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub const ScreenOrientation = struct {
|
||||
pub const prototype = *EventTarget;
|
||||
|
||||
angle: u32 = 0,
|
||||
type: ScreenOrientationType,
|
||||
|
||||
pub fn get_angle(self: *const ScreenOrientation) u32 {
|
||||
return self.angle;
|
||||
}
|
||||
|
||||
pub fn get_type(self: *const ScreenOrientation) []const u8 {
|
||||
return self.type.toString();
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.HTML.Screen" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let screen = window.screen", "undefined" },
|
||||
.{ "screen.width === 1920", "true" },
|
||||
.{ "screen.height === 1080", "true" },
|
||||
.{ "let orientation = screen.orientation", "undefined" },
|
||||
.{ "orientation.angle === 0", "true" },
|
||||
.{ "orientation.type === \"landscape-primary\"", "true" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -31,9 +31,11 @@ const Crypto = @import("../crypto/crypto.zig").Crypto;
|
||||
const Console = @import("../console/console.zig").Console;
|
||||
const EventTarget = @import("../dom/event_target.zig").EventTarget;
|
||||
const MediaQueryList = @import("media_query_list.zig").MediaQueryList;
|
||||
const Performance = @import("performance.zig").Performance;
|
||||
const Performance = @import("../dom/performance.zig").Performance;
|
||||
const CSSStyleDeclaration = @import("../cssom/css_style_declaration.zig").CSSStyleDeclaration;
|
||||
const CustomElementRegistry = @import("../webcomponents/custom_element_registry.zig").CustomElementRegistry;
|
||||
const Screen = @import("screen.zig").Screen;
|
||||
const Css = @import("../css/css.zig").Css;
|
||||
|
||||
const storage = @import("../storage/storage.zig");
|
||||
|
||||
@@ -60,6 +62,8 @@ pub const Window = struct {
|
||||
navigator: Navigator = .{},
|
||||
performance: Performance,
|
||||
custom_elements: CustomElementRegistry = .{},
|
||||
screen: Screen = .{},
|
||||
css: Css = .{},
|
||||
|
||||
pub fn create(target: ?[]const u8, navigator: ?Navigator) !Window {
|
||||
var fbs = std.io.fixedBufferStream("");
|
||||
@@ -169,6 +173,14 @@ pub const Window = struct {
|
||||
return &self.custom_elements;
|
||||
}
|
||||
|
||||
pub fn get_screen(self: *Window) *Screen {
|
||||
return &self.screen;
|
||||
}
|
||||
|
||||
pub fn get_CSS(self: *Window) *Css {
|
||||
return &self.css;
|
||||
}
|
||||
|
||||
pub fn _requestAnimationFrame(self: *Window, cbk: Function, page: *Page) !u32 {
|
||||
return self.createTimeout(cbk, 5, page, .{ .animation_frame = true });
|
||||
}
|
||||
@@ -205,6 +217,21 @@ pub const Window = struct {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn _btoa(_: *const Window, value: []const u8, page: *Page) ![]const u8 {
|
||||
const Encoder = std.base64.standard.Encoder;
|
||||
const out = try page.call_arena.alloc(u8, Encoder.calcSize(value.len));
|
||||
return Encoder.encode(out, value);
|
||||
}
|
||||
|
||||
pub fn _atob(_: *const Window, value: []const u8, page: *Page) ![]const u8 {
|
||||
const Decoder = std.base64.standard.Decoder;
|
||||
const size = Decoder.calcSizeForSlice(value) catch return error.InvalidCharacterError;
|
||||
|
||||
const out = try page.call_arena.alloc(u8, size);
|
||||
Decoder.decode(out, value) catch return error.InvalidCharacterError;
|
||||
return out;
|
||||
}
|
||||
|
||||
const CreateTimeoutOpts = struct {
|
||||
repeat: bool = false,
|
||||
animation_frame: bool = false,
|
||||
@@ -271,9 +298,31 @@ pub const Window = struct {
|
||||
behavior: []const u8,
|
||||
};
|
||||
};
|
||||
pub fn _scrollTo(_: *const Window, opts: ScrollToOpts, y: ?u32) void {
|
||||
pub fn _scrollTo(self: *Window, opts: ScrollToOpts, y: ?u32) !void {
|
||||
_ = opts;
|
||||
_ = y;
|
||||
|
||||
{
|
||||
const scroll_event = try parser.eventCreate();
|
||||
defer parser.eventDestroy(scroll_event);
|
||||
|
||||
try parser.eventInit(scroll_event, "scroll", .{});
|
||||
_ = try parser.eventTargetDispatchEvent(
|
||||
parser.toEventTarget(Window, self),
|
||||
scroll_event,
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const scroll_end = try parser.eventCreate();
|
||||
defer parser.eventDestroy(scroll_end);
|
||||
|
||||
try parser.eventInit(scroll_end, "scrollend", .{});
|
||||
_ = try parser.eventTargetDispatchEvent(
|
||||
parser.toEventTarget(parser.DocumentHTML, self.document),
|
||||
scroll_end,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -387,4 +436,36 @@ test "Browser.HTML.Window" {
|
||||
.{ "window.setTimeout(() => {longCall = true}, 5001);", null },
|
||||
.{ "longCall;", "false" },
|
||||
}, .{});
|
||||
|
||||
// window event target
|
||||
try runner.testCases(&.{
|
||||
.{
|
||||
\\ let called = false;
|
||||
\\ window.addEventListener("ready", (e) => {
|
||||
\\ called = (e.currentTarget == window);
|
||||
\\ }, {capture: false, once: false});
|
||||
\\ const evt = new Event("ready", { bubbles: true, cancelable: false });
|
||||
\\ window.dispatchEvent(evt);
|
||||
\\ called;
|
||||
,
|
||||
"true",
|
||||
},
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "const b64 = btoa('https://ziglang.org/documentation/master/std/#std.base64.Base64Decoder')", "undefined" },
|
||||
.{ "b64", "aHR0cHM6Ly96aWdsYW5nLm9yZy9kb2N1bWVudGF0aW9uL21hc3Rlci9zdGQvI3N0ZC5iYXNlNjQuQmFzZTY0RGVjb2Rlcg==" },
|
||||
.{ "const str = atob(b64)", "undefined" },
|
||||
.{ "str", "https://ziglang.org/documentation/master/std/#std.base64.Base64Decoder" },
|
||||
.{ "try { atob('b') } catch (e) { e } ", "Error: InvalidCharacterError" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let scroll = false; let scrolend = false", null },
|
||||
.{ "window.addEventListener('scroll', () => {scroll = true});", null },
|
||||
.{ "document.addEventListener('scrollend', () => {scrollend = true});", null },
|
||||
.{ "window.scrollTo(0)", null },
|
||||
.{ "scroll", "true" },
|
||||
.{ "scrollend", "true" },
|
||||
}, .{});
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ pub const Mime = struct {
|
||||
text_html,
|
||||
text_javascript,
|
||||
text_plain,
|
||||
text_css,
|
||||
application_json,
|
||||
unknown,
|
||||
other,
|
||||
};
|
||||
@@ -44,6 +46,8 @@ pub const Mime = struct {
|
||||
text_html: void,
|
||||
text_javascript: void,
|
||||
text_plain: void,
|
||||
text_css: void,
|
||||
application_json: void,
|
||||
unknown: void,
|
||||
other: struct { type: []const u8, sub_type: []const u8 },
|
||||
};
|
||||
@@ -174,18 +178,22 @@ pub const Mime = struct {
|
||||
if (std.meta.stringToEnum(enum {
|
||||
@"text/xml",
|
||||
@"text/html",
|
||||
@"text/css",
|
||||
@"text/plain",
|
||||
|
||||
@"text/javascript",
|
||||
@"application/javascript",
|
||||
@"application/x-javascript",
|
||||
|
||||
@"text/plain",
|
||||
@"application/json",
|
||||
}, type_name)) |known_type| {
|
||||
const ct: ContentType = switch (known_type) {
|
||||
.@"text/xml" => .{ .text_xml = {} },
|
||||
.@"text/html" => .{ .text_html = {} },
|
||||
.@"text/javascript", .@"application/javascript", .@"application/x-javascript" => .{ .text_javascript = {} },
|
||||
.@"text/plain" => .{ .text_plain = {} },
|
||||
.@"text/css" => .{ .text_css = {} },
|
||||
.@"application/json" => .{ .application_json = {} },
|
||||
};
|
||||
return .{ ct, attribute_start };
|
||||
}
|
||||
@@ -218,7 +226,9 @@ pub const Mime = struct {
|
||||
|
||||
fn parseAttributeValue(arena: Allocator, value: []const u8) ![]const u8 {
|
||||
if (value[0] != '"') {
|
||||
return value;
|
||||
// almost certainly referenced from an http.Request which has its
|
||||
// own lifetime.
|
||||
return arena.dupe(u8, value);
|
||||
}
|
||||
|
||||
// 1 to skip the opening quote
|
||||
@@ -349,6 +359,9 @@ test "Mime: parse common" {
|
||||
try expect(.{ .content_type = .{ .text_javascript = {} } }, "text/javascript");
|
||||
try expect(.{ .content_type = .{ .text_javascript = {} } }, "Application/JavaScript");
|
||||
try expect(.{ .content_type = .{ .text_javascript = {} } }, "application/x-javascript");
|
||||
|
||||
try expect(.{ .content_type = .{ .application_json = {} } }, "application/json");
|
||||
try expect(.{ .content_type = .{ .text_css = {} } }, "text/css");
|
||||
}
|
||||
|
||||
test "Mime: parse uncommon" {
|
||||
|
||||
@@ -525,6 +525,9 @@ pub const EventType = enum(u8) {
|
||||
progress_event = 1,
|
||||
custom_event = 2,
|
||||
mouse_event = 3,
|
||||
error_event = 4,
|
||||
abort_signal = 5,
|
||||
xhr_event = 6,
|
||||
};
|
||||
|
||||
pub const MutationEvent = c.dom_mutation_event;
|
||||
@@ -775,6 +778,17 @@ pub const EventTargetTBase = extern struct {
|
||||
.add_event_listener = add_event_listener,
|
||||
.iter_event_listener = iter_event_listener,
|
||||
},
|
||||
|
||||
// When we dispatch the event, we need to provide a target. In reality, the
|
||||
// target is the container of this EventTargetTBase. But we can't pass that
|
||||
// to _dom_event_target_dispatch, because it expects a dom_event_target.
|
||||
// If you try to pass an non-event_target, you'll get weird behavior. For
|
||||
// example, libdom might dom_node_ref that memory. Say we passed a *Window
|
||||
// as the target, what happens if libdom calls dom_node_ref(window)? If
|
||||
// you're lucky, you'll crash. If you're unlucky, you'll increment a random
|
||||
// part of the window structure.
|
||||
refcnt: u32 = 0,
|
||||
|
||||
eti: c.dom_event_target_internal = c.dom_event_target_internal{ .listeners = null },
|
||||
|
||||
pub fn add_event_listener(et: [*c]c.dom_event_target, t: [*c]c.dom_string, l: ?*c.struct_dom_event_listener, capture: bool) callconv(.C) c.dom_exception {
|
||||
@@ -1816,6 +1830,21 @@ pub fn anchorSetRel(a: *Anchor, rel: []const u8) !void {
|
||||
try DOMErr(err);
|
||||
}
|
||||
|
||||
// HTMLLinkElement
|
||||
|
||||
pub fn linkGetHref(link: *Link) ![]const u8 {
|
||||
var res: ?*String = undefined;
|
||||
const err = c.dom_html_link_element_get_href(link, &res);
|
||||
try DOMErr(err);
|
||||
if (res == null) return "";
|
||||
return strToData(res.?);
|
||||
}
|
||||
|
||||
pub fn linkSetHref(link: *Link, href: []const u8) !void {
|
||||
const err = c.dom_html_link_element_set_href(link, try strFromData(href));
|
||||
try DOMErr(err);
|
||||
}
|
||||
|
||||
// ElementsHTML
|
||||
|
||||
pub const MediaElement = struct { base: *c.dom_html_element };
|
||||
@@ -2189,12 +2218,12 @@ pub inline fn documentCreateAttributeNS(doc: *Document, ns: []const u8, qname: [
|
||||
return attr.?;
|
||||
}
|
||||
|
||||
pub fn documentSetElementAddedCallback(
|
||||
pub fn documentSetScriptAddedCallback(
|
||||
doc: *Document,
|
||||
ctx: *anyopaque,
|
||||
callback: c.dom_element_added_callback,
|
||||
callback: c.dom_script_added_callback,
|
||||
) void {
|
||||
c._dom_document_set_element_added_callback(doc, ctx, callback);
|
||||
c._dom_document_set_script_added_callback(doc, ctx, callback);
|
||||
}
|
||||
|
||||
// DocumentHTML
|
||||
|
||||
@@ -78,7 +78,10 @@ pub const Page = struct {
|
||||
|
||||
renderer: Renderer,
|
||||
|
||||
// run v8 micro tasks
|
||||
microtask_node: Loop.CallbackNode,
|
||||
// run v8 pump message loop and idle tasks
|
||||
messageloop_node: Loop.CallbackNode,
|
||||
|
||||
keydown_event_node: parser.EventNode,
|
||||
window_clicked_event_node: parser.EventNode,
|
||||
@@ -87,13 +90,6 @@ pub const Page = struct {
|
||||
// execute any JavaScript
|
||||
main_context: *Env.JsContext,
|
||||
|
||||
// List of modules currently fetched/loaded.
|
||||
module_map: std.StringHashMapUnmanaged([]const u8),
|
||||
|
||||
// current_script is the script currently evaluated by the page.
|
||||
// current_script could by fetch module to resolve module's url to fetch.
|
||||
current_script: ?*const Script = null,
|
||||
|
||||
// indicates intention to navigate to another page on the next loop execution.
|
||||
delayed_navigation: bool = false,
|
||||
|
||||
@@ -113,20 +109,24 @@ pub const Page = struct {
|
||||
.state_pool = &browser.state_pool,
|
||||
.cookie_jar = &session.cookie_jar,
|
||||
.microtask_node = .{ .func = microtaskCallback },
|
||||
.messageloop_node = .{ .func = messageLoopCallback },
|
||||
.keydown_event_node = .{ .func = keydownCallback },
|
||||
.window_clicked_event_node = .{ .func = windowClicked },
|
||||
.request_factory = browser.http_client.requestFactory(.{
|
||||
.notification = browser.notification,
|
||||
}),
|
||||
.main_context = undefined,
|
||||
.module_map = .empty,
|
||||
};
|
||||
self.main_context = try session.executor.createJsContext(&self.window, self, self, true);
|
||||
|
||||
// load polyfills
|
||||
try polyfill.load(self.arena, self.main_context);
|
||||
|
||||
_ = try session.browser.app.loop.timeout(1 * std.time.ns_per_ms, &self.microtask_node);
|
||||
// message loop must run only non-test env
|
||||
if (comptime !builtin.is_test) {
|
||||
_ = try session.browser.app.loop.timeout(1 * std.time.ns_per_ms, &self.microtask_node);
|
||||
_ = try session.browser.app.loop.timeout(100 * std.time.ns_per_ms, &self.messageloop_node);
|
||||
}
|
||||
}
|
||||
|
||||
fn microtaskCallback(node: *Loop.CallbackNode, repeat_delay: *?u63) void {
|
||||
@@ -135,6 +135,12 @@ pub const Page = struct {
|
||||
repeat_delay.* = 1 * std.time.ns_per_ms;
|
||||
}
|
||||
|
||||
fn messageLoopCallback(node: *Loop.CallbackNode, repeat_delay: *?u63) void {
|
||||
const self: *Page = @fieldParentPtr("messageloop_node", node);
|
||||
self.session.browser.runMessageLoop();
|
||||
repeat_delay.* = 100 * std.time.ns_per_ms;
|
||||
}
|
||||
|
||||
// dump writes the page content into the given file.
|
||||
pub fn dump(self: *const Page, out: std.fs.File) !void {
|
||||
if (self.raw_data) |raw_data| {
|
||||
@@ -147,29 +153,17 @@ pub const Page = struct {
|
||||
try Dump.writeHTML(doc, out);
|
||||
}
|
||||
|
||||
pub fn fetchModuleSource(ctx: *anyopaque, specifier: []const u8) !?[]const u8 {
|
||||
pub fn fetchModuleSource(ctx: *anyopaque, src: []const u8) !?[]const u8 {
|
||||
const self: *Page = @ptrCast(@alignCast(ctx));
|
||||
const base = if (self.current_script) |s| s.src else null;
|
||||
|
||||
const file_src = blk: {
|
||||
if (base) |_base| {
|
||||
break :blk try URL.stitch(self.arena, specifier, _base, .{});
|
||||
} else break :blk specifier;
|
||||
};
|
||||
|
||||
if (self.module_map.get(file_src)) |module| return module;
|
||||
|
||||
const module = try self.fetchData(specifier, base);
|
||||
if (module) |_module| try self.module_map.putNoClobber(self.arena, file_src, _module);
|
||||
return module;
|
||||
return self.fetchData("module", src);
|
||||
}
|
||||
|
||||
pub fn wait(self: *Page) !void {
|
||||
pub fn wait(self: *Page, wait_ns: usize) !void {
|
||||
var try_catch: Env.TryCatch = undefined;
|
||||
try_catch.init(self.main_context);
|
||||
defer try_catch.deinit();
|
||||
|
||||
try self.session.browser.app.loop.run();
|
||||
try self.session.browser.app.loop.run(wait_ns);
|
||||
|
||||
if (try_catch.hasCaught() == false) {
|
||||
log.debug(.browser, "page wait complete", .{});
|
||||
@@ -217,7 +211,7 @@ pub const Page = struct {
|
||||
{
|
||||
// block exists to limit the lifetime of the request, which holds
|
||||
// onto a connection
|
||||
var request = try self.newHTTPRequest(opts.method, &self.url, .{ .navigation = true });
|
||||
var request = try self.newHTTPRequest(opts.method, &self.url, .{ .navigation = true, .is_http = true });
|
||||
defer request.deinit();
|
||||
|
||||
request.body = opts.body;
|
||||
@@ -258,17 +252,33 @@ pub const Page = struct {
|
||||
.reason = opts.reason,
|
||||
});
|
||||
|
||||
if (!mime.isHTML()) {
|
||||
if (mime.isHTML()) {
|
||||
// the page is an HTML, load it as it.
|
||||
try self.loadHTMLDoc(&response, mime.charset orelse "utf-8");
|
||||
} else {
|
||||
// the page isn't an HTML
|
||||
var arr: std.ArrayListUnmanaged(u8) = .{};
|
||||
while (try response.next()) |data| {
|
||||
try arr.appendSlice(arena, try arena.dupe(u8, data));
|
||||
}
|
||||
// save the body into the page.
|
||||
self.raw_data = arr.items;
|
||||
return;
|
||||
}
|
||||
|
||||
try self.loadHTMLDoc(&response, mime.charset orelse "utf-8");
|
||||
// construct a pseudo HTML containing the response body.
|
||||
var buf: std.ArrayListUnmanaged(u8) = .{};
|
||||
|
||||
switch (mime.content_type) {
|
||||
.application_json, .text_plain, .text_javascript, .text_css => {
|
||||
try buf.appendSlice(arena, "<html><head><meta charset=\"utf-8\"></head><body><pre>");
|
||||
try buf.appendSlice(arena, self.raw_data.?);
|
||||
try buf.appendSlice(arena, "</pre></body></html>\n");
|
||||
},
|
||||
// In other cases, we prefer to not integrate the content into the HTML document page iself.
|
||||
else => {},
|
||||
}
|
||||
var fbs = std.io.fixedBufferStream(buf.items);
|
||||
try self.loadHTMLDoc(fbs.reader(), mime.charset orelse "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
try self.processHTMLDoc();
|
||||
@@ -297,17 +307,16 @@ pub const Page = struct {
|
||||
self.window.setStorageShelf(
|
||||
try self.session.storage_shed.getOrPut(try self.origin(self.arena)),
|
||||
);
|
||||
|
||||
// we want to be notified of any dynamically added script tags
|
||||
// so that we can load the script. Or dynamically added custom elements
|
||||
// for their lifecycle callbacks.
|
||||
parser.documentSetElementAddedCallback(doc, self, elementAddedCallback);
|
||||
}
|
||||
|
||||
fn processHTMLDoc(self: *Page) !void {
|
||||
const html_doc = self.window.document;
|
||||
const doc = parser.documentHTMLToDocument(html_doc);
|
||||
|
||||
// we want to be notified of any dynamically added script tags
|
||||
// so that we can load the script
|
||||
parser.documentSetScriptAddedCallback(doc, self, scriptAddedCallback);
|
||||
|
||||
const document_element = (try parser.documentGetDocumentElement(doc)) orelse return error.DocumentElementError;
|
||||
_ = try parser.eventTargetAddEventListener(
|
||||
parser.toEventTarget(parser.Element, document_element),
|
||||
@@ -461,26 +470,20 @@ pub const Page = struct {
|
||||
log.err(.browser, "clear document script", .{ .err = err });
|
||||
};
|
||||
|
||||
var script_source: ?[]const u8 = null;
|
||||
if (script.src) |src| {
|
||||
self.current_script = script;
|
||||
defer self.current_script = null;
|
||||
|
||||
// https://html.spec.whatwg.org/multipage/webappapis.html#fetch-a-classic-script
|
||||
script_source = (try self.fetchData(src, null)) orelse {
|
||||
// TODO If el's result is null, then fire an event named error at
|
||||
// el, and return
|
||||
return;
|
||||
};
|
||||
} else {
|
||||
const src = script.src orelse {
|
||||
// source is inline
|
||||
// TODO handle charset attribute
|
||||
script_source = try parser.nodeTextContent(parser.elementToNode(script.element));
|
||||
}
|
||||
const script_source = try parser.nodeTextContent(parser.elementToNode(script.element)) orelse return;
|
||||
return script.eval(self, script_source);
|
||||
};
|
||||
|
||||
if (script_source) |ss| {
|
||||
try script.eval(self, ss);
|
||||
}
|
||||
// https://html.spec.whatwg.org/multipage/webappapis.html#fetch-a-classic-script
|
||||
const script_source = (try self.fetchData("script", src)) orelse {
|
||||
// TODO If el's result is null, then fire an event named error at
|
||||
// el, and return
|
||||
return;
|
||||
};
|
||||
return script.eval(self, script_source);
|
||||
|
||||
// TODO If el's from an external file is true, then fire an event
|
||||
// named load at el.
|
||||
@@ -490,7 +493,11 @@ pub const Page = struct {
|
||||
// It resolves src using the page's uri.
|
||||
// If a base path is given, src is resolved according to the base first.
|
||||
// the caller owns the returned string
|
||||
fn fetchData(self: *const Page, src: []const u8, base: ?[]const u8) !?[]const u8 {
|
||||
fn fetchData(
|
||||
self: *const Page,
|
||||
comptime reason: []const u8,
|
||||
src: []const u8,
|
||||
) !?[]const u8 {
|
||||
const arena = self.arena;
|
||||
|
||||
// Handle data URIs.
|
||||
@@ -498,22 +505,27 @@ pub const Page = struct {
|
||||
return data_uri.data;
|
||||
}
|
||||
|
||||
var res_src = src;
|
||||
|
||||
// if a base path is given, we resolve src using base.
|
||||
if (base) |_base| {
|
||||
res_src = try URL.stitch(arena, src, _base, .{ .alloc = .if_needed });
|
||||
}
|
||||
|
||||
var origin_url = &self.url;
|
||||
const url = try origin_url.resolve(arena, res_src);
|
||||
const url = try origin_url.resolve(arena, src);
|
||||
|
||||
log.debug(.http, "fetching script", .{ .url = url });
|
||||
errdefer |err| log.err(.http, "fetch error", .{ .err = err, .url = url });
|
||||
var status_code: u16 = 0;
|
||||
log.debug(.http, "fetching script", .{
|
||||
.url = url,
|
||||
.src = src,
|
||||
.reason = reason,
|
||||
});
|
||||
|
||||
errdefer |err| log.err(.http, "fetch error", .{
|
||||
.err = err,
|
||||
.url = url,
|
||||
.reason = reason,
|
||||
.status = status_code,
|
||||
});
|
||||
|
||||
var request = try self.newHTTPRequest(.GET, &url, .{
|
||||
.origin_uri = &origin_url.uri,
|
||||
.navigation = false,
|
||||
.is_http = true,
|
||||
});
|
||||
defer request.deinit();
|
||||
|
||||
@@ -521,7 +533,8 @@ pub const Page = struct {
|
||||
var header = response.header;
|
||||
try self.session.cookie_jar.populateFromResponse(&url.uri, &header);
|
||||
|
||||
if (header.status < 200 or header.status > 299) {
|
||||
status_code = header.status;
|
||||
if (status_code < 200 or status_code > 299) {
|
||||
return error.BadStatusCode;
|
||||
}
|
||||
|
||||
@@ -539,7 +552,8 @@ pub const Page = struct {
|
||||
|
||||
log.info(.http, "fetch complete", .{
|
||||
.url = url,
|
||||
.status = header.status,
|
||||
.reason = reason,
|
||||
.status = status_code,
|
||||
.content_length = arr.items.len,
|
||||
});
|
||||
return arr.items;
|
||||
@@ -818,43 +832,6 @@ pub const Page = struct {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn elementAdded(self: *Page, element: *parser.Element) !void {
|
||||
if (self.delayed_navigation) {
|
||||
// if we're planning on navigating to another page, we can skip whatever
|
||||
// this is.
|
||||
return;
|
||||
}
|
||||
|
||||
switch (try parser.elementHTMLGetTagType(@as(*parser.ElementHTML, @ptrCast(element)))) {
|
||||
.script => {
|
||||
var script = Script.init(element, self) catch |err| {
|
||||
log.warn(.browser, "script added", .{ .err = err });
|
||||
return;
|
||||
} orelse return;
|
||||
_ = self.evalScript(&script);
|
||||
},
|
||||
.undef => {
|
||||
// a custom element
|
||||
const js_obj = self.main_context.getJsObject(element) orelse return;
|
||||
|
||||
// @memory
|
||||
// getFunction, and more generally Env.Function always create
|
||||
// a Persisted Object. But, in cases like this, we don't need
|
||||
// it to persist.
|
||||
const func = (try js_obj.getFunction("connectedCallback")) orelse return;
|
||||
|
||||
var result: Env.Function.Result = undefined;
|
||||
func.tryCallWithThis(void, js_obj, .{}, &result) catch {
|
||||
log.warn(.user_script, "connected callback", .{
|
||||
.err = result.exception,
|
||||
.stack = result.stack,
|
||||
});
|
||||
};
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const DelayedNavigation = struct {
|
||||
@@ -1038,22 +1015,27 @@ const Script = struct {
|
||||
try_catch.init(page.main_context);
|
||||
defer try_catch.deinit();
|
||||
|
||||
const src = self.src orelse "inline";
|
||||
_ = switch (self.kind) {
|
||||
.javascript => page.main_context.exec(body, src),
|
||||
.module => blk: {
|
||||
switch (try page.main_context.module(body, src)) {
|
||||
.value => |v| break :blk v,
|
||||
.exception => |e| {
|
||||
log.warn(.user_script, "eval module", .{
|
||||
.src = src,
|
||||
.err = try e.exception(page.arena),
|
||||
});
|
||||
return error.JsErr;
|
||||
},
|
||||
}
|
||||
},
|
||||
} catch {
|
||||
const src: []const u8 = blk: {
|
||||
const s = self.src orelse break :blk page.url.raw;
|
||||
break :blk try URL.stitch(page.arena, s, page.url.raw, .{ .alloc = .if_needed });
|
||||
};
|
||||
|
||||
// if self.src is null, then this is an inline script, and it should
|
||||
// not be cached.
|
||||
const cacheable = self.src != null;
|
||||
|
||||
log.debug(.browser, "executing script", .{
|
||||
.src = src,
|
||||
.kind = self.kind,
|
||||
.cacheable = cacheable,
|
||||
});
|
||||
|
||||
const result = switch (self.kind) {
|
||||
.javascript => page.main_context.eval(body, src),
|
||||
.module => page.main_context.module(body, src, cacheable),
|
||||
};
|
||||
|
||||
result catch {
|
||||
if (page.delayed_navigation) {
|
||||
return error.Terminated;
|
||||
}
|
||||
@@ -1062,6 +1044,7 @@ const Script = struct {
|
||||
log.warn(.user_script, "eval script", .{
|
||||
.src = src,
|
||||
.err = msg,
|
||||
.cacheable = cacheable,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1128,22 +1111,24 @@ fn timestamp() u32 {
|
||||
return @intCast(ts.sec);
|
||||
}
|
||||
|
||||
// A callback from libdom whenever an html element is added to the DOM.
|
||||
// Note that "added" could mean that it was removed from one parent and re-added
|
||||
// to another, which is how MOST APIs implement "move" (corrently so).
|
||||
//
|
||||
// The only API which seems to actual "move" is Element.moveBefore, which we
|
||||
// don't currently implement, but should support in general, and should handle
|
||||
// specifically here.
|
||||
// A callback from libdom whenever a script tag is added to the DOM.
|
||||
// element is guaranteed to be a script element.
|
||||
// The script tag might not have a src. It might have any attribute, like
|
||||
// The script tag might not have a src. It might be any attribute, like
|
||||
// `nomodule`, `defer` and `async`. `Script.init` will return null on `nomodule`
|
||||
// so that's handled. And because we're only executing the inline <script> tags
|
||||
// after the document is loaded, it's ok to execute any async and defer scripts
|
||||
// immediately.
|
||||
pub export fn elementAddedCallback(ctx: ?*anyopaque, element: ?*parser.Element) callconv(.C) void {
|
||||
pub export fn scriptAddedCallback(ctx: ?*anyopaque, element: ?*parser.Element) callconv(.C) void {
|
||||
const self: *Page = @alignCast(@ptrCast(ctx.?));
|
||||
self.elementAdded(element.?) catch |err| {
|
||||
log.warn(.browser, "element added callback", .{ .err = err });
|
||||
};
|
||||
if (self.delayed_navigation) {
|
||||
// if we're planning on navigating to another page, don't run this script
|
||||
return;
|
||||
}
|
||||
|
||||
var script = Script.init(element.?, self) catch |err| {
|
||||
log.warn(.browser, "script added init error", .{ .err = err });
|
||||
return;
|
||||
} orelse return;
|
||||
|
||||
_ = self.evalScript(&script);
|
||||
}
|
||||
|
||||
@@ -62,20 +62,38 @@ const FlatRenderer = struct {
|
||||
gop.value_ptr.* = x;
|
||||
}
|
||||
|
||||
const _x: f64 = @floatFromInt(x);
|
||||
const y: f64 = 0.0;
|
||||
const w: f64 = 1.0;
|
||||
const h: f64 = 1.0;
|
||||
|
||||
return .{
|
||||
.x = @floatFromInt(x),
|
||||
.y = 0.0,
|
||||
.width = 1.0,
|
||||
.height = 1.0,
|
||||
.x = _x,
|
||||
.y = y,
|
||||
.width = w,
|
||||
.height = h,
|
||||
.left = _x,
|
||||
.top = y,
|
||||
.right = _x + w,
|
||||
.bottom = y + h,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn boundingRect(self: *const FlatRenderer) Element.DOMRect {
|
||||
const x: f64 = 0.0;
|
||||
const y: f64 = 0.0;
|
||||
const w: f64 = @floatFromInt(self.width());
|
||||
const h: f64 = @floatFromInt(self.width());
|
||||
|
||||
return .{
|
||||
.x = 0.0,
|
||||
.y = 0.0,
|
||||
.width = @floatFromInt(self.width()),
|
||||
.height = @floatFromInt(self.height()),
|
||||
.x = x,
|
||||
.y = y,
|
||||
.width = w,
|
||||
.height = h,
|
||||
.left = x,
|
||||
.top = y,
|
||||
.right = x + w,
|
||||
.bottom = y + h,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ pub const LookupOpts = struct {
|
||||
request_time: ?i64 = null,
|
||||
origin_uri: ?*const Uri = null,
|
||||
navigation: bool = true,
|
||||
is_http: bool,
|
||||
};
|
||||
|
||||
pub const Jar = struct {
|
||||
@@ -32,6 +33,13 @@ pub const Jar = struct {
|
||||
self.cookies.deinit(self.allocator);
|
||||
}
|
||||
|
||||
pub fn clearRetainingCapacity(self: *Jar) void {
|
||||
for (self.cookies.items) |c| {
|
||||
c.deinit();
|
||||
}
|
||||
self.cookies.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
pub fn add(
|
||||
self: *Jar,
|
||||
cookie: Cookie,
|
||||
@@ -59,87 +67,33 @@ pub const Jar = struct {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn removeExpired(self: *Jar, request_time: ?i64) void {
|
||||
if (self.cookies.items.len == 0) return;
|
||||
const time = request_time orelse std.time.timestamp();
|
||||
var i: usize = self.cookies.items.len - 1;
|
||||
while (i > 0) {
|
||||
defer i -= 1;
|
||||
const cookie = &self.cookies.items[i];
|
||||
if (isCookieExpired(cookie, time)) {
|
||||
self.cookies.swapRemove(i).deinit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn forRequest(self: *Jar, target_uri: *const Uri, writer: anytype, opts: LookupOpts) !void {
|
||||
const target_path = target_uri.path.percent_encoded;
|
||||
const target_host = (target_uri.host orelse return error.InvalidURI).percent_encoded;
|
||||
const target = PreparedUri{
|
||||
.host = (target_uri.host orelse return error.InvalidURI).percent_encoded,
|
||||
.path = target_uri.path.percent_encoded,
|
||||
.secure = std.mem.eql(u8, target_uri.scheme, "https"),
|
||||
};
|
||||
const same_site = try areSameSite(opts.origin_uri, target.host);
|
||||
|
||||
const same_site = try areSameSite(opts.origin_uri, target_host);
|
||||
const is_secure = std.mem.eql(u8, target_uri.scheme, "https");
|
||||
|
||||
var i: usize = 0;
|
||||
var cookies = self.cookies.items;
|
||||
const navigation = opts.navigation;
|
||||
const request_time = opts.request_time orelse std.time.timestamp();
|
||||
removeExpired(self, opts.request_time);
|
||||
|
||||
var first = true;
|
||||
while (i < cookies.len) {
|
||||
const cookie = &cookies[i];
|
||||
for (self.cookies.items) |*cookie| {
|
||||
if (!cookie.appliesTo(&target, same_site, opts.navigation, opts.is_http)) continue;
|
||||
|
||||
if (isCookieExpired(cookie, request_time)) {
|
||||
cookie.deinit();
|
||||
_ = self.cookies.swapRemove(i);
|
||||
// don't increment i !
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
|
||||
if (is_secure == false and cookie.secure) {
|
||||
// secure cookie can only be sent over HTTPs
|
||||
continue;
|
||||
}
|
||||
|
||||
if (same_site == false) {
|
||||
// If we aren't on the "same site" (matching 2nd level domain
|
||||
// taking into account public suffix list), then the cookie
|
||||
// can only be sent if cookie.same_site == .none, or if
|
||||
// we're navigating to (as opposed to, say, loading an image)
|
||||
// and cookie.same_site == .lax
|
||||
switch (cookie.same_site) {
|
||||
.strict => continue,
|
||||
.lax => if (navigation == false) continue,
|
||||
.none => {},
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const domain = cookie.domain;
|
||||
if (domain[0] == '.') {
|
||||
// When a Set-Cookie header has a Domain attribute
|
||||
// Then we will _always_ prefix it with a dot, extending its
|
||||
// availability to all subdomains (yes, setting the Domain
|
||||
// attributes EXPANDS the domains which the cookie will be
|
||||
// sent to, to always include all subdomains).
|
||||
if (std.mem.eql(u8, target_host, domain[1..]) == false and std.mem.endsWith(u8, target_host, domain) == false) {
|
||||
continue;
|
||||
}
|
||||
} else if (std.mem.eql(u8, target_host, domain) == false) {
|
||||
// When the Domain attribute isn't specific, then the cookie
|
||||
// is only sent on an exact match.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const path = cookie.path;
|
||||
if (path[path.len - 1] == '/') {
|
||||
// If our cookie has a trailing slash, we can only match is
|
||||
// the target path is a perfix. I.e., if our path is
|
||||
// /doc/ we can only match /doc/*
|
||||
if (std.mem.startsWith(u8, target_path, path) == false) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Our cookie path is something like /hello
|
||||
if (std.mem.startsWith(u8, target_path, path) == false) {
|
||||
// The target path has to either be /hello (it isn't)
|
||||
continue;
|
||||
} else if (target_path.len < path.len or (target_path.len > path.len and target_path[path.len] != '/')) {
|
||||
// Or it has to be something like /hello/* (it isn't)
|
||||
// it isn't!
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// we have a match!
|
||||
if (first) {
|
||||
first = false;
|
||||
@@ -173,47 +127,9 @@ pub const Jar = struct {
|
||||
}
|
||||
};
|
||||
|
||||
pub const CookieList = struct {
|
||||
_cookies: std.ArrayListUnmanaged(*const Cookie) = .{},
|
||||
|
||||
pub fn deinit(self: *CookieList, allocator: Allocator) void {
|
||||
self._cookies.deinit(allocator);
|
||||
}
|
||||
|
||||
pub fn cookies(self: *const CookieList) []*const Cookie {
|
||||
return self._cookies.items;
|
||||
}
|
||||
|
||||
pub fn len(self: *const CookieList) usize {
|
||||
return self._cookies.items.len;
|
||||
}
|
||||
|
||||
pub fn write(self: *const CookieList, writer: anytype) !void {
|
||||
const all = self._cookies.items;
|
||||
if (all.len == 0) {
|
||||
return;
|
||||
}
|
||||
try writeCookie(all[0], writer);
|
||||
for (all[1..]) |cookie| {
|
||||
try writer.writeAll("; ");
|
||||
try writeCookie(cookie, writer);
|
||||
}
|
||||
}
|
||||
|
||||
fn writeCookie(cookie: *const Cookie, writer: anytype) !void {
|
||||
if (cookie.name.len > 0) {
|
||||
try writer.writeAll(cookie.name);
|
||||
try writer.writeByte('=');
|
||||
}
|
||||
if (cookie.value.len > 0) {
|
||||
try writer.writeAll(cookie.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fn isCookieExpired(cookie: *const Cookie, now: i64) bool {
|
||||
const ce = cookie.expires orelse return false;
|
||||
return ce <= now;
|
||||
return ce <= @as(f64, @floatFromInt(now));
|
||||
}
|
||||
|
||||
fn areCookiesEqual(a: *const Cookie, b: *const Cookie) bool {
|
||||
@@ -256,12 +172,12 @@ pub const Cookie = struct {
|
||||
arena: ArenaAllocator,
|
||||
name: []const u8,
|
||||
value: []const u8,
|
||||
path: []const u8,
|
||||
domain: []const u8,
|
||||
expires: ?i64,
|
||||
secure: bool,
|
||||
http_only: bool,
|
||||
same_site: SameSite,
|
||||
path: []const u8,
|
||||
expires: ?f64,
|
||||
secure: bool = false,
|
||||
http_only: bool = false,
|
||||
same_site: SameSite = .none,
|
||||
|
||||
const SameSite = enum {
|
||||
strict,
|
||||
@@ -292,9 +208,6 @@ pub const Cookie = struct {
|
||||
// this check is necessary, `std.mem.minMax` asserts len > 0
|
||||
return error.Empty;
|
||||
}
|
||||
|
||||
const host = (uri.host orelse return error.InvalidURI).percent_encoded;
|
||||
|
||||
{
|
||||
const min, const max = std.mem.minMax(u8, str);
|
||||
if (min < 32 or max > 126) {
|
||||
@@ -313,7 +226,7 @@ pub const Cookie = struct {
|
||||
var secure: ?bool = null;
|
||||
var max_age: ?i64 = null;
|
||||
var http_only: ?bool = null;
|
||||
var expires: ?DateTime = null;
|
||||
var expires: ?[]const u8 = null;
|
||||
var same_site: ?Cookie.SameSite = null;
|
||||
|
||||
var it = std.mem.splitScalar(u8, rest, ';');
|
||||
@@ -339,37 +252,13 @@ pub const Cookie = struct {
|
||||
samesite,
|
||||
}, std.ascii.lowerString(&scrap, key_string)) orelse continue;
|
||||
|
||||
var value = if (sep == attribute.len) "" else trim(attribute[sep + 1 ..]);
|
||||
const value = if (sep == attribute.len) "" else trim(attribute[sep + 1 ..]);
|
||||
switch (key) {
|
||||
.path => {
|
||||
// path attribute value either begins with a '/' or we
|
||||
// ignore it and use the "default-path" algorithm
|
||||
if (value.len > 0 and value[0] == '/') {
|
||||
path = value;
|
||||
}
|
||||
},
|
||||
.domain => {
|
||||
if (value.len == 0) {
|
||||
continue;
|
||||
}
|
||||
if (value[0] == '.') {
|
||||
// leading dot is ignored
|
||||
value = value[1..];
|
||||
}
|
||||
|
||||
if (std.mem.indexOfScalarPos(u8, value, 0, '.') == null and std.ascii.eqlIgnoreCase("localhost", value) == false) {
|
||||
// can't set a cookie for a TLD
|
||||
return error.InvalidDomain;
|
||||
}
|
||||
|
||||
if (std.mem.endsWith(u8, host, value) == false) {
|
||||
return error.InvalidDomain;
|
||||
}
|
||||
domain = value;
|
||||
},
|
||||
.path => path = value,
|
||||
.domain => domain = value,
|
||||
.secure => secure = true,
|
||||
.@"max-age" => max_age = std.fmt.parseInt(i64, value, 10) catch continue,
|
||||
.expires => expires = DateTime.parse(value, .rfc822) catch continue,
|
||||
.expires => expires = value,
|
||||
.httponly => http_only = true,
|
||||
.samesite => {
|
||||
same_site = std.meta.stringToEnum(Cookie.SameSite, std.ascii.lowerString(&scrap, value)) orelse continue;
|
||||
@@ -386,27 +275,33 @@ pub const Cookie = struct {
|
||||
const aa = arena.allocator();
|
||||
const owned_name = try aa.dupe(u8, cookie_name);
|
||||
const owned_value = try aa.dupe(u8, cookie_value);
|
||||
const owned_path = if (path) |p|
|
||||
try aa.dupe(u8, p)
|
||||
else
|
||||
try defaultPath(aa, uri.path.percent_encoded);
|
||||
const owned_path = try parsePath(aa, uri, path);
|
||||
const owned_domain = try parseDomain(aa, uri, domain);
|
||||
|
||||
const owned_domain = if (domain) |d| blk: {
|
||||
const s = try aa.alloc(u8, d.len + 1);
|
||||
s[0] = '.';
|
||||
@memcpy(s[1..], d);
|
||||
break :blk s;
|
||||
} else blk: {
|
||||
break :blk try aa.dupe(u8, host);
|
||||
};
|
||||
|
||||
var normalized_expires: ?i64 = null;
|
||||
var normalized_expires: ?f64 = null;
|
||||
if (max_age) |ma| {
|
||||
normalized_expires = std.time.timestamp() + ma;
|
||||
normalized_expires = @floatFromInt(std.time.timestamp() + ma);
|
||||
} else {
|
||||
// max age takes priority over expires
|
||||
if (expires) |e| {
|
||||
normalized_expires = e.sub(DateTime.now(), .seconds);
|
||||
if (expires) |expires_| {
|
||||
var exp_dt = DateTime.parse(expires_, .rfc822) catch null;
|
||||
if (exp_dt == null) {
|
||||
if ((expires_.len > 11 and expires_[7] == '-' and expires_[11] == '-')) {
|
||||
// Replace dashes and try again
|
||||
const output = try aa.dupe(u8, expires_);
|
||||
output[7] = ' ';
|
||||
output[11] = ' ';
|
||||
exp_dt = DateTime.parse(output, .rfc822) catch null;
|
||||
}
|
||||
}
|
||||
if (exp_dt) |dt| {
|
||||
normalized_expires = @floatFromInt(dt.unix(.seconds));
|
||||
} else {
|
||||
// Algolia, for example, will call document.setCookie with
|
||||
// an expired value which is literally 'Invalid Date'
|
||||
// (it's trying to do something like: `new Date() + undefined`).
|
||||
log.debug(.web_api, "cookie expires date", .{ .date = expires_ });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,6 +318,100 @@ pub const Cookie = struct {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn parsePath(arena: Allocator, uri: ?*const std.Uri, explicit_path: ?[]const u8) ![]const u8 {
|
||||
// path attribute value either begins with a '/' or we
|
||||
// ignore it and use the "default-path" algorithm
|
||||
if (explicit_path) |path| {
|
||||
if (path.len > 0 and path[0] == '/') {
|
||||
return try arena.dupe(u8, path);
|
||||
}
|
||||
}
|
||||
|
||||
// default-path
|
||||
const url_path = (uri orelse return "/").path;
|
||||
|
||||
const either = url_path.percent_encoded;
|
||||
if (either.len == 0 or (either.len == 1 and either[0] == '/')) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
var owned_path: []const u8 = try percentEncode(arena, url_path, isPathChar);
|
||||
const last = std.mem.lastIndexOfScalar(u8, owned_path[1..], '/') orelse {
|
||||
return "/";
|
||||
};
|
||||
return try arena.dupe(u8, owned_path[0 .. last + 1]);
|
||||
}
|
||||
|
||||
pub fn parseDomain(arena: Allocator, uri: ?*const std.Uri, explicit_domain: ?[]const u8) ![]const u8 {
|
||||
var encoded_host: ?[]const u8 = null;
|
||||
if (uri) |uri_| {
|
||||
const uri_host = uri_.host orelse return error.InvalidURI;
|
||||
const host = try percentEncode(arena, uri_host, isHostChar);
|
||||
_ = toLower(host);
|
||||
encoded_host = host;
|
||||
}
|
||||
|
||||
if (explicit_domain) |domain| {
|
||||
if (domain.len > 0) {
|
||||
const no_leading_dot = if (domain[0] == '.') domain[1..] else domain;
|
||||
|
||||
var list = std.ArrayList(u8).init(arena);
|
||||
try list.ensureTotalCapacity(no_leading_dot.len + 1); // Expect no precents needed
|
||||
list.appendAssumeCapacity('.');
|
||||
try std.Uri.Component.percentEncode(list.writer(), no_leading_dot, isHostChar);
|
||||
var owned_domain: []u8 = list.items; // @memory retains memory used before growing
|
||||
_ = toLower(owned_domain);
|
||||
|
||||
if (std.mem.indexOfScalarPos(u8, owned_domain, 1, '.') == null and std.mem.eql(u8, "localhost", owned_domain[1..]) == false) {
|
||||
// can't set a cookie for a TLD
|
||||
return error.InvalidDomain;
|
||||
}
|
||||
if (encoded_host) |host| {
|
||||
if (std.mem.endsWith(u8, host, owned_domain[1..]) == false) {
|
||||
return error.InvalidDomain;
|
||||
}
|
||||
}
|
||||
|
||||
return owned_domain;
|
||||
}
|
||||
}
|
||||
|
||||
return encoded_host orelse return error.InvalidDomain; // default-domain
|
||||
}
|
||||
|
||||
pub fn percentEncode(arena: Allocator, component: std.Uri.Component, comptime isValidChar: fn (u8) bool) ![]u8 {
|
||||
switch (component) {
|
||||
.raw => |str| {
|
||||
var list = std.ArrayList(u8).init(arena);
|
||||
try list.ensureTotalCapacity(str.len); // Expect no precents needed
|
||||
try std.Uri.Component.percentEncode(list.writer(), str, isValidChar);
|
||||
return list.items; // @memory retains memory used before growing
|
||||
},
|
||||
.percent_encoded => |str| {
|
||||
return try arena.dupe(u8, str);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn isHostChar(c: u8) bool {
|
||||
return switch (c) {
|
||||
'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true,
|
||||
'!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => true,
|
||||
':' => true,
|
||||
'[', ']' => true,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn isPathChar(c: u8) bool {
|
||||
return switch (c) {
|
||||
'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true,
|
||||
'!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => true,
|
||||
'/', ':', '@' => true,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
fn parseNameValue(str: []const u8) !struct { []const u8, []const u8, []const u8 } {
|
||||
const key_value_end = std.mem.indexOfScalarPos(u8, str, 0, ';') orelse str.len;
|
||||
const rest = if (key_value_end == str.len) "" else str[key_value_end + 1 ..];
|
||||
@@ -439,17 +428,77 @@ pub const Cookie = struct {
|
||||
const value = trim(str[sep + 1 .. key_value_end]);
|
||||
return .{ name, value, rest };
|
||||
}
|
||||
|
||||
pub fn appliesTo(self: *const Cookie, url: *const PreparedUri, same_site: bool, navigation: bool, is_http: bool) bool {
|
||||
if (self.http_only and is_http == false) {
|
||||
// http only cookies can be accessed from Javascript
|
||||
return false;
|
||||
}
|
||||
|
||||
if (url.secure == false and self.secure) {
|
||||
// secure cookie can only be sent over HTTPs
|
||||
return false;
|
||||
}
|
||||
|
||||
if (same_site == false) {
|
||||
// If we aren't on the "same site" (matching 2nd level domain
|
||||
// taking into account public suffix list), then the cookie
|
||||
// can only be sent if cookie.same_site == .none, or if
|
||||
// we're navigating to (as opposed to, say, loading an image)
|
||||
// and cookie.same_site == .lax
|
||||
switch (self.same_site) {
|
||||
.strict => return false,
|
||||
.lax => if (navigation == false) return false,
|
||||
.none => {},
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
if (self.domain[0] == '.') {
|
||||
// When a Set-Cookie header has a Domain attribute
|
||||
// Then we will _always_ prefix it with a dot, extending its
|
||||
// availability to all subdomains (yes, setting the Domain
|
||||
// attributes EXPANDS the domains which the cookie will be
|
||||
// sent to, to always include all subdomains).
|
||||
if (std.mem.eql(u8, url.host, self.domain[1..]) == false and std.mem.endsWith(u8, url.host, self.domain) == false) {
|
||||
return false;
|
||||
}
|
||||
} else if (std.mem.eql(u8, url.host, self.domain) == false) {
|
||||
// When the Domain attribute isn't specific, then the cookie
|
||||
// is only sent on an exact match.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
if (self.path[self.path.len - 1] == '/') {
|
||||
// If our cookie has a trailing slash, we can only match is
|
||||
// the target path is a perfix. I.e., if our path is
|
||||
// /doc/ we can only match /doc/*
|
||||
if (std.mem.startsWith(u8, url.path, self.path) == false) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// Our cookie path is something like /hello
|
||||
if (std.mem.startsWith(u8, url.path, self.path) == false) {
|
||||
// The target path has to either be /hello (it isn't)
|
||||
return false;
|
||||
} else if (url.path.len < self.path.len or (url.path.len > self.path.len and url.path[self.path.len] != '/')) {
|
||||
// Or it has to be something like /hello/* (it isn't)
|
||||
// it isn't!
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
fn defaultPath(allocator: Allocator, document_path: []const u8) ![]const u8 {
|
||||
if (document_path.len == 0 or (document_path.len == 1 and document_path[0] == '/')) {
|
||||
return "/";
|
||||
}
|
||||
const last = std.mem.lastIndexOfScalar(u8, document_path[1..], '/') orelse {
|
||||
return "/";
|
||||
};
|
||||
return try allocator.dupe(u8, document_path[0 .. last + 1]);
|
||||
}
|
||||
pub const PreparedUri = struct {
|
||||
host: []const u8, // Percent encoded, lower case
|
||||
path: []const u8, // Percent encoded
|
||||
secure: bool, // True if scheme is https
|
||||
};
|
||||
|
||||
fn trim(str: []const u8) []const u8 {
|
||||
return std.mem.trim(u8, str, &std.ascii.whitespace);
|
||||
@@ -463,6 +512,13 @@ fn trimRight(str: []const u8) []const u8 {
|
||||
return std.mem.trimLeft(u8, str, &std.ascii.whitespace);
|
||||
}
|
||||
|
||||
pub fn toLower(str: []u8) []u8 {
|
||||
for (str, 0..) |c, i| {
|
||||
str[i] = std.ascii.toLower(c);
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "cookie: findSecondLevelDomain" {
|
||||
const cases = [_]struct { []const u8, []const u8 }{
|
||||
@@ -548,7 +604,7 @@ test "Jar: forRequest" {
|
||||
|
||||
{
|
||||
// test with no cookies
|
||||
try expectCookies("", &jar, test_uri, .{});
|
||||
try expectCookies("", &jar, test_uri, .{ .is_http = true });
|
||||
}
|
||||
|
||||
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "global1=1"), now);
|
||||
@@ -562,97 +618,114 @@ test "Jar: forRequest" {
|
||||
try jar.add(try Cookie.parse(testing.allocator, &test_uri_2, "domain1=9;domain=test.lightpanda.io"), now);
|
||||
|
||||
// nothing fancy here
|
||||
try expectCookies("global1=1; global2=2", &jar, test_uri, .{});
|
||||
try expectCookies("global1=1; global2=2", &jar, test_uri, .{ .origin_uri = &test_uri, .navigation = false });
|
||||
try expectCookies("global1=1; global2=2", &jar, test_uri, .{ .is_http = true });
|
||||
try expectCookies("global1=1; global2=2", &jar, test_uri, .{ .origin_uri = &test_uri, .navigation = false, .is_http = true });
|
||||
|
||||
// We have a cookie where Domain=lightpanda.io
|
||||
// This should _not_ match xyxlightpanda.io
|
||||
try expectCookies("", &jar, try std.Uri.parse("http://anothersitelightpanda.io/"), .{
|
||||
.origin_uri = &test_uri,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// matching path without trailing /
|
||||
try expectCookies("global1=1; global2=2; path1=3", &jar, try std.Uri.parse("http://lightpanda.io/about"), .{
|
||||
.origin_uri = &test_uri,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// incomplete prefix path
|
||||
try expectCookies("global1=1; global2=2", &jar, try std.Uri.parse("http://lightpanda.io/abou"), .{
|
||||
.origin_uri = &test_uri,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// path doesn't match
|
||||
try expectCookies("global1=1; global2=2", &jar, try std.Uri.parse("http://lightpanda.io/aboutus"), .{
|
||||
.origin_uri = &test_uri,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// path doesn't match cookie directory
|
||||
try expectCookies("global1=1; global2=2", &jar, try std.Uri.parse("http://lightpanda.io/docs"), .{
|
||||
.origin_uri = &test_uri,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// exact directory match
|
||||
try expectCookies("global1=1; global2=2; path2=4", &jar, try std.Uri.parse("http://lightpanda.io/docs/"), .{
|
||||
.origin_uri = &test_uri,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// sub directory match
|
||||
try expectCookies("global1=1; global2=2; path2=4", &jar, try std.Uri.parse("http://lightpanda.io/docs/more"), .{
|
||||
.origin_uri = &test_uri,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// secure
|
||||
try expectCookies("global1=1; global2=2; secure=5", &jar, try std.Uri.parse("https://lightpanda.io/"), .{
|
||||
.origin_uri = &test_uri,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// navigational cross domain, secure
|
||||
try expectCookies("global1=1; global2=2; secure=5; sitenone=6; sitelax=7", &jar, try std.Uri.parse("https://lightpanda.io/x/"), .{
|
||||
.origin_uri = &(try std.Uri.parse("https://example.com/")),
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// navigational cross domain, insecure
|
||||
try expectCookies("global1=1; global2=2; sitelax=7", &jar, try std.Uri.parse("http://lightpanda.io/x/"), .{
|
||||
.origin_uri = &(try std.Uri.parse("https://example.com/")),
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// non-navigational cross domain, insecure
|
||||
try expectCookies("", &jar, try std.Uri.parse("http://lightpanda.io/x/"), .{
|
||||
.origin_uri = &(try std.Uri.parse("https://example.com/")),
|
||||
.navigation = false,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// non-navigational cross domain, secure
|
||||
try expectCookies("sitenone=6", &jar, try std.Uri.parse("https://lightpanda.io/x/"), .{
|
||||
.origin_uri = &(try std.Uri.parse("https://example.com/")),
|
||||
.navigation = false,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// non-navigational same origin
|
||||
try expectCookies("global1=1; global2=2; sitelax=7; sitestrict=8", &jar, try std.Uri.parse("http://lightpanda.io/x/"), .{
|
||||
.origin_uri = &(try std.Uri.parse("https://lightpanda.io/")),
|
||||
.navigation = false,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// exact domain match + suffix
|
||||
try expectCookies("global2=2; domain1=9", &jar, try std.Uri.parse("http://test.lightpanda.io/"), .{
|
||||
.origin_uri = &test_uri,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// domain suffix match + suffix
|
||||
try expectCookies("global2=2; domain1=9", &jar, try std.Uri.parse("http://1.test.lightpanda.io/"), .{
|
||||
.origin_uri = &test_uri,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// non-matching domain
|
||||
try expectCookies("global2=2", &jar, try std.Uri.parse("http://other.lightpanda.io/"), .{
|
||||
.origin_uri = &test_uri,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
const l = jar.cookies.items.len;
|
||||
try expectCookies("global1=1", &jar, test_uri, .{
|
||||
.request_time = now + 100,
|
||||
.origin_uri = &test_uri,
|
||||
.is_http = true,
|
||||
});
|
||||
try testing.expectEqual(l - 1, jar.cookies.items.len);
|
||||
|
||||
@@ -660,40 +733,6 @@ test "Jar: forRequest" {
|
||||
// the 'global2' cookie
|
||||
}
|
||||
|
||||
test "CookieList: write" {
|
||||
var arr: std.ArrayListUnmanaged(u8) = .{};
|
||||
defer arr.deinit(testing.allocator);
|
||||
|
||||
var cookie_list = CookieList{};
|
||||
defer cookie_list.deinit(testing.allocator);
|
||||
|
||||
const c1 = try Cookie.parse(testing.allocator, &test_uri, "cookie_name=cookie_value");
|
||||
defer c1.deinit();
|
||||
{
|
||||
try cookie_list._cookies.append(testing.allocator, &c1);
|
||||
try cookie_list.write(arr.writer(testing.allocator));
|
||||
try testing.expectEqual("cookie_name=cookie_value", arr.items);
|
||||
}
|
||||
|
||||
const c2 = try Cookie.parse(testing.allocator, &test_uri, "x84");
|
||||
defer c2.deinit();
|
||||
{
|
||||
arr.clearRetainingCapacity();
|
||||
try cookie_list._cookies.append(testing.allocator, &c2);
|
||||
try cookie_list.write(arr.writer(testing.allocator));
|
||||
try testing.expectEqual("cookie_name=cookie_value; x84", arr.items);
|
||||
}
|
||||
|
||||
const c3 = try Cookie.parse(testing.allocator, &test_uri, "nope=");
|
||||
defer c3.deinit();
|
||||
{
|
||||
arr.clearRetainingCapacity();
|
||||
try cookie_list._cookies.append(testing.allocator, &c3);
|
||||
try cookie_list.write(arr.writer(testing.allocator));
|
||||
try testing.expectEqual("cookie_name=cookie_value; x84; nope=", arr.items);
|
||||
}
|
||||
}
|
||||
|
||||
test "Cookie: parse key=value" {
|
||||
try expectError(error.Empty, null, "");
|
||||
try expectError(error.InvalidByteSequence, null, &.{ 'a', 30, '=', 'b' });
|
||||
@@ -816,7 +855,8 @@ test "Cookie: parse expires" {
|
||||
try expectAttribute(.{ .expires = null }, null, "b;expires=13.22");
|
||||
try expectAttribute(.{ .expires = null }, null, "b;expires=33");
|
||||
|
||||
try expectAttribute(.{ .expires = 1918798080 - std.time.timestamp() }, null, "b;expires=Wed, 21 Oct 2030 07:28:00 GMT");
|
||||
try expectAttribute(.{ .expires = 1918798080 }, null, "b;expires=Wed, 21 Oct 2030 07:28:00 GMT");
|
||||
try expectAttribute(.{ .expires = 1784275395 }, null, "b;expires=Fri, 17-Jul-2026 08:03:15 GMT");
|
||||
// max-age has priority over expires
|
||||
try expectAttribute(.{ .expires = std.time.timestamp() + 10 }, null, "b;Max-Age=10; expires=Wed, 21 Oct 2030 07:28:00 GMT");
|
||||
}
|
||||
@@ -836,7 +876,7 @@ test "Cookie: parse all" {
|
||||
.http_only = true,
|
||||
.secure = true,
|
||||
.domain = ".lightpanda.io",
|
||||
.expires = std.time.timestamp() + 30,
|
||||
.expires = @floatFromInt(std.time.timestamp() + 30),
|
||||
}, "https://lightpanda.io/cms/users", "user-id=9000; HttpOnly; Max-Age=30; Secure; path=/; Domain=lightpanda.io");
|
||||
|
||||
try expectCookie(.{
|
||||
@@ -847,7 +887,7 @@ test "Cookie: parse all" {
|
||||
.secure = false,
|
||||
.domain = ".localhost",
|
||||
.same_site = .lax,
|
||||
.expires = std.time.timestamp() + 7200,
|
||||
.expires = @floatFromInt(std.time.timestamp() + 7200),
|
||||
}, "http://localhost:8000/login", "app_session=123; Max-Age=7200; path=/; domain=localhost; httponly; samesite=lax");
|
||||
}
|
||||
|
||||
@@ -874,7 +914,7 @@ const ExpectedCookie = struct {
|
||||
value: []const u8,
|
||||
path: []const u8,
|
||||
domain: []const u8,
|
||||
expires: ?i64 = null,
|
||||
expires: ?f64 = null,
|
||||
secure: bool = false,
|
||||
http_only: bool = false,
|
||||
same_site: Cookie.SameSite = .lax,
|
||||
@@ -893,7 +933,7 @@ fn expectCookie(expected: ExpectedCookie, url: []const u8, set_cookie: []const u
|
||||
try testing.expectEqual(expected.path, cookie.path);
|
||||
try testing.expectEqual(expected.domain, cookie.domain);
|
||||
|
||||
try testing.expectDelta(expected.expires, cookie.expires, 2);
|
||||
try testing.expectDelta(expected.expires, cookie.expires, 2.0);
|
||||
}
|
||||
|
||||
fn expectAttribute(expected: anytype, url: ?[]const u8, set_cookie: []const u8) !void {
|
||||
@@ -903,7 +943,10 @@ fn expectAttribute(expected: anytype, url: ?[]const u8, set_cookie: []const u8)
|
||||
|
||||
inline for (@typeInfo(@TypeOf(expected)).@"struct".fields) |f| {
|
||||
if (comptime std.mem.eql(u8, f.name, "expires")) {
|
||||
try testing.expectDelta(expected.expires, cookie.expires, 1);
|
||||
switch (@typeInfo(@TypeOf(expected.expires))) {
|
||||
.int, .comptime_int => try testing.expectDelta(@as(f64, @floatFromInt(expected.expires)), cookie.expires, 1.0),
|
||||
else => try testing.expectDelta(expected.expires, cookie.expires, 1.0),
|
||||
}
|
||||
} else {
|
||||
try testing.expectEqual(@field(expected, f.name), @field(cookie, f.name));
|
||||
}
|
||||
|
||||
@@ -54,6 +54,11 @@ pub const URL = struct {
|
||||
uri: std.Uri,
|
||||
search_params: URLSearchParams,
|
||||
|
||||
pub const empty = URL{
|
||||
.uri = .{ .scheme = "" },
|
||||
.search_params = .{},
|
||||
};
|
||||
|
||||
const URLArg = union(enum) {
|
||||
url: *URL,
|
||||
element: *parser.ElementHTML,
|
||||
@@ -224,6 +229,19 @@ pub const URL = struct {
|
||||
pub fn _toJSON(self: *URL, page: *Page) ![]const u8 {
|
||||
return self.get_href(page);
|
||||
}
|
||||
|
||||
pub fn set_pathname(self: *URL, fragment: []const u8, page: *Page) !void {
|
||||
// pathname must always start with a '/';
|
||||
const real_path = blk: {
|
||||
if (std.mem.startsWith(u8, fragment, "/")) {
|
||||
break :blk try page.arena.dupe(u8, fragment);
|
||||
} else {
|
||||
break :blk try std.fmt.allocPrint(page.arena, "/{s}", .{fragment});
|
||||
}
|
||||
};
|
||||
|
||||
self.uri.path = .{ .percent_encoded = real_path };
|
||||
}
|
||||
};
|
||||
|
||||
// uriComponentNullStr converts an optional std.Uri.Component to string value.
|
||||
|
||||
@@ -23,6 +23,8 @@ const v8 = @import("v8");
|
||||
const Env = @import("../env.zig").Env;
|
||||
const Page = @import("../page.zig").Page;
|
||||
|
||||
const Element = @import("../dom/element.zig").Element;
|
||||
|
||||
pub const CustomElementRegistry = struct {
|
||||
// tag_name -> Function
|
||||
lookup: std.StringHashMapUnmanaged(Env.Function) = .empty,
|
||||
@@ -44,26 +46,6 @@ pub const CustomElementRegistry = struct {
|
||||
pub fn _get(self: *CustomElementRegistry, name: []const u8) ?Env.Function {
|
||||
return self.lookup.get(name);
|
||||
}
|
||||
|
||||
pub fn newInstance(self: *const CustomElementRegistry, tag_name: []const u8) !?Env.JsObject {
|
||||
const func = self.lookup.get(tag_name) orelse return null;
|
||||
|
||||
var result: Env.Function.Result = undefined;
|
||||
const js_obj = func.newInstance(&result) catch |err| {
|
||||
log.fatal(.user_script, "newInstance error", .{
|
||||
.err = result.exception,
|
||||
.stack = result.stack,
|
||||
.tag_name = tag_name,
|
||||
.source = "createElement",
|
||||
});
|
||||
return err;
|
||||
};
|
||||
|
||||
// This is associated with an HTML element, which, at the very least
|
||||
// is going to be libdom node. It will outlive this call, and thus needs
|
||||
// to be persisted.
|
||||
return try js_obj.persist();
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
@@ -81,11 +63,7 @@ test "Browser.CustomElementRegistry" {
|
||||
\\ class MyElement extends HTMLElement {
|
||||
\\ constructor() {
|
||||
\\ super();
|
||||
\\ this.textContent = 'created';
|
||||
\\ }
|
||||
\\
|
||||
\\ connectedCallback() {
|
||||
\\ this.textContent = 'connected';
|
||||
\\ this.textContent = 'Hello World';
|
||||
\\ }
|
||||
\\ }
|
||||
,
|
||||
@@ -102,9 +80,7 @@ test "Browser.CustomElementRegistry" {
|
||||
.{ "el instanceof MyElement", "true" },
|
||||
.{ "el instanceof HTMLElement", "true" },
|
||||
.{ "el.tagName", "MY-ELEMENT" },
|
||||
.{ "el.textContent", "created" },
|
||||
.{ "document.getElementsByTagName('body')[0].append(el)", null },
|
||||
.{ "el.textContent", "connected" },
|
||||
.{ "el.textContent", "Hello World" },
|
||||
|
||||
// Create element via HTML parsing
|
||||
// .{ "document.body.innerHTML = '<my-element></my-element>'", "undefined" },
|
||||
|
||||
@@ -39,6 +39,7 @@ pub const XMLHttpRequestEventTarget = struct {
|
||||
onload_cbk: ?Function = null,
|
||||
ontimeout_cbk: ?Function = null,
|
||||
onloadend_cbk: ?Function = null,
|
||||
onreadystatechange_cbk: ?Function = null,
|
||||
|
||||
fn register(
|
||||
self: *XMLHttpRequestEventTarget,
|
||||
@@ -86,6 +87,9 @@ pub const XMLHttpRequestEventTarget = struct {
|
||||
pub fn get_onloadend(self: *XMLHttpRequestEventTarget) ?Function {
|
||||
return self.onloadend_cbk;
|
||||
}
|
||||
pub fn get_onreadystatechange(self: *XMLHttpRequestEventTarget) ?Function {
|
||||
return self.onreadystatechange_cbk;
|
||||
}
|
||||
|
||||
pub fn set_onloadstart(self: *XMLHttpRequestEventTarget, listener: EventHandler.Listener, page: *Page) !void {
|
||||
if (self.onloadstart_cbk) |cbk| try self.unregister("loadstart", cbk.id);
|
||||
@@ -111,4 +115,8 @@ pub const XMLHttpRequestEventTarget = struct {
|
||||
if (self.onloadend_cbk) |cbk| try self.unregister("loadend", cbk.id);
|
||||
self.onloadend_cbk = try self.register(page.arena, "loadend", listener);
|
||||
}
|
||||
pub fn set_onreadystatechange(self: *XMLHttpRequestEventTarget, listener: EventHandler.Listener, page: *Page) !void {
|
||||
if (self.onreadystatechange_cbk) |cbk| try self.unregister("readystatechange", cbk.id);
|
||||
self.onreadystatechange_cbk = try self.register(page.arena, "readystatechange", listener);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -37,10 +37,10 @@ pub const ProgressEvent = struct {
|
||||
loaded: u64 = 0,
|
||||
total: u64 = 0,
|
||||
|
||||
pub fn constructor(eventType: []const u8, opts: ?EventInit) !ProgressEvent {
|
||||
pub fn constructor(event_type: []const u8, opts: ?EventInit) !ProgressEvent {
|
||||
const event = try parser.eventCreate();
|
||||
defer parser.eventDestroy(event);
|
||||
try parser.eventInit(event, eventType, .{});
|
||||
try parser.eventInit(event, event_type, .{});
|
||||
try parser.eventSetInternalType(event, .progress_event);
|
||||
|
||||
const o = opts orelse EventInit{};
|
||||
|
||||
@@ -138,6 +138,13 @@ pub const XMLHttpRequest = struct {
|
||||
done = 4,
|
||||
};
|
||||
|
||||
// class attributes
|
||||
pub const _UNSENT = @intFromEnum(State.unsent);
|
||||
pub const _OPENED = @intFromEnum(State.opened);
|
||||
pub const _HEADERS_RECEIVED = @intFromEnum(State.headers_received);
|
||||
pub const _LOADING = @intFromEnum(State.loading);
|
||||
pub const _DONE = @intFromEnum(State.done);
|
||||
|
||||
// https://xhr.spec.whatwg.org/#response-type
|
||||
const ResponseType = enum {
|
||||
Empty,
|
||||
@@ -360,6 +367,8 @@ pub const XMLHttpRequest = struct {
|
||||
// We can we defer event destroy once the event is dispatched.
|
||||
defer parser.eventDestroy(evt);
|
||||
|
||||
try parser.eventSetInternalType(evt, .xhr_event);
|
||||
|
||||
try parser.eventInit(evt, typ, .{ .bubbles = true, .cancelable = true });
|
||||
_ = try parser.eventTargetDispatchEvent(@as(*parser.EventTarget, @ptrCast(self)), evt);
|
||||
}
|
||||
@@ -458,7 +467,6 @@ pub const XMLHttpRequest = struct {
|
||||
&self.url.?.uri,
|
||||
self,
|
||||
onHttpRequestReady,
|
||||
self.loop,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -475,6 +483,7 @@ pub const XMLHttpRequest = struct {
|
||||
try self.cookie_jar.forRequest(&self.url.?.uri, arr.writer(self.arena), .{
|
||||
.navigation = false,
|
||||
.origin_uri = &self.origin_url.uri,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
if (arr.items.len > 0) {
|
||||
@@ -493,7 +502,7 @@ pub const XMLHttpRequest = struct {
|
||||
}
|
||||
}
|
||||
|
||||
try request.sendAsync(self.loop, self, .{});
|
||||
try request.sendAsync(self, .{});
|
||||
self.request = request;
|
||||
}
|
||||
|
||||
@@ -579,11 +588,27 @@ pub const XMLHttpRequest = struct {
|
||||
}
|
||||
|
||||
fn onErr(self: *XMLHttpRequest, err: anyerror) void {
|
||||
self.state = .done;
|
||||
self.send_flag = false;
|
||||
self.dispatchEvt("readystatechange");
|
||||
self.dispatchProgressEvent("error", .{});
|
||||
self.dispatchProgressEvent("loadend", .{});
|
||||
|
||||
// capture the state before we change it
|
||||
const s = self.state;
|
||||
|
||||
const is_abort = err == DOMError.Abort;
|
||||
|
||||
if (is_abort) {
|
||||
self.state = .unsent;
|
||||
} else {
|
||||
self.state = .done;
|
||||
self.dispatchEvt("error");
|
||||
}
|
||||
|
||||
if (s != .done or s != .unsent) {
|
||||
self.dispatchEvt("readystatechange");
|
||||
if (is_abort) {
|
||||
self.dispatchProgressEvent("abort", .{});
|
||||
}
|
||||
self.dispatchProgressEvent("loadend", .{});
|
||||
}
|
||||
|
||||
const level: log.Level = if (err == DOMError.Abort) .debug else .err;
|
||||
log.log(.http, level, "error", .{
|
||||
@@ -922,4 +947,26 @@ test "Browser.XHR.XMLHttpRequest" {
|
||||
// So the url has been retrieved.
|
||||
.{ "status", "200" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "const req6 = new XMLHttpRequest()", null },
|
||||
.{
|
||||
\\ var readyStates = [];
|
||||
\\ var currentTarget = null;
|
||||
\\ req6.onreadystatechange = (e) => {
|
||||
\\ currentTarget = e.currentTarget;
|
||||
\\ readyStates.push(req6.readyState);
|
||||
\\ }
|
||||
,
|
||||
null,
|
||||
},
|
||||
.{ "req6.open('GET', 'https://127.0.0.1:9581/xhr')", null },
|
||||
.{ "req6.send()", null },
|
||||
.{ "readyStates.length", "4" },
|
||||
.{ "readyStates[0] === XMLHttpRequest.OPENED", "true" },
|
||||
.{ "readyStates[1] === XMLHttpRequest.HEADERS_RECEIVED", "true" },
|
||||
.{ "readyStates[2] === XMLHttpRequest.LOADING", "true" },
|
||||
.{ "readyStates[3] === XMLHttpRequest.DONE", "true" },
|
||||
.{ "currentTarget == req6", "true" },
|
||||
}, .{});
|
||||
}
|
||||
|
||||
@@ -203,6 +203,7 @@ pub fn CDPT(comptime TypeProvider: type) type {
|
||||
asUint(u56, "Browser") => return @import("domains/browser.zig").processMessage(command),
|
||||
asUint(u56, "Runtime") => return @import("domains/runtime.zig").processMessage(command),
|
||||
asUint(u56, "Network") => return @import("domains/network.zig").processMessage(command),
|
||||
asUint(u56, "Storage") => return @import("domains/storage.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
8 => switch (@as(u64, @bitCast(domain[0..8].*))) {
|
||||
@@ -319,6 +320,7 @@ pub fn BrowserContext(comptime CDP_T: type) type {
|
||||
|
||||
inspector: Inspector,
|
||||
isolated_world: ?IsolatedWorld,
|
||||
http_proxy_before: ??std.Uri = null,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
@@ -373,6 +375,8 @@ pub fn BrowserContext(comptime CDP_T: type) type {
|
||||
self.node_registry.deinit();
|
||||
self.node_search_list.deinit();
|
||||
self.cdp.browser.notification.unregisterAll(self);
|
||||
|
||||
if (self.http_proxy_before) |prev_proxy| self.cdp.browser.http_client.http_proxy = prev_proxy;
|
||||
}
|
||||
|
||||
pub fn reset(self: *Self) void {
|
||||
|
||||
@@ -17,10 +17,11 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const Notification = @import("../../notification.zig").Notification;
|
||||
const log = @import("../../log.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
const CdpStorage = @import("storage.zig");
|
||||
|
||||
pub fn processMessage(cmd: anytype) !void {
|
||||
const action = std.meta.stringToEnum(enum {
|
||||
@@ -28,6 +29,11 @@ pub fn processMessage(cmd: anytype) !void {
|
||||
disable,
|
||||
setCacheDisabled,
|
||||
setExtraHTTPHeaders,
|
||||
deleteCookies,
|
||||
clearBrowserCookies,
|
||||
setCookie,
|
||||
setCookies,
|
||||
getCookies,
|
||||
}, cmd.input.action) orelse return error.UnknownMethod;
|
||||
|
||||
switch (action) {
|
||||
@@ -35,6 +41,11 @@ pub fn processMessage(cmd: anytype) !void {
|
||||
.disable => return disable(cmd),
|
||||
.setCacheDisabled => return cmd.sendResult(null, .{}),
|
||||
.setExtraHTTPHeaders => return setExtraHTTPHeaders(cmd),
|
||||
.deleteCookies => return deleteCookies(cmd),
|
||||
.clearBrowserCookies => return clearBrowserCookies(cmd),
|
||||
.setCookie => return setCookie(cmd),
|
||||
.setCookies => return setCookies(cmd),
|
||||
.getCookies => return getCookies(cmd),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +82,112 @@ fn setExtraHTTPHeaders(cmd: anytype) !void {
|
||||
return cmd.sendResult(null, .{});
|
||||
}
|
||||
|
||||
const Cookie = @import("../../browser/storage/storage.zig").Cookie;
|
||||
|
||||
// Only matches the cookie on provided parameters
|
||||
fn cookieMatches(cookie: *const Cookie, name: []const u8, domain: ?[]const u8, path: ?[]const u8) bool {
|
||||
if (!std.mem.eql(u8, cookie.name, name)) return false;
|
||||
|
||||
if (domain) |domain_| {
|
||||
const c_no_dot = if (std.mem.startsWith(u8, cookie.domain, ".")) cookie.domain[1..] else cookie.domain;
|
||||
const d_no_dot = if (std.mem.startsWith(u8, domain_, ".")) domain_[1..] else domain_;
|
||||
if (!std.mem.eql(u8, c_no_dot, d_no_dot)) return false;
|
||||
}
|
||||
if (path) |path_| {
|
||||
if (!std.mem.eql(u8, cookie.path, path_)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
fn deleteCookies(cmd: anytype) !void {
|
||||
const params = (try cmd.params(struct {
|
||||
name: []const u8,
|
||||
url: ?[]const u8 = null,
|
||||
domain: ?[]const u8 = null,
|
||||
path: ?[]const u8 = null,
|
||||
partitionKey: ?CdpStorage.CookiePartitionKey = null,
|
||||
})) orelse return error.InvalidParams;
|
||||
if (params.partitionKey != null) return error.NotYetImplementedParams;
|
||||
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
const cookies = &bc.session.cookie_jar.cookies;
|
||||
|
||||
const uri = if (params.url) |url| std.Uri.parse(url) catch return error.InvalidParams else null;
|
||||
const uri_ptr = if (uri) |u| &u else null;
|
||||
|
||||
var index = cookies.items.len;
|
||||
while (index > 0) {
|
||||
index -= 1;
|
||||
const cookie = &cookies.items[index];
|
||||
const domain = try Cookie.parseDomain(cmd.arena, uri_ptr, params.domain);
|
||||
const path = try Cookie.parsePath(cmd.arena, uri_ptr, params.path);
|
||||
|
||||
// We do not want to use Cookie.appliesTo here. As a Cookie with a shorter path would match.
|
||||
// Similar to deduplicating with areCookiesEqual, except domain and path are optional.
|
||||
if (cookieMatches(cookie, params.name, domain, path)) {
|
||||
cookies.swapRemove(index).deinit();
|
||||
}
|
||||
}
|
||||
return cmd.sendResult(null, .{});
|
||||
}
|
||||
|
||||
fn clearBrowserCookies(cmd: anytype) !void {
|
||||
if (try cmd.params(struct {}) != null) return error.InvalidParams;
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
bc.session.cookie_jar.clearRetainingCapacity();
|
||||
return cmd.sendResult(null, .{});
|
||||
}
|
||||
|
||||
fn setCookie(cmd: anytype) !void {
|
||||
const params = (try cmd.params(
|
||||
CdpStorage.CdpCookie,
|
||||
)) orelse return error.InvalidParams;
|
||||
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
try CdpStorage.setCdpCookie(&bc.session.cookie_jar, params);
|
||||
|
||||
try cmd.sendResult(.{ .success = true }, .{});
|
||||
}
|
||||
|
||||
fn setCookies(cmd: anytype) !void {
|
||||
const params = (try cmd.params(struct {
|
||||
cookies: []const CdpStorage.CdpCookie,
|
||||
})) orelse return error.InvalidParams;
|
||||
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
for (params.cookies) |param| {
|
||||
try CdpStorage.setCdpCookie(&bc.session.cookie_jar, param);
|
||||
}
|
||||
|
||||
try cmd.sendResult(null, .{});
|
||||
}
|
||||
|
||||
const GetCookiesParam = struct { urls: ?[]const []const u8 = null };
|
||||
fn getCookies(cmd: anytype) !void {
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
const params = (try cmd.params(GetCookiesParam)) orelse GetCookiesParam{};
|
||||
|
||||
// If not specified, use the URLs of the page and all of its subframes. TODO subframes
|
||||
const page_url = if (bc.session.page) |*page| page.url.raw else null; // @speed: avoid repasing the URL
|
||||
const param_urls = params.urls orelse &[_][]const u8{page_url orelse return error.InvalidParams};
|
||||
|
||||
var urls = try std.ArrayListUnmanaged(CdpStorage.PreparedUri).initCapacity(cmd.arena, param_urls.len);
|
||||
for (param_urls) |url| {
|
||||
const uri = std.Uri.parse(url) catch return error.InvalidParams;
|
||||
|
||||
urls.appendAssumeCapacity(.{
|
||||
.host = try Cookie.parseDomain(cmd.arena, &uri, null),
|
||||
.path = try Cookie.parsePath(cmd.arena, &uri, null),
|
||||
.secure = std.mem.eql(u8, uri.scheme, "https"),
|
||||
});
|
||||
}
|
||||
|
||||
var jar = &bc.session.cookie_jar;
|
||||
jar.removeExpired(null);
|
||||
const writer = CdpStorage.CookieWriter{ .cookies = jar.cookies.items, .urls = urls.items };
|
||||
try cmd.sendResult(.{ .cookies = writer }, .{});
|
||||
}
|
||||
|
||||
// Upsert a header into the headers array.
|
||||
// returns true if the header was added, false if it was updated
|
||||
fn putAssumeCapacity(headers: *std.ArrayListUnmanaged(std.http.Header), extra: std.http.Header) bool {
|
||||
@@ -235,3 +352,77 @@ test "cdp.network setExtraHTTPHeaders" {
|
||||
try ctx.processMessage(.{ .id = 5, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } });
|
||||
try testing.expectEqual(bc.cdp.extra_headers.items.len, 0);
|
||||
}
|
||||
|
||||
test "cdp.Network: cookies" {
|
||||
const ResCookie = CdpStorage.ResCookie;
|
||||
const CdpCookie = CdpStorage.CdpCookie;
|
||||
|
||||
var ctx = testing.context();
|
||||
defer ctx.deinit();
|
||||
_ = try ctx.loadBrowserContext(.{ .id = "BID-S" });
|
||||
|
||||
// Initially empty
|
||||
try ctx.processMessage(.{
|
||||
.id = 3,
|
||||
.method = "Network.getCookies",
|
||||
.params = .{ .urls = &[_][]const u8{"https://example.com/pancakes"} },
|
||||
});
|
||||
try ctx.expectSentResult(.{ .cookies = &[_]ResCookie{} }, .{ .id = 3 });
|
||||
|
||||
// Has cookies after setting them
|
||||
try ctx.processMessage(.{
|
||||
.id = 4,
|
||||
.method = "Network.setCookie",
|
||||
.params = CdpCookie{ .name = "test3", .value = "valuenot3", .url = "https://car.example.com/defnotpancakes" },
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 4 });
|
||||
try ctx.processMessage(.{
|
||||
.id = 5,
|
||||
.method = "Network.setCookies",
|
||||
.params = .{
|
||||
.cookies = &[_]CdpCookie{
|
||||
.{ .name = "test3", .value = "value3", .url = "https://car.example.com/pan/cakes" },
|
||||
.{ .name = "test4", .value = "value4", .domain = "example.com", .path = "/mango" },
|
||||
},
|
||||
},
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 5 });
|
||||
try ctx.processMessage(.{
|
||||
.id = 6,
|
||||
.method = "Network.getCookies",
|
||||
.params = .{ .urls = &[_][]const u8{"https://car.example.com/pan/cakes"} },
|
||||
});
|
||||
try ctx.expectSentResult(.{
|
||||
.cookies = &[_]ResCookie{
|
||||
.{ .name = "test3", .value = "value3", .domain = "car.example.com", .path = "/", .secure = true }, // No Pancakes!
|
||||
},
|
||||
}, .{ .id = 6 });
|
||||
|
||||
// deleteCookies
|
||||
try ctx.processMessage(.{
|
||||
.id = 7,
|
||||
.method = "Network.deleteCookies",
|
||||
.params = .{ .name = "test3", .domain = "car.example.com" },
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 7 });
|
||||
try ctx.processMessage(.{
|
||||
.id = 8,
|
||||
.method = "Storage.getCookies",
|
||||
.params = .{ .browserContextId = "BID-S" },
|
||||
});
|
||||
// Just the untouched test4 should be in the result
|
||||
try ctx.expectSentResult(.{ .cookies = &[_]ResCookie{.{ .name = "test4", .value = "value4", .domain = ".example.com", .path = "/mango" }} }, .{ .id = 8 });
|
||||
|
||||
// Empty after clearBrowserCookies
|
||||
try ctx.processMessage(.{
|
||||
.id = 9,
|
||||
.method = "Network.clearBrowserCookies",
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 9 });
|
||||
try ctx.processMessage(.{
|
||||
.id = 10,
|
||||
.method = "Storage.getCookies",
|
||||
.params = .{ .browserContextId = "BID-S" },
|
||||
});
|
||||
try ctx.expectSentResult(.{ .cookies = &[_]ResCookie{} }, .{ .id = 10 });
|
||||
}
|
||||
|
||||
301
src/cdp/domains/storage.zig
Normal file
301
src/cdp/domains/storage.zig
Normal file
@@ -0,0 +1,301 @@
|
||||
// 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 Allocator = std.mem.Allocator;
|
||||
|
||||
const log = @import("../../log.zig");
|
||||
const Cookie = @import("../../browser/storage/storage.zig").Cookie;
|
||||
const CookieJar = @import("../../browser/storage/storage.zig").CookieJar;
|
||||
pub const PreparedUri = @import("../../browser/storage/cookie.zig").PreparedUri;
|
||||
|
||||
pub fn processMessage(cmd: anytype) !void {
|
||||
const action = std.meta.stringToEnum(enum {
|
||||
clearCookies,
|
||||
setCookies,
|
||||
getCookies,
|
||||
}, cmd.input.action) orelse return error.UnknownMethod;
|
||||
|
||||
switch (action) {
|
||||
.clearCookies => return clearCookies(cmd),
|
||||
.getCookies => return getCookies(cmd),
|
||||
.setCookies => return setCookies(cmd),
|
||||
}
|
||||
}
|
||||
|
||||
const BrowserContextParam = struct { browserContextId: ?[]const u8 = null };
|
||||
|
||||
fn clearCookies(cmd: anytype) !void {
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
const params = (try cmd.params(BrowserContextParam)) orelse BrowserContextParam{};
|
||||
|
||||
if (params.browserContextId) |browser_context_id| {
|
||||
if (std.mem.eql(u8, browser_context_id, bc.id) == false) {
|
||||
return error.UnknownBrowserContextId;
|
||||
}
|
||||
}
|
||||
|
||||
bc.session.cookie_jar.clearRetainingCapacity();
|
||||
|
||||
return cmd.sendResult(null, .{});
|
||||
}
|
||||
|
||||
fn getCookies(cmd: anytype) !void {
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
const params = (try cmd.params(BrowserContextParam)) orelse BrowserContextParam{};
|
||||
|
||||
if (params.browserContextId) |browser_context_id| {
|
||||
if (std.mem.eql(u8, browser_context_id, bc.id) == false) {
|
||||
return error.UnknownBrowserContextId;
|
||||
}
|
||||
}
|
||||
bc.session.cookie_jar.removeExpired(null);
|
||||
const writer = CookieWriter{ .cookies = bc.session.cookie_jar.cookies.items };
|
||||
try cmd.sendResult(.{ .cookies = writer }, .{});
|
||||
}
|
||||
|
||||
fn setCookies(cmd: anytype) !void {
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
const params = (try cmd.params(struct {
|
||||
cookies: []const CdpCookie,
|
||||
browserContextId: ?[]const u8 = null,
|
||||
})) orelse return error.InvalidParams;
|
||||
|
||||
if (params.browserContextId) |browser_context_id| {
|
||||
if (std.mem.eql(u8, browser_context_id, bc.id) == false) {
|
||||
return error.UnknownBrowserContextId;
|
||||
}
|
||||
}
|
||||
|
||||
for (params.cookies) |param| {
|
||||
try setCdpCookie(&bc.session.cookie_jar, param);
|
||||
}
|
||||
|
||||
try cmd.sendResult(null, .{});
|
||||
}
|
||||
|
||||
pub const SameSite = enum {
|
||||
Strict,
|
||||
Lax,
|
||||
None,
|
||||
};
|
||||
pub const CookiePriority = enum {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
};
|
||||
pub const CookieSourceScheme = enum {
|
||||
Unset,
|
||||
NonSecure,
|
||||
Secure,
|
||||
};
|
||||
|
||||
pub const CookiePartitionKey = struct {
|
||||
topLevelSite: []const u8,
|
||||
hasCrossSiteAncestor: bool,
|
||||
};
|
||||
|
||||
pub const CdpCookie = struct {
|
||||
name: []const u8,
|
||||
value: []const u8,
|
||||
url: ?[]const u8 = null,
|
||||
domain: ?[]const u8 = null,
|
||||
path: ?[]const u8 = null,
|
||||
secure: ?bool = null, // default: https://www.rfc-editor.org/rfc/rfc6265#section-5.3
|
||||
httpOnly: bool = false, // default: https://www.rfc-editor.org/rfc/rfc6265#section-5.3
|
||||
sameSite: SameSite = .None, // default: https://datatracker.ietf.org/doc/html/draft-west-first-party-cookies
|
||||
expires: ?f64 = null, // -1? says google
|
||||
priority: CookiePriority = .Medium, // default: https://datatracker.ietf.org/doc/html/draft-west-cookie-priority-00
|
||||
sameParty: ?bool = null,
|
||||
sourceScheme: ?CookieSourceScheme = null,
|
||||
// sourcePort: Temporary ability and it will be removed from CDP
|
||||
partitionKey: ?CookiePartitionKey = null,
|
||||
};
|
||||
|
||||
pub fn setCdpCookie(cookie_jar: *CookieJar, param: CdpCookie) !void {
|
||||
if (param.priority != .Medium or param.sameParty != null or param.sourceScheme != null or param.partitionKey != null) {
|
||||
return error.NotYetImplementedParams;
|
||||
}
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(cookie_jar.allocator);
|
||||
errdefer arena.deinit();
|
||||
const a = arena.allocator();
|
||||
|
||||
// NOTE: The param.url can affect the default domain, (NOT path), secure, source port, and source scheme.
|
||||
const uri = if (param.url) |url| std.Uri.parse(url) catch return error.InvalidParams else null;
|
||||
const uri_ptr = if (uri) |*u| u else null;
|
||||
const domain = try Cookie.parseDomain(a, uri_ptr, param.domain);
|
||||
const path = if (param.path == null) "/" else try Cookie.parsePath(a, null, param.path);
|
||||
|
||||
const secure = if (param.secure) |s| s else if (uri) |uri_| std.mem.eql(u8, uri_.scheme, "https") else false;
|
||||
|
||||
const cookie = Cookie{
|
||||
.arena = arena,
|
||||
.name = try a.dupe(u8, param.name),
|
||||
.value = try a.dupe(u8, param.value),
|
||||
.path = path,
|
||||
.domain = domain,
|
||||
.expires = param.expires,
|
||||
.secure = secure,
|
||||
.http_only = param.httpOnly,
|
||||
.same_site = switch (param.sameSite) {
|
||||
.Strict => .strict,
|
||||
.Lax => .lax,
|
||||
.None => .none,
|
||||
},
|
||||
};
|
||||
try cookie_jar.add(cookie, std.time.timestamp());
|
||||
}
|
||||
|
||||
pub const CookieWriter = struct {
|
||||
cookies: []const Cookie,
|
||||
urls: ?[]const PreparedUri = null,
|
||||
|
||||
pub fn jsonStringify(self: *const CookieWriter, w: anytype) !void {
|
||||
self.writeCookies(w) catch |err| {
|
||||
// The only error our jsonStringify method can return is @TypeOf(w).Error.
|
||||
log.err(.cdp, "json stringify", .{ .err = err });
|
||||
return error.OutOfMemory;
|
||||
};
|
||||
}
|
||||
|
||||
fn writeCookies(self: CookieWriter, w: anytype) !void {
|
||||
try w.beginArray();
|
||||
if (self.urls) |urls| {
|
||||
for (self.cookies) |*cookie| {
|
||||
for (urls) |*url| {
|
||||
if (cookie.appliesTo(url, true, true, true)) { // TBD same_site, should we compare to the pages url?
|
||||
try writeCookie(cookie, w);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (self.cookies) |*cookie| {
|
||||
try writeCookie(cookie, w);
|
||||
}
|
||||
}
|
||||
try w.endArray();
|
||||
}
|
||||
};
|
||||
pub fn writeCookie(cookie: *const Cookie, w: anytype) !void {
|
||||
try w.beginObject();
|
||||
{
|
||||
try w.objectField("name");
|
||||
try w.write(cookie.name);
|
||||
|
||||
try w.objectField("value");
|
||||
try w.write(cookie.value);
|
||||
|
||||
try w.objectField("domain");
|
||||
try w.write(cookie.domain); // Should we hide a leading dot?
|
||||
|
||||
try w.objectField("path");
|
||||
try w.write(cookie.path);
|
||||
|
||||
try w.objectField("expires");
|
||||
try w.write(cookie.expires orelse -1);
|
||||
|
||||
// TODO size
|
||||
|
||||
try w.objectField("httpOnly");
|
||||
try w.write(cookie.http_only);
|
||||
|
||||
try w.objectField("secure");
|
||||
try w.write(cookie.secure);
|
||||
|
||||
try w.objectField("session");
|
||||
try w.write(cookie.expires == null);
|
||||
|
||||
try w.objectField("sameSite");
|
||||
switch (cookie.same_site) {
|
||||
.none => try w.write("None"),
|
||||
.lax => try w.write("Lax"),
|
||||
.strict => try w.write("Strict"),
|
||||
}
|
||||
|
||||
// TODO experimentals
|
||||
}
|
||||
try w.endObject();
|
||||
}
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
|
||||
test "cdp.Storage: cookies" {
|
||||
var ctx = testing.context();
|
||||
defer ctx.deinit();
|
||||
_ = try ctx.loadBrowserContext(.{ .id = "BID-S" });
|
||||
|
||||
// Initially empty
|
||||
try ctx.processMessage(.{
|
||||
.id = 3,
|
||||
.method = "Storage.getCookies",
|
||||
.params = .{ .browserContextId = "BID-S" },
|
||||
});
|
||||
try ctx.expectSentResult(.{ .cookies = &[_]ResCookie{} }, .{ .id = 3 });
|
||||
|
||||
// Has cookies after setting them
|
||||
try ctx.processMessage(.{
|
||||
.id = 4,
|
||||
.method = "Storage.setCookies",
|
||||
.params = .{
|
||||
.cookies = &[_]CdpCookie{
|
||||
.{ .name = "test", .value = "value", .domain = "example.com", .path = "/mango" },
|
||||
.{ .name = "test2", .value = "value2", .url = "https://car.example.com/pancakes" },
|
||||
},
|
||||
.browserContextId = "BID-S",
|
||||
},
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 4 });
|
||||
try ctx.processMessage(.{
|
||||
.id = 5,
|
||||
.method = "Storage.getCookies",
|
||||
.params = .{ .browserContextId = "BID-S" },
|
||||
});
|
||||
try ctx.expectSentResult(.{
|
||||
.cookies = &[_]ResCookie{
|
||||
.{ .name = "test", .value = "value", .domain = ".example.com", .path = "/mango" },
|
||||
.{ .name = "test2", .value = "value2", .domain = "car.example.com", .path = "/", .secure = true }, // No Pancakes!
|
||||
},
|
||||
}, .{ .id = 5 });
|
||||
|
||||
// Empty after clearing cookies
|
||||
try ctx.processMessage(.{
|
||||
.id = 6,
|
||||
.method = "Storage.clearCookies",
|
||||
.params = .{ .browserContextId = "BID-S" },
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 6 });
|
||||
try ctx.processMessage(.{
|
||||
.id = 7,
|
||||
.method = "Storage.getCookies",
|
||||
.params = .{ .browserContextId = "BID-S" },
|
||||
});
|
||||
try ctx.expectSentResult(.{ .cookies = &[_]ResCookie{} }, .{ .id = 7 });
|
||||
}
|
||||
|
||||
pub const ResCookie = struct {
|
||||
name: []const u8,
|
||||
value: []const u8,
|
||||
domain: []const u8,
|
||||
path: []const u8 = "/",
|
||||
expires: f64 = -1,
|
||||
httpOnly: bool = false,
|
||||
secure: bool = false,
|
||||
sameSite: []const u8 = "None",
|
||||
};
|
||||
@@ -66,11 +66,30 @@ fn getBrowserContexts(cmd: anytype) !void {
|
||||
}
|
||||
|
||||
fn createBrowserContext(cmd: anytype) !void {
|
||||
const params = try cmd.params(struct {
|
||||
disposeOnDetach: bool = false,
|
||||
proxyServer: ?[]const u8 = null,
|
||||
proxyBypassList: ?[]const u8 = null,
|
||||
originsWithUniversalNetworkAccess: ?[]const []const u8 = null,
|
||||
});
|
||||
if (params) |p| {
|
||||
if (p.disposeOnDetach or p.proxyBypassList != null or p.originsWithUniversalNetworkAccess != null) std.debug.print("Target.createBrowserContext: Not implemented param set\n", .{});
|
||||
}
|
||||
|
||||
const bc = cmd.createBrowserContext() catch |err| switch (err) {
|
||||
error.AlreadyExists => return cmd.sendError(-32000, "Cannot have more than one browser context at a time"),
|
||||
else => return err,
|
||||
};
|
||||
|
||||
if (params) |p| {
|
||||
if (p.proxyServer) |proxy| {
|
||||
// For now the http client is not in the browser context so we assume there is just 1.
|
||||
bc.http_proxy_before = cmd.cdp.browser.http_client.http_proxy;
|
||||
const proxy_cp = try cmd.cdp.browser.http_client.allocator.dupe(u8, proxy);
|
||||
cmd.cdp.browser.http_client.http_proxy = try std.Uri.parse(proxy_cp);
|
||||
}
|
||||
}
|
||||
|
||||
return cmd.sendResult(.{
|
||||
.browserContextId = bc.id,
|
||||
}, .{});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1054
src/http/client.zig
1054
src/http/client.zig
File diff suppressed because it is too large
Load Diff
206
src/main.zig
206
src/main.zig
@@ -23,6 +23,7 @@ const Allocator = std.mem.Allocator;
|
||||
const log = @import("log.zig");
|
||||
const server = @import("server.zig");
|
||||
const App = @import("app.zig").App;
|
||||
const http = @import("http/client.zig");
|
||||
const Platform = @import("runtime/js.zig").Platform;
|
||||
const Browser = @import("browser/browser.zig").Browser;
|
||||
|
||||
@@ -82,7 +83,10 @@ fn run(alloc: Allocator) !void {
|
||||
|
||||
var app = try App.init(alloc, .{
|
||||
.run_mode = args.mode,
|
||||
.platform = &platform,
|
||||
.http_proxy = args.httpProxy(),
|
||||
.proxy_type = args.proxyType(),
|
||||
.proxy_auth = args.proxyAuth(),
|
||||
.tls_verify_host = args.tlsVerifyHost(),
|
||||
});
|
||||
defer app.deinit();
|
||||
@@ -126,7 +130,7 @@ fn run(alloc: Allocator) !void {
|
||||
},
|
||||
};
|
||||
|
||||
try page.wait();
|
||||
try page.wait(std.time.ns_per_s * 3);
|
||||
|
||||
// dump
|
||||
if (opts.dump) {
|
||||
@@ -155,6 +159,20 @@ const Command = struct {
|
||||
};
|
||||
}
|
||||
|
||||
fn proxyType(self: *const Command) ?http.ProxyType {
|
||||
return switch (self.mode) {
|
||||
inline .serve, .fetch => |opts| opts.common.proxy_type,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
fn proxyAuth(self: *const Command) ?http.ProxyAuth {
|
||||
return switch (self.mode) {
|
||||
inline .serve, .fetch => |opts| opts.common.proxy_auth,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
fn logLevel(self: *const Command) ?log.Level {
|
||||
return switch (self.mode) {
|
||||
inline .serve, .fetch => |opts| opts.common.log_level,
|
||||
@@ -198,6 +216,8 @@ const Command = struct {
|
||||
|
||||
const Common = struct {
|
||||
http_proxy: ?std.Uri = null,
|
||||
proxy_type: ?http.ProxyType = null,
|
||||
proxy_auth: ?http.ProxyAuth = null,
|
||||
tls_verify_host: bool = true,
|
||||
log_level: ?log.Level = null,
|
||||
log_format: ?log.Format = null,
|
||||
@@ -216,6 +236,21 @@ const Command = struct {
|
||||
\\--http_proxy The HTTP proxy to use for all HTTP requests.
|
||||
\\ Defaults to none.
|
||||
\\
|
||||
\\--proxy_type The type of proxy: connect, forward.
|
||||
\\ 'connect' creates a tunnel through the proxy via
|
||||
\\ and initial CONNECT request.
|
||||
\\ 'forward' sends the full URL in the request target
|
||||
\\ and expects the proxy to MITM the request.
|
||||
\\ Defaults to connect when --http_proxy is set.
|
||||
\\
|
||||
\\--proxy_bearer_token
|
||||
\\ The token to send for bearer authentication with the proxy
|
||||
\\ Proxy-Authorization: Bearer <token>
|
||||
\\
|
||||
\\--proxy_basic_auth
|
||||
\\ The user:password to send for basic authentication with the proxy
|
||||
\\ Proxy-Authorization: Basic <base64(user:password)>
|
||||
\\
|
||||
\\--log_level The log level: debug, info, warn, error or fatal.
|
||||
\\ Defaults to
|
||||
++ (if (builtin.mode == .Debug) " info." else "warn.") ++
|
||||
@@ -456,6 +491,47 @@ fn parseCommonArg(
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
common.http_proxy = try std.Uri.parse(try allocator.dupe(u8, str));
|
||||
if (common.http_proxy.?.host == null) {
|
||||
log.fatal(.app, "invalid http proxy", .{ .arg = "--http_proxy", .hint = "missing scheme?" });
|
||||
return error.InvalidArgument;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--proxy_type", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--proxy_type" });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
common.proxy_type = std.meta.stringToEnum(http.ProxyType, str) orelse {
|
||||
log.fatal(.app, "invalid option choice", .{ .arg = "--proxy_type", .value = str });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--proxy_bearer_token", opt)) {
|
||||
if (common.proxy_auth != null) {
|
||||
log.fatal(.app, "proxy auth already set", .{ .arg = "--proxy_bearer_token" });
|
||||
return error.InvalidArgument;
|
||||
}
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--proxy_bearer_token" });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
common.proxy_auth = .{ .bearer = .{ .token = str } };
|
||||
return true;
|
||||
}
|
||||
if (std.mem.eql(u8, "--proxy_basic_auth", opt)) {
|
||||
if (common.proxy_auth != null) {
|
||||
log.fatal(.app, "proxy auth already set", .{ .arg = "--proxy_basic_auth" });
|
||||
return error.InvalidArgument;
|
||||
}
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--proxy_basic_auth" });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
common.proxy_auth = .{ .basic = .{ .user_pass = str } };
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -505,7 +581,7 @@ fn parseCommonArg(
|
||||
var it = std.mem.splitScalar(u8, str, ',');
|
||||
while (it.next()) |part| {
|
||||
try arr.append(allocator, std.meta.stringToEnum(log.Scope, part) orelse {
|
||||
log.fatal(.app, "invalid option choice", .{ .arg = "--log_scope_filter", .value = part });
|
||||
log.fatal(.app, "invalid option choice", .{ .arg = "--log_filter_scopes", .value = part });
|
||||
return false;
|
||||
});
|
||||
}
|
||||
@@ -527,7 +603,7 @@ test "tests:beforeAll" {
|
||||
log.opts.format = .logfmt;
|
||||
|
||||
test_wg.startMany(3);
|
||||
_ = try Platform.init();
|
||||
const platform = try Platform.init();
|
||||
|
||||
{
|
||||
const address = try std.net.Address.parseIp("127.0.0.1", 9582);
|
||||
@@ -543,7 +619,7 @@ test "tests:beforeAll" {
|
||||
|
||||
{
|
||||
const address = try std.net.Address.parseIp("127.0.0.1", 9583);
|
||||
const thread = try std.Thread.spawn(.{}, serveCDP, .{address});
|
||||
const thread = try std.Thread.spawn(.{}, serveCDP, .{ address, &platform });
|
||||
thread.detach();
|
||||
}
|
||||
|
||||
@@ -573,58 +649,81 @@ fn serveHTTP(address: std.net.Address) !void {
|
||||
var conn = try listener.accept();
|
||||
defer conn.stream.close();
|
||||
var http_server = std.http.Server.init(conn, &read_buffer);
|
||||
|
||||
var request = http_server.receiveHead() catch |err| switch (err) {
|
||||
error.HttpConnectionClosing => continue :ACCEPT,
|
||||
else => {
|
||||
std.debug.print("Test HTTP Server error: {}\n", .{err});
|
||||
return err;
|
||||
},
|
||||
};
|
||||
|
||||
const path = request.head.target;
|
||||
if (std.mem.eql(u8, path, "/loader")) {
|
||||
try request.respond("Hello!", .{
|
||||
.extra_headers = &.{.{ .name = "Connection", .value = "close" }},
|
||||
});
|
||||
} else if (std.mem.eql(u8, path, "/http_client/simple")) {
|
||||
try request.respond("", .{
|
||||
.extra_headers = &.{.{ .name = "Connection", .value = "close" }},
|
||||
});
|
||||
} else if (std.mem.eql(u8, path, "/http_client/redirect")) {
|
||||
try request.respond("", .{
|
||||
.status = .moved_permanently,
|
||||
.extra_headers = &.{
|
||||
.{ .name = "Connection", .value = "close" },
|
||||
.{ .name = "LOCATION", .value = "../http_client/echo" },
|
||||
var connect_headers: std.ArrayListUnmanaged(std.http.Header) = .{};
|
||||
REQUEST: while (true) {
|
||||
var request = http_server.receiveHead() catch |err| switch (err) {
|
||||
error.HttpConnectionClosing => continue :ACCEPT,
|
||||
else => {
|
||||
std.debug.print("Test HTTP Server error: {}\n", .{err});
|
||||
return err;
|
||||
},
|
||||
});
|
||||
} else if (std.mem.eql(u8, path, "/http_client/redirect/secure")) {
|
||||
try request.respond("", .{
|
||||
.status = .moved_permanently,
|
||||
.extra_headers = &.{ .{ .name = "Connection", .value = "close" }, .{ .name = "LOCATION", .value = "https://127.0.0.1:9581/http_client/body" } },
|
||||
});
|
||||
} else if (std.mem.eql(u8, path, "/http_client/gzip")) {
|
||||
const body = &.{ 0x1f, 0x8b, 0x08, 0x08, 0x01, 0xc6, 0x19, 0x68, 0x00, 0x03, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x68, 0x74, 0x6d, 0x6c, 0x00, 0x73, 0x54, 0xc8, 0x4b, 0x2d, 0x57, 0x48, 0x2a, 0xca, 0x2f, 0x2f, 0x4e, 0x2d, 0x52, 0x48, 0x2a, 0xcd, 0xcc, 0x29, 0x51, 0x48, 0xcb, 0x2f, 0x52, 0xc8, 0x4d, 0x4c, 0xce, 0xc8, 0xcc, 0x4b, 0x2d, 0xe6, 0x02, 0x00, 0xe7, 0xc3, 0x4b, 0x27, 0x21, 0x00, 0x00, 0x00 };
|
||||
try request.respond(body, .{
|
||||
.extra_headers = &.{ .{ .name = "Connection", .value = "close" }, .{ .name = "Content-Encoding", .value = "gzip" } },
|
||||
});
|
||||
} else if (std.mem.eql(u8, path, "/http_client/echo")) {
|
||||
var headers: std.ArrayListUnmanaged(std.http.Header) = .{};
|
||||
};
|
||||
|
||||
var it = request.iterateHeaders();
|
||||
while (it.next()) |hdr| {
|
||||
try headers.append(aa, .{
|
||||
.name = try std.fmt.allocPrint(aa, "_{s}", .{hdr.name}),
|
||||
.value = hdr.value,
|
||||
if (request.head.method == .CONNECT) {
|
||||
try request.respond("", .{ .status = .ok });
|
||||
|
||||
// Proxy headers and destination headers are separated in the case of a CONNECT proxy
|
||||
// We store the CONNECT headers, then continue with the request for the destination
|
||||
var it = request.iterateHeaders();
|
||||
while (it.next()) |hdr| {
|
||||
try connect_headers.append(aa, .{
|
||||
.name = try std.fmt.allocPrint(aa, "__{s}", .{hdr.name}),
|
||||
.value = try aa.dupe(u8, hdr.value),
|
||||
});
|
||||
}
|
||||
continue :REQUEST;
|
||||
}
|
||||
|
||||
const path = request.head.target;
|
||||
if (std.mem.eql(u8, path, "/loader")) {
|
||||
try request.respond("Hello!", .{
|
||||
.extra_headers = &.{.{ .name = "Connection", .value = "close" }},
|
||||
});
|
||||
} else if (std.mem.eql(u8, path, "/http_client/simple")) {
|
||||
try request.respond("", .{
|
||||
.extra_headers = &.{.{ .name = "Connection", .value = "close" }},
|
||||
});
|
||||
} else if (std.mem.eql(u8, path, "/http_client/redirect")) {
|
||||
try request.respond("", .{
|
||||
.status = .moved_permanently,
|
||||
.extra_headers = &.{
|
||||
.{ .name = "Connection", .value = "close" },
|
||||
.{ .name = "LOCATION", .value = "../http_client/echo" },
|
||||
},
|
||||
});
|
||||
} else if (std.mem.eql(u8, path, "/http_client/redirect/secure")) {
|
||||
try request.respond("", .{
|
||||
.status = .moved_permanently,
|
||||
.extra_headers = &.{ .{ .name = "Connection", .value = "close" }, .{ .name = "LOCATION", .value = "https://127.0.0.1:9581/http_client/body" } },
|
||||
});
|
||||
} else if (std.mem.eql(u8, path, "/http_client/gzip")) {
|
||||
const body = &.{ 0x1f, 0x8b, 0x08, 0x08, 0x01, 0xc6, 0x19, 0x68, 0x00, 0x03, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x68, 0x74, 0x6d, 0x6c, 0x00, 0x73, 0x54, 0xc8, 0x4b, 0x2d, 0x57, 0x48, 0x2a, 0xca, 0x2f, 0x2f, 0x4e, 0x2d, 0x52, 0x48, 0x2a, 0xcd, 0xcc, 0x29, 0x51, 0x48, 0xcb, 0x2f, 0x52, 0xc8, 0x4d, 0x4c, 0xce, 0xc8, 0xcc, 0x4b, 0x2d, 0xe6, 0x02, 0x00, 0xe7, 0xc3, 0x4b, 0x27, 0x21, 0x00, 0x00, 0x00 };
|
||||
try request.respond(body, .{
|
||||
.extra_headers = &.{ .{ .name = "Connection", .value = "close" }, .{ .name = "Content-Encoding", .value = "gzip" } },
|
||||
});
|
||||
} else if (std.mem.eql(u8, path, "/http_client/echo")) {
|
||||
var headers: std.ArrayListUnmanaged(std.http.Header) = .{};
|
||||
|
||||
var it = request.iterateHeaders();
|
||||
while (it.next()) |hdr| {
|
||||
try headers.append(aa, .{
|
||||
.name = try std.fmt.allocPrint(aa, "_{s}", .{hdr.name}),
|
||||
.value = hdr.value,
|
||||
});
|
||||
}
|
||||
|
||||
if (connect_headers.items.len > 0) {
|
||||
try headers.appendSlice(aa, connect_headers.items);
|
||||
connect_headers.clearRetainingCapacity();
|
||||
}
|
||||
try headers.append(aa, .{ .name = "Connection", .value = "Close" });
|
||||
|
||||
try request.respond("over 9000!", .{
|
||||
.status = .created,
|
||||
.extra_headers = headers.items,
|
||||
});
|
||||
}
|
||||
try headers.append(aa, .{ .name = "Connection", .value = "Close" });
|
||||
|
||||
try request.respond("over 9000!", .{
|
||||
.status = .created,
|
||||
.extra_headers = headers.items,
|
||||
});
|
||||
continue :ACCEPT;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -702,11 +801,12 @@ fn serveHTTPS(address: std.net.Address) !void {
|
||||
}
|
||||
}
|
||||
|
||||
fn serveCDP(address: std.net.Address) !void {
|
||||
fn serveCDP(address: std.net.Address, platform: *const Platform) !void {
|
||||
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
|
||||
var app = try App.init(gpa.allocator(), .{
|
||||
.run_mode = .serve,
|
||||
.tls_verify_host = false,
|
||||
.platform = platform,
|
||||
});
|
||||
defer app.deinit();
|
||||
|
||||
|
||||
@@ -70,7 +70,13 @@ pub fn main() !void {
|
||||
defer _ = test_arena.reset(.{ .retain_capacity = {} });
|
||||
|
||||
var err_out: ?[]const u8 = null;
|
||||
const result = run(test_arena.allocator(), test_file, &loader, &err_out) catch |err| blk: {
|
||||
const result = run(
|
||||
test_arena.allocator(),
|
||||
&platform,
|
||||
test_file,
|
||||
&loader,
|
||||
&err_out,
|
||||
) catch |err| blk: {
|
||||
if (err_out == null) {
|
||||
err_out = @errorName(err);
|
||||
}
|
||||
@@ -89,7 +95,13 @@ pub fn main() !void {
|
||||
try writer.finalize();
|
||||
}
|
||||
|
||||
fn run(arena: Allocator, test_file: []const u8, loader: *FileLoader, err_out: *?[]const u8) !?[]const u8 {
|
||||
fn run(
|
||||
arena: Allocator,
|
||||
platform: *const Platform,
|
||||
test_file: []const u8,
|
||||
loader: *FileLoader,
|
||||
err_out: *?[]const u8,
|
||||
) !?[]const u8 {
|
||||
// document
|
||||
const html = blk: {
|
||||
const full_path = try std.fs.path.join(arena, &.{ WPT_DIR, test_file });
|
||||
@@ -110,6 +122,7 @@ fn run(arena: Allocator, test_file: []const u8, loader: *FileLoader, err_out: *?
|
||||
var runner = try @import("testing.zig").jsRunner(arena, .{
|
||||
.url = "http://127.0.0.1",
|
||||
.html = html,
|
||||
.platform = platform,
|
||||
});
|
||||
defer runner.deinit();
|
||||
|
||||
@@ -157,7 +170,7 @@ fn run(arena: Allocator, test_file: []const u8, loader: *FileLoader, err_out: *?
|
||||
var try_catch: Env.TryCatch = undefined;
|
||||
try_catch.init(runner.page.main_context);
|
||||
defer try_catch.deinit();
|
||||
try runner.page.loop.run();
|
||||
try runner.page.loop.run(std.time.ns_per_ms * 200);
|
||||
|
||||
if (try_catch.hasCaught()) {
|
||||
err_out.* = (try try_catch.err(arena)) orelse "unknwon error";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,8 @@ const MemoryPool = std.heap.MemoryPool;
|
||||
const log = @import("../log.zig");
|
||||
pub const IO = @import("tigerbeetle-io").IO;
|
||||
|
||||
const RUN_DURATION = 10 * std.time.ns_per_ms;
|
||||
|
||||
// SingleThreaded I/O Loop based on Tigerbeetle io_uring loop.
|
||||
// On Linux it's using io_uring.
|
||||
// On MacOS and Windows it's using kqueue/IOCP with a ring design.
|
||||
@@ -82,7 +84,7 @@ pub const Loop = struct {
|
||||
// run tail events. We do run the tail events to ensure all the
|
||||
// contexts are correcly free.
|
||||
while (self.pending_network_count != 0 or self.pending_timeout_count != 0) {
|
||||
self.io.run_for_ns(std.time.ns_per_ms * 10) catch |err| {
|
||||
self.io.run_for_ns(RUN_DURATION) catch |err| {
|
||||
log.err(.loop, "deinit", .{ .err = err });
|
||||
break;
|
||||
};
|
||||
@@ -102,12 +104,16 @@ pub const Loop = struct {
|
||||
// Stops when there is no more I/O events registered on the loop.
|
||||
// Note that I/O events callbacks might register more I/O events
|
||||
// on the go when they are executed (ie. nested I/O events).
|
||||
pub fn run(self: *Self) !void {
|
||||
pub fn run(self: *Self, wait_ns: usize) !void {
|
||||
// stop repeating / interval timeouts from re-registering
|
||||
self.stopping = true;
|
||||
defer self.stopping = false;
|
||||
|
||||
while (self.pending_network_count != 0 or self.pending_timeout_count != 0) {
|
||||
const max_iterations = wait_ns / (RUN_DURATION);
|
||||
for (0..max_iterations) |_| {
|
||||
if (self.pending_network_count == 0 and self.pending_timeout_count == 0) {
|
||||
break;
|
||||
}
|
||||
self.io.run_for_ns(std.time.ns_per_ms * 10) catch |err| {
|
||||
log.err(.loop, "deinit", .{ .err = err });
|
||||
break;
|
||||
@@ -187,6 +193,11 @@ pub const Loop = struct {
|
||||
}
|
||||
|
||||
pub fn timeout(self: *Self, nanoseconds: u63, callback_node: ?*CallbackNode) !usize {
|
||||
if (self.stopping and nanoseconds > std.time.ns_per_ms * 500) {
|
||||
// we're trying to shutdown, we probably don't want to wait for a new
|
||||
// long timeout
|
||||
return 0;
|
||||
}
|
||||
const completion = try self.alloc.create(Completion);
|
||||
errdefer self.alloc.destroy(completion);
|
||||
completion.* = undefined;
|
||||
|
||||
@@ -77,6 +77,117 @@ pub const MyAPI = struct {
|
||||
}
|
||||
};
|
||||
|
||||
pub const Parent = packed struct {
|
||||
parent_id: i32 = 0,
|
||||
|
||||
pub fn get_parent(self: *const Parent) i32 {
|
||||
return self.parent_id;
|
||||
}
|
||||
pub fn set_parent(self: *Parent, id: i32) void {
|
||||
self.parent_id = id;
|
||||
}
|
||||
};
|
||||
|
||||
pub const Middle = struct {
|
||||
pub const prototype = *Parent;
|
||||
|
||||
middle_id: i32 = 0,
|
||||
_padding_1: u8 = 0,
|
||||
_padding_2: u8 = 1,
|
||||
_padding_3: u8 = 2,
|
||||
proto: Parent,
|
||||
|
||||
pub fn constructor() Middle {
|
||||
return .{
|
||||
.middle_id = 0,
|
||||
.proto = .{ .parent_id = 0 },
|
||||
};
|
||||
}
|
||||
|
||||
pub fn get_middle(self: *const Middle) i32 {
|
||||
return self.middle_id;
|
||||
}
|
||||
pub fn set_middle(self: *Middle, id: i32) void {
|
||||
self.middle_id = id;
|
||||
}
|
||||
};
|
||||
|
||||
pub const Child = struct {
|
||||
pub const prototype = *Middle;
|
||||
|
||||
child_id: i32 = 0,
|
||||
_padding_1: u8 = 0,
|
||||
proto: Middle,
|
||||
|
||||
pub fn constructor() Child {
|
||||
return .{
|
||||
.child_id = 0,
|
||||
.proto = .{ .middle_id = 0, .proto = .{ .parent_id = 0 } },
|
||||
};
|
||||
}
|
||||
|
||||
pub fn get_child(self: *const Child) i32 {
|
||||
return self.child_id;
|
||||
}
|
||||
pub fn set_child(self: *Child, id: i32) void {
|
||||
self.child_id = id;
|
||||
}
|
||||
};
|
||||
|
||||
pub const MiddlePtr = packed struct {
|
||||
pub const prototype = *Parent;
|
||||
|
||||
middle_id: i32 = 0,
|
||||
_padding_1: u8 = 0,
|
||||
_padding_2: u8 = 1,
|
||||
_padding_3: u8 = 2,
|
||||
proto: *Parent,
|
||||
|
||||
pub fn constructor(state: State) !MiddlePtr {
|
||||
const parent = try state.arena.create(Parent);
|
||||
parent.* = .{ .parent_id = 0 };
|
||||
return .{
|
||||
.middle_id = 0,
|
||||
.proto = parent,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn get_middle(self: *const MiddlePtr) i32 {
|
||||
return self.middle_id;
|
||||
}
|
||||
pub fn set_middle(self: *MiddlePtr, id: i32) void {
|
||||
self.middle_id = id;
|
||||
}
|
||||
};
|
||||
|
||||
pub const ChildPtr = packed struct {
|
||||
pub const prototype = *MiddlePtr;
|
||||
|
||||
child_id: i32 = 0,
|
||||
_padding_1: u8 = 0,
|
||||
_padding_2: u8 = 1,
|
||||
proto: *MiddlePtr,
|
||||
|
||||
pub fn constructor(state: State) !ChildPtr {
|
||||
const parent = try state.arena.create(Parent);
|
||||
const middle = try state.arena.create(MiddlePtr);
|
||||
|
||||
parent.* = .{ .parent_id = 0 };
|
||||
middle.* = .{ .middle_id = 0, .proto = parent };
|
||||
return .{
|
||||
.child_id = 0,
|
||||
.proto = middle,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn get_child(self: *const ChildPtr) i32 {
|
||||
return self.child_id;
|
||||
}
|
||||
pub fn set_child(self: *ChildPtr, id: i32) void {
|
||||
self.child_id = id;
|
||||
}
|
||||
};
|
||||
|
||||
const State = struct {
|
||||
arena: Allocator,
|
||||
};
|
||||
@@ -90,6 +201,11 @@ test "JS: object types" {
|
||||
Other,
|
||||
MyObject,
|
||||
MyAPI,
|
||||
Parent,
|
||||
Middle,
|
||||
Child,
|
||||
MiddlePtr,
|
||||
ChildPtr,
|
||||
}).init(.{ .arena = arena.allocator() }, {});
|
||||
|
||||
defer runner.deinit();
|
||||
@@ -120,4 +236,40 @@ test "JS: object types" {
|
||||
// check object property
|
||||
.{ "myObjIndirect.a.val()", "4" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let m1 = new Middle();", null },
|
||||
.{ "m1.middle = 2", null },
|
||||
.{ "m1.parent = 3", null },
|
||||
.{ "m1.middle", "2" },
|
||||
.{ "m1.parent", "3" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let c1 = new Child();", null },
|
||||
.{ "c1.child = 1", null },
|
||||
.{ "c1.middle = 2", null },
|
||||
.{ "c1.parent = 3", null },
|
||||
.{ "c1.child", "1" },
|
||||
.{ "c1.middle", "2" },
|
||||
.{ "c1.parent", "3" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let m2 = new MiddlePtr();", null },
|
||||
.{ "m2.middle = 2", null },
|
||||
.{ "m2.parent = 3", null },
|
||||
.{ "m2.middle", "2" },
|
||||
.{ "m2.parent", "3" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let c2 = new ChildPtr();", null },
|
||||
.{ "c2.child = 1", null },
|
||||
.{ "c2.middle = 2", null },
|
||||
.{ "c2.parent = 3", null },
|
||||
.{ "c2.child", "1" },
|
||||
.{ "c2.middle", "2" },
|
||||
.{ "c2.parent", "3" },
|
||||
}, .{});
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ pub fn Runner(comptime State: type, comptime Global: type, comptime types: anyty
|
||||
const self = try allocator.create(Self);
|
||||
errdefer allocator.destroy(self);
|
||||
|
||||
self.env = try Env.init(allocator, .{});
|
||||
self.env = try Env.init(allocator, null, .{});
|
||||
errdefer self.env.deinit();
|
||||
|
||||
self.executor = try self.env.newExecutionWorld();
|
||||
|
||||
@@ -39,7 +39,7 @@ const CDP = @import("cdp/cdp.zig").CDP;
|
||||
|
||||
const TimeoutCheck = std.time.ns_per_ms * 100;
|
||||
|
||||
const MAX_HTTP_REQUEST_SIZE = 2048;
|
||||
const MAX_HTTP_REQUEST_SIZE = 4096;
|
||||
|
||||
// max message size
|
||||
// +14 for max websocket payload overhead
|
||||
@@ -223,7 +223,7 @@ pub const Client = struct {
|
||||
}
|
||||
|
||||
fn close(self: *Self) void {
|
||||
log.info(.app, "client disconected", .{});
|
||||
log.info(.app, "client disconnected", .{});
|
||||
self.connected = false;
|
||||
// recv only, because we might have pending writes we'd like to get
|
||||
// out (like the HTTP error response)
|
||||
@@ -1142,7 +1142,7 @@ test "Client: http invalid request" {
|
||||
var c = try createTestClient();
|
||||
defer c.deinit();
|
||||
|
||||
const res = try c.httpRequest("GET /over/9000 HTTP/1.1\r\n" ++ "Header: " ++ ("a" ** 2050) ++ "\r\n\r\n");
|
||||
const res = try c.httpRequest("GET /over/9000 HTTP/1.1\r\n" ++ "Header: " ++ ("a" ** 4100) ++ "\r\n\r\n");
|
||||
try testing.expectEqualStrings("HTTP/1.1 413 \r\n" ++
|
||||
"Connection: Close\r\n" ++
|
||||
"Content-Length: 17\r\n\r\n" ++
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const Platform = @import("runtime/js.zig").Platform;
|
||||
|
||||
pub const allocator = std.testing.allocator;
|
||||
pub const expectError = std.testing.expectError;
|
||||
pub const expect = std.testing.expect;
|
||||
@@ -66,7 +68,10 @@ pub fn expectEqual(expected: anytype, actual: anytype) !void {
|
||||
if (@typeInfo(@TypeOf(expected)) == .null) {
|
||||
return std.testing.expectEqual(null, actual);
|
||||
}
|
||||
return expectEqual(expected, actual.?);
|
||||
if (actual) |_actual| {
|
||||
return expectEqual(expected, _actual);
|
||||
}
|
||||
return std.testing.expectEqual(expected, null);
|
||||
},
|
||||
.@"union" => |union_info| {
|
||||
if (union_info.tag_type == null) {
|
||||
@@ -380,6 +385,7 @@ pub const JsRunner = struct {
|
||||
var app = try App.init(alloc, .{
|
||||
.run_mode = .serve,
|
||||
.tls_verify_host = false,
|
||||
.platform = opts.platform,
|
||||
});
|
||||
errdefer app.deinit();
|
||||
|
||||
@@ -435,7 +441,7 @@ pub const JsRunner = struct {
|
||||
}
|
||||
return err;
|
||||
};
|
||||
try self.page.loop.run();
|
||||
try self.page.loop.run(std.time.ns_per_ms * 200);
|
||||
@import("root").js_runner_duration += std.time.Instant.since(try std.time.Instant.now(), start);
|
||||
|
||||
if (case.@"1") |expected| {
|
||||
@@ -471,6 +477,7 @@ pub const JsRunner = struct {
|
||||
};
|
||||
|
||||
const RunnerOpts = struct {
|
||||
platform: ?*const Platform = null,
|
||||
url: []const u8 = "https://lightpanda.io/opensource-browser/",
|
||||
html: []const u8 =
|
||||
\\ <div id="content">
|
||||
|
||||
55
src/url.zig
55
src/url.zig
@@ -110,7 +110,13 @@ pub const URL = struct {
|
||||
}
|
||||
return src;
|
||||
}
|
||||
if (src.len == 0) {
|
||||
|
||||
var normalized_src = src;
|
||||
while (std.mem.startsWith(u8, normalized_src, "./")) {
|
||||
normalized_src = normalized_src[2..];
|
||||
}
|
||||
|
||||
if (normalized_src.len == 0) {
|
||||
if (opts.alloc == .always) {
|
||||
return allocator.dupe(u8, base);
|
||||
}
|
||||
@@ -125,7 +131,12 @@ pub const URL = struct {
|
||||
}
|
||||
};
|
||||
|
||||
const normalized_src = if (src[0] == '/') src[1..] else src;
|
||||
if (normalized_src[0] == '/') {
|
||||
if (std.mem.indexOfScalarPos(u8, base, protocol_end, '/')) |pos| {
|
||||
return std.fmt.allocPrint(allocator, "{s}{s}", .{ base[0..pos], normalized_src });
|
||||
}
|
||||
// not sure what to do here...error? Just let it fallthrough for now.
|
||||
}
|
||||
|
||||
if (std.mem.lastIndexOfScalar(u8, base[protocol_end..], '/')) |index| {
|
||||
const last_slash_pos = index + protocol_end;
|
||||
@@ -216,41 +227,51 @@ test "URL: resolve size" {
|
||||
test "URL: Stitching Base & Src URLs (Basic)" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const base = "https://www.google.com/xyz/abc/123";
|
||||
const base = "https://lightpanda.io/xyz/abc/123";
|
||||
const src = "something.js";
|
||||
const result = try URL.stitch(allocator, src, base, .{});
|
||||
defer allocator.free(result);
|
||||
try testing.expectString("https://www.google.com/xyz/abc/something.js", result);
|
||||
try testing.expectString("https://lightpanda.io/xyz/abc/something.js", result);
|
||||
}
|
||||
|
||||
test "URL: Stitching Base & Src URLs (Just Ending Slash)" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const base = "https://www.google.com/";
|
||||
const base = "https://lightpanda.io/";
|
||||
const src = "something.js";
|
||||
const result = try URL.stitch(allocator, src, base, .{});
|
||||
defer allocator.free(result);
|
||||
try testing.expectString("https://www.google.com/something.js", result);
|
||||
try testing.expectString("https://lightpanda.io/something.js", result);
|
||||
}
|
||||
|
||||
test "URL: Stitching Base & Src URLs with leading slash" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const base = "https://www.google.com/";
|
||||
const base = "https://lightpanda.io/";
|
||||
const src = "/something.js";
|
||||
const result = try URL.stitch(allocator, src, base, .{});
|
||||
defer allocator.free(result);
|
||||
try testing.expectString("https://www.google.com/something.js", result);
|
||||
try testing.expectString("https://lightpanda.io/something.js", result);
|
||||
}
|
||||
|
||||
test "URL: Stitching Base & Src URLs (No Ending Slash)" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const base = "https://www.google.com";
|
||||
const base = "https://lightpanda.io";
|
||||
const src = "something.js";
|
||||
const result = try URL.stitch(allocator, src, base, .{});
|
||||
defer allocator.free(result);
|
||||
try testing.expectString("https://www.google.com/something.js", result);
|
||||
try testing.expectString("https://lightpanda.io/something.js", result);
|
||||
}
|
||||
|
||||
test "URL: Stitching Base with absolute src" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const base = "https://lightpanda.io/hello";
|
||||
const src = "/abc/something.js";
|
||||
const result = try URL.stitch(allocator, src, base, .{});
|
||||
defer allocator.free(result);
|
||||
try testing.expectString("https://lightpanda.io/abc/something.js", result);
|
||||
}
|
||||
|
||||
test "URL: Stiching Base & Src URLs (Both Local)" {
|
||||
@@ -275,11 +296,21 @@ test "URL: Stiching src as full path" {
|
||||
test "URL: Stitching Base & Src URLs (empty src)" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const base = "https://www.google.com/xyz/abc/123";
|
||||
const base = "https://lightpanda.io/xyz/abc/123";
|
||||
const src = "";
|
||||
const result = try URL.stitch(allocator, src, base, .{});
|
||||
defer allocator.free(result);
|
||||
try testing.expectString("https://www.google.com/xyz/abc/123", result);
|
||||
try testing.expectString("https://lightpanda.io/xyz/abc/123", result);
|
||||
}
|
||||
|
||||
test "URL: Stitching dotslash" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const base = "https://lightpanda.io/hello/";
|
||||
const src = "./something.js";
|
||||
const result = try URL.stitch(allocator, src, base, .{});
|
||||
defer allocator.free(result);
|
||||
try testing.expectString("https://lightpanda.io/hello/something.js", result);
|
||||
}
|
||||
|
||||
test "URL: concatQueryString" {
|
||||
|
||||
2
vendor/netsurf/libdom
vendored
2
vendor/netsurf/libdom
vendored
Submodule vendor/netsurf/libdom updated: 73d1bc9f23...614187b0aa
Reference in New Issue
Block a user