Merge branch 'main' into run-command

# Conflicts:
#	src/Config.zig
#	src/help.zon
This commit is contained in:
Adrià Arrufat
2026-07-17 22:46:47 +02:00
256 changed files with 20287 additions and 7745 deletions

View File

@@ -48,7 +48,7 @@ brew install lightpanda-io/browser/lightpanda
Latest nightly from Arch Linux User Repository:
```console
yay -S lightpanda-nightly-bi
yay -S lightpanda-nightly-bin
```
**Download from the nightly builds**
@@ -200,6 +200,28 @@ Add to your MCP configuration:
}
```
#### HTTP transport and independent sessions
For serving several agents from one process, start the MCP server over HTTP
instead of stdio by giving it a port (add `--host x.x.x.x` to specify the
interface to listen on):
```bash
lightpanda mcp --port 9223
```
Clients POST JSON-RPC to `http://host:9223/mcp`. Each connection is routed to
its own **browsing session** — its own page, cookies and memory — so agents no
longer clobber each other's page:
- A client that `initialize`s without an `Mcp-Session-Id` header is assigned a
fresh session; the id comes back in the response's `Mcp-Session-Id` header.
Send it on subsequent requests to stay on that session (**isolation**).
- Two agents that send the **same** `Mcp-Session-Id` share one browsing context
(**sharing** — e.g. a workflow where several agents work the same page).
- The `session_new`, `session_list` and `session_close` tools manage sessions
explicitly. Sending `DELETE /mcp` with an `Mcp-Session-Id` closes that session.
[Read full documentation](https://lightpanda.io/docs/open-source/guides/mcp-server)
A skill is available in [lightpanda-io/agent-skill](https://github.com/lightpanda-io/agent-skill).

View File

@@ -176,6 +176,38 @@ pub fn build(b: *Build) !void {
run_step.dependOn(&run_cmd.step);
}
{
// skills generator
const exe = b.addExecutable(.{
.name = "lightpanda-skills",
.use_llvm = true,
.root_module = b.createModule(.{
.root_source_file = b.path("src/main_skills.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "lightpanda", .module = lightpanda_module },
},
}),
});
const exe_check = b.addLibrary(.{
.name = "skills_check",
.root_module = exe.root_module,
});
check.dependOn(&exe_check.step);
const run_cmd = b.addRunArtifact(exe);
const out_dir = run_cmd.addOutputDirectoryArg("skills");
const install = b.addInstallDirectory(.{
.source_dir = out_dir,
.install_dir = .prefix,
.install_subdir = "skills",
});
const skills_step = b.step("skills", "Generate LLM skill docs (zig-out/skills/<name>/SKILL.md)");
skills_step.dependOn(&install.step);
}
{
// test
const tests = b.addTest(.{

View File

@@ -35,8 +35,8 @@
.hash = "sqlite3-3.51.0-DMxLWssOAABZ8cAvU_LfBIbp0kZjm824PU8sSLXpEDdr",
},
.zenai = .{
.url = "git+https://github.com/lightpanda-io/zenai.git#e58635146733157eb4936388b21a071fcabfa687",
.hash = "zenai-0.0.0-iOY_VIf0BADH7PvSOlnVf564krXnAlns_1IIhh9ZDdwz",
.url = "git+https://github.com/lightpanda-io/zenai.git#1df180de89ef72535d973af7854bf24dc8ab94a7",
.hash = "zenai-0.0.0-iOY_VPyeBQDnDr5NOUWDK7HWpFTJR8JXZ5UYcwNGEMOu",
},
.isocline = .{
.url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47",

View File

@@ -111,25 +111,27 @@ pub fn deinit(self: *ArenaPool) void {
// - Pass a BucketSize (.tiny, .small, .medium, .large) for explicit bucket selection
// - Pass a usize for automatic bucket selection based on expected size
pub fn acquire(self: *ArenaPool, size_or_bucket: anytype, debug: []const u8) !Allocator {
const bucket = blk: {
const bucket_size: BucketSize = blk: {
const T = @TypeOf(size_or_bucket);
if (T == BucketSize or T == @TypeOf(.enum_literal)) {
break :blk switch (@as(BucketSize, size_or_bucket)) {
.tiny => &self.tiny,
.small => &self.small,
.medium => &self.medium,
.large => &self.large,
};
break :blk @as(BucketSize, size_or_bucket);
}
if (T == usize or T == comptime_int) {
if (size_or_bucket <= self.tiny.retain_bytes) break :blk &self.tiny;
if (size_or_bucket <= self.small.retain_bytes) break :blk &self.small;
if (size_or_bucket <= self.medium.retain_bytes) break :blk &self.medium;
break :blk &self.large;
if (size_or_bucket <= self.tiny.retain_bytes) break :blk .tiny;
if (size_or_bucket <= self.small.retain_bytes) break :blk .small;
if (size_or_bucket <= self.medium.retain_bytes) break :blk .medium;
break :blk .large;
}
@compileError("acquire expects BucketSize or usize, got " ++ @typeName(T));
};
const bucket = switch (bucket_size) {
.tiny => &self.tiny,
.small => &self.small,
.medium => &self.medium,
.large => &self.large,
};
self.mutex.lock();
defer self.mutex.unlock();
@@ -144,9 +146,12 @@ pub fn acquire(self: *ArenaPool, size_or_bucket: anytype, debug: []const u8) !Al
}
gop.value_ptr.* += 1;
}
lp.metrics.arena_hit.incr(bucket_size);
return entry.arena.allocator();
}
lp.metrics.arena_miss.incr(bucket_size);
const entry = try self.entry_pool.create();
entry.* = .{
.next = null,

View File

@@ -203,6 +203,7 @@ const Commands = cli.Builder(.{
.{ .name = "cdp_max_message_size", .type = u32, .default = 1024 * 1024 },
// Don't widen this without growing the reader buffer in the HTTP path.
.{ .name = "cdp_max_http_message_size", .type = u14, .default = 4096 },
.{ .name = "disable_metrics", .type = bool },
},
.shared_options = CommonOptions,
},
@@ -235,12 +236,15 @@ const Commands = cli.Builder(.{
},
.{ .name = "terminate_ms", .type = ?u32 },
.{ .name = "json", .type = bool },
.{ .name = "metrics", .type = bool },
},
.shared_options = CommonOptions,
},
.{
.name = "mcp",
.options = .{
.{ .name = "port", .type = ?u16 },
.{ .name = "host", .type = []const u8, .default = "127.0.0.1" },
.{ .name = "cdp_port", .type = ?u16 },
},
.shared_options = CommonOptions,
@@ -595,6 +599,20 @@ pub fn cdpMaxMessageSize(self: *const Config) u32 {
};
}
pub fn metricsEndpointEnabled(self: *const Config) bool {
return switch (self.mode) {
.serve => |opts| !opts.disable_metrics,
else => unreachable,
};
}
pub fn dumpMetricsOnExit(self: *const Config) bool {
return switch (self.mode) {
.fetch => |opts| opts.metrics,
else => false,
};
}
pub fn cdpMaxHTTPMessageSize(self: *const Config) u14 {
return switch (self.mode) {
.serve => |opts| opts.cdp_max_http_message_size,
@@ -707,7 +725,7 @@ pub const HttpHeaders = struct {
}
};
pub fn printUsageAndExit(self: *const Config, help_for: RunMode, success: bool) void {
pub fn printUsageAndExit(self: *const Config, allocator: Allocator, help_for: RunMode, success: bool) !void {
const exec_name = self.exec_name;
const Help = @import("help.zon");
const is_debug = builtin.mode == .Debug;
@@ -715,36 +733,86 @@ pub fn printUsageAndExit(self: *const Config, help_for: RunMode, success: bool)
const pretty_or_logfmt = if (comptime is_debug) "pretty" else "logfmt";
const comptimePrint = std.fmt.comptimePrint;
switch (help_for) {
const text = switch (help_for) {
// Requested help for everything.
.help => {
.help => text: {
const template = comptimePrint(
\\{s}
\\
, .{Help.general});
std.debug.print(template, .{exec_name});
break :text try std.fmt.allocPrint(allocator, template, .{exec_name});
},
inline .fetch, .serve, .mcp, .agent, .run => |tag| {
inline .fetch, .serve, .mcp, .agent, .run => |tag| text: {
const template = comptimePrint(
\\{s}
\\
\\{s}
\\
, .{ @field(Help, @tagName(tag)), Help.common_options });
std.debug.print(template, .{ exec_name, info_or_warn, pretty_or_logfmt });
break :text try std.fmt.allocPrint(allocator, template, .{ exec_name, info_or_warn, pretty_or_logfmt });
},
.version => {
.version => text: {
const template = Help.version ++ "\n";
std.debug.print(template, .{exec_name});
break :text try std.fmt.allocPrint(allocator, template, .{exec_name});
},
}
};
defer allocator.free(text);
if (success) {
printPaged(allocator, text);
return std.process.cleanExit();
}
var stderr = std.fs.File.stderr().writer(&.{});
stderr.interface.writeAll(text) catch {};
std.process.exit(1);
}
fn printPlain(text: []const u8) void {
var stdout = std.fs.File.stdout().writer(&.{});
stdout.interface.writeAll(text) catch {};
}
/// Pages explicitly requested help through $PAGER (fallback: less) when
/// stdout is an interactive terminal; prints plainly otherwise.
fn printPaged(allocator: Allocator, text: []const u8) void {
if (!std.posix.isatty(std.posix.STDOUT_FILENO)) {
return printPlain(text);
}
const term = std.posix.getenv("TERM") orelse "";
if (term.len == 0 or std.mem.eql(u8, term, "dumb")) {
return printPlain(text);
}
const pager = std.posix.getenv("PAGER") orelse "";
const argv: []const []const u8 = if (pager.len > 0)
&.{ "/bin/sh", "-c", pager }
else
&.{ "less", "-FIRX" };
var child = std.process.Child.init(argv, allocator);
child.stdin_behavior = .Pipe;
child.stdout_behavior = .Inherit;
child.stderr_behavior = .Inherit;
child.spawn() catch return printPlain(text);
if (child.stdin) |stdin| {
var writer = stdin.writer(&.{});
// A write error here is the pager exiting early (user quit, or the
// command failed) — wait() below decides which.
writer.interface.writeAll(text) catch {};
stdin.close();
child.stdin = null;
}
const term_result = child.wait() catch return printPlain(text);
const clean_exit = term_result == .Exited and term_result.Exited == 0;
// Quitting the pager early is still exit 0; a non-zero exit means the
// pager failed (e.g. $PAGER not found) and the help was never shown.
if (!clean_exit) {
printPlain(text);
}
}
pub fn parseArgs(allocator: Allocator) !Config {
const exec_name, var command = try Commands.parse(allocator);
if (command == .serve and command.serve.timeout != null) {

299
src/Metrics.zig Normal file
View File

@@ -0,0 +1,299 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const Metrics = @This();
cdp_connections: Counter = .{},
cdp_connection_limit: Counter = .{},
cdp_active_connections: Gauge = .{},
cdp_commands: Counter = .{},
cdp_unknown_commands: Counter = .{},
js_heap_limits: Counter = .{},
script_errors: Counter = .{},
arena_hit: CounterEnum("size", @import("ArenaPool.zig").BucketSize) = .{},
arena_miss: CounterEnum("size", @import("ArenaPool.zig").BucketSize) = .{},
navigate: CounterEnum("type", @import("telemetry/telemetry.zig").Event.Navigate.Context) = .{},
js_heap_size_bytes: Histogram(&.{
4 * 1024 * 1024,
8 * 1024 * 1024,
16 * 1024 * 1024,
32 * 1024 * 1024,
64 * 1024 * 1024,
128 * 1024 * 1024,
256 * 1024 * 1024,
512 * 1024 * 1024,
}) = .{},
http_requests: CounterEnum("mode", enum { sync, async }) = .{},
http_status: CounterEnum("category", @import("network/http.zig").StatusCategory) = .{},
http_error: CounterEnum("reason", @import("network/http.zig").ErrorReason) = .{},
http_cache: CounterEnum("result", enum { hit, miss, revalidated }) = .{},
http_redirects: Counter = .{},
http_duration_ms: Histogram(&.{
5,
10,
25,
50,
100,
250,
500,
1000,
2500,
5000,
10000,
}) = .{},
http_response_size_bytes: Histogram(&.{
32 * 1024,
64 * 1024,
128 * 1024,
256 * 1024,
512 * 1024,
1024 * 1024,
2 * 1024 * 1024,
4 * 1024 * 1024,
}) = .{},
robots_status: CounterEnum("category", @import("network/http.zig").StatusCategory) = .{},
robots_access: CounterEnum("result", enum { allow, deny }) = .{},
// Emitted as each metric's "# HELP" line. A field without an entry is a
// compile error.
const help = .{
.cdp_connections = "CDP websocket connections accepted",
.cdp_connection_limit = "Connections rejected because --cdp-max-connections was reached",
.cdp_active_connections = "Currently connected CDP clients",
.cdp_commands = "CDP commands dispatched",
.cdp_unknown_commands = "CDP commands rejected for an unknown domain or method",
.js_heap_limits = "Pages terminated for reaching the V8 heap limit",
.script_errors = "Scripts that failed to evaluate, e.g. an uncaught top-level exception",
.arena_hit = "Arena pool acquisitions served from the free list",
.arena_miss = "Arena pool acquisitions that had to allocate a new arena",
.navigate = "Navigations by initiating frame type",
.js_heap_size_bytes = "V8 heap physical size, sampled when a page is closed",
.http_requests = "HTTP requests submitted, by dispatch mode (excludes internal requests like robots.txt)",
.http_status = "Final HTTP response status category (redirects counted once, at the final hop)",
.http_error = "HTTP requests that failed before delivering a response, by cause",
.http_cache = "HTTP cache lookups by outcome",
.http_redirects = "HTTP redirect hops followed",
.http_duration_ms = "HTTP request wall-clock duration in milliseconds",
.http_response_size_bytes = "HTTP response body size in bytes",
.robots_status = "robots.txt response status",
.robots_access = "robots.txt result",
};
pub fn write(self: *const Metrics, writer: *std.Io.Writer) void {
self._write(writer) catch |err| {
lp.log.err(.app, "metrics write", .{ .err = err });
};
}
fn _write(self: *const Metrics, writer: *std.Io.Writer) !void {
try writer.print(
"# HELP build_info Lightpanda build information\n" ++
"# TYPE build_info gauge\nbuild_info{{version=\"{s}\"}} 1\n",
.{lp.build_config.version},
);
inline for (@typeInfo(Metrics).@"struct".fields) |f| {
try @field(self, f.name).write(f.name, @field(help, f.name), writer);
}
}
const Counter = struct {
count: usize = 0,
pub fn incr(self: *Counter) void {
self.incrBy(1);
}
pub fn incrBy(self: *Counter, c: usize) void {
_ = @atomicRmw(usize, &self.count, .Add, c, .monotonic);
}
fn write(self: *const Counter, comptime name: []const u8, comptime help_text: []const u8, writer: *std.Io.Writer) !void {
try writer.writeAll("# HELP " ++ name ++ "_total " ++ help_text ++ "\n" ++ "# TYPE " ++ name ++ "_total counter\n");
try writer.print(name ++ "_total {d}\n", .{self.get()});
}
fn get(self: *const Counter) usize {
return @atomicLoad(usize, &self.count, .monotonic);
}
};
const Gauge = struct {
value: isize = 0,
pub fn incr(self: *Gauge) void {
_ = @atomicRmw(isize, &self.value, .Add, 1, .monotonic);
}
pub fn decr(self: *Gauge) void {
_ = @atomicRmw(isize, &self.value, .Sub, 1, .monotonic);
}
fn write(self: *const Gauge, comptime name: []const u8, comptime help_text: []const u8, writer: *std.Io.Writer) !void {
try writer.writeAll("# HELP " ++ name ++ " " ++ help_text ++ "\n" ++ "# TYPE " ++ name ++ " gauge\n");
try writer.print(name ++ " {d}\n", .{@atomicLoad(isize, &self.value, .monotonic)});
}
};
fn CounterEnum(comptime label: []const u8, comptime T: type) type {
return struct {
counts: std.enums.EnumArray(T, Counter) = .initFill(.{}),
pub const Tag = T;
pub const label_name = label;
const Self = @This();
pub fn incr(self: *Self, tag: T) void {
self.incrBy(tag, 1);
}
pub fn incrBy(self: *Self, tag: T, c: usize) void {
self.counts.getPtr(tag).incrBy(c);
}
fn write(self: *const Self, comptime name: []const u8, comptime help_text: []const u8, writer: *std.Io.Writer) !void {
try writer.writeAll("# HELP " ++ name ++ "_total " ++ help_text ++ "\n" ++ "# TYPE " ++ name ++ "_total counter\n");
inline for (comptime std.enums.values(Tag)) |tag| {
try writer.print(name ++ "_total{{" ++ label ++ "=\"" ++ @tagName(tag) ++ "\"}} {d}\n", .{self.counts.getPtrConst(tag).get()});
}
}
};
}
fn Histogram(comptime upper_bounds: []const usize) type {
comptime {
std.debug.assert(upper_bounds.len > 0);
for (upper_bounds[0 .. upper_bounds.len - 1], upper_bounds[1..]) |a, b| {
std.debug.assert(a < b);
}
}
return struct {
sum: usize = 0,
count: usize = 0,
buckets: [upper_bounds.len]usize = @splat(0),
const Self = @This();
pub fn observe(self: *Self, value: usize) void {
_ = @atomicRmw(usize, &self.count, .Add, 1, .monotonic);
_ = @atomicRmw(usize, &self.sum, .Add, value, .monotonic);
inline for (upper_bounds, 0..) |upper, i| {
if (value <= upper) {
_ = @atomicRmw(usize, &self.buckets[i], .Add, 1, .monotonic);
return;
}
}
// falls in the implicit +Inf bucket, which is derived from count
}
fn write(self: *const Self, comptime name: []const u8, comptime help_text: []const u8, writer: *std.Io.Writer) !void {
try writer.writeAll("# HELP " ++ name ++ " " ++ help_text ++ "\n" ++ "# TYPE " ++ name ++ " histogram\n");
// le buckets are cumulative
var sum: usize = 0;
inline for (upper_bounds, 0..) |upper, i| {
sum += @atomicLoad(usize, &self.buckets[i], .monotonic);
try writer.print(name ++ "_bucket{{le=\"" ++ std.fmt.comptimePrint("{d}", .{upper}) ++ "\"}} {d}\n", .{sum});
}
const count = @atomicLoad(usize, &self.count, .monotonic);
try writer.print(
name ++ "_bucket{{le=\"+Inf\"}} {d}\n" ++ name ++ "_sum {d}\n" ++ name ++ "_count {d}\n",
.{ count, @atomicLoad(usize, &self.sum, .monotonic), count },
);
}
};
}
const testing = @import("testing.zig");
test "Metrics: Counter" {
var w = std.Io.Writer.Allocating.init(testing.allocator);
defer w.deinit();
var c = Counter{};
c.incr();
c.incrBy(3);
try c.write("stat", "counts stats", &w.writer);
try testing.expectEqual(
\\# HELP stat_total counts stats
\\# TYPE stat_total counter
\\stat_total 4
\\
, w.written());
}
test "Metrics: Gauge" {
var w = std.Io.Writer.Allocating.init(testing.allocator);
defer w.deinit();
var g = Gauge{};
g.incr();
g.incr();
g.decr();
try g.write("stat", "counts stats", &w.writer);
try testing.expectEqual(
\\# HELP stat counts stats
\\# TYPE stat gauge
\\stat 1
\\
, w.written());
}
test "Metrics: CounterEnum" {
var w = std.Io.Writer.Allocating.init(testing.allocator);
defer w.deinit();
var c = CounterEnum("color", enum { red, blue }){};
c.incr(.blue);
c.incrBy(.blue, 2);
try c.write("stat", "counts stats", &w.writer);
try testing.expectEqual(
\\# HELP stat_total counts stats
\\# TYPE stat_total counter
\\stat_total{color="red"} 0
\\stat_total{color="blue"} 3
\\
, w.written());
}
test "Metrics: Histogram" {
var w = std.Io.Writer.Allocating.init(testing.allocator);
defer w.deinit();
var h = Histogram(&.{ 10, 100 }){};
h.observe(5);
h.observe(10); // le is inclusive
h.observe(50);
h.observe(200); // +Inf only
try h.write("stat", "counts stats", &w.writer);
try testing.expectEqual(
\\# HELP stat counts stats
\\# TYPE stat histogram
\\stat_bucket{le="10"} 2
\\stat_bucket{le="100"} 3
\\stat_bucket{le="+Inf"} 4
\\stat_sum 265
\\stat_count 4
\\
, w.written());
}

View File

@@ -21,8 +21,7 @@ const lp = @import("lightpanda");
const js = @import("browser/js/js.zig");
const Frame = @import("browser/Frame.zig");
const Transfer = @import("browser/HttpClient.zig").Transfer;
const Response = @import("browser/HttpClient.zig").Response;
const Transfer = @import("network/HttpClient.zig").Transfer;
const log = lp.log;
const Execution = js.Execution;
@@ -76,6 +75,7 @@ const EventListeners = struct {
frame_created: List = .{},
frame_navigate: List = .{},
frame_navigated: List = .{},
frame_navigated_within_document: List = .{},
frame_navigate_failed: List = .{},
frame_network_idle: List = .{},
frame_network_almost_idle: List = .{},
@@ -105,6 +105,7 @@ const Events = union(enum) {
frame_created: *Frame,
frame_navigate: *const FrameNavigate,
frame_navigated: *const FrameNavigated,
frame_navigated_within_document: *const FrameNavigatedWithinDocument,
frame_navigate_failed: *const FrameNavigateFailed,
frame_network_idle: *const FrameNetworkIdle,
frame_network_almost_idle: *const FrameNetworkAlmostIdle,
@@ -155,6 +156,23 @@ pub const FrameNavigated = struct {
opts: Frame.NavigatedOpts,
};
// A same-document navigation (History pushState/replaceState, fragment
// changes, or history traversal). The document and its execution context
// stay alive — only the URL changes — so CDP must emit
// Page.navigatedWithinDocument WITHOUT clearing the execution context or
// sending DOM.documentUpdated.
pub const FrameNavigatedWithinDocument = struct {
frame_id: u32,
url: [:0]const u8,
navigation_type: NavigationType = .other,
pub const NavigationType = enum {
fragment,
historyApi,
other,
};
};
// A root navigation that failed before commit (DNS failure, connection
// refused, malformed response, ...). Dispatched so CDP can answer the
// pending Page.navigate command with an errorText instead of leaving the
@@ -224,7 +242,6 @@ pub const ResponseData = struct {
pub const ResponseHeaderDone = struct {
transfer: *Transfer,
response: *const Response,
};
pub const RequestDone = struct {

View File

@@ -139,7 +139,7 @@ fn walk(
if (html_el.getHidden()) return;
}
} else if (node.is(CData.Text)) |text_node| {
const text = text_node.getWholeText();
const text = text_node.ownData();
if (isAllWhitespace(text)) {
return;
}
@@ -414,7 +414,7 @@ const JsonVisitor = struct {
try self.jw.objectField("nodeType");
try self.jw.write(3);
try self.jw.objectField("nodeValue");
try self.jw.write(text_node.getWholeText());
try self.jw.write(text_node.ownData());
} else {
try self.jw.objectField("nodeType");
try self.jw.write(9);
@@ -472,7 +472,7 @@ const TextVisitor = struct {
name_to_print = n;
}
} else if (node.is(CData.Text)) |text_node| {
const trimmed = std.mem.trim(u8, text_node.getWholeText(), " \t\r\n");
const trimmed = std.mem.trim(u8, text_node.ownData(), " \t\r\n");
if (trimmed.len > 0) {
name_to_print = trimmed;
}

View File

@@ -156,6 +156,7 @@ fn spawnWorker(self: *Server, socket: posix.socket_t) !void {
while (current < self.max_connections) {
current = self.active_threads.cmpxchgWeak(current, current + 1, .monotonic, .monotonic) orelse break;
} else {
lp.metrics.cdp_connection_limit.incr();
return error.MaxThreadsReached;
}
errdefer _ = self.active_threads.fetchSub(1, .monotonic);
@@ -218,6 +219,12 @@ fn handleConnection(self: *Server, socket: posix.socket_t) void {
return;
}
// only count websocket (i.e. CDP) connections, not HTTP requests like
// /json/version probes or /metrics scrapes
lp.metrics.cdp_connections.incr();
lp.metrics.cdp_active_connections.incr();
defer lp.metrics.cdp_active_connections.decr();
{
// Transition from .handshake state to .live
// Lock needed even though the main thread hasn't seen this yet because
@@ -276,6 +283,7 @@ fn buildJSONVersionResponse(app: *const App, port: u16) ![]const u8 {
"\"Browser\": \"Lightpanda/1.0\", " ++
"\"Protocol-Version\": \"1.3\", " ++
"\"User-Agent\": \"Lightpanda/1.0\", " ++
"\"Lightpanda-Version\": \"" ++ lp.build_config.version ++ "\", " ++
"\"webSocketDebuggerUrl\": \"ws://{s}:{d}/\"" ++
"}}";
const body_len = std.fmt.count(body_format, .{ host, port });
@@ -309,6 +317,7 @@ test "server: buildJSONVersionResponse" {
try testing.expect(std.mem.indexOf(u8, res, "\"Browser\": \"Lightpanda/") != null);
try testing.expect(std.mem.indexOf(u8, res, "\"Protocol-Version\": \"1.3\"") != null);
try testing.expect(std.mem.indexOf(u8, res, "\"User-Agent\": \"Lightpanda/") != null);
try testing.expect(std.mem.indexOf(u8, res, "\"Lightpanda-Version\": \"" ++ lp.build_config.version ++ "\"") != null);
try testing.expect(std.mem.indexOf(u8, res, "\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9222/\"") != null);
}
@@ -551,6 +560,18 @@ test "server: get /json/version" {
}
}
test "server: get /metrics" {
var c = try createTestClient();
defer c.deinit();
const res = try c.httpRequest("GET /metrics HTTP/1.1\r\n\r\n");
std.debug.print("1\n", .{});
try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 200 OK\r\n"));
try testing.expect(std.mem.indexOf(u8, res, "Content-Type: text/plain; version=0.0.4") != null);
try testing.expect(std.mem.indexOf(u8, res, "build_info{version=") != null);
try testing.expect(std.mem.indexOf(u8, res, "# TYPE cdp_connections_total counter") != null);
}
fn assertHTTPError(
comptime expected_status: u16,
comptime expected_body: []const u8,
@@ -648,7 +669,7 @@ fn createTestClient() !TestClient {
const TestClient = struct {
stream: std.net.Stream,
buf: [1024]u8 = undefined,
buf: [8192]u8 = undefined,
reader: WS.Reader(false),
const WS = @import("network/WS.zig");
@@ -664,10 +685,11 @@ const TestClient = struct {
var pos: usize = 0;
var total_length: ?usize = null;
while (true) {
pos += try self.stream.read(self.buf[pos..]);
if (pos == 0) {
return error.NoMoreData;
const n = try self.stream.read(self.buf[pos..]);
if (n == 0) {
return if (pos == self.buf.len) error.MessageTooLarge else error.NoMoreData;
}
pos += n;
const response = self.buf[0..pos];
if (total_length == null) {
const header_end = std.mem.indexOf(u8, response, "\r\n\r\n") orelse continue;

View File

@@ -35,8 +35,10 @@ const App = @import("../App.zig");
const CDPNode = @import("../cdp/Node.zig");
const Conversation = @import("Conversation.zig");
const Terminal = @import("Terminal.zig");
const ansi = @import("ansi.zig");
const SlashCommand = @import("SlashCommand.zig");
const settings = @import("settings.zig");
const picker = @import("picker.zig");
const save = @import("save.zig");
const welcome = @import("welcome.zig");
const string = @import("../string.zig");
@@ -75,135 +77,15 @@ const default_system_prompt = browser_tools.driver_guidance ++
\\- If the user asks for account-scoped data (karma, profile, inbox, …)
\\ and the page shows you're not signed in, log in proactively (per
\\ the Credentials section above) before reporting unavailable.
;
\\
++ lp.skill.semantics_note;
/// Skill-like documentation for writing Lightpanda agent script
/// Used in the system prompt of the `/save` command.
const script_skill =
\\# Writing Lightpanda agent scripts
\\
\\Run with:
\\
\\```console
\\./lightpanda agent script.js
\\```
\\
\\## Mental model (get this right first)
\\
\\The script runs in its **own V8 context** — neither the page nor Node.js:
\\
\\- `Page` is the only global. `new Page()` makes a page and `await page.goto(url)` navigates it; every other primitive is a **method on that page**: `const page = new Page(); await page.goto(url); page.extract({...}); page.click(sel);`.
\\- No `window`, `document`, DOM, `localStorage` — read pages with `page.extract(...)`, run page-side JS only via `page.evaluate("...")`.
\\- No `require`, `process`, `fs`, npm. Standard ECMAScript built-ins only (`JSON`, `Map`, template literals, …).
\\- `page.goto(...)` is **async — always `await` it**. Page methods are **synchronous**: `const data = page.extract({...})`, never `await page.extract(...)`. The script body runs as an async function, so top-level `await` is allowed.
\\- **Re-navigating reuses the same page**: `await page.goto(url2)` keeps `page` valid and points it at the new URL, discarding the old page — read it before navigating away. Independent URLs don't share a page: make a `new Page()` for each and load them in parallel (fan-out, best practice 2).
\\- Page `evaluate("...")` cannot see script variables — interpolate values into the string. Script code cannot see page variables.
\\- Variables persist across navigations within one run, so cross-page aggregation is plain JS.
\\- **`return <value>` is the script's output**, printed automatically (objects/arrays as JSON). End with `return page.extract({...});` or `return results;`. A bare trailing expression is NOT printed; neither is `console.log(JSON.stringify(...))`.
\\
\\## Primitives
\\
\\`Page` is the only global; `new Page()` makes a page and everything else is a method on it.
\\
\\| Call | Notes |
\\|------|-------|
\\| `new Page()` | Makes a page object. No navigation yet — call `page.goto(url)` before any other method. Make several to navigate in parallel (fan-out, best practice 2). |
\\| `await page.goto(url[, { timeout }])` | **Async — must be `await`ed.** Navigates the page (re-navigating reuses the same object). Waits for `load`. Default timeout 10000 ms. Rejects on navigation failure; a **timeout does NOT reject** (the page may still be usable). |
\\| `page.close()` | Marks the page done; later method calls on it error. The page is otherwise reclaimed at script end. |
\\| `page.extract(schema)` | The only primitive returning a real JS value (object/array). See schema below. |
\\| `page.evaluate(script[, { url, timeout, save }])` | Page-side JS escape hatch; returns text (JSON for objects/arrays). |
\\| `page.click(sel)` / `page.hover(sel)` | |
\\| `page.fill(sel, value)` / `page.selectOption(sel, value)` | |
\\| `page.setChecked(sel[, checked])` | `checked` defaults to `true`. |
\\| `page.press(sel, key)` / `page.press(null, key)` / `page.press({ key })` | Selector first! `page.press("Enter")` binds "Enter" to `selector` and fails. |
\\| `page.scroll()` / `page.scroll({ x, y })` | |
\\| `page.waitForSelector(sel[, { timeout }])` | `waitFor*` default timeout 5000 ms. |
\\| `page.waitForScript(js[, { timeout }])` | Re-evaluates page JS until truthy. |
\\| `page.waitForState(state[, { timeout }])` | `"load"`, `"domcontentloaded"`, `"networkalmostidle"`, `"networkidle"`, `"done"`. |
\\
\\Calling convention: leading positionals + optional trailing options object, or one object with everything (`page.waitForSelector("#row", { timeout: 2000 })` ≡ `page.waitForSelector({ selector: "#row", timeout: 2000 })`). A bare option positional (`page.waitForSelector("#row", 2000)`) and a field passed both ways are `invalid arguments`. `null` skips a positional. Arguments must be JSON-serializable.
\\
\\CSS selectors only — `backendNodeId`s don't exist here. Standard CSS only: no jQuery `:contains()` or Playwright `:has-text()`.
\\
\\## extract schema
\\
\\Keys = output field names; values pick what to lift (not a JSON Schema):
\\
\\```js
\\const { stories } = page.extract({
\\ stories: [{
\\ selector: "tr.athing", // one record per match
\\ limit: 5,
\\ fields: { // resolved relative to each match
\\ title: ".titleline > a", // first match's text (null if missing)
\\ url: { selector: ".titleline > a", attr: "href" },
\\ text: "" // "" = the matched element's own text
\\ }
\\ }]
\\});
\\```
\\
\\- `"sel"` → first match's text; `["sel"]` → all matches' text; `{ selector, attr }` / `[{ selector, attr }]` → attribute(s); `limit: N` caps any array form.
\\- Every value is a string or null — parse numbers in script logic.
\\- Empty arrays are valid results; if **every** field misses, extract throws ("no schema selector matched any element") → your selectors are wrong, not the page empty.
\\- An object schema always returns an object (destructure it); a bare array schema returns the array directly.
\\- No `save:` option in scripts — keep results in variables.
\\
\\## Best practices
\\
\\1. **Navigate, settle, read.** After `await page.goto` on a dynamic page (feeds, search results, comment threads), call `page.waitForState("networkidle")` or `page.waitForSelector(...)` before extracting. Most static pages are complete at `load` — don't wait blindly.
\\2. **List-to-detail — fan out independent pages.** Extract the list, then open one page per item and start every navigation together so the detail pages load in parallel instead of one-after-another:
\\ ```js
\\ const list = new Page();
\\ await list.goto(listUrl);
\\ const { items } = list.extract({ items: [{ selector: "a.row", fields: { url: { attr: "href" } } }] });
\\
\\ const pages = items.map(() => new Page());
\\ await Promise.all(pages.map((p, i) => p.goto(items[i].url))); // all in flight at once
\\ return pages.map((p, i) => ({ ...items[i], ...p.extract({ /* schema */ }) }));
\\ ```
\\ - Concurrency is bounded by the HTTP connection pool (40 by default, `--http-max-concurrent`); extra navigations queue rather than fail. For long lists, fan out in batches and `page.close()` each page once read so its memory is reclaimed.
\\ - `Promise.all` rejects the whole batch if any `goto` *fails* (a timeout does not reject); use `Promise.allSettled` when partial results are fine.
\\ - Walk **serially** on one page (`for (const it of items) { await page.goto(it.url); … }`) only when the steps depend on each other — each page decides the next URL, or they share login/session state.
\\3. **`evaluate` is a last resort, not a reading tool.** A `querySelectorAll`-and-parse `page.evaluate` block is always wrong: lift the raw strings with `page.extract`, then trim/split/parse them in top-level JS. Reserve `page.evaluate` for behavior that must run inside the page and no builtin covers — and remember its state dies on every navigation/reload, while script variables persist.
\\4. **Credentials via `$LP_*` placeholders** in any string argument (`page.fill("#pw", "$LP_HN_PASSWORD")`). Never inline a real secret; placeholders resolve inside the Lightpanda process.
\\5. **Unique selectors.** Disambiguate with attributes/position: `input[type="submit"][value="login"]`, not `input[type="submit"]`.
\\6. **Let failures fail.** Primitives throw on error and stop the script — only `try/catch` where you have a real fallback (e.g. optional cookie banner: `try { page.click("#accept") } catch {}`).
\\7. **End with `return <result>`.** `console.log` is for debug output only and doesn't JSON-format objects.
\\8. Modern, readable JS: `const`/`let`, `for (const x of xs)`, template literals, destructuring, 2-space indent.
\\9. **Comment the intent of each block.** Put a one-line `//` comment above each logical step describing what it accomplishes toward the goal (not restating the call). One comment per block, not per line — skip self-evident lines:
\\ ```js
\\ // Load the Hacker News front page
\\ const page = new Page();
\\ await page.goto("https://news.ycombinator.com");
\\
\\ // Pull the top 5 stories (title + link)
\\ const { stories } = page.extract({ stories: [{ selector: "tr.athing", limit: 5, fields: { title: ".titleline > a", url: { selector: ".titleline > a", attr: "href" } } }] });
\\
\\ // Open each story page in parallel and read its text
\\ const pages = stories.map(() => new Page());
\\ await Promise.all(pages.map((p, i) => p.goto(stories[i].url)));
\\ ```
\\
\\## Common errors
\\
\\| Error | Cause / fix |
\\|-------|-------------|
\\| `extract is not defined` (or click/fill/…) | These are methods on the page object, not globals → `const page = new Page(); await page.goto(url); page.extract(...)` |
\\| `Page must be called with new` | `Page(...)` called without `new` → `const page = new Page();` |
\\| `page is not navigated or has been closed` | A method on a fresh `new Page()` (or a closed page) → `await page.goto(url)` first |
\\| `page handle is no longer valid` | Used a page after a later `goto` on the **same** page replaced it → read it before navigating away. Sibling pages from other `new Page()` calls stay valid. |
\\| `document is not defined` | DOM API in script context → use `page.extract` or `page.evaluate` |
\\| `require is not defined` | Not Node.js |
\\| `no page loaded - run page.goto(url) first` | Page method before navigation |
\\| `invalid arguments` | Wrong arity/shape, non-JSON value, or a field set both positionally and in options |
\\| `extract: no schema selector matched any element` | All schema fields missed → fix selectors |
\\| `press` fails with one string arg | Selector-first: use `page.press(null, "Enter")` or `page.press({ key: "Enter" })` |
;
// Sytem prompt of the `/save` command
// With the save instructions and the skill-like agent script documentation.
const save_system_prompt = browser_tools.save_synthesis_prompt ++ "\n\n" ++ script_skill;
// System prompt of the `/save` command: the save instructions plus the
// script skill (`lp.skill`), whose primitives reference is rendered from
// the tool schemas at first use — hence lazy rather than comptime.
var save_prompts_once = std.once(initSavePrompts);
var save_system_prompt: []const u8 = undefined;
var save_revision_system_prompt: []const u8 = undefined;
// Swapped in instead of `save_system_prompt` when the user message carries a
// previously saved script: the "this session" framing above would otherwise
@@ -216,7 +98,23 @@ const save_revision_note =
\\a change, and add what this session contributes. Output the complete
\\updated script — never a fragment, diff, or continuation.
;
const save_revision_system_prompt = browser_tools.save_synthesis_prompt ++ "\n" ++ save_revision_note ++ "\n" ++ script_skill;
/// Panics on OOM like `Schema.all()` — the inputs are static, process-lifetime
/// strings, so failure is a build bug, not a runtime condition.
fn initSavePrompts() void {
const a = std.heap.page_allocator;
save_system_prompt = std.mem.concat(a, u8, &.{
browser_tools.save_synthesis_prompt, "\n\n", lp.skill.text(),
}) catch @panic("OOM building save prompt");
save_revision_system_prompt = std.mem.concat(a, u8, &.{
browser_tools.save_synthesis_prompt, "\n", save_revision_note, "\n", lp.skill.text(),
}) catch @panic("OOM building save prompt");
}
fn savePrompt(revision: bool) []const u8 {
save_prompts_once.call();
return if (revision) save_revision_system_prompt else save_system_prompt;
}
const synthesis_prompt =
\\You have used your tool budget or cannot finish the exploration.
@@ -276,6 +174,15 @@ synthetic_tool_call_id: u32 = 0,
total_usage: zenai.provider.Usage = .{},
/// Set when the last turn ended in a model refusal (safety stop).
last_turn_refused: bool = false,
/// Whether assistant text streams to the terminal as the model produces it.
/// Toggled via `/stream`; persisted in `.lp-agent.zon`.
stream_enabled: bool,
/// True while assistant text is streaming to stdout (spinner paused). Cleared
/// by `endStreamedText`, which also emits the closing newline.
stream_active: bool = false,
/// True when any assistant text streamed during the current turn, so `runTurn`
/// skips the buffered `printAssistant` that would double-print it.
streamed_text: bool = false,
available_providers: []const []const u8,
/// Cached reachability of each `local_providers` entry, so the per-keystroke
/// `/provider` hinter probes each local server at most once.
@@ -388,10 +295,11 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent
const effort = settings.resolveEffort(opts, remembered, will_repl, if (resolved) |r| r.credentials.provider else null);
const verbosity = settings.resolveVerbosity(opts, remembered);
const stream_enabled = settings.resolveStream(remembered);
if (resolved) |r| {
if (r.source == .picked) {
settings.saveRemembered(.{ .provider = r.credentials.provider, .model = model, .effort = effort, .verbosity = verbosity }) catch {};
settings.saveRemembered(.{ .provider = r.credentials.provider, .model = model, .effort = effort, .verbosity = verbosity, .stream = stream_enabled }) catch {};
}
// provider/model now live in the status bar; just space before the help
std.debug.print("\n", .{});
@@ -427,6 +335,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent
.conversation = .init(allocator, opts.system_prompt orelse default_system_prompt),
.model = model,
.effort = effort,
.stream_enabled = stream_enabled,
.script_file = opts.script_file,
.one_shot_task = opts.task,
.one_shot_save = opts.save,
@@ -449,12 +358,11 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent
if (self.ai_client) |c| c.setInterrupt(&self.http_interrupt);
if (will_repl) {
self.terminal.attachCompleter();
self.terminal.completion_source = .{
self.terminal.attachCompleter(.{
.context = @ptrCast(self),
.providers = completionProviders,
.models = completionModels,
};
});
// The model-list cache fills lazily on the first `/model` completion,
// so startup never blocks on the network.
Terminal.setIdleCallback(&idlePump, @ptrCast(self));
@@ -564,12 +472,43 @@ fn drainCancellation(self: *Agent, baseline: usize) error{UserCancelled} {
/// The side effects of `drainCancellation` without surfacing the error, for
/// void callers (e.g. `/save` synthesis) that just need to clean up.
fn resetAfterCancel(self: *Agent, baseline: usize) void {
self.endStreamedText();
self.conversation.rollback(baseline);
self.browser.env.cancelTerminate();
self.cancel_requested.store(false, .release);
self.http_interrupt.reset();
}
/// On the first delta it pauses the spinner, whose stderr frames would
/// otherwise interleave with this stdout text.
fn streamAssistantDelta(ctx: *anyopaque, delta: []const u8) void {
const self: *Agent = @ptrCast(@alignCast(ctx));
if (delta.len == 0) return;
if (!self.stream_active) {
self.terminal.spinner.pause();
self.stream_active = true;
}
self.streamed_text = true;
self.terminal.printAssistantDelta(delta);
}
/// Close an in-progress streamed line with a newline. Idempotent, so every
/// `runTools` exit path can call it unconditionally.
fn endStreamedText(self: *Agent) void {
if (!self.stream_active) return;
self.terminal.endAssistantStream();
self.stream_active = false;
}
/// The text-delta hook, or null when `/stream off` — a null hook makes
/// `runTools` fall back to a buffered response. Streaming is an interactive
/// REPL affordance; one-shot (`--task`) and script modes keep stdout to the
/// buffered final answer so wrappers can parse it cleanly.
fn streamHook(self: *Agent) ?zenai.provider.Client.TextDeltaHook {
if (!self.stream_enabled or !self.terminal.isRepl()) return null;
return .{ .context = @ptrCast(self), .onText = streamAssistantDelta };
}
/// One agent turn: the prompt sent to the model, plus optional context — a
/// recorder comment to write before the turn, file attachments to bundle into
/// the first user message, and a display label used in error output.
@@ -639,9 +578,14 @@ fn runTurn(self: *Agent, input: TurnInput) bool {
},
};
if (!input.suppress_answer) {
if (text) |t|
self.terminal.printAssistant(t)
else if (self.last_turn_refused)
if (text) |t| {
// Streaming already emitted the text incrementally (plus a closing
// newline); only the buffered path needs to print it here. Safe as a
// turn-wide flag: the stream accumulator reconstructs `t` from the
// same deltas it emitted, and the synthesis fallback also streams, so
// `streamed_text` implies `t` was already shown in full.
if (!self.streamed_text) self.terminal.printAssistant(t);
} else if (self.last_turn_refused)
self.terminal.printInfo("(model declined to respond — safety refusal)", .{})
else
self.terminal.printInfo("(no response from model)", .{});
@@ -654,8 +598,7 @@ fn runRepl(self: *Agent) void {
log.debug(.app, "tools loaded", .{ .count = globalTools().len });
if (self.ai_client != null) {
const a = Terminal.ansi;
std.debug.print(" model: {s}{s} {s}effort: {s}{s}{s}\n", .{ a.dim, self.model, a.reset, a.dim, @tagName(self.effort), a.reset });
std.debug.print(" model: {s}{s} {s}effort: {s}{s} {s}stream: {s}{s}{s}\n", .{ ansi.dim, self.model, ansi.reset, ansi.dim, @tagName(self.effort), ansi.reset, ansi.dim, if (self.stream_enabled) "on" else "off", ansi.reset });
}
repl: while (true) {
@@ -770,6 +713,7 @@ fn handleMeta(self: *Agent, arena: std.mem.Allocator, meta: *const SlashCommand.
.help => self.printSlashHelp(arena, rest),
.verbosity => self.setEnumOption("verbosity", &self.terminal.verbosity, rest),
.effort => self.setEnumOption("effort", &self.effort, rest),
.stream => self.handleStream(rest),
.usage => self.handleUsage(),
.clear => self.handleClear(),
.reset => self.handleReset(),
@@ -798,6 +742,24 @@ fn setEnumOption(self: *Agent, comptime name: []const u8, target: anytype, rest:
self.reportSaved(name, @tagName(level));
}
/// `/stream`: bare prints the current state; `on`/`off` sets and persists it.
fn handleStream(self: *Agent, rest: []const u8) void {
if (rest.len == 0) {
self.terminal.printInfo("stream: {s}", .{if (self.stream_enabled) "on" else "off"});
return;
}
const on = if (std.ascii.eqlIgnoreCase(rest, "on") or std.ascii.eqlIgnoreCase(rest, "true"))
true
else if (std.ascii.eqlIgnoreCase(rest, "off") or std.ascii.eqlIgnoreCase(rest, "false"))
false
else {
self.terminal.printError("usage: /stream [on|off] (got {s})", .{rest});
return;
};
self.stream_enabled = on;
self.reportSaved("stream", if (on) "on" else "off");
}
/// Print cumulative session token usage, broken down so the cache's effect is
/// visible — the REPL otherwise never surfaces the `$usage` line `--task`
/// prints. Reads `total_usage` (accumulated per turn by `processUserMessage`);
@@ -908,7 +870,7 @@ fn reportSaved(self: *Agent, label: []const u8, value: []const u8) void {
self.terminal.printInfo("{s}: {s}", .{ label, value });
return;
}
if (settings.saveRemembered(.{ .provider = provider, .model = self.model, .effort = self.effort, .verbosity = self.terminal.verbosity })) {
if (settings.saveRemembered(.{ .provider = provider, .model = self.model, .effort = self.effort, .verbosity = self.terminal.verbosity, .stream = self.stream_enabled })) {
self.terminal.printInfo("{s}: {s} (saved to {s})", .{ label, value, settings.remembered_path });
} else |_| {
self.terminal.printInfo("{s}: {s}", .{ label, value });
@@ -1131,7 +1093,7 @@ fn promptSaveMode(self: *Agent, path: []const u8) ?save.Mode {
"append — add the recorded commands at the end",
"replace — overwrite with the recorded commands",
};
const idx = Terminal.promptNumberedChoice(header, labels, 0) catch {
const idx = picker.promptNumberedChoice(header, labels, 0) catch {
self.terminal.printInfo("Save cancelled.", .{});
return null;
};
@@ -1212,7 +1174,7 @@ fn synthesizeSaveTo(self: *Agent, arena: std.mem.Allocator, path: []const u8, mo
// regular turns keep the driver prompt. (`messages[0]` is the system
// message — rollback and prune never touch it.)
const plain_system = self.conversation.messages.items[0].content;
self.conversation.messages.items[0].content = if (previous_script != null) save_revision_system_prompt else save_system_prompt;
self.conversation.messages.items[0].content = savePrompt(previous_script != null);
defer self.conversation.messages.items[0].content = plain_system;
const ma = self.conversation.arena.allocator();
@@ -1366,6 +1328,10 @@ fn printSlashHelp(self: *Agent, arena: std.mem.Allocator, target: []const u8) vo
"/effort " ++ Config.tagHint(Config.Effort) ++ " — set per-turn reasoning effort (currently: {s}); saved to {s}. Bare /effort prints the level.",
.{ @tagName(self.effort), settings.remembered_path },
),
.stream => self.terminal.printInfo(
"/stream [on|off] — stream assistant text as it's generated (currently: {s}); saved to {s}. Bare /stream prints the state.",
.{ if (self.stream_enabled) "on" else "off", settings.remembered_path },
),
.usage => self.terminal.printInfo(
"/usage — show cumulative token usage and cache hit rate for this session",
.{},
@@ -1406,7 +1372,7 @@ fn printSlashHelp(self: *Agent, arena: std.mem.Allocator, target: []const u8) vo
}
}
const tool_schema = Schema.findByName(target) orelse {
if (Terminal.closestCommand(target)) |near| {
if (SlashCommand.closestCommand(target)) |near| {
self.terminal.printError("unknown command: {s}. Did you mean " ++ Terminal.highlightCmd("/help {s}") ++ "?", .{ target, near });
} else {
self.terminal.printError("unknown command: {s}", .{target});
@@ -1601,6 +1567,11 @@ fn formatApiError(self: *Agent, client: zenai.provider.Client, err: anyerror) []
fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 {
const ma = self.conversation.arena.allocator();
self.api_error_detail = null;
self.streamed_text = false;
// Defensive: if an exit path ever missed `endStreamedText`, a stale-true
// `stream_active` would skip the spinner pause on the next turn's first
// delta and let frames interleave with the text. Reset it at the source.
self.stream_active = false;
self.http_interrupt.reset();
try self.conversation.ensureSystemPrompt();
@@ -1646,8 +1617,12 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 {
// non-thinking models.
.effort = self.effort,
.cancel = .{ .context = @ptrCast(self), .checkFn = checkCancel },
// Suppressed turns (e.g. `--save` capture) must keep stdout clean;
// streaming would bypass the `suppress_answer` guard in `runTurn`.
.stream = if (input.suppress_answer) null else self.streamHook(),
},
) catch |err| {
self.endStreamedText();
self.terminal.spinner.cancel();
// Ctrl-C can land while runTools unwinds an HTTP error — surface
// UserCancelled, not ApiError, so the user sees the outcome they asked for.
@@ -1657,6 +1632,7 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 {
self.conversation.rollback(msg_baseline);
return error.ApiError;
};
self.endStreamedText();
self.terminal.spinner.stop();
defer result.deinit();
self.total_usage.add(result.usage);
@@ -1730,13 +1706,16 @@ fn processUserMessage(self: *Agent, input: TurnInput) !?[]const u8 {
// `.none` stays off to opt out on models that reject it.
.effort = if (self.effort == .none) .none else .low,
.cancel = .{ .context = @ptrCast(self), .checkFn = checkCancel },
.stream = if (input.suppress_answer) null else self.streamHook(),
},
) catch |err| {
self.endStreamedText();
if (self.cancel_requested.load(.acquire)) return self.drainCancellation(msg_baseline);
log.err(.app, "AI synthesis error", .{ .err = err });
self.conversation.rollback(synth_baseline);
break :blk null;
};
self.endStreamedText();
defer synth.deinit();
self.total_usage.add(synth.usage);
@@ -1819,6 +1798,8 @@ fn capToolOutput(allocator: std.mem.Allocator, output: []const u8) []const u8 {
fn handleToolCall(ctx: *anyopaque, allocator: std.mem.Allocator, tool_name: []const u8, arguments: ?std.json.Value) zenai.provider.Client.ToolHandler.Result {
const self: *Agent = @ptrCast(@alignCast(ctx));
// Close any assistant text streamed this turn before the tool spinner shows.
self.endStreamedText();
// The spinner doesn't render args, and `agentToolDone` skips the body line
// at low verbosity — don't pay for the stringify when nobody reads it.
const needs_args = self.terminal.spinner.isEnabled() or self.terminal.verbosity != .low;
@@ -1932,6 +1913,17 @@ fn completionModels(context: *anyopaque, _: std.mem.Allocator) []const []const u
test {
_ = save;
_ = settings;
_ = picker;
}
test "savePrompt: save instructions followed by the rendered script skill" {
const prompt = savePrompt(false);
try std.testing.expect(std.mem.startsWith(u8, prompt, browser_tools.save_synthesis_prompt));
try std.testing.expect(std.mem.endsWith(u8, prompt, lp.skill.text()));
const revision = savePrompt(true);
try std.testing.expect(std.mem.indexOf(u8, revision, save_revision_note) != null);
try std.testing.expect(std.mem.endsWith(u8, revision, lp.skill.text()));
}
test "capToolOutput: passes through when under cap" {

View File

@@ -17,7 +17,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//! REPL-only meta slash commands (`/help`, `/quit`, `/verbosity`, `/effort`,
//! `/usage`, `/model`, `/provider`). Not tool slash commands — handled by
//! `/stream`, `/usage`, `/model`, `/provider`). Not tool slash commands — handled by
//! `Agent.handleMeta`, never reaching the recorder. Tool slash-command schema
//! primitives live in `lp.Schema`; import that directly.
@@ -46,7 +46,7 @@ pub const MetaCommand = struct {
/// Dispatched by `Agent.handleMeta` via an exhaustive switch, so a new meta
/// command is a compile error until it's wired up there too.
const Tag = enum { help, quit, verbosity, effort, usage, clear, reset, save, load, model, provider };
const Tag = enum { help, quit, verbosity, effort, stream, usage, clear, reset, save, load, model, provider };
};
const tagNames = Config.tagNames;
@@ -57,6 +57,7 @@ pub const meta_commands = [_]MetaCommand{
.{ .tag = .quit, .name = "quit", .hint = "", .values = &.{}, .description = "Exit the REPL" },
.{ .tag = .verbosity, .name = "verbosity", .hint = tagHint(Config.AgentVerbosity), .values = tagNames(Config.AgentVerbosity), .description = "Set agent verbosity" },
.{ .tag = .effort, .name = "effort", .hint = tagHint(Config.Effort), .values = tagNames(Config.Effort), .description = "Set per-turn reasoning effort" },
.{ .tag = .stream, .name = "stream", .hint = "[on|off]", .values = &.{ "on", "off" }, .description = "Toggle streaming of assistant text" },
.{ .tag = .usage, .name = "usage", .hint = "", .values = &.{}, .description = "Show token usage and cache stats for this session" },
.{ .tag = .clear, .name = "clear", .hint = "", .values = &.{}, .description = "Clear conversation history and usage (keeps page/cookies)" },
.{ .tag = .reset, .name = "reset", .hint = "", .values = &.{}, .description = "Reset conversation and browser session (drops page/cookies)" },
@@ -81,3 +82,56 @@ pub fn findMeta(name: []const u8) ?*const MetaCommand {
}
return null;
}
const browser_tools = lp.tools;
const llm_values = std.enums.values(Command.LlmCommand);
/// Every slash-invocable name: browser tools, LLM triggers, meta commands.
pub const all_names: [browser_tools.names.len + meta_commands.len + llm_values.len][]const u8 = blk: {
var arr: [browser_tools.names.len + meta_commands.len + llm_values.len][]const u8 = undefined;
var idx: usize = 0;
for (browser_tools.names) |n| {
arr[idx] = n;
idx += 1;
}
for (llm_values) |lc| {
arr[idx] = @tagName(lc);
idx += 1;
}
for (meta_commands) |m| {
arr[idx] = m.name;
idx += 1;
}
break :blk arr;
};
/// Closest command name within two edits, or null — for "did you mean?" on typos.
pub fn closestCommand(name: []const u8) ?[]const u8 {
var best: ?[]const u8 = null;
var best_dist: usize = std.math.maxInt(usize);
for (all_names) |cand| {
const dist = editDistance(name, cand);
if (dist < best_dist) {
best_dist = dist;
best = cand;
}
}
return if (best_dist <= 2) best else null;
}
/// Case-insensitive Levenshtein distance. Returns `maxInt` for inputs longer
/// than the table (no slash command is that long).
fn editDistance(a: []const u8, b: []const u8) usize {
const max = 32;
if (a.len >= max or b.len >= max) return std.math.maxInt(usize);
var dp: [max][max]usize = undefined;
for (0..a.len + 1) |i| dp[i][0] = i;
for (0..b.len + 1) |j| dp[0][j] = j;
for (a, 1..) |ca, i| {
for (b, 1..) |cb, j| {
const cost: usize = if (std.ascii.toLower(ca) == std.ascii.toLower(cb)) 0 else 1;
dp[i][j] = @min(@min(dp[i - 1][j] + 1, dp[i][j - 1] + 1), dp[i - 1][j - 1] + cost);
}
}
return dp[a.len][b.len];
}

View File

@@ -19,8 +19,7 @@
const std = @import("std");
const lp = @import("lightpanda");
const log = lp.log;
const Terminal = @import("Terminal.zig");
const ansi = Terminal.ansi;
const ansi = @import("ansi.zig");
const truncateUtf8 = @import("../string.zig").truncateUtf8;
const Spinner = @This();
@@ -70,6 +69,11 @@ mu: std.Thread.Mutex = .{},
cv: std.Thread.Condition = .{},
state: State = .idle,
frame: u8 = 0,
/// True while a caller streams assistant text to stdout: the worker stops
/// rendering and the current frame is cleared, so the stderr spinner does not
/// interleave with the streamed line. Turn state (timer, tool count) is
/// preserved for the eventual `stop`.
paused: bool = false,
tool_calls: u32 = 0,
turn_started_ns: i128 = 0,
@@ -105,6 +109,7 @@ pub fn start(self: *Spinner) void {
self.mu.lock();
defer self.mu.unlock();
self.state = .thinking;
self.paused = false;
self.frame = 0;
self.tool_calls = 0;
self.turn_started_ns = std.time.nanoTimestamp();
@@ -143,6 +148,7 @@ pub fn stop(self: *Spinner) void {
_ = std.posix.write(std.posix.STDERR_FILENO, summary) catch {};
self.state = .idle;
self.paused = false;
self.last_render_len = 0;
}
@@ -155,9 +161,25 @@ pub fn cancel(self: *Spinner) void {
if (self.state == .idle) return;
_ = std.posix.write(std.posix.STDERR_FILENO, "\r" ++ clear_eol) catch {};
self.state = .idle;
self.paused = false;
self.last_render_len = 0;
}
/// Clear the current frame and stop rendering without ending the turn, so the
/// caller can stream text to stdout. The next explicit state change
/// (`setTool`/`stop`/`cancel`/`start`) clears `paused` and resumes the
/// animation. No-op when idle or already paused.
pub fn pause(self: *Spinner) void {
if (!self.isEnabled()) return;
self.mu.lock();
defer self.mu.unlock();
if (self.state == .idle or self.paused) return;
self.paused = true;
_ = std.posix.write(std.posix.STDERR_FILENO, "\r" ++ clear_eol) catch {};
self.last_render_len = 0;
self.cv.signal();
}
/// Switch the indicator to "running tool <name> <args>". Counts toward the
/// turn's tool-call total. Args are truncated to `max_args_bytes`. Called
/// without a preceding `start()` (state `.idle`), the label drops the `agent:`
@@ -166,6 +188,8 @@ pub fn setTool(self: *Spinner, name: []const u8, args: []const u8) void {
if (!self.isEnabled()) return;
self.mu.lock();
defer self.mu.unlock();
// A tool call ends any in-progress text stream; resume normal rendering.
self.paused = false;
const manual = self.state == .idle;
self.tool_calls += 1;
var tool: ToolState = .{ .set_ns = std.time.nanoTimestamp(), .manual = manual };
@@ -224,7 +248,7 @@ fn workerLoop(self: *Spinner) void {
self.mu.lock();
defer self.mu.unlock();
while (!self.should_exit) {
while (!self.should_exit and self.state == .idle) self.cv.wait(&self.mu);
while (!self.should_exit and (self.state == .idle or self.paused)) self.cv.wait(&self.mu);
if (self.should_exit) return;
switch (self.state) {
@@ -266,7 +290,7 @@ fn renderLocked(self: *Spinner) void {
// (glyph, two spaces, `[`, `]`) around prefix+name+args. `\r` and
// ANSI escapes are zero-width, so they don't count toward wrap.
const decoration_cells: usize = 5 + prefix.len + name.len;
const cols: usize = Terminal.columns() orelse 80;
const cols: usize = columns() orelse 80;
// Reserve one extra cell so the line is strictly less than `cols`:
// auto-wrap (DECAWM) terminals advance past a row that exactly fills
// the width.
@@ -288,6 +312,21 @@ fn renderLocked(self: *Spinner) void {
_ = std.posix.write(std.posix.STDERR_FILENO, written) catch {};
}
/// Current terminal width in columns, queried via TIOCGWINSZ on stderr.
/// Null when stderr isn't a tty, the ioctl fails, or the kernel reports 0
/// (some pseudo-ttys leave the field unset). Cheap enough to call per render
/// frame; picks up resizes without SIGWINCH plumbing.
fn columns() ?u16 {
var ws: std.posix.winsize = undefined;
// bitcast via c_uint: on archs where `_IOR` sets the direction bit
// (MIPS/PPC/SPARC), `IOCGWINSZ` exceeds i32 range, so a plain @intCast
// panics; the bitcast preserves the bit pattern.
const req: c_int = @bitCast(@as(c_uint, std.posix.T.IOCGWINSZ));
const rc = std.c.ioctl(std.posix.STDERR_FILENO, req, &ws);
if (rc != 0 or ws.col == 0) return null;
return ws.col;
}
/// Returns the byte length of `bytes` that fits in `max_cells` cells, rounded
/// down to a whole UTF-8 codepoint. Multi-cell glyphs (CJK, wide emoji) count
/// as 1 — args are typically ASCII, so the approximation is good enough.

View File

File diff suppressed because it is too large Load Diff

39
src/agent/ansi.zig Normal file
View File

@@ -0,0 +1,39 @@
// 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/>.
pub const reset = "\x1b[0m";
pub const bold = "\x1b[1m";
pub const dim = "\x1b[2m";
pub const italic = "\x1b[3m";
pub const underline = "\x1b[4m";
pub const strike = "\x1b[9m";
// Color names follow isocline's (bbcode_colors.c): teal is the dark cyan
// pair of bright cyan, like maroon/red or olive/yellow.
pub const teal = "\x1b[36m";
pub const cyan = "\x1b[96m";
pub const green = "\x1b[32m";
pub const yellow = "\x1b[33m";
pub const red = "\x1b[31m";
pub const blue = "\x1b[34m";
pub const magenta = "\x1b[35m";
pub const clear_eol = "\x1b[K";
pub const clear_line = "\x1b[2K";
// Kitty keyboard protocol; each push must pair with a pop.
pub const kitty_disambiguate = "\x1b[>1u";
pub const kitty_legacy = "\x1b[>0u";
pub const kitty_pop = "\x1b[<u";

269
src/agent/js_highlight.zig Normal file
View File

@@ -0,0 +1,269 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const string = @import("../string.zig");
pub const Kind = enum { comment, string, variable, interpolation, number, keyword, global, function, method, type_name };
/// Carried between calls because fenced code is tokenized one line at a time:
/// block comments and template literals outlive a line boundary.
pub const State = enum { normal, block_comment, template };
pub const StringSpan = struct { end: usize, closed: bool };
/// Scan the quoted run opening at `text[start]`. Escapes are not honored —
/// good enough for coloring, not parsing.
pub fn scanString(text: []const u8, start: usize) StringSpan {
if (start >= text.len) return .{ .end = start, .closed = false };
const close = std.mem.indexOfScalarPos(u8, text, start + 1, text[start]) orelse
return .{ .end = text.len, .closed = false };
return .{ .end = close + 1, .closed = true };
}
/// Index just past the `$name` ref opening at `text[start]` (scanning within
/// `text[..end]`), or `start + 1` when the `$` is bare. A ref is a `$` followed
/// by at least one `[A-Za-z0-9_]`.
fn dollarRefEnd(text: []const u8, start: usize, end: usize) usize {
var i = start + 1;
while (i < end and (std.ascii.isAlphanumeric(text[i]) or text[i] == '_')) i += 1;
return i;
}
pub const DollarRef = struct { start: usize, end: usize, kind: Kind };
/// Next `$name` (`.variable`) ref at or after `from` within `text[..end]`,
/// or null; bare `$`s are skipped. `interpolation` additionally recognizes
/// `${…}` (`.interpolation`, unclosed runs to `end`) — only template
/// literals interpolate, so callers pass their quote kind.
pub fn nextDollarRef(text: []const u8, from: usize, end: usize, interpolation: bool) ?DollarRef {
var i = from;
while (i < end) {
if (text[i] != '$') {
i += 1;
continue;
}
if (interpolation and i + 1 < end and text[i + 1] == '{') {
const close = std.mem.indexOfScalarPos(u8, text[0..end], i + 2, '}');
return .{ .start = i, .end = if (close) |c| c + 1 else end, .kind = .interpolation };
}
const ref_end = dollarRefEnd(text, i, end);
if (ref_end > i + 1) return .{ .start = i, .end = ref_end, .kind = .variable };
i += 1;
}
return null;
}
/// Tokenize `text` as JavaScript starting from `state`, reporting spans to
/// `sink.emit(start, len, kind)` and returning the state at end of input.
/// Spans arrive in order and never overlap — the gaps between them are plain
/// text — so an emitter that writes sequentially (ANSI) works as well as one
/// that paints cells (isocline).
pub fn tokenize(text: []const u8, state: State, sink: anytype) State {
var i: usize = 0;
switch (state) {
.block_comment => {
const close = std.mem.indexOfPos(u8, text, 0, "*/");
i = if (close) |p| p + 2 else text.len;
if (i > 0) sink.emit(0, i, .comment);
if (close == null) return .block_comment;
},
.template => {
const close = std.mem.indexOfScalarPos(u8, text, 0, '`');
i = if (close) |p| p + 1 else text.len;
emitString(text, 0, i, true, sink);
if (close == null) return .template;
},
.normal => {},
}
while (i < text.len) {
const ch = text[i];
if (ch == '/' and i + 1 < text.len and (text[i + 1] == '/' or text[i + 1] == '*')) {
const start = i;
if (text[i + 1] == '/') {
i = std.mem.indexOfScalarPos(u8, text, i + 2, '\n') orelse text.len;
sink.emit(start, i - start, .comment);
continue;
}
const close = std.mem.indexOfPos(u8, text, i + 2, "*/");
i = if (close) |p| p + 2 else text.len;
sink.emit(start, i - start, .comment);
if (close == null) return .block_comment;
continue;
}
if (ch == '\'' or ch == '"' or ch == '`') {
const span = scanString(text, i);
emitString(text, i, span.end, ch == '`', sink);
i = span.end;
// Only a template literal may legally continue on the next line.
if (!span.closed and ch == '`') return .template;
continue;
}
if (ch == '$') {
const start = i;
i = dollarRefEnd(text, i, text.len);
if (i > start + 1) sink.emit(start, i - start, .variable);
continue;
}
if (std.ascii.isDigit(ch) or (ch == '.' and i + 1 < text.len and std.ascii.isDigit(text[i + 1]))) {
const start = i;
i += 1;
while (i < text.len and (std.ascii.isHex(text[i]) or text[i] == '.' or text[i] == '_' or text[i] == 'x' or text[i] == 'X')) i += 1;
sink.emit(start, i - start, .number);
continue;
}
if (std.ascii.isAlphabetic(ch) or ch == '_') {
const start = i;
i += 1;
while (i < text.len and isIdChar(text[i])) i += 1;
const tok = text[start..i];
if (string.isOneOf(tok, &keywords)) {
sink.emit(start, i - start, .keyword);
} else if (string.isOneOf(tok, &globals) and (start == 0 or text[start - 1] != '.')) {
// `.document` is a property access, not the global
sink.emit(start, i - start, .global);
} else if (i < text.len and text[i] == '(') {
const kind: Kind = if (std.ascii.isUpper(tok[0]))
.type_name
else if (start > 0 and text[start - 1] == '.')
.method
else
.function;
sink.emit(start, i - start, kind);
}
continue;
}
i += 1;
}
return .normal;
}
/// Emit `text[start..end]` as string spans split around any `$name` refs —
/// spans must never overlap, so a sequential writer (ANSI) works as well as
/// isocline's last-write-wins cell painting.
fn emitString(text: []const u8, start: usize, end: usize, interpolation: bool, sink: anytype) void {
var seg = start;
while (nextDollarRef(text, seg, end, interpolation)) |ref| {
if (ref.start > seg) sink.emit(seg, ref.start - seg, .string);
sink.emit(ref.start, ref.end - ref.start, ref.kind);
seg = ref.end;
}
if (end > seg) sink.emit(seg, end - seg, .string);
}
const keywords = [_][]const u8{
"function", "async", "await", "yield", "return", "if", "else",
"for", "while", "do", "switch", "case", "break", "continue",
"var", "let", "const", "new", "delete", "typeof", "instanceof",
"in", "of", "void", "this", "super", "class", "extends",
"import", "export", "from", "default", "try", "catch", "finally",
"throw", "true", "false", "null", "undefined", "NaN", "Infinity",
};
// Globals available in the JS-mode page context; highlighted so it's visible
// at the prompt that they're in scope.
const globals = [_][]const u8{ "document", "window", "globalThis", "console", "lp" };
fn isIdChar(ch: u8) bool {
return std.ascii.isAlphanumeric(ch) or ch == '_' or ch == '$' or ch >= 0x80;
}
const testing = std.testing;
/// Records spans as `kind:text` so tests read as the tokenization, not offsets.
const TestSink = struct {
text: []const u8,
buf: std.ArrayListUnmanaged(u8) = .empty,
last_end: usize = 0,
overlapped: bool = false,
fn emit(self: *TestSink, start: usize, len: usize, kind: Kind) void {
if (start < self.last_end) self.overlapped = true;
self.last_end = start + len;
self.buf.writer(testing.allocator).print("{s}:{s} ", .{
@tagName(kind), self.text[start..][0..len],
}) catch unreachable;
}
};
fn expectTokens(expected: []const u8, src: []const u8) !void {
var sink: TestSink = .{ .text = src };
defer sink.buf.deinit(testing.allocator);
_ = tokenize(src, .normal, &sink);
try testing.expect(!sink.overlapped);
try testing.expectEqualStrings(expected, std.mem.trimRight(u8, sink.buf.items, " "));
}
test "js_highlight: keywords, globals, numbers" {
try expectTokens("keyword:const number:1", "const x = 1");
try expectTokens("global:document", "document.body");
// A property access is not the global.
try expectTokens("", "el.document");
}
test "js_highlight: comments and strings" {
try expectTokens("comment:// hi", "// hi");
try expectTokens("comment:/* hi */ keyword:const", "/* hi */ const");
try expectTokens("string:'hi'", "'hi'");
}
test "js_highlight: calls color by identifier case" {
try expectTokens("function:markdown string:'x'", "markdown('x')");
try expectTokens("global:console method:log", "console.log(x)");
try expectTokens("keyword:new type_name:URL", "new URL(u)");
// A keyword before `(` stays a keyword; a bare identifier stays plain.
try expectTokens("keyword:if", "if (x)");
try expectTokens("", "markdown");
}
test "js_highlight: $refs inside strings do not overlap" {
try expectTokens("string:'a variable:$LP_KEY string:'", "'a $LP_KEY'");
try expectTokens("variable:$LP_KEY", "$LP_KEY");
}
test "js_highlight: template interpolations split string spans" {
try expectTokens("string:`a interpolation:${x.y} string:`", "`a ${x.y}`");
// Unclosed interpolation runs to end of line.
try expectTokens("string:`a interpolation:${x", "`a ${x");
// Only template literals interpolate.
try expectTokens("string:'a ${x}'", "'a ${x}'");
}
test "js_highlight: block comment spans lines" {
var sink: TestSink = .{ .text = "/* open" };
defer sink.buf.deinit(testing.allocator);
try testing.expectEqual(State.block_comment, tokenize("/* open", .normal, &sink));
var sink2: TestSink = .{ .text = "still */ const" };
defer sink2.buf.deinit(testing.allocator);
try testing.expectEqual(State.normal, tokenize("still */ const", .block_comment, &sink2));
try testing.expectEqualStrings("comment:still */ keyword:const", std.mem.trimRight(u8, sink2.buf.items, " "));
}
test "js_highlight: template literal spans lines" {
var sink: TestSink = .{ .text = "`<div>" };
defer sink.buf.deinit(testing.allocator);
try testing.expectEqual(State.template, tokenize("`<div>", .normal, &sink));
var sink2: TestSink = .{ .text = "</div>` + x" };
defer sink2.buf.deinit(testing.allocator);
try testing.expectEqual(State.normal, tokenize("</div>` + x", .template, &sink2));
try testing.expectEqualStrings("string:</div>`", std.mem.trimRight(u8, sink2.buf.items, " "));
}

903
src/agent/md_term.zig Normal file
View File

@@ -0,0 +1,903 @@
// 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 ansi = @import("ansi.zig");
const js_highlight = @import("js_highlight.zig");
/// Render markdown `src` as ANSI-styled terminal output to `w` — one-shot
/// wrapper over `Stream`, so batch and streamed output share one dispatcher.
pub fn render(w: *std.Io.Writer, src: []const u8) !void {
var s: Stream = .{};
try s.feed(w, src);
try s.close(w);
}
const LineIterator = std.mem.SplitIterator(u8, .scalar);
const max_table_columns = 16;
/// Shown while a streamed table is withheld; written without a newline so
/// `clear_placeholder` can erase it in place before the table renders.
const table_placeholder = ansi.dim ++ "… rendering table" ++ ansi.reset;
const clear_placeholder = "\r" ++ ansi.clear_line;
/// Cells are inline-rendered and padded to per-column visible width. Widths
/// count codepoints, so double-width glyphs (CJK, emoji) may misalign.
fn renderTable(w: *std.Io.Writer, header: []const u8, it: *LineIterator) !void {
var widths: [max_table_columns]usize = @splat(0);
const cols = measureTable(header, it.*, &widths) orelse
return renderTableVerbatim(w, header, it);
try emitRow(w, header, widths[0..cols], true);
_ = it.next();
try w.writeByte('\n');
try emitSeparator(w, widths[0..cols]);
while (it.peek()) |line| {
if (!isTableRow(line)) break;
_ = it.next();
try w.writeByte('\n');
try emitRow(w, line, widths[0..cols], false);
}
}
/// Fallback for tables the aligner can't measure (too many columns, or a
/// rendered cell overflowing its fixed buffer): rows pass through untouched.
fn renderTableVerbatim(w: *std.Io.Writer, header: []const u8, it: *LineIterator) !void {
try styled(w, header, ansi.bold);
while (it.peek()) |line| {
if (!isTableRow(line) and !isTableSeparator(line)) break;
_ = it.next();
try w.writeByte('\n');
if (isTableSeparator(line)) {
try styled(w, line, ansi.dim);
} else {
try w.writeAll(line);
}
}
}
/// Per-column visible widths for the whole table (`it` positioned on the
/// separator row), or null when it can't be aligned: no cells, too many
/// columns, or a cell overflowing the rendering buffer.
fn measureTable(header: []const u8, it: LineIterator, widths: *[max_table_columns]usize) ?usize {
var cols = measureRow(header, widths, true) orelse return null;
var scan = it;
_ = scan.next();
while (scan.peek()) |line| {
if (!isTableRow(line)) break;
_ = scan.next();
cols = @max(cols, measureRow(line, widths, false) orelse return null);
}
return if (cols == 0) null else cols;
}
/// Column count of `row`, or null when the table can't be aligned (too many
/// columns, or a cell overflowing the rendering buffer).
fn measureRow(row: []const u8, widths: *[max_table_columns]usize, is_header: bool) ?usize {
var cells = cellIterator(row);
var col: usize = 0;
while (cells.next()) |cell| {
if (col == max_table_columns) return null;
const width = cellDisplayWidth(cell, is_header) orelse return null;
widths[col] = @max(widths[col], width);
col += 1;
}
return col;
}
fn emitRow(w: *std.Io.Writer, row: []const u8, widths: []const usize, is_header: bool) !void {
var cells = cellIterator(row);
for (widths) |width| {
try styled(w, "", ansi.dim);
try w.writeByte(' ');
const cell = cells.next() orelse "";
var used: usize = 0;
if (cell.len > 0) {
// Render into scratch so `used` is measured from the exact bytes
// written. measureRow rendered the identical bytes into an
// identically-sized buffer, so this cannot overflow here.
var buf: [cell_buf_len]u8 = undefined;
var fw: std.Io.Writer = .fixed(&buf);
try renderCell(&fw, cell, is_header);
try w.writeAll(fw.buffered());
used = visibleWidth(fw.buffered());
}
try w.splatByteAll(' ', width - used + 1);
}
try styled(w, "", ansi.dim);
}
fn renderCell(w: *std.Io.Writer, cell: []const u8, is_header: bool) std.Io.Writer.Error!void {
if (is_header) return span(w, cell, ansi.bold, null);
try renderInline(w, cell);
}
fn emitSeparator(w: *std.Io.Writer, widths: []const usize) !void {
try w.writeAll(ansi.dim);
for (widths, 0..) |width, col| {
try w.writeAll(if (col == 0) "" else "");
try w.splatBytesAll("", width + 2);
}
try w.writeAll("");
try w.writeAll(ansi.reset);
}
const CellIterator = struct {
row: []const u8,
pos: usize,
fn next(self: *CellIterator) ?[]const u8 {
if (self.pos >= self.row.len) return null;
var i = self.pos;
var in_code = false;
while (i < self.row.len) : (i += 1) {
switch (self.row[i]) {
'`' => in_code = !in_code,
'\\' => i += 1,
'|' => if (!in_code) break,
else => {},
}
}
const end = @min(i, self.row.len);
const cell = std.mem.trim(u8, self.row[self.pos..end], " \t");
self.pos = i + 1;
// A row's trailing `|` leaves one empty last segment; drop it.
if (cell.len == 0 and i >= self.row.len) return null;
return cell;
}
};
fn cellIterator(row: []const u8) CellIterator {
const first_pipe = std.mem.indexOfScalar(u8, row, '|');
return .{ .row = row, .pos = if (first_pipe) |p| p + 1 else 0 };
}
const cell_buf_len = 1024;
fn cellDisplayWidth(cell: []const u8, is_header: bool) ?usize {
var buf: [cell_buf_len]u8 = undefined;
var fw: std.Io.Writer = .fixed(&buf);
renderCell(&fw, cell, is_header) catch return null;
return visibleWidth(fw.buffered());
}
/// Display columns of rendered output: escapes are zero, codepoints are one.
fn visibleWidth(s: []const u8) usize {
var n: usize = 0;
var i: usize = 0;
while (i < s.len) {
if (s[i] == 0x1b and i + 1 < s.len) {
switch (s[i + 1]) {
'[' => {
i += 2;
while (i < s.len and (s[i] < 0x40 or s[i] > 0x7e)) i += 1;
i += 1;
continue;
},
']' => {
i += 2;
while (i < s.len and s[i] != 0x07 and s[i] != 0x1b) i += 1;
i += @as(usize, if (i < s.len and s[i] == 0x1b) 2 else 1);
continue;
},
'\\' => {
i += 2;
continue;
},
else => {},
}
}
if (s[i] & 0xc0 != 0x80) n += 1;
i += 1;
}
return n;
}
fn isFenceDelimiter(line: []const u8) bool {
return std.mem.startsWith(u8, std.mem.trimLeft(u8, line, " \t"), "```");
}
/// Shared by the fence rules and the `---` horizontal rule so they line up.
const rule_width = 24;
/// Dim `╭── lang ───` / `╰─────` rule marking where a fenced code block starts
/// and ends. The opening rule carries the fence's info-string language, if any.
fn renderFenceRule(w: *std.Io.Writer, opening: bool, delimiter: []const u8) !void {
try w.writeAll(ansi.dim);
try w.writeAll(if (opening) "" else "");
var fill: usize = rule_width - 1;
if (opening) {
const info = std.mem.trim(u8, std.mem.trimLeft(u8, delimiter, " \t`"), " \t");
const lang = info[0 .. std.mem.indexOfAny(u8, info, " \t") orelse info.len];
if (lang.len > 0 and lang.len + 4 <= fill) {
try w.writeAll("");
try w.writeAll(lang);
try w.writeByte(' ');
fill -= lang.len + 3;
}
}
try w.splatBytesAll("", fill);
try w.writeAll(ansi.reset);
}
fn isTableRow(line: []const u8) bool {
const trimmed = std.mem.trimLeft(u8, line, " \t");
return trimmed.len >= 1 and trimmed[0] == '|';
}
/// A row of `-`, `:`, `|` and spaces with at least one dash and one pipe,
/// e.g. `| --- |:---:|`.
fn isTableSeparator(line: []const u8) bool {
var has_dash = false;
var has_pipe = false;
for (line) |c| switch (c) {
'-' => has_dash = true,
'|' => has_pipe = true,
':', ' ', '\t' => {},
else => return false,
};
return has_dash and has_pipe;
}
/// Incremental renderer for streamed deltas: buffers until each newline,
/// then renders the completed line. Fence state carries across lines. Table
/// rows are withheld and re-rendered aligned once the table ends — alignment
/// needs the whole table, so it can't stream row by row.
pub const Stream = struct {
/// Show a progress marker while a streamed table is withheld, erased in
/// place before the table renders. Only for streaming to an interactive
/// terminal (the erase assumes the cursor still sits on the marker);
/// off, withheld tables stay silent until they render.
show_table_placeholder: bool = false,
len: usize = 0,
in_fence: bool = false,
/// Fenced-code lexer state, carried across lines and chunks.
js_state: js_highlight.State = .normal,
/// A line that outgrew `buf` passes through unrendered to its newline.
raw: bool = false,
/// `held`: one pipe row buffered, pending the separator that confirms a
/// table. `table`: rows accumulate in `table_buf` until the table ends.
mode: enum { text, held, table } = .text,
table_len: usize = 0,
buf: [4096]u8 = undefined,
/// A table that outgrows this falls back to per-line rendering.
table_buf: [16384]u8 = undefined,
pub fn feed(self: *Stream, w: *std.Io.Writer, data: []const u8) !void {
var rest = data;
while (std.mem.indexOfScalar(u8, rest, '\n')) |nl| {
const head = rest[0..nl];
rest = rest[nl + 1 ..];
if (self.raw) {
try w.writeAll(head);
try w.writeByte('\n');
self.raw = false;
} else if (self.len == 0) {
try self.emitLine(w, head);
} else if (self.len + head.len <= self.buf.len) {
@memcpy(self.buf[self.len..][0..head.len], head);
const full = self.buf[0 .. self.len + head.len];
self.len = 0;
try self.emitLine(w, full);
} else {
try w.writeAll(self.buf[0..self.len]);
try w.writeAll(head);
try w.writeByte('\n');
self.len = 0;
}
}
if (rest.len == 0) return;
if (self.raw) {
try w.writeAll(rest);
} else if (self.len + rest.len <= self.buf.len) {
@memcpy(self.buf[self.len..][0..rest.len], rest);
self.len += rest.len;
} else {
try w.writeAll(self.buf[0..self.len]);
try w.writeAll(rest);
self.len = 0;
self.raw = true;
}
}
/// Flush any withheld table and a trailing partial line (no newline is
/// written for it), then reset all state for the next message.
pub fn close(self: *Stream, w: *std.Io.Writer) !void {
defer self.* = .{ .show_table_placeholder = self.show_table_placeholder };
// A message often ends inside a table: adopt the partial last row.
if (self.len > 0 and !self.raw) {
const partial = self.buf[0..self.len];
const adopt = switch (self.mode) {
.table => isTableRow(partial),
.held => isTableSeparator(partial),
.text => false,
};
if (adopt and self.appendTableLine(partial)) {
self.mode = .table;
self.len = 0;
}
}
switch (self.mode) {
.text => {},
.held => try self.releaseHeldRow(w),
.table => try self.renderBufferedTable(w),
}
if (self.len == 0) return;
const partial = self.buf[0..self.len];
if (isFenceDelimiter(partial)) {
if (self.in_fence) try w.writeByte('\n');
return renderFenceRule(w, !self.in_fence, partial);
}
try renderLine(w, partial, if (self.in_fence) &self.js_state else null);
}
fn emitLine(self: *Stream, w: *std.Io.Writer, text: []const u8) std.Io.Writer.Error!void {
switch (self.mode) {
.text => {
if (isFenceDelimiter(text)) {
self.in_fence = !self.in_fence;
self.js_state = .normal;
if (!self.in_fence) try w.writeByte('\n');
try renderFenceRule(w, self.in_fence, text);
try w.writeByte('\n');
if (self.in_fence) try w.writeByte('\n');
return;
}
if (!self.in_fence and isTableRow(text) and self.appendTableLine(text)) {
self.mode = .held;
return;
}
try renderLine(w, text, if (self.in_fence) &self.js_state else null);
try w.writeByte('\n');
},
.held => {
if (isTableSeparator(text) and self.appendTableLine(text)) {
self.mode = .table;
// Withholding goes quiet; leave a marker until the
// table renders over it.
if (self.show_table_placeholder) try w.writeAll(table_placeholder);
return;
}
try self.releaseHeldRow(w);
try self.emitLine(w, text);
},
.table => {
if (isTableRow(text)) {
if (self.appendTableLine(text)) return;
try self.flushTableUnaligned(w);
try renderLine(w, text, null);
try w.writeByte('\n');
return;
}
try self.renderBufferedTable(w);
try self.emitLine(w, text);
},
}
}
/// No separator followed, so the held row wasn't a table header.
fn releaseHeldRow(self: *Stream, w: *std.Io.Writer) std.Io.Writer.Error!void {
try renderLine(w, self.tableText(), null);
try w.writeByte('\n');
self.resetTable();
}
fn renderBufferedTable(self: *Stream, w: *std.Io.Writer) std.Io.Writer.Error!void {
if (self.show_table_placeholder) try w.writeAll(clear_placeholder);
var it: LineIterator = std.mem.splitScalar(u8, self.tableText(), '\n');
const header = it.first();
try renderTable(w, header, &it);
try w.writeByte('\n');
self.resetTable();
}
fn flushTableUnaligned(self: *Stream, w: *std.Io.Writer) std.Io.Writer.Error!void {
if (self.show_table_placeholder) try w.writeAll(clear_placeholder);
var it = std.mem.splitScalar(u8, self.tableText(), '\n');
while (it.next()) |line| {
try renderLine(w, line, null);
try w.writeByte('\n');
}
self.resetTable();
}
fn appendTableLine(self: *Stream, line: []const u8) bool {
const sep: usize = if (self.table_len == 0) 0 else 1;
if (self.table_len + sep + line.len > self.table_buf.len) return false;
if (sep == 1) {
self.table_buf[self.table_len] = '\n';
self.table_len += 1;
}
@memcpy(self.table_buf[self.table_len..][0..line.len], line);
self.table_len += line.len;
return true;
}
fn tableText(self: *const Stream) []const u8 {
return self.table_buf[0..self.table_len];
}
fn resetTable(self: *Stream) void {
self.table_len = 0;
self.mode = .text;
}
};
/// `js` carries the fenced-code lexer state across lines; null outside a fence.
fn renderLine(w: *std.Io.Writer, line: []const u8, js: ?*js_highlight.State) !void {
if (js) |state| {
state.* = try renderCodeLine(w, line, state.*);
return;
}
const indent_len = line.len - std.mem.trimLeft(u8, line, " \t").len;
const indent = line[0..indent_len];
const trimmed = line[indent_len..];
if (trimmed.len >= 1 and trimmed[0] == '>') {
try styled(w, "", ansi.dim);
try w.writeByte(' ');
try renderInline(w, std.mem.trimLeft(u8, trimmed[1..], " "));
return;
}
// Dashed, unlike the solid fence rules, so adjacent ones read differently.
if (isHorizontalRule(trimmed)) {
try styled(w, "" ** rule_width, ansi.dim);
return;
}
var hashes: usize = 0;
while (hashes < trimmed.len and trimmed[hashes] == '#') hashes += 1;
if (hashes >= 1 and hashes <= 6 and hashes < trimmed.len and trimmed[hashes] == ' ') {
try span(w, std.mem.trimLeft(u8, trimmed[hashes..], " "), ansi.bold, null);
return;
}
if (trimmed.len >= 2 and (trimmed[0] == '-' or trimmed[0] == '*' or trimmed[0] == '+') and trimmed[1] == ' ') {
try w.writeAll(indent);
try styled(w, "", ansi.dim);
try w.writeByte(' ');
try renderInline(w, std.mem.trimLeft(u8, trimmed[2..], " "));
return;
}
var digits: usize = 0;
while (digits < trimmed.len and std.ascii.isDigit(trimmed[digits])) digits += 1;
if (digits > 0 and digits + 1 < trimmed.len and
(trimmed[digits] == '.' or trimmed[digits] == ')') and trimmed[digits + 1] == ' ')
{
try w.writeAll(indent);
try w.writeAll(trimmed[0 .. digits + 2]);
try renderInline(w, std.mem.trimLeft(u8, trimmed[digits + 2 ..], " "));
return;
}
try renderInline(w, line);
}
/// Stack-threaded chain of enclosing span styles; a nested span's reset
/// re-applies the whole chain, so styling survives arbitrary nesting depth.
const Style = struct {
code: []const u8,
parent: ?*const Style,
fn apply(self: *const Style, w: *std.Io.Writer) std.Io.Writer.Error!void {
if (self.parent) |p| try p.apply(w);
try w.writeAll(self.code);
}
fn applyOpt(active: ?*const Style, w: *std.Io.Writer) std.Io.Writer.Error!void {
if (active) |a| try a.apply(w);
}
};
fn renderInline(w: *std.Io.Writer, text: []const u8) !void {
try renderInlineStyled(w, text, null);
}
fn renderInlineStyled(w: *std.Io.Writer, text: []const u8, active: ?*const Style) std.Io.Writer.Error!void {
var i: usize = 0;
while (i < text.len) {
switch (text[i]) {
// Only unescape markdown-special chars; leave e.g. `C:\Users` intact.
'\\' => if (i + 1 < text.len and isEscapable(text[i + 1])) {
try w.writeByte(text[i + 1]);
i += 2;
continue;
},
'`' => if (std.mem.indexOfPos(u8, text, i + 1, "`")) |end| {
try styled(w, text[i + 1 .. end], ansi.teal);
try Style.applyOpt(active, w);
i = end + 1;
continue;
},
'*', '_' => |ch| {
const double = [2]u8{ ch, ch };
if (i + 1 < text.len and text[i + 1] == ch) {
if (std.mem.indexOfPos(u8, text, i + 2, &double)) |end| {
try span(w, text[i + 2 .. end], ansi.bold, active);
i = end + 2;
continue;
}
} else if (std.mem.indexOfScalarPos(u8, text, i + 1, ch)) |end| {
try span(w, text[i + 1 .. end], ansi.italic, active);
i = end + 1;
continue;
}
},
'~' => if (i + 1 < text.len and text[i + 1] == '~') {
if (std.mem.indexOfPos(u8, text, i + 2, "~~")) |end| {
try span(w, text[i + 2 .. end], ansi.strike, active);
i = end + 2;
continue;
}
},
'[' => if (std.mem.indexOfPos(u8, text, i + 1, "](")) |mid| {
if (std.mem.indexOfScalarPos(u8, text, mid + 2, ')')) |end| {
try renderLink(w, text[i + 1 .. mid], text[mid + 2 .. end]);
try Style.applyOpt(active, w);
i = end + 1;
continue;
}
},
else => {},
}
try w.writeByte(text[i]);
i += 1;
}
}
fn span(w: *std.Io.Writer, inner: []const u8, style: []const u8, active: ?*const Style) std.Io.Writer.Error!void {
try w.writeAll(style);
try renderInlineStyled(w, inner, &.{ .code = style, .parent = active });
try w.writeAll(ansi.reset);
try Style.applyOpt(active, w);
}
/// Writes `js_highlight` spans as ANSI, filling the gaps between them with
/// unstyled text. `emit` cannot fail, so a write error is stashed and returned
/// by `finish`.
const JsSink = struct {
w: *std.Io.Writer,
text: []const u8,
last: usize = 0,
err: ?std.Io.Writer.Error = null,
fn color(kind: js_highlight.Kind) []const u8 {
return switch (kind) {
.comment => ansi.dim ++ ansi.italic,
.string => ansi.green,
.variable, .interpolation => ansi.yellow,
.number => ansi.magenta,
.keyword => ansi.blue ++ ansi.bold,
.global, .type_name => ansi.cyan,
.function => ansi.teal,
.method => ansi.teal ++ ansi.italic,
};
}
pub fn emit(self: *JsSink, start: usize, len: usize, kind: js_highlight.Kind) void {
self.write(start, len, kind) catch |err| {
self.err = self.err orelse err;
};
}
fn write(self: *JsSink, start: usize, len: usize, kind: js_highlight.Kind) !void {
if (start > self.last) try self.w.writeAll(self.text[self.last..start]);
try styled(self.w, self.text[start..][0..len], color(kind));
self.last = start + len;
}
fn finish(self: *JsSink) !void {
if (self.err) |err| return err;
if (self.last < self.text.len) try self.w.writeAll(self.text[self.last..]);
}
};
/// Syntax-highlight one line of fenced code as JavaScript — the only language
/// this agent emits in practice. Returns the lexer state for the next line.
fn renderCodeLine(w: *std.Io.Writer, line: []const u8, state: js_highlight.State) !js_highlight.State {
var sink: JsSink = .{ .w = w, .text = line };
const next = js_highlight.tokenize(line, state, &sink);
try sink.finish();
return next;
}
fn styled(w: *std.Io.Writer, inner: []const u8, style: []const u8) !void {
try w.writeAll(style);
try w.writeAll(inner);
try w.writeAll(ansi.reset);
}
fn isEscapable(c: u8) bool {
return switch (c) {
'*', '_', '`', '~', '[', ']', '(', ')', '|', '\\' => true,
else => false,
};
}
/// A left-trimmed line of 3+ identical `-`, `*` or `_` markers — the whole
/// line, so `***bold***` stays inline text.
fn isHorizontalRule(line: []const u8) bool {
if (line.len < 3) return false;
const first = line[0];
if (first != '-' and first != '*' and first != '_') return false;
for (line[1..]) |c| {
if (c != first) return false;
}
return true;
}
fn renderLink(w: *std.Io.Writer, label: []const u8, url: []const u8) !void {
// OSC 8 makes the label clickable where supported and is ignored elsewhere;
// the trailing dim url is the fallback for terminals without OSC 8.
try w.print("\x1b]8;;{s}\x1b\\", .{url});
try styled(w, label, ansi.underline);
try w.writeAll("\x1b]8;;\x1b\\");
if (!std.mem.eql(u8, label, url)) try w.print(" {s}({s}){s}", .{ ansi.dim, url, ansi.reset });
}
const testing = std.testing;
fn expectRender(expected: []const u8, src: []const u8) !void {
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try render(&aw.writer, src);
try testing.expectEqualStrings(expected, aw.written());
}
test "md_term: inline styles" {
try expectRender("say \x1b[1mhi\x1b[0m now", "say **hi** now");
try expectRender("say \x1b[3mhi\x1b[0m now", "say *hi* now");
try expectRender("say \x1b[3mhi\x1b[0m now", "say _hi_ now");
try expectRender("run \x1b[36mls\x1b[0m ok", "run `ls` ok");
try expectRender("no \x1b[9mway\x1b[0m", "no ~~way~~");
}
test "md_term: blocks" {
try expectRender("\x1b[1mTitle\x1b[0m", "# Title");
try expectRender("\x1b[1mSub\x1b[0m", "### Sub");
try expectRender("\x1b[2m•\x1b[0m item", "- item");
try expectRender("1. item", "1. item");
}
test "md_term: alignment spaces after list marker collapse" {
try expectRender("\x1b[2m•\x1b[0m item", "- item");
try expectRender("1. item", "1. item");
}
test "md_term: nested inline styles" {
// The code span's reset re-applies the enclosing bold.
try expectRender(
"\x1b[1muse \x1b[36mls\x1b[0m\x1b[1m now\x1b[0m",
"**use `ls` now**",
);
try expectRender(
"\x1b[1m\x1b[36mgoto(url)\x1b[0m\x1b[1m\x1b[0m: nav",
"**`goto(url)`**: nav",
);
// Inside a heading too: bold survives past the code span.
try expectRender(
"\x1b[1mrun \x1b[36mls\x1b[0m\x1b[1m first\x1b[0m",
"## run `ls` first",
);
// Italic nested in bold stacks, then bold is restored.
try expectRender(
"\x1b[1ma \x1b[3mb\x1b[0m\x1b[1m c\x1b[0m",
"**a *b* c**",
);
// Depth 2: the code span's reset re-applies the full bold+italic chain.
try expectRender(
"\x1b[1ma \x1b[3mb \x1b[36mc\x1b[0m\x1b[1m\x1b[3m d\x1b[0m\x1b[1m e\x1b[0m",
"**a *b `c` d* e**",
);
}
const open_rule = "\x1b[2m╭" ++ "" ** 23 ++ "\x1b[0m";
const close_rule = "\x1b[2m╰" ++ "" ** 23 ++ "\x1b[0m";
test "md_term: fenced code block is highlighted as JavaScript" {
try expectRender(
open_rule ++ "\n\n\x1b[34m\x1b[1mlet\x1b[0m x = \x1b[35m1\x1b[0m;\n\n" ++ close_rule,
"```\nlet x = 1;\n```",
);
// Untokenized text passes through unstyled.
try expectRender(open_rule ++ "\n\nplain\n\n" ++ close_rule, "```\nplain\n```");
}
test "md_term: fence rules carry the language tag" {
try expectRender(
"\x1b[2m╭─ js " ++ "" ** 18 ++ "\x1b[0m\n\nx\n\n" ++ close_rule,
"```js\nx\n```",
);
// An overlong info string doesn't fit the rule and is dropped.
try expectRender(
open_rule ++ "\n\nx\n\n" ++ close_rule,
"```" ++ "x" ** 20 ++ "\nx\n```",
);
}
test "md_term: fenced template literal spans lines" {
try expectRender(
open_rule ++ "\n\n\x1b[32m`<div>\x1b[0m\n\x1b[32m</div>`\x1b[0m\n\n" ++ close_rule,
"```\n`<div>\n</div>`\n```",
);
}
test "md_term: link" {
// OSC 8 hyperlink around the label, plus a dim fallback url.
try expectRender(
"\x1b]8;;https://x.io\x1b\\\x1b[4mLP\x1b[0m\x1b]8;;\x1b\\ \x1b[2m(https://x.io)\x1b[0m",
"[LP](https://x.io)",
);
// A bare link (label == url) omits the redundant suffix.
try expectRender(
"\x1b]8;;https://x.io\x1b\\\x1b[4mhttps://x.io\x1b[0m\x1b]8;;\x1b\\",
"[https://x.io](https://x.io)",
);
}
test "md_term: blockquote" {
try expectRender("\x1b[2m│\x1b[0m quoted \x1b[1mnote\x1b[0m", "> quoted **note**");
}
test "md_term: horizontal rule" {
try expectRender("\x1b[2m" ++ "" ** 24 ++ "\x1b[0m", "---");
try expectRender("\x1b[2m" ++ "" ** 24 ++ "\x1b[0m", "***");
try expectRender("---x", "---x");
}
test "md_term: tables align columns and style cells" {
const B = "\x1b[1m";
const C = "\x1b[36m";
const D = "\x1b[2m";
const R = "\x1b[0m";
const pipe = D ++ "" ++ R;
// Source is already padded; rendering keeps the alignment after
// stripping the cell markers.
try expectRender(
pipe ++ " " ++ B ++ "Tool" ++ R ++ " " ++ pipe ++ " " ++ B ++ "Use" ++ R ++ " " ++ pipe ++ "\n" ++
D ++ "├──────┼─────┤" ++ R ++ "\n" ++
pipe ++ " " ++ C ++ "goto" ++ R ++ " " ++ pipe ++ " nav " ++ pipe ++ "\n",
"| Tool | Use |\n|--------|-----|\n| `goto` | nav |",
);
// Gemini-style unpadded source: columns are padded to the widest
// rendered cell.
try expectRender(
pipe ++ " " ++ B ++ "Tool" ++ R ++ " " ++ pipe ++ " " ++ B ++ "Usage" ++ R ++ " " ++ pipe ++ "\n" ++
D ++ "├──────┼─────────────┤" ++ R ++ "\n" ++
pipe ++ " goto " ++ pipe ++ " " ++ B ++ "Nav:" ++ R ++ " to url " ++ pipe ++ "\n",
"| Tool | Usage |\n| :--- | :--- |\n| goto | **Nav:** to url |",
);
// A pipe line without a separator row underneath is not a table.
try expectRender("| just \x1b[1mtext\x1b[0m |", "| just **text** |");
}
test "md_term: overwide table falls back to verbatim rows" {
const header = "|a" ** 17 ++ "|";
const sep = "|-" ** 17 ++ "|";
try expectRender(
"\x1b[1m" ++ header ++ "\x1b[0m\n\x1b[2m" ++ sep ++ "\x1b[0m\n",
header ++ "\n" ++ sep,
);
}
test "md_term: stream renders across chunk boundaries" {
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
var s: Stream = .{};
try s.feed(&aw.writer, "say **h");
try s.feed(&aw.writer, "i** now\n- ite");
try s.feed(&aw.writer, "m\ntail");
try s.close(&aw.writer);
try testing.expectEqualStrings(
"say \x1b[1mhi\x1b[0m now\n\x1b[2m•\x1b[0m item\ntail",
aw.written(),
);
}
test "md_term: stream withholds tables and renders them aligned" {
const B = "\x1b[1m";
const C = "\x1b[36m";
const D = "\x1b[2m";
const R = "\x1b[0m";
const pipe = D ++ "" ++ R;
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
var s: Stream = .{ .show_table_placeholder = true };
try s.feed(&aw.writer, "| Tool | Use |\n| :--- | :-");
try testing.expectEqualStrings("", aw.written());
try s.feed(&aw.writer, "-- |\n| `goto` | nav |\ndone\n");
try testing.expectEqualStrings(
D ++ "… rendering table" ++ R ++ "\r\x1b[2K" ++
pipe ++ " " ++ B ++ "Tool" ++ R ++ " " ++ pipe ++ " " ++ B ++ "Use" ++ R ++ " " ++ pipe ++ "\n" ++
D ++ "├──────┼─────┤" ++ R ++ "\n" ++
pipe ++ " " ++ C ++ "goto" ++ R ++ " " ++ pipe ++ " nav " ++ pipe ++ "\n" ++
"done\n",
aw.written(),
);
}
test "md_term: stream table ending at close adopts the partial row" {
const B = "\x1b[1m";
const D = "\x1b[2m";
const R = "\x1b[0m";
const pipe = D ++ "" ++ R;
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
var s: Stream = .{ .show_table_placeholder = true };
try s.feed(&aw.writer, "| A | B |\n|-|-|\n| x | y |");
try s.close(&aw.writer);
try testing.expectEqualStrings(
D ++ "… rendering table" ++ R ++ "\r\x1b[2K" ++
pipe ++ " " ++ B ++ "A" ++ R ++ " " ++ pipe ++ " " ++ B ++ "B" ++ R ++ " " ++ pipe ++ "\n" ++
D ++ "├───┼───┤" ++ R ++ "\n" ++
pipe ++ " x " ++ pipe ++ " y " ++ pipe ++ "\n",
aw.written(),
);
}
test "md_term: stream releases a lone pipe row" {
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
var s: Stream = .{};
try s.feed(&aw.writer, "| a |\nplain\n");
try s.close(&aw.writer);
try testing.expectEqualStrings("| a |\nplain\n", aw.written());
}
test "md_term: stream fence state spans chunks" {
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
var s: Stream = .{};
try s.feed(&aw.writer, "```\ncode\n``");
try s.feed(&aw.writer, "`\nafter\n");
try s.close(&aw.writer);
try testing.expectEqualStrings(
open_rule ++ "\n\ncode\n\n" ++ close_rule ++ "\nafter\n",
aw.written(),
);
}
test "md_term: stream closing fence at message end still draws its rule" {
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
var s: Stream = .{};
try s.feed(&aw.writer, "```\ncode\n```");
try s.close(&aw.writer);
try testing.expectEqualStrings(
open_rule ++ "\n\ncode\n\n" ++ close_rule,
aw.written(),
);
}
test "md_term: backslash escapes" {
try expectRender("a * b", "a \\* b");
try expectRender("keep \\d and C:\\Users", "keep \\d and C:\\Users");
}
test "md_term: unterminated markers stay literal" {
try expectRender("say **hi now", "say **hi now");
try expectRender("a * b", "a * b");
try expectRender("see [x](y", "see [x](y");
}

262
src/agent/picker.zig Normal file
View File

@@ -0,0 +1,262 @@
// 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/>.
//! Interactive numbered-choice picker for stdin/stderr prompts (provider
//! selection, /save mode, …). Self-contained raw-terminal handling; runs
//! before — or without — the isocline REPL.
const std = @import("std");
const ansi = @import("ansi.zig");
pub fn interactiveTty() bool {
return std.posix.isatty(std.posix.STDIN_FILENO) and std.posix.isatty(std.posix.STDERR_FILENO);
}
/// Numbered TTY picker. `default` (if set) marks that row "(default)" and
/// makes Enter start on that index. Up/Down moves the active row; Enter
/// selects it. Numbered input still works for users who prefer typing.
pub fn promptNumberedChoice(header: []const u8, items: []const [:0]const u8, default: ?usize) !usize {
if (items.len == 0) return error.NoChoice;
const valid_default: ?usize = if (default) |d| if (d < items.len) d else null else null;
if (interactiveTty()) {
return promptInteractiveChoice(header, items, valid_default) catch |err| switch (err) {
error.NotInteractive => try promptNumberedChoiceLine(header, items, valid_default),
else => err,
};
}
return promptNumberedChoiceLine(header, items, valid_default);
}
/// Line-oriented fallback. Errors with NoChoice after 3 invalid attempts.
fn promptNumberedChoiceLine(header: []const u8, items: []const [:0]const u8, default: ?usize) !usize {
var stdin_buf: [128]u8 = undefined;
var stdin = std.fs.File.stdin().reader(&stdin_buf);
var attempt: u8 = 0;
while (attempt < 3) : (attempt += 1) {
std.debug.print("{s}\n", .{header});
for (items, 0..) |item, idx| {
const marker: []const u8 = if (default) |d| (if (d == idx) " (default)" else "") else "";
std.debug.print(" {d:>3}) {s}{s}\n", .{ idx + 1, item, marker });
}
std.debug.print("> ", .{});
const line = stdin.interface.takeDelimiterInclusive('\n') catch |err| switch (err) {
error.EndOfStream, error.StreamTooLong, error.ReadFailed => return error.UserCancelled,
};
const trimmed = std.mem.trim(u8, line, " \t\r\n");
if (trimmed.len == 0) {
if (default) |d| return d;
std.debug.print("Invalid input — type a number.\n", .{});
continue;
}
const choice = std.fmt.parseInt(usize, trimmed, 10) catch {
const hint: []const u8 = if (default != null) " (or press Enter for default)" else "";
std.debug.print("Invalid input — type a number{s}.\n", .{hint});
continue;
};
if (choice >= 1 and choice <= items.len) return choice - 1;
std.debug.print("Out of range.\n", .{});
}
return error.NoChoice;
}
const ChoiceInput = enum { up, down, enter, cancel, ignore };
const ChoiceState = struct {
selected: usize,
fn init(default: ?usize) ChoiceState {
return .{ .selected = default orelse 0 };
}
fn apply(self: *ChoiceState, input: ChoiceInput, item_count: usize) ?usize {
switch (input) {
.up => self.selected = if (self.selected == 0) item_count - 1 else self.selected - 1,
.down => self.selected = (self.selected + 1) % item_count,
.enter => return self.selected,
.cancel, .ignore => {},
}
return null;
}
};
const RawTerminal = struct {
original: std.posix.termios,
fn enable() error{NotInteractive}!RawTerminal {
if (!interactiveTty()) return error.NotInteractive;
// A tty that refuses raw mode is non-interactive for our purposes.
const original = std.posix.tcgetattr(std.posix.STDIN_FILENO) catch return error.NotInteractive;
var raw = original;
raw.iflag.BRKINT = false;
raw.iflag.ICRNL = false;
raw.iflag.INPCK = false;
raw.iflag.ISTRIP = false;
raw.iflag.IXON = false;
raw.oflag.OPOST = false;
raw.cflag.CSIZE = .CS8;
raw.lflag.ECHO = false;
raw.lflag.ICANON = false;
raw.lflag.IEXTEN = false;
raw.lflag.ISIG = false;
raw.cc[@intFromEnum(std.c.V.MIN)] = 0;
raw.cc[@intFromEnum(std.c.V.TIME)] = 1;
std.posix.tcsetattr(std.posix.STDIN_FILENO, .FLUSH, raw) catch return error.NotInteractive;
// Under `ansi.kitty_disambiguate` (pushed by `Terminal.readLine`),
// cursor keys arrive as CSI-u the byte reader can't parse; push the
// legacy encoding to force plain arrows. restore() pops back to
// whatever the REPL had pushed.
_ = std.posix.write(std.posix.STDOUT_FILENO, ansi.kitty_legacy) catch {};
return .{ .original = original };
}
fn restore(self: *const RawTerminal) void {
_ = std.posix.write(std.posix.STDOUT_FILENO, ansi.kitty_pop) catch {};
std.posix.tcsetattr(std.posix.STDIN_FILENO, .FLUSH, self.original) catch {};
}
};
fn promptInteractiveChoice(header: []const u8, items: []const [:0]const u8, default: ?usize) !usize {
var raw: RawTerminal = try .enable();
defer raw.restore();
var state: ChoiceState = .init(default);
const line_count = items.len + 2;
var first_render = true;
while (true) {
renderChoice(header, items, default, state.selected, first_render);
first_render = false;
const input = readChoiceInput() catch return error.UserCancelled;
if (input == .cancel) {
clearChoiceRender(line_count);
return error.UserCancelled;
}
if (state.apply(input, items.len)) |idx| {
clearChoiceRender(line_count);
std.debug.print("{s} {s}\r\n", .{ header, items[idx] });
return idx;
}
}
}
/// Emit a whole redraw frame in one write — per-line writes can tear
/// visually mid-frame (same approach as Spinner's renderLocked). A frame
/// that outgrows the buffer is emitted truncated.
fn emitFrame(bytes: []const u8) void {
std.debug.lockStdErr();
defer std.debug.unlockStdErr();
_ = std.posix.write(std.posix.STDERR_FILENO, bytes) catch {};
}
const frame_buf_len = 4096;
fn clearChoiceRender(line_count: usize) void {
var buf: [frame_buf_len]u8 = undefined;
var fw: std.Io.Writer = .fixed(&buf);
blk: {
moveChoiceRenderStart(&fw, line_count) catch break :blk;
for (0..line_count) |i| {
fw.writeAll(ansi.clear_line) catch break :blk;
if (i + 1 < line_count) fw.writeAll("\r\n") catch break :blk;
}
moveChoiceRenderStart(&fw, line_count) catch break :blk;
}
emitFrame(fw.buffered());
}
fn moveChoiceRenderStart(w: *std.Io.Writer, line_count: usize) std.Io.Writer.Error!void {
if (line_count > 1) {
try w.print("\x1b[{d}F", .{line_count - 1});
} else {
try w.writeAll("\r");
}
}
fn renderChoice(header: []const u8, items: []const [:0]const u8, default: ?usize, selected: usize, first_render: bool) void {
var buf: [frame_buf_len]u8 = undefined;
var fw: std.Io.Writer = .fixed(&buf);
writeChoiceFrame(&fw, header, items, default, selected, first_render) catch {};
emitFrame(fw.buffered());
}
fn writeChoiceFrame(w: *std.Io.Writer, header: []const u8, items: []const [:0]const u8, default: ?usize, selected: usize, first_render: bool) std.Io.Writer.Error!void {
if (!first_render) try moveChoiceRenderStart(w, items.len + 2);
try w.print(ansi.clear_line ++ "{s}\r\n", .{header});
for (items, 0..) |item, idx| {
const on_row = idx == selected;
const marker: []const u8 = if (on_row) ">" else " ";
const style: []const u8 = if (on_row) ansi.bold ++ ansi.teal else "";
const reset: []const u8 = if (on_row) ansi.reset else "";
const default_marker: []const u8 = if (default) |d| (if (d == idx) " (default)" else "") else "";
try w.print(ansi.clear_line ++ " {s} {s}{s}{s}{s}\r\n", .{ marker, style, item, default_marker, reset });
}
try w.print(ansi.clear_line ++ "{s}Use Up/Down then Enter. Esc cancels.{s}", .{ ansi.dim, ansi.reset });
}
fn readChoiceInput() !ChoiceInput {
while (true) {
const ch = try readChoiceByte() orelse continue;
return switch (ch) {
3, 4, 27 => esc: {
if (ch != 27) break :esc .cancel;
const b1 = try readChoiceByte() orelse break :esc .cancel;
if (b1 != '[' and b1 != 'O') break :esc .cancel;
const b2 = try readChoiceByte() orelse break :esc .cancel;
break :esc switch (b2) {
'A' => .up,
'B' => .down,
else => .ignore,
};
},
'\r', '\n' => .enter,
else => .ignore,
};
}
}
fn readChoiceByte() !?u8 {
var buf: [1]u8 = undefined;
const n = std.posix.read(std.posix.STDIN_FILENO, &buf) catch |err| switch (err) {
error.WouldBlock => return null,
error.InputOutput => return error.ReadFailed,
else => return err,
};
if (n == 0) return null;
return buf[0];
}
test "ChoiceState: arrows wrap and enter selects highlighted item" {
var state: ChoiceState = .init(null);
try std.testing.expectEqual(@as(usize, 0), state.selected);
try std.testing.expectEqual(@as(?usize, null), state.apply(.up, 3));
try std.testing.expectEqual(@as(usize, 2), state.selected);
try std.testing.expectEqual(@as(?usize, null), state.apply(.down, 3));
try std.testing.expectEqual(@as(usize, 0), state.selected);
try std.testing.expectEqual(@as(?usize, 0), state.apply(.enter, 3));
}
test "ChoiceState: starts on default and enter returns it" {
var state: ChoiceState = .init(2);
try std.testing.expectEqual(@as(usize, 2), state.selected);
try std.testing.expectEqual(@as(?usize, 2), state.apply(.enter, 3));
}

840
src/agent/prompt_assist.zig Normal file
View File

@@ -0,0 +1,840 @@
// 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/>.
//! The REPL prompt's input assistance: isocline completion, ghost hints, and
//! syntax highlighting over the slash-command schemas and JS mode. Terminal
//! owns the readline lifecycle; this module owns everything that reacts to
//! the buffer while the user types.
const std = @import("std");
const lp = @import("lightpanda");
const browser_tools = lp.tools;
const Schema = lp.Schema;
const SlashCommand = @import("SlashCommand.zig");
const js_highlight = @import("js_highlight.zig");
const c = @cImport({
@cInclude("isocline.h");
});
const style_slash = "ps-slash";
const style_string = "ps-string";
const style_var = "ps-var";
const style_url = "ps-url";
const style_key = "ps-key";
const style_num = "ps-num";
const style_err = "ps-err";
const style_jsmode = "ps-jsmode";
/// The prompt's style palette (`ps-*` avoids isocline's built-in `ic-*`
/// namespace). Registration and token-kind painting both derive from it, so
/// a painted style is registered by construction.
const Style = struct {
name: [:0]const u8,
spec: [:0]const u8,
/// `js_highlight` token kinds painted with this style; empty for styles
/// applied directly by name.
kinds: []const js_highlight.Kind = &.{},
};
const styles = [_]Style{
.{ .name = style_slash, .spec = "ansi-teal bold" },
.{ .name = style_string, .spec = "ansi-green", .kinds = &.{.string} },
.{ .name = style_var, .spec = "ansi-yellow", .kinds = &.{ .variable, .interpolation } },
.{ .name = style_url, .spec = "ansi-blue underline" },
.{ .name = style_key, .spec = "ansi-blue" },
.{ .name = style_num, .spec = "ansi-magenta", .kinds = &.{.number} },
.{ .name = style_err, .spec = "ansi-red" },
.{ .name = style_jsmode, .spec = "ansi-red bold" },
.{ .name = "ps-keyword", .spec = "ansi-blue bold", .kinds = &.{.keyword} },
.{ .name = "ps-comment", .spec = "ansi-darkgray italic", .kinds = &.{.comment} },
.{ .name = "ps-jsglobal", .spec = "ansi-cyan", .kinds = &.{.global} },
.{ .name = "ps-fn", .spec = "ansi-teal", .kinds = &.{.function} },
.{ .name = "ps-method", .spec = "ansi-teal italic", .kinds = &.{.method} },
.{ .name = "ps-type", .spec = "ansi-cyan", .kinds = &.{.type_name} },
};
/// Style name per token kind, derived from `styles`. Unmapped or
/// doubly-mapped kinds are compile errors.
const kind_styles = blk: {
const n = std.enums.values(js_highlight.Kind).len;
var arr: [n]?[:0]const u8 = @splat(null);
for (styles) |s| for (s.kinds) |kind| {
if (arr[@intFromEnum(kind)] != null) @compileError("kind styled twice: " ++ @tagName(kind));
arr[@intFromEnum(kind)] = s.name;
};
var out: [n][:0]const u8 = undefined;
for (arr, 0..) |name, i| {
out[i] = name orelse @compileError("js_highlight.Kind with no ps-* style: " ++
@tagName(@as(js_highlight.Kind, @enumFromInt(i))));
}
break :blk out;
};
/// Lets the completer/hinter pull dynamic candidates from the `Agent` without
/// this module depending on it (same idiom as `Session.cancel_hook`).
pub const CompletionSource = struct {
context: *anyopaque,
providers: *const fn (context: *anyopaque, arena: std.mem.Allocator) []const []const u8,
/// May block on an HTTP fetch.
models: *const fn (context: *anyopaque, arena: std.mem.Allocator) []const []const u8,
};
/// Separate history files for normal and JS prompt modes. isocline holds one
/// history list at a time, so we swap files on mode toggle rather than tag a
/// shared file.
pub const HistoryPaths = struct {
normal: [:0]const u8,
js: [:0]const u8,
};
/// The mutable state the C callbacks read: registered once via `attach`, so
/// it must live at a stable address.
pub const State = struct {
/// True while the REPL is in JS mode; set by isocline's mode callback.
js_mode: bool = false,
completion_source: ?CompletionSource = null,
/// Per-mode history files (null outside REPL mode). `modeCallback` swaps
/// the active one so JS and normal recall stay separate.
history_paths: ?HistoryPaths = null,
};
/// One-time isocline configuration for REPL mode. Probes the terminal
/// (isocline writes an ESC[6n cursor-report on stdout), so callers must skip
/// it in script-only mode.
pub fn setupRepl() void {
_ = c.ic_enable_multiline(true);
_ = c.ic_enable_hint(true);
_ = c.ic_enable_inline_help(true);
// Show ghost completions instantly; isocline's default is 400 ms.
_ = c.ic_set_hint_delay(0);
_ = c.ic_enable_brace_insertion(true);
for (styles) |s| c.ic_style_def(s.name.ptr, s.spec.ptr);
// lighten the ghost/inline-hint color from isocline's default ansi-darkgray
c.ic_style_def("ic-hint", "ansi-color=244");
// `!` on an empty prompt toggles JS mode; state callback wired in attach.
c.ic_set_prompt_mode("[" ++ style_jsmode ++ "]![/" ++ style_jsmode ++ "] ", '!');
// Blank continuation marker so multiline input isn't prefixed with `>`.
c.ic_set_prompt_marker(" ", "");
_ = c.ic_enable_highlight(true);
}
/// Wires the isocline completer, hinter, and highlighter to `state` and
/// loads the initial history. Must run after `state` is in its final memory
/// location and before the first readline.
pub fn attach(state: *State) void {
c.ic_set_default_completer(&completionCallback, state);
c.ic_set_default_hinter(&hintsCallback, state);
c.ic_set_mode_callback(&modeCallback, state);
c.ic_set_ctrl_d_hint(" press Ctrl-D again to exit");
c.ic_set_esc_clear_hint(" esc again to clear");
c.ic_set_mode_hint(" JS mode - esc to exit");
c.ic_set_default_highlighter(&highlighterCallback, state);
if (state.history_paths) |hp| {
// Mode inactive at launch, so load the normal file; modeCallback
// swaps to JS on mode entry.
c.ic_set_history(hp.normal.ptr, -1); // -1 → 200-entry default cap
}
}
fn modeCallback(active: bool, arg: ?*anyopaque) callconv(.c) void {
const state: *State = @ptrCast(@alignCast(arg orelse return));
state.js_mode = active;
if (state.history_paths) |hp| {
c.ic_set_history((if (active) hp.js else hp.normal).ptr, -1);
}
}
const completion_buf_len = 512;
fn addPrefixedCompletion(
cenv: ?*c.ic_completion_env_t,
buf: *[completion_buf_len:0]u8,
input: []const u8,
prefix: []const u8,
name: []const u8,
suffix: []const u8,
partial: []const u8,
) void {
if (!std.ascii.startsWithIgnoreCase(name, partial)) return;
const text = std.fmt.bufPrintZ(buf, "{s}{s}{s}", .{ prefix, name, suffix }) catch return;
_ = c.ic_add_completion_prim(cenv, text.ptr, null, null, @intCast(input.len), 0);
}
// Cap on tokens read out of the body; extra tokens are ignored. Real schemas
// and CLI inputs have far fewer fields.
const max_tokens = 32;
const BodyAnalysis = struct {
used: [max_tokens][]const u8 = undefined,
used_len: usize = 0,
// Trailing in-progress token when the user is typing a key prefix (no `=`
// yet, not a positional binding). Null when the body is empty, ends with
// whitespace, or the trailing token is fully committed.
partial_key: ?[]const u8 = null,
fn markUsed(self: *BodyAnalysis, name: []const u8) void {
if (self.used_len >= self.used.len) return;
self.used[self.used_len] = name;
self.used_len += 1;
}
fn isUsed(self: *const BodyAnalysis, name: []const u8) bool {
for (self.used[0..self.used_len]) |u| {
if (std.mem.eql(u8, u, name)) return true;
}
return false;
}
};
fn analyzeBody(schema: *const Schema, body: []const u8, ends_ws: bool) BodyAnalysis {
var a: BodyAnalysis = .{};
var tokens: [max_tokens][]const u8 = undefined;
var n: usize = 0;
var it = std.mem.tokenizeAny(u8, body, &std.ascii.whitespace);
while (it.next()) |tok| {
if (n >= tokens.len) break;
tokens[n] = tok;
n += 1;
}
if (n == 0) return a;
const last = n - 1;
for (tokens[0..n], 0..) |tok, i| {
if (std.mem.indexOfScalar(u8, tok, '=')) |eq| {
a.markUsed(tok[0..eq]);
continue;
}
// The first bare arg binds positionally to the schema's positional
// field (`/goto https://example.com`, `/getEnv LP_TOKEN`).
if (i == 0) if (schema.leadingPositionalField()) |pos| {
a.markUsed(pos);
continue;
};
if (i == last and !ends_ws) a.partial_key = tok;
}
return a;
}
const help_arg_prefix = "/help ";
fn parseHelpArgPrefix(input: []const u8) ?[]const u8 {
if (!std.ascii.startsWithIgnoreCase(input, help_arg_prefix)) return null;
const arg = std.mem.trimLeft(u8, input[help_arg_prefix.len..], " ");
if (std.mem.indexOfScalar(u8, arg, ' ') != null) return null;
return arg;
}
/// A field whose value the cursor is positioned to complete, plus the partial
/// value typed so far. Covers both the leading positional (`/waitForState net`)
/// and an explicit `key=` pair (`/waitForState state=net`).
const ValueAt = struct {
field: Schema.FieldEntry,
partial: []const u8,
/// `key=value` form rather than the bare leading positional.
kv: bool,
};
/// Classifies the token under the cursor as a value position for some schema
/// field. Null when the cursor isn't on a completable value (a key prefix, a
/// non-leading positional, or an unknown field).
fn valueAt(schema: *const Schema, body: []const u8, ends_ws: bool) ?ValueAt {
var last: []const u8 = "";
var n: usize = 0;
var it = std.mem.tokenizeAny(u8, body, &std.ascii.whitespace);
while (it.next()) |tok| {
last = tok;
n += 1;
}
// An empty body or a trailing space puts the cursor on a fresh token after
// the `n` committed ones; otherwise it sits on the last token.
const active: []const u8 = if (ends_ws or n == 0) "" else last;
const active_index: usize = if (ends_ws or n == 0) n else n - 1;
if (std.mem.indexOfScalar(u8, active, '=')) |eq| {
const field = schema.findField(active[0..eq]) orelse return null;
return .{ .field = field, .partial = active[eq + 1 ..], .kv = true };
}
// The leading bare token binds to the schema's positional field (lone
// required, or sole optional field like getEnv's `name`).
if (active_index == 0) if (schema.leadingPositionalField()) |pos| {
const field = schema.findField(pos) orelse return null;
return .{ .field = field, .partial = active, .kv = false };
};
return null;
}
/// Returns true when it owns the completion, so the caller skips key hints.
fn addValueCompletions(
cenv: ?*c.ic_completion_env_t,
input: []const u8,
body: []const u8,
schema: *const Schema,
buf: *[completion_buf_len:0]u8,
) bool {
const ends_ws = input[input.len - 1] == ' ';
const v = valueAt(schema, body, ends_ws) orelse return false;
const prefix = input[0 .. input.len - v.partial.len];
if (schema.tool == .getEnv) {
var name_buf: [2048]u8 = undefined;
const names = lpEnvNameList(&name_buf) orelse return true;
for (names) |name| addPrefixedCompletion(cenv, buf, input, prefix, name, "", v.partial);
return true;
}
if (v.field.enum_values.len == 0) return false;
for (v.field.enum_values) |val| addPrefixedCompletion(cenv, buf, input, prefix, val, "", v.partial);
return true;
}
fn addPartialKeyCompletions(
cenv: ?*c.ic_completion_env_t,
input: []const u8,
body: []const u8,
schema: *const Schema,
buf: *[completion_buf_len:0]u8,
) void {
std.debug.assert(input.len > 0);
const ends_ws = input[input.len - 1] == ' ';
const a = analyzeBody(schema, body, ends_ws);
// Without a partial AND without trailing whitespace, the user is mid-typing
// a positional value or some other non-completable state — bail.
if (a.partial_key == null and !ends_ws) return;
const partial = a.partial_key orelse "";
const prefix = input[0 .. input.len - partial.len];
for (schema.hints) |slot| {
if (a.isUsed(slot.name)) continue;
addPrefixedCompletion(cenv, buf, input, prefix, slot.name, "=", partial);
}
}
fn addMetaValueCompletions(
state: *State,
cenv: ?*c.ic_completion_env_t,
input: []const u8,
body: []const u8,
meta: *const SlashCommand.MetaCommand,
buf: *[completion_buf_len:0]u8,
) void {
// Past the first positional arg — don't offer value completions anymore.
if (std.mem.indexOfAny(u8, body, &std.ascii.whitespace) != null) return;
const prefix = input[0 .. input.len - body.len];
if (meta.tag == .load or meta.tag == .save) {
addPathCompletions(cenv, input, body, prefix, buf);
return;
}
// `/provider` / `/model` candidates are resolved at runtime, not in `meta.values`.
if (state.completion_source) |src| switch (meta.tag) {
.provider, .model => {
var name_buf: [512]u8 = undefined;
var fba: std.heap.FixedBufferAllocator = .init(&name_buf);
const names = if (meta.tag == .provider)
src.providers(src.context, fba.allocator())
else
src.models(src.context, fba.allocator());
for (names) |v| addPrefixedCompletion(cenv, buf, input, prefix, v, "", body);
return;
},
else => {},
};
for (meta.values) |v| addPrefixedCompletion(cenv, buf, input, prefix, v, "", body);
}
/// Directory entries whose basename completes the partial path `body`
/// (`dir/ba` → entries of `dir/` prefix-matching `ba`). Returned names
/// borrow the iterator; use before the next `next()`.
const PathMatchIterator = struct {
dir: std.fs.Dir,
it: std.fs.Dir.Iterator,
/// `body` up to and including its last `/`; kept verbatim in candidates.
dir_part: []const u8,
base: []const u8,
const Match = struct { name: []const u8, is_dir: bool };
fn init(body: []const u8) ?PathMatchIterator {
const slash = std.mem.lastIndexOfScalar(u8, body, '/');
const dir_part = if (slash) |i| body[0 .. i + 1] else "";
const open_path = if (dir_part.len == 0) "." else dir_part;
const dir = std.fs.cwd().openDir(open_path, .{ .iterate = true }) catch return null;
return .{
.dir = dir,
.it = dir.iterate(),
.dir_part = dir_part,
.base = body[dir_part.len..],
};
}
fn deinit(self: *PathMatchIterator) void {
self.dir.close();
}
fn next(self: *PathMatchIterator) ?Match {
while (self.it.next() catch return null) |entry| {
if (!std.ascii.startsWithIgnoreCase(entry.name, self.base)) continue;
return .{ .name = entry.name, .is_dir = entry.kind == .directory };
}
return null;
}
};
fn addPathCompletions(
cenv: ?*c.ic_completion_env_t,
input: []const u8,
body: []const u8,
prefix: []const u8,
buf: *[completion_buf_len:0]u8,
) void {
var matches = PathMatchIterator.init(body) orelse return;
defer matches.deinit();
var name_buf: [completion_buf_len]u8 = undefined;
while (matches.next()) |m| {
const suffix: []const u8 = if (m.is_dir) "/" else "";
const full = std.fmt.bufPrint(&name_buf, "{s}{s}", .{ matches.dir_part, m.name }) catch continue;
addPrefixedCompletion(cenv, buf, input, prefix, full, suffix, body);
}
}
/// LP_* env var names (sorted) written into `buf`; null on enumeration failure.
/// Returned slices borrow `buf`, which must outlive them.
fn lpEnvNameList(buf: []u8) ?[]const []const u8 {
var fba: std.heap.FixedBufferAllocator = .init(buf);
return browser_tools.lpEnvNames(fba.allocator()) catch null;
}
/// Completes `$LP_*` against the live process environment.
fn addEnvVarCompletions(
cenv: ?*c.ic_completion_env_t,
buf: *[completion_buf_len:0]u8,
input: []const u8,
) void {
const dollar = std.mem.lastIndexOfScalar(u8, input, '$') orelse return;
const partial = input[dollar + 1 ..];
for (partial) |ch| {
if (!std.ascii.isAlphanumeric(ch) and ch != '_') return;
}
var name_buf: [2048]u8 = undefined;
const names = lpEnvNameList(&name_buf) orelse return;
if (names.len == 0) return;
const head = input[0 .. dollar + 1];
for (names) |name| addPrefixedCompletion(cenv, buf, input, head, name, "", partial);
}
fn completionCallback(cenv: ?*c.ic_completion_env_t, prefix: [*c]const u8) callconv(.c) void {
const state: *State = @ptrCast(@alignCast(c.ic_completion_arg(cenv) orelse return));
const input = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(prefix)), 0);
var buf: [completion_buf_len:0]u8 = undefined;
// `/help <name>`: arg is a command name, not a value — skip env-var fallthrough.
if (parseHelpArgPrefix(input)) |partial| {
for (SlashCommand.all_names) |name| addPrefixedCompletion(cenv, &buf, input, help_arg_prefix, name, "", partial);
return;
}
if (input.len == 0) return;
const has_space = std.mem.indexOfScalar(u8, input, ' ') != null;
const inside_block = Schema.hasUnclosedTripleQuote(input);
if (input[0] == '/') {
if (!has_space) {
const partial = input[1..];
// Trailing space on commands with params hands off to the hinter,
// which renders the full ` <url> [timeout=…]` template uniformly
// whether the name was typed or Tab-completed.
for (SlashCommand.all_names) |name| {
const suffix: []const u8 = if (slashHasParams(name)) " " else "";
addPrefixedCompletion(cenv, &buf, input, "/", name, suffix, partial);
}
return;
} else if (!inside_block) {
if (Schema.parseSlashCommand(input)) |parts| {
if (Schema.findByName(parts.name)) |schema| {
if (!addValueCompletions(cenv, input, parts.rest, schema, &buf)) {
addPartialKeyCompletions(cenv, input, parts.rest, schema, &buf);
}
} else if (SlashCommand.findMeta(parts.name)) |meta| {
addMetaValueCompletions(state, cenv, input, parts.rest, meta, &buf);
}
}
}
// Fall through so `value=$LP_` picks up env completions, including
// inside an unclosed `'''` block.
}
addEnvVarCompletions(cenv, &buf, input);
}
// File-scope so the buffer outlives the callback's stack frame. Isocline's
// `sbuf_replace` copies the returned string into its own stringbuf, so
// overwriting this on the next invocation is safe. Single-threaded: isocline's
// edit loop runs on the main thread, and we have one Terminal instance.
var hint_buf: [completion_buf_len:0]u8 = undefined;
fn hintsCallback(input_c: [*c]const u8, arg: ?*anyopaque) callconv(.c) [*c]const u8 {
const state: *State = @ptrCast(@alignCast(arg orelse return null));
const input = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(input_c)), 0);
// JS mode: the buffer is raw JS, so slash/kv hints don't apply.
if (state.js_mode) return null;
if (input.len == 0) return null;
if (parseHelpArgPrefix(input)) |partial| return ghostFirstMatch(&SlashCommand.all_names, partial, "");
// Inside an open `'''…'''` body the buffer is script text, not kv args.
if (Schema.hasUnclosedTripleQuote(input)) return null;
if (std.mem.eql(u8, input, "/")) return ghostFirstMatch(&SlashCommand.all_names, "", "");
if (Schema.parseSlashCommand(input)) |parts| {
const ends_ws = input[input.len - 1] == ' ';
if (Schema.findByName(parts.name)) |schema| {
return renderSchemaHint(schema, parts.rest, ends_ws);
}
if (SlashCommand.findMeta(parts.name)) |meta| {
return renderMetaHint(state, meta, parts.rest, ends_ws);
}
if (std.mem.indexOfScalar(u8, input, ' ') == null) {
return ghostFirstMatch(&SlashCommand.all_names, parts.name, "");
}
return null;
}
// Non-slash lines are natural-language prompts to the LLM (REPL only).
// No syntactic hint to render — the LLM sees the line verbatim.
return null;
}
/// Join `fragments` into `hint_buf` with single-space separators, prefixed by
/// `lead` (typically `""` or `" "`). Null-terminates and returns the isocline
/// C pointer, or null when nothing to render or the buffer would overflow.
fn writeHints(lead: []const u8, fragments: []const []const u8) [*c]const u8 {
if (fragments.len == 0) return null;
const cap = hint_buf.len - 1;
if (lead.len > cap) return null;
@memcpy(hint_buf[0..lead.len], lead);
var pos: usize = lead.len;
for (fragments, 0..) |frag, i| {
if (i > 0) {
if (pos + 1 > cap) return null;
hint_buf[pos] = ' ';
pos += 1;
}
if (pos + frag.len > cap) return null;
@memcpy(hint_buf[pos..][0..frag.len], frag);
pos += frag.len;
}
hint_buf[pos] = 0;
return @ptrCast(&hint_buf);
}
/// Ghosts a meta command's argument: providers resolve synchronously, `/model`
/// needs a blocking fetch (placeholder until committed), static values match
/// `meta.values`.
fn renderMetaHint(state: *State, meta: *const SlashCommand.MetaCommand, body: []const u8, ends_ws: bool) [*c]const u8 {
if (meta.hint.len == 0) return null;
if (ends_ws and body.len != 0) return null; // value already committed
if (state.completion_source) |src| {
var name_buf: [512]u8 = undefined;
var fba: std.heap.FixedBufferAllocator = .init(&name_buf);
if (meta.tag == .provider) {
const lead: []const u8 = if (body.len == 0 and !ends_ws) " " else "";
return ghostFirstMatch(src.providers(src.context, fba.allocator()), body, lead);
}
if (meta.tag == .model and (ends_ws or body.len != 0)) {
return ghostFirstMatch(src.models(src.context, fba.allocator()), body, "");
}
}
if (body.len == 0) {
var frags: [1][]const u8 = .{meta.hint};
return writeHints(if (ends_ws) "" else " ", &frags);
}
if (ends_ws) return null;
if (meta.tag == .load or meta.tag == .save) return ghostPathFirstMatch(body);
return ghostFirstMatch(meta.values, body, "");
}
fn ghostPathFirstMatch(body: []const u8) [*c]const u8 {
var matches = PathMatchIterator.init(body) orelse return null;
defer matches.deinit();
const m = matches.next() orelse return null;
const suffix: []const u8 = if (m.is_dir) "/" else "";
const text = std.fmt.bufPrintZ(&hint_buf, "{s}{s}", .{ m.name[matches.base.len..], suffix }) catch return null;
return text.ptr;
}
/// Ghosts `lead` + the suffix of the first `names` entry that prefix-matches
/// `body`.
fn ghostFirstMatch(names: []const []const u8, body: []const u8, lead: []const u8) [*c]const u8 {
for (names) |v| {
if (!std.ascii.startsWithIgnoreCase(v, body)) continue;
const text = std.fmt.bufPrintZ(&hint_buf, "{s}{s}", .{ lead, v[body.len..] }) catch return null;
return text.ptr;
}
return null;
}
/// Renders `<required>` and `[optional=…]` for each unused field, or
/// `<keyname>=…` when the user is typing a key prefix.
fn renderSchemaHint(schema: *const Schema, body: []const u8, ends_ws: bool) [*c]const u8 {
// Ghost a matching enum value once the user is typing one. A bare leading
// positional with nothing typed keeps the `<state> …` template below — more
// informative than ghosting one arbitrary value.
if (valueAt(schema, body, ends_ws)) |v| {
if (v.field.enum_values.len > 0 and (v.kv or v.partial.len > 0)) {
return ghostFirstMatch(v.field.enum_values, v.partial, "");
}
// getEnv's `name` ghosts a live LP_* var, like /provider ghosts a provider.
if (schema.tool == .getEnv) {
var name_buf: [2048]u8 = undefined;
if (lpEnvNameList(&name_buf)) |names| {
const lead: []const u8 = if (v.partial.len == 0 and !ends_ws) " " else "";
if (ghostFirstMatch(names, v.partial, lead)) |hint| return hint;
}
}
}
const a = analyzeBody(schema, body, ends_ws);
if (a.partial_key) |pk| {
for (schema.hints) |slot| {
if (a.isUsed(slot.name)) continue;
if (!std.ascii.startsWithIgnoreCase(slot.name, pk)) continue;
const text = std.fmt.bufPrintZ(&hint_buf, "{s}=…", .{slot.name[pk.len..]}) catch return null;
return text.ptr;
}
return null;
}
var frags: [Schema.max_hint_slots][]const u8 = undefined;
var n: usize = 0;
for (schema.hints) |slot| {
if (a.isUsed(slot.name)) continue;
frags[n] = slot.fragment;
n += 1;
}
return writeHints(if (ends_ws) "" else " ", frags[0..n]);
}
/// Byte offsets to ic_highlight are not UTF-8 code points; safe because we
/// only tokenize on ASCII boundaries (whitespace, quotes, `=`, `$`).
fn highlighterCallback(henv: ?*c.ic_highlight_env_t, input: [*c]const u8, arg: ?*anyopaque) callconv(.c) void {
const state: *State = @ptrCast(@alignCast(arg orelse return));
const text = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(input)), 0);
// JS mode: the buffer is raw JS, so highlight it as such (plus `$LP_*` refs).
if (state.js_mode) {
highlightJavaScript(henv, text);
return;
}
const cmd_start = std.mem.indexOfNonePos(u8, text, 0, &std.ascii.whitespace) orelse return;
var i = cmd_start;
while (i < text.len and !std.ascii.isWhitespace(text[i])) i += 1;
const cmd = text[cmd_start..i];
// Commit to red once the cursor moves past the token, OR as soon as the
// prefix cannot complete to any known name.
const closed = i < text.len;
if (cmd.len > 0 and cmd[0] == '/') {
c.ic_highlight(henv, @intCast(cmd_start), 1, style_slash.ptr);
if (cmd.len > 1) {
const name = cmd[1..];
const style: ?[*:0]const u8 = if (isKnownSlashName(name))
style_slash
else if (closed or !slashHasPrefix(name))
style_err
else
null;
if (style) |s| c.ic_highlight(henv, @intCast(cmd_start + 1), @intCast(cmd.len - 1), s);
}
highlightSlashArgs(henv, text, i);
} else {
// No leading `/`: a natural-language prompt, so no command validation.
// Start at `cmd_start`, not `i`, so a `$LP_*` first token highlights too.
highlightDollarVars(henv, text, cmd_start);
}
}
fn isKnownSlashName(name: []const u8) bool {
for (SlashCommand.all_names) |n| {
if (std.ascii.eqlIgnoreCase(n, name)) return true;
}
return false;
}
fn slashHasPrefix(name: []const u8) bool {
for (SlashCommand.all_names) |n| {
if (std.ascii.startsWithIgnoreCase(n, name)) return true;
}
return false;
}
fn slashHasParams(name: []const u8) bool {
if (Schema.findByName(name)) |s| return s.hints.len > 0;
if (SlashCommand.findMeta(name)) |m| return m.hint.len > 0;
return false;
}
fn highlightBareToken(henv: ?*c.ic_highlight_env_t, text: []const u8, start: usize, end: usize) void {
if (start >= end) return;
const tok = text[start..end];
if (tok[0] == '$') {
c.ic_highlight(henv, @intCast(start), @intCast(end - start), style_var.ptr);
return;
}
if (lp.URL.isCompleteHTTPUrl(tok)) {
c.ic_highlight(henv, @intCast(start), @intCast(end - start), style_url.ptr);
return;
}
if (std.fmt.parseFloat(f64, tok)) |_| {
c.ic_highlight(henv, @intCast(start), @intCast(end - start), style_num.ptr);
} else |_| {}
}
/// Highlight `$LP_*` tokens appearing from `start` onward. `${…}` is not a
/// prompt substitution form, so interpolation stays off.
fn highlightDollarVars(henv: ?*c.ic_highlight_env_t, text: []const u8, start: usize) void {
var i = start;
while (js_highlight.nextDollarRef(text, i, text.len, false)) |ref| {
c.ic_highlight(henv, @intCast(ref.start), @intCast(ref.end - ref.start), style_var.ptr);
i = ref.end;
}
}
/// Paints `js_highlight` spans onto isocline's cell attributes.
const IcSink = struct {
henv: ?*c.ic_highlight_env_t,
pub fn emit(self: IcSink, start: usize, len: usize, kind: js_highlight.Kind) void {
c.ic_highlight(self.henv, @intCast(start), @intCast(len), kind_styles[@intFromEnum(kind)].ptr);
}
};
/// Highlight the buffer as JavaScript. Byte offsets are safe (see
/// `highlighterCallback`): every token boundary is an ASCII byte and non-ASCII
/// bytes advance singly without being highlighted.
fn highlightJavaScript(henv: ?*c.ic_highlight_env_t, text: []const u8) void {
const sink: IcSink = .{ .henv = henv };
_ = js_highlight.tokenize(text, .normal, sink);
}
fn highlightSlashArgs(henv: ?*c.ic_highlight_env_t, text: []const u8, start: usize) void {
var i = start;
while (std.mem.indexOfNonePos(u8, text, i, &std.ascii.whitespace)) |tok_start| {
i = tok_start;
if (text[i] == '\'' or text[i] == '"') {
i = Schema.quotedSpanEnd(text, i);
c.ic_highlight(henv, @intCast(tok_start), @intCast(i - tok_start), style_string.ptr);
continue;
}
while (i < text.len and !std.ascii.isWhitespace(text[i]) and text[i] != '=') i += 1;
const key_end = i;
if (i < text.len and text[i] == '=') {
c.ic_highlight(henv, @intCast(tok_start), @intCast(key_end - tok_start), style_key.ptr);
i += 1;
const val_start = i;
if (i < text.len and (text[i] == '\'' or text[i] == '"')) {
i = Schema.quotedSpanEnd(text, i);
c.ic_highlight(henv, @intCast(val_start), @intCast(i - val_start), style_string.ptr);
} else {
while (i < text.len and !std.ascii.isWhitespace(text[i])) i += 1;
highlightBareToken(henv, text, val_start, i);
}
}
}
}
test "valueAt: enum field via positional and kv, partial and empty" {
const schema = Schema.findByName("waitForState").?;
// Leading positional, nothing typed: empty partial, not kv.
{
const v = valueAt(schema, "", true).?;
try std.testing.expect(v.field.enum_values.len > 0);
try std.testing.expectEqualStrings("", v.partial);
try std.testing.expect(!v.kv);
}
// Leading positional, partial value.
{
const v = valueAt(schema, "net", false).?;
try std.testing.expectEqualStrings("net", v.partial);
try std.testing.expect(!v.kv);
}
// Explicit `state=` with empty value.
{
const v = valueAt(schema, "state=", false).?;
try std.testing.expectEqualStrings("", v.partial);
try std.testing.expect(v.kv);
}
// Explicit `state=net`.
{
const v = valueAt(schema, "state=net", false).?;
try std.testing.expectEqualStrings("net", v.partial);
try std.testing.expect(v.kv);
}
// Past the only required field — timeout is not an enum, so no value match.
{
const v = valueAt(schema, "networkidle timeout=", false).?;
try std.testing.expectEqual(@as(usize, 0), v.field.enum_values.len);
}
}
test "valueAt: getEnv binds the leading positional to its optional name field" {
const schema = Schema.findByName("getEnv").?;
// `/getEnv ` — fresh positional, even though `name` is optional (0 required).
{
const v = valueAt(schema, "", true).?;
try std.testing.expectEqualStrings("name", v.field.name);
try std.testing.expectEqualStrings("", v.partial);
try std.testing.expect(!v.kv);
}
// `/getEnv LP_H` — partial value bound to `name`.
{
const v = valueAt(schema, "LP_H", false).?;
try std.testing.expectEqualStrings("name", v.field.name);
try std.testing.expectEqualStrings("LP_H", v.partial);
}
}
test "renderSchemaHint: ghosts enum value once typing, keeps template when empty" {
const schema = Schema.findByName("waitForState").?;
const hintStr = struct {
fn f(p: [*c]const u8) ?[]const u8 {
if (p == null) return null;
return std.mem.sliceTo(@as([*:0]const u8, @ptrCast(p)), 0);
}
}.f;
// Nothing typed yet: the `<state> …` template, not an arbitrary value.
try std.testing.expectEqualStrings(" <state> [timeout=…]", hintStr(renderSchemaHint(schema, "", false)).?);
// Partial positional ghosts the suffix of the first matching value.
try std.testing.expectEqualStrings("workalmostidle", hintStr(renderSchemaHint(schema, "net", false)).?);
// `state=` ghosts the first value.
try std.testing.expectEqualStrings("load", hintStr(renderSchemaHint(schema, "state=", false)).?);
}

View File

@@ -26,7 +26,7 @@ const std = @import("std");
const zenai = @import("zenai");
const lp = @import("lightpanda");
const Config = lp.Config;
const Terminal = @import("Terminal.zig");
const picker = @import("picker.zig");
const string = @import("../string.zig");
const Credentials = zenai.provider.Credentials;
@@ -156,14 +156,14 @@ pub fn resolveCredentials(allocator: std.mem.Allocator, opts: Config.Agent, reme
}
// A single key needs no choice; non-interactive callers (--list-models,
// one-shot tasks, pipes) must not block on a prompt — take the first.
if (!allow_pick or found.len == 1 or !Terminal.interactiveTty()) {
if (!allow_pick or found.len == 1 or !picker.interactiveTty()) {
return try finishResolved(allocator, found[0], .detected);
}
var names: [zenai.provider.default_candidates.len][:0]const u8 = undefined;
for (found, 0..) |cred, i| names[i] = @tagName(cred.provider);
std.debug.print("\n", .{});
const idx = Terminal.promptNumberedChoice(" Select a provider:", names[0..found.len], 0) catch {
const idx = picker.promptNumberedChoice(" Select a provider:", names[0..found.len], 0) catch {
return try finishResolved(allocator, found[0], .detected);
};
return try finishResolved(allocator, found[idx], .picked);
@@ -186,12 +186,14 @@ pub const remembered_path = ".lp-agent.zon";
/// disabled the LLM (`/provider null`), so the REPL starts in basic mode without
/// re-prompting. `effort`/`verbosity` are optional so files predating them still
/// parse; null means "use the mode default" (see `Agent.resolveEffort` /
/// `Agent.resolveVerbosity`).
/// `Agent.resolveVerbosity`). `stream` is likewise optional: null means "use the
/// default" (see `resolveStream`).
pub const Remembered = struct {
provider: ?Config.AiProvider = null,
model: []const u8,
effort: ?Config.Effort = null,
verbosity: ?Config.AgentVerbosity = null,
stream: ?bool = null,
};
pub fn loadRemembered(allocator: std.mem.Allocator) ?Remembered {
@@ -269,6 +271,13 @@ pub fn resolveVerbosity(opts: Config.Agent, remembered: ?Remembered) Config.Agen
return Config.agentVerbosity(opts);
}
/// Precedence: remembered `.lp-agent.zon` value > default (on). Streaming has no
/// CLI flag — the REPL `/stream` command toggles and persists it.
pub fn resolveStream(remembered: ?Remembered) bool {
if (remembered) |r| if (r.stream) |s| return s;
return true;
}
pub const ReconciledModel = union(enum) {
/// Owned by the allocator passed to reconcileModel.
use: []u8,
@@ -329,4 +338,19 @@ test "parseRemembered: valid file round-trips" {
defer std.zon.parse.free(testing.allocator, remembered);
try testing.expect(remembered.provider == null);
try testing.expectString("some-model", remembered.model);
// Absent `stream` is null so pre-streaming files still fall back to the default.
try testing.expect(remembered.stream == null);
}
test "parseRemembered: stream field round-trips" {
const remembered = parseRemembered(testing.allocator, ".{ .model = \"m\", .stream = false }").?;
defer std.zon.parse.free(testing.allocator, remembered);
try testing.expect(remembered.stream == false);
}
test "resolveStream: default on, remembered wins" {
try testing.expect(resolveStream(null));
try testing.expect(resolveStream(.{ .model = "m", .stream = null }));
try testing.expect(resolveStream(.{ .model = "m", .stream = true }));
try testing.expect(!resolveStream(.{ .model = "m", .stream = false }));
}

View File

@@ -22,7 +22,7 @@
const std = @import("std");
const lp = @import("lightpanda");
const Terminal = @import("Terminal.zig");
const ansi = @import("ansi.zig");
// A pre-colored (truecolor braille) panda. Each line carries its own ANSI and
// resets at the end, so it prints as-is; non-empty lines are the visible rows.
@@ -82,30 +82,28 @@ comptime {
/// Prints the welcome banner: the logo on the left with the title and command
/// hints beside it, vertically centered. `llm_active` picks the tagline.
pub fn print(llm_active: bool) void {
const a = Terminal.ansi;
var version_buf: [192]u8 = undefined;
const version: []const u8 = std.fmt.bufPrint(&version_buf, a.dim ++ "{s}" ++ a.reset, .{lp.build_config.version}) catch "";
const version: []const u8 = std.fmt.bufPrint(&version_buf, ansi.dim ++ "{s}" ++ ansi.reset, .{lp.build_config.version}) catch "";
var lines: [9][]const u8 = undefined;
var n: usize = 0;
lines[n] = a.bold ++ "Lightpanda Agent" ++ a.reset;
lines[n] = ansi.bold ++ "Lightpanda Agent" ++ ansi.reset;
n += 1;
lines[n] = version;
n += 1;
lines[n] = "";
n += 1;
if (llm_active) {
lines[n] = a.italic ++ banner_tagline_llm ++ a.reset;
lines[n] = ansi.italic ++ banner_tagline_llm ++ ansi.reset;
n += 1;
} else {
lines[n] = a.italic ++ banner_tagline_basic ++ a.reset;
lines[n] = ansi.italic ++ banner_tagline_basic ++ ansi.reset;
n += 1;
lines[n] = a.dim ++ banner_setup ++ a.reset;
lines[n] = ansi.dim ++ banner_setup ++ ansi.reset;
n += 1;
}
inline for (banner_hints) |t| {
lines[n] = a.dim ++ t ++ a.reset;
lines[n] = ansi.dim ++ t ++ ansi.reset;
n += 1;
}
const text = lines[0..n];

View File

@@ -28,7 +28,7 @@ const Watchdog = @import("../Watchdog.zig");
const Session = @import("Session.zig");
const Selector = @import("webapi/selector/Selector.zig");
const Viewport = @import("Viewport.zig");
const HttpClient = @import("HttpClient.zig");
const HttpClient = @import("../network/HttpClient.zig");
const PermissionState = @import("webapi/Permissions.zig").State;
const ArenaPool = App.ArenaPool;

View File

@@ -28,6 +28,7 @@ const Node = @import("webapi/Node.zig");
const Event = @import("webapi/Event.zig");
const EventTarget = @import("webapi/EventTarget.zig");
const Element = @import("webapi/Element.zig");
const ShadowRoot = @import("webapi/ShadowRoot.zig");
const log = lp.log;
const Allocator = std.mem.Allocator;
@@ -111,10 +112,27 @@ pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, co
switch (target._type) {
.node => |node| try self.dispatchNode(node, event, opts),
.xhr => |xhr| try self.dispatchDirect(target, event, xhr.inlineHandler(event._type_string), .{ .context = "dispatch" }),
.window => |w| try self.dispatchDirect(target, event, windowInlineHandler(w, event._type_string), .{ .context = "dispatch" }),
else => try self.dispatchDirect(target, event, null, .{ .context = "dispatch" }),
}
}
// Resolves the Window's property event handler for the given event type.
fn windowInlineHandler(window: *@import("webapi/Window.zig"), typ: lp.String) ?js.Function.Global {
const global_event_handlers = @import("webapi/global_event_handlers.zig");
const handler_type = global_event_handlers.fromEventType(typ.str()) orelse return null;
return switch (handler_type) {
.onerror => window._on_error,
.onload => window._on_load,
.onblur => window._on_blur,
.onfocus => window._on_focus,
.onresize => window._on_resize,
.onscroll => window._on_scroll,
else => null,
};
}
// There are a lot of events that can be attached via addEventListener or as
// a property, like the XHR events, or window.onload. You might think that the
// property is just a shortcut for calling addEventListener, but they are distinct.
@@ -124,7 +142,7 @@ pub const DispatchDirectOptions = EventManagerBase.DispatchDirectOptions;
// Direct dispatch for non-DOM targets (Window, XHR, AbortSignal) or DOM nodes with
// property handlers. No propagation - just calls the handler and registered listeners.
// Handler can be: null, ?js.Function.Global, ?js.Function.Temp, or js.Function
// Handler can be: null, ?js.Function.Global or js.Function
pub fn dispatchDirect(self: *EventManager, target: *EventTarget, event: *Event, handler: anytype, comptime opts: DispatchDirectOptions) !void {
const frame = self.frame;
@@ -144,12 +162,19 @@ pub fn hasDirectListeners(self: *EventManager, target: *EventTarget, typ: []cons
}
fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts: DispatchOpts) !void {
const ShadowRoot = @import("webapi/ShadowRoot.zig");
{
const et = target.asEventTarget();
event._target = et;
event._dispatch_target = et; // Store original target for composedPath()
// Retarget the relatedTarget against the dispatch target up front
// (DOM dispatch step 4); listeners observe the retargeted value and
// it survives the dispatch.
if (event.relatedTargetPtr()) |related_ptr| {
if (related_ptr.*) |related| {
related_ptr.* = getAdjustedTarget(related, et);
}
}
}
const frame = self.frame;
@@ -179,6 +204,7 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
var path_len: usize = 0;
var node_path_len: usize = 0;
var path_buffer: [128]*EventTarget = undefined;
var clear_targets = false;
// Defer runs even on early return - ensures event phase is reset
// and default actions execute (unless prevented)
@@ -187,7 +213,14 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
event._current_target = null;
event._stop_propagation = false;
event._stop_immediate_propagation = false;
if (event._needs_retargeting and node_path_len > 0) {
if (clear_targets) {
// Don't leak nodes living in a shadow tree: reset the targets
// (decided on the pre-dispatch tree, see below).
event._target = null;
if (event.relatedTargetPtr()) |related_ptr| {
related_ptr.* = null;
}
} else if (event._needs_retargeting and node_path_len > 0) {
const adjusted = getAdjustedTarget(event._dispatch_target, path_buffer[node_path_len - 1]);
event._target = if (rootIsShadowRoot(adjusted)) null else adjusted;
}
@@ -200,9 +233,16 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
if (event._prevent_default) {
// can't return in a defer (╯°□°)╯︵ ┻━┻
} else if (event._type_string.eql(comptime .wrap("click"))) {
Frame.user_input.handleClick(frame, target) catch |err| {
log.warn(.event, "frame.click", .{ .err = err });
};
// Per spec, only a MouseEvent "click" is an activation event, and
// the activation target is the nearest inclusive ancestor with
// activation behavior (ancestors only for bubbling events).
if (event.is(@import("webapi/event/MouseEvent.zig")) != null) {
if (Frame.user_input.findClickActivationTarget(target, event._bubbles)) |activation_target| {
Frame.user_input.handleClick(frame, activation_target) catch |err| {
log.warn(.event, "frame.click", .{ .err = err });
};
}
}
} else if (event._type_string.eql(comptime .wrap("keydown"))) {
Frame.user_input.handleKeydown(frame, target, event) catch |err| {
log.warn(.event, "frame.keydown", .{ .err = err });
@@ -258,6 +298,26 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
}
}
// DOM dispatch: decide up front — on the pre-dispatch tree, so listener
// mutations can't affect it — whether target and relatedTarget must be
// reset after dispatch because they would expose nodes inside a shadow
// tree.
if (node_path_len > 0) {
const last = path_buffer[node_path_len - 1];
if (event._needs_retargeting) {
if (rootIsShadowRoot(getAdjustedTarget(event._dispatch_target, last))) {
clear_targets = true;
}
}
if (event.relatedTargetPtr()) |related_ptr| {
if (related_ptr.*) |related| {
if (rootIsShadowRoot(getAdjustedTarget(related, last))) {
clear_targets = true;
}
}
}
}
const path = path_buffer[0..path_len];
// Phase 1: Capturing phase (root → target, excluding target)
@@ -284,11 +344,18 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
was_handled = true;
event._current_target = target_et;
const prev_current_event = window._current_event;
window._current_event = currentEventForTarget(target_et, event);
defer window._current_event = prev_current_event;
// Inline handlers (e.g. onclick property) follow the same "report,
// don't propagate" rule as addEventListener listeners — see Listener.run.
ls.toLocal(inline_handler).callWithThis(void, target_et, .{event}) catch |err| {
log.warn(.event, "inline handler", .{ .err = err });
var caught: js.TryCatch.Caught = undefined;
const handler_return: ?js.Value = ls.toLocal(inline_handler).tryCallWithThis(js.Value, target_et, .{event}, &caught) catch |err| ret: {
log.warn(.event, "inline handler", .{ .err = err, .caught = caught });
break :ret null;
};
processHandlerReturnValue(event, handler_return);
if (event._stop_propagation) {
return;
@@ -299,8 +366,18 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
}
}
// Per spec, the target is invoked once during the capturing iteration
// and once during the bubbling iteration, each with its own snapshot
// of the listener list: a bubble listener added while running the
// target's capture listeners must run.
if (self.base.getListeners(target_et, event._type_string)) |list| {
try self.dispatchPhase(list, target_et, event, &was_handled, &ls.local, comptime .init(null, opts));
try self.dispatchPhase(list, target_et, event, &was_handled, &ls.local, comptime .init(true, opts));
if (event._stop_propagation) {
return;
}
}
if (self.base.getListeners(target_et, event._type_string)) |list| {
try self.dispatchPhase(list, target_et, event, &was_handled, &ls.local, comptime .init(false, opts));
if (event._stop_propagation) {
return;
}
@@ -320,14 +397,21 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
was_handled = true;
event._current_target = current_target;
const prev_current_event = window._current_event;
window._current_event = currentEventForTarget(current_target, event);
defer window._current_event = prev_current_event;
const original_target = event._target;
if (event._needs_retargeting) {
event._target = getAdjustedTarget(original_target, current_target);
}
ls.toLocal(inline_handler).callWithThis(void, current_target, .{event}) catch |err| {
log.warn(.event, "inline handler", .{ .err = err });
var caught: js.TryCatch.Caught = undefined;
const handler_return: ?js.Value = ls.toLocal(inline_handler).tryCallWithThis(js.Value, current_target, .{event}, &caught) catch |err| ret: {
log.warn(.event, "inline handler", .{ .err = err, .caught = caught });
break :ret null;
};
processHandlerReturnValue(event, handler_return);
if (event._needs_retargeting) {
event._target = original_target;
@@ -348,6 +432,19 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts
}
}
fn processHandlerReturnValue(event: *Event, handler_return: ?js.Value) void {
const ret = handler_return orelse return;
if (ret.isFalse() and !event._type_string.eql(comptime .wrap("error"))) {
event.preventDefault();
}
}
// Per spec ("invocation target in shadow tree"), window.event is left
// undefined while invoking listeners whose target lives in a shadow tree.
fn currentEventForTarget(target: *EventTarget, event: *Event) ?*Event {
return if (rootIsShadowRoot(target)) null else event;
}
const DispatchPhaseOpts = struct {
capture_only: ?bool = null,
apply_ignore: bool = false,
@@ -364,6 +461,11 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
const frame = self.frame;
const base = &self.base;
const window = frame.window;
const prev_current_event = window._current_event;
window._current_event = currentEventForTarget(current_target, event);
defer window._current_event = prev_current_event;
// Track dispatch depth for deferred removal
base.dispatch_depth += 1;
defer {
@@ -465,6 +567,17 @@ fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?j
// Look up the inline handler for this target
const html_element = switch (target._type) {
.node => |n| n.is(Element.Html) orelse return null,
// The Window stores its event handlers in dedicated fields; an event
// propagating to the window must fire them too.
.window => |w| return switch (handler_type) {
.onerror => w._on_error,
.onload => w._on_load,
.onblur => w._on_blur,
.onfocus => w._on_focus,
.onresize => w._on_resize,
.onscroll => w._on_scroll,
else => null,
},
else => return null,
};
@@ -477,8 +590,6 @@ fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?j
// DOM spec "retarget": walk original_target out of shadow trees until the
// node is visible from current_target's tree.
fn getAdjustedTarget(original_target: ?*EventTarget, current_target: *EventTarget) ?*EventTarget {
const ShadowRoot = @import("webapi/ShadowRoot.zig");
const orig_node = switch ((original_target orelse return null)._type) {
.node => |n| n,
else => return original_target,
@@ -500,8 +611,6 @@ fn getAdjustedTarget(original_target: ?*EventTarget, current_target: *EventTarge
}
fn isShadowIncludingInclusiveAncestor(ancestor: *Node, node: *Node) bool {
const ShadowRoot = @import("webapi/ShadowRoot.zig");
var n: ?*Node = node;
while (n) |cur| {
if (cur == ancestor) {
@@ -519,8 +628,6 @@ fn isShadowIncludingInclusiveAncestor(ancestor: *Node, node: *Node) bool {
// Whether the target's tree root (without crossing shadow boundaries) is a
// shadow root. Used for the spec's post-dispatch "clear targets" step.
fn rootIsShadowRoot(target_: ?*EventTarget) bool {
const ShadowRoot = @import("webapi/ShadowRoot.zig");
const target = target_ orelse return false;
var current: *Node = switch (target._type) {
.node => |n| n,
@@ -569,12 +676,18 @@ const ActivationState = struct {
const Input = Element.Html.Input;
fn create(event: *const Event, target: *Node, frame: *Frame) !?ActivationState {
fn create(event: *Event, target: *Node, frame: *Frame) !?ActivationState {
if (event._type_string.eql(comptime .wrap("click")) == false) {
return null;
}
const input = target.is(Element.Html.Input) orelse return null;
// Per spec, only a MouseEvent "click" is an activation event.
if (event.is(@import("webapi/event/MouseEvent.zig")) == null) {
return null;
}
const activation_node = Frame.user_input.findClickActivationTarget(target, event._bubbles) orelse return null;
const input = activation_node.is(Element.Html.Input) orelse return null;
if (input._input_type != .checkbox and input._input_type != .radio) {
return null;
}

View File

@@ -223,7 +223,7 @@ pub const DispatchDirectOptions = struct {
/// Direct dispatch for non-DOM targets. No propagation - just calls the property
/// handler and registered listeners. Caller is responsible for event ref counting.
/// Handler can be: null, ?js.Function.Global, ?js.Function.Temp, or js.Function
/// Handler can be: null, ?js.Function.Global or js.Function
pub fn dispatchDirect(
self: *EventManagerBase,
arena: Allocator,
@@ -287,11 +287,12 @@ pub fn dispatchDirect(
// Call the property handler (e.g., onmessage) if present
if (getFunction(handler, &ls.local)) |func| {
event._current_target = target;
_ = func.callWithThis(void, target, .{event}) catch |err| {
var caught: js.TryCatch.Caught = undefined;
_ = func.tryCallWithThis(void, target, .{event}, &caught) catch |err| {
if (err == error.JsException) {
event._listeners_did_throw = true;
} else {
log.warn(.event, opts.context, .{ .err = err });
log.warn(.event, opts.context, .{ .err = err, .caught = caught });
}
};
}
@@ -359,7 +360,6 @@ fn getFunction(handler: anytype, local: *const js.Local) ?js.Function {
}
return switch (T) {
js.Function => handler,
js.Function.Temp => local.toLocal(handler),
js.Function.Global => local.toLocal(handler),
else => @compileError("handler must be null or \\??js.Function(\\.(Temp|Global))?"),
};
@@ -435,12 +435,19 @@ pub const Listener = struct {
comptime context: []const u8,
) error{OutOfMemory}!void {
switch (self.function) {
.value => |value| local.toLocal(value).callWithThis(void, event._current_target.?, .{event}) catch |err| {
if (err == error.JsException) {
event._listeners_did_throw = true;
} else {
log.warn(.event, context, .{ .err = err });
}
.value => |value| {
var try_catch: js.TryCatch = undefined;
try_catch.init(local);
defer try_catch.deinit();
local.toLocal(value).callWithThisRethrow(void, event._current_target.?, .{event}) catch |err| switch (err) {
// The rethrow variant surfaces a thrown JS exception as
// TryCatchRethrow so our enclosing TryCatch holds it.
error.JsException, error.TryCatchRethrow => {
event._listeners_did_throw = true;
reportException(&try_catch, local);
},
else => log.warn(.event, context, .{ .err = err }),
};
},
.string => |string| {
const str = try arena.dupeZ(u8, string.str());
@@ -454,31 +461,64 @@ pub const Listener = struct {
},
.object => |obj_global| {
const obj = local.toLocal(obj_global);
const handle_event = obj.getFunction("handleEvent") catch |err| blk: {
// Getting "handleEvent" threw (e.g. a throwing getter).
var try_catch: js.TryCatch = undefined;
try_catch.init(local);
defer try_catch.deinit();
// Get(handleEvent) can run a getter: a thrown exception is
// reported like an exception from the listener itself.
const handle_event_value = obj.get("handleEvent") catch |err| {
if (err == error.JsException) {
event._listeners_did_throw = true;
reportException(&try_catch, local);
} else {
log.warn(.event, context, .{ .err = err });
}
break :blk null;
return;
};
if (handle_event) |handleEvent| {
handleEvent.callWithThis(void, obj, .{event}) catch |err| {
if (err == error.JsException) {
event._listeners_did_throw = true;
} else {
log.warn(.event, context, .{ .err = err });
}
};
} else {
// The listener was an object without the handleEvent function
// This is throwing a TypeError
if (!handle_event_value.isFunction()) {
// Per the callback interface invocation steps, a
// non-callable handleEvent is a reported TypeError.
event._listeners_did_throw = true;
reportExceptionValue(local, .{
.local = local,
.handle = local.isolate.createTypeError("handleEvent is not a function"),
});
return;
}
const handle_event = js.Function{
.local = local,
.handle = @ptrCast(handle_event_value.handle),
};
handle_event.callWithThisRethrow(void, obj, .{event}) catch |err| switch (err) {
error.JsException, error.TryCatchRethrow => {
event._listeners_did_throw = true;
reportException(&try_catch, local);
},
else => log.warn(.event, context, .{ .err = err }),
};
},
}
}
// Reports a listener exception to the relevant global (firing
// window.onerror / an "error" event) without stopping the dispatch.
fn reportException(try_catch: *js.TryCatch, local: *const js.Local) void {
const exc = try_catch.exceptionValue() orelse return;
reportExceptionValue(local, exc);
}
fn reportExceptionValue(local: *const js.Local, exc: js.Value) void {
switch (local.ctx.global) {
.frame => |frame| frame.window.reportError(exc, frame) catch |err| {
log.warn(.event, "listener report error", .{ .err = err });
},
.worker => {},
}
}
};
pub const Function = union(enum) {

View File

@@ -35,6 +35,8 @@ const EventTarget = @import("webapi/EventTarget.zig");
const XMLHttpRequestEventTarget = @import("webapi/net/XMLHttpRequestEventTarget.zig");
const Blob = @import("webapi/Blob.zig");
const AbstractRange = @import("webapi/AbstractRange.zig");
const DOMRect = @import("webapi/DOMRect.zig");
const DOMRectReadOnly = @import("webapi/DOMRectReadOnly.zig");
const log = lp.log;
const String = lp.String;
@@ -235,8 +237,9 @@ fn AutoPrototypeChain(comptime types: []const type) type {
fn eventInit(arena: Allocator, typ: String, value: anytype) !Event {
// Round to 2ms for privacy (browsers do this)
const raw_timestamp = @import("../datetime.zig").milliTimestamp(.monotonic);
const time_stamp = (raw_timestamp / 2) * 2;
// Same (already coarsened) clock as the performance time origin, so the
// timeStamp getter can report it relative to that origin.
const time_stamp = @import("webapi/Performance.zig").highResTimestamp();
return .{
._rc = .{},
@@ -292,6 +295,22 @@ pub fn abstractRange(_: *const Factory, arena: Allocator, child: anytype, frame:
return chain.get(1);
}
pub fn domRect(self: *Factory, rect: DOMRectReadOnly.Data) !*DOMRect {
const chain = try PrototypeChain(&.{ DOMRectReadOnly, DOMRect }).allocate(self._slab.allocator());
const base = chain.get(0);
base.* = .{
._type = .{ .mutable = chain.get(1) },
._x = rect.x,
._y = rect.y,
._width = rect.width,
._height = rect.height,
};
chain.setLeaf(1, DOMRect{ ._proto = base });
return chain.get(1);
}
pub fn node(self: *Factory, child: anytype) !*@TypeOf(child) {
const allocator = self._slab.allocator();
return try AutoPrototypeChain(
@@ -335,33 +354,49 @@ pub fn htmlMediaElement(self: *Factory, child: anytype) !*@TypeOf(child) {
}
pub fn svgElement(self: *Factory, tag_name: []const u8, child: anytype) !*@TypeOf(child) {
const allocator = self._slab.allocator();
const ChildT = @TypeOf(child);
if (ChildT == Element.Svg) {
return self.element(child);
}
const chain = try PrototypeChain(
&.{ EventTarget, Node, Element, Element.Svg, ChildT },
).allocate(allocator);
&.{ EventTarget, Node, Element, Element.Svg, @TypeOf(child) },
).allocate(self._slab.allocator());
try self.setSvgChainBase(chain, tag_name);
chain.setLeaf(4, child);
return chain.get(4);
}
pub fn svgGraphicsElement(self: *Factory, tag_name: []const u8, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ EventTarget, Node, Element, Element.Svg, Element.Svg.Graphics, @TypeOf(child) },
).allocate(self._slab.allocator());
try self.setSvgChainBase(chain, tag_name);
chain.setMiddle(4, Element.Svg.Graphics.Type);
chain.setLeaf(5, child);
return chain.get(5);
}
pub fn svgGeometryElement(self: *Factory, tag_name: []const u8, child: anytype) !*@TypeOf(child) {
const chain = try PrototypeChain(
&.{ EventTarget, Node, Element, Element.Svg, Element.Svg.Graphics, Element.Svg.Graphics.Geometry, @TypeOf(child) },
).allocate(self._slab.allocator());
try self.setSvgChainBase(chain, tag_name);
chain.setMiddle(4, Element.Svg.Graphics.Type);
chain.setMiddle(5, Element.Svg.Graphics.Geometry.Type);
chain.setLeaf(6, child);
return chain.get(6);
}
fn setSvgChainBase(self: *Factory, chain: anytype, tag_name: []const u8) !void {
chain.setRoot(EventTarget.Type);
chain.setMiddle(1, Node.Type);
chain.setMiddle(2, Element.Type);
// will never allocate, can't fail
const tag_name_str = String.init(self._arena, tag_name, .{}) catch unreachable;
// Manually set Element.Svg with the tag_name
chain.set(3, .{
._proto = chain.get(2),
._tag_name = tag_name_str,
._tag_name = try String.init(self._arena, tag_name, .{}),
._type = unionInit(Element.Svg.Type, chain.get(4)),
});
chain.setLeaf(4, child);
return chain.get(4);
}
pub fn xhrEventTarget(_: *const Factory, allocator: Allocator, child: anytype) !*@TypeOf(child) {

View File

@@ -42,6 +42,7 @@ const Event = @import("webapi/Event.zig");
const EventTarget = @import("webapi/EventTarget.zig");
const Element = @import("webapi/Element.zig");
const HtmlElement = @import("webapi/element/Html.zig");
const AnimatedString = @import("webapi/svg/AnimatedString.zig");
const Window = @import("webapi/Window.zig");
const Location = @import("webapi/Location.zig");
const Document = @import("webapi/Document.zig");
@@ -50,7 +51,9 @@ const Performance = @import("webapi/Performance.zig");
const Screen = @import("webapi/Screen.zig");
const VisualViewport = @import("webapi/VisualViewport.zig");
const AbstractRange = @import("webapi/AbstractRange.zig");
const DOMNodeIterator = @import("webapi/DOMNodeIterator.zig");
const Worker = @import("webapi/Worker.zig");
const MessagePort = @import("webapi/MessagePort.zig");
const CSSStyleSheet = @import("webapi/css/CSSStyleSheet.zig");
const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig");
const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig");
@@ -60,7 +63,7 @@ const popover = @import("webapi/element/popover.zig");
const slotting = @import("webapi/element/slotting.zig");
const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind;
const HttpClient = @import("HttpClient.zig");
const HttpClient = @import("../network/HttpClient.zig");
const sys_url = @import("../sys/url.zig");
const timestamp = @import("../datetime.zig").timestamp;
@@ -68,6 +71,7 @@ const milliTimestamp = @import("../datetime.zig").milliTimestamp;
const GlobalEventHandlersLookup = @import("webapi/global_event_handlers.zig").Lookup;
pub const preload = @import("frame/preload.zig");
pub const observers = @import("frame/observers.zig");
pub const user_input = @import("frame/user_input.zig");
pub const node_factory = @import("frame/node_factory.zig");
@@ -133,10 +137,12 @@ _element_computed_styles: Element.StyleLookup = .empty,
_element_datasets: Element.DatasetLookup = .empty,
_element_class_lists: Element.ClassListLookup = .empty,
_element_rel_lists: Element.RelListLookup = .empty,
_element_token_lists: Element.TokenListLookup = .empty,
_element_shadow_roots: Element.ShadowRootLookup = .empty,
_node_owner_documents: Node.OwnerDocumentLookup = .empty,
_element_scroll_positions: Element.ScrollPositionLookup = .empty,
_element_namespace_uris: Element.NamespaceUriLookup = .empty,
_svg_animated_strings: AnimatedString.Lookup = .empty,
// Same as above, but for Nodes (slot assigments apply to both Element AND
// Text nodes)
@@ -186,11 +192,17 @@ _http_owner: HttpClient.Owner = .{},
// List of active live ranges (for mutation updates per DOM spec)
_live_ranges: std.DoublyLinkedList = .{},
// Live NodeIterators for the DOM pre-removing steps. Iterators are
// slab-allocated (frame lifetime) and never unlinked.
_live_node_iterators: std.DoublyLinkedList = .{},
// List of open BroadcastChannels, used to route postMessage between same-named
// channels in this frame's origin
_broadcast_channels: std.DoublyLinkedList = .{},
// List of MessagePorts living in this frame's context.
_message_ports: std.DoublyLinkedList = .{},
// MutationObserver / IntersectionObserver bookkeeping. See frame/observers.zig.
_mutation: observers.Mutation = .{},
_intersection: observers.Intersection = .{},
@@ -209,6 +221,12 @@ _customized_builtin_disconnected_callback_invoked: std.AutoHashMapUnmanaged(*Ele
// The constructor can access this to get the element being upgraded.
_upgrading_element: ?*Node = null,
// Set when materializing the fragment parser's context element. The element
// is never inserted into the tree so if its a custom element ,we must not run
// its constructor (else we'll end up in an endless loop if the constructor
// sets this.innerHTML = '...', which happens).
_skip_custom_element_upgrade: bool = false,
// List of custom elements that were created before their definition was registered
_undefined_custom_elements: std.ArrayList(*Element.Html.Custom) = .{},
@@ -223,6 +241,10 @@ _factory: *Factory,
_load_state: LoadState = .waiting,
// The navigation response's MIME essence, recorded when the first chunk
// arrives and applied to the new document (document.contentType) once the
// navigation commits. null means the text/html default.
_pending_content_type: ?[]const u8 = null,
_parse_state: ParseState = .pre,
/// `frameErrorCallback` swallows the failure into a placeholder page;
@@ -460,6 +482,11 @@ pub fn deinit(self: *Frame) void {
page.releaseArena(qn.arena);
}
while (self._message_ports.first) |node| {
const port: *MessagePort = @alignCast(@fieldParentPtr("_node", node));
port.close(); // removes from self._message_ports
}
{
// Release all objects we're referencing
{
@@ -597,16 +624,16 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
self._load_state = .parsing;
self._last_navigate_error = null;
const req_id = self._session.browser.http_client.nextReqId();
log.info(.frame, "navigate", .{
.url = request_url,
.method = opts.method,
.reason = opts.reason,
.body = opts.body != null,
.req_id = req_id,
.type = self._type,
});
const http_client = &session.browser.http_client;
// Handle synthetic navigations: about:blank and blob: URLs
const is_about_blank = std.mem.eql(u8, "about:blank", request_url);
const is_blob = !is_about_blank and std.mem.startsWith(u8, request_url, "blob:");
@@ -661,8 +688,11 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
};
const parse_arena = try self.getArena(.medium, "Frame.parseBlob");
defer self.releaseArena(parse_arena);
// A script executed mid-parse can revoke the blob URL, letting GC
// free the buffer under the parser; parse a copy.
const html = try parse_arena.dupe(u8, blob._slice);
var parser = Parser.init(parse_arena, self.document.asNode(), self, .{ .allow_declarative_shadow = true });
parser.parse(blob._slice);
parser.parse(html);
} else {
self.document.injectBlank(self) catch |err| {
log.err(.browser, "inject blank", .{ .err = err });
@@ -670,6 +700,10 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
};
}
// No real request is made, but CDP still correlates the navigation
// events by request id, so consume one.
const req_id = http_client.incrReqId();
session.notification.dispatch(.frame_navigate, &.{
.opts = opts,
.req_id = req_id,
@@ -694,9 +728,6 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
.timestamp = timestamp(.monotonic),
});
// force next request id manually b/c we won't create a real req.
_ = session.browser.http_client.incrReqId();
if (self.parent == null) {
session.navigation._current_navigation_kind = opts.kind;
try session.navigation.commitNavigation(self);
@@ -706,8 +737,6 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
return;
}
const http_client = &session.browser.http_client;
self._http_status = null;
self._http_headers = .empty;
@@ -719,7 +748,6 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
};
self.origin = try URL.getOrigin(self.arena, self.url);
self._req_id = req_id;
self._navigated_options = .{
.cdp_id = opts.cdp_id,
.reason = opts.reason,
@@ -728,14 +756,37 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
.header = if (opts.header) |h| try self.arena.dupeZ(u8, h) else null,
};
var headers = try http_client.newHeaders();
try headers.add(lp.Config.HttpHeaders.navigation_accept);
if (opts.header) |hdr| {
try headers.add(hdr);
}
if (opts.referer) |ref| {
const ref_header = try std.mem.concatWithSentinel(self.arena, u8, &.{ "Referer: ", ref }, 0);
try headers.add(ref_header);
const transfer = try http_client.newRequest(.{
.ctx = self,
.url = self.url,
.frame_id = self._frame_id,
.loader_id = self._loader_id,
.method = opts.method,
.body = opts.body,
.cookie_jar = &session.cookie_jar,
.cookie_origin = opts.initiator_url orelse self.url,
.resource_type = .document,
.notification = self._session.notification,
.header_callback = frameHeaderDoneCallback,
.data_callback = frameDataCallback,
.done_callback = frameDoneCallback,
.error_callback = frameErrorCallback,
// The frame tracks its navigation by id (_req_id), never by pointer.
.shutdown_callback = HttpClient.noopShutdown,
}, &self._http_owner);
self._req_id = transfer.id;
{
// Ours until submit; clean up if header setup fails.
errdefer transfer.deinit();
try transfer.req.headers.add(lp.Config.HttpHeaders.navigation_accept);
if (opts.header) |hdr| {
try transfer.req.headers.add(hdr);
}
if (opts.referer) |ref| {
const ref_header = try std.mem.concatWithSentinel(transfer.arena, u8, &.{ "Referer: ", ref }, 0);
try transfer.req.headers.add(ref_header);
}
}
// A root navigation issued against a pending Page (i.e. one allocated by
@@ -751,7 +802,7 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
session.notification.dispatch(.frame_navigate, &.{
.opts = opts,
.url = self.url,
.req_id = req_id,
.req_id = transfer.id,
.frame_id = self._frame_id,
.loader_id = self._loader_id,
.timestamp = timestamp(.monotonic),
@@ -763,37 +814,24 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
session.navigation._current_navigation_kind = opts.kind;
self.makeRequest(.{
.ctx = self,
.url = self.url,
.frame_id = self._frame_id,
.loader_id = self._loader_id,
.method = opts.method,
.headers = headers,
.body = opts.body,
.cookie_jar = &session.cookie_jar,
.cookie_origin = opts.initiator_url orelse self.url,
.resource_type = .document,
.notification = self._session.notification,
.header_callback = frameHeaderDoneCallback,
.data_callback = frameDataCallback,
.done_callback = frameDoneCallback,
.error_callback = frameErrorCallback,
}) catch |err| {
transfer.submit() catch |err| {
log.err(.frame, "navigate request", .{ .url = self.url, .err = err, .type = self._type });
return err;
};
}
fn recordNavigateTelemetry(self: *Frame, tls: bool) void {
const TE = @import("../telemetry/telemetry.zig").Event;
const context: TE.Navigate.Context = if (self.parent != null)
.iframe
else if (self.window._opener != null)
.popup
else
.page;
lp.metrics.navigate.incr(context);
self._session.browser.app.telemetry.record(.{ .navigate = .{
.tls = tls,
.context = if (self.parent != null)
.iframe
else if (self.window._opener != null)
.popup
else
.page,
.context = context,
} });
}
@@ -856,6 +894,21 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: Allocator, request_url
};
const session = target._session;
// Re-navigating to the exact current URL is only a reload when the URL
// has no fragment. With a fragment it's a fragment navigation per the
// HTML "navigate" steps (url equals the document's URL excluding
// fragments and url's fragment is non-null): no reload, and since the
// fragment didn't change, no hashchange and no new history entry either.
if (!opts.force and
opts.kind != .reload and
std.mem.eql(u8, target.url, resolved_url) and
std.mem.indexOfScalar(u8, resolved_url, '#') != null)
{
session.releaseArena(arena);
return;
}
// Short-circuit only true fragment-only navigations (same path/query, different
// fragment). Identical URLs fall through and trigger a real reload.
const is_fragment_navigation = !std.mem.eql(u8, target.url, resolved_url) and URL.eqlDocument(target.url, resolved_url);
@@ -962,6 +1015,11 @@ pub fn makeRequest(self: *Frame, req: HttpClient.Request) !void {
return self._session.browser.http_client.request(req, &self._http_owner);
}
// Two-phase variant; see HttpClient.newRequest for the ownership contract.
pub fn newRequest(self: *Frame, req: HttpClient.Request) !*HttpClient.Transfer {
return self._session.browser.http_client.newRequest(req, &self._http_owner);
}
// Synchronously abort every transfer and WebSocket owned by this frame
// and all of its descendants.
pub fn abortTransfers(self: *Frame) void {
@@ -970,8 +1028,6 @@ pub fn abortTransfers(self: *Frame) void {
}
const http_client = &self._session.browser.http_client;
http_client.abortOwner(&self._http_owner);
// abortOwner misses deferred contexts whose transfer already completed.
http_client.deferring_layer.cancelFrame(self._frame_id);
}
pub fn documentIsLoaded(self: *Frame) void {
@@ -1144,8 +1200,8 @@ fn notifyParentLoadComplete(self: *Frame) void {
parent.iframeCompletedLoading(self.iframe.?, self._delays_parent_load);
}
fn frameHeaderDoneCallback(response: HttpClient.Response) !HttpClient.HeaderResult {
var self: *Frame = @ptrCast(@alignCast(response.ctx));
fn frameHeaderDoneCallback(transfer: *HttpClient.Transfer) !HttpClient.Transfer.HeaderResult {
var self: *Frame = @ptrCast(@alignCast(transfer.req.ctx));
// Commit point for a pending root navigation. The session has been
// holding the OLD page alive during the round-trip; now that response
@@ -1157,7 +1213,7 @@ fn frameHeaderDoneCallback(response: HttpClient.Response) !HttpClient.HeaderResu
try self._session.commitPendingPage(self._page);
}
const response_url = response.url();
const response_url = transfer.req.url;
if (std.mem.eql(u8, response_url, self.url) == false) {
// would be different than self.url in the case of a redirect
self.url = try self.arena.dupeZ(u8, response_url);
@@ -1169,7 +1225,7 @@ fn frameHeaderDoneCallback(response: HttpClient.Response) !HttpClient.HeaderResu
// Page.reload doesn't re-POST form data to the redirect target. Conservative
// default — 307/308 technically preserve the method per RFC 7231, but
// resubmitting form data is the more dangerous failure mode.
if ((response.redirectCount() orelse 0) > 0) {
if ((transfer.redirectCount() orelse 0) > 0) {
if (self._navigated_options) |*no| {
no.method = .GET;
no.body = null;
@@ -1186,14 +1242,14 @@ fn frameHeaderDoneCallback(response: HttpClient.Response) !HttpClient.HeaderResu
if (comptime IS_DEBUG) {
log.debug(.frame, "navigate header", .{
.url = self.url,
.status = response.status(),
.content_type = response.contentType(),
.status = transfer.responseStatus(),
.content_type = transfer.contentType(),
.type = self._type,
});
}
self._http_status = response.status();
var it = response.headerIterator();
self._http_status = transfer.responseStatus();
var it = transfer.responseHeaderIterator();
while (it.next()) |hdr| {
try self._http_headers.append(self.arena, .{
.name = try self.arena.dupe(u8, hdr.name),
@@ -1218,7 +1274,7 @@ fn frameHeaderDoneCallback(response: HttpClient.Response) !HttpClient.HeaderResu
// If the response is a file download, stream its body to disk instead of
// parsing it as a page. This sets _parse_state to .download, which the
// data/done callbacks below special-case.
_ = try self.maybeStartDownload(response);
_ = try self.maybeStartDownload(transfer);
return .proceed;
}
@@ -1227,7 +1283,7 @@ fn frameHeaderDoneCallback(response: HttpClient.Response) !HttpClient.HeaderResu
// treated as a download when Browser.setDownloadBehavior opted in
// (allow/allowAndName) and the response carries Content-Disposition: attachment.
// See issue #2701.
fn maybeStartDownload(self: *Frame, response: HttpClient.Response) !bool {
fn maybeStartDownload(self: *Frame, transfer: *HttpClient.Transfer) !bool {
const session = self._session;
switch (session.download_behavior) {
.allow, .allow_and_name => {},
@@ -1235,7 +1291,7 @@ fn maybeStartDownload(self: *Frame, response: HttpClient.Response) !bool {
}
const disposition: HttpClient.Header = blk: {
var it = response.headerIterator();
var it = transfer.responseHeaderIterator();
while (it.next()) |hdr| {
if (std.ascii.eqlIgnoreCase(hdr.name, "content-disposition")) {
break :blk hdr;
@@ -1281,7 +1337,7 @@ fn maybeStartDownload(self: *Frame, response: HttpClient.Response) !bool {
return false;
};
const total: ?u64 = if (response.contentLength()) |cl| cl else null;
const total: ?u64 = if (transfer.getContentLength()) |cl| cl else null;
self._parse_state = .{ .download = .{
.guid = guid,
@@ -1364,14 +1420,14 @@ fn isUtf16Encoding(charset: []const u8) bool {
return std.mem.eql(u8, charset, "UTF-16LE") or std.mem.eql(u8, charset, "UTF-16BE");
}
fn frameDataCallback(response: HttpClient.Response, data: []const u8) !void {
var self: *Frame = @ptrCast(@alignCast(response.ctx));
fn frameDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
var self: *Frame = @ptrCast(@alignCast(transfer.req.ctx));
if (self._parse_state == .pre) {
// we lazily do this, because we might need the first chunk of data
// to sniff the content type
var mime: Mime = blk: {
if (response.contentType()) |ct| {
if (transfer.contentType()) |ct| {
break :blk try Mime.parse(ct);
}
break :blk Mime.sniff(data);
@@ -1400,6 +1456,22 @@ fn frameDataCallback(response: HttpClient.Response, data: []const u8) !void {
});
}
// Record the response's MIME essence so the resulting document
// reports it (document.contentType). text/html keeps the default, so an
// HTML response (parsed from the header, or sniffed when there's no
// header) never needs an override; inside this branch the essence can
// no longer be text/html.
self._pending_content_type = null;
if (mime.content_type != .text_html) {
if (transfer.contentType()) |ct| {
const end = std.mem.indexOfScalarPos(u8, ct, 0, ';') orelse ct.len;
const essence = std.mem.trim(u8, ct[0..end], " \t");
if (essence.len > 0) {
self._pending_content_type = try std.ascii.allocLowerString(self.arena, essence);
}
}
}
switch (mime.content_type) {
.text_html => {
// Normalize and store the charset using encoding_rs canonical names
@@ -1474,6 +1546,11 @@ fn frameDoneCallback(ctx: *anyopaque) !void {
//We need to handle different navigation types differently.
try self._session.navigation.commitNavigation(self);
if (self._pending_content_type) |content_type| {
self.document._content_type = content_type;
self._pending_content_type = null;
}
defer if (comptime IS_DEBUG) {
log.debug(.frame, "frame load complete", .{
.url = self.url,
@@ -1497,6 +1574,8 @@ fn frameDoneCallback(ctx: *anyopaque) !void {
const raw_html = html.buffer.items;
preload.prescan(self, raw_html);
if (std.mem.eql(u8, self.charset, "UTF-8")) {
parser.parse(raw_html);
} else {
@@ -1824,6 +1903,7 @@ pub fn openPopup(self: *Frame, opts: OpenPopupOpts) !*Frame {
errdefer popup.deinit();
popup.window._opener = opts.opener;
if (opts.name.len > 0 and
!std.ascii.eqlIgnoreCase(opts.name, "_blank") and
!std.ascii.eqlIgnoreCase(opts.name, "_self") and
@@ -1843,6 +1923,13 @@ pub fn openPopup(self: *Frame, opts: OpenPopupOpts) !*Frame {
return err;
};
// A bit hacky. The type of window we return depends on whether or not its
// origin is the same as the opener. I believe that we're supposed to do
// this check lazily, per function invocation. But we don't have that in
// place right now. So the best we can do is set the origin now, before the
// async navigation completed.
try popup.js.setOrigin(popup.origin);
return popup;
}
@@ -2027,40 +2114,6 @@ pub fn queueHashChange(self: *Frame, old_url: []const u8, new_url: []const u8) !
// splitting by route anyway).
const MAX_STYLESHEET_BYTES: usize = 2 * 1024 * 1024;
// start prefetching <link rel="preload" as="script" href=...>`
pub fn preloadScriptHint(self: *Frame, element: *Element.Html, href: []const u8) bool {
if (self.isGoingAway() or self._parse_mode == .fragment) {
return false;
}
const arena = self.getArena(.small, "Frame.preloadScriptHint") catch return false;
defer self.releaseArena(arena);
const resolved = URL.resolve(arena, self.base(), href, .{ .encoding = self.charset }) catch return false;
if (!std.ascii.startsWithIgnoreCase(resolved, "http:") and !std.ascii.startsWithIgnoreCase(resolved, "https:")) {
// data:/blob: are synthesized locally — no round-trip to hide.
return false;
}
return self._script_manager.preloadScript(element, resolved) catch false;
}
// start prefetching <link rel="modulepreload" href=...>
pub fn preloadModuleHint(self: *Frame, element: *Element.Html, href: []const u8) bool {
if (self.isGoingAway() or self._parse_mode == .fragment) {
return false;
}
// The url becomes the imported_modules key, which must outlive the fetch
// so it lives on the frame arena
const resolved = URL.resolve(self.arena, self.base(), href, .{ .encoding = self.charset }) catch return false;
if (!std.ascii.startsWithIgnoreCase(resolved, "http:") and !std.ascii.startsWithIgnoreCase(resolved, "https:")) {
// data:/blob: are synthesized locally — no round-trip to hide.
return false;
}
return self._script_manager.base.preloadModuleHint(element, resolved, self.url) catch false;
}
// Synchronously fetch and parse an external `<link rel=stylesheet>`.
// href is passed in as an optimization since the [currently] only callsite has
// it, so why look it up again?
@@ -2096,18 +2149,10 @@ pub fn loadExternalStylesheet(self: *Frame, link: *Element.Html.Link, href: []co
const http_client = &session.browser.http_client;
// `syncRequest` below registers a blocking request for this frame, which
// makes the DeferringLayer hold back the completion callbacks of every
// OTHER in-flight transfer for the frame (e.g. a `<script defer>` still
// loading) so they don't run JS while we're on the parser stack. Those
// deferred completions must be flushed once the sync fetch returns —
// otherwise a `<script defer>` whose fetch finishes during this window is
// left at `complete == false` forever, the deferred-script queue never
// drains, and `documentIsLoaded` (readyState -> "interactive",
// DOMContentLoaded, the load event) never fires. The blocking-`<script>`
// path (ScriptManager.addFromElement) and the worker path already flush;
// the external-stylesheet path must too.
defer http_client.deferring_layer.flushFrame(self._frame_id);
// `syncRequest` below registers a blocking request for this frame; the
// client's dispatcher holds back every OTHER transfer's callbacks for
// the frame while it's registered (they'd run JS on the parser's stack)
// and delivers them on the next tick after the sync fetch returns.
var headers = try http_client.newHeaders();
try headers.add("Accept: text/css,*/*;q=0.1");
@@ -2135,6 +2180,7 @@ pub fn loadExternalStylesheet(self: *Frame, link: *Element.Html.Link, href: []co
.cookie_origin = self.url,
.resource_type = .stylesheet,
.notification = session.notification,
.shutdown_callback = HttpClient.noopShutdown, // syncRequest installs its own
}) catch |err| {
log.warn(.http, "external stylesheet fetch", .{ .err = err, .url = resolved });
return self.fireElementEvent(element, comptime .wrap("error"));
@@ -2351,8 +2397,19 @@ pub fn dupeSSO(self: *Frame, value: []const u8) !String {
const RemoveNodeOpts = struct {
will_be_reconnected: bool,
// Set to false when the caller queues its own combined mutation record
notify_observers: bool = true,
};
pub fn removeNode(self: *Frame, parent: *Node, child: *Node, opts: RemoveNodeOpts) void {
// NodeIterator pre-removing steps must run while the tree is intact.
if (self._live_node_iterators.first != null) {
var it: ?*std.DoublyLinkedList.Node = self._live_node_iterators.first;
while (it) |link| : (it = link.next) {
const iterator: *DOMNodeIterator = @fieldParentPtr("_iterator_link", link);
iterator.nodeWillBeRemoved(child);
}
}
// Capture siblings before removing
const previous_sibling = child.previousSibling();
const next_sibling = child.nextSibling();
@@ -2385,7 +2442,7 @@ pub fn removeNode(self: *Frame, parent: *Node, child: *Node, opts: RemoveNodeOpt
slotting.removalSteps(parent, child, self);
if (observers.hasMutationObservers(self)) {
if (opts.notify_observers and observers.hasMutationObservers(self)) {
const removed = [_]*Node{child};
observers.notifyChildListChange(self, parent, &.{}, &removed, previous_sibling, next_sibling);
}
@@ -2448,31 +2505,68 @@ pub fn appendNode(self: *Frame, parent: *Node, child: *Node, opts: InsertNodeOpt
}
pub fn appendAllChildren(self: *Frame, parent: *Node, target: *Node) !void {
self.domChanged();
const dest_connected = target.isConnected();
var it = parent.childrenIterator();
while (it.next()) |child| {
const child_was_connected = child.isConnected();
self.removeNode(parent, child, .{ .will_be_reconnected = dest_connected });
try self.appendNode(target, child, .{ .child_already_connected = child_was_connected });
}
return self.moveAllChildren(parent, target, null, .records);
}
pub fn insertAllChildrenBefore(self: *Frame, fragment: *Node, parent: *Node, ref_node: *Node) !void {
return self.moveAllChildren(fragment, parent, ref_node, .records);
}
pub const MoveChildrenNotify = enum { records, silent_parent };
// Moves every child of `source` into `parent` (before `ref_node`, or
// appended). Per the DOM insert algorithm for fragments, observers get one
// removal record on the source and one addition record on the parent, not
// one record per child. `.silent_parent` suppresses only the parent record
// (replaceChild queues its own combined record); the source record is queued
// regardless — the spec's insert algorithm notes it "intentionally does not
// pay attention to suppressObservers".
pub fn moveAllChildren(self: *Frame, source: *Node, parent: *Node, ref_node: ?*Node, notify_mode: MoveChildrenNotify) !void {
self.domChanged();
const dest_connected = parent.isConnected();
const notify = observers.hasMutationObservers(self);
var it = fragment.childrenIterator();
var moved: std.ArrayList(*Node) = .empty;
const previous_sibling = if (ref_node) |ref| ref.previousSibling() else parent.lastChild();
var it = source.childrenIterator();
while (it.next()) |child| {
try moved.append(self.call_arena, child);
const child_was_connected = child.isConnected();
self.removeNode(fragment, child, .{ .will_be_reconnected = dest_connected });
try self.insertNodeRelative(
parent,
child,
.{ .before = ref_node },
.{ .child_already_connected = child_was_connected },
);
self.removeNode(source, child, .{ .will_be_reconnected = dest_connected, .notify_observers = false });
if (ref_node) |ref| {
try self.insertNodeRelative(
parent,
child,
.{ .before = ref },
.{ .child_already_connected = child_was_connected, .notify_observers = false, .run_ready = false },
);
} else {
try self.appendNode(parent, child, .{ .child_already_connected = child_was_connected, .notify_observers = false, .run_ready = false });
}
}
if (notify and moved.items.len > 0) {
observers.notifyChildListChange(self, source, &.{}, moved.items, null, null);
if (notify_mode == .records) {
observers.notifyChildListChange(self, parent, moved.items, &.{}, previous_sibling, ref_node);
}
}
// Nodes moved into a not-yet-started script run the script's
// children-changed steps first (whatwg/html#10188), then each inserted
// node's own ready work runs, in tree order, with everything in place.
// Ready work only happens on becoming connected, so a detached
// destination has none.
if (parent.isConnected()) {
if (parent.is(Element.Html.Script)) |script| {
if (!script._executed) {
try self.nodeIsReady(false, parent);
}
}
for (moved.items) |child| {
try self.nodeIsReadySubtree(child);
}
}
}
@@ -2484,6 +2578,12 @@ const InsertNodeRelative = union(enum) {
const InsertNodeOpts = struct {
child_already_connected: bool = false,
adopting_to_new_document: bool = false,
// Set to false when the caller queues its own combined mutation record
notify_observers: bool = true,
// Set to false for multi-node insertions (fragments): the caller runs
// the ready work itself once every node is in place, so an earlier
// script observes its later siblings already inserted.
run_ready: bool = true,
};
pub fn insertNodeRelative(self: *Frame, parent: *Node, child: *Node, relative: InsertNodeRelative, opts: InsertNodeOpts) !void {
return self._insertNodeRelative(false, parent, child, relative, opts);
@@ -2539,12 +2639,14 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No
}
}
// The parser path does its own (limited) notification and connected-callback
// work, then returns.
// The parser path does its own (limited) notification and
// connected-callback work, then returns.
if (comptime from_parser) {
// Of the parser insertions, only fragment parses (innerHTML) mutate a
// live tree; the initial document parse suppresses notifications.
if (self._parse_mode == .fragment) {
// Main-document parser insertions notify per node: scripts running
// during parsing can observe the document. Fragment parses
// (innerHTML et al.) stay silent; Node.setHTML queues one combined
// "replace all" record instead.
if (self._parse_mode != .fragment) {
self.notifyChildInserted(parent, child);
}
@@ -2564,20 +2666,28 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No
const parent_is_connected = parent.isConnected();
// nodeIsReady resolves the node's owning frame itself (only for the few node
// types that have ready work), so pass the incumbent `self`.
try self.nodeIsReady(false, child);
// Mutation records queue synchronously at insertion, before any script
// runs: an inserted script can observe its own record via takeRecords.
if (opts.notify_observers) {
self.notifyChildInserted(parent, child);
}
// Check if text was added to a script that hasn't started yet.
if (child._type == .cdata and parent_is_connected) {
// Inserting into a not-yet-started script runs the script's
// children-changed steps first, then the inserted nodes' own ready work
// (whatwg/html#10188: the outer script executes before an inner one).
// Ready work only happens on becoming connected, so a detached parent
// has none.
if (opts.run_ready and parent_is_connected) {
if (parent.is(Element.Html.Script)) |script| {
if (!script._executed) {
try self.nodeIsReady(false, parent);
}
}
}
self.notifyChildInserted(parent, child);
// nodeIsReady resolves the node's owning frame itself (only for the few node
// types that have ready work), so pass the incumbent `self`.
try self.nodeIsReadySubtree(child);
}
if (opts.child_already_connected and !opts.adopting_to_new_document) {
// The child is already connected in the same document, we don't have to reconnect it.
@@ -2800,21 +2910,31 @@ fn parseHtmlAsChildrenInner(self: *Frame, node: *Node, html: []const u8, opts: F
}
node._children = first._children;
if (observers.hasMutationObservers(self)) {
var it = node.childrenIterator();
while (it.next()) |child| {
child._parent = node;
// Notify mutation observers for each unwrapped child
const previous_sibling = child.previousSibling();
const next_sibling = child.nextSibling();
const added = [_]*Node{child};
observers.notifyChildListChange(self, node, &added, &.{}, previous_sibling, next_sibling);
}
} else {
var it = node.childrenIterator();
while (it.next()) |child| {
child._parent = node;
}
// No mutation records for the unwrapped children either; see the comment
// about fragment parses in _insertNodeRelative.
var it = node.childrenIterator();
while (it.next()) |child| {
child._parent = node;
}
}
// Runs the "ready" work for an inserted node and, when it's an element with
// children, for its descendants in tree order: appending a subtree
// containing scripts must execute them all, after the whole insertion.
fn nodeIsReadySubtree(self: *Frame, node: *Node) !void {
if (node._type != .element or node.firstChild() == null) {
return self.nodeIsReady(false, node);
}
// Scripts can mutate the tree. Safe to do this since nodeIsReady re-checks
// connectivity.
var elements: std.ArrayList(*Node) = .empty;
var tw = @import("webapi/TreeWalker.zig").Full.Elements.init(node, .{});
while (tw.next()) |el| {
try elements.append(self.call_arena, el.asNode());
}
for (elements.items) |el| {
try self.nodeIsReady(false, el);
}
}
@@ -2839,6 +2959,16 @@ fn nodeIsReady(self: *Frame, comptime from_parser: bool, node: *Node) !void {
// walk, so we only do it once we've matched a node type that has ready work
// (the common text/element insertion does nothing here). The parser inserts
// into its own document, so from_parser always uses `self`.
// Scripts, iframes, links and styles activate on becoming connected;
// appending them to a detached parent does nothing (they run/load later
// if the subtree gets inserted into the document).
if (comptime from_parser == false) {
switch (node._type) {
.element => if (!node.isConnected()) return,
else => {},
}
}
if (node.is(Element.Html.Script)) |script| {
if ((comptime from_parser == false) and script._src.len == 0) {
// Script was added via JavaScript without a src attribute.
@@ -3131,6 +3261,11 @@ const SubmitFormOpts = struct {
pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.Form, submit_opts: SubmitFormOpts) !void {
const form = form_ orelse return;
if (submit_opts.fire_event and form.asNode().isConnected() == false) {
// interactive submission (e.g. submit button) noop if the form is disconnected
return;
}
// see the `_constructing_entry_list` field documentation
if (form._constructing_entry_list) {
return;

View File

File diff suppressed because it is too large Load Diff

View File

@@ -41,6 +41,7 @@ pub const ContentTypeEnum = enum {
text_plain,
text_css,
text_markdown,
text_event_stream,
image_jpeg,
image_gif,
image_png,
@@ -57,6 +58,7 @@ pub const ContentType = union(ContentTypeEnum) {
text_plain: void,
text_css: void,
text_markdown: void,
text_event_stream: void,
image_jpeg: void,
image_gif: void,
image_png: void,
@@ -76,6 +78,7 @@ pub fn contentTypeString(mime: *const Mime) []const u8 {
.text_plain => "text/plain",
.text_markdown => "text/markdown",
.text_css => "text/css",
.text_event_stream => "text/event-stream",
.image_jpeg => "image/jpeg",
.image_png => "image/png",
.image_gif => "image/gif",
@@ -363,6 +366,7 @@ fn parseContentType(value: []const u8) !struct { ContentType, usize } {
@"text/css",
@"text/plain",
@"text/markdown",
@"text/event-stream",
@"text/javascript",
@"application/javascript",
@@ -382,6 +386,7 @@ fn parseContentType(value: []const u8) !struct { ContentType, usize } {
.@"text/plain" => .{ .text_plain = {} },
.@"text/css" => .{ .text_css = {} },
.@"text/markdown" => .{ .text_markdown = {} },
.@"text/event-stream" => .{ .text_event_stream = {} },
.@"image/jpeg" => .{ .image_jpeg = {} },
.@"image/png" => .{ .image_png = {} },
.@"image/gif" => .{ .image_gif = {} },

View File

@@ -17,16 +17,19 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const builtin = @import("builtin");
const js = @import("js/js.zig");
const v8 = js.v8;
const Frame = @import("Frame.zig");
const Session = @import("Session.zig");
const Factory = @import("Factory.zig");
const Viewport = @import("Viewport.zig");
const SharedWorkerGlobalScope = @import("webapi/SharedWorkerGlobalScope.zig");
const v8 = js.v8;
const Allocator = std.mem.Allocator;
const IS_DEBUG = builtin.mode == .Debug;
@@ -81,11 +84,10 @@ identity: js.Identity = .{},
// weak-callback safety.
finalizer_callbacks: std.AutoHashMapUnmanaged(usize, *js.FinalizerCallback) = .empty,
// Tracked global v8 objects that need to be released when the Page tears down.
globals: std.ArrayList(v8.Global) = .empty,
// Temporary v8 globals that can be released early. Key is global.data_ptr.
temps: std.AutoHashMapUnmanaged(usize, v8.Global) = .empty,
// Persisted v8 handles owned by this Page. Handles that outlive the Page are
// reset on teardown; handles that can be released early are dropped
// individually. See js.GlobalTracker.
globals: js.GlobalTracker,
// Double buffered so that, as we process one list of queued navigations, new
// entries are added to the separate buffer. Prevents endless navigation loops
@@ -116,6 +118,10 @@ popups: std.ArrayList(*Frame) = .empty,
// ideal from a memory point of view).
closed_frames: std.ArrayList(*Frame) = .empty,
// SharedWorkerGlobalScopes created by this Page's frames (also registered in
// session.shared_workers so other pages can connect).
shared_workers: std.ArrayList(*SharedWorkerGlobalScope) = .empty,
// In-flight navigation for a root page. When not null, this page will "replace"
// the referenced page once the response header arrives. This is necessary
// because, during navigation, both the "old" and "new" pages remain addressable
@@ -144,6 +150,7 @@ pub fn init(self: *Page, session: *Session, frame_id: u32) !void {
.frame = undefined,
.frame_arena = frame_arena,
.factory = Factory.init(frame_arena),
.globals = .init(session.browser.app.allocator),
};
self.queued_navigation = &self.queued_navigation_1;
@@ -165,7 +172,13 @@ pub fn deinit(self: *Page) void {
self.frame.deinit();
for (self.shared_workers.items) |scope| {
scope.deinit();
}
self.shared_workers = .empty;
const session = self.session;
lp.metrics.js_heap_size_bytes.observe(session.browser.env.isolate.getHeapStatistics().total_physical_size);
defer session.browser.env.memoryPressureNotification(.moderate);
self.identity.deinit();
@@ -180,20 +193,7 @@ pub fn deinit(self: *Page) void {
self.finalizer_callbacks = .empty;
}
{
for (self.globals.items) |*global| {
v8.v8__Global__Reset(global);
}
self.globals = .empty;
}
{
var it = self.temps.valueIterator();
while (it.next()) |global| {
v8.v8__Global__Reset(global);
}
self.temps = .empty;
}
self.globals.deinit();
if (comptime IS_DEBUG) {
std.debug.assert(self.origins.count() == 0);

View File

@@ -22,7 +22,7 @@ const lp = @import("lightpanda");
const js = @import("js/js.zig");
const Browser = @import("Browser.zig");
const Session = @import("Session.zig");
const HttpClient = @import("HttpClient.zig");
const HttpClient = @import("../network/HttpClient.zig");
const Node = @import("webapi/Node.zig");
const Selector = @import("webapi/selector/Selector.zig");
@@ -170,7 +170,7 @@ fn _wait(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa
if (elapsed >= timeout_ms) {
return .timeout;
}
try self.http_client.tick(@min(timeout_ms - elapsed, 200), .all);
try self.http_client.tick(@min(timeout_ms - elapsed, 200));
break :done_blk 0;
},
};
@@ -220,13 +220,12 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa
try browser.runMacrotasks();
}
const http_active = http_client.http_active;
const http_next_tick = http_client.next_tick_count;
const total_http_activity = http_active + http_next_tick + http_client.interception_layer.intercepted;
const total_network_activity = total_http_activity + http_client.ws_active;
const activity = http_client.activity();
const total_http_activity = activity.http;
const total_network_activity = activity.total();
const ms_to_next_macrotask = browser.msToNextMacrotask();
const network_idle = total_network_activity == 0 and http_client.queue.first == null and http_client.ready_queue.first == null;
const network_idle = activity.idle();
const is_done = ms_to_next_macrotask == null and network_idle;
// _we_ have nothing to run, but v8 is working on background tasks. We'll
@@ -302,10 +301,17 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa
// for a client message; loop back and run macrotasks instead.
ms_to_wait = @min(ms_to_wait, 10);
}
try http_client.tick(@intCast(ms_to_wait), .all);
try http_client.tick(@intCast(ms_to_wait));
return .{ .ok = 0 };
}
// WebSocket often remain open forever. We can't wait for them to "complete".
// But, if we have web socket connections, we still want to make progress on
// them, so we tick with no delay.
if (activity.ws_conns > 0 or activity.ws_events > 0) {
try http_client.tick(0);
}
return .done;
}

View File

@@ -20,7 +20,7 @@ const std = @import("std");
const lp = @import("lightpanda");
const builtin = @import("builtin");
const HttpClient = @import("HttpClient.zig");
const HttpClient = @import("../network/HttpClient.zig");
const js = @import("js/js.zig");
const URL = @import("URL.zig");
@@ -46,7 +46,8 @@ frame: *Frame,
// "load" event).
frame_notified_of_completion: bool,
// scripts loaded based on a <link rel=preload as=script href=...> found during parsing
// Scripts loaded based on a <link rel=preload as=script href=...> found during
// parsing, keyed by resolved URL.
preloaded_scripts: std.StringHashMapUnmanaged(PreloadedScript),
pub fn init(allocator: Allocator, http_client: *HttpClient, frame: *Frame) ScriptManager {
@@ -104,7 +105,8 @@ fn getHeaders(self: *ScriptManager) !HttpClient.Headers {
// Returns true when a fetch was started: the link's load/error event fires
// when the fetch settles. false (duplicate hint) = no event will fire.
pub fn preloadScript(self: *ScriptManager, element: *Element.Html, url: []const u8) !bool {
// element is null when the hint came from the prescan rather than a <link>.
pub fn preloadScript(self: *ScriptManager, element: ?*Element.Html, url: []const u8) !bool {
if (self.preloaded_scripts.contains(url)) {
return false;
}
@@ -169,7 +171,7 @@ fn waitForPreload(self: *ScriptManager, url: [:0]const u8) ?*Script {
const entry = self.preloaded_scripts.getPtr(url) orelse return null;
switch (entry.state) {
.loading => {
_ = client.tick(200, .sync_wait) catch return null;
_ = client.tickSync(200) catch return null;
continue;
},
.done => |script| {
@@ -225,54 +227,28 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
return;
};
var handover = false;
const frame = self.frame;
const base_url = frame.base();
// A consumed preload (waitForPreload below) is owned by us: its buffer is
// borrowed by `script`, so it must outlive eval.
var consumed_preload: ?*Script = null;
defer if (consumed_preload) |p| {
p.deinit();
const src = element.getAttributeSafe(comptime .wrap("src")) orelse {
return self.addInlineScript(script_element, kind);
};
// The script is remote (even data: and blob: are synthesized via HttpClient)
// Set once arena ownership is resolved — transferred to `script`, or
// released early on the adoption path — so the errdefer can't double-free.
var handover = false;
const arena = try frame.getArena(.large, "SM.addFromElement");
errdefer if (!handover) {
errdefer if (handover == false) {
frame.releaseArena(arena);
};
var source: Script.Source = undefined;
var remote_url: ?[:0]const u8 = null;
const base_url = frame.base();
if (element.getAttributeSafe(comptime .wrap("src"))) |src| {
// data: and blob: srcs flow through the normal request path; HttpClient
// synthesizes the response. Execution mode (blocking vs async/defer) is
// attribute-driven, the same as any other src.
remote_url = try URL.resolve(arena, base_url, src, .{ .encoding = frame.charset });
source = .{ .remote = .{} };
} else {
var buf = std.Io.Writer.Allocating.init(arena);
try element.asNode().getChildTextContent(&buf.writer);
try buf.writer.writeByte(0);
const data = buf.written();
const inline_source: [:0]const u8 = data[0 .. data.len - 1 :0];
if (inline_source.len == 0) {
// we haven't set script_element._executed = true yet, which is good.
// If content is appended to the script, we will execute it then.
frame.releaseArena(arena);
return;
}
source = .{ .@"inline" = inline_source };
}
// Only set _executed (already-started) when we actually have content to execute
const remote_url = try URL.resolve(arena, base_url, src, .{ .encoding = frame.charset });
script_element._executed = true;
const is_inline = source == .@"inline";
const mode: Script.Extra.FrameExtra.Mode = blk: {
if (source == .@"inline") {
break :blk if (kind == .module) .@"defer" else .normal;
}
if (element.getAttributeSafe(comptime .wrap("async")) != null) {
break :blk .async;
}
@@ -294,54 +270,84 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
break :blk .normal;
};
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
log.debug(.http, "script queue", .{
.ctx = ctx,
.url = remote_url,
.element = element,
.stack = ls.local.stackTrace() catch "???",
});
}
const frame_extra: Script.Extra = .{ .frame = .{
.kind = kind,
.mode = mode,
.script_element = script_element,
.frame = frame,
} };
if (mode != .normal) {
var preloaded = self.takePreload(remote_url);
if (preloaded == null and kind == .module) {
preloaded = self.base.takeModuleHint(remote_url);
}
if (preloaded) |pre| {
if (comptime IS_DEBUG) {
log.debug(.http, "script adopt", .{ .url = remote_url, .ctx = ctx, .state = if (pre.complete) "done" else "loading" });
}
pre.extra = frame_extra;
// The adopted Script has its own arena; ours held only the URL
// resolution, which nothing below needs.
handover = true;
frame.releaseArena(arena);
if (pre.complete and mode == .async) {
// The fetch already finished, so no doneCallback will move it
// to ready_scripts; queue it there directly.
self.base.ready_scripts.append(&pre.node);
} else {
self.base.scriptList(pre).append(&pre.node);
}
if (pre.complete) {
// ...and no doneCallback will trigger evaluation.
self.base.evaluate();
}
return;
}
}
const script = try arena.create(Script);
script.* = .{
.node = .{},
.arena = arena,
.manager = &self.base,
.source = source,
.complete = is_inline,
.status = if (is_inline) 200 else 0,
.url = remote_url orelse base_url,
.extra = .{ .frame = .{
.kind = kind,
.mode = mode,
.script_element = script_element,
.frame = frame,
} },
.source = .{ .remote = .{} },
.complete = false,
.url = remote_url,
.extra = frame_extra,
};
const is_blocking = mode == .normal;
if (mode == .normal) {
// Blocking: fetch synchronously and evaluate before the parser resumes.
// Once parsing is done, the deferred-script batch has already drained and
// won't run again, so a non-blocking script inserted afterwards would go
// unprocessed. Run it immediately instead. Remote scripts still need to
// be queued so they execute when their fetch completes.
const run_immediately = is_blocking or (self.base.static_scripts_done and remote_url == null);
if (run_immediately == false) {
self.base.scriptList(script).append(&script.node);
}
// A consumed preload (waitForPreload below) is owned by us: its buffer
// is borrowed by `script`, so it must outlive eval.
var consumed_preload: ?*Script = null;
defer if (consumed_preload) |p| {
p.deinit();
};
if (remote_url) |url| {
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
{
const was_evaluating = self.base.is_evaluating;
self.base.is_evaluating = true;
defer self.base.endEvaluationWindow(was_evaluating);
log.debug(.http, "script queue", .{
.ctx = ctx,
.url = remote_url.?,
.element = element,
.stack = ls.local.stackTrace() catch "???",
});
}
const was_evaluating = self.base.is_evaluating;
self.base.is_evaluating = true;
defer self.base.endEvaluationWindow(was_evaluating);
if (is_blocking) {
if (self.waitForPreload(url)) |pre| {
if (self.waitForPreload(remote_url)) |pre| {
// There was a preloaded script, we borrow it's source and status
consumed_preload = pre;
script.source = pre.source;
@@ -349,7 +355,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
script.complete = true;
} else {
const response = try self.base.client.syncRequest(arena, .{
.url = url,
.url = remote_url,
.method = .GET,
.frame_id = frame._frame_id,
.loader_id = frame._loader_id,
@@ -358,54 +364,114 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
.cookie_origin = frame.url,
.resource_type = .script,
.notification = frame._session.notification,
.shutdown_callback = HttpClient.noopShutdown, // syncRequest installs its own
});
script.source = .{ .remote = response.body };
script.status = response.status;
script.complete = true;
}
} else {
errdefer {
self.base.scriptList(script).remove(&script.node);
// Let the outer errdefer handle releasing the arena if client.request fails
}
try frame.makeRequest(.{
.ctx = script,
.url = url,
.method = .GET,
.frame_id = frame._frame_id,
.loader_id = frame._loader_id,
.headers = try self.getHeaders(),
.cookie_jar = &frame._session.cookie_jar,
.cookie_origin = frame.url,
.resource_type = .script,
.notification = frame._session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback,
.data_callback = Script.dataCallback,
.done_callback = Script.doneCallback,
.error_callback = Script.errorCallback,
});
handover = true;
}
handover = true;
if (script.status < 200 or script.status > 299) {
log.info(.http, "script load error", .{ .status = script.status });
script.executeCallback(comptime .wrap("error"));
script.deinit();
return;
}
return self.evalNow(script);
}
if (run_immediately == false) {
// async/defer: queue the script and fetch in the background; doneCallback
// routes it through the ready_scripts / defer_scripts draining.
self.base.scriptList(script).append(&script.node);
const was_evaluating = self.base.is_evaluating;
self.base.is_evaluating = true;
defer self.base.endEvaluationWindow(was_evaluating);
errdefer self.base.scriptList(script).remove(&script.node);
try frame.makeRequest(.{
.ctx = script,
.url = remote_url,
.method = .GET,
.frame_id = frame._frame_id,
.loader_id = frame._loader_id,
.headers = try self.getHeaders(),
.cookie_jar = &frame._session.cookie_jar,
.cookie_origin = frame.url,
.resource_type = .script,
.notification = frame._session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback,
.data_callback = Script.dataCallback,
.done_callback = Script.doneCallback,
.error_callback = Script.errorCallback,
// Nothing holds the transfer; teardown cleanup runs through
// the manager's script lists.
.shutdown_callback = HttpClient.noopShutdown,
});
handover = true;
}
// A <script> with no src. Runs synchronously right now, except an inline
// module during parsing, which waits its turn in defer_scripts.
fn addInlineScript(self: *ScriptManager, script_element: *Element.Html.Script, kind: Script.Extra.FrameExtra.Kind) !void {
const node = script_element.asElement().asNode();
const source_len = node.childTextContentLen();
if (source_len == 0) {
// if content is appended later, we'll execute it then since
// script_element._executed is still false
return;
}
// This will flush any deferred scripts.
defer self.base.client.deferring_layer.flushFrame(self.base.owner.frameId());
const frame = self.frame;
const arena = try frame.getArena(source_len + @sizeOf(Script) + 1, "SM.addInlineScript");
errdefer frame.releaseArena(arena);
if (script.status < 200 or script.status > 299) {
log.info(.http, "script load error", .{ .status = script.status });
script.executeCallback(comptime .wrap("error"));
script.deinit();
const source = blk: {
const buf = try arena.alloc(u8, source_len + 1);
var writer: std.Io.Writer = .fixed(buf);
try node.getChildTextContent(&writer);
buf[source_len] = 0;
break :blk buf[0..source_len :0];
};
script_element._executed = true;
const mode: Script.Extra.FrameExtra.Mode = if (kind == .module) .@"defer" else .normal;
const script = try arena.create(Script);
script.* = .{
.node = .{},
.arena = arena,
.manager = &self.base,
.source = .{ .@"inline" = source },
.complete = true,
.status = 200,
.url = frame.base(),
.extra = .{ .frame = .{
.kind = kind,
.mode = mode,
.script_element = script_element,
.frame = frame,
} },
};
// An inline module found during parsing waits its turn in document order.
// Once parsing is done, the deferred batch has already drained and won't
// run again, so run it immediately instead.
if (mode == .@"defer" and self.base.static_scripts_done == false) {
self.base.scriptList(script).append(&script.node);
return;
}
self.evalNow(script);
}
fn evalNow(self: *ScriptManager, script: *Script) void {
// could have already been evaluating if this is dynamically added
const was_evaluating = self.base.is_evaluating;
self.base.is_evaluating = true;
@@ -421,6 +487,14 @@ pub fn staticScriptsDone(self: *ScriptManager) void {
self.base.staticScriptsDone();
}
// Removes and returns the preload entry for `url` in whatever state it's in.
fn takePreload(self: *ScriptManager, url: [:0]const u8) ?*Script {
const kv = self.preloaded_scripts.fetchRemove(url) orelse return null;
return switch (kv.value.state) {
inline else => |script| script,
};
}
const PreloadedScript = struct {
state: State,
@@ -437,6 +511,11 @@ const PreloadedScript = struct {
fn doneCallback(ctx: *anyopaque) !void {
const script: *Script = @ptrCast(@alignCast(ctx));
if (script.extra != .preload) {
// Adopted by a real <script> element (addFromElement) while the
// fetch was in flight; complete it as a normal frame script.
return Script.doneCallback(ctx);
}
script.complete = true;
if (comptime IS_DEBUG) {
log.debug(.http, "script fetch complete", .{ .req = script.url });
@@ -449,6 +528,11 @@ const PreloadedScript = struct {
fn errorCallback(ctx: *anyopaque, err: anyerror) void {
const script: *Script = @ptrCast(@alignCast(ctx));
if (script.extra != .preload) {
// Adopted mid-flight; fail it as a normal frame script (list
// unlink, error event on the element and the hint link).
return Script.errorCallback(ctx, err);
}
if (script.status == 404) {
log.info(.http, "script 404", .{ .req = script.url, .extra = "preload" });
} else {
@@ -468,6 +552,11 @@ const PreloadedScript = struct {
// errorCallback, since the owner is being torn down.
fn shutdownCallback(ctx: *anyopaque) void {
const script: *Script = @ptrCast(@alignCast(ctx));
if (script.extra != .preload) {
// Adopted: the Script lives in the manager's script lists and is
// reaped by reset(), same as any async script (noopShutdown).
return;
}
const self: *ScriptManager = @fieldParentPtr("base", script.manager);
_ = self.preloaded_scripts.remove(script.url);
script.deinit();

View File

@@ -20,8 +20,8 @@ const std = @import("std");
const lp = @import("lightpanda");
const builtin = @import("builtin");
const HttpClient = @import("HttpClient.zig");
const http = @import("../network/http.zig");
const HttpClient = @import("../network/HttpClient.zig");
const js = @import("js/js.zig");
const Session = @import("Session.zig");
@@ -216,7 +216,12 @@ pub fn resolveSpecifier(self: *ScriptManagerBase, arena: Allocator, base: [:0]co
}
const PreloadOpts = struct {
// set when the preload comes from a <link rel=modulepreload> hint
// true when the preload is a hint (modulepreload link or prescan) and can
// be taken by anyone.
hint: bool = false,
// the <link rel=modulepreload> element to fire load/error on, when the
// hint came from one.
hint_element: ?*Element.Html = null,
};
pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []const u8, opts: PreloadOpts) !void {
@@ -248,7 +253,7 @@ pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []co
.hint_element = opts.hint_element,
};
gop.value_ptr.* = .{ .state = .{ .loading = script }, .hint = opts.hint_element != null };
gop.value_ptr.* = .{ .state = .{ .loading = script }, .hint = opts.hint };
if (comptime IS_DEBUG) {
var ls: js.Local.Scope = undefined;
@@ -294,17 +299,45 @@ pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []co
};
}
// <link rel=modulepreload href=...> — start fetching a module before import
// resolution discovers it. Returns false when no fetch was started (the
// module is already fetched/fetching): no event will fire on the link.
pub fn preloadModuleHint(self: *ScriptManagerBase, element: *Element.Html, url: [:0]const u8, referrer: []const u8) !bool {
// <link rel=modulepreload href=...> (element set) or the prescan finding a
// <script type=module src=...> (element null) — start fetching a module
// before import resolution discovers it. Returns false when no fetch was
// started (the module is already fetched/fetching): no event will fire on
// the link.
pub fn preloadModuleHint(self: *ScriptManagerBase, element: ?*Element.Html, url: [:0]const u8, referrer: []const u8) !bool {
if (self.imported_modules.contains(url)) {
return false;
}
try self.preloadImport(url, referrer, .{ .hint_element = element });
try self.preloadImport(url, referrer, .{ .hint = true, .hint_element = element });
return true;
}
// A <script type=module src=...> whose URL was hinted (modulepreload link or
// prescan)
pub fn takeModuleHint(self: *ScriptManagerBase, url: [:0]const u8) ?*Script {
const entry = self.imported_modules.getEntry(url) orelse return null;
if (entry.value_ptr.hint == false) {
// The script was preloaded, but not because of a hint. It came from v8
// telling us to preload the module. We cannot take it here because we know
// v8 is going to come back and wait for it.
return null;
}
const script = switch (entry.value_ptr.state) {
.loading => |script| blk: {
self.async_scripts.remove(&script.node);
break :blk script;
},
.done => |script| script,
// The hint's fetch failed; give the script its own attempt.
// I'm not sure if this is the right behavior. Why would a preload fail
// but the "real" load work? But it's definetly safer.
.err => return null,
};
self.imported_modules.removeByPtr(entry.key_ptr);
return script;
}
pub fn waitForImport(self: *ScriptManagerBase, url: [:0]const u8) !ModuleSource {
const was_evaluating = self.is_evaluating;
self.is_evaluating = true;
@@ -321,7 +354,7 @@ pub fn waitForImport(self: *ScriptManagerBase, url: [:0]const u8) !ModuleSource
};
switch (entry.value_ptr.state) {
.loading => {
_ = try client.tick(200, .sync_wait);
_ = try client.tickSync(200);
continue;
},
.done => |script| {
@@ -454,6 +487,7 @@ pub fn getAsyncImport(self: *ScriptManagerBase, url: [:0]const u8, cb: ImportAsy
.data_callback = Script.dataCallback,
.done_callback = Script.doneCallback,
.error_callback = Script.errorCallback,
.shutdown_callback = Script.shutdownCallback,
}) catch |err| {
self.async_scripts.remove(&script.node);
return err;
@@ -660,19 +694,19 @@ pub const Script = struct {
self.manager.releaseArena(self.arena);
}
pub fn startCallback(response: HttpClient.Response) !void {
log.debug(.http, "script fetch start", .{ .req = response });
pub fn startCallback(transfer: *HttpClient.Transfer) !void {
log.debug(.http, "script fetch start", .{ .req = transfer });
}
pub fn headerCallback(response: HttpClient.Response) !HttpClient.HeaderResult {
const self: *Script = @ptrCast(@alignCast(response.ctx));
pub fn headerCallback(transfer: *HttpClient.Transfer) !HttpClient.Transfer.HeaderResult {
const self: *Script = @ptrCast(@alignCast(transfer.req.ctx));
self.status = response.status().?;
if (response.status() != 200) {
self.status = transfer.responseStatus().?;
if (transfer.responseStatus() != 200) {
log.info(.http, "script header", .{
.req = response,
.status = response.status(),
.content_type = response.contentType(),
.req = transfer,
.status = transfer.responseStatus(),
.content_type = transfer.contentType(),
});
return .abort;
@@ -680,64 +714,61 @@ pub const Script = struct {
if (comptime IS_DEBUG) {
log.debug(.http, "script header", .{
.req = response,
.status = response.status(),
.content_type = response.contentType(),
.req = transfer,
.status = transfer.responseStatus(),
.content_type = transfer.contentType(),
});
}
switch (response.inner) {
.transfer => |transfer| {
// temp debug, trying to figure out why the next assert sometimes
// fails. Is the buffer just corrupt or is headerCallback really
// being called twice?
lp.assert(self.header_callback_called == false, "ScriptManagerBase.Header recall", .{
.m = @tagName(std.meta.activeTag(self.extra)),
.a1 = self.debug_transfer_id,
.a2 = self.debug_transfer_tries,
.a3 = self.debug_transfer_aborted,
.a4 = self.debug_transfer_bytes_received,
.a5 = self.debug_transfer_notified_fail,
.a8 = self.debug_transfer_auth_challenge,
.a9 = self.debug_transfer_easy_id,
.b1 = transfer.id,
.b2 = transfer._tries,
.b3 = transfer.state == .aborted,
.b4 = transfer.res.bytes_received,
.b5 = transfer._notified_fail,
.b8 = transfer._auth_challenge != null,
.b9 = if (transfer._conn) |c| @intFromPtr(c._easy) else 0,
});
self.header_callback_called = true;
self.debug_transfer_id = transfer.id;
self.debug_transfer_tries = transfer._tries;
self.debug_transfer_aborted = transfer.state == .aborted;
self.debug_transfer_bytes_received = transfer.res.bytes_received;
self.debug_transfer_notified_fail = transfer._notified_fail;
self.debug_transfer_auth_challenge = transfer._auth_challenge != null;
self.debug_transfer_easy_id = if (transfer._conn) |c| @intFromPtr(c._easy) else 0;
},
else => {},
{
// temp debug, trying to figure out why the next assert sometimes
// fails. Is the buffer just corrupt or is headerCallback really
// being called twice?
lp.assert(self.header_callback_called == false, "ScriptManagerBase.Header recall", .{
.m = @tagName(std.meta.activeTag(self.extra)),
.a1 = self.debug_transfer_id,
.a2 = self.debug_transfer_tries,
.a3 = self.debug_transfer_aborted,
.a4 = self.debug_transfer_bytes_received,
.a5 = self.debug_transfer_notified_fail,
.a8 = self.debug_transfer_auth_challenge,
.a9 = self.debug_transfer_easy_id,
.b1 = transfer.id,
.b2 = transfer._tries,
.b3 = transfer.state == .aborted,
.b4 = transfer.res.bytes_received,
.b5 = transfer._notified_fail,
.b8 = transfer._auth_challenge != null,
.b9 = if (transfer._conn) |c| @intFromPtr(c._easy) else 0,
});
self.header_callback_called = true;
self.debug_transfer_id = transfer.id;
self.debug_transfer_tries = transfer._tries;
self.debug_transfer_aborted = transfer.state == .aborted;
self.debug_transfer_bytes_received = transfer.res.bytes_received;
self.debug_transfer_notified_fail = transfer._notified_fail;
self.debug_transfer_auth_challenge = transfer._auth_challenge != null;
self.debug_transfer_easy_id = if (transfer._conn) |c| @intFromPtr(c._easy) else 0;
}
lp.assert(self.source.remote.capacity == 0, "ScriptManagerBase.Header buffer", .{ .capacity = self.source.remote.capacity });
var buffer: std.ArrayList(u8) = .empty;
if (response.contentLength()) |cl| {
if (transfer.getContentLength()) |cl| {
try buffer.ensureTotalCapacity(self.arena, cl);
}
self.source = .{ .remote = buffer };
return .proceed;
}
pub fn dataCallback(response: HttpClient.Response, data: []const u8) !void {
const self: *Script = @ptrCast(@alignCast(response.ctx));
self._dataCallback(response, data) catch |err| {
log.err(.http, "SM.dataCallback", .{ .err = err, .transfer = response, .len = data.len });
pub fn dataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void {
const self: *Script = @ptrCast(@alignCast(transfer.req.ctx));
self._dataCallback(transfer, data) catch |err| {
log.err(.http, "SM.dataCallback", .{ .err = err, .transfer = transfer, .len = data.len });
return err;
};
}
fn _dataCallback(self: *Script, _: HttpClient.Response, data: []const u8) !void {
fn _dataCallback(self: *Script, _: *HttpClient.Transfer, data: []const u8) !void {
try self.source.remote.appendSlice(self.arena, data);
}
@@ -874,8 +905,13 @@ pub const Script = struct {
const local = &ls.local;
// Per spec, trigger any microtasks BEFORE execution in case the parser
// (e.g. via event callbacks) queued anything.
local.runMicrotasks();
// (e.g. via event callbacks) queued anything. Only at a microtask
// checkpoint though (empty JS stack): a script executing because a
// running script inserted it must not drain the queue mid-task -
// e.g. its own insertion record must survive for takeRecords().
if (frame.js.call_depth == 0) {
local.runMicrotasks();
}
// Handle importmap special case here: the content is a JSON containing imports.
// Multiple <script type="importmap"> elements merge with first-wins semantics.
@@ -937,6 +973,7 @@ pub const Script = struct {
}
const caught = try_catch.caughtOrError(frame.local_arena, error.Unknown);
lp.metrics.script_errors.incr();
log.warn(.js, "eval script", .{
.url = url,
.caught = caught,

View File

@@ -33,6 +33,7 @@ const Browser = @import("Browser.zig");
pub const Runner = @import("Runner.zig");
const Notification = @import("../Notification.zig");
const QueuedNavigation = Frame.QueuedNavigation;
const SharedWorkerGlobalScope = @import("webapi/SharedWorkerGlobalScope.zig");
const log = lp.log;
const ArenaPool = App.ArenaPool;
@@ -68,6 +69,11 @@ arena_pool: *ArenaPool,
// both the live page and its in-flight replacement.
pages: std.ArrayList(*Page) = .{},
// Live SharedWorkerGlobalScopes, keyed by "url\x00name", so every
// `new SharedWorker(url, name)` in the session connects to the same instance.
// Owned by the Page that creates it.
shared_workers: std.StringHashMapUnmanaged(*SharedWorkerGlobalScope) = .empty,
_page_destruction_queue: std.ArrayList(*Page) = .{},
// Round-robin cursor for fair page iteration (processQueuedNavigation)

View File

@@ -76,11 +76,11 @@ pub fn deinit(self: *StyleManager) void {
self.frame.releaseArena(self.arena);
}
/// Hard cap on `@media` nesting depth. CSS Nesting allows arbitrarily-deep
/// Hard cap on `@media` / `@layer` nesting depth. CSS allows arbitrarily-deep
/// at-rule nesting; without a cap a hostile inline stylesheet could blow the
/// Zig stack via mutually-recursive `applyMediaAtRule` frames. 32 is well
/// past anything seen in the wild.
const MAX_MEDIA_NESTING: u8 = 32;
/// Zig stack via the mutually-recursive `applyMediaAtRule` / `applyLayerAtRule`
/// / `applyInnerRules` frames. 32 is well past anything seen in the wild.
const MAX_AT_RULE_NESTING: u8 = 32;
fn parseSheet(self: *StyleManager, sheet: *CSSStyleSheet) !void {
if (sheet._css_rules) |css_rules| {
@@ -91,6 +91,9 @@ fn parseSheet(self: *StyleManager, sheet: *CSSStyleSheet) !void {
// `insertRule` / `replaceSync` participates in the cascade
// when its query matches the viewport.
.media => try self.applyMediaAtRule(rule._text, 0),
// `@layer` blocks flatten into the cascade (see
// applyLayerAtRule for the deliberate scope).
.layer => try self.applyLayerAtRule(rule._text, 0),
else => {},
}
}
@@ -105,12 +108,15 @@ fn parseSheet(self: *StyleManager, sheet: *CSSStyleSheet) !void {
switch (parsed_rule) {
.style => |s| try self.addRawRule(s.selector, s.block),
.at_rule => |a| {
// Only `@media` participates in the cascade here. Other
// at-rules (`@keyframes`, `@supports`, `@font-face`, …)
// don't carry top-level declarations relevant to the
// visibility filter and stay skipped as before.
// Only `@media` and `@layer` participate in the cascade
// here. Other at-rules (`@keyframes`, `@supports`,
// `@font-face`, …) don't carry top-level declarations
// relevant to the visibility filter and stay skipped as
// before.
if (std.ascii.eqlIgnoreCase(a.keyword, "media")) {
try self.applyMediaAtRule(a.text, 0);
} else if (std.ascii.eqlIgnoreCase(a.keyword, "layer")) {
try self.applyLayerAtRule(a.text, 0);
}
},
}
@@ -123,45 +129,74 @@ fn parseSheet(self: *StyleManager, sheet: *CSSStyleSheet) !void {
/// lived at the top level. Non-matching queries silently drop the inner
/// rules. Inline-only by design: external `<link rel="stylesheet">` is out
/// of scope for the headless engine.
fn applyMediaAtRule(self: *StyleManager, text: []const u8, depth: u8) !void {
if (depth >= MAX_MEDIA_NESTING) return;
fn applyMediaAtRule(self: *StyleManager, text: []const u8, depth: u8) Allocator.Error!void {
if (depth >= MAX_AT_RULE_NESTING) return;
// text shape: `@media <query> { <inner> }` for well-formed input.
// `CssParser.RulesIterator.consumeAtRule` always emits a span starting
// at `@`; for unclosed blocks it runs to EOF, so the closing `}` is
// located explicitly rather than assumed to be the final byte.
if (text.len < @as(usize, "@media".len) + 2) return;
if (!std.ascii.startsWithIgnoreCase(text, "@media")) return;
const rest = text["@media".len..];
// Use a comment-aware brace finder; a `/* { */` in the prelude would
// otherwise split the rule at the wrong place. The inner block's
// contents are re-parsed by CssParser below, which has its own trivia
// handling, so only this outer boundary needs the special-case scan.
const open = indexOfOpenBraceSkippingComments(rest) orelse return;
// Search only past the opening brace — the matching `}` lives there, and
// any returned position is naturally `> open` (since `rest[open] == '{'`).
const close = open + (std.mem.lastIndexOfScalar(u8, rest[open..], '}') orelse return);
const query = std.mem.trim(u8, rest[0..open], &std.ascii.whitespace);
const inner = rest[open + 1 .. close];
const block = atRuleBlock(text, "@media") orelse return;
const query = std.mem.trim(u8, block.prelude, &std.ascii.whitespace);
if (!MediaQuery.matches(query, self.frame._page.getViewport())) return;
try self.applyInnerRules(block.body, depth + 1);
}
/// Apply an `@layer` block rule by parsing its inner block into the cascade
/// as if its rules lived at the top level, recursing into nested `@media` /
/// `@layer`. Cascade-layer priority ordering (css-cascade-5 §6.4) is
/// deliberately not implemented: layers are flattened and ties keep breaking
/// on specificity + document order. Handles named blocks (including dotted
/// sub-layer names like `@layer theme.dark`) and anonymous blocks; the
/// statement form (`@layer a, b;`) declares ordering only and carries no
/// block (`atRuleBlock` returns null), so it applies nothing.
fn applyLayerAtRule(self: *StyleManager, text: []const u8, depth: u8) Allocator.Error!void {
if (depth >= MAX_AT_RULE_NESTING) return;
// The layer-name prelude is intentionally ignored — layers are flattened.
const block = atRuleBlock(text, "@layer") orelse return;
try self.applyInnerRules(block.body, depth + 1);
}
/// Parse a conditional at-rule's inner block, adding style rules to the
/// cascade and recursing into nested `@media` / `@layer`. `depth` is the
/// nesting depth of the *nested* at-rules (callers pass their own depth + 1).
fn applyInnerRules(self: *StyleManager, inner: []const u8, depth: u8) Allocator.Error!void {
var it = CssParser.parseStylesheet(inner);
while (it.next()) |nested_rule| {
switch (nested_rule) {
.style => |s| try self.addRawRule(s.selector, s.block),
.at_rule => |nested| {
if (std.ascii.eqlIgnoreCase(nested.keyword, "media")) {
try self.applyMediaAtRule(nested.text, depth + 1);
try self.applyMediaAtRule(nested.text, depth);
} else if (std.ascii.eqlIgnoreCase(nested.keyword, "layer")) {
try self.applyLayerAtRule(nested.text, depth);
}
},
}
}
}
/// Split a block at-rule into its prelude and inner block: for
/// `@media <query> { <body> }` returns `.{ .prelude = "<query>", .body = "<body>" }`.
/// Returns `null` when `text` doesn't start with `keyword`, or when it carries
/// no block — notably the statement form (`@layer a, b;`), which declares
/// ordering only.
///
/// `CssParser.RulesIterator.consumeAtRule` always emits a span starting at `@`;
/// for unclosed blocks it runs to EOF, so the closing `}` is located explicitly
/// rather than assumed to be the final byte. The opening `{` is found with a
/// comment-aware scan so a `/* { */` in the prelude doesn't split the rule at
/// the wrong place; the block's own trivia is handled by the CssParser re-parse
/// in `applyInnerRules`, so only this outer boundary needs the special-case scan.
fn atRuleBlock(text: []const u8, keyword: []const u8) ?struct { prelude: []const u8, body: []const u8 } {
if (!std.ascii.startsWithIgnoreCase(text, keyword)) return null;
const rest = text[keyword.len..];
const open = indexOfOpenBraceSkippingComments(rest) orelse return null;
// Search only past the opening brace — the matching `}` lives there, and
// any returned position is naturally `> open` (since `rest[open] == '{'`).
const close = open + (std.mem.lastIndexOfScalar(u8, rest[open..], '}') orelse return null);
return .{ .prelude = rest[0..open], .body = rest[open + 1 .. close] };
}
/// Find the first `{` in `s` that is not inside a CSS `/* ... */` comment.
/// An unclosed comment returns `null` (treat the whole rule as malformed).
fn indexOfOpenBraceSkippingComments(s: []const u8) ?usize {

View File

@@ -829,6 +829,12 @@ pub fn createElementNS(frame: *Frame, namespace: Element.Namespace, name: []cons
._definition = definition,
});
// Fragment-parse context element. It will not be inserted and
// we should not run the custom element's constructor.
if (frame._skip_custom_element_upgrade) {
return node;
}
const def = definition orelse {
const element = node.as(Element);
const custom = element.is(Element.Html.Custom).?;
@@ -879,16 +885,47 @@ pub fn createElementNS(frame: *Frame, namespace: Element.Namespace, name: []cons
return createHtmlElementT(frame, Element.Html.Unknown, namespace, attribute_iterator, .{ ._proto = undefined, ._tag_name = tag_name });
},
.svg => {
const tag_name = try String.init(frame.arena, name, .{});
if (std.ascii.eqlIgnoreCase(name, "svg")) {
return createSvgElementT(frame, Element.Svg, name, attribute_iterator, .{
._proto = undefined,
._type = .svg,
._tag_name = tag_name,
});
const Graphics = Element.Svg.Graphics;
const Geometry = Graphics.Geometry;
// SVG tag names are case-sensitive; no lowering before matching.
switch (name.len) {
1 => switch (name[0]) {
'g' => return createSvgGraphicsElementT(frame, Graphics.G, name, attribute_iterator),
'a' => return createSvgGraphicsElementT(frame, Graphics.A, name, attribute_iterator),
else => {},
},
3 => switch (@as(u24, @bitCast(name[0..3].*))) {
asUint("svg") => return createSvgGraphicsElementT(frame, Graphics.Svg, name, attribute_iterator),
asUint("use") => return createSvgGraphicsElementT(frame, Graphics.Use, name, attribute_iterator),
else => {},
},
4 => switch (@as(u32, @bitCast(name[0..4].*))) {
asUint("defs") => return createSvgGraphicsElementT(frame, Graphics.Defs, name, attribute_iterator),
asUint("rect") => return createSvgGeometryElementT(frame, Geometry.Rect, name, attribute_iterator),
asUint("line") => return createSvgGeometryElementT(frame, Geometry.Line, name, attribute_iterator),
asUint("path") => return createSvgGeometryElementT(frame, Geometry.Path, name, attribute_iterator),
else => {},
},
5 => switch (@as(u40, @bitCast(name[0..5].*))) {
asUint("image") => return createSvgGraphicsElementT(frame, Graphics.Image, name, attribute_iterator),
else => {},
},
6 => switch (@as(u48, @bitCast(name[0..6].*))) {
asUint("circle") => return createSvgGeometryElementT(frame, Geometry.Circle, name, attribute_iterator),
else => {},
},
7 => switch (@as(u56, @bitCast(name[0..7].*))) {
asUint("ellipse") => return createSvgGeometryElementT(frame, Geometry.Ellipse, name, attribute_iterator),
asUint("polygon") => return createSvgGeometryElementT(frame, Geometry.Polygon, name, attribute_iterator),
else => {},
},
8 => switch (@as(u64, @bitCast(name[0..8].*))) {
asUint("polyline") => return createSvgGeometryElementT(frame, Geometry.Polyline, name, attribute_iterator),
else => {},
},
else => {},
}
// Other SVG elements (rect, circle, text, g, etc.)
const lower = std.ascii.lowerString(&frame.buf, name);
const tag = std.meta.stringToEnum(Element.Tag, lower) orelse .unknown;
return createSvgElementT(frame, Element.Svg.Generic, name, attribute_iterator, .{ ._proto = undefined, ._tag = tag });
@@ -930,7 +967,20 @@ fn createHtmlMediaElementT(frame: *Frame, comptime E: type, namespace: Element.N
fn createSvgElementT(frame: *Frame, comptime E: type, tag_name: []const u8, attribute_iterator: anytype, svg_element: E) !*Node {
const svg_element_ptr = try frame._factory.svgElement(tag_name, svg_element);
var element = svg_element_ptr.asElement();
return initSvgElement(frame, svg_element_ptr.asElement(), attribute_iterator);
}
fn createSvgGraphicsElementT(frame: *Frame, comptime E: type, tag_name: []const u8, attribute_iterator: anytype) !*Node {
const svg_element_ptr = try frame._factory.svgGraphicsElement(tag_name, E{ ._proto = undefined });
return initSvgElement(frame, svg_element_ptr.asElement(), attribute_iterator);
}
fn createSvgGeometryElementT(frame: *Frame, comptime E: type, tag_name: []const u8, attribute_iterator: anytype) !*Node {
const svg_element_ptr = try frame._factory.svgGeometryElement(tag_name, E{ ._proto = undefined });
return initSvgElement(frame, svg_element_ptr.asElement(), attribute_iterator);
}
fn initSvgElement(frame: *Frame, element: *Element, attribute_iterator: anytype) !*Node {
element._namespace = .svg;
element._attributes.normalize = false;
try populateElementAttributes(frame, element, attribute_iterator);

View File

@@ -0,0 +1,196 @@
// 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/>.
// Speculative script fetching: <link rel=preload/modulepreload> hints and the
// pre-parse scan of the raw HTML. All of it only starts downloads early —
// consumption happens in ScriptManager / ScriptManagerBase when the real
// <script> element or import shows up.
const std = @import("std");
const URL = @import("../URL.zig");
const Frame = @import("../Frame.zig");
const Parser = @import("../parser/Parser.zig");
const Element = @import("../webapi/Element.zig");
const Allocator = std.mem.Allocator;
// start prefetching <link rel="preload" as="script" href=...>`. element is the
// hint <link> to fire load/error on, null when the hint came from the prescan.
pub fn scriptHint(frame: *Frame, element: ?*Element.Html, href: []const u8) bool {
if (frame.isGoingAway() or frame._parse_mode == .fragment) {
return false;
}
const arena = frame.getArena(.small, "preload.scriptHint") catch return false;
defer frame.releaseArena(arena);
const resolved = URL.resolve(arena, frame.base(), href, .{ .encoding = frame.charset }) catch return false;
if (!isRemoteScheme(resolved)) {
return false;
}
return frame._script_manager.preloadScript(element, resolved) catch false;
}
// start prefetching <link rel="modulepreload" href=...>. element is the hint
// <link> to fire load/error on, null when the hint came from the prescan.
pub fn moduleHint(frame: *Frame, element: ?*Element.Html, href: []const u8) bool {
if (frame.isGoingAway() or frame._parse_mode == .fragment) {
return false;
}
if (hasNonRemoteScheme(href)) {
return false;
}
// The url becomes the imported_modules key, which must outlive the fetch
// so it lives on the frame arena
const resolved = URL.resolve(frame.arena, frame.base(), href, .{ .encoding = frame.charset }) catch return false;
if (!isRemoteScheme(resolved)) {
return false;
}
return frame._script_manager.base.preloadModuleHint(element, resolved, frame.url) catch false;
}
// Scan the HTML for <script src=...> before parsing so that we can start
// fetching them ASAP. The downside is we might accidentally fetch more than we
// should, but the upside can be a pretty significant performance improvement.
// Without this, N large blocking <script src=...> tags are downloaded serially.
// This essentially does the same thing as the <link preload/preloadModule> but
// without needing anything special from the HTML.
pub fn prescan(frame: *Frame, html: []const u8) void {
if (frame.isGoingAway() or frame._parse_mode == .fragment) {
return;
}
const arena = frame.getArena(.small, "preload.prescan") catch return;
defer frame.releaseArena(arena);
var scan = Prescan{ .frame = frame, .base = frame.base(), .arena = arena };
Parser.prescan(html, frame.charset, &scan, Prescan.callback);
}
const Prescan = struct {
frame: *Frame,
arena: Allocator,
base: [:0]const u8,
fn callback(ctx: *anyopaque, kind: Parser.PrescanResource, url_ptr: [*c]const u8, url_len: usize) callconv(.c) void {
const self: *Prescan = @ptrCast(@alignCast(ctx));
if (url_len == 0) {
return;
}
const href = url_ptr[0..url_len];
const frame = self.frame;
switch (kind) {
.base => {
self.base = URL.resolve(self.arena, self.base, href, .{ .encoding = frame.charset }) catch return;
},
.script => {
if (hasNonRemoteScheme(href)) {
return;
}
const resolved = URL.resolve(self.arena, self.base, href, .{ .encoding = frame.charset }) catch return;
if (isRemoteScheme(resolved) == false) {
return;
}
_ = frame._script_manager.preloadScript(null, resolved) catch {};
},
.module => {
if (hasNonRemoteScheme(href)) {
return;
}
// The url becomes the imported_modules key, which must
// outlive the fetch so it lives on the frame arena.
const resolved = URL.resolve(frame.arena, self.base, href, .{ .encoding = frame.charset }) catch return;
if (isRemoteScheme(resolved) == false) {
return;
}
_ = frame._script_manager.base.preloadModuleHint(null, resolved, frame.url) catch {};
},
_ => {},
}
}
};
fn isRemoteScheme(url: []const u8) bool {
return std.ascii.startsWithIgnoreCase(url, "http:") or std.ascii.startsWithIgnoreCase(url, "https:");
}
// Non-http(s) scheme (e.g. data:, blob:) never resolve to a remote URL. We
// detect this upfront to prevent uncessary URL.resolves that would dupe the
// (potentially very lage) data.
fn hasNonRemoteScheme(href: []const u8) bool {
if (isRemoteScheme(href)) {
return false;
}
if (href.len == 0 or !std.ascii.isAlphabetic(href[0])) {
return false;
}
for (href[1..]) |c| {
switch (c) {
':' => return true,
'a'...'z', 'A'...'Z', '0'...'9', '+', '-', '.' => {},
else => return false,
}
}
return false;
}
const testing = @import("../../testing.zig");
test "preload: prescan" {
defer testing.reset();
const page = try testing.pageTest("mcp_nav.html", .{});
defer page.close();
const frame = page.frame().?;
// The fetches this starts stay in flight (nothing ticks the client before
// page.close aborts them), so the maps hold exactly what the scan found.
// All srcs sit under /serve-count/ because the test server panics on
// unknown paths elsewhere; unknown names there get a plain 404.
prescan(frame,
\\<html><head>
\\<script src="/serve-count/unit_a.js"></script>
\\<script src="/serve-count/unit_a.js"></script>
\\<script type="module" src="/serve-count/unit_m.js"></script>
\\<script src="/serve-count/unit_skipped.js" nomodule></script>
\\<script type="application/json" src="/serve-count/unit_data.js"></script>
\\<script src="data:text/javascript,window.unit_data_url = 1;"></script>
\\<script type="module" src="data:text/javascript,window.unit_data_module = 1;"></script>
\\<script>var trap = "</scr" + "ipt><script src=/serve-count/unit_inline.js>";</script>
\\<!-- <script src="/serve-count/unit_commented.js"></script> -->
\\<template><script src="/serve-count/unit_templated.js"></script></template>
\\<noscript><script src="/serve-count/unit_noscripted.js"></script></noscript>
\\<style>p:before { content: "<script src=/serve-count/unit_styled.js></script>" }</style>
\\<base href="/serve-count/sub/">
\\<base href="/serve-count/ignored/">
\\<script src="based.js"></script>
\\</head><body></body></html>
);
const sm = &frame._script_manager;
try testing.expectEqual(2, sm.preloaded_scripts.count());
try testing.expectEqual(true, sm.preloaded_scripts.contains("http://127.0.0.1:9582/serve-count/unit_a.js"));
try testing.expectEqual(true, sm.preloaded_scripts.contains("http://127.0.0.1:9582/serve-count/sub/based.js"));
try testing.expectEqual(1, sm.base.imported_modules.count());
const module = sm.base.imported_modules.get("http://127.0.0.1:9582/serve-count/unit_m.js") orelse return error.MissingModule;
try testing.expectEqual(true, module.hint);
}

View File

@@ -27,6 +27,7 @@ const lp = @import("lightpanda");
const builtin = @import("builtin");
const Frame = @import("../Frame.zig");
const js = @import("../js/js.zig");
const Node = @import("../webapi/Node.zig");
const Event = @import("../webapi/Event.zig");
@@ -78,6 +79,7 @@ pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32) !void {
});
}
try dispatchMouseEventOn(frame, target, "mousedown", x, y, button, 0);
try focusEditingHostForMouseDown(frame, target);
}
pub fn triggerMouseMove(frame: *Frame, x: f64, y: f64) !void {
@@ -200,6 +202,128 @@ fn deltaToScroll(d: f64) i32 {
}
// callback when the "click" event reaches the frame.
// Whether the element has a click activation behavior that handleClick
// implements.
fn hasClickActivationBehavior(node: *Node) bool {
const element = node.is(Element) orelse return false;
const html_element = element.is(Element.Html) orelse return false;
return switch (html_element._type) {
.anchor => element.getAttributeSafe(comptime .wrap("href")) != null,
.input, .button, .select, .textarea, .label => true,
.generic => |generic| generic._tag == .summary,
else => false,
};
}
// Clicks on editable content are for editing: they don't activate the
// element or any enclosing link.
// "contenteditable" is 15 bytes — past the comptime SSO limit — so the
// String wrap runs at runtime, mirroring Html.getIsContentEditable.
fn isEditingHost(node: *Node) bool {
const element = node.is(Element) orelse return false;
const value = element.getAttributeSafe(.wrap("contenteditable")) orelse return false;
return std.ascii.eqlIgnoreCase(value, "false") == false;
}
// A mousedown on editable content focuses its editing host: the outermost
// element of the contiguous editable chain containing the target.
pub fn focusEditingHostForMouseDown(frame: *Frame, target: *Element) !void {
var node: ?*Node = target.asNode();
var editable: ?*Node = null;
while (node) |n| : (node = n._parent) {
if (isEditingHost(n)) {
editable = n;
break;
}
}
var host = editable orelse return;
while (host._parent) |p| {
if (!isEditingHost(p)) {
break;
}
host = p;
}
const host_element = host.is(Element) orelse return;
try host_element.focus(frame);
}
// Per the DOM dispatch algorithm, a click's activation target is the event
// target itself when it has activation behavior, otherwise — for bubbling
// events only — the nearest ancestor that has one.
pub fn findClickActivationTarget(target: *Node, bubbles: bool) ?*Node {
if (isEditingHost(target)) {
return null;
}
if (hasClickActivationBehavior(target)) {
return target;
}
if (!bubbles) {
return null;
}
var node = target._parent;
while (node) |n| : (node = n._parent) {
if (isEditingHost(n)) {
return null;
}
if (hasClickActivationBehavior(n)) {
return n;
}
}
return null;
}
fn runJavascriptUrl(frame: *Frame, source: []const u8) !void {
const arena = try frame.getArena(.tiny, "javascript-url");
errdefer frame.releaseArena(arena);
const task = try arena.create(JavascriptUrlTask);
task.* = .{
.frame = frame,
.arena = arena,
// TODO: the URL body should be percent-decoded; hrefs written in
// markup rarely are.
.source = try arena.dupe(u8, source),
};
try frame.js.scheduler.add(task, JavascriptUrlTask.run, 0, .{
.name = "javascript-url",
.finalizer = JavascriptUrlTask.finalize,
});
}
const JavascriptUrlTask = struct {
frame: *Frame,
arena: std.mem.Allocator,
source: []const u8,
fn run(ptr: *anyopaque) !?u32 {
const self: *JavascriptUrlTask = @ptrCast(@alignCast(ptr));
const frame = self.frame;
defer self.deinit();
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
const script = ls.local.compile(self.source, "javascript:") catch |err| {
log.warn(.browser, "javascript-url compile", .{ .err = err, .type = frame._type, .url = frame.url });
return null;
};
_ = script.run() catch |err| {
log.warn(.browser, "javascript-url run", .{ .err = err, .type = frame._type, .url = frame.url });
};
return null;
}
fn finalize(ptr: *anyopaque) void {
const self: *JavascriptUrlTask = @ptrCast(@alignCast(ptr));
self.deinit();
}
fn deinit(self: *JavascriptUrlTask) void {
self.frame.releaseArena(self.arena);
}
};
pub fn handleClick(frame: *Frame, target: *Node) !void {
// TODO: Also support <area> elements when implement
const element = target.is(Element) orelse return;
@@ -213,7 +337,10 @@ pub fn handleClick(frame: *Frame, target: *Node) !void {
}
if (std.mem.startsWith(u8, href, "javascript:")) {
return;
// Navigating to a javascript: URL evaluates the script in the
// node's frame as a queued task. (A string completion value
// would replace the document; we ignore results.)
return runJavascriptUrl(target.ownerFrame(frame), href["javascript:".len..]);
}
if (try element.hasAttribute(comptime .wrap("download"), frame)) {

View File

@@ -448,7 +448,7 @@ pub fn getTextContent(node: *Node, arena: Allocator) !?[]const u8 {
}
if (child.is(Node.CData)) |cdata| {
if (cdata.is(Node.CData.Text)) |text| {
const content = std.mem.trim(u8, text.getWholeText(), &std.ascii.whitespace);
const content = std.mem.trim(u8, text.ownData(), &std.ascii.whitespace);
if (content.len > 0) {
if (single_chunk == null and arr.items.len == 0) {
single_chunk = content;

View File

@@ -209,59 +209,6 @@ fn _getIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, inf
return handleIndexedReturn(T, F, true, local, ret, info, opts);
}
pub fn setIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, js_value: *const v8.Value, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
const local = &self.local;
var hs: js.HandleScope = undefined;
hs.init(local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return _setIndex(T, local, func, idx, .{ .local = &self.local, .handle = js_value }, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info);
return js.Intercepted.no;
};
}
fn _setIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, js_value: js.Value, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = idx;
@field(args, "2") = try local.jsValueToZig(@TypeOf(@field(args, "2")), js_value);
if (@typeInfo(F).@"fn".params.len == 4) {
@field(args, "3") = getGlobalArg(@TypeOf(args.@"3"), local.ctx);
}
const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, false, local, ret, info, opts);
}
pub fn deleteIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
const local = &self.local;
var hs: js.HandleScope = undefined;
hs.init(local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return _deleteIndex(T, local, func, idx, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info);
return js.Intercepted.no;
};
}
fn _deleteIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = idx;
if (@typeInfo(F).@"fn".params.len == 3) {
@field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx);
}
const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, false, local, ret, info, opts);
}
pub fn getNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
const local = &self.local;
@@ -288,6 +235,59 @@ fn _getNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *c
return handleIndexedReturn(T, F, true, local, ret, info, opts);
}
pub fn setIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, js_value: *const v8.Value, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
const local = &self.local;
var hs: js.HandleScope = undefined;
hs.init(local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return _setIndex(T, local, func, idx, .{ .local = &self.local, .handle = js_value }, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info);
return js.Intercepted.no;
};
}
fn _setIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, js_value: js.Value, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = idx;
@field(args, "2") = try local.jsValueToZig(@TypeOf(@field(args, "2")), js_value);
if (@typeInfo(F).@"fn".params.len == 4) {
@field(args, "3") = getGlobalArg(@TypeOf(args.@"3"), local.ctx);
}
const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, comptime returnsBool(F), local, ret, info, opts);
}
pub fn deleteOrDefineIndex(self: *Caller, comptime T: type, func: anytype, idx: u32, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
const local = &self.local;
var hs: js.HandleScope = undefined;
hs.init(local.isolate);
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return _deleteOrDefineIndex(T, local, func, idx, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info);
return js.Intercepted.no;
};
}
fn _deleteOrDefineIndex(comptime T: type, local: *const Local, func: anytype, idx: u32, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@field(args, "1") = idx;
if (@typeInfo(F).@"fn".params.len == 3) {
@field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx);
}
const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, comptime returnsBool(F), local, ret, info, opts);
}
pub fn setNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, js_value: *const v8.Value, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
const local = &self.local;
@@ -312,10 +312,10 @@ fn _setNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *c
@field(args, "3") = getGlobalArg(@TypeOf(args.@"3"), local.ctx);
}
const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, false, local, ret, info, opts);
return handleIndexedReturn(T, F, comptime returnsBool(F), local, ret, info, opts);
}
pub fn deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
pub fn deleteOrDefineNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
const local = &self.local;
var hs: js.HandleScope = undefined;
@@ -323,13 +323,13 @@ pub fn deleteNamedIndex(self: *Caller, comptime T: type, func: anytype, name: *c
defer hs.deinit();
const info = PropertyCallbackInfo{ .handle = handle };
return _deleteNamedIndex(T, local, func, name, info, opts) catch |err| {
return _deleteOrDefineNamedIndex(T, local, func, name, info, opts) catch |err| {
handleError(T, @TypeOf(func), local, err, info);
return js.Intercepted.no;
};
}
fn _deleteNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 {
fn _deleteOrDefineNamedIndex(comptime T: type, local: *const Local, func: anytype, name: *const v8.Name, info: PropertyCallbackInfo, comptime opts: CallOpts) !u32 {
const F = @TypeOf(func);
var args: ParameterTypes(F) = undefined;
@field(args, "0") = try TaggedOpaque.fromJS(*T, info.getThis());
@@ -338,7 +338,7 @@ fn _deleteNamedIndex(comptime T: type, local: *const Local, func: anytype, name:
@field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx);
}
const ret = @call(.auto, func, args);
return handleIndexedReturn(T, F, false, local, ret, info, opts);
return handleIndexedReturn(T, F, comptime returnsBool(F), local, ret, info, opts);
}
pub fn getEnumerator(self: *Caller, comptime T: type, func: anytype, handle: *const v8.PropertyCallbackInfo, comptime opts: CallOpts) u32 {
@@ -388,11 +388,7 @@ fn _getIndexQuery(comptime T: type, local: *const Local, func: anytype, idx: u32
if (@typeInfo(F).@"fn".params.len == 3) {
@field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx);
}
if (@call(.auto, func, args) == false) {
return js.Intercepted.no;
}
info.getReturnValue().set(try local.zigValueToJs(@as(u32, v8.None), .{}));
return js.Intercepted.yes;
return queryReturn(local, @call(.auto, func, args), info);
}
pub fn getNamedQuery(self: *Caller, comptime T: type, func: anytype, name: *const v8.Name, handle: *const v8.PropertyCallbackInfo) u32 {
@@ -417,12 +413,33 @@ fn _getNamedQuery(comptime T: type, local: *const Local, func: anytype, name: *c
if (@typeInfo(F).@"fn".params.len == 3) {
@field(args, "2") = getGlobalArg(@TypeOf(args.@"2"), local.ctx);
}
if (@call(.auto, func, args) == false) {
return js.Intercepted.no;
return queryReturn(local, @call(.auto, func, args), info);
}
// A query callback either returns a bool (true -> the property exists as an
// enumerable, writable, configurable data property, PropertyAttribute.None)
// or the v8.PropertyAttribute bits directly (e.g. v8.ReadOnly).
// error.NotHandled falls through to the ordinary property lookup.
fn queryReturn(local: *const Local, ret: anytype, info: PropertyCallbackInfo) !u32 {
const val = switch (@typeInfo(@TypeOf(ret))) {
.error_union => |eu| ret catch |err| {
if (comptime isInErrorSet(error.NotHandled, eu.error_set)) {
if (err == error.NotHandled) {
return js.Intercepted.no;
}
}
return err;
},
else => ret,
};
if (@TypeOf(val) == bool) {
if (val == false) {
return js.Intercepted.no;
}
info.getReturnValue().set(try local.zigValueToJs(@as(u32, v8.None), .{}));
} else {
info.getReturnValue().set(try local.zigValueToJs(@as(u32, val), .{}));
}
// The property exists as a supported property name; report it as an
// enumerable, writable, configurable data property (PropertyAttribute.None).
info.getReturnValue().set(try local.zigValueToJs(@as(u32, v8.None), .{}));
return js.Intercepted.yes;
}
@@ -453,6 +470,18 @@ fn handleIndexedReturn(comptime T: type, comptime F: type, comptime with_value:
return js.Intercepted.yes;
}
// Setter/deleter interceptors normally return void: intercepting is enough
// to mark the operation successful. When they return a bool instead, it is
// forwarded as the v8 return value; false marks the operation as failed,
// which makes v8 throw a TypeError in strict mode.
fn returnsBool(comptime F: type) bool {
const RT = @typeInfo(F).@"fn".return_type.?;
return switch (@typeInfo(RT)) {
.error_union => |eu| eu.payload == bool,
else => RT == bool,
};
}
fn isInErrorSet(err: anyerror, comptime T: type) bool {
inline for (@typeInfo(T).error_set.?) |e| {
if (err == @field(anyerror, e.name)) return true;
@@ -471,6 +500,65 @@ fn nameToString(local: *const Local, comptime T: type, name: *const v8.Name) !T
return try js.String.toSlice(.{ .local = local, .handle = handle });
}
// Per Web IDL, exceptions belong to the operation's relevant realm — the
// receiver's — which differs from the calling realm for cross-realm calls
// (v8 API callbacks run in the caller's context, and our JS wrappers are
// shared across the page's contexts). For DOM nodes, the relevant realm is
// the node document's frame; otherwise fall back to the calling realm.
fn errorLocal(comptime T: type, local: *const Local, info: anytype) Local {
if (@TypeOf(info) != FunctionCallbackInfo) {
return local.*;
}
const frame = switch (local.ctx.global) {
.frame => |f| f,
.worker => return local.*,
};
const Node = @import("../webapi/Node.zig");
const Document = @import("../webapi/Document.zig");
const is_node_type = comptime blk: {
if (@typeInfo(T) != .@"struct" or !@hasDecl(T, "JsApi")) break :blk false;
break :blk @import("bridge.zig").inheritsOrIs(T.JsApi, Node.JsApi);
};
if (comptime !is_node_type) {
return local.*;
}
const instance = TaggedOpaque.fromJS(*T, info.getThis()) catch return local.*;
const node = protoNode(T, instance);
const doc: *Document = node.ownerDocument(frame) orelse switch (node._type) {
.document => |d| d,
else => return local.*,
};
const doc_frame = doc._frame orelse return local.*;
if (doc_frame == frame) {
return local.*;
}
const ctx = doc_frame.js;
const local_v8_context: *const v8.Context = @ptrCast(v8.v8__Global__Get(&ctx.handle, ctx.isolate.handle) orelse return local.*);
return .{
.ctx = ctx,
.handle = local_v8_context,
.call_arena = ctx.call_arena,
.isolate = ctx.isolate,
};
}
// Upcast a Node-descendant instance to *Node by walking the _proto chain.
// Not every node type defines an asNode() helper (e.g. Comment, Text), but
// inheritsOrIs guarantees Node is in the chain
fn protoNode(comptime T: type, instance: *T) *@import("../webapi/Node.zig") {
if (T == @import("../webapi/Node.zig")) {
return instance;
}
const Proto = @typeInfo(std.meta.fieldInfo(T, ._proto).type).pointer.child;
return protoNode(Proto, instance._proto);
}
fn handleError(comptime T: type, comptime F: type, local: *const Local, err: anyerror, info: anytype) void {
const isolate = local.isolate;
@@ -484,14 +572,34 @@ fn handleError(comptime T: type, comptime F: type, local: *const Local, err: any
}
}
const js_err: *const v8.Value = switch (err) {
// early exit
switch (err) {
error.TryCatchRethrow => return,
error.InvalidArgument => isolate.createTypeError("invalid argument"),
error.TypeError => isolate.createTypeError(""),
error.RangeError => isolate.createRangeError(""),
error.OutOfMemory => isolate.createError("out of memory"),
error.IllegalConstructor => isolate.createError("Illegal Constructor"),
else => domExceptionToJs(local, err) orelse isolate.createError(@errorName(err)),
// A JS exception is already pending in the isolate (e.g. a value's
// toString threw during argument conversion); throwing anything here
// would replace the original exception the script expects to see.
error.JsException => return,
else => {},
}
const err_local = errorLocal(T, local, info);
const js_err: *const v8.Value = blk: {
// Error constructors use the isolate's current context: enter the
// receiver's realm so the exception gets its prototypes.
const entered = err_local.ctx != local.ctx;
if (entered) v8.v8__Context__Enter(err_local.handle);
defer if (entered) v8.v8__Context__Exit(err_local.handle);
break :blk switch (err) {
error.InvalidArgument => isolate.createTypeError("invalid argument"),
error.TypeError => isolate.createTypeError(""),
error.RangeError => isolate.createRangeError(""),
error.OutOfMemory => isolate.createError("out of memory"),
error.IllegalConstructor => isolate.createError("Illegal Constructor"),
error.TryCatchRethrow, error.JsException => unreachable, // early exited a few lines up
else => domExceptionToJs(&err_local, err) orelse isolate.createError(@errorName(err)),
};
};
const js_exception = isolate.throwException(js_err);
@@ -686,6 +794,7 @@ pub const Function = struct {
exposed: Exposed = .both,
ce_reactions: bool = false,
js_name: ?[:0]const u8 = null,
unforgeable: bool = false,
pub const Exposed = enum { both, window, worker };
@@ -987,6 +1096,11 @@ fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info:
// type instantiation of jsValueToZig may not include such errors
// in its inferred error set.
@field(args, tupleFieldName(field_index)) = local.jsValueToZig(param.type.?, js_val) catch |err| {
if (err == error.JsException) {
// an exception thrown by user code (e.g. a toString
// getter) is pending; propagate it untouched
return err;
}
const DOMException = @import("../webapi/DOMException.zig");
if (DOMException.fromError(err) != null) {
return err;

View File

@@ -277,14 +277,6 @@ pub fn setOrigin(self: *Context, key: ?[]const u8) !void {
}
}
pub fn trackGlobal(self: *Context, global: v8.Global) !void {
return self.page.globals.append(self.page.frame_arena, global);
}
pub fn trackTemp(self: *Context, global: v8.Global) !void {
return self.page.temps.put(self.page.frame_arena, global.data_ptr, global);
}
pub const IdentityResult = struct {
value_ptr: *v8.Global,
found_existing: bool,
@@ -1134,7 +1126,7 @@ fn enqueueMicrotask(self: *Context, callback: anytype) void {
// this should be safe (I think). In whatever HandleScope a microtask is enqueued,
// PerformCheckpoint should be run. So the v8::Local<v8::Function> should remain
// valid. If we have problems with this, a simple solution is to provide a Zig
// wrapper for these callbacks which references a js.Function.Temp, on callback
// wrapper for these callbacks which references a js.Function, on callback
// it executes the function and then releases the global.
pub fn queueMicrotaskFunc(self: *Context, cb: js.Function) void {
// Use context-specific microtask queue instead of isolate queue

View File

@@ -31,6 +31,7 @@ const App = @import("../../App.zig");
const Frame = @import("../Frame.zig");
const Window = @import("../webapi/Window.zig");
const WorkerGlobalScope = @import("../webapi/WorkerGlobalScope.zig");
const SharedWorkerGlobalScope = @import("../webapi/SharedWorkerGlobalScope.zig");
const DedicatedWorkerGlobalScope = @import("../webapi/DedicatedWorkerGlobalScope.zig");
const v8 = js.v8;
@@ -274,8 +275,12 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context
break :blk @ptrCast(v8.v8__Global__Get(existing, isolate.handle));
};
// Restore the context from the snapshot (0 = Page, 1 = Worker)
const snapshot_index: u32 = if (comptime is_frame) 0 else 1;
// Restore the context from the snapshot
// (0 = Page, 1 = DedicatedWorker, 2 = SharedWorker)
const snapshot_index: u32 = if (comptime is_frame) 0 else switch (global._type) {
.dedicated => 1,
.shared => 2,
};
const v8_context = v8.v8__Context__FromSnapshot__Config(isolate.handle, snapshot_index, &.{
.global_template = null,
.global_object = reuse_global_object,
@@ -297,11 +302,19 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context
.prototype_chain = (&Window.JsApi.Meta.prototype_chain).ptr,
.prototype_len = @intCast(Window.JsApi.Meta.prototype_chain.len),
.subtype = .node,
} else .{
.value = @ptrCast(global._type.dedicated),
.prototype_chain = (&DedicatedWorkerGlobalScope.JsApi.Meta.prototype_chain).ptr,
.prototype_len = @intCast(DedicatedWorkerGlobalScope.JsApi.Meta.prototype_chain.len),
.subtype = null,
} else switch (global._type) {
.dedicated => |scope| .{
.value = @ptrCast(scope),
.prototype_chain = (&DedicatedWorkerGlobalScope.JsApi.Meta.prototype_chain).ptr,
.prototype_len = @intCast(DedicatedWorkerGlobalScope.JsApi.Meta.prototype_chain.len),
.subtype = null,
},
.shared => |scope| .{
.value = @ptrCast(scope),
.prototype_chain = (&SharedWorkerGlobalScope.JsApi.Meta.prototype_chain).ptr,
.prototype_len = @intCast(SharedWorkerGlobalScope.JsApi.Meta.prototype_chain.len),
.subtype = null,
},
};
v8.v8__Object__SetAlignedPointerInInternalField(global_obj, 0, tao);
@@ -349,7 +362,9 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context
// Register in the identity map. Multiple contexts can be created for the
// same global (via CDP), so we only register the first one.
const identity_ptr = if (comptime is_frame) @intFromPtr(global.window) else @intFromPtr(global._type.dedicated);
const identity_ptr = if (comptime is_frame) @intFromPtr(global.window) else switch (global._type) {
inline else => |scope| @intFromPtr(scope),
};
const gop = try params.identity.identity_map.getOrPut(params.identity_arena, identity_ptr);
if (gop.found_existing == false) {
var global_global: v8.Global = undefined;
@@ -578,6 +593,7 @@ pub fn protectHeapLimit(self: *Env) void {
// V8 expects.
fn nearHeapLimit(data: ?*anyopaque, current_limit: usize, initial_limit: usize) callconv(.c) usize {
const self: *Env = @ptrCast(@alignCast(data.?));
lp.metrics.js_heap_limits.incr();
log.err(.app, "JS heap limit reached", .{
.initial_limit = initial_limit,
.current_limit = current_limit,

View File

@@ -33,7 +33,7 @@ const Scheduler = @import("Scheduler.zig");
const Page = @import("../Page.zig");
const Session = @import("../Session.zig");
const Factory = @import("../Factory.zig");
const HttpClient = @import("../HttpClient.zig");
const HttpClient = @import("../../network/HttpClient.zig");
const EventManagerBase = @import("../EventManagerBase.zig");
const Event = @import("../webapi/Event.zig");
@@ -102,12 +102,27 @@ pub fn makeRequest(self: *const Execution, req: HttpClient.Request) !void {
};
}
// Two-phase variant; see HttpClient.newRequest for the ownership contract.
pub fn newRequest(self: *const Execution, req: HttpClient.Request) !*HttpClient.Transfer {
return switch (self.js.global) {
inline else => |g| g.newRequest(req),
};
}
pub fn getBroadcastChannels(self: *const Execution) *std.DoublyLinkedList {
return switch (self.js.global) {
inline else => |g| &g._broadcast_channels,
};
}
// The owning global's (Frame or WGS) list of live MessagePorts, walked at
// that global's teardown to sever cross-context entanglement.
pub fn messagePorts(self: *const Execution) *std.DoublyLinkedList {
return switch (self.js.global) {
inline else => |g| &g._message_ports,
};
}
// The global's serialized origin (e.g. "https://example.com"), or null for an
// opaque origin.
pub fn origin(self: *const Execution) ?[]const u8 {

View File

@@ -95,7 +95,11 @@ pub fn call(self: *const Function, comptime T: type, args: anytype) !T {
pub fn callRethrow(self: *const Function, comptime T: type, args: anytype) !T {
var caught: js.TryCatch.Caught = undefined;
return self._tryCallWithThis(T, self.getThis(), args, &caught, .{ .rethrow = true }) catch |err| {
log.warn(.js, "call caught", .{ .err = err, .caught = caught });
if (err != error.TryCatchRethrow) {
// error.TryCatchRethrow is a control flow (sorry!), not an actual
// error we want to log
log.warn(.js, "call caught", .{ .err = err, .caught = caught });
}
return err;
};
}
@@ -108,6 +112,14 @@ pub fn callWithThis(self: *const Function, comptime T: type, this: anytype, args
};
}
// Like callWithThis, but a thrown JS exception is rethrown past the internal
// TryCatch, so an enclosing TryCatch of the caller can observe the exception
// value itself (e.g. to report it to window.onerror).
pub fn callWithThisRethrow(self: *const Function, comptime T: type, this: anytype, args: anytype) !T {
var caught: js.TryCatch.Caught = undefined;
return self._tryCallWithThis(T, this, args, &caught, .{ .rethrow = true });
}
pub fn tryCall(self: *const Function, comptime T: type, args: anytype, caught: *js.TryCatch.Caught) !T {
return self._tryCallWithThis(T, self.getThis(), args, caught, .{});
}
@@ -227,29 +239,7 @@ pub fn getPropertyValue(self: *const Function, name: []const u8) !?js.Value {
}
pub fn persist(self: *const Function) !Global {
return self._persist(true);
}
pub fn temp(self: *const Function) !Temp {
return self._persist(false);
}
fn _persist(self: *const Function, comptime is_global: bool) !(if (is_global) Global else Temp) {
var ctx = self.local.ctx;
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
if (comptime is_global) {
try ctx.trackGlobal(global);
return .{ .handle = global, .temps = {} };
}
try ctx.trackTemp(global);
return .{ .handle = global, .temps = &ctx.page.temps };
}
pub fn tempWithThis(self: *const Function, value: anytype) !Temp {
const with_this = try self.withThis(value);
return with_this.temp();
return .{ .slot = try js.newTrackedSlot(self.local.ctx, self.handle) };
}
pub fn persistWithThis(self: *const Function, value: anytype) !Global {
@@ -257,41 +247,23 @@ pub fn persistWithThis(self: *const Function, value: anytype) !Global {
return with_this.persist();
}
pub const Temp = G(.temp);
pub const Global = G(.global);
// A cheap, copyable handle to a persisted function. See js.GlobalSlot.
pub const Global = struct {
slot: *js.GlobalSlot,
const GlobalType = enum(u8) {
temp,
global,
pub fn deinit(self: Global) void {
self.slot.release();
}
pub const release = deinit;
pub fn local(self: Global, l: *const js.Local) Function {
return .{
.local = l,
.handle = @ptrCast(v8.v8__Global__Get(&self.slot.handle, l.isolate.handle)),
};
}
pub fn isEqual(self: Global, other: Function) bool {
return v8.v8__Global__IsEqual(&self.slot.handle, other.handle);
}
};
fn G(comptime global_type: GlobalType) type {
return struct {
handle: v8.Global,
temps: if (global_type == .temp) *std.AutoHashMapUnmanaged(usize, v8.Global) else void,
const Self = @This();
pub fn deinit(self: *Self) void {
v8.v8__Global__Reset(&self.handle);
}
pub fn local(self: *const Self, l: *const js.Local) Function {
return .{
.local = l,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
};
}
pub fn isEqual(self: *const Self, other: Function) bool {
return v8.v8__Global__IsEqual(&self.handle, other.handle);
}
pub fn release(self: *const Self) void {
if (self.temps.fetchRemove(self.handle.data_ptr)) |kv| {
var g = kv.value;
v8.v8__Global__Reset(&g);
}
}
};
}

View File

@@ -275,7 +275,10 @@ pub fn mapZigInstanceToJs(self: *const Local, js_obj_handle: ?*const v8.Object,
const gop = try ctx.addIdentity(resolved_ptr_id);
if (gop.found_existing) {
// we've seen this instance before, return the same object
return (js.Object.Global{ .handle = gop.value_ptr.* }).local(self);
return .{
.local = self,
.handle = @ptrCast(v8.v8__Global__Get(gop.value_ptr, self.isolate.handle)),
};
}
const isolate = self.isolate;
@@ -468,12 +471,9 @@ pub fn zigValueToJs(self: *const Local, value: anytype, comptime opts: CallOpts)
inline
js.Function.Global,
js.Function.Temp,
js.Value.Global,
js.Value.Temp,
js.Object.Global,
js.Promise.Global,
js.Promise.Temp,
js.PromiseResolver.Global,
js.Module.Global => return .{ .local = self, .handle = @ptrCast(value.local(self).handle) },
@@ -742,15 +742,22 @@ pub fn jsValueToZig(self: *const Local, comptime T: type, js_val: js.Value) !T {
// Extracted so that it can be used in both jsValueToZig and in
// probeJsValueToZig. Avoids having to duplicate this logic when probing.
fn jsValueToStruct(self: *const Local, comptime T: type, js_val: js.Value) !?T {
// js.Nullable(T): a required argument that accepts null.
if (@hasDecl(T, "js_nullable")) {
if (js_val.isNullOrUndefined()) {
return T{ .value = null };
}
return T{ .value = try self.jsValueToZig(T.js_nullable, js_val) };
}
return switch (T) {
js.Function, js.Function.Global, js.Function.Temp => {
js.Function, js.Function.Global => {
if (!js_val.isFunction()) {
return null;
}
const js_func = js.Function{ .local = self, .handle = @ptrCast(js_val.handle) };
return switch (T) {
js.Function => js_func,
js.Function.Temp => try js_func.temp(),
js.Function.Global => try js_func.persist(),
else => unreachable,
};
@@ -767,7 +774,6 @@ fn jsValueToStruct(self: *const Local, comptime T: type, js_val: js.Value) !?T {
},
js.Value => js_val,
js.Value.Global => return try js_val.persist(),
js.Value.Temp => return try js_val.temp(),
js.Object => {
if (!js_val.isObject()) {
return null;
@@ -788,7 +794,7 @@ fn jsValueToStruct(self: *const Local, comptime T: type, js_val: js.Value) !?T {
return try obj.persist();
},
js.Promise.Global, js.Promise.Temp => {
js.Promise.Global => {
if (!js_val.isPromise()) {
return null;
}
@@ -796,11 +802,7 @@ fn jsValueToStruct(self: *const Local, comptime T: type, js_val: js.Value) !?T {
.local = self,
.handle = @ptrCast(js_val.handle),
};
return switch (T) {
js.Promise.Temp => try js_promise.temp(),
js.Promise.Global => try js_promise.persist(),
else => unreachable,
};
return try js_promise.persist();
},
js.String => return js_val.isString(),
js.String.OneByte => {

View File

@@ -92,14 +92,7 @@ pub fn format(self: Object, writer: *std.Io.Writer) !void {
}
pub fn persist(self: Object) !Global {
var ctx = self.local.ctx;
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
try ctx.trackGlobal(global);
return .{ .handle = global };
return .{ .slot = try js.newTrackedSlot(self.local.ctx, self.handle) };
}
pub fn getFunction(self: Object, name: []const u8) !?js.Function {
@@ -171,21 +164,24 @@ pub fn toZig(self: Object, comptime T: type) !T {
}
pub const Global = struct {
handle: v8.Global,
slot: *js.GlobalSlot,
pub fn deinit(self: *Global) void {
v8.v8__Global__Reset(&self.handle);
pub fn deinit(self: Global) void {
self.slot.release();
}
pub fn local(self: *const Global, l: *const js.Local) Object {
// TODO: deprecated. @GlobalSlot
pub const release = deinit;
pub fn local(self: Global, l: *const js.Local) Object {
return .{
.local = l,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
.handle = @ptrCast(v8.v8__Global__Get(&self.slot.handle, l.isolate.handle)),
};
}
pub fn isEqual(self: *const Global, other: Object) bool {
return v8.v8__Global__IsEqual(&self.handle, other.handle);
pub fn isEqual(self: Global, other: Object) bool {
return v8.v8__Global__IsEqual(&self.slot.handle, other.handle);
}
};

View File

@@ -74,57 +74,21 @@ pub fn markAsHandled(self: Promise) void {
}
pub fn persist(self: Promise) !Global {
return self._persist(true);
return .{ .slot = try js.newTrackedSlot(self.local.ctx, self.handle) };
}
pub fn temp(self: Promise) !Temp {
return self._persist(false);
}
pub const Global = struct {
slot: *js.GlobalSlot,
fn _persist(self: *const Promise, comptime is_global: bool) !(if (is_global) Global else Temp) {
var ctx = self.local.ctx;
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
if (comptime is_global) {
try ctx.trackGlobal(global);
return .{ .handle = global, .temps = {} };
pub fn deinit(self: Global) void {
self.slot.release();
}
try ctx.trackTemp(global);
return .{ .handle = global, .temps = &ctx.page.temps };
}
pub const release = deinit;
pub const Temp = G(.temp);
pub const Global = G(.global);
const GlobalType = enum(u8) {
temp,
global,
pub fn local(self: Global, l: *const js.Local) Promise {
return .{
.local = l,
.handle = @ptrCast(v8.v8__Global__Get(&self.slot.handle, l.isolate.handle)),
};
}
};
fn G(comptime global_type: GlobalType) type {
return struct {
handle: v8.Global,
temps: if (global_type == .temp) *std.AutoHashMapUnmanaged(usize, v8.Global) else void,
const Self = @This();
pub fn deinit(self: *Self) void {
v8.v8__Global__Reset(&self.handle);
}
pub fn local(self: *const Self, l: *const js.Local) Promise {
return .{
.local = l,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
};
}
pub fn release(self: *const Self) void {
if (self.temps.fetchRemove(self.handle.data_ptr)) |kv| {
var g = kv.value;
v8.v8__Global__Reset(&g);
}
}
};
}

View File

@@ -118,24 +118,22 @@ fn _reject(self: PromiseResolver, value: anytype) !void {
}
pub fn persist(self: PromiseResolver) !Global {
var ctx = self.local.ctx;
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
try ctx.trackGlobal(global);
return .{ .handle = global };
return .{ .slot = try js.newTrackedSlot(self.local.ctx, self.handle) };
}
pub const Global = struct {
handle: v8.Global,
slot: *js.GlobalSlot,
pub fn deinit(self: *Global) void {
v8.v8__Global__Reset(&self.handle);
pub fn deinit(self: Global) void {
self.slot.release();
}
pub fn local(self: *const Global, l: *const js.Local) PromiseResolver {
pub const release = deinit;
pub fn local(self: Global, l: *const js.Local) PromiseResolver {
return .{
.local = l,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
.handle = @ptrCast(v8.v8__Global__Get(&self.slot.handle, l.isolate.handle)),
};
}
};

View File

@@ -22,11 +22,18 @@ const lp = @import("lightpanda");
const js = @import("js.zig");
const bridge = @import("bridge.zig");
// Not ideal, but its children have special constructor rules (can be extended
// can't be instantiated). And rather than coming up with a generic definition
// for this one case, we just hardcode it.
const HtmlElement = @import("../webapi/element/Html.zig");
const v8 = js.v8;
const log = lp.log;
const JsApis = bridge.JsApis;
const PageJsApis = bridge.PageJsApis;
const WorkerJsApis = bridge.WorkerJsApis;
const SharedWorkerJsApis = bridge.SharedWorkerJsApis;
const DedicatedWorkerJsApis = bridge.DedicatedWorkerJsApis;
const IS_DEBUG = @import("builtin").mode == .Debug;
const Snapshot = @This();
@@ -204,9 +211,15 @@ pub fn create() !Snapshot {
{
const DedicatedWorkerGlobalScope = @import("../webapi/DedicatedWorkerGlobalScope.zig");
const index = try createSnapshotContext(.worker, &WorkerJsApis, DedicatedWorkerGlobalScope.JsApi, isolate, snapshot_creator.?, &templates);
const index = try createSnapshotContext(.worker, &DedicatedWorkerJsApis, DedicatedWorkerGlobalScope.JsApi, isolate, snapshot_creator.?, &templates);
std.debug.assert(index == 1);
}
{
const SharedWorkerGlobalScope = @import("../webapi/SharedWorkerGlobalScope.zig");
const index = try createSnapshotContext(.worker, &SharedWorkerJsApis, SharedWorkerGlobalScope.JsApi, isolate, snapshot_creator.?, &templates);
std.debug.assert(index == 2);
}
}
const blob = v8.v8__SnapshotCreator__createBlob(snapshot_creator, v8.kKeep);
@@ -402,6 +415,9 @@ fn countExternalReferences() comptime_int {
// +1 for the illegal constructor callback shared by various types
count += 1;
// +1 for the upgrade constructor shared by built-in element interfaces
count += 1;
// +1 for the noop function shared by various types
count += 1;
@@ -446,12 +462,15 @@ fn countExternalReferences() comptime_int {
if (value.setter != null) count += 1;
if (value.deleter != null) count += 1;
if (value.query != null) count += 1;
if (value.definer != null) count += 1;
} else if (T == bridge.NamedIndexed) {
count += 1;
if (value.setter != null) count += 1;
if (value.deleter != null) count += 1;
if (value.enumerator != null) count += 1;
if (value.query != null) count += 1;
if (value.definer != null) count += 1;
if (value.descriptor != null) count += 1;
if (value.enumerator != null) count += 1;
}
}
}
@@ -474,6 +493,9 @@ fn collectExternalReferences() [countExternalReferences()]isize {
references[idx] = @bitCast(@intFromPtr(&illegalConstructorCallback));
idx += 1;
references[idx] = @bitCast(@intFromPtr(HtmlElement.JsApi.upgrade_constructor.func));
idx += 1;
references[idx] = @bitCast(@intFromPtr(&bridge.Function.noopFunction));
idx += 1;
@@ -536,6 +558,10 @@ fn collectExternalReferences() [countExternalReferences()]isize {
references[idx] = @bitCast(@intFromPtr(query));
idx += 1;
}
if (value.definer) |definer| {
references[idx] = @bitCast(@intFromPtr(definer));
idx += 1;
}
} else if (T == bridge.NamedIndexed) {
references[idx] = @bitCast(@intFromPtr(value.getter));
idx += 1;
@@ -547,14 +573,22 @@ fn collectExternalReferences() [countExternalReferences()]isize {
references[idx] = @bitCast(@intFromPtr(deleter));
idx += 1;
}
if (value.enumerator) |enumerator| {
references[idx] = @bitCast(@intFromPtr(enumerator));
idx += 1;
}
if (value.query) |query| {
references[idx] = @bitCast(@intFromPtr(query));
idx += 1;
}
if (value.definer) |definer| {
references[idx] = @bitCast(@intFromPtr(definer));
idx += 1;
}
if (value.descriptor) |descriptor| {
references[idx] = @bitCast(@intFromPtr(descriptor));
idx += 1;
}
if (value.enumerator) |enumerator| {
references[idx] = @bitCast(@intFromPtr(enumerator));
idx += 1;
}
}
}
}
@@ -676,14 +710,15 @@ fn protoIndexLookup(comptime JsApi: type) ?u16 {
// Generate a constructor template for a JsApi type (public for reuse)
pub fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const v8.FunctionTemplate {
const callback = blk: {
const callback, const arity = comptime blk: {
if (@hasDecl(JsApi, "constructor")) {
break :blk JsApi.constructor.func;
break :blk .{ JsApi.constructor.func, JsApi.constructor.arity };
}
break :blk illegalConstructorCallback;
if (inheritsFromHtmlElement(JsApi)) {
break :blk .{ HtmlElement.JsApi.upgrade_constructor.func, @as(c_int, HtmlElement.JsApi.upgrade_constructor.arity) };
}
break :blk .{ &illegalConstructorCallback, @as(c_int, 0) };
};
const arity: c_int = if (@hasDecl(JsApi, "constructor")) JsApi.constructor.arity else 0;
const template = v8.v8__FunctionTemplate__New__Config(isolate, &.{
.length = arity,
.callback = callback,
@@ -704,12 +739,21 @@ pub fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const v8
return template;
}
// Attach JsApi members to a template (public for reuse). This is called on all
// types. But, for globals (window, WGS) it's called twice. The first time, it's
// called like any other interface. The 2nd time, it's called with flatten == true
// and define_on != null. This is the "flattening" pass, and it defines all of
// the functions/accessors on directly on the global instance. Thus, globals have
// it defined on both their prototype (first pass) and their own instance (2nd pass).
// hard-coded special case for HtmlElement which can be extended but not
// instantiated.
fn inheritsFromHtmlElement(comptime JsApi: type) bool {
if (JsApi.bridge.type == HtmlElement) {
return false;
}
return bridge.inheritsOrIs(JsApi, HtmlElement.JsApi);
}
const Unforgeable = struct {
Owner: type,
name: [:0]const u8,
accessor: bridge.Accessor,
};
fn attachClass(comptime JsApi: type, comptime flatten: bool, isolate: *v8.Isolate, template: *const v8.FunctionTemplate, define_on: ?*const v8.ObjectTemplate) void {
const instance = v8.v8__FunctionTemplate__InstanceTemplate(template);
const prototype = v8.v8__FunctionTemplate__PrototypeTemplate(template);
@@ -745,49 +789,12 @@ fn attachClass(comptime JsApi: type, comptime flatten: bool, isolate: *v8.Isolat
continue;
}
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
const getter_signature = if (value.static) null else signature;
const getter_callback = v8.v8__FunctionTemplate__New__Config(isolate, &.{
.callback = value.getter,
.signature = getter_signature,
}).?;
// WebIDL: getter function's .name should be "get X"
const getter_name_str = "get " ++ name;
const getter_name_v8 = v8.v8__String__NewFromUtf8(isolate, getter_name_str.ptr, v8.kNormal, @intCast(getter_name_str.len));
v8.v8__FunctionTemplate__SetClassName(getter_callback, getter_name_v8);
const setter_callback = if (value.setter) |setter| blk: {
const cb = v8.v8__FunctionTemplate__New__Config(isolate, &.{
.callback = setter,
.signature = getter_signature,
.length = 1,
}).?;
const setter_name_str = "set " ++ name;
const setter_name_v8 = v8.v8__String__NewFromUtf8(isolate, setter_name_str.ptr, v8.kNormal, @intCast(setter_name_str.len));
v8.v8__FunctionTemplate__SetClassName(cb, setter_name_v8);
break :blk cb;
} else null;
var attribute: v8.PropertyAttribute = 0;
if (value.setter == null) {
attribute |= v8.ReadOnly;
}
if (value.deletable == false) {
attribute |= v8.DontDelete;
}
if (value.static) {
v8.v8__Template__SetAccessorProperty(@ptrCast(template), js_name, getter_callback, setter_callback, attribute);
} else {
// Web IDL: attributes on the interface prototype object
// (and mirrored onto [Global] instances) are enumerable.
v8.v8__ObjectTemplate__SetAccessorProperty__Config(define_on orelse prototype, &.{
.key = js_name,
.getter = getter_callback,
.setter = setter_callback,
.attribute = attribute,
});
if (comptime value.unforgeable) {
// this accessor will be handled in the unforgeables loop
// later in this function.
continue;
}
attachAccessorProperty(name, value, isolate, template, signature, define_on orelse prototype);
},
bridge.Function => {
if (value.wpt_only and wpt_extensions_enabled == false) {
@@ -820,7 +827,7 @@ fn attachClass(comptime JsApi: type, comptime flatten: bool, isolate: *v8.Isolat
.setter = value.setter,
.query = value.query,
.deleter = value.deleter,
.definer = null,
.definer = if (value.definer) |definer| @ptrCast(definer) else null,
.descriptor = null,
.index_of = null,
.data = null,
@@ -835,8 +842,8 @@ fn attachClass(comptime JsApi: type, comptime flatten: bool, isolate: *v8.Isolat
.query = value.query,
.deleter = value.deleter,
.enumerator = value.enumerator,
.definer = null,
.descriptor = null,
.definer = if (value.definer) |definer| @ptrCast(definer) else null,
.descriptor = if (value.descriptor) |descriptor| @ptrCast(descriptor) else null,
.data = null,
.flags = v8.kOnlyInterceptStrings | v8.kNonMasking,
};
@@ -873,6 +880,16 @@ fn attachClass(comptime JsApi: type, comptime flatten: bool, isolate: *v8.Isolat
}
}
if (comptime flatten == false) {
inline for (unforgeables) |u| {
if (comptime bridge.inheritsOrIs(JsApi, u.Owner)) {
// unforgeables attributes aren't only applied directly on the
// instance, they're also applied directly on every child instance
attachAccessorProperty(u.name, u.accessor, isolate, template, signature, instance);
}
}
}
// The remaining per-class setup targets the class's own instance template;
// in [Global] flattening mode the global already has these (or doesn't need
// them), so skip it.
@@ -923,3 +940,71 @@ fn globalScopeChain(comptime GlobalScopeApi: type) []const type {
return chain;
}
}
// Every [LegacyUnforgeable] accessor. We collect this once so we don't need to
// keep scanning for this in attachClass
const unforgeables: []const Unforgeable = blk: {
@setEvalBranchQuota(100_000);
var list: []const Unforgeable = &.{};
for (JsApis) |Api| {
for (@typeInfo(Api).@"struct".decls) |d| {
const value = @field(Api, d.name);
if (@TypeOf(value) == bridge.Accessor and value.unforgeable and !value.static) {
list = list ++ &[_]Unforgeable{.{ .Owner = Api, .name = d.name, .accessor = value }};
}
}
}
break :blk list;
};
// Attach JsApi members to a template (public for reuse). This is called on all
// types. But, for globals (window, WGS) it's called twice. The first time, it's
// called like any other interface. The 2nd time, it's called with flatten == true
// and define_on != null. This is the "flattening" pass, and it defines all of
// the functions/accessors on directly on the global instance. Thus, globals have
// it defined on both their prototype (first pass) and their own instance (2nd pass).
fn attachAccessorProperty(comptime name: [:0]const u8, value: bridge.Accessor, isolate: *v8.Isolate, template: *const v8.FunctionTemplate, signature: anytype, target: anytype) void {
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
const getter_signature = if (value.static) null else signature;
const getter_callback = v8.v8__FunctionTemplate__New__Config(isolate, &.{
.callback = value.getter,
.signature = getter_signature,
}).?;
// WebIDL: getter function's .name should be "get X"
const getter_name_str = "get " ++ name;
const getter_name_v8 = v8.v8__String__NewFromUtf8(isolate, getter_name_str.ptr, v8.kNormal, @intCast(getter_name_str.len));
v8.v8__FunctionTemplate__SetClassName(getter_callback, getter_name_v8);
const setter_callback = if (value.setter) |setter| blk: {
const cb = v8.v8__FunctionTemplate__New__Config(isolate, &.{
.callback = setter,
.signature = getter_signature,
.length = 1,
}).?;
const setter_name_str = "set " ++ name;
const setter_name_v8 = v8.v8__String__NewFromUtf8(isolate, setter_name_str.ptr, v8.kNormal, @intCast(setter_name_str.len));
v8.v8__FunctionTemplate__SetClassName(cb, setter_name_v8);
break :blk cb;
} else null;
var attribute: v8.PropertyAttribute = 0;
if (value.setter == null) {
attribute |= v8.ReadOnly;
}
if (value.deletable == false) {
attribute |= v8.DontDelete;
}
if (value.static) {
v8.v8__Template__SetAccessorProperty(@ptrCast(template), js_name, getter_callback, setter_callback, attribute);
} else {
// Web IDL: attributes on the interface prototype object
// (and mirrored onto [Global] instances) are enumerable.
v8.v8__ObjectTemplate__SetAccessorProperty__Config(target, &.{
.key = js_name,
.getter = getter_callback,
.setter = setter_callback,
.attribute = attribute,
});
}
}

View File

@@ -106,7 +106,14 @@ pub fn fromJS(comptime R: type, js_obj_handle: *const v8.Object) !R {
@compileError("unknown Zig type: " ++ @typeName(R));
}
const tao_ptr = v8.v8__Object__GetAlignedPointerFromInternalField(js_obj_handle, 0).?;
const tao_ptr = v8.v8__Object__GetAlignedPointerFromInternalField(js_obj_handle, 0) orelse return error.InvalidArgument;
// A wrapped object always embeds an aligned TaggedOpaque pointer. An
// unaligned value is a leftover v8 tagged value: the object was created
// from our template but never mapped to a Zig instance (e.g. a custom
// element whose constructor threw).
if (@intFromPtr(tao_ptr) % @alignOf(TaggedOpaque) != 0) {
return error.InvalidArgument;
}
const tao: *TaggedOpaque = @ptrCast(@alignCast(tao_ptr));
const expected_type_index = bridge.JsApiLookup.getId(JsApi);

View File

@@ -46,6 +46,12 @@ pub fn rethrow(self: *TryCatch) void {
_ = v8.v8__TryCatch__ReThrow(&self.handle);
}
// The raw caught exception value, e.g. to report it to a global's onerror.
pub fn exceptionValue(self: TryCatch) ?js.Value {
const handle = v8.v8__TryCatch__Exception(&self.handle) orelse return null;
return .{ .local = self.local, .handle = handle };
}
pub fn caught(self: TryCatch, allocator: Allocator) ?Caught {
if (self.hasCaught() == false) {
return null;

View File

@@ -425,6 +425,8 @@ const cloneable_types = .{
@import("../webapi/ImageData.zig"),
@import("../webapi/DOMPointReadOnly.zig"),
@import("../webapi/DOMPoint.zig"),
@import("../webapi/DOMRectReadOnly.zig"),
@import("../webapi/DOMRect.zig"),
};
// Passed to a type's structuredSerialize hook to write its payload into the
@@ -622,34 +624,17 @@ const CloneDelegate = struct {
};
pub fn persist(self: Value) !Global {
return self._persist(true);
return .{ .slot = try js.newTrackedSlot(self.local.ctx, self.handle) };
}
pub fn temp(self: Value) !Temp {
return self._persist(false);
}
// Like persist(), but not tracked on the context: the caller owns the handle
// and must deinit (Reset) it. A reset is only idempotent through the same
// instance — copies alias one v8 slot, so keep a single canonical instance and
// reset through it.
pub fn bare(self: Value) BareGlobal {
// like persist, but not tracked by the page. Caller takes responsibility for
// resetting and freeing the allocation.
pub fn persistBare(self: Value, arena: std.mem.Allocator) !*js.GlobalSlot {
const slot = try arena.create(js.GlobalSlot);
var global: v8.Global = undefined;
v8.v8__Global__New(self.local.ctx.isolate.handle, self.handle, &global);
return .{ .handle = global, .temps = {} };
}
fn _persist(self: *const Value, comptime is_global: bool) !(if (is_global) Global else Temp) {
var ctx = self.local.ctx;
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
if (comptime is_global) {
try ctx.trackGlobal(global);
return .{ .handle = global, .temps = {} };
}
try ctx.trackTemp(global);
return .{ .handle = global, .temps = &ctx.page.temps };
slot.* = .{ .handle = global, .tracker = null, .gindex = undefined };
return slot;
}
pub fn toZig(self: Value, comptime T: type) !T {
@@ -696,48 +681,71 @@ pub fn format(self: Value, writer: *std.Io.Writer) !void {
return js_str.format(writer);
}
pub const Temp = G(.temp);
pub const Global = G(.global);
pub const BareGlobal = G(.bare);
// Copyable handle to our v8::Global wrapper so that releasing a copy resets
// the underlying v8::Global
pub const Global = struct {
slot: *js.GlobalSlot,
const GlobalType = enum(u8) {
temp,
global,
bare,
pub fn deinit(self: Global) void {
self.slot.release();
}
pub const release = deinit;
pub fn local(self: Global, l: *const js.Local) Value {
return .{
.local = l,
.handle = @ptrCast(v8.v8__Global__Get(&self.slot.handle, l.isolate.handle)),
};
}
pub fn isEqual(self: Global, other: Value) bool {
return v8.v8__Global__IsEqual(&self.slot.handle, other.handle);
}
};
fn G(comptime global_type: GlobalType) type {
return struct {
handle: v8.Global,
temps: if (global_type == .temp) *std.AutoHashMapUnmanaged(usize, v8.Global) else void,
const testing = @import("../../testing.zig");
test "Value: persisted handle early-release swap-removes and fixes up indices" {
const frame = try testing.createFrame();
defer testing.test_session.closeAllPages();
const Self = @This();
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
pub fn deinit(self: *Self) void {
v8.v8__Global__Reset(&self.handle);
}
const tracker = &frame.js.page.globals;
const base = tracker.list.items.len;
pub fn local(self: *const Self, l: *const js.Local) Value {
return .{
.local = l,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
};
}
var a = try (try ls.local.exec("({a:1})", null)).persist();
var b = try (try ls.local.exec("({b:2})", null)).persist();
var c = try (try ls.local.exec("({c:3})", null)).persist();
pub fn isEqual(self: *const Self, other: Value) bool {
return v8.v8__Global__IsEqual(&self.handle, other.handle);
}
try testing.expectEqual(base + 3, tracker.list.items.len);
try testing.expectEqual(base + 0, a.slot.gindex);
try testing.expectEqual(base + 1, b.slot.gindex);
try testing.expectEqual(base + 2, c.slot.gindex);
pub fn release(self: *const Self) void {
if (self.temps.fetchRemove(self.handle.data_ptr)) |kv| {
var g = kv.value;
v8.v8__Global__Reset(&g);
}
}
};
// Release the middle one: the last live slot (c) must move into b's spot and
// have its stored index rewritten to match, or a later release corrupts.
b.deinit();
try testing.expectEqual(base + 2, tracker.list.items.len);
try testing.expectEqual(base + 1, c.slot.gindex);
try testing.expectEqual(c.slot, tracker.list.items[base + 1]);
try testing.expectEqual(a.slot, tracker.list.items[base + 0]);
// a and c are still usable (right handle, not b's).
try testing.expect(a.local(&ls.local).isObject());
try testing.expect(c.local(&ls.local).isObject());
// Release the remaining two via the moved indices — must not corrupt.
a.deinit();
try testing.expectEqual(base + 1, tracker.list.items.len);
try testing.expectEqual(base + 0, c.slot.gindex);
try testing.expectEqual(c.slot, tracker.list.items[base + 0]);
c.deinit();
try testing.expectEqual(base, tracker.list.items.len);
}
const testing = @import("../../testing.zig");
test "Value: jsonStringify maps unserializable JS values to null" {
const frame = try testing.createFrame();
defer testing.test_session.closeAllPages();

View File

@@ -46,15 +46,23 @@ pub fn Builder(comptime T: type) type {
}
pub fn indexed(comptime getter_func: anytype, comptime enumerator_func: anytype, comptime opts: Indexed.Opts) Indexed {
return Indexed.init(T, getter_func, null, null, null, enumerator_func, opts);
return Indexed.init(T, getter_func, null, null, null, null, enumerator_func, opts);
}
pub fn indexedReadWrite(comptime getter_func: anytype, setter_func: anytype, deleter_func: anytype, query_func: anytype, comptime enumerator_func: anytype, comptime opts: Indexed.Opts) Indexed {
return Indexed.init(T, getter_func, setter_func, deleter_func, query_func, enumerator_func, opts);
return Indexed.init(T, getter_func, setter_func, deleter_func, query_func, null, enumerator_func, opts);
}
pub fn indexedFull(comptime getter_func: anytype, setter_func: anytype, deleter_func: anytype, query_func: anytype, definer_func: anytype, comptime enumerator_func: anytype, comptime opts: Indexed.Opts) Indexed {
return Indexed.init(T, getter_func, setter_func, deleter_func, query_func, definer_func, enumerator_func, opts);
}
pub fn namedIndexed(comptime getter_func: anytype, setter_func: anytype, deleter_func: anytype, enumerator_func: anytype, query_func: anytype, comptime opts: NamedIndexed.Opts) NamedIndexed {
return NamedIndexed.init(T, getter_func, setter_func, deleter_func, enumerator_func, query_func, opts);
return NamedIndexed.init(T, getter_func, setter_func, deleter_func, enumerator_func, query_func, null, null, opts);
}
pub fn namedIndexedFull(comptime getter_func: anytype, setter_func: anytype, deleter_func: anytype, enumerator_func: anytype, query_func: anytype, definer_func: anytype, descriptor_func: anytype, comptime opts: NamedIndexed.Opts) NamedIndexed {
return NamedIndexed.init(T, getter_func, setter_func, deleter_func, enumerator_func, query_func, definer_func, descriptor_func, opts);
}
pub fn iterator(comptime func: anytype, comptime opts: Iterator.Opts) Iterator {
@@ -222,6 +230,7 @@ pub const Accessor = struct {
static: bool = false,
deletable: bool = true,
wpt_only: bool = false,
unforgeable: bool = false, // Web IDL [LegacyUnforgeable]
exposed: Caller.Function.Opts.Exposed = .both,
cache: ?Caller.Function.Opts.Caching = null,
getter: ?*const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void = null,
@@ -233,6 +242,7 @@ pub const Accessor = struct {
.static = opts.static,
.wpt_only = opts.wpt_only,
.deletable = opts.deletable,
.unforgeable = opts.unforgeable,
.exposed = opts.exposed,
};
@@ -268,13 +278,16 @@ pub const Indexed = struct {
setter: ?*const fn (idx: u32, c_value: ?*const v8.Value, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
deleter: ?*const fn (idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
query: ?*const fn (idx: u32, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
// v8 expects an Intercepted (u32) return; the stale void-returning
// typedef in binding.h is papered over with a @ptrCast at install time.
definer: ?*const fn (idx: u32, desc: ?*v8.PropertyDescriptor, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
const Opts = struct {
as_typed_array: bool = false,
null_as_undefined: bool = false,
};
fn init(comptime T: type, comptime getter: anytype, setter: anytype, deleter: anytype, query: anytype, comptime enumerator: anytype, comptime opts: Opts) Indexed {
fn init(comptime T: type, comptime getter: anytype, setter: anytype, deleter: anytype, query: anytype, definer: anytype, comptime enumerator: anytype, comptime opts: Opts) Indexed {
var indexed = Indexed{
.enumerator = null,
.getter = struct {
@@ -336,7 +349,7 @@ pub const Indexed = struct {
}
defer caller.deinit();
return caller.deleteIndex(T, deleter, idx, handle.?, .{
return caller.deleteOrDefineIndex(T, deleter, idx, handle.?, .{
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
@@ -359,6 +372,22 @@ pub const Indexed = struct {
}.wrap;
}
if (@typeInfo(@TypeOf(definer)) != .null) {
indexed.definer = struct {
fn wrap(idx: u32, _: ?*v8.PropertyDescriptor, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 {
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
if (!caller.init(v8_isolate)) {
return js.Intercepted.no;
}
defer caller.deinit();
// same (self, index) -> bool shape as a deleter
return caller.deleteOrDefineIndex(T, definer, idx, handle.?, .{});
}
}.wrap;
}
return indexed;
}
};
@@ -367,8 +396,12 @@ pub const NamedIndexed = struct {
getter: *const fn (c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32,
setter: ?*const fn (c_name: ?*const v8.Name, c_value: ?*const v8.Value, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
deleter: ?*const fn (c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
enumerator: ?*const fn (handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
query: ?*const fn (c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
// v8 expects an Intercepted (u32) return; the stale void-returning
// typedefs in binding.h are papered over with a @ptrCast at install time.
definer: ?*const fn (c_name: ?*const v8.Name, desc: ?*v8.PropertyDescriptor, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
descriptor: ?*const fn (c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
enumerator: ?*const fn (handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 = null,
const Opts = struct {
as_typed_array: bool = false,
@@ -379,7 +412,7 @@ pub const NamedIndexed = struct {
ce_reactions: bool = false,
};
fn init(comptime T: type, comptime getter: anytype, setter: anytype, deleter: anytype, enumerator: anytype, query: anytype, comptime opts: Opts) NamedIndexed {
fn init(comptime T: type, comptime getter: anytype, setter: anytype, deleter: anytype, enumerator: anytype, query: anytype, definer: anytype, descriptor: anytype, comptime opts: Opts) NamedIndexed {
const getter_fn = struct {
fn wrap(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 {
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
@@ -445,13 +478,42 @@ pub const NamedIndexed = struct {
if (ce_frame) |frame| frame._ce_reactions.popAndInvoke(ce_checkpoint, frame);
};
return caller.deleteNamedIndex(T, deleter, c_name.?, handle.?, .{
return caller.deleteOrDefineNamedIndex(T, deleter, c_name.?, handle.?, .{
.as_typed_array = opts.as_typed_array,
.null_as_undefined = opts.null_as_undefined,
});
}
}.wrap;
const definer_fn = if (@typeInfo(@TypeOf(definer)) == .null) null else struct {
fn wrap(c_name: ?*const v8.Name, _: ?*v8.PropertyDescriptor, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 {
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
if (!caller.init(v8_isolate)) {
return js.Intercepted.no;
}
defer caller.deinit();
// same (self, name) -> bool shape as a deleter
return caller.deleteOrDefineNamedIndex(T, definer, c_name.?, handle.?, .{});
}
}.wrap;
const descriptor_fn = if (@typeInfo(@TypeOf(descriptor)) == .null) null else struct {
fn wrap(c_name: ?*const v8.Name, handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 {
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
var caller: Caller = undefined;
if (!caller.init(v8_isolate)) {
return js.Intercepted.no;
}
defer caller.deinit();
// same (self, name) -> value shape as a getter; the returned
// struct is converted to a JS property descriptor object
return caller.getNamedIndex(T, descriptor, c_name.?, handle.?, .{});
}
}.wrap;
const enumerator_fn = if (@typeInfo(@TypeOf(enumerator)) == .null) null else struct {
fn wrap(handle: ?*const v8.PropertyCallbackInfo) callconv(.c) u32 {
const v8_isolate = v8.v8__PropertyCallbackInfo__GetIsolate(handle).?;
@@ -482,8 +544,10 @@ pub const NamedIndexed = struct {
.getter = getter_fn,
.setter = setter_fn,
.deleter = deleter_fn,
.enumerator = enumerator_fn,
.query = query_fn,
.definer = definer_fn,
.descriptor = descriptor_fn,
.enumerator = enumerator_fn,
};
}
};
@@ -907,6 +971,7 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/DOMImplementation.zig"),
@import("../webapi/DOMTreeWalker.zig"),
@import("../webapi/DOMNodeIterator.zig"),
@import("../webapi/DOMRectReadOnly.zig"),
@import("../webapi/DOMRect.zig"),
@import("../webapi/DOMMatrixReadOnly.zig"),
@import("../webapi/DOMMatrix.zig"),
@@ -995,6 +1060,23 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/element/html/ValidityState.zig"),
@import("../webapi/element/Svg.zig"),
@import("../webapi/element/svg/Generic.zig"),
@import("../webapi/element/svg/Graphics.zig"),
@import("../webapi/element/svg/Geometry.zig"),
@import("../webapi/element/svg/Svg.zig"),
@import("../webapi/element/svg/G.zig"),
@import("../webapi/element/svg/A.zig"),
@import("../webapi/element/svg/Use.zig"),
@import("../webapi/element/svg/Image.zig"),
@import("../webapi/element/svg/Defs.zig"),
@import("../webapi/element/svg/Rect.zig"),
@import("../webapi/element/svg/Circle.zig"),
@import("../webapi/element/svg/Ellipse.zig"),
@import("../webapi/element/svg/Line.zig"),
@import("../webapi/element/svg/Path.zig"),
@import("../webapi/element/svg/Polygon.zig"),
@import("../webapi/element/svg/Polyline.zig"),
@import("../webapi/svg/AnimatedString.zig"),
@import("../webapi/svg/Number.zig"),
@import("../webapi/encoding/TextDecoder.zig"),
@import("../webapi/encoding/TextEncoder.zig"),
@import("../webapi/encoding/TextEncoderStream.zig"),
@@ -1009,6 +1091,12 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/event/PageTransitionEvent.zig"),
@import("../webapi/event/PopStateEvent.zig"),
@import("../webapi/event/HashChangeEvent.zig"),
@import("../webapi/event/BeforeUnloadEvent.zig"),
@import("../webapi/event/StorageEvent.zig"),
@import("../webapi/event/DeviceMotionEvent.zig"),
@import("../webapi/event/GamepadEvent.zig"),
@import("../webapi/event/DeviceOrientationEvent.zig"),
@import("../webapi/event/TouchEvent.zig"),
@import("../webapi/event/UIEvent.zig"),
@import("../webapi/event/MouseEvent.zig"),
@import("../webapi/event/PointerEvent.zig"),
@@ -1026,6 +1114,7 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/MessagePort.zig"),
@import("../webapi/BroadcastChannel.zig"),
@import("../webapi/Worker.zig"),
@import("../webapi/SharedWorker.zig"),
@import("../webapi/media/MediaError.zig"),
@import("../webapi/media/TextTrackCue.zig"),
@import("../webapi/media/VTTCue.zig"),
@@ -1045,6 +1134,7 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/net/XMLHttpRequestEventTarget.zig"),
@import("../webapi/net/XMLHttpRequestUpload.zig"),
@import("../webapi/net/WebSocket.zig"),
@import("../webapi/net/EventSource.zig"),
@import("../webapi/event/CloseEvent.zig"),
@import("../webapi/streams/ReadableStream.zig"),
@import("../webapi/streams/ReadableStreamDefaultReader.zig"),
@@ -1093,11 +1183,13 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/collections/DOMStringList.zig"),
});
// APIs available on Worker context globals (constructors like URL, Headers, etc.)
// APIs available on every Worker context global (constructors like URL,
// Headers, etc.), regardless of worker kind. Each kind's snapshot context
// adds its own global-scope type on top (DedicatedWorkerJsApis,
// SharedWorkerJsApis below).
// This is a subset of PageJsApis plus WorkerGlobalScope.
// TODO: Expand this list to include all worker-appropriate APIs.
pub const WorkerJsApis = flattenTypes(&.{
@import("../webapi/DedicatedWorkerGlobalScope.zig"),
const worker_common_apis = [_]type{
@import("../webapi/WorkerGlobalScope.zig"),
@import("../webapi/WorkerLocation.zig"),
@import("../webapi/Navigator.zig"),
@@ -1111,6 +1203,8 @@ pub const WorkerJsApis = flattenTypes(&.{
@import("../webapi/event/PromiseRejectionEvent.zig"),
@import("../webapi/event/CloseEvent.zig"),
@import("../webapi/DOMException.zig"),
@import("../webapi/DOMRectReadOnly.zig"),
@import("../webapi/DOMRect.zig"),
@import("../webapi/DOMMatrixReadOnly.zig"),
@import("../webapi/DOMMatrix.zig"),
@import("../webapi/DOMPointReadOnly.zig"),
@@ -1146,6 +1240,7 @@ pub const WorkerJsApis = flattenTypes(&.{
@import("../webapi/net/XMLHttpRequestEventTarget.zig"),
@import("../webapi/net/XMLHttpRequestUpload.zig"),
@import("../webapi/net/WebSocket.zig"),
@import("../webapi/net/EventSource.zig"),
@import("../webapi/FileReader.zig"),
@import("../webapi/ImageData.zig"),
@import("../webapi/Performance.zig"),
@@ -1161,7 +1256,10 @@ pub const WorkerJsApis = flattenTypes(&.{
@import("../webapi/MessageChannel.zig"),
@import("../webapi/MessagePort.zig"),
@import("../webapi/collections/DOMStringList.zig"),
});
};
pub const DedicatedWorkerJsApis = flattenTypes(&([_]type{@import("../webapi/DedicatedWorkerGlobalScope.zig")} ++ worker_common_apis));
pub const SharedWorkerJsApis = flattenTypes(&([_]type{@import("../webapi/SharedWorkerGlobalScope.zig")} ++ worker_common_apis));
// Master list of ALL JS APIs across all contexts.
// Used by Env (class IDs, templates), JsApiLookup, and anywhere that needs
@@ -1170,6 +1268,7 @@ pub const WorkerJsApis = flattenTypes(&.{
pub const JsApis = blk: {
const base = PageJsApis ++ [_]type{
@import("../webapi/DedicatedWorkerGlobalScope.zig").JsApi,
@import("../webapi/SharedWorkerGlobalScope.zig").JsApi,
@import("../webapi/WorkerGlobalScope.zig").JsApi,
@import("../webapi/WorkerLocation.zig").JsApi,
};
@@ -1178,3 +1277,20 @@ pub const JsApis = blk: {
}
break :blk base ++ [_]type{@import("../webapi/WebDriver.zig").JsApi};
};
// Whether Child is Ancestor or inherits from it, following the _proto chain.
pub fn inheritsOrIs(comptime Child: type, comptime Ancestor: type) bool {
comptime {
const target = Ancestor.bridge.type;
var T = Child.bridge.type;
while (true) {
if (T == target) {
return true;
}
if (!@hasField(T, "_proto")) {
return false;
}
T = @typeInfo(std.meta.fieldInfo(T, ._proto).type).pointer.child;
}
}
}

View File

@@ -67,6 +67,84 @@ pub fn Bridge(comptime T: type) type {
return bridge.Builder(T);
}
// Our wrapper around a v8::Global designed to be tracked (in a GlobalTracker).
pub const GlobalSlot = struct {
handle: v8.Global,
tracker: ?*GlobalTracker, // null for Bare globals (see IndexedDB)
gindex: u32, // position in GlobalTracker, used to efficiently remove + reuse
pub fn reset(self: *GlobalSlot) void {
v8.v8__Global__Reset(&self.handle);
}
// Eager free: reset the handle and, if page-tracked, drop the slot from the
// tracker and return it to the pool. Idempotent for bare slots.
pub fn release(self: *GlobalSlot) void {
self.reset();
if (self.tracker) |t| {
t.untrack(self);
}
}
pub fn local(self: *const GlobalSlot, l: *const Local) Value {
return .{
.local = l,
.handle = @ptrCast(v8.v8__Global__Get(&self.handle, l.isolate.handle)),
};
}
};
// Per-page owner of persisted v8 handles (v8::Global). Teardown resets all globals
pub const GlobalTracker = struct {
allocator: Allocator,
list: std.ArrayList(*GlobalSlot) = .empty,
pool: std.heap.MemoryPool(GlobalSlot),
pub fn init(allocator: Allocator) GlobalTracker {
return .{ .allocator = allocator, .pool = std.heap.MemoryPool(GlobalSlot).init(allocator) };
}
pub fn deinit(self: *GlobalTracker) void {
for (self.list.items) |slot| {
slot.reset();
}
self.list.deinit(self.allocator);
self.pool.deinit();
}
pub fn track(self: *GlobalTracker, handle: v8.Global) !*GlobalSlot {
const slot = try self.pool.create();
errdefer self.pool.destroy(slot);
slot.* = .{
.handle = handle,
.tracker = self,
.gindex = @intCast(self.list.items.len),
};
try self.list.append(self.allocator, slot);
return slot;
}
// swapRemove + updating the moved's index
fn untrack(self: *GlobalTracker, slot: *GlobalSlot) void {
const idx = slot.gindex;
const moved = self.list.pop().?;
if (moved != slot) {
self.list.items[idx] = moved;
// moved has..well...moved, we need to update its gindex
moved.gindex = idx;
}
self.pool.destroy(slot);
}
};
// Build a v8.Global from a live handle and track it on the context's page.
pub fn newTrackedSlot(ctx: *Context, handle: anytype) !*GlobalSlot {
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, handle, &global);
errdefer v8.v8__Global__Reset(&global);
return ctx.page.globals.track(global);
}
// If a function returns a []i32, should that map to a plain-old
// JavaScript array, or a Int32Array? It's ambiguous. By default, we'll
// map arrays/slices to the JavaScript arrays. If you want a TypedArray
@@ -126,14 +204,16 @@ pub fn ArrayBufferRef(comptime kind: ArrayType) type {
/// Persisted typed array.
pub const Global = struct {
handle: v8.Global,
slot: *GlobalSlot,
pub fn deinit(self: *Global) void {
v8.v8__Global__Reset(&self.handle);
pub fn deinit(self: Global) void {
self.slot.release();
}
pub fn local(self: *const Global, l: *const Local) Self {
return .{ .local = l, .handle = v8.v8__Global__Get(&self.handle, l.isolate.handle).? };
pub const release = deinit;
pub fn local(self: Global, l: *const Local) Self {
return .{ .local = l, .handle = v8.v8__Global__Get(&self.slot.handle, l.isolate.handle).? };
}
};
@@ -173,12 +253,7 @@ pub fn ArrayBufferRef(comptime kind: ArrayType) type {
}
pub fn persist(self: *const Self) !Global {
var ctx = self.local.ctx;
var global: v8.Global = undefined;
v8.v8__Global__New(ctx.isolate.handle, self.handle, &global);
try ctx.trackGlobal(global);
return .{ .handle = global };
return .{ .slot = try js.newTrackedSlot(self.local.ctx, self.handle) };
}
// Direct view into the typed array's backing memory.
@@ -209,6 +284,18 @@ pub const NullableString = struct {
value: []const u8,
};
// A required argument that accepts null (Web IDL "T?"): unlike a Zig optional
// parameter, omitting the argument is a TypeError, while passing null or
// undefined yields .{ .value = null }. It also counts towards the JS-visible
// function length, which a plain optional would not.
pub fn Nullable(comptime T: type) type {
return struct {
value: ?T,
pub const js_nullable = T;
};
}
pub const Exception = struct {
local: *const Local,
handle: *const v8.Value,

View File

@@ -142,7 +142,7 @@ fn isStandaloneAnchor(el: *Element) bool {
fn isSignificantText(node: *Node) bool {
const text = node.is(Node.CData.Text) orelse return false;
return !isAllWhitespace(text.getWholeText());
return !isAllWhitespace(text.ownData());
}
fn isVisibleElement(el: *Element) bool {

View File

@@ -171,6 +171,16 @@ const Error = struct {
};
};
pub const PrescanResource = h5e.PrescanResource;
pub const PrescanCallback = h5e.PrescanCallback;
// Preload scanner: a tokenizer-only pass over a buffered document, reporting
// fetchable script resources (and the first <base href>) through `callback`.
// Builds no tree; purely a hint source.
pub fn prescan(html: []const u8, charset: []const u8, ctx: *anyopaque, callback: PrescanCallback) void {
h5e.html5ever_prescan(html.ptr, html.len, charset.ptr, charset.len, ctx, callback);
}
pub fn parse(self: *Parser, html: []const u8) void {
h5e.html5ever_parse_document(
html.ptr,
@@ -272,6 +282,7 @@ pub fn parseFragment(self: *Parser, html: []const u8) void {
&self.container,
self,
createElementCallback,
createContextElementCallback,
getDataCallback,
appendCallback,
parseErrorCallback,
@@ -439,6 +450,25 @@ fn createXMLElementCallback(ctx: *anyopaque, data: *anyopaque, qname: h5e.QualNa
return _createElementCallbackWithDefaultnamespace(ctx, data, qname, attributes, .xml);
}
// html5ever_parse_fragment materializes the fragment's context element through
// this dedicated callback, never through createElementCallback. The context
// element is a throwaway: html5ever only queries its name (and, for a
// <template> context, its content), it is never inserted into the tree. It
// must be built bare — running a custom element constructor here is unbounded
// recursion when the constructor sets innerHTML: that re-parse needs a context
// element of the same custom tag, reconstructing forever (stack overflow). No
// _ce_reactions window is needed: nothing can enqueue reactions on a bare
// create.
fn createContextElementCallback(ctx: *anyopaque, data: *anyopaque, qname: h5e.QualName, attributes: h5e.AttributeIterator) callconv(.c) ?*anyopaque {
const self: *Parser = @ptrCast(@alignCast(ctx));
self.frame._skip_custom_element_upgrade = true;
defer self.frame._skip_custom_element_upgrade = false;
return self._createElementCallback(data, qname, attributes, .unknown) catch |err| {
self.err = .{ .err = err, .source = .create_element };
return null;
};
}
fn _createElementCallbackWithDefaultnamespace(ctx: *anyopaque, data: *anyopaque, qname: h5e.QualName, attributes: h5e.AttributeIterator, default_namespace: Element.Namespace) ?*anyopaque {
const self: *Parser = @ptrCast(@alignCast(ctx));
const cp = self.frame._ce_reactions.push();

View File

@@ -75,6 +75,7 @@ pub extern "c" fn html5ever_parse_fragment(
doc: *anyopaque,
ctx: *anyopaque,
createElementCallback: *const fn (ctx: *anyopaque, data: *anyopaque, QualName, AttributeIterator) callconv(.c) ?*anyopaque,
createContextElementCallback: *const fn (ctx: *anyopaque, data: *anyopaque, QualName, AttributeIterator) callconv(.c) ?*anyopaque,
elemNameCallback: *const fn (node_ref: *anyopaque) callconv(.c) *anyopaque,
appendCallback: *const fn (ctx: *anyopaque, parent_ref: *anyopaque, NodeOrText) callconv(.c) void,
parseErrorCallback: *const fn (ctx: *anyopaque, StringSlice) callconv(.c) void,
@@ -314,3 +315,23 @@ pub extern "c" fn encoding_max_encode_buffer_length(
handle: *anyopaque,
input_len: usize,
) usize;
// Preload scanner (see prescan.rs). Kinds must stay in sync with the
// KIND_* constants there.
pub const PrescanResource = enum(u32) {
script = 0,
module = 1,
base = 2,
_,
};
pub const PrescanCallback = *const fn (ctx: *anyopaque, kind: PrescanResource, url: [*c]const u8, url_len: usize) callconv(.c) void;
pub extern "c" fn html5ever_prescan(
html: [*c]const u8,
len: usize,
charset: [*c]const u8,
charset_len: usize,
ctx: *anyopaque,
callback: PrescanCallback,
) void;

View File

@@ -0,0 +1,203 @@
<!DOCTYPE html>
<head>
<script src="../testing.js"></script>
</head>
<!--
`@layer` block rules (CSS Cascade 5) are flattened into the cascade: the
inner rules participate exactly as if they were written at the top level,
recursing into nested `@media` / `@layer`. Layer-priority ordering
(css-cascade-5 §6.4) is deliberately not implemented — ties keep breaking
on specificity + document order. The statement form (`@layer a, b;`)
declares ordering only and carries no block, so it is inert.
-->
<style>
/* Named layer block — inner rules join the cascade. */
@layer utilities {
#hidden-by-layer { display: none; }
}
/* Anonymous layer block. */
@layer {
#hidden-by-anon-layer { display: none; }
}
/* Dotted sub-layer name in the prelude. */
@layer theme.dark {
#hidden-by-dotted-layer { display: none; }
}
/* Statement form: declares layer order, carries no block. It must be
inert AND must not abort parsing of the rest of the sheet. */
@layer theme, base, components, utilities;
#hidden-after-statement { display: none; }
/* Matching @media nested inside @layer — applies. */
@layer responsive {
@media (min-width: 600px) {
#hidden-media-in-layer { display: none; }
}
}
/* Non-matching @media nested inside @layer — dropped. */
@layer responsive {
@media print {
#not-hidden-print-in-layer { display: none; }
}
}
/* @layer nested inside @media. */
@media screen {
@layer inner {
#hidden-layer-in-media { display: none; }
}
}
/* @layer nested inside @layer. */
@layer outer {
@layer inner {
#hidden-nested-layer { display: none; }
}
}
/* Utility-style !important declaration inside a layer. */
@layer utilities {
#hidden-important { display: none !important; }
}
/* visibility:hidden inside a layer. */
@layer utilities {
#layer-visibility-hidden { visibility: hidden; }
}
</style>
<body>
<div id="hidden-by-layer">named</div>
<div id="hidden-by-anon-layer">anonymous</div>
<div id="hidden-by-dotted-layer">dotted</div>
<div id="hidden-after-statement">after-statement</div>
<div id="hidden-media-in-layer">media-in-layer</div>
<div id="not-hidden-print-in-layer">print-in-layer</div>
<div id="hidden-layer-in-media">layer-in-media</div>
<div id="hidden-nested-layer">nested-layer</div>
<div id="hidden-important">important</div>
<div id="layer-visibility-hidden">visibility</div>
<div id="dyn-target">dyn-target</div>
<div id="dyn2-target">dyn2-target</div>
<div id="deep-nest-target">deep-nest-target</div>
<style id="dyn"></style>
<style id="dyn2"></style>
<style id="deep-nest-style"></style>
</body>
<script id=layer_named_block_hides>
{
testing.expectFalse($('#hidden-by-layer').checkVisibility());
// getComputedStyle consumes the same cascade.
testing.expectEqual('none', getComputedStyle($('#hidden-by-layer')).display);
}
</script>
<script id=layer_anonymous_block_hides>
{
testing.expectFalse($('#hidden-by-anon-layer').checkVisibility());
}
</script>
<script id=layer_dotted_name_hides>
{
testing.expectFalse($('#hidden-by-dotted-layer').checkVisibility());
}
</script>
<script id=layer_statement_is_inert>
{
// The statement form has nothing to apply, and rules after it still parse.
testing.expectFalse($('#hidden-after-statement').checkVisibility());
}
</script>
<script id=layer_media_in_layer>
{
// Matching query inside a layer applies; non-matching is dropped.
testing.expectFalse($('#hidden-media-in-layer').checkVisibility());
testing.expectTrue($('#not-hidden-print-in-layer').checkVisibility());
}
</script>
<script id=layer_in_media>
{
testing.expectFalse($('#hidden-layer-in-media').checkVisibility());
}
</script>
<script id=layer_nested_layers>
{
testing.expectFalse($('#hidden-nested-layer').checkVisibility());
}
</script>
<script id=layer_important_declaration>
{
testing.expectFalse($('#hidden-important').checkVisibility());
}
</script>
<script id=layer_visibility_hidden>
{
// checkVisibility() defaults to display-only; visibilityProperty opts in.
testing.expectTrue($('#layer-visibility-hidden').checkVisibility());
testing.expectFalse(
$('#layer-visibility-hidden').checkVisibility({ visibilityProperty: true })
);
}
</script>
<script id=layer_dynamic_insertRule>
{
// The cssRules path (insertRule) must recurse into @layer too.
const sheet = $('#dyn').sheet;
const index = sheet.insertRule(
'@layer utilities { #dyn-target { display: none; } }', 0
);
testing.expectEqual(0, index);
testing.expectFalse($('#dyn-target').checkVisibility());
}
</script>
<script id=layer_cssRules_surface>
{
// @layer rules postdate the legacy numeric CSSRule type constants, so
// `type` is 0 (CSSOM §6.4.1) — same as Chrome's CSSLayerBlockRule.
const sheet = $('#dyn').sheet;
const rule = sheet.cssRules[0];
testing.expectEqual(0, rule.type);
}
</script>
<script id=layer_statement_insertRule>
{
// Statement form through insertRule: surfaced via cssRules, applies nothing.
const sheet = $('#dyn').sheet;
const index = sheet.insertRule('@layer theme, base;', 0);
testing.expectEqual(0, index);
testing.expectEqual(0, sheet.cssRules[0].type);
}
</script>
<script id=layer_replaceSync>
{
// The cssRules path also fires through replaceSync.
$('#dyn2').sheet.replaceSync(
'@layer utilities { #dyn2-target { display: none; } }'
);
testing.expectFalse($('#dyn2-target').checkVisibility());
}
</script>
<script id=layer_deep_nesting_capped>
{
// 50 levels of `@layer a { ... }` around a `display:none` rule. The depth
// guard (MAX_AT_RULE_NESTING in StyleManager.zig) must short-circuit
// before the Zig stack is exhausted — and the inner rule sits beyond the
// cap, so the element stays visible.
let css = '';
for (let i = 0; i < 50; i++) css += '@layer a { ';
css += '#deep-nest-target { display: none; }';
for (let i = 0; i < 50; i++) css += ' }';
$('#deep-nest-style').sheet.replaceSync(css);
testing.expectTrue($('#deep-nest-target').checkVisibility());
}
</script>

View File

@@ -256,7 +256,7 @@
<script id=cascade_deep_nesting_capped>
{
// 50 levels of `@media screen { ... }` around a `display:none` rule. The
// depth guard (MAX_MEDIA_NESTING in StyleManager.zig) must short-circuit
// depth guard (MAX_AT_RULE_NESTING in StyleManager.zig) must short-circuit
// before the Zig stack is exhausted — and the inner rule sits beyond the
// cap, so the element stays visible.
let css = '';

View File

@@ -67,6 +67,39 @@
testing.expectEqual('DIV', div.tagName);
}
{
let constructorCalled = 0;
class TypedButton extends HTMLButtonElement {
constructor() {
super();
constructorCalled++;
this.custom = true;
}
}
customElements.define('typed-button', TypedButton, { extends: 'button' });
const btn = document.createElement('button', { is: 'typed-button' });
testing.expectEqual(1, constructorCalled);
testing.expectEqual(true, btn instanceof TypedButton);
testing.expectEqual(true, btn instanceof HTMLButtonElement);
testing.expectEqual('BUTTON', btn.tagName);
testing.expectEqual(true, btn.custom);
}
// Directly constructing a built-in interface is unsupported and must throw a
// TypeError (not silently succeed).
{
let threw = false;
try {
new HTMLDivElement();
} catch (e) {
threw = e instanceof TypeError;
}
testing.expectEqual(true, threw);
}
{
let connectedCount = 0;
let disconnectedCount = 0;

View File

@@ -0,0 +1,69 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id="constructor_sets_innerhtml">
{
// A custom element whose constructor sets innerHTML. The innerHTML
// setter fragment-parses with this element as the parsing context.
// html5ever's parse_fragment builds a throwaway context element of the
// same tag via our create_element callback; if we run its constructor
// it sets innerHTML again -> re-parses -> rebuilds the context element
// -> constructs forever (stack overflow). The context element must be
// built bare, so the constructor runs exactly once.
let calls = 0;
class CeCtorInnerHtml extends HTMLElement {
constructor() {
super();
calls += 1;
this.innerHTML = '<span>hi</span>';
}
}
customElements.define('ce-ctor-innerhtml', CeCtorInnerHtml);
const el = document.createElement('ce-ctor-innerhtml');
testing.expectEqual(1, calls);
testing.expectEqual('<span>hi</span>', el.innerHTML);
}
</script>
<script id="innerhtml_on_custom_element_host">
{
// Setting innerHTML on an existing custom-element instance parses with
// that element as context. The throwaway context element html5ever
// builds must NOT invoke the constructor a second time.
let calls = 0;
class CeHost extends HTMLElement {
constructor() {
super();
calls += 1;
}
}
customElements.define('ce-host', CeHost);
const host = document.createElement('ce-host');
testing.expectEqual(1, calls);
host.innerHTML = '<b>x</b>';
testing.expectEqual(1, calls);
testing.expectEqual('<b>x</b>', host.innerHTML);
}
</script>
<script id="fragment_children_still_upgrade">
{
// Only the context element is skipped: real custom elements that appear
// *inside* the parsed fragment must still be constructed.
let calls = 0;
class CeChild extends HTMLElement {
constructor() {
super();
calls += 1;
}
}
customElements.define('ce-child', CeChild);
const container = document.createElement('div');
container.innerHTML = '<ce-child></ce-child><ce-child></ce-child>';
testing.expectEqual(2, calls);
testing.expectEqual(2, container.querySelectorAll('ce-child').length);
}
</script>

View File

@@ -32,8 +32,7 @@
const unknownNsElement = document.createElementNS('http://example.com/unknown', 'custom');
testing.expectEqual('custom', unknownNsElement.tagName);
// Should be http://example.com/unknown
testing.expectEqual('http://lightpanda.io/unsupported/namespace', unknownNsElement.namespaceURI);
testing.expectEqual('http://example.com/unknown', unknownNsElement.namespaceURI);
const regularDiv = document.createElement('div');
testing.expectEqual('DIV', regularDiv.tagName);
@@ -45,5 +44,5 @@
testing.expectEqual('te:ST', custom.tagName);
testing.expectEqual('te', custom.prefix);
testing.expectEqual('ST', custom.localName);
testing.expectEqual('http://lightpanda.io/unsupported/namespace', custom.namespaceURI); // Should be test
testing.expectEqual('test', custom.namespaceURI);
</script>

View File

@@ -319,3 +319,38 @@
testing.expectEqual(0, blurCount);
}
</script>
<script id="focus_display_none_noop">
{
// Per HTML spec §6.4.4: an element inside a display:none container
// is "not being rendered" and therefore not focusable.
const input1 = $('#input1');
input1.focus();
testing.expectEqual(input1, document.activeElement);
// Create a hidden container with a button
const wrapper = document.createElement('div');
wrapper.style.display = 'none';
const btn = document.createElement('button');
btn.textContent = 'Hidden';
wrapper.appendChild(btn);
document.body.appendChild(wrapper);
let focusCount = 0;
btn.addEventListener('focus', () => focusCount++);
// focus() on the hidden button must be a no-op
btn.focus();
testing.expectEqual(input1, document.activeElement);
testing.expectEqual(0, focusCount);
// Make it visible — now focus should work
wrapper.style.display = '';
btn.focus();
testing.expectEqual(btn, document.activeElement);
testing.expectEqual(1, focusCount);
// Cleanup
wrapper.remove();
}
</script>

View File

@@ -15,6 +15,8 @@
<div class="nested">nested2</div>
</div>
<div class="baaa">overlap1</div>
<script>
const foos = document.getElementsByClassName('foo');
</script>
@@ -96,3 +98,10 @@
testing.expectEqual('nested1', nested[0].textContent);
testing.expectEqual('nested2', nested[1].textContent);
</script>
<script id=overlappingSubstring>
// "baaa" is a single class token; "aa" is a substring of it but not its own
// token, so it must not match.
const overlap = document.getElementsByClassName('aa');
testing.expectEqual(0, overlap.length);
</script>

View File

@@ -221,8 +221,7 @@
const root = doc.documentElement;
testing.expectEqual('prefix:localName', root.tagName);
// TODO: Custom namespaces are being replaced with an empty value
testing.expectEqual('http://lightpanda.io/unsupported/namespace', root.namespaceURI);
testing.expectEqual('http://example.com', root.namespaceURI);
}
</script>

View File

@@ -0,0 +1,155 @@
<!DOCTYPE html>
<head>
<title>DOMRect Test</title>
<script src="testing.js"></script>
</head>
<body>
</body>
<script id=default_construct>
{
const r = new DOMRect();
testing.expectEqual(0, r.x);
testing.expectEqual(0, r.y);
testing.expectEqual(0, r.width);
testing.expectEqual(0, r.height);
}
</script>
<script id=construct_with_args>
{
const r = new DOMRect(1, 2, 3, 4);
testing.expectEqual(1, r.x);
testing.expectEqual(2, r.y);
testing.expectEqual(3, r.width);
testing.expectEqual(4, r.height);
testing.expectEqual(2, r.top);
testing.expectEqual(4, r.right);
testing.expectEqual(6, r.bottom);
testing.expectEqual(1, r.left);
}
</script>
<script id=negative_dimensions>
{
// top/left take the min edge, right/bottom the max edge.
const r = new DOMRect(10, 20, -4, -6);
testing.expectEqual(14, r.top);
testing.expectEqual(10, r.right);
testing.expectEqual(20, r.bottom);
testing.expectEqual(6, r.left);
}
</script>
<script id=mutable_setters>
{
const r = new DOMRect(1, 1, 1, 1);
r.x = 5;
r.y = 6;
r.width = 7;
r.height = 8;
testing.expectEqual(5, r.x);
testing.expectEqual(6, r.y);
testing.expectEqual(7, r.width);
testing.expectEqual(8, r.height);
// Inherited computed getters reflect the mutated values.
testing.expectEqual(6, r.top);
testing.expectEqual(12, r.right);
}
</script>
<script id=readonly_is_immutable>
{
const ro = new DOMRectReadOnly(1, 2, 3, 4);
testing.expectEqual(1, ro.x);
// Assigning a readonly attribute is a silent no-op in non-strict mode.
ro.x = 99;
testing.expectEqual(1, ro.x);
testing.expectEqual(3, ro.width);
}
</script>
<script id=prototype_chain>
{
const r = new DOMRect(1, 2, 3, 4);
testing.expectTrue(r instanceof DOMRect);
testing.expectTrue(r instanceof DOMRectReadOnly);
const ro = new DOMRectReadOnly(1, 2, 3, 4);
testing.expectTrue(ro instanceof DOMRectReadOnly);
testing.expectFalse(ro instanceof DOMRect);
}
</script>
<script id=from_rect>
{
const r = DOMRect.fromRect({ x: 5, y: 6, width: 7 });
testing.expectTrue(r instanceof DOMRect);
testing.expectEqual(5, r.x);
testing.expectEqual(6, r.y);
testing.expectEqual(7, r.width);
testing.expectEqual(0, r.height);
const ro = DOMRectReadOnly.fromRect({ width: 10, height: 20 });
testing.expectTrue(ro instanceof DOMRectReadOnly);
testing.expectFalse(ro instanceof DOMRect);
testing.expectEqual(10, ro.width);
testing.expectEqual(20, ro.height);
}
</script>
<script id=from_rect_empty>
{
const r = DOMRect.fromRect();
testing.expectEqual(0, r.x);
testing.expectEqual(0, r.y);
testing.expectEqual(0, r.width);
testing.expectEqual(0, r.height);
}
</script>
<script id=to_json>
{
const r = new DOMRect(1, 2, 3, 4);
const j = r.toJSON();
testing.expectEqual(1, j.x);
testing.expectEqual(2, j.y);
testing.expectEqual(3, j.width);
testing.expectEqual(4, j.height);
testing.expectEqual(2, j.top);
testing.expectEqual(4, j.right);
testing.expectEqual(6, j.bottom);
testing.expectEqual(1, j.left);
}
</script>
<script id=structured_clone>
{
const r = structuredClone(new DOMRect(1, 2, 3, 4));
testing.expectTrue(r instanceof DOMRect);
testing.expectTrue(r instanceof DOMRectReadOnly);
testing.expectEqual(1, r.x);
testing.expectEqual(2, r.y);
testing.expectEqual(3, r.width);
testing.expectEqual(4, r.height);
const ro = structuredClone(new DOMRectReadOnly(5, 6, 7, 8));
testing.expectTrue(ro instanceof DOMRectReadOnly);
testing.expectFalse(ro instanceof DOMRect);
testing.expectEqual(5, ro.x);
testing.expectEqual(6, ro.y);
testing.expectEqual(7, ro.width);
testing.expectEqual(8, ro.height);
}
</script>
<script id=bounding_client_rect>
{
const el = document.createElement('div');
document.body.appendChild(el);
const r = el.getBoundingClientRect();
testing.expectTrue(r instanceof DOMRect);
testing.expectTrue(r instanceof DOMRectReadOnly);
}
</script>

View File

@@ -0,0 +1,68 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<map name="m">
<area id="a1" alt="logo" coords="0,0,82,126" shape="rect" target="_blank" download="pic" rel="noopener noreferrer" referrerpolicy="no-referrer" href="/page">
<area id="a2">
</map>
<script id="area-from-html">
{
const a1 = document.getElementById('a1');
testing.expectEqual('HTMLAreaElement', a1.constructor.name);
testing.expectEqual('logo', a1.alt);
testing.expectEqual('0,0,82,126', a1.coords);
testing.expectEqual('rect', a1.shape);
testing.expectEqual('_blank', a1.target);
testing.expectEqual('pic', a1.download);
testing.expectEqual('noopener noreferrer', a1.rel);
testing.expectEqual('no-referrer', a1.referrerPolicy);
testing.expectEqual(2, a1.relList.length);
testing.expectEqual('noopener', a1.relList.item(0));
testing.expectEqual('noreferrer', a1.relList.item(1));
testing.expectEqual(true, a1.relList.contains('noopener'));
const a2 = document.getElementById('a2');
testing.expectEqual('', a2.alt);
testing.expectEqual('', a2.coords);
testing.expectEqual('', a2.shape);
testing.expectEqual('', a2.target);
testing.expectEqual('', a2.download);
testing.expectEqual('', a2.rel);
testing.expectEqual('', a2.referrerPolicy);
testing.expectEqual(0, a2.relList.length);
}
</script>
<script id="area-reflection">
{
const a = document.createElement('area');
a.alt = 'hero';
testing.expectEqual('hero', a.getAttribute('alt'));
a.setAttribute('alt', 'banner');
testing.expectEqual('banner', a.alt);
a.coords = '1,2,3,4';
testing.expectEqual('1,2,3,4', a.getAttribute('coords'));
a.shape = 'circle';
testing.expectEqual('circle', a.getAttribute('shape'));
a.target = '_self';
testing.expectEqual('_self', a.getAttribute('target'));
a.download = 'file.png';
testing.expectEqual('file.png', a.getAttribute('download'));
a.rel = 'nofollow';
testing.expectEqual('nofollow', a.getAttribute('rel'));
testing.expectEqual('nofollow', a.relList.item(0));
// referrerPolicy reflects an enumerated attribute: unknown values map to ''
a.referrerPolicy = 'origin';
testing.expectEqual('origin', a.referrerPolicy);
a.setAttribute('referrerpolicy', 'bogus');
testing.expectEqual('', a.referrerPolicy);
}
</script>

View File

@@ -0,0 +1,56 @@
<!DOCTYPE html>
<script src="../../../testing.js"></script>
<!--
Non-blocking consumers of <link rel=preload as=script>: async, defer, and
dynamically-inserted scripts adopt the preloaded Script (takePreload in
addFromElement) instead of fetching the URL a second time.
Each /serve-count/ URL is served by the test server with a body of
`window.__serve_count_<name> = N;` where N counts serves of that URL — so the
body the consumer executes records whether the preload was reused (1) or a
duplicate fetch happened (2). The responses are Cache-Control: no-store, so
the HTTP cache can't mask a duplicate fetch.
-->
<!--
Adopted by a defer script. The parser doesn't pump HTTP between the link and
the script tag, so the entry is still in flight at adoption: this exercises
loading-adoption, where the transfer completes after the Script has been
rewritten into a .frame defer script (PreloadedScript.doneCallback delegates
to Script.doneCallback).
-->
<link rel="preload" as="script" href="/serve-count/defer.js" onload="window.link_defer_load = true">
<script defer src="/serve-count/defer.js"></script>
<!-- Same, adopted by an async script. -->
<link rel="preload" as="script" href="/serve-count/async.js" onload="window.link_async_load = true">
<script async src="/serve-count/async.js"></script>
<!--
Adopted by a dynamically-inserted script (defaults to async). The blocking
empty.js fetch below pumps the HTTP client, so by insertion time the preload
may be done (done-adoption: queued straight onto ready_scripts) or still in
flight (loading-adoption) — both must produce the same result.
-->
<link rel="preload" as="script" href="/serve-count/dynamic.js" onload="window.link_dynamic_load = true">
<script src="empty.js"></script>
<script>
const s = document.createElement('script');
s.src = '/serve-count/dynamic.js';
document.head.appendChild(s);
</script>
<script id="checks">
testing.onload(() => {
// Every consumer executed the first (and only) serve of its URL.
testing.expectEqual(1, window.__serve_count_defer);
testing.expectEqual(1, window.__serve_count_async);
testing.expectEqual(1, window.__serve_count_dynamic);
// Adoption must not lose the hint links' load events (hint_element rides
// on the Script, outside extra).
testing.expectEqual(true, window.link_defer_load);
testing.expectEqual(true, window.link_async_load);
testing.expectEqual(true, window.link_dynamic_load);
});
</script>

View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<script src="../../../testing.js"></script>
<!--
No <link> hints anywhere here: the preload scanner (Frame.prescanScripts)
runs over the raw buffered HTML before parsing and starts every script
download up front. Each consumer below must then reuse that fetch rather
than issue its own.
/serve-count/ bodies record how many times the URL was served (see
testing.zig); the body a consumer executes carries 1 when the prescan's
fetch was reused, 2 when a duplicate fetch happened.
-->
<!-- Blocking: waitForPreload consumes the prescan's in-flight fetch. -->
<script src="/serve-count/prescan_blocking.js"></script>
<!-- Defer: takePreload adopts the prescan's Script. -->
<script defer src="/serve-count/prescan_defer.js"></script>
<!--
Module: the prescan parks module hints in imported_modules; the script
element's own fetch adopts it via takeModuleHint.
-->
<script type="module" src="/serve-count/prescan_module.js"></script>
<script id="order">
// The blocking script above executed in document order, before us.
testing.expectEqual(1, window.__serve_count_prescan_blocking);
</script>
<script id="checks">
testing.onload(() => {
testing.expectEqual(1, window.__serve_count_prescan_blocking);
testing.expectEqual(1, window.__serve_count_prescan_defer);
testing.expectEqual(1, window.__serve_count_prescan_module);
});
</script>

View File

@@ -0,0 +1,81 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<script id="deleteRow">
{
// Built by hand so the tree order deliberately differs from the spec's
// row order: the tfoot comes first and bare tr children sit between
// the sections.
const table = document.createElement('table');
const rows = {};
const addRow = (parent, id) => {
const tr = document.createElement('tr');
tr.id = id;
parent.appendChild(tr);
rows[id] = tr;
};
const addSection = (tag, ...ids) => {
const section = document.createElement(tag);
for (const id of ids) addRow(section, id);
table.appendChild(section);
};
addSection('tfoot', 'foot1');
addRow(table, 'top1');
addSection('thead', 'head1', 'head2');
addSection('tbody', 'body1');
addRow(table, 'top2');
addSection('tbody', 'body2');
// Spec order: thead rows first, then table-level tr and tbody rows in
// tree order, then tfoot rows:
// head1, head2, top1, body1, top2, body2, foot1
// -1 removes the last row in spec order: the tfoot row, even though
// the tfoot is the table's first child.
table.deleteRow(-1);
testing.expectEqual(null, rows.foot1.parentNode);
// 6 rows left; out-of-range and < -1 both throw.
testing.expectError('IndexSizeError', () => table.deleteRow(6));
testing.expectError('IndexSizeError', () => table.deleteRow(-2));
// Index 3 of head1, head2, top1, body1, top2, body2 is the tbody row.
table.deleteRow(3);
testing.expectEqual(null, rows.body1.parentNode);
// deleteRow(0) drains the rest in spec order.
for (const id of ['head1', 'head2', 'top1', 'top2', 'body2']) {
testing.expectTrue(rows[id].parentNode !== null);
table.deleteRow(0);
testing.expectEqual(null, rows[id].parentNode);
}
// On a rowless table, -1 is a no-op but anything else throws.
table.deleteRow(-1);
testing.expectError('IndexSizeError', () => table.deleteRow(0));
}
</script>
<table id="tbodies">
<thead><tr></tr></thead>
<tbody id="b1"><tr></tr></tbody>
<tbody id="b2"><tr></tr></tbody>
<tfoot><tr></tr></tfoot>
</table>
<script id="tBodies">
{
const table = document.getElementById('tbodies');
testing.expectEqual(2, table.tBodies.length);
testing.expectEqual('b1', table.tBodies[0].id);
testing.expectEqual('b2', table.tBodies[1].id);
// The collection is live.
const bodies = table.tBodies;
table.removeChild(document.getElementById('b1'));
testing.expectEqual(1, bodies.length);
testing.expectEqual('b2', bodies[0].id);
}
</script>

View File

@@ -0,0 +1,73 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<svg id=root class="chart wide">
<use id=use1 xlink:href="#icon"></use>
<use id=use2 href="#modern" xlink:href="#legacy"></use>
<a id=link1></a>
</svg>
<div id=plain class="html-class"></div>
<script id=className>
{
const root = $('#root');
testing.expectEqual('object', typeof root.className);
testing.expectEqual(true, root.className instanceof SVGAnimatedString);
testing.expectEqual('chart wide', root.className.baseVal);
testing.expectEqual('chart wide', root.className.animVal);
// [SameObject]
testing.expectEqual(true, root.className === root.className);
root.className.baseVal = 'chart narrow';
testing.expectEqual('chart narrow', root.getAttribute('class'));
testing.expectEqual('chart narrow', root.className.animVal);
testing.expectEqual(true, root.classList.contains('narrow'));
root.setAttribute('class', 'other');
testing.expectEqual('other', root.className.baseVal);
// The SVG className accessor doesn't leak onto HTML elements.
testing.expectEqual('html-class', $('#plain').className);
}
</script>
<script id=href>
{
// The parser stores xlink:href under its local name, href.
const use1 = $('#use1');
testing.expectEqual(true, use1.href instanceof SVGAnimatedString);
testing.expectEqual('#icon', use1.href.baseVal);
testing.expectEqual('#icon', use1.href.animVal);
testing.expectEqual(true, use1.href === use1.href);
testing.expectEqual('#modern', $('#use2').href.baseVal);
// Writes land in xlink:href in browsers (it's the attribute that's set)
// but in href for us since the parser stored it there; assert the common
// observable.
use1.href.baseVal = '#other';
testing.expectEqual('#other', use1.href.baseVal);
// setAttributeNS stores under the local name, so a runtime xlink:href
// lands in href and reflects like the parsed variant. (Browsers keep the
// xlink:href attribute but reflect it the same way.)
const use3 = document.createElementNS('http://www.w3.org/2000/svg', 'use');
use3.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', '#runtime');
testing.expectEqual('#runtime', use3.href.baseVal);
// A namespace-less setAttribute('xlink:href') is a literal attribute in no
// namespace: not reflected, in browsers or here.
const use4 = document.createElementNS('http://www.w3.org/2000/svg', 'use');
use4.setAttribute('xlink:href', '#literal');
testing.expectEqual('', use4.href.baseVal);
use4.href.baseVal = '#written';
testing.expectEqual('#written', use4.getAttribute('href'));
testing.expectEqual(true, use4.hasAttribute('xlink:href'));
// No href attribute at all: set writes to href.
const link = $('#link1');
testing.expectEqual('', link.href.baseVal);
link.href.baseVal = '/docs';
testing.expectEqual('/docs', link.getAttribute('href'));
}
</script>

View File

@@ -0,0 +1,115 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<svg id=root width="200" height="100">
<g id=group>
<rect id=rect1 width="10" height="10"></rect>
<circle id=circle1 r="5"></circle>
<ellipse id=ellipse1></ellipse>
<line id=line1></line>
<polygon id=polygon1></polygon>
<polyline id=polyline1></polyline>
</g>
<path id=path1 d="M 0 0 L 10 10"></path>
<a id=link1></a>
<use id=use1></use>
<image id=image1></image>
<defs id=defs1></defs>
<filter id=filter1></filter>
</svg>
<script id=classes>
{
testing.expectEqual('function', typeof SVGElement);
testing.expectEqual('function', typeof SVGGraphicsElement);
testing.expectEqual('function', typeof SVGGeometryElement);
testing.expectEqual('function', typeof SVGSVGElement);
testing.expectEqual('function', typeof SVGGElement);
testing.expectEqual('function', typeof SVGAElement);
testing.expectEqual('function', typeof SVGUseElement);
testing.expectEqual('function', typeof SVGImageElement);
testing.expectEqual('function', typeof SVGDefsElement);
testing.expectEqual('function', typeof SVGRectElement);
testing.expectEqual('function', typeof SVGCircleElement);
testing.expectEqual('function', typeof SVGEllipseElement);
testing.expectEqual('function', typeof SVGLineElement);
testing.expectEqual('function', typeof SVGPathElement);
testing.expectEqual('function', typeof SVGPolygonElement);
testing.expectEqual('function', typeof SVGPolylineElement);
testing.expectEqual('function', typeof SVGAnimatedString);
testing.expectEqual('function', typeof SVGNumber);
}
</script>
<script id=instanceof>
{
const root = $('#root');
testing.expectEqual(true, root instanceof SVGSVGElement);
testing.expectEqual(true, root instanceof SVGGraphicsElement);
testing.expectEqual(true, root instanceof SVGElement);
testing.expectEqual(true, root instanceof Element);
testing.expectEqual(false, root instanceof SVGGeometryElement);
testing.expectEqual('svg', root.tagName);
const rect = $('#rect1');
testing.expectEqual(true, rect instanceof SVGRectElement);
testing.expectEqual(true, rect instanceof SVGGeometryElement);
testing.expectEqual(true, rect instanceof SVGGraphicsElement);
testing.expectEqual(true, rect instanceof SVGElement);
testing.expectEqual(false, rect instanceof SVGSVGElement);
testing.expectEqual('rect', rect.tagName);
testing.expectEqual('10', rect.getAttribute('width'));
testing.expectEqual(true, $('#group') instanceof SVGGElement);
testing.expectEqual(true, $('#circle1') instanceof SVGCircleElement);
testing.expectEqual(true, $('#ellipse1') instanceof SVGEllipseElement);
testing.expectEqual(true, $('#line1') instanceof SVGLineElement);
testing.expectEqual(true, $('#polygon1') instanceof SVGPolygonElement);
testing.expectEqual(true, $('#polyline1') instanceof SVGPolylineElement);
testing.expectEqual(true, $('#path1') instanceof SVGPathElement);
testing.expectEqual(true, $('#link1') instanceof SVGAElement);
testing.expectEqual(true, $('#use1') instanceof SVGUseElement);
testing.expectEqual(true, $('#image1') instanceof SVGImageElement);
testing.expectEqual(true, $('#defs1') instanceof SVGDefsElement);
// Elements without a dedicated type stay generic, but are still SVGElement.
const filter = $('#filter1');
testing.expectEqual(true, filter instanceof SVGElement);
testing.expectEqual(false, filter instanceof SVGGraphicsElement);
testing.expectEqual('filter', filter.tagName);
}
</script>
<script id=createElementNS>
{
const NS = 'http://www.w3.org/2000/svg';
const rect = document.createElementNS(NS, 'rect');
testing.expectEqual(true, rect instanceof SVGRectElement);
testing.expectEqual('rect', rect.tagName);
// SVG tag names are case-sensitive: 'RECT' isn't a rect element.
const notRect = document.createElementNS(NS, 'RECT');
testing.expectEqual(false, notRect instanceof SVGRectElement);
testing.expectEqual(true, notRect instanceof SVGElement);
testing.expectEqual('RECT', notRect.tagName);
const svg = document.createElementNS(NS, 'svg');
testing.expectEqual(true, svg instanceof SVGSVGElement);
// querySelector still matches both dedicated and generic types.
testing.expectEqual($('#rect1'), document.querySelector('#root rect'));
testing.expectEqual($('#use1'), document.querySelector('#root use'));
testing.expectEqual($('#filter1'), document.querySelector('#root filter'));
}
</script>
<script id=hasAttributeNS>
{
// Parser-created attributes have a null namespace. We ignore attribute
// namespaces, so only null/'' lookups behave like a real browser here.
const rect = $('#rect1');
testing.expectEqual(true, rect.hasAttributeNS(null, 'width'));
testing.expectEqual(true, rect.hasAttributeNS('', 'width'));
testing.expectEqual(false, rect.hasAttributeNS(null, 'nope'));
}
</script>

View File

@@ -0,0 +1,85 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<svg id=first>
<g>
<rect id=inner-rect></rect>
<svg id=nested>
<circle id=nested-circle></circle>
</svg>
</g>
</svg>
<svg id=second>
<rect id=other-rect></rect>
</svg>
<script id=getElementById>
{
const first = $('#first');
testing.expectEqual($('#inner-rect'), first.getElementById('inner-rect'));
testing.expectEqual($('#nested-circle'), first.getElementById('nested-circle'));
// Scoped to the subtree, unlike document.getElementById.
testing.expectEqual(null, first.getElementById('other-rect'));
testing.expectEqual(null, $('#second').getElementById('inner-rect'));
testing.expectEqual(null, first.getElementById(''));
testing.expectEqual(null, first.getElementById('missing'));
}
</script>
<script id=factories>
{
// SVG2 defines SVGPoint/SVGMatrix/SVGRect as aliases of the DOM geometry
// types, which is what we return. Browsers (Chrome and Firefox both) still
// ship distinct legacy interfaces, so the instanceof and DOM-geometry-only
// members (w, is2D) fail there. The DOM types are functional supersets of
// the legacy ones, so site-facing behavior only gains members.
const svg = $('#first');
const point = svg.createSVGPoint();
testing.expectEqual(true, point instanceof DOMPoint);
testing.expectEqual(0, point.x);
testing.expectEqual(0, point.y);
testing.expectEqual(1, point.w);
point.x = 5;
testing.expectEqual(5, point.x);
const matrix = svg.createSVGMatrix();
testing.expectEqual(true, matrix instanceof DOMMatrix);
testing.expectEqual(1, matrix.a);
testing.expectEqual(0, matrix.b);
testing.expectEqual(1, matrix.d);
testing.expectEqual(true, matrix.is2D);
const rect = svg.createSVGRect();
testing.expectEqual(true, rect instanceof DOMRect);
testing.expectEqual(0, rect.x);
testing.expectEqual(0, rect.width);
const number = svg.createSVGNumber();
testing.expectEqual(true, number instanceof SVGNumber);
testing.expectEqual(0, number.value);
number.value = 4.5;
testing.expectEqual(4.5, number.value);
}
</script>
<script id=owner>
{
const first = $('#first');
const nested = $('#nested');
testing.expectEqual(null, first.ownerSVGElement);
testing.expectEqual(null, first.viewportElement);
testing.expectEqual(first, $('#inner-rect').ownerSVGElement);
testing.expectEqual(first, $('#inner-rect').viewportElement);
testing.expectEqual(first, nested.ownerSVGElement);
testing.expectEqual(nested, $('#nested-circle').ownerSVGElement);
testing.expectEqual($('#second'), $('#other-rect').ownerSVGElement);
// A foreignObject starts a new SVG document fragment.
first.innerHTML += '<foreignObject><div><svg id=foreign-svg></svg></div></foreignObject>';
testing.expectEqual(null, $('#foreign-svg').ownerSVGElement);
}
</script>

View File

@@ -11,7 +11,8 @@
testing.expectEqual("test", event.type);
testing.expectEqual("", event.data);
testing.expectEqual(0, event.detail);
testing.expectEqual(window, event.view);
// per UIEventInit, view defaults to null for synthetic events
testing.expectEqual(null, event.view);
}
</script>

View File

@@ -75,8 +75,10 @@
const event2 = document.createEvent('CUSTOMEVENT');
testing.expectEqual('', event2.type);
const event3 = document.createEvent('CustomEvents');
testing.expectEqual('', event3.type);
// per spec, the pluralized legacy alias is not supported
let threw = false;
try { document.createEvent('CustomEvents'); } catch (e) { threw = e.name === 'NotSupportedError'; }
testing.expectEqual(true, threw);
}
</script>

View File

@@ -11,7 +11,7 @@
testing.expectEqual(5, evt.detail);
testing.expectEqual(true, evt.bubbles);
testing.expectEqual(true, evt.cancelable);
testing.expectEqual(window, evt.view);
testing.expectEqual(null, evt.view);
</script>
<script id=uiEventWithView>
@@ -30,7 +30,7 @@
testing.expectEqual(0, evt3.detail);
testing.expectEqual(false, evt3.bubbles);
testing.expectEqual(false, evt3.cancelable);
testing.expectEqual(window, evt3.view);
testing.expectEqual(null, evt3.view);
</script>
<script id=uiEventInheritance>

View File

@@ -900,3 +900,56 @@
window.removeEventListener('win_throw', c);
}
</script>
<div id=return_false_parent><span id=return_false_child></span></div>
<script id=inlineHandlerReturnFalseCancels>
// Per the HTML event handler processing algorithm, an event handler (on*
// property/attribute) returning false cancels the event; the return value
// of addEventListener listeners is ignored.
{
const parent = $('#return_false_parent');
const child = $('#return_false_child');
// At-target inline handler.
child.onclick = () => false;
const ev1 = new MouseEvent('click', {bubbles: true, cancelable: true});
testing.expectEqual(false, child.dispatchEvent(ev1));
testing.expectEqual(true, ev1.defaultPrevented);
child.onclick = null;
// Inline handler on an ancestor (bubble phase).
parent.onclick = () => false;
const ev2 = new MouseEvent('click', {bubbles: true, cancelable: true});
testing.expectEqual(false, child.dispatchEvent(ev2));
testing.expectEqual(true, ev2.defaultPrevented);
parent.onclick = null;
// Only false cancels; other falsy values don't.
child.onclick = () => undefined;
const ev3 = new MouseEvent('click', {bubbles: true, cancelable: true});
testing.expectEqual(true, child.dispatchEvent(ev3));
testing.expectEqual(false, ev3.defaultPrevented);
child.onclick = null;
// addEventListener listeners returning false don't cancel.
const listener = () => false;
child.addEventListener('click', listener);
const ev4 = new MouseEvent('click', {bubbles: true, cancelable: true});
testing.expectEqual(true, child.dispatchEvent(ev4));
testing.expectEqual(false, ev4.defaultPrevented);
child.removeEventListener('click', listener);
}
</script>
<script id=beforeUnloadEventReturnValue>
{
const event = document.createEvent('BeforeUnloadEvent');
testing.expectEqual('', event.returnValue);
event.returnValue = 'first';
testing.expectEqual('first', event.returnValue);
event.returnValue = 'replacement';
testing.expectEqual('replacement', event.returnValue);
}
</script>

View File

@@ -0,0 +1,6 @@
<!DOCTYPE html>
<!-- The onresize attribute exercises the parse-time path: it must land on
THIS frame's window, not the top frame's. -->
<body onresize="window.__attr_resize = true">
<p>x</p>
</body>

View File

@@ -0,0 +1,53 @@
<!DOCTYPE html>
<head></head>
<body>
<script src="../testing.js"></script>
<!--
The window-reflecting body element event handlers (onblur, onerror, onfocus,
onload, onresize, onscroll) alias the Window of the body's node document. A
same-origin script reaching into another frame (parent setting a handler on
an iframe's body) must target the iframe's window, not the window of the
realm running the assignment.
-->
<iframe id="wrh" src="support/window_reflecting.html"></iframe>
<script id="cross_realm_property_handlers">
testing.onload(() => {
const idf = document.getElementById('wrh');
const ibody = idf.contentDocument.body;
const iwin = idf.contentWindow;
const cb = () => {};
ibody.onblur = cb;
testing.expectEqual(cb, ibody.onblur);
testing.expectEqual(cb, iwin.onblur);
// the parent's window (the caller's realm) must be untouched
testing.expectTrue(window.onblur == null);
testing.expectTrue(document.body.onblur == null);
ibody.onblur = null;
testing.expectTrue(iwin.onblur == null);
});
</script>
<script id="cross_realm_attribute_handlers">
testing.onload(() => {
const idf = document.getElementById('wrh');
const ibody = idf.contentDocument.body;
const iwin = idf.contentWindow;
// Parse-time: the onresize attribute in the iframe's markup was compiled
// onto the iframe's window while the parent frame was the active one.
testing.expectEqual('function', typeof iwin.onresize);
testing.expectTrue(window.onresize == null);
// Runtime: setting the content attribute from the parent realm.
ibody.setAttribute('onscroll', 'window.__parent_set = true');
testing.expectEqual('function', typeof iwin.onscroll);
testing.expectTrue(window.onscroll == null);
ibody.removeAttribute('onscroll');
testing.expectTrue(iwin.onscroll == null);
});
</script>

View File

@@ -70,6 +70,19 @@
}
</script>
<script id=structured-clone>
{
const img = new ImageData(2, 2);
img.data[0] = 42;
const clone = structuredClone(img);
testing.expectEqual(2, clone.width);
testing.expectEqual(2, clone.height);
testing.expectEqual(42, clone.data[0]);
clone.data[0] = 7;
testing.expectEqual(42, img.data[0]);
}
</script>
<script id=too-large>
testing.expectError("IndexSizeError", () => new ImageData(2_147_483_648, 2_147_483_648));
</script>

View File

@@ -183,31 +183,113 @@
replaceParent.replaceChild(replaceNew, replaceOld);
await state.done((mutations) => {
// replaceChild generates two separate mutation records in modern spec:
// 1. First record for insertBefore (new node added)
// 2. Second record for removeChild (old node removed)
testing.expectEqual(2, mutations.length);
// Per the DOM "replace" algorithm, replaceChild queues one combined
// mutation record with both the added and the removed node.
testing.expectEqual(1, mutations.length);
// First mutation: insertion of new node
testing.expectEqual('childList', mutations[0].type);
testing.expectEqual(replaceParent, mutations[0].target);
testing.expectEqual(1, mutations[0].addedNodes.length);
testing.expectEqual(replaceNew, mutations[0].addedNodes[0]);
testing.expectEqual(0, mutations[0].removedNodes.length);
testing.expectEqual(1, mutations[0].removedNodes.length);
testing.expectEqual(replaceOld, mutations[0].removedNodes[0]);
testing.expectEqual(null, mutations[0].previousSibling);
testing.expectEqual(replaceOld, mutations[0].nextSibling);
testing.expectEqual(null, mutations[0].nextSibling);
});
</script>
<div id="replace-frag-parent"><div id="replace-frag-old">Old</div></div>
<script id="childlist_replace_child_fragment" type=module>
const state = await testing.async();
const parent = document.getElementById('replace-frag-parent');
const oldChild = document.getElementById('replace-frag-old');
const fragment = document.createDocumentFragment();
const frag1 = document.createElement('div');
const frag2 = document.createElement('div');
fragment.append(frag1, frag2);
const observer = new MutationObserver((records) => {
observer.disconnect();
state.resolve(records);
});
observer.observe(parent, { childList: true });
observer.observe(fragment, { childList: true });
parent.replaceChild(fragment, oldChild);
await state.done((mutations) => {
// The fragment's removal record is queued even though the replacement
// itself suppresses observers (the spec's insert algorithm notes the
// fragment record "intentionally does not pay attention to
// suppressObservers"), followed by the parent's combined record.
testing.expectEqual(2, mutations.length);
testing.expectEqual('childList', mutations[0].type);
testing.expectEqual(fragment, mutations[0].target);
testing.expectEqual(0, mutations[0].addedNodes.length);
testing.expectEqual(2, mutations[0].removedNodes.length);
testing.expectEqual(frag1, mutations[0].removedNodes[0]);
testing.expectEqual(frag2, mutations[0].removedNodes[1]);
testing.expectEqual(null, mutations[0].previousSibling);
testing.expectEqual(null, mutations[0].nextSibling);
// Second mutation: removal of old node
testing.expectEqual('childList', mutations[1].type);
testing.expectEqual(replaceParent, mutations[1].target);
testing.expectEqual(0, mutations[1].addedNodes.length);
testing.expectEqual(parent, mutations[1].target);
testing.expectEqual(2, mutations[1].addedNodes.length);
testing.expectEqual(frag1, mutations[1].addedNodes[0]);
testing.expectEqual(frag2, mutations[1].addedNodes[1]);
testing.expectEqual(1, mutations[1].removedNodes.length);
testing.expectEqual(replaceOld, mutations[1].removedNodes[0]);
testing.expectEqual(replaceNew, mutations[1].previousSibling);
testing.expectEqual(oldChild, mutations[1].removedNodes[0]);
testing.expectEqual(null, mutations[1].previousSibling);
testing.expectEqual(null, mutations[1].nextSibling);
});
</script>
<div id="replace-adjacent-parent"><div id="replace-adjacent-a">A</div><div id="replace-adjacent-new">New</div><div id="replace-adjacent-old">Old</div><div id="replace-adjacent-b">B</div></div>
<script id="childlist_replace_child_adjacent" type=module>
const state = await testing.async();
const parent = document.getElementById('replace-adjacent-parent');
const a = document.getElementById('replace-adjacent-a');
const newChild = document.getElementById('replace-adjacent-new');
const oldChild = document.getElementById('replace-adjacent-old');
const b = document.getElementById('replace-adjacent-b');
const observer = new MutationObserver((records) => {
observer.disconnect();
state.resolve(records);
});
observer.observe(parent, { childList: true });
// Replacing a child with its immediate previous sibling: the combined
// record's previousSibling is captured before newChild is removed from
// its old position, so it is newChild itself.
parent.replaceChild(newChild, oldChild);
await state.done((mutations) => {
testing.expectEqual(2, mutations.length);
testing.expectEqual('childList', mutations[0].type);
testing.expectEqual(parent, mutations[0].target);
testing.expectEqual(0, mutations[0].addedNodes.length);
testing.expectEqual(1, mutations[0].removedNodes.length);
testing.expectEqual(newChild, mutations[0].removedNodes[0]);
testing.expectEqual(a, mutations[0].previousSibling);
testing.expectEqual(oldChild, mutations[0].nextSibling);
testing.expectEqual('childList', mutations[1].type);
testing.expectEqual(parent, mutations[1].target);
testing.expectEqual(1, mutations[1].addedNodes.length);
testing.expectEqual(newChild, mutations[1].addedNodes[0]);
testing.expectEqual(1, mutations[1].removedNodes.length);
testing.expectEqual(oldChild, mutations[1].removedNodes[0]);
testing.expectEqual(newChild, mutations[1].previousSibling);
testing.expectEqual(b, mutations[1].nextSibling);
});
</script>
<div id="multiple-parent"></div>
<script id="childlist_multiple_mutations" type=module>
@@ -269,41 +351,18 @@
innerHtmlParent.innerHTML = '<span>New 1</span><span>New 2</span><span>New 3</span>';
await state.done((mutations) => {
// innerHTML triggers mutations for both removals and additions
// With tri-state: from_parser=true + parse_mode=fragment -> mutations fire
// HTML wrapper element is filtered out, so: 3 removals + 3 additions = 6
testing.expectEqual(6, mutations.length);
// Per the "replace all" algorithm, innerHTML queues a single mutation
// record combining all removed and added nodes.
testing.expectEqual(1, mutations.length);
// First 3: removals
testing.expectEqual('childList', mutations[0].type);
testing.expectEqual(1, mutations[0].removedNodes.length);
testing.expectEqual(3, mutations[0].removedNodes.length);
testing.expectEqual(oldChildren[0], mutations[0].removedNodes[0]);
testing.expectEqual(0, mutations[0].addedNodes.length);
testing.expectEqual('childList', mutations[1].type);
testing.expectEqual(1, mutations[1].removedNodes.length);
testing.expectEqual(oldChildren[1], mutations[1].removedNodes[0]);
testing.expectEqual(0, mutations[1].addedNodes.length);
testing.expectEqual('childList', mutations[2].type);
testing.expectEqual(1, mutations[2].removedNodes.length);
testing.expectEqual(oldChildren[2], mutations[2].removedNodes[0]);
testing.expectEqual(0, mutations[2].addedNodes.length);
// Last 3: additions (unwrapped span elements)
testing.expectEqual('childList', mutations[3].type);
testing.expectEqual(0, mutations[3].removedNodes.length);
testing.expectEqual(1, mutations[3].addedNodes.length);
testing.expectEqual('SPAN', mutations[3].addedNodes[0].nodeName);
testing.expectEqual('childList', mutations[4].type);
testing.expectEqual(0, mutations[4].removedNodes.length);
testing.expectEqual(1, mutations[4].addedNodes.length);
testing.expectEqual('SPAN', mutations[4].addedNodes[0].nodeName);
testing.expectEqual('childList', mutations[5].type);
testing.expectEqual(0, mutations[5].removedNodes.length);
testing.expectEqual(1, mutations[5].addedNodes.length);
testing.expectEqual('SPAN', mutations[5].addedNodes[0].nodeName);
testing.expectEqual(oldChildren[1], mutations[0].removedNodes[1]);
testing.expectEqual(oldChildren[2], mutations[0].removedNodes[2]);
testing.expectEqual(3, mutations[0].addedNodes.length);
testing.expectEqual('SPAN', mutations[0].addedNodes[0].nodeName);
testing.expectEqual('SPAN', mutations[0].addedNodes[1].nodeName);
testing.expectEqual('SPAN', mutations[0].addedNodes[2].nodeName);
});
</script>

View File

@@ -0,0 +1,18 @@
// Exercises EventSource inside a worker. Receives a command from the page,
// consumes the stream, and posts the results back.
self.onmessage = function() {
try {
const received = [];
const es = new EventSource('http://127.0.0.1:9582/sse/simple');
es.onopen = () => received.push('open');
es.onmessage = (e) => received.push(e.data);
es.addEventListener('custom', (e) => {
received.push(e.data);
es.close();
postMessage({ ok: true, received, readyState: es.readyState });
});
es.onerror = () => postMessage({ ok: false, err: 'eventsource error' });
} catch (err) {
postMessage({ ok: false, err: String(err) });
}
};

View File

@@ -0,0 +1,167 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=constants type=module>
testing.expectEqual(0, EventSource.CONNECTING);
testing.expectEqual(1, EventSource.OPEN);
testing.expectEqual(2, EventSource.CLOSED);
let threw = false;
try {
new EventSource('http://this is invalid/');
} catch (e) {
threw = true;
testing.expectEqual('SyntaxError', e.name);
}
testing.expectTrue(threw, 'invalid URL must throw');
</script>
<script id=simple type=module>
{
const state = await testing.async();
const received = [];
const es = new EventSource('http://127.0.0.1:9582/sse/simple');
testing.expectEqual(0, es.readyState);
testing.expectEqual('http://127.0.0.1:9582/sse/simple', es.url);
testing.expectEqual(false, es.withCredentials);
es.onopen = () => {
received.push(['open', es.readyState]);
};
es.onmessage = (e) => {
received.push(['message', e.data, e.lastEventId, e.origin, e.isTrusted]);
};
es.addEventListener('custom', (e) => {
received.push(['custom', e.data, e.lastEventId]);
es.close();
received.push(['closed', es.readyState]);
state.resolve();
});
await state.done(() => {
testing.expectEqual([
['open', 1],
['message', 'first', '', 'http://127.0.0.1:9582', true],
['custom', 'a\nb', '42'],
['closed', 2],
], received);
});
}
</script>
<script id=relative_url type=module>
{
const state = await testing.async();
const es = new EventSource('/sse/simple');
testing.expectEqual('http://127.0.0.1:9582/sse/simple', es.url);
es.onerror = () => { throw new Error('unexpected error'); };
es.onmessage = (e) => {
es.close();
state.resolve(e.data);
};
await state.done((data) => {
testing.expectEqual('first', data);
});
}
</script>
<script id=streaming type=module>
{
// The server only sends "second" after seeing our /sse/flag request, which
// we make on receiving "first" — so this only completes if data events are
// delivered while the response is still streaming.
const state = await testing.async();
const received = [];
const es = new EventSource('http://127.0.0.1:9582/sse/streaming');
es.onmessage = (e) => {
received.push(e.data);
if (e.data === 'first') {
fetch('http://127.0.0.1:9582/sse/flag');
} else {
es.close();
state.resolve();
}
};
await state.done(() => {
testing.expectEqual(['first', 'second'], received);
});
}
</script>
<script id=reconnect type=module>
{
const state = await testing.async();
const received = [];
const es = new EventSource('http://127.0.0.1:9582/sse/reconnect');
es.onerror = () => {
received.push(['error', es.readyState]);
};
es.onmessage = (e) => {
received.push(e.data);
if (e.data === 'two') {
es.close();
state.resolve();
}
};
await state.done(() => {
testing.expectEqual(['one', ['error', 0], 'two'], received);
});
}
</script>
<script id=pending_reconnect_at_teardown type=module>
{
// Deliberately left open: the stream ends and the source sits on its
// (60s) reconnect timer with no transfer, so page teardown can only
// clean it up through the scheduler-task finalizer. The test runner's
// leak detection fails this test if that path regresses.
const state = await testing.async();
const es = new EventSource('http://127.0.0.1:9582/sse/long_retry');
es.onerror = () => state.resolve(es.readyState);
await state.done((readyState) => {
testing.expectEqual(0, readyState);
});
}
</script>
<script id=fail_status type=module>
{
const state = await testing.async();
const es = new EventSource('http://127.0.0.1:9582/sse/404');
es.onmessage = () => { throw new Error('unexpected message'); };
es.onerror = () => state.resolve(es.readyState);
await state.done((readyState) => {
testing.expectEqual(2, readyState);
});
}
</script>
<script id=fail_mime type=module>
{
const state = await testing.async();
const es = new EventSource('http://127.0.0.1:9582/sse/badmime');
es.onmessage = () => { throw new Error('unexpected message'); };
es.onerror = () => state.resolve(es.readyState);
await state.done((readyState) => {
testing.expectEqual(2, readyState);
});
}
</script>
<script id=fail_scheme type=module>
{
const state = await testing.async();
const es = new EventSource('ftp://127.0.0.1/');
es.onerror = () => state.resolve(es.readyState);
await state.done((readyState) => {
testing.expectEqual(2, readyState);
});
}
</script>

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=eventsource_in_worker type=module>
{
const state = await testing.async();
const worker = new Worker('./eventsource-worker.js');
worker.onmessage = (e) => state.resolve(e.data);
worker.postMessage({});
await state.done((data) => {
testing.expectTrue(data.ok, 'worker eventsource error: ' + data.err);
testing.expectEqual(['open', 'first', 'a\nb'], data.received);
testing.expectEqual(2, data.readyState);
});
}
</script>

View File

@@ -562,6 +562,29 @@
}
</script>
<script id=close_while_connecting type=module>
{
const state = await testing.async();
let received = [];
let ws = new WebSocket('ws://127.0.0.1:9584/');
ws.addEventListener('open', () => { received.push('open'); });
ws.addEventListener('close', (e) => {
received.push(['close', e.code, e.reason, e.wasClean]);
state.resolve();
});
ws.close(3000, 'changed my mind');
received.push(ws.readyState); // CLOSED = 3, immediately
await state.done(() => {
// open never fires; the close event is delivered asynchronously with
// the values close() was given and wasClean=false.
testing.expectEqual([3, ['close', 3000, 'changed my mind', false]], received);
});
}
</script>
<script id=ws_within_callback type=module>
{
const state = await testing.async();

View File

@@ -22,9 +22,10 @@
let c4 = document.createElement('div');
c4.id = 'c4';
// Per spec, a child with the wrong parent is a NotFoundError.
testing.withError((err) => {
testing.expectEqual(3, err.code);
testing.expectEqual("HierarchyRequestError", err.name);
testing.expectEqual(8, err.code);
testing.expectEqual("NotFoundError", err.name);
}, () => d1.replaceChild(c4, c3));
testing.expectEqual(c2, d1.replaceChild(c4, c2));

View File

@@ -0,0 +1,8 @@
let counter = 0;
onconnect = (e) => {
const port = e.ports[0];
port.onmessage = () => {
counter++;
port.postMessage({ count: counter, name: self.name });
};
};

View File

@@ -0,0 +1,8 @@
onconnect = (e) => {
// Per spec the connect event's source is the port itself (testharness.js
// relies on this rather than ports[0]).
const port = e.source;
port.onmessage = (event) => {
port.postMessage({ echo: event.data, from: 'shared-worker', sourceIsPort: e.source === e.ports[0] });
};
};

View File

@@ -0,0 +1,10 @@
// Exercises the addEventListener flavor on both the global ('connect') and
// the port ('message' + explicit start()), instead of the auto-starting
// onconnect/onmessage attribute setters.
self.addEventListener('connect', (e) => {
const port = e.ports[0];
port.addEventListener('message', (event) => {
port.postMessage('listener:' + event.data);
});
port.start();
});

View File

@@ -0,0 +1,137 @@
<!DOCTYPE html>
<body></body>
<script src="../testing.js"></script>
<script id="shared_worker_creation">
{
const worker = new SharedWorker('./echo-worker.js');
testing.expectTrue(worker instanceof SharedWorker);
testing.expectTrue(worker.port instanceof MessagePort);
}
</script>
<script id="shared_worker_echo" type=module>
// Also exercises post-before-load: the message is posted while the worker
// script's fetch is still in flight, so it must sit in the worker-side
// port's queue until the connect handler assigns port.onmessage.
{
const state = await testing.async();
const worker = new SharedWorker('./echo-worker.js');
worker.port.onmessage = (event) => {
state.resolve(event.data);
};
worker.port.postMessage({ greeting: 'hello' });
await state.done((response) => {
testing.expectEqual('hello', response.echo.greeting);
testing.expectEqual('shared-worker', response.from);
testing.expectEqual(true, response.sourceIsPort);
});
}
</script>
<script id="shared_worker_shared_instance" type=module>
// Two SharedWorker handles for the same (url, name) are distinct JS
// objects but connect to the same worker: the counter is shared.
{
const state = await testing.async();
const w1 = new SharedWorker('./counter-worker.js');
const w2 = new SharedWorker('./counter-worker.js');
testing.expectTrue(w1 !== w2);
testing.expectTrue(w1.port !== w2.port);
const incr = (w) => new Promise((resolve) => {
w.port.onmessage = (e) => resolve(e.data);
w.port.postMessage('incr');
});
const c1 = await incr(w1);
const c2 = await incr(w2);
state.resolve();
await state.done(() => {
testing.expectEqual(1, c1.count);
testing.expectEqual(2, c2.count);
testing.expectEqual('', c1.name);
});
}
</script>
<script id="shared_worker_named_isolation" type=module>
// Different names on the same url are different workers, and each worker
// sees its own name. The second argument can be a string or an options
// object.
{
const state = await testing.async();
const wa = new SharedWorker('./counter-worker.js', 'a');
const wb = new SharedWorker('./counter-worker.js', { name: 'b' });
const incr = (w) => new Promise((resolve) => {
w.port.onmessage = (e) => resolve(e.data);
w.port.postMessage('incr');
});
const ca = await incr(wa);
const cb = await incr(wb);
state.resolve();
await state.done(() => {
testing.expectEqual(1, ca.count);
testing.expectEqual('a', ca.name);
testing.expectEqual(1, cb.count);
testing.expectEqual('b', cb.name);
});
}
</script>
<script id="shared_worker_add_event_listener" type=module>
// addEventListener('message') on the page-side port does not auto-start
// it; an explicit start() is required. The worker script likewise uses
// addEventListener('connect') + port.start().
{
const state = await testing.async();
const worker = new SharedWorker('./listener-worker.js');
worker.port.addEventListener('message', (event) => {
state.resolve(event.data);
});
worker.port.start();
worker.port.postMessage('ping');
await state.done((response) => {
testing.expectEqual('listener:ping', response);
});
}
</script>
<script id="shared_worker_structured_clone" type=module>
// Messages cross a context boundary: the receiver gets an independent
// structured clone, and unserializable values throw a DataCloneError.
{
const state = await testing.async();
const worker = new SharedWorker('./echo-worker.js');
let threw = false;
try {
worker.port.postMessage(function () {});
} catch (e) {
threw = true;
testing.expectEqual('DataCloneError', e.name);
}
testing.expectEqual(true, threw);
const payload = { date: new Date('2024-06-15T12:30:00Z'), arr: [1, 2, 3] };
worker.port.onmessage = (event) => {
state.resolve(event.data);
};
worker.port.postMessage(payload);
payload.arr.push(4); // must not be visible to the worker
await state.done((response) => {
testing.expectTrue(response.echo.date instanceof Date);
testing.expectEqual(payload.date.getTime(), response.echo.date.getTime());
testing.expectEqual(3, response.echo.arr.length);
});
}
</script>

View File

@@ -78,7 +78,7 @@
async function async() {
const script_id = (IS_TEST_RUNNER) ? document.currentScript.id : 'cannot track module id in FF/Chrome';
if (async_seen.has(script_id)) {
if (async_seen.has(script_id) && IS_TEST_RUNNER) {
throw new Error(`testing.async() called more than once for script '${script_id}'. A script may only register one async block (the runner can declare success in the gap between two of them); split the test into separate <script> tags.`);
}
async_seen.add(script_id);

View File

@@ -68,3 +68,24 @@
location.hash = "";
testing.expectEqual("", location.hash);
</script>
<script id=location_hash_no_change>
// Per the Location hash setter, assigning a value that doesn't change the
// fragment must not navigate at all: re-assigning "" on a fragment-less URL
// must not schedule a same-URL reload, and re-assigning the current
// fragment must be a no-op too.
const stable_href = location.href;
location.hash = "";
location.hash = "";
testing.expectEqual(stable_href, location.href);
location.hash = "#same";
const with_fragment = location.href;
location.hash = "#same";
location.hash = "same";
testing.expectEqual(with_fragment, location.href);
testing.expectEqual("#same", location.hash);
location.hash = "";
testing.expectEqual(stable_href, location.href);
</script>

View File

@@ -34,3 +34,52 @@
testing.expectEqual(0, window.scrollX);
testing.expectEqual(0, window.scrollY);
</script>
<script id=element_scroll_events_coalesce_and_preserve_reentrancy type=module>
const state = await testing.async();
const element = document.createElement('div');
let scrolls = 0;
let scrollends = 0;
element.addEventListener('scroll', () => {
scrolls++;
if (scrolls === 1) {
element.scrollTop = 101;
}
});
element.addEventListener('scrollend', () => {
scrollends++;
if (scrollends === 1) {
element.scrollTop = 102;
}
});
for (let i = 1; i <= 100; i++) {
element.scrollTop = i;
}
await new Promise(resolve => setTimeout(resolve, 100));
state.resolve();
await state.done(() => {
testing.expectEqual(3, scrolls);
testing.expectEqual(2, scrollends);
});
</script>
<script id=element_scrollBy_noop_does_not_fire_events type=module>
const state = await testing.async();
const element = document.createElement('div');
let scrolls = 0;
let scrollends = 0;
element.addEventListener('scroll', () => scrolls++);
element.addEventListener('scrollend', () => scrollends++);
element.scrollBy(-1, -1);
await new Promise(resolve => setTimeout(resolve, 50));
state.resolve();
await state.done(() => {
testing.expectEqual(0, scrolls);
testing.expectEqual(0, scrollends);
});
</script>

View File

@@ -483,3 +483,26 @@
});
}
</script>
<script id="worker_from_blob_url_revoked_before_delivery" type=module>
// Revoking right after construction must not invalidate the queued
// script response: it's delivered on a later tick, after the blob's
// last reference may be gone.
{
const state = await testing.async();
const blob = new Blob(["onmessage = (e) => postMessage({ echo: e.data, from: 'revoked-blob-worker' });"], { type: 'text/javascript' });
const blobUrl = URL.createObjectURL(blob);
const worker = new Worker(blobUrl);
URL.revokeObjectURL(blobUrl);
worker.onmessage = function(event) {
state.resolve(event.data);
};
worker.postMessage({ greeting: 'still-works' });
await state.done((response) => {
testing.expectEqual('still-works', response.echo.greeting);
testing.expectEqual('revoked-blob-worker', response.from);
});
}
</script>

View File

@@ -37,8 +37,8 @@ pub fn getSignal(self: *const AbortController) *AbortSignal {
return self._signal;
}
pub fn abort(self: *AbortController, reason_: ?js.Value.Global, exec: *const Execution) !void {
try self._signal.abort(if (reason_) |r| .{ .js_val = r } else null, exec);
pub fn abort(self: *AbortController, reason_: ?js.Value, exec: *const Execution) !void {
try self._signal.abort(try AbortSignal.reasonFromJs(reason_), exec);
}
pub const JsApi = struct {

View File

@@ -35,14 +35,18 @@ const Dependend = union(enum) {
signal: *AbortSignal,
model_context_tool: *ModelContextTool,
fn markAborted(self: Dependend, reason_: ?Reason, exec: *const Execution) !void {
// Returns false if the dependent was already aborted, in which case no
// abort event must be dispatched for it.
fn markAborted(self: Dependend, reason_: ?Reason, exec: *const Execution) !bool {
switch (self) {
.signal => |dep| {
if (dep._aborted) return;
if (dep._aborted) return false;
try dep.markAborted(reason_, exec);
return true;
},
.model_context_tool => |dep| {
try dep.markAborted(exec);
return true;
},
}
}
@@ -101,8 +105,9 @@ pub fn abort(self: *AbortSignal, reason_: ?Reason, exec: *const Execution) !void
// so we never need to recurse here.
var to_dispatch: std.ArrayList(Dependend) = .{};
for (self._dependents.items) |dep| {
try dep.markAborted(self._reason, exec);
try to_dispatch.append(exec.arena, dep);
if (try dep.markAborted(self._reason, exec)) {
try to_dispatch.append(exec.call_arena, dep);
}
}
try self.dispatchAbortEvent(exec);
@@ -123,7 +128,11 @@ fn markAborted(self: *AbortSignal, reason_: ?Reason, exec: *const Execution) !vo
.undefined => self._reason = reason,
}
} else {
self._reason = .{ .dom = DOMException.fromError(error.AbortError).? };
// Allocate the DOMException so the reason keeps a single JS identity:
// dependent signals must expose the very same DOMException instance.
const dom = try exec.arena.create(DOMException);
dom.* = DOMException.fromError(error.AbortError).?;
self._reason = .{ .dom = dom };
}
}
@@ -140,14 +149,30 @@ fn dispatchAbortEvent(self: *AbortSignal, exec: *const Execution) !void {
}
}
// Converts an abort(reason) JS argument to a Reason. Per spec, only a
// missing or undefined reason falls back to the default "AbortError"
// DOMException: an explicit null (or any other value) is kept as-is.
pub fn reasonFromJs(reason_: ?js.Value) !?Reason {
const reason = reason_ orelse return null;
if (reason.isUndefined()) {
return null;
}
return .{ .js_val = try reason.persist() };
}
// Static method to create an already-aborted signal
pub fn createAborted(reason_: ?js.Value.Global, exec: *const Execution) !*AbortSignal {
pub fn createAborted(reason_: ?js.Value, exec: *const Execution) !*AbortSignal {
const signal = try init(exec);
try signal.abort(if (reason_) |r| .{ .js_val = r } else null, exec);
try signal.abort(try reasonFromJs(reason_), exec);
return signal;
}
pub fn createAny(signals: []const *AbortSignal, exec: *const Execution) !*AbortSignal {
pub fn createAny(signals_value: js.Value, exec: *const Execution) !*AbortSignal {
// The parameter isn't optional. If we declared it as a slice directy, the
// bridge would treat it as a variadic and map it to empty rather than throwing
// a TypeError as it should.
const signals = try signals_value.toZig([]const *AbortSignal);
const result = try init(exec);
for (signals) |source| {
if (source._aborted) {
@@ -207,7 +232,7 @@ pub fn throwIfAborted(self: *const AbortSignal, exec: *const Execution) !ThrowIf
const Reason = union(enum) {
js_val: js.Value.Global,
dom: DOMException,
dom: *DOMException,
string: []const u8,
undefined: void,
};
@@ -218,11 +243,33 @@ const TimeoutCallback = struct {
fn run(ctx: *anyopaque) !?u32 {
const self: *TimeoutCallback = @ptrCast(@alignCast(ctx));
self.signal.abort(.{ .dom = DOMException.fromError(error.TimeoutError).? }, self.exec) catch |err| {
self.timeoutAbort() catch |err| {
log.warn(.app, "abort signal timeout", .{ .err = err });
};
return null;
}
fn timeoutAbort(self: *TimeoutCallback) !void {
// Per spec, the abort is queued as a global task on the signal's
// global: it must not run when the global's document is no longer
// fully active (e.g. the iframe that created the signal was detached).
switch (self.exec.js.global) {
.frame => |frame| {
var current: ?@TypeOf(frame) = frame;
while (current) |f| : (current = f.parent) {
const iframe = f.iframe orelse continue;
if (!iframe.asNode().isConnected()) {
return;
}
}
},
.worker => {},
}
const dom = try self.exec.arena.create(DOMException);
dom.* = DOMException.fromError(error.TimeoutError).?;
try self.signal.abort(.{ .dom = dom }, self.exec);
}
};
pub const JsApi = struct {
@@ -237,7 +284,6 @@ pub const JsApi = struct {
pub const Prototype = EventTarget;
pub const constructor = bridge.constructor(AbortSignal.init, .{});
pub const aborted = bridge.accessor(AbortSignal.getAborted, null, .{});
pub const reason = bridge.accessor(AbortSignal.getReason, null, .{});
pub const onabort = bridge.accessor(AbortSignal.getOnAbort, AbortSignal.setOnAbort, .{});

View File

@@ -88,7 +88,7 @@ pub fn postMessage(self: *BroadcastChannel, message: js.Value, exec: *Execution)
const cloned = message.structuredCloneTo(&ls.local) catch {
return error.DataClone;
};
break :blk try cloned.temp();
break :blk try cloned.persist();
};
errdefer snapshot.release();
@@ -134,7 +134,7 @@ const PostMessageCallback = struct {
sender: *BroadcastChannel,
// A self-owned structured-clone snapshot of the posted message. Re-cloned
// (never shared) into each receiver's MessageEvent, then released.
message: js.Value.Temp,
message: js.Value.Global,
exec: *Execution,
post_sequence: u64,
@@ -221,7 +221,7 @@ const PostMessageCallback = struct {
continue;
};
const cloned_temp = cloned.temp() catch |err| {
const cloned_temp = cloned.persist() catch |err| {
log.err(.dom, "BroadcastChannel.postMessage", .{ .err = err });
continue;
};

View File

@@ -40,6 +40,13 @@ _data: String = .empty,
/// 4-byte UTF-8 sequences (codepoints >= U+10000) produce 2 UTF-16 code units (surrogate pair),
/// everything else produces 1.
pub fn utf16Len(data: []const u8) usize {
// All-ASCII data (the common case) is one code unit per byte.
for (data) |byte| {
if (byte >= 0x80) {
break;
}
} else return data.len;
var count: usize = 0;
var i: usize = 0;
while (i < data.len) {
@@ -96,6 +103,41 @@ pub fn utf16OffsetToUtf8(data: []const u8, utf16_offset: usize) error{IndexSizeE
return error.IndexSizeError;
}
/// Convert a UTF-16 code unit offset to a UTF-8 byte offset, rounding an
/// offset that lands inside a surrogate pair down to the code point's start
/// and clamping an offset past the end to data.len. Unlike the exact-or-error
/// variant above, this is total and monotonic, so converting a start/end pair
/// always yields ordered byte offsets.
pub fn utf16OffsetToUtf8Floor(data: []const u8, utf16_offset: usize) usize {
var utf16_pos: usize = 0;
var i: usize = 0;
while (i < data.len) {
if (utf16_pos == utf16_offset) return i;
const byte = data[i];
const seq_len = std.unicode.utf8ByteSequenceLength(byte) catch {
i += 1;
utf16_pos += 1;
continue;
};
if (i + seq_len > data.len) {
utf16_pos += 1;
i += 1;
continue;
}
if (seq_len == 4) {
if (utf16_offset == utf16_pos + 1) {
// Between the two code units of a surrogate pair.
return i;
}
utf16_pos += 2;
} else {
utf16_pos += 1;
}
i += seq_len;
}
return i;
}
/// Convert a UTF-16 code unit range to UTF-8 byte offsets in a single pass.
/// Returns IndexSizeError if utf16_start > utf16 length of data.
/// Clamps utf16_end to the actual string length if it exceeds it.
@@ -527,6 +569,31 @@ test "utf16OffsetToUtf8" {
try std.testing.expectError(error.IndexSizeError, utf16OffsetToUtf8("", 1));
}
test "utf16OffsetToUtf8Floor" {
// ASCII: identical to the exact variant, clamps past the end
try std.testing.expectEqual(@as(usize, 0), utf16OffsetToUtf8Floor("hello", 0));
try std.testing.expectEqual(@as(usize, 3), utf16OffsetToUtf8Floor("hello", 3));
try std.testing.expectEqual(@as(usize, 5), utf16OffsetToUtf8Floor("hello", 5));
try std.testing.expectEqual(@as(usize, 5), utf16OffsetToUtf8Floor("hello", 6)); // clamped
// Emoji "🌠AB" (4+1+1 = 6 bytes; 2+1+1 = 4 UTF-16 code units).
// Offset 1 lands inside the surrogate pair and rounds down to its start.
try std.testing.expectEqual(@as(usize, 0), utf16OffsetToUtf8Floor("🌠AB", 0));
try std.testing.expectEqual(@as(usize, 0), utf16OffsetToUtf8Floor("🌠AB", 1)); // mid-surrogate
try std.testing.expectEqual(@as(usize, 4), utf16OffsetToUtf8Floor("🌠AB", 2));
try std.testing.expectEqual(@as(usize, 5), utf16OffsetToUtf8Floor("🌠AB", 3));
try std.testing.expectEqual(@as(usize, 6), utf16OffsetToUtf8Floor("🌠AB", 4));
try std.testing.expectEqual(@as(usize, 6), utf16OffsetToUtf8Floor("🌠AB", 5)); // clamped
// Trailing surrogate pair: mid-surrogate at the very end still floors
try std.testing.expectEqual(@as(usize, 1), utf16OffsetToUtf8Floor("A🌠", 2)); // mid-surrogate
try std.testing.expectEqual(@as(usize, 5), utf16OffsetToUtf8Floor("A🌠", 3));
// Empty string
try std.testing.expectEqual(@as(usize, 0), utf16OffsetToUtf8Floor("", 0));
try std.testing.expectEqual(@as(usize, 0), utf16OffsetToUtf8Floor("", 1)); // clamped
}
test "utf16RangeToUtf8" {
// ASCII: basic range
{

View File

@@ -29,7 +29,30 @@ pub fn parseDimension(value: []const u8) ?f64 {
if (value.len == 0) {
return null;
}
return parseNonEmptyDimension(value);
}
// parseDimension plus viewport-relative units, which the faux layout
// resolves against the page viewport.
pub fn parseDimensionViewport(value: []const u8, frame: *Frame) ?f64 {
if (value.len == 0) {
return null;
}
if (std.mem.endsWith(u8, value, "vh")) {
const n = std.fmt.parseFloat(f64, value[0 .. value.len - 2]) catch return null;
return n * @as(f64, @floatFromInt(frame._page.getViewport().height)) / 100.0;
}
if (std.mem.endsWith(u8, value, "vw")) {
const n = std.fmt.parseFloat(f64, value[0 .. value.len - 2]) catch return null;
return n * @as(f64, @floatFromInt(frame._page.getViewport().width)) / 100.0;
}
return parseNonEmptyDimension(value);
}
fn parseNonEmptyDimension(value: []const u8) ?f64 {
var num_str = value;
if (std.mem.endsWith(u8, value, "px")) {
num_str = value[0 .. value.len - 2];

View File

@@ -16,16 +16,22 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const crypto = @import("../../sys/libcrypto.zig");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Execution = js.Execution;
const Allocator = std.mem.Allocator;
/// Represents a cryptographic key obtained from one of the SubtleCrypto methods
/// generateKey(), deriveKey(), importKey(), or unwrapKey().
const CryptoKey = @This();
_rc: lp.RC(u8) = .{},
_arena: Allocator = undefined,
/// Algorithm being used.
_type: Type,
/// Whether this is a secret (symmetric), public, or private key. Surfaced as
@@ -37,8 +43,8 @@ _extractable: bool,
_usages: u8,
/// Raw bytes of key.
_key: []const u8,
/// Metadata needed to reconstruct the JS `.algorithm` dictionary. The strings
/// are expected to outlive the key (arena-allocated alongside it).
/// Metadata needed to reconstruct the JS `.algorithm` dictionary. `hash` is
/// duped into the key's arena by init; `name`/`named_curve` are static.
_algorithm: Algorithm,
/// Different algorithms may use different data structures;
/// this union can be used for such situations. Active field is understood
@@ -94,6 +100,39 @@ pub const Usages = struct {
// zig fmt: on
};
/// Copies `key` into an owned pooled arena, duping the caller-provided
/// slices (`_key`, `_algorithm.hash`). `_algorithm.name` and `_named_curve`
/// are expected to be static strings. Takes ownership of `_vary.pkey`.
pub fn init(exec: *const Execution, key: CryptoKey) !*CryptoKey {
const arena = try exec.getArena(.tiny, "CryptoKey");
errdefer exec.releaseArena(arena);
const self = try arena.create(CryptoKey);
self.* = key;
self._arena = arena;
self._key = try arena.dupe(u8, key._key);
if (key._algorithm.hash) |hash| {
self._algorithm.hash = try arena.dupe(u8, hash);
}
return self;
}
pub fn deinit(self: *CryptoKey, page: *Page) void {
switch (self._vary) {
.pkey => |pkey| crypto.EVP_PKEY_free(pkey),
.none, .digest => {},
}
page.releaseArena(self._arena);
}
pub fn releaseRef(self: *CryptoKey, page: *Page) void {
self._rc.release(self, page);
}
pub fn acquireRef(self: *CryptoKey) void {
self._rc.acquire();
}
pub fn canEncrypt(self: *const CryptoKey) bool {
return self._usages & Usages.encrypt != 0;
}

View File

@@ -16,6 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const js = @import("../js/js.zig");
const Frame = @import("../Frame.zig");
const Node = @import("Node.zig");
@@ -23,16 +24,33 @@ const Document = @import("Document.zig");
const DocumentType = @import("DocumentType.zig");
const DOMImplementation = @This();
_pad: bool = false,
pub fn createDocumentType(_: *const DOMImplementation, qualified_name: []const u8, public_id: ?[]const u8, system_id: ?[]const u8, frame: *Frame) !*DocumentType {
return DocumentType.init(qualified_name, public_id, system_id, frame);
// The document this implementation object belongs to: nodes created through
// it are owned by that document, not necessarily the frame's main document.
_document: *Document,
pub fn createDocumentType(self: *const DOMImplementation, qualified_name: []const u8, public_id: ?[]const u8, system_id: ?[]const u8, frame: *Frame) !*DocumentType {
// Per spec, qualifiedName must match the doctype name production: any
// characters except ASCII whitespace or '>'.
for (qualified_name) |c| {
switch (c) {
'\t', '\n', 0x0C, '\r', ' ', '>' => return error.InvalidCharacterError,
else => {},
}
}
const doctype = try DocumentType.init(qualified_name, public_id, system_id, frame);
if (self._document != frame.document) {
try frame.setNodeOwnerDocument(doctype.asNode(), self._document);
}
return doctype;
}
pub fn createHTMLDocument(_: *const DOMImplementation, title: ?js.NullableString, frame: *Frame) !*Document {
const document = (try frame._factory.document(Node.Document.HTMLDocument{ ._proto = undefined })).asDocument();
document._ready_state = .complete;
document._url = "about:blank";
document._charset = "UTF-8";
{
const doctype = try frame._factory.node(DocumentType{
@@ -63,10 +81,35 @@ pub fn createHTMLDocument(_: *const DOMImplementation, title: ?js.NullableString
return document;
}
pub fn createDocument(_: *const DOMImplementation, namespace_: ?[]const u8, qualified_name: ?[]const u8, doctype: ?*DocumentType, frame: *Frame) !*Document {
pub fn createDocument(_: *const DOMImplementation, namespace_nullable: js.Nullable([]const u8), qualified_name_: js.Value, doctype: ?*DocumentType, frame: *Frame) !*Document {
// Both namespace (nullable) and qualifiedName are required arguments.
const namespace_ = namespace_nullable.value;
// Per Web IDL, qualifiedName is [LegacyNullToEmptyString]: null becomes
// the empty string, while undefined stringifies to "undefined". The raw
// js.Value keeps that distinction.
const qname: []const u8 = blk: {
if (qualified_name_.isNull()) {
break :blk "";
}
break :blk try qualified_name_.toStringSlice();
};
if (qname.len > 0) {
_ = try Document.validateAndExtract(namespace_, qname, .element);
}
// Create XML Document
const document = (try frame._factory.document(Node.Document.XMLDocument{ ._proto = undefined })).asDocument();
document._url = "about:blank";
document._charset = "UTF-8";
// Per spec the content type depends on the requested namespace.
document._content_type = blk: {
const ns = namespace_ orelse break :blk "application/xml";
if (std.mem.eql(u8, ns, "http://www.w3.org/1999/xhtml")) break :blk "application/xhtml+xml";
if (std.mem.eql(u8, ns, "http://www.w3.org/2000/svg")) break :blk "image/svg+xml";
break :blk "application/xml";
};
// Append doctype if provided
if (doctype) |dt| {
@@ -74,12 +117,20 @@ pub fn createDocument(_: *const DOMImplementation, namespace_: ?[]const u8, qual
}
// Create and append root element if qualified_name provided
if (qualified_name) |qname| {
if (qname.len > 0) {
const namespace = Node.Element.Namespace.parse(namespace_);
const root = try Frame.node_factory.createElementNS(frame, namespace, qname, null);
_ = try document.asNode().appendChild(root, frame);
if (qname.len > 0) {
const namespace = Node.Element.Namespace.parse(namespace_);
const root = try Frame.node_factory.createElementNS(frame, namespace, qname, null);
// Store the original URI for unknown namespaces so namespaceURI and
// lookupNamespaceURI can return it (mirrors Document.createElementNS).
if (namespace == .unknown) {
if (namespace_) |uri| {
const duped = try frame.dupeString(uri);
try frame._element_namespace_uris.put(frame.arena, root.as(Node.Element), duped);
}
}
_ = try document.asNode().appendChild(root, frame);
}
return document;
@@ -98,7 +149,6 @@ pub const JsApi = struct {
pub const name = "DOMImplementation";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const empty_with_no_proto = true;
};
pub const createDocumentType = bridge.function(DOMImplementation.createDocumentType, .{});

View File

@@ -16,7 +16,11 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Frame = @import("../Frame.zig");
const Node = @import("Node.zig");
@@ -25,28 +29,87 @@ pub const FilterOpts = NodeFilter.FilterOpts;
const DOMNodeIterator = @This();
_rc: lp.RC(u8) = .{},
_root: *Node,
_what_to_show: u32,
_filter: NodeFilter,
_reference_node: *Node,
_pointer_before_reference_node: bool,
_active: bool = false,
_frame_loader_id: u32,
_iterator_link: std.DoublyLinkedList.Node = .{},
pub fn init(root: *Node, what_to_show: u32, filter: ?FilterOpts, frame: *Frame) !*DOMNodeIterator {
const node_filter = try NodeFilter.init(filter);
return frame._factory.create(DOMNodeIterator{
const iterator = try frame._factory.create(DOMNodeIterator{
._root = root,
._filter = node_filter,
._reference_node = root,
._what_to_show = what_to_show,
._frame_loader_id = frame._loader_id,
._pointer_before_reference_node = true,
});
frame._live_node_iterators.append(&iterator._iterator_link);
return iterator;
}
pub fn deinit(self: *DOMNodeIterator, page: *Page) void {
if (page.findFrameByLoaderId(self._frame_loader_id)) |frame| {
frame._live_node_iterators.remove(&self._iterator_link);
}
self._filter.deinit();
page.factory.destroy(self);
}
pub fn releaseRef(self: *DOMNodeIterator, page: *Page) void {
self._rc.release(self, page);
}
pub fn acquireRef(self: *DOMNodeIterator) void {
self._rc.acquire();
}
pub fn getRoot(self: *const DOMNodeIterator) *Node {
return self._root;
}
// DOM "node iterator pre-removing steps", run while the tree still contains
// to_be_removed.
pub fn nodeWillBeRemoved(self: *DOMNodeIterator, to_be_removed: *Node) void {
if (to_be_removed.contains(self._root)) {
// Removing the root or one of its ancestors leaves the iterator alone.
return;
}
if (to_be_removed != self._reference_node and to_be_removed.contains(self._reference_node) == false) {
return;
}
if (self._pointer_before_reference_node) {
// The first node following to_be_removed's subtree, if any.
var node = to_be_removed;
while (node != self._root) {
if (node.nextSibling()) |sibling| {
self._reference_node = sibling;
return;
}
node = node.parentNode() orelse break;
}
self._pointer_before_reference_node = false;
}
// The node immediately preceding to_be_removed in tree order: the
// previous sibling's last inclusive descendant, or the parent.
if (to_be_removed.previousSibling()) |prev| {
var node = prev;
while (node.lastChild()) |child| {
node = child;
}
self._reference_node = node;
} else {
self._reference_node = to_be_removed.parentNode() orelse self._root;
}
}
pub fn getReferenceNode(self: *const DOMNodeIterator) *Node {
return self._reference_node;
}
@@ -60,7 +123,7 @@ pub fn getWhatToShow(self: *const DOMNodeIterator) u32 {
}
pub fn getFilter(self: *const DOMNodeIterator) ?FilterOpts {
return self._filter._original_filter;
return self._filter._opts;
}
pub fn nextNode(self: *DOMNodeIterator, frame: *Frame) !?*Node {

Some files were not shown because too many files have changed in this diff Show More