Compare commits

..

57 Commits

Author SHA1 Message Date
Pierre Tachoire
d88139c5b5 test using lpd-bench-hetzner 2026-03-30 11:19:22 +02:00
Karl Seguin
078e13d8a4 Remove DOMContentLoaded and Loaded events from page_navigated
These were moved to their own distinct events, and should have been removed from
here.
2026-03-30 11:39:38 +08:00
Karl Seguin
cabb029bed Improve/Fix CDP navigation event order
These changes all better align with chrome's event ordering/timing.

There are two big changes. The first is that our internal page_navigated event,
which is kind of our heavy hitter, is sent once the header is received as
opposed to (much later) on document load. The main goal of this internal event
is to trigger the "Page.frameNavigated" CDP event which is meant to happen
once the URL is committed, which _is_ on header response.

To accommodate this earlier trigger, new explicit events for DOMContentLoaded
and load have be added.

This drastically changes the flow of events as things go from:
Start Page Navigation
Response Received
  Start Frame Navigation
  Response Received
  End Frame Navigation
End Page Navigation
context clear + reset
DOMContentLoaded
Loaded

TO:
Start Page Navigation
Response Received
End Page Navigation
context clear + reset
Start Frame Navigation
Response Received
End Frame Navigation
DOMContentLoaded
Loaded

So not only does it remove the nesting, but it ensures that the context are
cleared and reset once the main page's navigation is locked in, and before any
frame is created.
2026-03-30 11:02:53 +08:00
Pierre Tachoire
03ed45637a Merge pull request #1889 from lightpanda-io/wp/mrdimidium/refactor-redirects
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Rework header/data callbacks in HttpClient
2026-03-27 14:22:58 +01:00
Nikolay Govorov
9068fe718e Fix SameSite cookies 2026-03-27 11:16:46 +00:00
Nikolay Govorov
5369d25213 fix recv e2e test 2026-03-27 09:49:16 +00:00
Nikolay Govorov
649d8d1024 Remove duplication in cookies instalation 2026-03-27 09:49:13 +00:00
Nikolay Govorov
15d60d845a Fixup error handling in HttpClient process messages 2026-03-27 09:49:11 +00:00
Nikolay Govorov
c4b837b598 Revert log reimport 2026-03-27 09:49:09 +00:00
Nikolay Govorov
54391238c9 Move cdp callbacks from dataCallback to processMessages 2026-03-27 09:49:07 +00:00
Nikolay Govorov
d33edc5697 Fixup cookies management 2026-03-27 09:49:05 +00:00
Nikolay Govorov
16ca8d4b14 Fix cleanup connections in HttpClient 2026-03-27 09:49:03 +00:00
Nikolay Govorov
707ffb4893 Move redirects handling from curl callbacks 2026-03-27 09:48:59 +00:00
Pierre Tachoire
4782b37216 Merge pull request #2016 from lightpanda-io/readme-mention-cors
mention CORS is missing in the README's status
2026-03-27 08:34:09 +01:00
Pierre Tachoire
ce197256dd Merge pull request #2010 from lightpanda-io/build-pre-nightly
build: simplify nightly versioning
2026-03-27 08:33:45 +01:00
Pierre Tachoire
e6d644998a mention CORS is missing in the README's status 2026-03-27 08:26:56 +01:00
Karl Seguin
67bd555e75 Merge pull request #2013 from lightpanda-io/cleanup_dead_code_removal
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Remove unused imports
2026-03-27 13:52:49 +08:00
Adrià Arrufat
a10e533701 Remove more unused imports 2026-03-27 14:24:17 +09:00
Karl Seguin
0065677273 Merge pull request #2011 from lightpanda-io/mcp-fixes
MCP fixes
2026-03-27 13:02:59 +08:00
Karl Seguin
226d9bfc6f zig fmt 2026-03-27 12:47:24 +08:00
Karl Seguin
2e65ae632e Merge pull request #2009 from lightpanda-io/fix/issue-1960
mcp: improve argument parsing error handling
2026-03-27 12:46:34 +08:00
Karl Seguin
ea422075c7 Remove unused imports
And some smaller cleanups.
2026-03-27 12:45:26 +08:00
Adrià Arrufat
1d54e6944b mcp: send error response when message is too long 2026-03-27 11:36:18 +09:00
Adrià Arrufat
de32e5cf34 mcp: handle missing request IDs safely 2026-03-27 11:34:06 +09:00
Adrià Arrufat
c8d8ca5e94 mcp: improve error handling in resources and tools
- Handle failures during HTML, Markdown, and link serialization.
- Return MCP internal errors when result serialization fails.
- Refactor resource reading logic for better clarity and consistency.
2026-03-27 11:28:47 +09:00
Adrià Arrufat
7f2139f612 build: simplify nightly versioning 2026-03-27 10:47:43 +09:00
Adrià Arrufat
da0828620f mcp: improve argument parsing error handling
Closes #1960
2026-03-27 10:04:45 +09:00
Adrià Arrufat
cdd33621e3 Merge pull request #2005 from lightpanda-io/mcp-lp-node-registry
MCP/CDP: unify node registration
2026-03-27 09:36:08 +09:00
Karl Seguin
8001709506 Merge pull request #2002 from lightpanda-io/nikneym/form-data-event
Support `FormDataEvent`
2026-03-27 08:16:32 +08:00
Karl Seguin
a0ae6b4c92 Merge pull request #2008 from buley/feature/fix-scanner-warnings
chore: fix dead code and error swallowing warnings
2026-03-27 08:10:31 +08:00
Karl Seguin
fdf7f5267a Merge pull request #2001 from lightpanda-io/refactor/mcp-tools-dedup
mcp: extract parseOptionalAndGetPage helper
2026-03-27 07:58:18 +08:00
Taylor
88e0b39d6b chore: fix dead code and error swallowing warnings
Fixes issues reported by polyglot-scanner:
- Removed explicit `return` keywords and trailing semicolons to resolve DEAD_CODE/DEAD_BRANCH warnings.
- Replaced `epoch::advance().unwrap()` and `stats::resident::read().unwrap()` with safer alternatives (`drop` and `unwrap_or(0)`) to resolve ERROR_SWALLOW warnings.
- Replaced `let _ = Box::from_raw(...)` with `drop(Box::from_raw(...))` to correctly drop the box while fixing the ERROR_SWALLOW warning.
2026-03-26 09:58:49 -07:00
Pierre Tachoire
f95396a487 Merge pull request #1998 from lightpanda-io/url_origin_fix
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / zig build release (push) Has been cancelled
wpt / build wpt runner (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-integration-test / zig build release (push) Has been cancelled
e2e-integration-test / demo-integration-scripts (push) Has been cancelled
Improve authority parsing
2026-03-26 17:32:40 +01:00
Pierre Tachoire
d02d05b246 Merge pull request #2004 from lightpanda-io/nikneym/resize-unobserver
`ResizeObserver`: make `unobserve` available in JS context
2026-03-26 16:48:42 +01:00
Pierre Tachoire
7b2d817d0e Merge pull request #2003 from lightpanda-io/nikneym/canvas-access-canvas
`CanvasRenderingContext2D`: make canvas able to access canvas element
2026-03-26 16:48:11 +01:00
Adrià Arrufat
7e778a17d6 MCP/CDP: unify node registration
This fixes a bug in MCP where interactive elements were not assigned
a backendNodeId, preventing agents from clicking or filling them. Also
extracts link collection to a shared browser module.
2026-03-26 23:51:43 +09:00
Pierre Tachoire
a0dd14aaad Merge pull request #1999 from lightpanda-io/wait_until_default
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Fix --wait-until default value.
2026-03-26 15:03:59 +01:00
Halil Durak
d447d1e3c7 ResizeObserver: make unobserve available in JS context 2026-03-26 16:37:17 +03:00
Halil Durak
8684d35394 add tests 2026-03-26 16:35:23 +03:00
Halil Durak
e243f96988 CanvasRenderingContext2D: make canvas able to access canvas element 2026-03-26 16:35:13 +03:00
Pierre Tachoire
7ea8f3f766 Merge pull request #2000 from lightpanda-io/add-pre-version
add a -Dpre_version build flag for custom pre version
2026-03-26 12:06:38 +01:00
Halil Durak
5e6082b5e9 FormDataEvent: add tests 2026-03-26 14:04:03 +03:00
Halil Durak
1befd9a5e8 make comment on SubmitEvent doc-comment 2026-03-26 14:03:51 +03:00
Halil Durak
e103ce0f39 FormDataEvent: initial support 2026-03-26 14:03:33 +03:00
Adrià Arrufat
14fa2da2ad mcp: remove duplicate code in testLoadPage 2026-03-26 19:57:14 +09:00
Pierre Tachoire
28cc60adb0 add a -Dpre_version build flag for custom pre version 2026-03-26 11:52:16 +01:00
Adrià Arrufat
96d24b5dc6 mcp: extract parseOptionalAndGetPage helper
Deduplicate the repeated "parse optional URL, maybe navigate, get page"
pattern across 6 MCP tool handlers (markdown, links, semantic_tree,
interactiveElements, structuredData, detectForms).
2026-03-26 19:44:44 +09:00
Karl Seguin
c14a9ad986 Merge pull request #1992 from navidemad/cdp-page-reload
CDP: implement Page.reload
2026-03-26 18:14:49 +08:00
Karl Seguin
679f2104f4 Fix --wait-until default value.
This was `load`, but it should have been (and was documented as `done`). This
is my fault. Sorry.

Should help with: https://github.com/lightpanda-io/browser/issues/1947#issuecomment-4120597764
2026-03-26 18:06:14 +08:00
Navid EMAD
c6b0c75106 Address review: use arena.dupeZ for URL copy, add try to testing.context()
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:09:48 +01:00
Navid EMAD
93485c1ef3 CDP: implement Page.reload
Add `Page.reload` to the CDP Page domain dispatch. Reuses the existing
`page.navigate()` path with `NavigationKind.reload`, matching what
`Location.reload` already does for the JS `location.reload()` API.

Accepts the standard CDP params (`ignoreCache`, `scriptToEvaluateOnLoad`)
per the Chrome DevTools Protocol spec.

The current page URL is copied to the stack before `replacePage()` to
avoid a use-after-free when the old page's arena is freed.

This unblocks CDP clients (Puppeteer, capybara-lightpanda, etc.) that
call `Page.reload` and currently get `UnknownMethod`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:09:48 +01:00
Karl Seguin
0324d5c232 Merge pull request #1997 from lightpanda-io/update-zig-v8
build: bump zig-v8 to v0.3.7
2026-03-26 16:01:40 +08:00
Karl Seguin
0588cc374d Improve authority parsing
Only look for @ within the first part of the url (up to the first possible
separator, i.e /, # or ?). This fixes potentially incorrect (and insecure)
getOrigin and getHost, both of which use the new helper.

Also make port parsing IPv6-aware.
2026-03-26 13:22:56 +08:00
Adrià Arrufat
a75c0cf08d build: bump zig-v8 to v0.3.7 2026-03-26 12:34:10 +09:00
Karl Seguin
2812b8f07c Merge pull request #1991 from lightpanda-io/v8_signature
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Set v8::Signature on FunctionTemplates
2026-03-26 09:27:22 +08:00
Karl Seguin
e2afbec29d update v8 dep 2026-03-26 09:17:32 +08:00
Karl Seguin
a45f9cb810 Set v8::Signature on FunctionTemplates
This causes v8 to verify the receiver of a function, and prevents calling an
accessor or function with the wrong receiver, e.g.:

```
const g = Object.getOwnPropertyDescriptor(Window.prototype, 'document').get;
g.call(null);
```

A few other cleanups in this commit:
1 - Define any accessor with a getter as ReadOnly
2 - Ability to define an accessor with the DontDelete attribute
    (window.document and window.location)
3 - Replace v8__ObjectTemplate__SetAccessorProperty__DEFAULTX overloads with
    new v8__ObjectTemplate__SetAccessorProperty__Config
4 - Remove unnecessary @constCast for FunctionTemplate which can be const
    everywhere.
2026-03-26 09:15:33 +08:00
80 changed files with 1489 additions and 1082 deletions

View File

@@ -13,7 +13,7 @@ inputs:
zig-v8: zig-v8:
description: 'zig v8 version to install' description: 'zig v8 version to install'
required: false required: false
default: 'v0.3.4' default: 'v0.3.7'
v8: v8:
description: 'v8 version to install' description: 'v8 version to install'
required: false required: false

View File

@@ -66,7 +66,8 @@ jobs:
name: demo-scripts name: demo-scripts
needs: zig-build-release needs: zig-build-release
runs-on: ubuntu-latest #runs-on: ubuntu-latest
runs-on: lpd-bench-hetzner
timeout-minutes: 15 timeout-minutes: 15
steps: steps:

View File

@@ -7,7 +7,7 @@ env:
AWS_REGION: ${{ vars.NIGHTLY_BUILD_AWS_REGION }} AWS_REGION: ${{ vars.NIGHTLY_BUILD_AWS_REGION }}
RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }} RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }}
VERSION: ${{ github.ref_type == 'tag' && github.ref_name || '1.0.0-nightly' }} VERSION_FLAG: ${{ github.ref_type == 'tag' && format('-Dversion={0}', github.ref_name) || '-Dversion=nightly' }}
on: on:
push: push:
@@ -45,7 +45,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build - name: zig build
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dversion_string=${{ env.VERSION }} run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 ${{ env.VERSION_FLAG }}
- name: Rename binary - name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -85,7 +85,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build - name: zig build
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic -Dversion_string=${{ env.VERSION }} run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic ${{ env.VERSION_FLAG }}
- name: Rename binary - name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -127,7 +127,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build - name: zig build
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dversion_string=${{ env.VERSION }} run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast ${{ env.VERSION_FLAG }}
- name: Rename binary - name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -167,7 +167,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build - name: zig build
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dversion_string=${{ env.VERSION }} run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast ${{ env.VERSION_FLAG }}
- name: Rename binary - name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}

View File

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

View File

@@ -170,6 +170,7 @@ You may still encounter errors or crashes. Please open an issue with specifics i
Here are the key features we have implemented: Here are the key features we have implemented:
- [ ] CORS [#2015](https://github.com/lightpanda-io/browser/issues/2015)
- [x] HTTP loader ([Libcurl](https://curl.se/libcurl/)) - [x] HTTP loader ([Libcurl](https://curl.se/libcurl/))
- [x] HTML parser ([html5ever](https://github.com/servo/html5ever)) - [x] HTML parser ([html5ever](https://github.com/servo/html5ever))
- [x] DOM tree - [x] DOM tree

View File

@@ -719,30 +719,45 @@ fn buildCurl(
return lib; return lib;
} }
/// Returns `MAJOR.MINOR.PATCH-dev` when `git describe` fails. /// Resolves the semantic version of the build.
///
/// The base version is read from `build.zig.zon`. This can be overridden
/// using the `-Dversion` command-line flag:
/// - If the flag contains a full semantic version (e.g., `1.2.3`), it replaces
/// the base version entirely.
/// - If the flag contains a simple string (e.g., `nightly`), it replaces only
/// the pre-release tag of the base version (e.g., `1.0.0-dev` -> `1.0.0-nightly`).
///
/// For versions that have a pre-release tag and no explicit build metadata,
/// this function automatically enriches the version with the git commit count
/// and short hash (e.g., `1.0.0-dev.5243+dbe45229`).
fn resolveVersion(b: *std.Build) std.SemanticVersion { fn resolveVersion(b: *std.Build) std.SemanticVersion {
const version_string = b.option([]const u8, "version_string", "Override the version of this build"); const opt_version = b.option([]const u8, "version", "Override the version of this build");
if (version_string) |semver_string| {
return std.SemanticVersion.parse(semver_string) catch |err| {
std.debug.panic("Expected -Dversion-string={s} to be a semantic version: {}", .{ semver_string, err });
};
}
// If it's a stable release (no pre or build metadata in build.zig.zon), use it as is const version = if (opt_version) |v|
if (lightpanda_version.pre == null and lightpanda_version.build == null) return lightpanda_version; std.SemanticVersion.parse(v) catch blk: {
var fallback = lightpanda_version;
fallback.pre = v;
break :blk fallback;
}
else
lightpanda_version;
// Only enrich versions that have a pre-release field and no explicit build metadata.
if (version.pre == null or version.build != null) return version;
// For dev/nightly versions, calculate the commit count and hash // For dev/nightly versions, calculate the commit count and hash
const git_hash_raw = runGit(b, &.{ "rev-parse", "--short", "HEAD" }) catch return lightpanda_version; const git_hash_raw = runGit(b, &.{ "rev-parse", "--short", "HEAD" }) catch return version;
const commit_hash = std.mem.trim(u8, git_hash_raw, " \n\r"); const commit_hash = std.mem.trim(u8, git_hash_raw, " \n\r");
const git_count_raw = runGit(b, &.{ "rev-list", "--count", "HEAD" }) catch return lightpanda_version; const git_count_raw = runGit(b, &.{ "rev-list", "--count", "HEAD" }) catch return version;
const commit_count = std.mem.trim(u8, git_count_raw, " \n\r"); const commit_count = std.mem.trim(u8, git_count_raw, " \n\r");
return .{ return .{
.major = lightpanda_version.major, .major = version.major,
.minor = lightpanda_version.minor, .minor = version.minor,
.patch = lightpanda_version.patch, .patch = version.patch,
.pre = b.fmt("{s}.{s}", .{ lightpanda_version.pre.?, commit_count }), .pre = b.fmt("{s}.{s}", .{ version.pre.?, commit_count }),
.build = commit_hash, .build = commit_hash,
}; };
} }

View File

@@ -5,8 +5,8 @@
.minimum_zig_version = "0.15.2", .minimum_zig_version = "0.15.2",
.dependencies = .{ .dependencies = .{
.v8 = .{ .v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.5.tar.gz", .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.7.tar.gz",
.hash = "v8-0.0.0-xddH66Z5BABx8CmC6u6qNOjrT4_42uliDSnA7Yg0pcBe", .hash = "v8-0.0.0-xddH67uBBAD95hWsPQz3Ni1PlZjdywtPXrGUAp8rSKco",
}, },
// .v8 = .{ .path = "../zig-v8-fork" }, // .v8 = .{ .path = "../zig-v8-fork" },
.brotli = .{ .brotli = .{

View File

@@ -18,6 +18,7 @@
const std = @import("std"); const std = @import("std");
const builtin = @import("builtin"); const builtin = @import("builtin");
const log = @import("log.zig");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator; const ArenaAllocator = std.heap.ArenaAllocator;
@@ -62,7 +63,7 @@ pub fn deinit(self: *ArenaPool) void {
var it = self._leak_track.iterator(); var it = self._leak_track.iterator();
while (it.next()) |kv| { while (it.next()) |kv| {
if (kv.value_ptr.* != 0) { if (kv.value_ptr.* != 0) {
std.debug.print("ArenaPool leak detected: '{s}' count={d}\n", .{ kv.key_ptr.*, kv.value_ptr.* }); log.err(.bug, "ArenaPool leak", .{ .name = kv.key_ptr.*, .count = kv.value_ptr.* });
has_leaks = true; has_leaks = true;
} }
} }
@@ -129,11 +130,11 @@ pub fn release(self: *ArenaPool, allocator: Allocator) void {
if (self._leak_track.getPtr(entry.debug)) |count| { if (self._leak_track.getPtr(entry.debug)) |count| {
count.* -= 1; count.* -= 1;
if (count.* < 0) { if (count.* < 0) {
std.debug.print("ArenaPool double-free detected: '{s}'\n", .{entry.debug}); log.err(.bug, "ArenaPool double-free", .{ .name = entry.debug });
@panic("ArenaPool: double-free detected"); @panic("ArenaPool: double-free detected");
} }
} else { } else {
std.debug.print("ArenaPool release of untracked arena: '{s}'\n", .{entry.debug}); log.err(.bug, "ArenaPool release unknown", .{ .name = entry.debug });
@panic("ArenaPool: release of untracked arena"); @panic("ArenaPool: release of untracked arena");
} }
} }

View File

@@ -247,7 +247,7 @@ pub const Fetch = struct {
with_frames: bool = false, with_frames: bool = false,
strip: dump.Opts.Strip = .{}, strip: dump.Opts.Strip = .{},
wait_ms: u32 = 5000, wait_ms: u32 = 5000,
wait_until: WaitUntil = .load, wait_until: WaitUntil = .done,
}; };
pub const Common = struct { pub const Common = struct {
@@ -665,7 +665,7 @@ fn parseFetchArgs(
var common: Common = .{}; var common: Common = .{};
var strip: dump.Opts.Strip = .{}; var strip: dump.Opts.Strip = .{};
var wait_ms: u32 = 5000; var wait_ms: u32 = 5000;
var wait_until: WaitUntil = .load; var wait_until: WaitUntil = .done;
while (args.next()) |opt| { while (args.next()) |opt| {
if (std.mem.eql(u8, "--wait-ms", opt) or std.mem.eql(u8, "--wait_ms", opt)) { if (std.mem.eql(u8, "--wait-ms", opt) or std.mem.eql(u8, "--wait_ms", opt)) {

View File

@@ -74,6 +74,8 @@ const EventListeners = struct {
page_network_idle: List = .{}, page_network_idle: List = .{},
page_network_almost_idle: List = .{}, page_network_almost_idle: List = .{},
page_frame_created: List = .{}, page_frame_created: List = .{},
page_dom_content_loaded: List = .{},
page_loaded: List = .{},
http_request_fail: List = .{}, http_request_fail: List = .{},
http_request_start: List = .{}, http_request_start: List = .{},
http_request_intercept: List = .{}, http_request_intercept: List = .{},
@@ -91,6 +93,8 @@ const Events = union(enum) {
page_network_idle: *const PageNetworkIdle, page_network_idle: *const PageNetworkIdle,
page_network_almost_idle: *const PageNetworkAlmostIdle, page_network_almost_idle: *const PageNetworkAlmostIdle,
page_frame_created: *const PageFrameCreated, page_frame_created: *const PageFrameCreated,
page_dom_content_loaded: *const PageDOMContentLoaded,
page_loaded: *const PageLoaded,
http_request_fail: *const RequestFail, http_request_fail: *const RequestFail,
http_request_start: *const RequestStart, http_request_start: *const RequestStart,
http_request_intercept: *const RequestIntercept, http_request_intercept: *const RequestIntercept,
@@ -137,6 +141,18 @@ pub const PageFrameCreated = struct {
timestamp: u64, timestamp: u64,
}; };
pub const PageDOMContentLoaded = struct {
req_id: u32,
frame_id: u32,
timestamp: u64,
};
pub const PageLoaded = struct {
req_id: u32,
frame_id: u32,
timestamp: u64,
};
pub const RequestStart = struct { pub const RequestStart = struct {
transfer: *Transfer, transfer: *Transfer,
}; };

View File

@@ -22,7 +22,6 @@ const net = std.net;
const posix = std.posix; const posix = std.posix;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const log = @import("log.zig"); const log = @import("log.zig");
const App = @import("App.zig"); const App = @import("App.zig");

View File

@@ -19,17 +19,13 @@
const std = @import("std"); const std = @import("std");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const js = @import("js/js.zig"); const js = @import("js/js.zig");
const log = @import("../log.zig");
const App = @import("../App.zig"); const App = @import("../App.zig");
const HttpClient = @import("HttpClient.zig"); const HttpClient = @import("HttpClient.zig");
const ArenaPool = App.ArenaPool; const ArenaPool = App.ArenaPool;
const IS_DEBUG = @import("builtin").mode == .Debug;
const Session = @import("Session.zig"); const Session = @import("Session.zig");
const Notification = @import("../Notification.zig"); const Notification = @import("../Notification.zig");

View File

@@ -425,7 +425,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
ls.deinit(); ls.deinit();
} }
const activation_state = ActivationState.create(event, target, page); const activation_state = try ActivationState.create(event, target, page);
// Defer runs even on early return - ensures event phase is reset // Defer runs even on early return - ensures event phase is reset
// and default actions execute (unless prevented) // and default actions execute (unless prevented)
@@ -820,7 +820,7 @@ const ActivationState = struct {
const Input = Element.Html.Input; const Input = Element.Html.Input;
fn create(event: *const Event, target: *Node, page: *Page) ?ActivationState { fn create(event: *const Event, target: *Node, page: *Page) !?ActivationState {
if (event._type_string.eql(comptime .wrap("click")) == false) { if (event._type_string.eql(comptime .wrap("click")) == false) {
return null; return null;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -27,7 +27,6 @@ const IS_DEBUG = builtin.mode == .Debug;
const log = @import("../log.zig"); const log = @import("../log.zig");
const App = @import("../App.zig");
const String = @import("../string.zig").String; const String = @import("../string.zig").String;
const Mime = @import("Mime.zig"); const Mime = @import("Mime.zig");
@@ -43,7 +42,6 @@ const URL = @import("URL.zig");
const Blob = @import("webapi/Blob.zig"); const Blob = @import("webapi/Blob.zig");
const Node = @import("webapi/Node.zig"); const Node = @import("webapi/Node.zig");
const Event = @import("webapi/Event.zig"); const Event = @import("webapi/Event.zig");
const EventTarget = @import("webapi/EventTarget.zig");
const CData = @import("webapi/CData.zig"); const CData = @import("webapi/CData.zig");
const Element = @import("webapi/Element.zig"); const Element = @import("webapi/Element.zig");
const HtmlElement = @import("webapi/element/Html.zig"); const HtmlElement = @import("webapi/element/Html.zig");
@@ -59,7 +57,6 @@ const AbstractRange = @import("webapi/AbstractRange.zig");
const MutationObserver = @import("webapi/MutationObserver.zig"); const MutationObserver = @import("webapi/MutationObserver.zig");
const IntersectionObserver = @import("webapi/IntersectionObserver.zig"); const IntersectionObserver = @import("webapi/IntersectionObserver.zig");
const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig"); const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig");
const storage = @import("webapi/storage/storage.zig");
const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig"); const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig");
const SubmitEvent = @import("webapi/event/SubmitEvent.zig"); const SubmitEvent = @import("webapi/event/SubmitEvent.zig");
const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind; const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind;
@@ -67,7 +64,6 @@ const KeyboardEvent = @import("webapi/event/KeyboardEvent.zig");
const MouseEvent = @import("webapi/event/MouseEvent.zig"); const MouseEvent = @import("webapi/event/MouseEvent.zig");
const HttpClient = @import("HttpClient.zig"); const HttpClient = @import("HttpClient.zig");
const ArenaPool = App.ArenaPool;
const timestamp = @import("../datetime.zig").timestamp; const timestamp = @import("../datetime.zig").timestamp;
const milliTimestamp = @import("../datetime.zig").milliTimestamp; const milliTimestamp = @import("../datetime.zig").milliTimestamp;
@@ -385,12 +381,9 @@ pub fn getTitle(self: *Page) !?[]const u8 {
return null; return null;
} }
// Add comon headers for a request: // Add common headers for a request:
// * cookies
// * referer // * referer
pub fn headersForRequest(self: *Page, temp: Allocator, url: [:0]const u8, headers: *HttpClient.Headers) !void { pub fn headersForRequest(self: *Page, headers: *HttpClient.Headers) !void {
try self.requestCookie(.{}).headersForRequest(temp, url, headers);
// Build the referer // Build the referer
const referer = blk: { const referer = blk: {
if (self.referer_header == null) { if (self.referer_header == null) {
@@ -494,7 +487,6 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
return error.InjectBlankFailed; return error.InjectBlankFailed;
}; };
} }
self.documentIsComplete();
session.notification.dispatch(.page_navigate, &.{ session.notification.dispatch(.page_navigate, &.{
.frame_id = self._frame_id, .frame_id = self._frame_id,
@@ -526,6 +518,8 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
// force next request id manually b/c we won't create a real req. // force next request id manually b/c we won't create a real req.
_ = session.browser.http_client.incrReqId(); _ = session.browser.http_client.incrReqId();
self.documentIsComplete();
return; return;
} }
@@ -545,8 +539,6 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
if (opts.header) |hdr| { if (opts.header) |hdr| {
try headers.add(hdr); try headers.add(hdr);
} }
try self.requestCookie(.{ .is_navigation = true }).headersForRequest(self.arena, self.url, &headers);
// We dispatch page_navigate event before sending the request. // We dispatch page_navigate event before sending the request.
// It ensures the event page_navigated is not dispatched before this one. // It ensures the event page_navigated is not dispatched before this one.
session.notification.dispatch(.page_navigate, &.{ session.notification.dispatch(.page_navigate, &.{
@@ -573,6 +565,7 @@ pub fn navigate(self: *Page, request_url: [:0]const u8, opts: NavigateOpts) !voi
.headers = headers, .headers = headers,
.body = opts.body, .body = opts.body,
.cookie_jar = &session.cookie_jar, .cookie_jar = &session.cookie_jar,
.cookie_origin = self.url,
.resource_type = .document, .resource_type = .document,
.notification = self._session.notification, .notification = self._session.notification,
.header_callback = pageHeaderDoneCallback, .header_callback = pageHeaderDoneCallback,
@@ -746,6 +739,12 @@ pub fn _documentIsLoaded(self: *Page) !void {
self.document.asEventTarget(), self.document.asEventTarget(),
event, event,
); );
self._session.notification.dispatch(.page_dom_content_loaded, &.{
.frame_id = self._frame_id,
.req_id = self._req_id,
.timestamp = timestamp(.monotonic),
});
} }
pub fn scriptsCompletedLoading(self: *Page) void { pub fn scriptsCompletedLoading(self: *Page) void {
@@ -804,19 +803,6 @@ pub fn documentIsComplete(self: *Page) void {
self._documentIsComplete() catch |err| { self._documentIsComplete() catch |err| {
log.err(.page, "document is complete", .{ .err = err, .type = self._type, .url = self.url }); log.err(.page, "document is complete", .{ .err = err, .type = self._type, .url = self.url });
}; };
if (self._navigated_options) |no| {
// _navigated_options will be null in special short-circuit cases, like
// "navigating" to about:blank, in which case this notification has
// already been sent
self._session.notification.dispatch(.page_navigated, &.{
.frame_id = self._frame_id,
.req_id = self._req_id,
.opts = no,
.url = self.url,
.timestamp = timestamp(.monotonic),
});
}
} }
fn _documentIsComplete(self: *Page) !void { fn _documentIsComplete(self: *Page) !void {
@@ -835,6 +821,12 @@ fn _documentIsComplete(self: *Page) !void {
try self._event_manager.dispatchDirect(window_target, event, self.window._on_load, .{ .inject_target = false, .context = "page load" }); try self._event_manager.dispatchDirect(window_target, event, self.window._on_load, .{ .inject_target = false, .context = "page load" });
} }
self._session.notification.dispatch(.page_loaded, &.{
.frame_id = self._frame_id,
.req_id = self._req_id,
.timestamp = timestamp(.monotonic),
});
if (self._event_manager.hasDirectListeners(window_target, "pageshow", self.window._on_pageshow)) { if (self._event_manager.hasDirectListeners(window_target, "pageshow", self.window._on_pageshow)) {
const pageshow_event = (try PageTransitionEvent.initTrusted(comptime .wrap("pageshow"), .{}, self)).asEvent(); const pageshow_event = (try PageTransitionEvent.initTrusted(comptime .wrap("pageshow"), .{}, self)).asEvent();
try self._event_manager.dispatchDirect(window_target, pageshow_event, self.window._on_pageshow, .{ .context = "page show" }); try self._event_manager.dispatchDirect(window_target, pageshow_event, self.window._on_pageshow, .{ .context = "page show" });
@@ -887,6 +879,19 @@ fn pageHeaderDoneCallback(transfer: *HttpClient.Transfer) !bool {
}); });
} }
if (self._navigated_options) |no| {
// _navigated_options will be null in special short-circuit cases, like
// "navigating" to about:blank, in which case this notification has
// already been sent
self._session.notification.dispatch(.page_navigated, &.{
.frame_id = self._frame_id,
.req_id = self._req_id,
.opts = no,
.url = self.url,
.timestamp = timestamp(.monotonic),
});
}
return true; return true;
} }
@@ -1036,6 +1041,7 @@ fn pageDoneCallback(ctx: *anyopaque) !void {
}); });
parser.parse(html); parser.parse(html);
self._parse_state = .complete;
self.documentIsComplete(); self.documentIsComplete();
}, },
else => unreachable, else => unreachable,
@@ -3554,19 +3560,6 @@ pub fn insertText(self: *Page, v: []const u8) !void {
} }
} }
const RequestCookieOpts = struct {
is_http: bool = true,
is_navigation: bool = false,
};
pub fn requestCookie(self: *const Page, opts: RequestCookieOpts) HttpClient.RequestCookie {
return .{
.jar = &self._session.cookie_jar,
.origin = self.url,
.is_http = opts.is_http,
.is_navigation = opts.is_navigation,
};
}
fn asUint(comptime string: anytype) std.meta.Int( fn asUint(comptime string: anytype) std.meta.Int(
.unsigned, .unsigned,
@bitSizeOf(@TypeOf(string.*)) - 8, // (- 8) to exclude sentinel 0 @bitSizeOf(@TypeOf(string.*)) - 8, // (- 8) to exclude sentinel 0

View File

@@ -21,12 +21,9 @@ const lp = @import("lightpanda");
const builtin = @import("builtin"); const builtin = @import("builtin");
const log = @import("../log.zig"); const log = @import("../log.zig");
const App = @import("../App.zig");
const Page = @import("Page.zig"); const Page = @import("Page.zig");
const Session = @import("Session.zig"); const Session = @import("Session.zig");
const Browser = @import("Browser.zig");
const Factory = @import("Factory.zig");
const HttpClient = @import("HttpClient.zig"); const HttpClient = @import("HttpClient.zig");
const IS_DEBUG = builtin.mode == .Debug; const IS_DEBUG = builtin.mode == .Debug;

View File

@@ -28,12 +28,10 @@ const String = @import("../string.zig").String;
const js = @import("js/js.zig"); const js = @import("js/js.zig");
const URL = @import("URL.zig"); const URL = @import("URL.zig");
const Page = @import("Page.zig"); const Page = @import("Page.zig");
const Browser = @import("Browser.zig");
const Element = @import("webapi/Element.zig"); const Element = @import("webapi/Element.zig");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const ArrayList = std.ArrayList;
const IS_DEBUG = builtin.mode == .Debug; const IS_DEBUG = builtin.mode == .Debug;
@@ -138,9 +136,9 @@ fn clearList(list: *std.DoublyLinkedList) void {
} }
} }
fn getHeaders(self: *ScriptManager, arena: Allocator, url: [:0]const u8) !net_http.Headers { fn getHeaders(self: *ScriptManager) !net_http.Headers {
var headers = try self.client.newHeaders(); var headers = try self.client.newHeaders();
try self.page.headersForRequest(arena, url, &headers); try self.page.headersForRequest(&headers);
return headers; return headers;
} }
@@ -280,9 +278,10 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
.ctx = script, .ctx = script,
.method = .GET, .method = .GET,
.frame_id = page._frame_id, .frame_id = page._frame_id,
.headers = try self.getHeaders(arena, url), .headers = try self.getHeaders(),
.blocking = is_blocking, .blocking = is_blocking,
.cookie_jar = &page._session.cookie_jar, .cookie_jar = &page._session.cookie_jar,
.cookie_origin = page.url,
.resource_type = .script, .resource_type = .script,
.notification = page._session.notification, .notification = page._session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null, .start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
@@ -405,8 +404,9 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const
.ctx = script, .ctx = script,
.method = .GET, .method = .GET,
.frame_id = page._frame_id, .frame_id = page._frame_id,
.headers = try self.getHeaders(arena, url), .headers = try self.getHeaders(),
.cookie_jar = &page._session.cookie_jar, .cookie_jar = &page._session.cookie_jar,
.cookie_origin = page.url,
.resource_type = .script, .resource_type = .script,
.notification = page._session.notification, .notification = page._session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null, .start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
@@ -508,10 +508,11 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C
.url = url, .url = url,
.method = .GET, .method = .GET,
.frame_id = page._frame_id, .frame_id = page._frame_id,
.headers = try self.getHeaders(arena, url), .headers = try self.getHeaders(),
.ctx = script, .ctx = script,
.resource_type = .script, .resource_type = .script,
.cookie_jar = &page._session.cookie_jar, .cookie_jar = &page._session.cookie_jar,
.cookie_origin = page.url,
.notification = page._session.notification, .notification = page._session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null, .start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback, .header_callback = Script.headerCallback,
@@ -654,7 +655,6 @@ pub const Script = struct {
debug_transfer_aborted: bool = false, debug_transfer_aborted: bool = false,
debug_transfer_bytes_received: usize = 0, debug_transfer_bytes_received: usize = 0,
debug_transfer_notified_fail: bool = false, debug_transfer_notified_fail: bool = false,
debug_transfer_redirecting: bool = false,
debug_transfer_intercept_state: u8 = 0, debug_transfer_intercept_state: u8 = 0,
debug_transfer_auth_challenge: bool = false, debug_transfer_auth_challenge: bool = false,
debug_transfer_easy_id: usize = 0, debug_transfer_easy_id: usize = 0,
@@ -730,7 +730,6 @@ pub const Script = struct {
.a3 = self.debug_transfer_aborted, .a3 = self.debug_transfer_aborted,
.a4 = self.debug_transfer_bytes_received, .a4 = self.debug_transfer_bytes_received,
.a5 = self.debug_transfer_notified_fail, .a5 = self.debug_transfer_notified_fail,
.a6 = self.debug_transfer_redirecting,
.a7 = self.debug_transfer_intercept_state, .a7 = self.debug_transfer_intercept_state,
.a8 = self.debug_transfer_auth_challenge, .a8 = self.debug_transfer_auth_challenge,
.a9 = self.debug_transfer_easy_id, .a9 = self.debug_transfer_easy_id,
@@ -739,10 +738,9 @@ pub const Script = struct {
.b3 = transfer.aborted, .b3 = transfer.aborted,
.b4 = transfer.bytes_received, .b4 = transfer.bytes_received,
.b5 = transfer._notified_fail, .b5 = transfer._notified_fail,
.b6 = transfer._redirecting,
.b7 = @intFromEnum(transfer._intercept_state), .b7 = @intFromEnum(transfer._intercept_state),
.b8 = transfer._auth_challenge != null, .b8 = transfer._auth_challenge != null,
.b9 = if (transfer._conn) |c| @intFromPtr(c.easy) else 0, .b9 = if (transfer._conn) |c| @intFromPtr(c._easy) else 0,
}); });
self.header_callback_called = true; self.header_callback_called = true;
self.debug_transfer_id = transfer.id; self.debug_transfer_id = transfer.id;
@@ -750,10 +748,9 @@ pub const Script = struct {
self.debug_transfer_aborted = transfer.aborted; self.debug_transfer_aborted = transfer.aborted;
self.debug_transfer_bytes_received = transfer.bytes_received; self.debug_transfer_bytes_received = transfer.bytes_received;
self.debug_transfer_notified_fail = transfer._notified_fail; self.debug_transfer_notified_fail = transfer._notified_fail;
self.debug_transfer_redirecting = transfer._redirecting;
self.debug_transfer_intercept_state = @intFromEnum(transfer._intercept_state); self.debug_transfer_intercept_state = @intFromEnum(transfer._intercept_state);
self.debug_transfer_auth_challenge = transfer._auth_challenge != null; self.debug_transfer_auth_challenge = transfer._auth_challenge != null;
self.debug_transfer_easy_id = if (transfer._conn) |c| @intFromPtr(c.easy) else 0; self.debug_transfer_easy_id = if (transfer._conn) |c| @intFromPtr(c._easy) else 0;
} }
lp.assert(self.source.remote.capacity == 0, "ScriptManager.Header buffer", .{ .capacity = self.source.remote.capacity }); lp.assert(self.source.remote.capacity == 0, "ScriptManager.Header buffer", .{ .capacity = self.source.remote.capacity });

View File

@@ -357,25 +357,38 @@ pub fn isHTTPS(raw: [:0]const u8) bool {
pub fn getHostname(raw: [:0]const u8) []const u8 { pub fn getHostname(raw: [:0]const u8) []const u8 {
const host = getHost(raw); const host = getHost(raw);
const pos = std.mem.lastIndexOfScalar(u8, host, ':') orelse return host; const port_sep = findPortSeparator(host) orelse return host;
return host[0..pos]; return host[0..port_sep];
} }
pub fn getPort(raw: [:0]const u8) []const u8 { pub fn getPort(raw: [:0]const u8) []const u8 {
const host = getHost(raw); const host = getHost(raw);
const pos = std.mem.lastIndexOfScalar(u8, host, ':') orelse return ""; const port_sep = findPortSeparator(host) orelse return "";
return host[port_sep + 1 ..];
}
if (pos + 1 >= host.len) { // Finds the colon separating host from port, handling IPv6 bracket notation.
return ""; // For IPv6 like "[::1]:8080", returns position of ":" after "]".
// For IPv6 like "[::1]" (no port), returns null.
// For regular hosts, returns position of last ":" if followed by digits.
fn findPortSeparator(host: []const u8) ?usize {
if (host.len > 0 and host[0] == '[') {
// IPv6: find closing bracket, port separator must be after it
const bracket_end = std.mem.indexOfScalar(u8, host, ']') orelse return null;
if (bracket_end + 1 < host.len and host[bracket_end + 1] == ':') {
return bracket_end + 1;
}
return null;
} }
// Regular host: find last colon and verify it's followed by digits
const pos = std.mem.lastIndexOfScalar(u8, host, ':') orelse return null;
if (pos + 1 >= host.len) return null;
for (host[pos + 1 ..]) |c| { for (host[pos + 1 ..]) |c| {
if (c < '0' or c > '9') { if (c < '0' or c > '9') return null;
return "";
}
} }
return pos;
return host[pos + 1 ..];
} }
pub fn getSearch(raw: [:0]const u8) []const u8 { pub fn getSearch(raw: [:0]const u8) []const u8 {
@@ -403,21 +416,12 @@ pub fn getOrigin(allocator: Allocator, raw: [:0]const u8) !?[]const u8 {
return null; return null;
} }
var authority_start = scheme_end + 3; const auth = parseAuthority(raw) orelse return null;
const has_user_info = if (std.mem.indexOf(u8, raw[authority_start..], "@")) |pos| blk: { const has_user_info = auth.has_user_info;
authority_start += pos + 1; const authority_end = auth.host_end;
break :blk true;
} else false;
// Find end of authority (start of path/query/fragment or end of string)
const authority_end_relative = std.mem.indexOfAny(u8, raw[authority_start..], "/?#");
const authority_end = if (authority_end_relative) |end|
authority_start + end
else
raw.len;
// Check for port in the host:port section // Check for port in the host:port section
const host_part = raw[authority_start..authority_end]; const host_part = auth.getHost(raw);
if (std.mem.lastIndexOfScalar(u8, host_part, ':')) |colon_pos_in_host| { if (std.mem.lastIndexOfScalar(u8, host_part, ':')) |colon_pos_in_host| {
const port = host_part[colon_pos_in_host + 1 ..]; const port = host_part[colon_pos_in_host + 1 ..];
@@ -458,31 +462,18 @@ pub fn getOrigin(allocator: Allocator, raw: [:0]const u8) !?[]const u8 {
} }
fn getUserInfo(raw: [:0]const u8) ?[]const u8 { fn getUserInfo(raw: [:0]const u8) ?[]const u8 {
const scheme_end = std.mem.indexOf(u8, raw, "://") orelse return null; const auth = parseAuthority(raw) orelse return null;
if (!auth.has_user_info) return null;
// User info is from authority_start to host_start - 1 (excluding the @)
const scheme_end = std.mem.indexOf(u8, raw, "://").?;
const authority_start = scheme_end + 3; const authority_start = scheme_end + 3;
return raw[authority_start .. auth.host_start - 1];
const pos = std.mem.indexOfScalar(u8, raw[authority_start..], '@') orelse return null;
const path_start = std.mem.indexOfScalarPos(u8, raw, authority_start, '/') orelse raw.len;
const full_pos = authority_start + pos;
if (full_pos < path_start) {
return raw[authority_start..full_pos];
}
return null;
} }
pub fn getHost(raw: [:0]const u8) []const u8 { pub fn getHost(raw: [:0]const u8) []const u8 {
const scheme_end = std.mem.indexOf(u8, raw, "://") orelse return ""; const auth = parseAuthority(raw) orelse return "";
return auth.getHost(raw);
var authority_start = scheme_end + 3;
if (std.mem.indexOf(u8, raw[authority_start..], "@")) |pos| {
authority_start += pos + 1;
}
const authority = raw[authority_start..];
const path_start = std.mem.indexOfAny(u8, authority, "/?#") orelse return authority;
return authority[0..path_start];
} }
// Returns true if these two URLs point to the same document. // Returns true if these two URLs point to the same document.
@@ -761,6 +752,47 @@ pub fn unescape(arena: Allocator, input: []const u8) ![]const u8 {
return result.items; return result.items;
} }
const AuthorityInfo = struct {
host_start: usize,
host_end: usize,
has_user_info: bool,
fn getHost(self: AuthorityInfo, raw: []const u8) []const u8 {
return raw[self.host_start..self.host_end];
}
};
// Parses the authority component of a URL, correctly handling userinfo.
// Returns null if the URL doesn't have a valid scheme (no "://").
// SECURITY: Only looks for @ within the authority portion (before /?#)
// to prevent path-based @ injection attacks.
fn parseAuthority(raw: []const u8) ?AuthorityInfo {
const scheme_end = std.mem.indexOf(u8, raw, "://") orelse return null;
const authority_start = scheme_end + 3;
// Find end of authority FIRST (start of path/query/fragment or end of string)
const authority_end = if (std.mem.indexOfAny(u8, raw[authority_start..], "/?#")) |end|
authority_start + end
else
raw.len;
// Only look for @ within the authority portion, not in path/query/fragment
const authority_portion = raw[authority_start..authority_end];
if (std.mem.indexOf(u8, authority_portion, "@")) |pos| {
return .{
.host_start = authority_start + pos + 1,
.host_end = authority_end,
.has_user_info = true,
};
}
return .{
.host_start = authority_start,
.host_end = authority_end,
.has_user_info = false,
};
}
const testing = @import("../testing.zig"); const testing = @import("../testing.zig");
test "URL: isCompleteHTTPUrl" { test "URL: isCompleteHTTPUrl" {
try testing.expectEqual(true, isCompleteHTTPUrl("http://example.com/about")); try testing.expectEqual(true, isCompleteHTTPUrl("http://example.com/about"));
@@ -1429,6 +1461,42 @@ test "URL: getHost" {
try testing.expectEqualSlices(u8, "example.com", getHost("https://user:pass@example.com/page")); try testing.expectEqualSlices(u8, "example.com", getHost("https://user:pass@example.com/page"));
try testing.expectEqualSlices(u8, "example.com:8080", getHost("https://user:pass@example.com:8080/page")); try testing.expectEqualSlices(u8, "example.com:8080", getHost("https://user:pass@example.com:8080/page"));
try testing.expectEqualSlices(u8, "", getHost("not-a-url")); try testing.expectEqualSlices(u8, "", getHost("not-a-url"));
// SECURITY: @ in path must NOT be treated as userinfo separator
try testing.expectEqualSlices(u8, "evil.example.com", getHost("http://evil.example.com/@victim.example.com/"));
try testing.expectEqualSlices(u8, "evil.example.com", getHost("https://evil.example.com/path/@victim.example.com"));
// IPv6 addresses
try testing.expectEqualSlices(u8, "[::1]:8080", getHost("http://[::1]:8080/path"));
try testing.expectEqualSlices(u8, "[::1]", getHost("http://[::1]/path"));
try testing.expectEqualSlices(u8, "[2001:db8::1]", getHost("https://[2001:db8::1]/"));
}
test "URL: getHostname" {
// Regular hosts
try testing.expectEqualSlices(u8, "example.com", getHostname("https://example.com:8080/path"));
try testing.expectEqualSlices(u8, "example.com", getHostname("https://example.com/path"));
// IPv6 with port
try testing.expectEqualSlices(u8, "[::1]", getHostname("http://[::1]:8080/path"));
// IPv6 without port - must return full bracket notation
try testing.expectEqualSlices(u8, "[::1]", getHostname("http://[::1]/path"));
try testing.expectEqualSlices(u8, "[2001:db8::1]", getHostname("https://[2001:db8::1]/"));
}
test "URL: getPort" {
// Regular hosts
try testing.expectEqualSlices(u8, "8080", getPort("https://example.com:8080/path"));
try testing.expectEqualSlices(u8, "", getPort("https://example.com/path"));
// IPv6 with port
try testing.expectEqualSlices(u8, "8080", getPort("http://[::1]:8080/path"));
try testing.expectEqualSlices(u8, "3000", getPort("http://[2001:db8::1]:3000/"));
// IPv6 without port - colons inside brackets must not be treated as port separator
try testing.expectEqualSlices(u8, "", getPort("http://[::1]/path"));
try testing.expectEqualSlices(u8, "", getPort("https://[2001:db8::1]/"));
} }
test "URL: setPathname percent-encodes" { test "URL: setPathname percent-encodes" {
@@ -1449,3 +1517,56 @@ test "URL: setPathname percent-encodes" {
const result3 = try setPathname("https://example.com/path?a=b#hash", "/new path", allocator); const result3 = try setPathname("https://example.com/path?a=b#hash", "/new path", allocator);
try testing.expectEqualSlices(u8, "https://example.com/new%20path?a=b#hash", result3); try testing.expectEqualSlices(u8, "https://example.com/new%20path?a=b#hash", result3);
} }
test "URL: getOrigin" {
defer testing.reset();
const Case = struct {
url: [:0]const u8,
expected: ?[]const u8,
};
const cases = [_]Case{
// Basic HTTP/HTTPS origins
.{ .url = "http://example.com/path", .expected = "http://example.com" },
.{ .url = "https://example.com/path", .expected = "https://example.com" },
.{ .url = "https://example.com:8080/path", .expected = "https://example.com:8080" },
// Default ports should be stripped
.{ .url = "http://example.com:80/path", .expected = "http://example.com" },
.{ .url = "https://example.com:443/path", .expected = "https://example.com" },
// User info should be stripped from origin
.{ .url = "http://user:pass@example.com/path", .expected = "http://example.com" },
.{ .url = "https://user@example.com:8080/path", .expected = "https://example.com:8080" },
// Non-HTTP schemes return null
.{ .url = "ftp://example.com/path", .expected = null },
.{ .url = "file:///path/to/file", .expected = null },
.{ .url = "about:blank", .expected = null },
// Query and fragment should not affect origin
.{ .url = "https://example.com?query=1", .expected = "https://example.com" },
.{ .url = "https://example.com#fragment", .expected = "https://example.com" },
.{ .url = "https://example.com/path?q=1#frag", .expected = "https://example.com" },
// SECURITY: @ in path must NOT be treated as userinfo separator
// This would be a Same-Origin Policy bypass if mishandled
.{ .url = "http://evil.example.com/@victim.example.com/", .expected = "http://evil.example.com" },
.{ .url = "https://evil.example.com/path/@victim.example.com/steal", .expected = "https://evil.example.com" },
.{ .url = "http://evil.example.com/@victim.example.com:443/", .expected = "http://evil.example.com" },
// @ in query/fragment must also not affect origin
.{ .url = "https://example.com/path?user=foo@bar.com", .expected = "https://example.com" },
.{ .url = "https://example.com/path#user@host", .expected = "https://example.com" },
};
for (cases) |case| {
const result = try getOrigin(testing.arena_allocator, case.url);
if (case.expected) |expected| {
try testing.expectString(expected, result.?);
} else {
try testing.expectEqual(null, result);
}
}
}

View File

@@ -36,6 +36,7 @@ pub const InteractivityType = enum {
}; };
pub const InteractiveElement = struct { pub const InteractiveElement = struct {
backendNodeId: ?u32 = null,
node: *Node, node: *Node,
tag_name: []const u8, tag_name: []const u8,
role: ?[]const u8, role: ?[]const u8,
@@ -55,6 +56,11 @@ pub const InteractiveElement = struct {
pub fn jsonStringify(self: *const InteractiveElement, jw: anytype) !void { pub fn jsonStringify(self: *const InteractiveElement, jw: anytype) !void {
try jw.beginObject(); try jw.beginObject();
if (self.backendNodeId) |id| {
try jw.objectField("backendNodeId");
try jw.write(id);
}
try jw.objectField("tagName"); try jw.objectField("tagName");
try jw.write(self.tag_name); try jw.write(self.tag_name);
@@ -123,6 +129,15 @@ pub const InteractiveElement = struct {
} }
}; };
/// Populate backendNodeId on each interactive element by registering
/// their nodes in the given registry. Works with both CDP and MCP registries.
pub fn registerNodes(elements: []InteractiveElement, registry: anytype) !void {
for (elements) |*el| {
const registered = try registry.register(el.node);
el.backendNodeId = registered.id;
}
}
/// Collect all interactive elements under `root`. /// Collect all interactive elements under `root`.
pub fn collectInteractiveElements( pub fn collectInteractiveElements(
root: *Node, root: *Node,

View File

@@ -128,7 +128,7 @@ fn _constructor(self: *Caller, func: anytype, info: FunctionCallbackInfo) !void
const new_this_handle = info.getThis(); const new_this_handle = info.getThis();
var this = js.Object{ .local = local, .handle = new_this_handle }; var this = js.Object{ .local = local, .handle = new_this_handle };
if (@typeInfo(ReturnType) == .error_union) { if (@typeInfo(ReturnType) == .error_union) {
const non_error_res = res catch |err| return err; const non_error_res = try res;
this = try local.mapZigInstanceToJs(new_this_handle, non_error_res); this = try local.mapZigInstanceToJs(new_this_handle, non_error_res);
} else { } else {
this = try local.mapZigInstanceToJs(new_this_handle, res); this = try local.mapZigInstanceToJs(new_this_handle, res);
@@ -505,6 +505,7 @@ pub const Function = struct {
pub const Opts = struct { pub const Opts = struct {
noop: bool = false, noop: bool = false,
static: bool = false, static: bool = false,
deletable: bool = true,
dom_exception: bool = false, dom_exception: bool = false,
as_typed_array: bool = false, as_typed_array: bool = false,
null_as_undefined: bool = false, null_as_undefined: bool = false,

View File

@@ -22,7 +22,6 @@ const log = @import("../../log.zig");
const js = @import("js.zig"); const js = @import("js.zig");
const Env = @import("Env.zig"); const Env = @import("Env.zig");
const bridge = @import("bridge.zig");
const Origin = @import("Origin.zig"); const Origin = @import("Origin.zig");
const Scheduler = @import("Scheduler.zig"); const Scheduler = @import("Scheduler.zig");

View File

@@ -26,7 +26,6 @@ const App = @import("../../App.zig");
const log = @import("../../log.zig"); const log = @import("../../log.zig");
const bridge = @import("bridge.zig"); const bridge = @import("bridge.zig");
const Origin = @import("Origin.zig");
const Context = @import("Context.zig"); const Context = @import("Context.zig");
const Isolate = @import("Isolate.zig"); const Isolate = @import("Isolate.zig");
const Platform = @import("Platform.zig"); const Platform = @import("Platform.zig");
@@ -34,7 +33,6 @@ const Snapshot = @import("Snapshot.zig");
const Inspector = @import("Inspector.zig"); const Inspector = @import("Inspector.zig");
const Page = @import("../Page.zig"); const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const Window = @import("../webapi/Window.zig"); const Window = @import("../webapi/Window.zig");
const JsApis = bridge.JsApis; const JsApis = bridge.JsApis;

View File

@@ -21,7 +21,6 @@ const js = @import("js.zig");
const v8 = js.v8; const v8 = js.v8;
const log = @import("../../log.zig"); const log = @import("../../log.zig");
const Session = @import("../Session.zig");
const Function = @This(); const Function = @This();

View File

@@ -32,7 +32,6 @@ const js = @import("js.zig");
const Session = @import("../Session.zig"); const Session = @import("../Session.zig");
const v8 = js.v8; const v8 = js.v8;
const Allocator = std.mem.Allocator;
const Identity = @This(); const Identity = @This();

View File

@@ -17,7 +17,6 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std"); const std = @import("std");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig"); const Session = @import("../Session.zig");
const log = @import("../../log.zig"); const log = @import("../../log.zig");
const string = @import("../../string.zig"); const string = @import("../../string.zig");
@@ -33,7 +32,6 @@ const IS_DEBUG = @import("builtin").mode == .Debug;
const v8 = js.v8; const v8 = js.v8;
const CallOpts = Caller.CallOpts; const CallOpts = Caller.CallOpts;
const Allocator = std.mem.Allocator;
// Where js.Context has a lifetime tied to the page, and holds the // Where js.Context has a lifetime tied to the page, and holds the
// v8::Global<v8::Context>, this has a much shorter lifetime and holds a // v8::Global<v8::Context>, this has a much shorter lifetime and holds a

View File

@@ -20,8 +20,6 @@ const std = @import("std");
const js = @import("js.zig"); const js = @import("js.zig");
const v8 = js.v8; const v8 = js.v8;
const Session = @import("../Session.zig");
const Promise = @This(); const Promise = @This();
local: *const js.Local, local: *const js.Local,

View File

@@ -25,7 +25,6 @@ const IS_DEBUG = @import("builtin").mode == .Debug;
const v8 = js.v8; const v8 = js.v8;
const JsApis = bridge.JsApis; const JsApis = bridge.JsApis;
const Allocator = std.mem.Allocator;
const Snapshot = @This(); const Snapshot = @This();
@@ -137,7 +136,7 @@ pub fn create() !Snapshot {
defer v8.v8__HandleScope__DESTRUCT(&handle_scope); defer v8.v8__HandleScope__DESTRUCT(&handle_scope);
// Create templates (constructors only) FIRST // Create templates (constructors only) FIRST
var templates: [JsApis.len]*v8.FunctionTemplate = undefined; var templates: [JsApis.len]*const v8.FunctionTemplate = undefined;
inline for (JsApis, 0..) |JsApi, i| { inline for (JsApis, 0..) |JsApi, i| {
@setEvalBranchQuota(10_000); @setEvalBranchQuota(10_000);
templates[i] = generateConstructor(JsApi, isolate); templates[i] = generateConstructor(JsApi, isolate);
@@ -419,7 +418,7 @@ fn collectExternalReferences() [countExternalReferences()]isize {
// via `new ClassName()` - but they could, for example, be created in // via `new ClassName()` - but they could, for example, be created in
// Zig and returned from a function call, which is why we need the // Zig and returned from a function call, which is why we need the
// FunctionTemplate. // FunctionTemplate.
fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *v8.FunctionTemplate { fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const v8.FunctionTemplate {
const callback = blk: { const callback = blk: {
if (@hasDecl(JsApi, "constructor")) { if (@hasDecl(JsApi, "constructor")) {
break :blk JsApi.constructor.func; break :blk JsApi.constructor.func;
@@ -429,7 +428,7 @@ fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *v8.FunctionT
break :blk illegalConstructorCallback; break :blk illegalConstructorCallback;
}; };
const template = @constCast(v8.v8__FunctionTemplate__New__DEFAULT2(isolate, callback).?); const template = v8.v8__FunctionTemplate__New__DEFAULT2(isolate, callback).?;
{ {
const internal_field_count = comptime countInternalFields(JsApi); const internal_field_count = comptime countInternalFields(JsApi);
if (internal_field_count > 0) { if (internal_field_count > 0) {
@@ -482,10 +481,15 @@ pub fn countInternalFields(comptime JsApi: type) u8 {
} }
// Attaches JsApi members to the prototype template (normal case) // Attaches JsApi members to the prototype template (normal case)
fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.FunctionTemplate) void { fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.FunctionTemplate) void {
const instance = v8.v8__FunctionTemplate__InstanceTemplate(template); const instance = v8.v8__FunctionTemplate__InstanceTemplate(template);
const prototype = v8.v8__FunctionTemplate__PrototypeTemplate(template); const prototype = v8.v8__FunctionTemplate__PrototypeTemplate(template);
// Create a signature that validates the receiver is an instance of this template.
// This prevents crashes when JavaScript extracts a getter/method and calls it
// with the wrong `this` (e.g., documentGetter.call(null)).
const signature = v8.v8__Signature__New(isolate, template);
const declarations = @typeInfo(JsApi).@"struct".decls; const declarations = @typeInfo(JsApi).@"struct".decls;
var has_named_index_getter = false; var has_named_index_getter = false;
@@ -497,23 +501,47 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
switch (definition) { switch (definition) {
bridge.Accessor => { bridge.Accessor => {
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len)); const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
const getter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.getter }).?); const getter_signature = if (value.static) null else signature;
const getter_callback = v8.v8__FunctionTemplate__New__Config(isolate, &.{
.callback = value.getter,
.signature = getter_signature,
}).?;
const setter_callback = if (value.setter) |setter|
v8.v8__FunctionTemplate__New__Config(isolate, &.{
.callback = setter,
.signature = getter_signature,
}).?
else
null;
var attribute: v8.PropertyAttribute = 0;
if (value.setter == null) { if (value.setter == null) {
if (value.static) { attribute |= v8.ReadOnly;
v8.v8__Template__SetAccessorProperty__DEFAULT(@ptrCast(template), js_name, getter_callback); }
} else { if (value.deletable == false) {
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT(prototype, js_name, getter_callback); attribute |= v8.DontDelete;
} }
if (value.static) {
// Static accessors: use Template's SetAccessorProperty
v8.v8__Template__SetAccessorProperty(@ptrCast(template), js_name, getter_callback, setter_callback, attribute);
} else { } else {
if (comptime IS_DEBUG) { v8.v8__ObjectTemplate__SetAccessorProperty__Config(prototype, &.{
std.debug.assert(value.static == false); .key = js_name,
} .getter = getter_callback,
const setter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.setter.? }).?); .setter = setter_callback,
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT2(prototype, js_name, getter_callback, setter_callback); .attribute = attribute,
});
} }
}, },
bridge.Function => { bridge.Function => {
const function_template = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.func, .length = value.arity }).?); // For non-static functions, use the signature to validate the receiver
const func_signature = if (value.static) null else signature;
const function_template = v8.v8__FunctionTemplate__New__Config(isolate, &.{
.callback = value.func,
.length = value.arity,
.signature = func_signature,
}).?;
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len)); const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
if (value.static) { if (value.static) {
v8.v8__Template__Set(@ptrCast(template), js_name, @ptrCast(function_template), v8.None); v8.v8__Template__Set(@ptrCast(template), js_name, @ptrCast(function_template), v8.None);
@@ -551,7 +579,7 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
has_named_index_getter = true; has_named_index_getter = true;
}, },
bridge.Iterator => { bridge.Iterator => {
const function_template = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.func }).?); const function_template = v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.func }).?;
const js_name = if (value.async) const js_name = if (value.async)
v8.v8__Symbol__GetAsyncIterator(isolate) v8.v8__Symbol__GetAsyncIterator(isolate)
else else

View File

@@ -25,7 +25,6 @@ const v8 = js.v8;
const IS_DEBUG = @import("builtin").mode == .Debug; const IS_DEBUG = @import("builtin").mode == .Debug;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const Session = @import("../Session.zig");
const Value = @This(); const Value = @This();

View File

@@ -18,15 +18,12 @@
const std = @import("std"); const std = @import("std");
const js = @import("js.zig"); const js = @import("js.zig");
const lp = @import("lightpanda");
const log = @import("../../log.zig");
const Page = @import("../Page.zig"); const Page = @import("../Page.zig");
const Session = @import("../Session.zig"); const Session = @import("../Session.zig");
const v8 = js.v8; const v8 = js.v8;
const Caller = @import("Caller.zig"); const Caller = @import("Caller.zig");
const Context = @import("Context.zig");
const IS_DEBUG = @import("builtin").mode == .Debug; const IS_DEBUG = @import("builtin").mode == .Debug;
@@ -198,6 +195,7 @@ pub const Function = struct {
pub const Accessor = struct { pub const Accessor = struct {
static: bool = false, static: bool = false,
deletable: bool = true,
cache: ?Caller.Function.Opts.Caching = null, cache: ?Caller.Function.Opts.Caching = null,
getter: ?*const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void = null, getter: ?*const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void = null,
setter: ?*const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void = null, setter: ?*const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void = null,
@@ -206,6 +204,7 @@ pub const Accessor = struct {
var accessor = Accessor{ var accessor = Accessor{
.cache = opts.cache, .cache = opts.cache,
.static = opts.static, .static = opts.static,
.deletable = opts.deletable,
}; };
if (@typeInfo(@TypeOf(getter)) != .null) { if (@typeInfo(@TypeOf(getter)) != .null) {
@@ -851,6 +850,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/event/InputEvent.zig"), @import("../webapi/event/InputEvent.zig"),
@import("../webapi/event/PromiseRejectionEvent.zig"), @import("../webapi/event/PromiseRejectionEvent.zig"),
@import("../webapi/event/SubmitEvent.zig"), @import("../webapi/event/SubmitEvent.zig"),
@import("../webapi/event/FormDataEvent.zig"),
@import("../webapi/MessageChannel.zig"), @import("../webapi/MessageChannel.zig"),
@import("../webapi/MessagePort.zig"), @import("../webapi/MessagePort.zig"),
@import("../webapi/media/MediaError.zig"), @import("../webapi/media/MediaError.zig"),

54
src/browser/links.zig Normal file
View File

@@ -0,0 +1,54 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const Element = @import("webapi/Element.zig");
const Node = @import("webapi/Node.zig");
const Page = @import("Page.zig");
const Selector = @import("webapi/selector/Selector.zig");
const Allocator = std.mem.Allocator;
/// Collect all links (href attributes from anchor tags) under `root`.
/// Returns a slice of strings allocated with `arena`.
pub fn collectLinks(arena: Allocator, root: *Node, page: *Page) ![]const []const u8 {
var links: std.ArrayList([]const u8) = .empty;
if (Selector.querySelectorAll(root, "a[href]", page)) |list| {
defer list.deinit(page._session);
for (list._nodes) |node| {
if (node.is(Element.Html.Anchor)) |anchor| {
const href = anchor.getHref(page) catch |err| {
@import("../lightpanda.zig").log.err(.app, "resolve href failed", .{ .err = err });
continue;
};
if (href.len > 0) {
try links.append(arena, href);
}
}
}
} else |err| {
@import("../lightpanda.zig").log.err(.app, "query links failed", .{ .err = err });
return err;
}
return links.items;
}

View File

@@ -21,7 +21,6 @@ const std = @import("std");
const Page = @import("Page.zig"); const Page = @import("Page.zig");
const URL = @import("URL.zig"); const URL = @import("URL.zig");
const TreeWalker = @import("webapi/TreeWalker.zig"); const TreeWalker = @import("webapi/TreeWalker.zig");
const CData = @import("webapi/CData.zig");
const Element = @import("webapi/Element.zig"); const Element = @import("webapi/Element.zig");
const Node = @import("webapi/Node.zig"); const Node = @import("webapi/Node.zig");
const isAllWhitespace = @import("../string.zig").isAllWhitespace; const isAllWhitespace = @import("../string.zig").isAllWhitespace;

View File

@@ -125,6 +125,19 @@
</script> </script>
<script id="CanvasRenderingContext2D#canvas">
{
const element = document.createElement("canvas");
const ctx = element.getContext("2d");
testing.expectEqual(ctx.canvas, element);
// Setting dimensions via ctx.canvas should update the element.
ctx.canvas.width = 40;
ctx.canvas.height = 25;
testing.expectEqual(element.width, 40);
testing.expectEqual(element.height, 25);
}
</script>
<script id="getter"> <script id="getter">
{ {
const element = document.createElement("canvas"); const element = document.createElement("canvas");

View File

@@ -734,3 +734,101 @@
testing.expectEqual([['field', 'data'], ['x', '0'], ['y', '0']], entries); testing.expectEqual([['field', 'data'], ['x', '0'], ['y', '0']], entries);
} }
</script> </script>
<script id=formDataEventFires>
{
// formdata event fires on the form when FormData is constructed with a form
const form = document.createElement('form');
const input = document.createElement('input');
input.name = 'field';
input.value = 'hello';
form.appendChild(input);
let eventFired = false;
let receivedFormData = null;
form.addEventListener('formdata', (e) => {
eventFired = true;
receivedFormData = e.formData;
});
const fd = new FormData(form);
testing.expectEqual(true, eventFired);
testing.expectEqual(fd, receivedFormData);
}
</script>
<script id=formDataEventNotFiredWithoutForm>
{
// formdata event should NOT fire when FormData is constructed without a form
const fd = new FormData();
fd.append('a', '1');
testing.expectEqual('1', fd.get('a'));
}
</script>
<script id=formDataEventBubbles>
{
// formdata event should bubble
const container = document.createElement('div');
const form = document.createElement('form');
container.appendChild(form);
document.body.appendChild(container);
const input = document.createElement('input');
input.name = 'x';
input.value = '1';
form.appendChild(input);
let bubbled = false;
container.addEventListener('formdata', () => {
bubbled = true;
});
const fd = new FormData(form);
testing.expectEqual(true, bubbled);
document.body.removeChild(container);
}
</script>
<script id=formDataEventNotCancelable>
{
// formdata event should not be cancelable
const form = document.createElement('form');
const input = document.createElement('input');
input.name = 'key';
input.value = 'val';
form.appendChild(input);
let cancelable = null;
form.addEventListener('formdata', (e) => {
cancelable = e.cancelable;
});
const fd = new FormData(form);
testing.expectEqual(false, cancelable);
}
</script>
<script id=formDataEventModifyFormData>
{
// Listeners can modify formData during the event
const form = document.createElement('form');
const input = document.createElement('input');
input.name = 'original';
input.value = 'data';
form.appendChild(input);
form.addEventListener('formdata', (e) => {
e.formData.append('added', 'by-listener');
});
const fd = new FormData(form);
testing.expectEqual('data', fd.get('original'));
testing.expectEqual('by-listener', fd.get('added'));
}
</script>

View File

@@ -262,6 +262,31 @@
} }
</script> </script>
<script id=cached_getter_wrong_this>
// Test that extracting a cached property getter and calling it with wrong `this`
// doesn't crash (V8 internal field out of bounds). V8's Signature validation
// should throw "Illegal invocation" for wrong receiver types.
const documentGetter = Object.getOwnPropertyDescriptor(Window.prototype, 'document').get;
// Verify we get an error with wrong this values
let errorCount = 0;
const testValues = [{}, null, undefined, 42, 'string', [], () => {}];
for (const val of testValues) {
try {
documentGetter.call(val);
} catch (e) {
if (e.message.includes('Illegal invocation')) {
errorCount++;
}
}
}
// At least some should throw (null/undefined/primitives get coerced in sloppy mode)
testing.expectEqual(true, errorCount > 0);
// Calling with correct this should still work
testing.expectEqual(document, documentGetter.call(window));
</script>
<script id=unhandled_rejection> <script id=unhandled_rejection>
{ {
let unhandledCalled = 0; let unhandledCalled = 0;

View File

@@ -24,7 +24,6 @@ const Page = @import("../Page.zig");
const Node = @import("Node.zig"); const Node = @import("Node.zig");
const Element = @import("Element.zig"); const Element = @import("Element.zig");
const DOMException = @import("DOMException.zig");
const Custom = @import("element/html/Custom.zig"); const Custom = @import("element/html/Custom.zig");
const CustomElementDefinition = @import("CustomElementDefinition.zig"); const CustomElementDefinition = @import("CustomElementDefinition.zig");

View File

@@ -77,6 +77,7 @@ pub const Type = union(enum) {
ui_event: *@import("event/UIEvent.zig"), ui_event: *@import("event/UIEvent.zig"),
promise_rejection_event: *@import("event/PromiseRejectionEvent.zig"), promise_rejection_event: *@import("event/PromiseRejectionEvent.zig"),
submit_event: *@import("event/SubmitEvent.zig"), submit_event: *@import("event/SubmitEvent.zig"),
form_data_event: *@import("event/FormDataEvent.zig"),
}; };
pub const Options = struct { pub const Options = struct {
@@ -176,6 +177,7 @@ pub fn is(self: *Event, comptime T: type) ?*T {
.pop_state_event => |e| return if (T == @import("event/PopStateEvent.zig")) e else null, .pop_state_event => |e| return if (T == @import("event/PopStateEvent.zig")) e else null,
.promise_rejection_event => |e| return if (T == @import("event/PromiseRejectionEvent.zig")) e else null, .promise_rejection_event => |e| return if (T == @import("event/PromiseRejectionEvent.zig")) e else null,
.submit_event => |e| return if (T == @import("event/SubmitEvent.zig")) e else null, .submit_event => |e| return if (T == @import("event/SubmitEvent.zig")) e else null,
.form_data_event => |e| return if (T == @import("event/FormDataEvent.zig")) e else null,
.ui_event => |e| { .ui_event => |e| {
if (T == @import("event/UIEvent.zig")) { if (T == @import("event/UIEvent.zig")) {
return e; return e;

View File

@@ -18,7 +18,6 @@
const std = @import("std"); const std = @import("std");
const js = @import("../js/js.zig"); const js = @import("../js/js.zig");
const String = @import("../../string.zig").String;
const Page = @import("../Page.zig"); const Page = @import("../Page.zig");
const Node = @import("Node.zig"); const Node = @import("Node.zig");

View File

@@ -19,10 +19,8 @@
const std = @import("std"); const std = @import("std");
const String = @import("../../string.zig").String; const String = @import("../../string.zig").String;
const log = @import("../../log.zig");
const js = @import("../js/js.zig"); const js = @import("../js/js.zig");
const color = @import("../color.zig");
const Page = @import("../Page.zig"); const Page = @import("../Page.zig");
/// https://developer.mozilla.org/en-US/docs/Web/API/ImageData/ImageData /// https://developer.mozilla.org/en-US/docs/Web/API/ImageData/ImageData

View File

@@ -28,8 +28,6 @@ const DocumentFragment = @import("DocumentFragment.zig");
const AbstractRange = @import("AbstractRange.zig"); const AbstractRange = @import("AbstractRange.zig");
const DOMRect = @import("DOMRect.zig"); const DOMRect = @import("DOMRect.zig");
const Allocator = std.mem.Allocator;
const Range = @This(); const Range = @This();
_proto: *AbstractRange, _proto: *AbstractRange,

View File

@@ -62,5 +62,6 @@ pub const JsApi = struct {
pub const constructor = bridge.constructor(ResizeObserver.init, .{}); pub const constructor = bridge.constructor(ResizeObserver.init, .{});
pub const observe = bridge.function(ResizeObserver.observe, .{}); pub const observe = bridge.function(ResizeObserver.observe, .{});
pub const unobserve = bridge.function(ResizeObserver.unobserve, .{});
pub const disconnect = bridge.function(ResizeObserver.disconnect, .{}); pub const disconnect = bridge.function(ResizeObserver.disconnect, .{});
}; };

View File

@@ -17,7 +17,6 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std"); const std = @import("std");
const log = @import("../../log.zig");
const js = @import("../js/js.zig"); const js = @import("../js/js.zig");
const Page = @import("../Page.zig"); const Page = @import("../Page.zig");
@@ -27,7 +26,6 @@ const Range = @import("Range.zig");
const AbstractRange = @import("AbstractRange.zig"); const AbstractRange = @import("AbstractRange.zig");
const Node = @import("Node.zig"); const Node = @import("Node.zig");
const Event = @import("Event.zig"); const Event = @import("Event.zig");
const Document = @import("Document.zig");
/// https://w3c.github.io/selection-api/ /// https://w3c.github.io/selection-api/
const Selection = @This(); const Selection = @This();

View File

@@ -19,7 +19,6 @@
const js = @import("../js/js.zig"); const js = @import("../js/js.zig");
const Page = @import("../Page.zig"); const Page = @import("../Page.zig");
const EventTarget = @import("EventTarget.zig"); const EventTarget = @import("EventTarget.zig");
const Window = @import("Window.zig");
const VisualViewport = @This(); const VisualViewport = @This();

View File

@@ -829,7 +829,7 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined; pub var class_id: bridge.ClassId = undefined;
}; };
pub const document = bridge.accessor(Window.getDocument, null, .{ .cache = .{ .internal = 1 } }); pub const document = bridge.accessor(Window.getDocument, null, .{ .cache = .{ .internal = 1 }, .deletable = false });
pub const console = bridge.accessor(Window.getConsole, null, .{ .cache = .{ .internal = 2 } }); pub const console = bridge.accessor(Window.getConsole, null, .{ .cache = .{ .internal = 2 } });
pub const top = bridge.accessor(Window.getTop, null, .{}); pub const top = bridge.accessor(Window.getTop, null, .{});
@@ -842,7 +842,7 @@ pub const JsApi = struct {
pub const performance = bridge.accessor(Window.getPerformance, null, .{}); pub const performance = bridge.accessor(Window.getPerformance, null, .{});
pub const localStorage = bridge.accessor(Window.getLocalStorage, null, .{}); pub const localStorage = bridge.accessor(Window.getLocalStorage, null, .{});
pub const sessionStorage = bridge.accessor(Window.getSessionStorage, null, .{}); pub const sessionStorage = bridge.accessor(Window.getSessionStorage, null, .{});
pub const location = bridge.accessor(Window.getLocation, Window.setLocation, .{}); pub const location = bridge.accessor(Window.getLocation, Window.setLocation, .{ .deletable = false });
pub const history = bridge.accessor(Window.getHistory, null, .{}); pub const history = bridge.accessor(Window.getHistory, null, .{});
pub const navigation = bridge.accessor(Window.getNavigation, null, .{}); pub const navigation = bridge.accessor(Window.getNavigation, null, .{});
pub const crypto = bridge.accessor(Window.getCrypto, null, .{}); pub const crypto = bridge.accessor(Window.getCrypto, null, .{});

View File

@@ -23,16 +23,24 @@ const js = @import("../../js/js.zig");
const color = @import("../../color.zig"); const color = @import("../../color.zig");
const Page = @import("../../Page.zig"); const Page = @import("../../Page.zig");
const Canvas = @import("../element/html/Canvas.zig");
const ImageData = @import("../ImageData.zig"); const ImageData = @import("../ImageData.zig");
/// This class doesn't implement a `constructor`. /// This class doesn't implement a `constructor`.
/// It can be obtained with a call to `HTMLCanvasElement#getContext`. /// It can be obtained with a call to `HTMLCanvasElement#getContext`.
/// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D /// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D
const CanvasRenderingContext2D = @This(); const CanvasRenderingContext2D = @This();
/// Reference to the parent canvas element.
/// https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/canvas
_canvas: *Canvas,
/// Fill color. /// Fill color.
/// TODO: Add support for `CanvasGradient` and `CanvasPattern`. /// TODO: Add support for `CanvasGradient` and `CanvasPattern`.
_fill_style: color.RGBA = color.RGBA.Named.black, _fill_style: color.RGBA = color.RGBA.Named.black,
pub fn getCanvas(self: *const CanvasRenderingContext2D) *Canvas {
return self._canvas;
}
pub fn getFillStyle(self: *const CanvasRenderingContext2D, page: *Page) ![]const u8 { pub fn getFillStyle(self: *const CanvasRenderingContext2D, page: *Page) ![]const u8 {
var w = std.Io.Writer.Allocating.init(page.call_arena); var w = std.Io.Writer.Allocating.init(page.call_arena);
try self._fill_style.format(&w.writer); try self._fill_style.format(&w.writer);
@@ -125,6 +133,7 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined; pub var class_id: bridge.ClassId = undefined;
}; };
pub const canvas = bridge.accessor(CanvasRenderingContext2D.getCanvas, null, .{});
pub const font = bridge.property("10px sans-serif", .{ .template = false, .readonly = false }); pub const font = bridge.property("10px sans-serif", .{ .template = false, .readonly = false });
pub const globalAlpha = bridge.property(1.0, .{ .template = false, .readonly = false }); pub const globalAlpha = bridge.property(1.0, .{ .template = false, .readonly = false });
pub const globalCompositeOperation = bridge.property("source-over", .{ .template = false, .readonly = false }); pub const globalCompositeOperation = bridge.property("source-over", .{ .template = false, .readonly = false });

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
const String = @import("../../../string.zig").String;
const js = @import("../../js/js.zig"); const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig"); const Page = @import("../../Page.zig");
const CData = @import("../CData.zig"); const CData = @import("../CData.zig");

View File

@@ -20,7 +20,6 @@
const std = @import("std"); const std = @import("std");
const lp = @import("lightpanda"); const lp = @import("lightpanda");
const log = @import("../../../log.zig");
const crypto = @import("../../../sys/libcrypto.zig"); const crypto = @import("../../../sys/libcrypto.zig");
const Page = @import("../../Page.zig"); const Page = @import("../../Page.zig");

View File

@@ -20,12 +20,10 @@
const std = @import("std"); const std = @import("std");
const lp = @import("lightpanda"); const lp = @import("lightpanda");
const log = @import("../../../log.zig");
const crypto = @import("../../../sys/libcrypto.zig"); const crypto = @import("../../../sys/libcrypto.zig");
const Page = @import("../../Page.zig"); const Page = @import("../../Page.zig");
const js = @import("../../js/js.zig"); const js = @import("../../js/js.zig");
const Algorithm = @import("algorithm.zig").Algorithm;
const CryptoKey = @import("../CryptoKey.zig"); const CryptoKey = @import("../CryptoKey.zig");

View File

@@ -22,7 +22,6 @@ const reflect = @import("../../reflect.zig");
const log = @import("../../../log.zig"); const log = @import("../../../log.zig");
const global_event_handlers = @import("../global_event_handlers.zig"); const global_event_handlers = @import("../global_event_handlers.zig");
const GlobalEventHandlersLookup = global_event_handlers.Lookup;
const GlobalEventHandler = global_event_handlers.Handler; const GlobalEventHandler = global_event_handlers.Handler;
const Page = @import("../../Page.zig"); const Page = @import("../../Page.zig");

View File

@@ -67,9 +67,9 @@ const DrawingContext = union(enum) {
webgl: *WebGLRenderingContext, webgl: *WebGLRenderingContext,
}; };
pub fn getContext(_: *Canvas, context_type: []const u8, page: *Page) !?DrawingContext { pub fn getContext(self: *Canvas, context_type: []const u8, page: *Page) !?DrawingContext {
if (std.mem.eql(u8, context_type, "2d")) { if (std.mem.eql(u8, context_type, "2d")) {
const ctx = try page._factory.create(CanvasRenderingContext2D{}); const ctx = try page._factory.create(CanvasRenderingContext2D{ ._canvas = self });
return .{ .@"2d" = ctx }; return .{ .@"2d" = ctx };
} }

View File

@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License // 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/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
const log = @import("../../../../log.zig");
const js = @import("../../../js/js.zig"); const js = @import("../../../js/js.zig");
const Page = @import("../../../Page.zig"); const Page = @import("../../../Page.zig");
const Window = @import("../../Window.zig"); const Window = @import("../../Window.zig");

View File

@@ -5,10 +5,6 @@ const URL = @import("../../../URL.zig");
const Node = @import("../../Node.zig"); const Node = @import("../../Node.zig");
const Element = @import("../../Element.zig"); const Element = @import("../../Element.zig");
const HtmlElement = @import("../Html.zig"); const HtmlElement = @import("../Html.zig");
const Event = @import("../../Event.zig");
const log = @import("../../../../log.zig");
const IS_DEBUG = @import("builtin").mode == .Debug;
const Image = @This(); const Image = @This();
_proto: *HtmlElement, _proto: *HtmlElement,

View File

@@ -17,7 +17,6 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std"); const std = @import("std");
const log = @import("../../../../log.zig");
const js = @import("../../../js/js.zig"); const js = @import("../../../js/js.zig");
const Page = @import("../../../Page.zig"); const Page = @import("../../../Page.zig");

View File

@@ -22,7 +22,6 @@ const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig"); const Page = @import("../../Page.zig");
const Session = @import("../../Session.zig"); const Session = @import("../../Session.zig");
const Event = @import("../Event.zig"); const Event = @import("../Event.zig");
const Allocator = std.mem.Allocator;
const CompositionEvent = @This(); const CompositionEvent = @This();

View File

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

View File

@@ -22,7 +22,6 @@ const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig"); const Page = @import("../../Page.zig");
const Session = @import("../../Session.zig"); const Session = @import("../../Session.zig");
const Event = @import("../Event.zig"); const Event = @import("../Event.zig");
const Allocator = std.mem.Allocator;
const PromiseRejectionEvent = @This(); const PromiseRejectionEvent = @This();

View File

@@ -26,7 +26,7 @@ const Event = @import("../Event.zig");
const HtmlElement = @import("../element/Html.zig"); const HtmlElement = @import("../element/Html.zig");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
// https://developer.mozilla.org/en-US/docs/Web/API/SubmitEvent /// https://developer.mozilla.org/en-US/docs/Web/API/SubmitEvent
const SubmitEvent = @This(); const SubmitEvent = @This();
_proto: *Event, _proto: *Event,

View File

@@ -27,8 +27,6 @@ const Page = @import("../../Page.zig");
const Event = @import("../Event.zig"); const Event = @import("../Event.zig");
const EventTarget = @import("../EventTarget.zig"); const EventTarget = @import("../EventTarget.zig");
const IS_DEBUG = @import("builtin").mode == .Debug;
// https://developer.mozilla.org/en-US/docs/Web/API/Navigation // https://developer.mozilla.org/en-US/docs/Web/API/Navigation
const Navigation = @This(); const Navigation = @This();

View File

@@ -18,7 +18,6 @@
const std = @import("std"); const std = @import("std");
const URL = @import("../URL.zig"); const URL = @import("../URL.zig");
const EventTarget = @import("../EventTarget.zig");
const NavigationState = @import("root.zig").NavigationState; const NavigationState = @import("root.zig").NavigationState;
const Page = @import("../../Page.zig"); const Page = @import("../../Page.zig");
const js = @import("../../js/js.zig"); const js = @import("../../js/js.zig");

View File

@@ -80,7 +80,7 @@ pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise {
if (request._headers) |h| { if (request._headers) |h| {
try h.populateHttpHeader(page.call_arena, &headers); try h.populateHttpHeader(page.call_arena, &headers);
} }
try page.headersForRequest(page.arena, request._url, &headers); try page.headersForRequest(&headers);
if (comptime IS_DEBUG) { if (comptime IS_DEBUG) {
log.debug(.http, "fetch", .{ .url = request._url }); log.debug(.http, "fetch", .{ .url = request._url });
@@ -95,6 +95,7 @@ pub fn init(input: Input, options: ?InitOpts, page: *Page) !js.Promise {
.headers = headers, .headers = headers,
.resource_type = .fetch, .resource_type = .fetch,
.cookie_jar = &page._session.cookie_jar, .cookie_jar = &page._session.cookie_jar,
.cookie_origin = page.url,
.notification = page._session.notification, .notification = page._session.notification,
.start_callback = httpStartCallback, .start_callback = httpStartCallback,
.header_callback = httpHeaderDoneCallback, .header_callback = httpHeaderDoneCallback,

View File

@@ -22,7 +22,6 @@ const log = @import("../../../log.zig");
const js = @import("../../js/js.zig"); const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig"); const Page = @import("../../Page.zig");
const Node = @import("../Node.zig");
const Form = @import("../element/html/Form.zig"); const Form = @import("../element/html/Form.zig");
const Element = @import("../Element.zig"); const Element = @import("../Element.zig");
const KeyValueList = @import("../KeyValueList.zig"); const KeyValueList = @import("../KeyValueList.zig");
@@ -35,10 +34,22 @@ _arena: Allocator,
_list: KeyValueList, _list: KeyValueList,
pub fn init(form: ?*Form, submitter: ?*Element, page: *Page) !*FormData { pub fn init(form: ?*Form, submitter: ?*Element, page: *Page) !*FormData {
return page._factory.create(FormData{ const form_data = try page._factory.create(FormData{
._arena = page.arena, ._arena = page.arena,
._list = try collectForm(page.arena, form, submitter, page), ._list = try collectForm(page.arena, form, submitter, page),
}); });
// Dispatch `formdata` event if form provided.
if (form) |_form| {
const form_data_event = try (@import("../event/FormDataEvent.zig")).initTrusted(
comptime .wrap("formdata"),
.{ .bubbles = true, .cancelable = false, .formData = form_data },
page,
);
try page._event_manager.dispatch(_form.asNode().asEventTarget(), form_data_event.asEvent());
}
return form_data;
} }
pub fn get(self: *const FormData, name: []const u8) ?[]const u8 { pub fn get(self: *const FormData, name: []const u8) ?[]const u8 {

View File

@@ -29,7 +29,6 @@ const Page = @import("../../Page.zig");
const Session = @import("../../Session.zig"); const Session = @import("../../Session.zig");
const Node = @import("../Node.zig"); const Node = @import("../Node.zig");
const Blob = @import("../Blob.zig");
const Event = @import("../Event.zig"); const Event = @import("../Event.zig");
const Headers = @import("Headers.zig"); const Headers = @import("Headers.zig");
const EventTarget = @import("../EventTarget.zig"); const EventTarget = @import("../EventTarget.zig");
@@ -225,7 +224,7 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
try self._request_headers.populateHttpHeader(page.call_arena, &headers); try self._request_headers.populateHttpHeader(page.call_arena, &headers);
if (cookie_support) { if (cookie_support) {
try page.headersForRequest(self._arena, self._url, &headers); try page.headersForRequest(&headers);
} }
try http_client.request(.{ try http_client.request(.{
@@ -236,6 +235,7 @@ pub fn send(self: *XMLHttpRequest, body_: ?[]const u8) !void {
.frame_id = page._frame_id, .frame_id = page._frame_id,
.body = self._request_body, .body = self._request_body,
.cookie_jar = if (cookie_support) &page._session.cookie_jar else null, .cookie_jar = if (cookie_support) &page._session.cookie_jar else null,
.cookie_origin = page.url,
.resource_type = .xhr, .resource_type = .xhr,
.notification = page._session.notification, .notification = page._session.notification,
.start_callback = httpStartCallback, .start_callback = httpStartCallback,

View File

@@ -25,7 +25,6 @@ const json = std.json;
const Incrementing = @import("id.zig").Incrementing; const Incrementing = @import("id.zig").Incrementing;
const log = @import("../log.zig"); const log = @import("../log.zig");
const App = @import("../App.zig");
const Notification = @import("../Notification.zig"); const Notification = @import("../Notification.zig");
const Client = @import("../Server.zig").Client; const Client = @import("../Server.zig").Client;
@@ -35,7 +34,6 @@ const Browser = @import("../browser/Browser.zig");
const Session = @import("../browser/Session.zig"); const Session = @import("../browser/Session.zig");
const Page = @import("../browser/Page.zig"); const Page = @import("../browser/Page.zig");
const Mime = @import("../browser/Mime.zig"); const Mime = @import("../browser/Mime.zig");
const HttpClient = @import("../browser/HttpClient.zig");
const InterceptState = @import("domains/fetch.zig").InterceptState; const InterceptState = @import("domains/fetch.zig").InterceptState;
@@ -429,6 +427,8 @@ pub fn BrowserContext(comptime CDP_T: type) type {
try notification.register(.page_navigate, self, onPageNavigate); try notification.register(.page_navigate, self, onPageNavigate);
try notification.register(.page_navigated, self, onPageNavigated); try notification.register(.page_navigated, self, onPageNavigated);
try notification.register(.page_frame_created, self, onPageFrameCreated); try notification.register(.page_frame_created, self, onPageFrameCreated);
try notification.register(.page_dom_content_loaded, self, onPageDOMContentLoaded);
try notification.register(.page_loaded, self, onPageLoaded);
} }
pub fn deinit(self: *Self) void { pub fn deinit(self: *Self) void {
@@ -470,8 +470,8 @@ pub fn BrowserContext(comptime CDP_T: type) type {
if (self.http_proxy_changed) { if (self.http_proxy_changed) {
// has to be called after browser.closeSession, since it won't // has to be called after browser.closeSession, since it won't
// work if there are active connections. // work if there are active connections.
browser.http_client.restoreOriginalProxy() catch |err| { browser.http_client.changeProxy(null) catch |err| {
log.warn(.http, "restoreOriginalProxy", .{ .err = err }); log.warn(.http, "changeProxy", .{ .err = err });
}; };
} }
self.intercept_state.deinit(); self.intercept_state.deinit();
@@ -604,6 +604,16 @@ pub fn BrowserContext(comptime CDP_T: type) type {
return @import("domains/page.zig").pageFrameCreated(self, msg); return @import("domains/page.zig").pageFrameCreated(self, msg);
} }
pub fn onPageDOMContentLoaded(ctx: *anyopaque, msg: *const Notification.PageDOMContentLoaded) !void {
const self: *Self = @ptrCast(@alignCast(ctx));
return @import("domains/page.zig").pageDOMContentLoaded(self, msg);
}
pub fn onPageLoaded(ctx: *anyopaque, msg: *const Notification.PageLoaded) !void {
const self: *Self = @ptrCast(@alignCast(ctx));
return @import("domains/page.zig").pageLoaded(self, msg);
}
pub fn onPageNetworkIdle(ctx: *anyopaque, msg: *const Notification.PageNetworkIdle) !void { pub fn onPageNetworkIdle(ctx: *anyopaque, msg: *const Notification.PageNetworkIdle) !void {
const self: *Self = @ptrCast(@alignCast(ctx)); const self: *Self = @ptrCast(@alignCast(ctx));
return @import("domains/page.zig").pageNetworkIdle(self, msg); return @import("domains/page.zig").pageNetworkIdle(self, msg);

View File

@@ -18,7 +18,6 @@
const std = @import("std"); const std = @import("std");
const lp = @import("lightpanda"); const lp = @import("lightpanda");
const log = @import("../../log.zig");
const markdown = lp.markdown; const markdown = lp.markdown;
const SemanticTree = lp.SemanticTree; const SemanticTree = lp.SemanticTree;
const interactive = lp.interactive; const interactive = lp.interactive;
@@ -135,17 +134,10 @@ fn getInteractiveElements(cmd: anytype) !void {
page.document.asNode(); page.document.asNode();
const elements = try interactive.collectInteractiveElements(root, cmd.arena, page); const elements = try interactive.collectInteractiveElements(root, cmd.arena, page);
try interactive.registerNodes(elements, &bc.node_registry);
// Register nodes so nodeIds are valid for subsequent CDP calls.
var node_ids: std.ArrayList(Node.Id) = try .initCapacity(cmd.arena, elements.len);
for (elements) |el| {
const registered = try bc.node_registry.register(el.node);
node_ids.appendAssumeCapacity(registered.id);
}
return cmd.sendResult(.{ return cmd.sendResult(.{
.elements = elements, .elements = elements,
.nodeIds = node_ids.items,
}, .{}); }, .{});
} }
@@ -308,7 +300,6 @@ test "cdp.lp: getInteractiveElements" {
const result = (try ctx.getSentMessage(0)).?.object.get("result").?.object; const result = (try ctx.getSentMessage(0)).?.object.get("result").?.object;
try testing.expect(result.get("elements") != null); try testing.expect(result.get("elements") != null);
try testing.expect(result.get("nodeIds") != null);
} }
test "cdp.lp: getStructuredData" { test "cdp.lp: getStructuredData" {

View File

@@ -351,6 +351,10 @@ pub const TransferAsRequestWriter = struct {
try jws.objectField(hdr.name); try jws.objectField(hdr.name);
try jws.write(hdr.value); try jws.write(hdr.value);
} }
if (try transfer.getCookieString()) |cookies| {
try jws.objectField("Cookie");
try jws.write(cookies[0 .. cookies.len - 1]);
}
try jws.endObject(); try jws.endObject();
} }
try jws.endObject(); try jws.endObject();

View File

@@ -39,6 +39,7 @@ pub fn processMessage(cmd: anytype) !void {
addScriptToEvaluateOnNewDocument, addScriptToEvaluateOnNewDocument,
createIsolatedWorld, createIsolatedWorld,
navigate, navigate,
reload,
stopLoading, stopLoading,
close, close,
captureScreenshot, captureScreenshot,
@@ -52,6 +53,7 @@ pub fn processMessage(cmd: anytype) !void {
.addScriptToEvaluateOnNewDocument => return addScriptToEvaluateOnNewDocument(cmd), .addScriptToEvaluateOnNewDocument => return addScriptToEvaluateOnNewDocument(cmd),
.createIsolatedWorld => return createIsolatedWorld(cmd), .createIsolatedWorld => return createIsolatedWorld(cmd),
.navigate => return navigate(cmd), .navigate => return navigate(cmd),
.reload => return doReload(cmd),
.stopLoading => return cmd.sendResult(null, .{}), .stopLoading => return cmd.sendResult(null, .{}),
.close => return close(cmd), .close => return close(cmd),
.captureScreenshot => return captureScreenshot(cmd), .captureScreenshot => return captureScreenshot(cmd),
@@ -252,6 +254,36 @@ fn navigate(cmd: anytype) !void {
}); });
} }
fn doReload(cmd: anytype) !void {
const params = try cmd.params(struct {
ignoreCache: ?bool = null,
scriptToEvaluateOnLoad: ?[]const u8 = null,
});
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
if (bc.session_id == null) {
return error.SessionIdNotLoaded;
}
const session = bc.session;
var page = session.currentPage() orelse return error.PageNotLoaded;
// Dupe URL before replacePage() frees the old page's arena.
const reload_url = try cmd.arena.dupeZ(u8, page.url);
if (page._load_state != .waiting) {
page = try session.replacePage();
}
try page.navigate(reload_url, .{
.reason = .address_bar,
.cdp_id = cmd.input.id,
.kind = .reload,
.force = if (params) |p| p.ignoreCache orelse false else false,
});
}
pub fn pageNavigate(bc: anytype, event: *const Notification.PageNavigate) !void { pub fn pageNavigate(bc: anytype, event: *const Notification.PageNavigate) !void {
// detachTarget could be called, in which case, we still have a page doing // detachTarget could be called, in which case, we still have a page doing
// things, but no session. // things, but no session.
@@ -353,7 +385,6 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
// things, but no session. // things, but no session.
const session_id = bc.session_id orelse return; const session_id = bc.session_id orelse return;
const timestamp = event.timestamp;
const frame_id = &id.toFrameId(event.frame_id); const frame_id = &id.toFrameId(event.frame_id);
const loader_id = &id.toLoaderId(event.req_id); const loader_id = &id.toLoaderId(event.req_id);
@@ -405,9 +436,9 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
const page = bc.session.currentPage() orelse return error.PageNotLoaded; const page = bc.session.currentPage() orelse return error.PageNotLoaded;
// When we actually recreated the context we should have the inspector send // When we actually recreated the context we should have the inspector send
// this event, see: resetContextGroup Sending this event will tell the // this event, see: resetContextGroup. Sending this event will tell the
// client that the context ids they had are invalid and the context shouls // client that the context ids they had are invalid and the context should
// be dropped The client will expect us to send new contextCreated events, // be dropped. The client will expect us to send new contextCreated events,
// such that the client has new id's for the active contexts. // such that the client has new id's for the active contexts.
// Only send executionContextsCleared for main frame navigations. For child // Only send executionContextsCleared for main frame navigations. For child
// frames (iframes), clearing all contexts would destroy the main frame's // frames (iframes), clearing all contexts would destroy the main frame's
@@ -417,6 +448,18 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
try cdp.sendEvent("Runtime.executionContextsCleared", null, .{ .session_id = session_id }); try cdp.sendEvent("Runtime.executionContextsCleared", null, .{ .session_id = session_id });
} }
// frameNavigated event
try cdp.sendEvent("Page.frameNavigated", .{
.type = "Navigation",
.frame = Frame{
.id = frame_id,
.url = event.url,
.loaderId = loader_id,
.securityOrigin = bc.security_origin,
.secureContextType = bc.secure_context_type,
},
}, .{ .session_id = session_id });
{ {
const aux_data = try std.fmt.allocPrint(arena, "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\",\"loaderId\":\"{s}\"}}", .{ frame_id, loader_id }); const aux_data = try std.fmt.allocPrint(arena, "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\",\"loaderId\":\"{s}\"}}", .{ frame_id, loader_id });
@@ -466,18 +509,22 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
// chromedp client expects to receive the events is this order. // chromedp client expects to receive the events is this order.
// see https://github.com/chromedp/chromedp/issues/1558 // see https://github.com/chromedp/chromedp/issues/1558
try cdp.sendEvent("DOM.documentUpdated", null, .{ .session_id = session_id }); try cdp.sendEvent("DOM.documentUpdated", null, .{ .session_id = session_id });
}
pub fn pageDOMContentLoaded(bc: anytype, event: *const Notification.PageDOMContentLoaded) !void {
const session_id = bc.session_id orelse return;
const timestamp = event.timestamp;
var cdp = bc.cdp;
// domContentEventFired event
// TODO: partially hard coded
try cdp.sendEvent( try cdp.sendEvent(
"Page.domContentEventFired", "Page.domContentEventFired",
.{ .timestamp = timestamp }, .{ .timestamp = timestamp },
.{ .session_id = session_id }, .{ .session_id = session_id },
); );
// lifecycle DOMContentLoaded event
// TODO: partially hard coded
if (bc.page_life_cycle_events) { if (bc.page_life_cycle_events) {
const frame_id = &id.toFrameId(event.frame_id);
const loader_id = &id.toLoaderId(event.req_id);
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{ try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
.timestamp = timestamp, .timestamp = timestamp,
.name = "DOMContentLoaded", .name = "DOMContentLoaded",
@@ -485,16 +532,23 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
.loaderId = loader_id, .loaderId = loader_id,
}, .{ .session_id = session_id }); }, .{ .session_id = session_id });
} }
}
pub fn pageLoaded(bc: anytype, event: *const Notification.PageLoaded) !void {
const session_id = bc.session_id orelse return;
const timestamp = event.timestamp;
var cdp = bc.cdp;
const frame_id = &id.toFrameId(event.frame_id);
// loadEventFired event
try cdp.sendEvent( try cdp.sendEvent(
"Page.loadEventFired", "Page.loadEventFired",
.{ .timestamp = timestamp }, .{ .timestamp = timestamp },
.{ .session_id = session_id }, .{ .session_id = session_id },
); );
// lifecycle DOMContentLoaded event
if (bc.page_life_cycle_events) { if (bc.page_life_cycle_events) {
const loader_id = &id.toLoaderId(event.req_id);
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{ try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
.timestamp = timestamp, .timestamp = timestamp,
.name = "load", .name = "load",
@@ -503,7 +557,6 @@ pub fn pageNavigated(arena: Allocator, bc: anytype, event: *const Notification.P
}, .{ .session_id = session_id }); }, .{ .session_id = session_id });
} }
// frameStoppedLoading
return cdp.sendEvent("Page.frameStoppedLoading", .{ return cdp.sendEvent("Page.frameStoppedLoading", .{
.frameId = frame_id, .frameId = frame_id,
}, .{ .session_id = session_id }); }, .{ .session_id = session_id });
@@ -784,3 +837,27 @@ test "cdp.page: getLayoutMetrics" {
}, },
}, .{ .id = 12 }); }, .{ .id = 12 });
} }
test "cdp.page: reload" {
var ctx = try testing.context();
defer ctx.deinit();
{
// reload without browser context — should error
try ctx.processMessage(.{ .id = 30, .method = "Page.reload" });
try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 30 });
}
_ = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* });
{
// reload with no params — should not error (navigation is async,
// so no result is sent synchronously; we just verify no error)
try ctx.processMessage(.{ .id = 31, .method = "Page.reload" });
}
{
// reload with ignoreCache param
try ctx.processMessage(.{ .id = 32, .method = "Page.reload", .params = .{ .ignoreCache = true } });
}
}

View File

@@ -24,9 +24,6 @@ const log = @import("../../log.zig");
const URL = @import("../../browser/URL.zig"); const URL = @import("../../browser/URL.zig");
const js = @import("../../browser/js/js.zig"); const js = @import("../../browser/js/js.zig");
// TODO: hard coded IDs
const LOADER_ID = "LOADERID42AA389647D702B4D805F49A";
pub fn processMessage(cmd: anytype) !void { pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum { const action = std.meta.stringToEnum(enum {
getTargets, getTargets,

View File

@@ -17,7 +17,6 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std"); const std = @import("std");
const IS_DEBUG = @import("builtin").mode == .Debug;
pub fn toPageId(comptime id_type: enum { frame_id, loader_id }, input: []const u8) !u32 { pub fn toPageId(comptime id_type: enum { frame_id, loader_id }, input: []const u8) !u32 {
const err = switch (comptime id_type) { const err = switch (comptime id_type) {

View File

@@ -19,10 +19,6 @@
const std = @import("std"); const std = @import("std");
const json = std.json; const json = std.json;
const posix = std.posix; const posix = std.posix;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const Testing = @This();
const CDP = @import("CDP.zig"); const CDP = @import("CDP.zig");
const Server = @import("../Server.zig"); const Server = @import("../Server.zig");

View File

@@ -158,13 +158,13 @@ pub extern "C" fn html5ever_attribute_iterator_next(
let attr = &iter.vec[pos]; let attr = &iter.vec[pos];
iter.pos += 1; iter.pos += 1;
return CNullable::<CAttribute>::some(CAttribute { CNullable::<CAttribute>::some(CAttribute {
name: CQualName::create(&attr.name), name: CQualName::create(&attr.name),
value: StringSlice { value: StringSlice {
ptr: attr.value.as_ptr(), ptr: attr.value.as_ptr(),
len: attr.value.len(), len: attr.value.len(),
}, },
}); })
} }
#[no_mangle] #[no_mangle]
@@ -186,12 +186,12 @@ pub extern "C" fn html5ever_get_memory_usage() -> Memory {
use tikv_jemalloc_ctl::{epoch, stats}; use tikv_jemalloc_ctl::{epoch, stats};
// many statistics are cached and only updated when the epoch is advanced. // many statistics are cached and only updated when the epoch is advanced.
epoch::advance().unwrap(); drop(epoch::advance());
return Memory { Memory {
resident: stats::resident::read().unwrap(), resident: stats::resident::read().unwrap_or(0),
allocated: stats::allocated::read().unwrap(), allocated: stats::allocated::read().unwrap_or(0),
}; }
} }
// Streaming parser API // Streaming parser API
@@ -325,7 +325,7 @@ pub extern "C" fn html5ever_streaming_parser_destroy(parser_ptr: *mut c_void) {
// Drop the parser box without finishing // Drop the parser box without finishing
// This is for cases where you want to cancel parsing // This is for cases where you want to cancel parsing
unsafe { unsafe {
let _ = Box::from_raw(parser_ptr as *mut StreamingParser); drop(Box::from_raw(parser_ptr as *mut StreamingParser));
} }
} }

View File

@@ -36,10 +36,10 @@ pub struct ElementData {
} }
impl ElementData { impl ElementData {
fn new(qname: QualName, flags: ElementFlags) -> Self { fn new(qname: QualName, flags: ElementFlags) -> Self {
return Self { Self {
qname: qname, qname: qname,
mathml_annotation_xml_integration_point: flags.mathml_annotation_xml_integration_point, mathml_annotation_xml_integration_point: flags.mathml_annotation_xml_integration_point,
}; }
} }
} }
@@ -130,12 +130,12 @@ impl<'arena> TreeSink for Sink<'arena> {
unsafe { unsafe {
let mut attribute_iterator = CAttributeIterator { vec: attrs, pos: 0 }; let mut attribute_iterator = CAttributeIterator { vec: attrs, pos: 0 };
return (self.create_element_callback)( (self.create_element_callback)(
self.ctx, self.ctx,
data as *mut _ as *mut c_void, data as *mut _ as *mut c_void,
CQualName::create(&name), CQualName::create(&name),
&mut attribute_iterator as *mut _ as *mut c_void, &mut attribute_iterator as *mut _ as *mut c_void,
); )
} }
} }

View File

@@ -126,21 +126,21 @@ impl CQualName {
None => CNullable::<StringSlice>::none(), None => CNullable::<StringSlice>::none(),
Some(prefix) => CNullable::<StringSlice>::some(StringSlice { ptr: prefix.as_ptr(), len: prefix.len()}), Some(prefix) => CNullable::<StringSlice>::some(StringSlice { ptr: prefix.as_ptr(), len: prefix.len()}),
}; };
return CQualName{ CQualName{
// inner: q as *const _ as *const c_void, // inner: q as *const _ as *const c_void,
ns: ns, ns: ns,
local: local, local: local,
prefix: prefix, prefix: prefix,
}; }
} }
} }
impl Default for CQualName { impl Default for CQualName {
fn default() -> Self { fn default() -> Self {
return Self{ Self{
prefix: CNullable::<StringSlice>::none(), prefix: CNullable::<StringSlice>::none(),
ns: StringSlice::default(), ns: StringSlice::default(),
local: StringSlice::default(), local: StringSlice::default(),
}; }
} }
} }

View File

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

View File

@@ -49,9 +49,6 @@ const Opts = struct {
pub var opts = Opts{}; pub var opts = Opts{};
// synchronizes writes to the output
var out_lock: Thread.Mutex = .{};
// synchronizes access to last_log // synchronizes access to last_log
var last_log_lock: Thread.Mutex = .{}; var last_log_lock: Thread.Mutex = .{};

View File

@@ -22,12 +22,14 @@ pub const resource_list = [_]protocol.Resource{
}; };
pub fn handleList(server: *Server, req: protocol.Request) !void { pub fn handleList(server: *Server, req: protocol.Request) !void {
try server.sendResult(req.id.?, .{ .resources = &resource_list }); const id = req.id orelse return;
try server.sendResult(id, .{ .resources = &resource_list });
} }
const ReadParams = struct { const ReadParams = struct {
uri: []const u8, uri: []const u8,
}; };
const Format = enum { html, markdown };
const ResourceStreamingResult = struct { const ResourceStreamingResult = struct {
contents: []const struct { contents: []const struct {
@@ -38,7 +40,7 @@ const ResourceStreamingResult = struct {
const StreamingText = struct { const StreamingText = struct {
page: *lp.Page, page: *lp.Page,
format: enum { html, markdown }, format: Format,
pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) !void { pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) !void {
try jw.beginWriteRaw(); try jw.beginWriteRaw();
@@ -47,9 +49,11 @@ const ResourceStreamingResult = struct {
switch (self.format) { switch (self.format) {
.html => lp.dump.root(self.page.document, .{}, &escaped.writer, self.page) catch |err| { .html => lp.dump.root(self.page.document, .{}, &escaped.writer, self.page) catch |err| {
log.err(.mcp, "html dump failed", .{ .err = err }); log.err(.mcp, "html dump failed", .{ .err = err });
return error.WriteFailed;
}, },
.markdown => lp.markdown.dump(self.page.document.asNode(), .{}, &escaped.writer, self.page) catch |err| { .markdown => lp.markdown.dump(self.page.document.asNode(), .{}, &escaped.writer, self.page) catch |err| {
log.err(.mcp, "markdown dump failed", .{ .err = err }); log.err(.mcp, "markdown dump failed", .{ .err = err });
return error.WriteFailed;
}, },
} }
try jw.writer.writeByte('"'); try jw.writer.writeByte('"');
@@ -86,28 +90,23 @@ pub fn handleRead(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
return server.sendError(req_id, .PageNotLoaded, "Page not loaded"); return server.sendError(req_id, .PageNotLoaded, "Page not loaded");
}; };
switch (uri) { const format: Format = switch (uri) {
.@"mcp://page/html" => { .@"mcp://page/html" => .html,
const result: ResourceStreamingResult = .{ .@"mcp://page/markdown" => .markdown,
.contents = &.{.{ };
.uri = params.uri, const mime_type: []const u8 = switch (uri) {
.mimeType = "text/html", .@"mcp://page/html" => "text/html",
.text = .{ .page = page, .format = .html }, .@"mcp://page/markdown" => "text/markdown",
}}, };
};
try server.sendResult(req_id, result);
},
.@"mcp://page/markdown" => {
const result: ResourceStreamingResult = .{
.contents = &.{.{
.uri = params.uri,
.mimeType = "text/markdown",
.text = .{ .page = page, .format = .markdown },
}},
};
try server.sendResult(req_id, result);
},
}
}
const testing = @import("../testing.zig"); const result: ResourceStreamingResult = .{
.contents = &.{.{
.uri = params.uri,
.mimeType = mime_type,
.text = .{ .page = page, .format = format },
}},
};
server.sendResult(req_id, result) catch {
return server.sendError(req_id, .InternalError, "Failed to serialize resource content");
};
}

View File

@@ -1,5 +1,4 @@
const std = @import("std"); const std = @import("std");
const lp = @import("lightpanda");
const protocol = @import("protocol.zig"); const protocol = @import("protocol.zig");
const resources = @import("resources.zig"); const resources = @import("resources.zig");
const Server = @import("Server.zig"); const Server = @import("Server.zig");
@@ -16,6 +15,7 @@ pub fn processRequests(server: *Server, reader: *std.io.Reader) !void {
const buffered_line = reader.takeDelimiter('\n') catch |err| switch (err) { const buffered_line = reader.takeDelimiter('\n') catch |err| switch (err) {
error.StreamTooLong => { error.StreamTooLong => {
log.err(.mcp, "Message too long", .{}); log.err(.mcp, "Message too long", .{});
try server.sendError(.null, .InvalidRequest, "Message too long");
continue; continue;
}, },
else => return err, else => return err,
@@ -80,6 +80,7 @@ pub fn handleMessage(server: *Server, arena: std.mem.Allocator, msg: []const u8)
} }
fn handleInitialize(server: *Server, req: protocol.Request) !void { fn handleInitialize(server: *Server, req: protocol.Request) !void {
const id = req.id orelse return;
const result = protocol.InitializeResult{ const result = protocol.InitializeResult{
.protocolVersion = "2025-11-25", .protocolVersion = "2025-11-25",
.capabilities = .{ .capabilities = .{
@@ -92,7 +93,7 @@ fn handleInitialize(server: *Server, req: protocol.Request) !void {
}, },
}; };
try server.sendResult(req.id.?, result); try server.sendResult(id, result);
} }
fn handlePing(server: *Server, req: protocol.Request) !void { fn handlePing(server: *Server, req: protocol.Request) !void {

View File

@@ -4,9 +4,7 @@ const lp = @import("lightpanda");
const log = lp.log; const log = lp.log;
const js = lp.js; const js = lp.js;
const Element = @import("../browser/webapi/Element.zig");
const DOMNode = @import("../browser/webapi/Node.zig"); const DOMNode = @import("../browser/webapi/Node.zig");
const Selector = @import("../browser/webapi/selector/Selector.zig");
const protocol = @import("protocol.zig"); const protocol = @import("protocol.zig");
const Server = @import("Server.zig"); const Server = @import("Server.zig");
const CDPNode = @import("../cdp/Node.zig"); const CDPNode = @import("../cdp/Node.zig");
@@ -172,13 +170,18 @@ pub const tool_list = [_]protocol.Tool{
pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void { pub fn handleList(server: *Server, arena: std.mem.Allocator, req: protocol.Request) !void {
_ = arena; _ = arena;
try server.sendResult(req.id.?, .{ .tools = &tool_list }); const id = req.id orelse return;
try server.sendResult(id, .{ .tools = &tool_list });
} }
const GotoParams = struct { const GotoParams = struct {
url: [:0]const u8, url: [:0]const u8,
}; };
const UrlParams = struct {
url: ?[:0]const u8 = null,
};
const EvaluateParams = struct { const EvaluateParams = struct {
script: [:0]const u8, script: [:0]const u8,
url: ?[:0]const u8 = null, url: ?[:0]const u8 = null,
@@ -201,28 +204,18 @@ const ToolStreamingText = struct {
switch (self.action) { switch (self.action) {
.markdown => lp.markdown.dump(self.page.document.asNode(), .{}, w, self.page) catch |err| { .markdown => lp.markdown.dump(self.page.document.asNode(), .{}, w, self.page) catch |err| {
log.err(.mcp, "markdown dump failed", .{ .err = err }); log.err(.mcp, "markdown dump failed", .{ .err = err });
return error.WriteFailed;
}, },
.links => { .links => {
if (Selector.querySelectorAll(self.page.document.asNode(), "a[href]", self.page)) |list| { const links = lp.links.collectLinks(self.page.call_arena, self.page.document.asNode(), self.page) catch |err| {
defer list.deinit(self.page._session);
var first = true;
for (list._nodes) |node| {
if (node.is(Element.Html.Anchor)) |anchor| {
const href = anchor.getHref(self.page) catch |err| {
log.err(.mcp, "resolve href failed", .{ .err = err });
continue;
};
if (href.len > 0) {
if (!first) try w.writeByte('\n');
try w.writeAll(href);
first = false;
}
}
}
} else |err| {
log.err(.mcp, "query links failed", .{ .err = err }); log.err(.mcp, "query links failed", .{ .err = err });
return error.WriteFailed;
};
var first = true;
for (links) |href| {
if (!first) try w.writeByte('\n');
try w.writeAll(href);
first = false;
} }
}, },
.semantic_tree => { .semantic_tree => {
@@ -248,6 +241,7 @@ const ToolStreamingText = struct {
st.textStringify(w) catch |err| { st.textStringify(w) catch |err| {
log.err(.mcp, "semantic tree dump failed", .{ .err = err }); log.err(.mcp, "semantic tree dump failed", .{ .err = err });
return error.WriteFailed;
}; };
}, },
} }
@@ -324,7 +318,7 @@ pub fn handleCall(server: *Server, arena: std.mem.Allocator, req: protocol.Reque
} }
fn handleGoto(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { fn handleGoto(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArguments(GotoParams, arena, arguments, server, id, "goto"); const args = try parseArgs(GotoParams, arena, arguments, server, id, "goto");
try performGoto(server, args.url, id); try performGoto(server, args.url, id);
const content = [_]protocol.TextContent([]const u8){.{ .text = "Navigated successfully." }}; const content = [_]protocol.TextContent([]const u8){.{ .text = "Navigated successfully." }};
@@ -332,45 +326,27 @@ fn handleGoto(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg
} }
fn handleMarkdown(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { fn handleMarkdown(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const MarkdownParams = struct { const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
url: ?[:0]const u8 = null, const page = try ensurePage(server, id, args.url);
};
if (arguments) |args_raw| {
if (std.json.parseFromValueLeaky(MarkdownParams, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| {
if (args.url) |u| {
try performGoto(server, u, id);
}
} else |_| {}
}
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const content = [_]protocol.TextContent(ToolStreamingText){.{ const content = [_]protocol.TextContent(ToolStreamingText){.{
.text = .{ .page = page, .action = .markdown }, .text = .{ .page = page, .action = .markdown },
}}; }};
try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }); server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }) catch {
return server.sendError(id, .InternalError, "Failed to serialize markdown content");
};
} }
fn handleLinks(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { fn handleLinks(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const LinksParams = struct { const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
url: ?[:0]const u8 = null, const page = try ensurePage(server, id, args.url);
};
if (arguments) |args_raw| {
if (std.json.parseFromValueLeaky(LinksParams, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| {
if (args.url) |u| {
try performGoto(server, u, id);
}
} else |_| {}
}
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const content = [_]protocol.TextContent(ToolStreamingText){.{ const content = [_]protocol.TextContent(ToolStreamingText){.{
.text = .{ .page = page, .action = .links }, .text = .{ .page = page, .action = .links },
}}; }};
try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }); server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }) catch {
return server.sendError(id, .InternalError, "Failed to serialize links content");
};
} }
fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
@@ -379,44 +355,38 @@ fn handleSemanticTree(server: *Server, arena: std.mem.Allocator, id: std.json.Va
backendNodeId: ?u32 = null, backendNodeId: ?u32 = null,
maxDepth: ?u32 = null, maxDepth: ?u32 = null,
}; };
var tree_args: TreeParams = .{}; const args = try parseArgsOrDefault(TreeParams, arena, arguments, server, id);
if (arguments) |args_raw| { const page = try ensurePage(server, id, args.url);
if (std.json.parseFromValueLeaky(TreeParams, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| {
tree_args = args;
if (args.url) |u| {
try performGoto(server, u, id);
}
} else |_| {}
}
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const content = [_]protocol.TextContent(ToolStreamingText){.{ const content = [_]protocol.TextContent(ToolStreamingText){.{
.text = .{ .page = page, .action = .semantic_tree, .registry = &server.node_registry, .arena = arena, .backendNodeId = tree_args.backendNodeId, .maxDepth = tree_args.maxDepth }, .text = .{
.page = page,
.action = .semantic_tree,
.registry = &server.node_registry,
.arena = arena,
.backendNodeId = args.backendNodeId,
.maxDepth = args.maxDepth,
},
}}; }};
try server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }); server.sendResult(id, protocol.CallToolResult(ToolStreamingText){ .content = &content }) catch {
return server.sendError(id, .InternalError, "Failed to serialize semantic tree content");
};
} }
fn handleInteractiveElements(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { fn handleInteractiveElements(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct { const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
url: ?[:0]const u8 = null, const page = try ensurePage(server, id, args.url);
};
if (arguments) |args_raw| {
if (std.json.parseFromValueLeaky(Params, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| {
if (args.url) |u| {
try performGoto(server, u, id);
}
} else |_| {}
}
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const elements = lp.interactive.collectInteractiveElements(page.document.asNode(), arena, page) catch |err| { const elements = lp.interactive.collectInteractiveElements(page.document.asNode(), arena, page) catch |err| {
log.err(.mcp, "elements collection failed", .{ .err = err }); log.err(.mcp, "elements collection failed", .{ .err = err });
return server.sendError(id, .InternalError, "Failed to collect interactive elements"); return server.sendError(id, .InternalError, "Failed to collect interactive elements");
}; };
lp.interactive.registerNodes(elements, &server.node_registry) catch |err| {
log.err(.mcp, "node registration failed", .{ .err = err });
return server.sendError(id, .InternalError, "Failed to register element nodes");
};
var aw: std.Io.Writer.Allocating = .init(arena); var aw: std.Io.Writer.Allocating = .init(arena);
try std.json.Stringify.value(elements, .{}, &aw.writer); try std.json.Stringify.value(elements, .{}, &aw.writer);
@@ -425,19 +395,8 @@ fn handleInteractiveElements(server: *Server, arena: std.mem.Allocator, id: std.
} }
fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct { const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
url: ?[:0]const u8 = null, const page = try ensurePage(server, id, args.url);
};
if (arguments) |args_raw| {
if (std.json.parseFromValueLeaky(Params, arena, args_raw, .{ .ignore_unknown_fields = true })) |args| {
if (args.url) |u| {
try performGoto(server, u, id);
}
} else |_| {}
}
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const data = lp.structured_data.collectStructuredData(page.document.asNode(), arena, page) catch |err| { const data = lp.structured_data.collectStructuredData(page.document.asNode(), arena, page) catch |err| {
log.err(.mcp, "struct data collection failed", .{ .err = err }); log.err(.mcp, "struct data collection failed", .{ .err = err });
@@ -451,20 +410,8 @@ fn handleStructuredData(server: *Server, arena: std.mem.Allocator, id: std.json.
} }
fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const Params = struct { const args = try parseArgsOrDefault(UrlParams, arena, arguments, server, id);
url: ?[:0]const u8 = null, const page = try ensurePage(server, id, args.url);
};
if (arguments) |args_raw| {
const args = std.json.parseFromValueLeaky(Params, arena, args_raw, .{ .ignore_unknown_fields = true }) catch {
return server.sendError(id, .InvalidParams, "Invalid arguments for detectForms");
};
if (args.url) |u| {
try performGoto(server, u, id);
}
}
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
const forms_data = lp.forms.collectForms(arena, page.document.asNode(), page) catch |err| { const forms_data = lp.forms.collectForms(arena, page.document.asNode(), page) catch |err| {
log.err(.mcp, "form collection failed", .{ .err = err }); log.err(.mcp, "form collection failed", .{ .err = err });
@@ -484,14 +431,8 @@ fn handleDetectForms(server: *Server, arena: std.mem.Allocator, id: std.json.Val
} }
fn handleEvaluate(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void { fn handleEvaluate(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arguments: ?std.json.Value) !void {
const args = try parseArguments(EvaluateParams, arena, arguments, server, id, "evaluate"); const args = try parseArgs(EvaluateParams, arena, arguments, server, id, "evaluate");
const page = try ensurePage(server, id, args.url);
if (args.url) |url| {
try performGoto(server, url, id);
}
const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded");
};
var ls: js.Local.Scope = undefined; var ls: js.Local.Scope = undefined;
page.js.localScope(&ls); page.js.localScope(&ls);
@@ -520,7 +461,7 @@ fn handleClick(server: *Server, arena: std.mem.Allocator, id: std.json.Value, ar
const ClickParams = struct { const ClickParams = struct {
backendNodeId: CDPNode.Id, backendNodeId: CDPNode.Id,
}; };
const args = try parseArguments(ClickParams, arena, arguments, server, id, "click"); const args = try parseArgs(ClickParams, arena, arguments, server, id, "click");
const page = server.session.currentPage() orelse { const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded"); return server.sendError(id, .PageNotLoaded, "Page not loaded");
@@ -552,7 +493,7 @@ fn handleFill(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg
backendNodeId: CDPNode.Id, backendNodeId: CDPNode.Id,
text: []const u8, text: []const u8,
}; };
const args = try parseArguments(FillParams, arena, arguments, server, id, "fill"); const args = try parseArgs(FillParams, arena, arguments, server, id, "fill");
const page = server.session.currentPage() orelse { const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded"); return server.sendError(id, .PageNotLoaded, "Page not loaded");
@@ -586,7 +527,7 @@ fn handleScroll(server: *Server, arena: std.mem.Allocator, id: std.json.Value, a
x: ?i32 = null, x: ?i32 = null,
y: ?i32 = null, y: ?i32 = null,
}; };
const args = try parseArguments(ScrollParams, arena, arguments, server, id, "scroll"); const args = try parseArgs(ScrollParams, arena, arguments, server, id, "scroll");
const page = server.session.currentPage() orelse { const page = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded"); return server.sendError(id, .PageNotLoaded, "Page not loaded");
@@ -623,7 +564,7 @@ fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json
selector: [:0]const u8, selector: [:0]const u8,
timeout: ?u32 = null, timeout: ?u32 = null,
}; };
const args = try parseArguments(WaitParams, arena, arguments, server, id, "waitForSelector"); const args = try parseArgs(WaitParams, arena, arguments, server, id, "waitForSelector");
_ = server.session.currentPage() orelse { _ = server.session.currentPage() orelse {
return server.sendError(id, .PageNotLoaded, "Page not loaded"); return server.sendError(id, .PageNotLoaded, "Page not loaded");
@@ -647,12 +588,38 @@ fn handleWaitForSelector(server: *Server, arena: std.mem.Allocator, id: std.json
return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content }); return server.sendResult(id, protocol.CallToolResult([]const u8){ .content = &content });
} }
fn parseArguments(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value, server: *Server, id: std.json.Value, tool_name: []const u8) !T { fn ensurePage(server: *Server, id: std.json.Value, url: ?[:0]const u8) !*lp.Page {
if (arguments == null) { if (url) |u| {
try performGoto(server, u, id);
}
return server.session.currentPage() orelse {
try server.sendError(id, .PageNotLoaded, "Page not loaded");
return error.PageNotLoaded;
};
}
/// Parses JSON arguments into a given struct type `T`.
/// If the arguments are missing, it returns a default-initialized `T` (e.g., `.{}`).
/// If the arguments are present but invalid, it sends an MCP error response and returns `error.InvalidParams`.
/// Use this for tools where all arguments are optional.
fn parseArgsOrDefault(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value, server: *Server, id: std.json.Value) !T {
const args_raw = arguments orelse return .{};
return std.json.parseFromValueLeaky(T, arena, args_raw, .{ .ignore_unknown_fields = true }) catch {
try server.sendError(id, .InvalidParams, "Invalid arguments");
return error.InvalidParams;
};
}
/// Parses JSON arguments into a given struct type `T`.
/// If the arguments are missing or invalid, it automatically sends an MCP error response to the client
/// and returns an `error.InvalidParams`.
/// Use this for tools that require strict validation or mandatory arguments.
fn parseArgs(comptime T: type, arena: std.mem.Allocator, arguments: ?std.json.Value, server: *Server, id: std.json.Value, tool_name: []const u8) !T {
const args_raw = arguments orelse {
try server.sendError(id, .InvalidParams, "Missing arguments"); try server.sendError(id, .InvalidParams, "Missing arguments");
return error.InvalidParams; return error.InvalidParams;
} };
return std.json.parseFromValueLeaky(T, arena, arguments.?, .{ .ignore_unknown_fields = true }) catch { return std.json.parseFromValueLeaky(T, arena, args_raw, .{ .ignore_unknown_fields = true }) catch {
const msg = std.fmt.allocPrint(arena, "Invalid arguments for {s}", .{tool_name}) catch "Invalid arguments"; const msg = std.fmt.allocPrint(arena, "Invalid arguments for {s}", .{tool_name}) catch "Invalid arguments";
try server.sendError(id, .InvalidParams, msg); try server.sendError(id, .InvalidParams, msg);
return error.InvalidParams; return error.InvalidParams;
@@ -664,7 +631,10 @@ fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value) !void {
if (session.page != null) { if (session.page != null) {
session.removePage(); session.removePage();
} }
const page = try session.createPage(); const page = session.createPage() catch {
try server.sendError(id, .InternalError, "Failed to create page");
return error.NavigationFailed;
};
page.navigate(url, .{ page.navigate(url, .{
.reason = .address_bar, .reason = .address_bar,
.kind = .{ .push = null }, .kind = .{ .push = null },
@@ -673,8 +643,14 @@ fn performGoto(server: *Server, url: [:0]const u8, id: std.json.Value) !void {
return error.NavigationFailed; return error.NavigationFailed;
}; };
var runner = try session.runner(.{}); var runner = session.runner(.{}) catch {
try runner.wait(.{ .ms = 2000 }); try server.sendError(id, .InternalError, "Failed to start page runner");
return error.NavigationFailed;
};
runner.wait(.{ .ms = 2000 }) catch {
try server.sendError(id, .InternalError, "Timeout waiting for page load");
return error.NavigationFailed;
};
} }
const router = @import("router.zig"); const router = @import("router.zig");

View File

@@ -461,7 +461,7 @@ fn drainQueue(self: *Runtime) void {
self.releaseConnection(conn); self.releaseConnection(conn);
continue; continue;
}; };
libcurl.curl_multi_add_handle(multi, conn.easy) catch |err| { libcurl.curl_multi_add_handle(multi, conn._easy) catch |err| {
lp.log.err(.app, "curl multi add", .{ .err = err }); lp.log.err(.app, "curl multi add", .{ .err = err });
self.releaseConnection(conn); self.releaseConnection(conn);
}; };
@@ -565,7 +565,7 @@ pub fn getConnection(self: *Runtime) ?*net_http.Connection {
} }
pub fn releaseConnection(self: *Runtime, conn: *net_http.Connection) void { pub fn releaseConnection(self: *Runtime, conn: *net_http.Connection) void {
conn.reset() catch |err| { conn.reset(self.config, self.ca_blob) catch |err| {
lp.assert(false, "couldn't reset curl easy", .{ .err = err }); lp.assert(false, "couldn't reset curl easy", .{ .err = err });
}; };

View File

@@ -17,10 +17,6 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std"); const std = @import("std");
const builtin = @import("builtin");
const posix = std.posix;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const Config = @import("../Config.zig"); const Config = @import("../Config.zig");
const libcurl = @import("../sys/libcurl.zig"); const libcurl = @import("../sys/libcurl.zig");
@@ -29,18 +25,12 @@ const log = @import("lightpanda").log;
const assert = @import("lightpanda").assert; const assert = @import("lightpanda").assert;
pub const ENABLE_DEBUG = false; pub const ENABLE_DEBUG = false;
const IS_DEBUG = builtin.mode == .Debug;
pub const Blob = libcurl.CurlBlob; pub const Blob = libcurl.CurlBlob;
pub const WaitFd = libcurl.CurlWaitFd; pub const WaitFd = libcurl.CurlWaitFd;
pub const writefunc_error = libcurl.curl_writefunc_error; pub const writefunc_error = libcurl.curl_writefunc_error;
const Error = libcurl.Error; const Error = libcurl.Error;
const ErrorMulti = libcurl.ErrorMulti;
const errorFromCode = libcurl.errorFromCode;
const errorMFromCode = libcurl.errorMFromCode;
const errorCheck = libcurl.errorCheck;
const errorMCheck = libcurl.errorMCheck;
pub fn curl_version() [*c]const u8 { pub fn curl_version() [*c]const u8 {
return libcurl.curl_version(); return libcurl.curl_version();
@@ -64,14 +54,13 @@ pub const Header = struct {
pub const Headers = struct { pub const Headers = struct {
headers: ?*libcurl.CurlSList, headers: ?*libcurl.CurlSList,
cookies: ?[*c]const u8,
pub fn init(user_agent: [:0]const u8) !Headers { pub fn init(user_agent: [:0]const u8) !Headers {
const header_list = libcurl.curl_slist_append(null, user_agent); const header_list = libcurl.curl_slist_append(null, user_agent);
if (header_list == null) { if (header_list == null) {
return error.OutOfMemory; return error.OutOfMemory;
} }
return .{ .headers = header_list, .cookies = null }; return .{ .headers = header_list };
} }
pub fn deinit(self: *const Headers) void { pub fn deinit(self: *const Headers) void {
@@ -102,20 +91,14 @@ pub const Headers = struct {
pub fn iterator(self: *Headers) Iterator { pub fn iterator(self: *Headers) Iterator {
return .{ return .{
.header = self.headers, .header = self.headers,
.cookies = self.cookies,
}; };
} }
const Iterator = struct { const Iterator = struct {
header: [*c]libcurl.CurlSList, header: [*c]libcurl.CurlSList,
cookies: ?[*c]const u8,
pub fn next(self: *Iterator) ?Header { pub fn next(self: *Iterator) ?Header {
const h = self.header orelse { const h = self.header orelse return null;
const cookies = self.cookies orelse return null;
self.cookies = null;
return .{ .name = "Cookie", .value = std.mem.span(@as([*:0]const u8, cookies)) };
};
self.header = h.*.next; self.header = h.*.next;
return parseHeader(std.mem.span(@as([*:0]const u8, @ptrCast(h.*.data)))); return parseHeader(std.mem.span(@as([*:0]const u8, @ptrCast(h.*.data))));
@@ -142,7 +125,7 @@ pub const HeaderIterator = union(enum) {
prev: ?*libcurl.CurlHeader = null, prev: ?*libcurl.CurlHeader = null,
pub fn next(self: *CurlHeaderIterator) ?Header { pub fn next(self: *CurlHeaderIterator) ?Header {
const h = libcurl.curl_easy_nextheader(self.conn.easy, .header, -1, self.prev) orelse return null; const h = libcurl.curl_easy_nextheader(self.conn._easy, .header, -1, self.prev) orelse return null;
self.prev = h; self.prev = h;
const header = h.*; const header = h.*;
@@ -174,33 +157,24 @@ const HeaderValue = struct {
}; };
pub const AuthChallenge = struct { pub const AuthChallenge = struct {
const Source = enum { server, proxy };
const Scheme = enum { basic, digest };
status: u16, status: u16,
source: ?enum { server, proxy }, source: ?Source,
scheme: ?enum { basic, digest }, scheme: ?Scheme,
realm: ?[]const u8, realm: ?[]const u8,
pub fn parse(status: u16, header: []const u8) !AuthChallenge { pub fn parse(status: u16, source: Source, value: []const u8) !AuthChallenge {
var ac: AuthChallenge = .{ var ac: AuthChallenge = .{
.status = status, .status = status,
.source = null, .source = source,
.realm = null, .realm = null,
.scheme = null, .scheme = null,
}; };
const sep = std.mem.indexOfPos(u8, header, 0, ": ") orelse return error.InvalidHeader; const pos = std.mem.indexOfPos(u8, std.mem.trim(u8, value, std.ascii.whitespace[0..]), 0, " ") orelse value.len;
const hname = header[0..sep]; const _scheme = value[0..pos];
const hvalue = header[sep + 2 ..];
if (std.ascii.eqlIgnoreCase("WWW-Authenticate", hname)) {
ac.source = .server;
} else if (std.ascii.eqlIgnoreCase("Proxy-Authenticate", hname)) {
ac.source = .proxy;
} else {
return error.InvalidAuthChallenge;
}
const pos = std.mem.indexOfPos(u8, std.mem.trim(u8, hvalue, std.ascii.whitespace[0..]), 0, " ") orelse hvalue.len;
const _scheme = hvalue[0..pos];
if (std.ascii.eqlIgnoreCase(_scheme, "basic")) { if (std.ascii.eqlIgnoreCase(_scheme, "basic")) {
ac.scheme = .basic; ac.scheme = .basic;
} else if (std.ascii.eqlIgnoreCase(_scheme, "digest")) { } else if (std.ascii.eqlIgnoreCase(_scheme, "digest")) {
@@ -236,77 +210,28 @@ pub const ResponseHead = struct {
}; };
pub const Connection = struct { pub const Connection = struct {
easy: *libcurl.Curl, _easy: *libcurl.Curl,
node: std.DoublyLinkedList.Node = .{}, node: std.DoublyLinkedList.Node = .{},
pub fn init( pub fn init(
ca_blob_: ?libcurl.CurlBlob, ca_blob: ?libcurl.CurlBlob,
config: *const Config, config: *const Config,
) !Connection { ) !Connection {
const easy = libcurl.curl_easy_init() orelse return error.FailedToInitializeEasy; const easy = libcurl.curl_easy_init() orelse return error.FailedToInitializeEasy;
errdefer libcurl.curl_easy_cleanup(easy);
// timeouts const self = Connection{ ._easy = easy };
try libcurl.curl_easy_setopt(easy, .timeout_ms, config.httpTimeout()); errdefer self.deinit();
try libcurl.curl_easy_setopt(easy, .connect_timeout_ms, config.httpConnectTimeout());
// redirect behavior try self.reset(config, ca_blob);
try libcurl.curl_easy_setopt(easy, .max_redirs, config.httpMaxRedirects()); return self;
try libcurl.curl_easy_setopt(easy, .follow_location, 2);
try libcurl.curl_easy_setopt(easy, .redir_protocols_str, "HTTP,HTTPS"); // remove FTP and FTPS from the default
// proxy
const http_proxy = config.httpProxy();
if (http_proxy) |proxy| {
try libcurl.curl_easy_setopt(easy, .proxy, proxy.ptr);
}
// tls
if (ca_blob_) |ca_blob| {
try libcurl.curl_easy_setopt(easy, .ca_info_blob, ca_blob);
if (http_proxy != null) {
try libcurl.curl_easy_setopt(easy, .proxy_ca_info_blob, ca_blob);
}
} else {
assert(config.tlsVerifyHost() == false, "Http.init tls_verify_host", .{});
try libcurl.curl_easy_setopt(easy, .ssl_verify_host, false);
try libcurl.curl_easy_setopt(easy, .ssl_verify_peer, false);
if (http_proxy != null) {
try libcurl.curl_easy_setopt(easy, .proxy_ssl_verify_host, false);
try libcurl.curl_easy_setopt(easy, .proxy_ssl_verify_peer, false);
}
}
// compression, don't remove this. CloudFront will send gzip content
// even if we don't support it, and then it won't be decompressed.
// empty string means: use whatever's available
try libcurl.curl_easy_setopt(easy, .accept_encoding, "");
// debug
if (comptime ENABLE_DEBUG) {
try libcurl.curl_easy_setopt(easy, .verbose, true);
// Sometimes the default debug output hides some useful data. You can
// uncomment the following line (BUT KEEP THE LIVE ABOVE AS-IS), to
// get more control over the data (specifically, the `CURLINFO_TEXT`
// can include useful data).
// try libcurl.curl_easy_setopt(easy, .debug_function, debugCallback);
}
return .{
.easy = easy,
};
} }
pub fn deinit(self: *const Connection) void { pub fn deinit(self: *const Connection) void {
libcurl.curl_easy_cleanup(self.easy); libcurl.curl_easy_cleanup(self._easy);
} }
pub fn setURL(self: *const Connection, url: [:0]const u8) !void { pub fn setURL(self: *const Connection, url: [:0]const u8) !void {
try libcurl.curl_easy_setopt(self.easy, .url, url.ptr); try libcurl.curl_easy_setopt(self._easy, .url, url.ptr);
} }
// a libcurl request has 2 methods. The first is the method that // a libcurl request has 2 methods. The first is the method that
@@ -329,7 +254,7 @@ pub const Connection = struct {
// can infer that based on the presence of the body, but we also reset it // can infer that based on the presence of the body, but we also reset it
// to be safe); // to be safe);
pub fn setMethod(self: *const Connection, method: Method) !void { pub fn setMethod(self: *const Connection, method: Method) !void {
const easy = self.easy; const easy = self._easy;
const m: [:0]const u8 = switch (method) { const m: [:0]const u8 = switch (method) {
.GET => "GET", .GET => "GET",
.POST => "POST", .POST => "POST",
@@ -344,56 +269,97 @@ pub const Connection = struct {
} }
pub fn setBody(self: *const Connection, body: []const u8) !void { pub fn setBody(self: *const Connection, body: []const u8) !void {
const easy = self.easy; const easy = self._easy;
try libcurl.curl_easy_setopt(easy, .post, true); try libcurl.curl_easy_setopt(easy, .post, true);
try libcurl.curl_easy_setopt(easy, .post_field_size, body.len); try libcurl.curl_easy_setopt(easy, .post_field_size, body.len);
try libcurl.curl_easy_setopt(easy, .copy_post_fields, body.ptr); try libcurl.curl_easy_setopt(easy, .copy_post_fields, body.ptr);
} }
pub fn setGetMode(self: *const Connection) !void { pub fn setGetMode(self: *const Connection) !void {
try libcurl.curl_easy_setopt(self.easy, .http_get, true); try libcurl.curl_easy_setopt(self._easy, .http_get, true);
} }
pub fn setHeaders(self: *const Connection, headers: *Headers) !void { pub fn setHeaders(self: *const Connection, headers: *Headers) !void {
try libcurl.curl_easy_setopt(self.easy, .http_header, headers.headers); try libcurl.curl_easy_setopt(self._easy, .http_header, headers.headers);
} }
pub fn setCookies(self: *const Connection, cookies: [*c]const u8) !void { pub fn setCookies(self: *const Connection, cookies: [*c]const u8) !void {
try libcurl.curl_easy_setopt(self.easy, .cookie, cookies); try libcurl.curl_easy_setopt(self._easy, .cookie, cookies);
} }
pub fn setPrivate(self: *const Connection, ptr: *anyopaque) !void { pub fn setPrivate(self: *const Connection, ptr: *anyopaque) !void {
try libcurl.curl_easy_setopt(self.easy, .private, ptr); try libcurl.curl_easy_setopt(self._easy, .private, ptr);
} }
pub fn setProxyCredentials(self: *const Connection, creds: [:0]const u8) !void { pub fn setProxyCredentials(self: *const Connection, creds: [:0]const u8) !void {
try libcurl.curl_easy_setopt(self.easy, .proxy_user_pwd, creds.ptr); try libcurl.curl_easy_setopt(self._easy, .proxy_user_pwd, creds.ptr);
} }
pub fn setCredentials(self: *const Connection, creds: [:0]const u8) !void { pub fn setCredentials(self: *const Connection, creds: [:0]const u8) !void {
try libcurl.curl_easy_setopt(self.easy, .user_pwd, creds.ptr); try libcurl.curl_easy_setopt(self._easy, .user_pwd, creds.ptr);
} }
pub fn setCallbacks( pub fn setCallbacks(
self: *const Connection, self: *Connection,
comptime header_cb: libcurl.CurlHeaderFunction,
comptime data_cb: libcurl.CurlWriteFunction, comptime data_cb: libcurl.CurlWriteFunction,
) !void { ) !void {
try libcurl.curl_easy_setopt(self.easy, .header_data, self.easy); try libcurl.curl_easy_setopt(self._easy, .write_data, self);
try libcurl.curl_easy_setopt(self.easy, .header_function, header_cb); try libcurl.curl_easy_setopt(self._easy, .write_function, data_cb);
try libcurl.curl_easy_setopt(self.easy, .write_data, self.easy);
try libcurl.curl_easy_setopt(self.easy, .write_function, data_cb);
} }
pub fn reset(self: *const Connection) !void { pub fn reset(
try libcurl.curl_easy_setopt(self.easy, .proxy, null); self: *const Connection,
try libcurl.curl_easy_setopt(self.easy, .http_header, null); config: *const Config,
ca_blob: ?libcurl.CurlBlob,
) !void {
libcurl.curl_easy_reset(self._easy);
try libcurl.curl_easy_setopt(self.easy, .header_data, null); // timeouts
try libcurl.curl_easy_setopt(self.easy, .header_function, null); try libcurl.curl_easy_setopt(self._easy, .timeout_ms, config.httpTimeout());
try libcurl.curl_easy_setopt(self._easy, .connect_timeout_ms, config.httpConnectTimeout());
try libcurl.curl_easy_setopt(self.easy, .write_data, null); // compression, don't remove this. CloudFront will send gzip content
try libcurl.curl_easy_setopt(self.easy, .write_function, discardBody); // even if we don't support it, and then it won't be decompressed.
// empty string means: use whatever's available
try libcurl.curl_easy_setopt(self._easy, .accept_encoding, "");
// proxy
const http_proxy = config.httpProxy();
if (http_proxy) |proxy| {
try libcurl.curl_easy_setopt(self._easy, .proxy, proxy.ptr);
} else {
try libcurl.curl_easy_setopt(self._easy, .proxy, null);
}
// tls
if (ca_blob) |ca| {
try libcurl.curl_easy_setopt(self._easy, .ca_info_blob, ca);
if (http_proxy != null) {
try libcurl.curl_easy_setopt(self._easy, .proxy_ca_info_blob, ca);
}
} else {
assert(config.tlsVerifyHost() == false, "Http.init tls_verify_host", .{});
try libcurl.curl_easy_setopt(self._easy, .ssl_verify_host, false);
try libcurl.curl_easy_setopt(self._easy, .ssl_verify_peer, false);
if (http_proxy != null) {
try libcurl.curl_easy_setopt(self._easy, .proxy_ssl_verify_host, false);
try libcurl.curl_easy_setopt(self._easy, .proxy_ssl_verify_peer, false);
}
}
// debug
if (comptime ENABLE_DEBUG) {
try libcurl.curl_easy_setopt(self._easy, .verbose, true);
// Sometimes the default debug output hides some useful data. You can
// uncomment the following line (BUT KEEP THE LIVE ABOVE AS-IS), to
// get more control over the data (specifically, the `CURLINFO_TEXT`
// can include useful data).
// try libcurl.curl_easy_setopt(easy, .debug_function, debugCallback);
}
} }
fn discardBody(_: [*]const u8, count: usize, len: usize, _: ?*anyopaque) usize { fn discardBody(_: [*]const u8, count: usize, len: usize, _: ?*anyopaque) usize {
@@ -401,27 +367,31 @@ pub const Connection = struct {
} }
pub fn setProxy(self: *const Connection, proxy: ?[:0]const u8) !void { pub fn setProxy(self: *const Connection, proxy: ?[:0]const u8) !void {
try libcurl.curl_easy_setopt(self.easy, .proxy, if (proxy) |p| p.ptr else null); try libcurl.curl_easy_setopt(self._easy, .proxy, if (proxy) |p| p.ptr else null);
}
pub fn setFollowLocation(self: *const Connection, follow: bool) !void {
try libcurl.curl_easy_setopt(self._easy, .follow_location, @as(c_long, if (follow) 2 else 0));
} }
pub fn setTlsVerify(self: *const Connection, verify: bool, use_proxy: bool) !void { pub fn setTlsVerify(self: *const Connection, verify: bool, use_proxy: bool) !void {
try libcurl.curl_easy_setopt(self.easy, .ssl_verify_host, verify); try libcurl.curl_easy_setopt(self._easy, .ssl_verify_host, verify);
try libcurl.curl_easy_setopt(self.easy, .ssl_verify_peer, verify); try libcurl.curl_easy_setopt(self._easy, .ssl_verify_peer, verify);
if (use_proxy) { if (use_proxy) {
try libcurl.curl_easy_setopt(self.easy, .proxy_ssl_verify_host, verify); try libcurl.curl_easy_setopt(self._easy, .proxy_ssl_verify_host, verify);
try libcurl.curl_easy_setopt(self.easy, .proxy_ssl_verify_peer, verify); try libcurl.curl_easy_setopt(self._easy, .proxy_ssl_verify_peer, verify);
} }
} }
pub fn getEffectiveUrl(self: *const Connection) ![*c]const u8 { pub fn getEffectiveUrl(self: *const Connection) ![*c]const u8 {
var url: [*c]u8 = undefined; var url: [*c]u8 = undefined;
try libcurl.curl_easy_getinfo(self.easy, .effective_url, &url); try libcurl.curl_easy_getinfo(self._easy, .effective_url, &url);
return url; return url;
} }
pub fn getResponseCode(self: *const Connection) !u16 { pub fn getResponseCode(self: *const Connection) !u16 {
var status: c_long = undefined; var status: c_long = undefined;
try libcurl.curl_easy_getinfo(self.easy, .response_code, &status); try libcurl.curl_easy_getinfo(self._easy, .response_code, &status);
if (status < 0 or status > std.math.maxInt(u16)) { if (status < 0 or status > std.math.maxInt(u16)) {
return 0; return 0;
} }
@@ -430,13 +400,13 @@ pub const Connection = struct {
pub fn getRedirectCount(self: *const Connection) !u32 { pub fn getRedirectCount(self: *const Connection) !u32 {
var count: c_long = undefined; var count: c_long = undefined;
try libcurl.curl_easy_getinfo(self.easy, .redirect_count, &count); try libcurl.curl_easy_getinfo(self._easy, .redirect_count, &count);
return @intCast(count); return @intCast(count);
} }
pub fn getResponseHeader(self: *const Connection, name: [:0]const u8, index: usize) ?HeaderValue { pub fn getResponseHeader(self: *const Connection, name: [:0]const u8, index: usize) ?HeaderValue {
var hdr: ?*libcurl.CurlHeader = null; var hdr: ?*libcurl.CurlHeader = null;
libcurl.curl_easy_header(self.easy, name, index, .header, -1, &hdr) catch |err| { libcurl.curl_easy_header(self._easy, name, index, .header, -1, &hdr) catch |err| {
// ErrorHeader includes OutOfMemory — rare but real errors from curl internals. // ErrorHeader includes OutOfMemory — rare but real errors from curl internals.
// Logged and returned as null since callers don't expect errors. // Logged and returned as null since callers don't expect errors.
log.err(.http, "get response header", .{ log.err(.http, "get response header", .{
@@ -454,7 +424,7 @@ pub const Connection = struct {
pub fn getPrivate(self: *const Connection) !*anyopaque { pub fn getPrivate(self: *const Connection) !*anyopaque {
var private: *anyopaque = undefined; var private: *anyopaque = undefined;
try libcurl.curl_easy_getinfo(self.easy, .private, &private); try libcurl.curl_easy_getinfo(self._easy, .private, &private);
return private; return private;
} }
@@ -471,12 +441,7 @@ pub const Connection = struct {
try self.secretHeaders(&header_list, http_headers); try self.secretHeaders(&header_list, http_headers);
try self.setHeaders(&header_list); try self.setHeaders(&header_list);
// Add cookies. try libcurl.curl_easy_perform(self._easy);
if (header_list.cookies) |cookies| {
try self.setCookies(cookies);
}
try libcurl.curl_easy_perform(self.easy);
return self.getResponseCode(); return self.getResponseCode();
} }
}; };
@@ -498,11 +463,11 @@ pub const Handles = struct {
} }
pub fn add(self: *Handles, conn: *const Connection) !void { pub fn add(self: *Handles, conn: *const Connection) !void {
try libcurl.curl_multi_add_handle(self.multi, conn.easy); try libcurl.curl_multi_add_handle(self.multi, conn._easy);
} }
pub fn remove(self: *Handles, conn: *const Connection) !void { pub fn remove(self: *Handles, conn: *const Connection) !void {
try libcurl.curl_multi_remove_handle(self.multi, conn.easy); try libcurl.curl_multi_remove_handle(self.multi, conn._easy);
} }
pub fn perform(self: *Handles) !c_int { pub fn perform(self: *Handles) !c_int {
@@ -525,7 +490,7 @@ pub const Handles = struct {
const msg = libcurl.curl_multi_info_read(self.multi, &messages_count) orelse return null; const msg = libcurl.curl_multi_info_read(self.multi, &messages_count) orelse return null;
return switch (msg.data) { return switch (msg.data) {
.done => |err| .{ .done => |err| .{
.conn = .{ .easy = msg.easy_handle }, .conn = .{ ._easy = msg.easy_handle },
.err = err, .err = err,
}, },
else => unreachable, else => unreachable,

View File

@@ -516,6 +516,10 @@ pub fn curl_easy_cleanup(easy: *Curl) void {
c.curl_easy_cleanup(easy); c.curl_easy_cleanup(easy);
} }
pub fn curl_easy_reset(easy: *Curl) void {
c.curl_easy_reset(easy);
}
pub fn curl_easy_perform(easy: *Curl) Error!void { pub fn curl_easy_perform(easy: *Curl) Error!void {
try errorCheck(c.curl_easy_perform(easy)); try errorCheck(c.curl_easy_perform(easy));
} }

View File

@@ -9,7 +9,6 @@ const App = @import("../App.zig");
const Config = @import("../Config.zig"); const Config = @import("../Config.zig");
const telemetry = @import("telemetry.zig"); const telemetry = @import("telemetry.zig");
const Runtime = @import("../network/Runtime.zig"); const Runtime = @import("../network/Runtime.zig");
const Connection = @import("../network/http.zig").Connection;
const URL = "https://telemetry.lightpanda.io"; const URL = "https://telemetry.lightpanda.io";
const BUFFER_SIZE = 1024; const BUFFER_SIZE = 1024;