diff --git a/.gitignore b/.gitignore
index 4c5a0f9f..16c98921 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@ zig-out
 /vendor/netsurf/out
 /vendor/libiconv/
 lightpanda.id
+v8/
diff --git a/.gitmodules b/.gitmodules
index 9ee19adb..f025f0bd 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,7 +1,3 @@
-[submodule "vendor/zig-js-runtime"]
-	path = vendor/zig-js-runtime
-	url = https://github.com/lightpanda-io/zig-js-runtime.git/
-	branch = zig-0.14
 [submodule "vendor/netsurf/libwapcaplet"]
 	path = vendor/netsurf/libwapcaplet
 	url = https://github.com/lightpanda-io/libwapcaplet.git/
diff --git a/Makefile b/Makefile
index e9bc5716..1c4b3636 100644
--- a/Makefile
+++ b/Makefile
@@ -92,26 +92,21 @@ wpt-summary:
 
 ## Test
 test:
-	@printf "\e[36mTesting...\e[0m\n"
-	@$(ZIG) build test -Dengine=v8 || (printf "\e[33mTest ERROR\e[0m\n"; exit 1;)
-	@printf "\e[33mTest OK\e[0m\n"
-
-unittest:
-	@TEST_FILTER='${F}' $(ZIG) build unittest -freference-trace --summary all
+	@TEST_FILTER='${F}' $(ZIG) build test -freference-trace --summary all
 
 # Install and build required dependencies commands
 # ------------
 .PHONY: install-submodule
-.PHONY: install-zig-js-runtime install-zig-js-runtime-dev install-libiconv
+.PHONY: install-libiconv
 .PHONY: _install-netsurf install-netsurf clean-netsurf test-netsurf install-netsurf-dev
 .PHONY: install-mimalloc install-mimalloc-dev clean-mimalloc
 .PHONY: install-dev install
 
 ## Install and build dependencies for release
-install: install-submodule install-zig-js-runtime install-libiconv install-netsurf install-mimalloc
+install: install-submodule install-libiconv install-netsurf install-mimalloc
 
 ## Install and build dependencies for dev
-install-dev: install-submodule install-zig-js-runtime-dev install-libiconv install-netsurf-dev install-mimalloc-dev
+install-dev: install-submodule install-libiconv install-netsurf-dev install-mimalloc-dev
 
 install-netsurf-dev: _install-netsurf
 install-netsurf-dev: OPTCFLAGS := -O0 -g -DNDEBUG
@@ -194,14 +189,6 @@ ifneq ("$(wildcard vendor/libiconv/libiconv-1.17/Makefile)","")
 	make clean
 endif
 
-install-zig-js-runtime-dev:
-	@cd vendor/zig-js-runtime && \
-	make install-dev
-
-install-zig-js-runtime:
-	@cd vendor/zig-js-runtime && \
-	make install
-
 data:
 	cd src/data && go run public_suffix_list_gen.go > public_suffix_list.zig
 
diff --git a/README.md b/README.md
index 25116970..f44fbaf3 100644
--- a/README.md
+++ b/README.md
@@ -216,17 +216,19 @@ Note: when Mimalloc is built in dev mode, you can dump memory stats with the
 env var `MIMALLOC_SHOW_STATS=1`. See
 [https://microsoft.github.io/mimalloc/environment.html](https://microsoft.github.io/mimalloc/environment.html).
 
-**zig-js-runtime**
+**v8**
 
-Our own Zig/Javascript runtime, which includes the v8 Javascript engine.
-
-This build task is very long and cpu consuming, as you will build v8 from sources.
+First, get the tools necessary for building V8, as well as the V8 source code:
 
 ```
-make install-zig-js-runtime
+zig build get-v8
 ```
 
-For dev env, use `make install-zig-js-runtime-dev`.
+Next, build v8. This build task is very long and cpu consuming, as you will build v8 from sources. 
+
+```
+zig build build-v8
+```
 
 ## Test
 
diff --git a/build.zig b/build.zig
index c26988c6..d17b3138 100644
--- a/build.zig
+++ b/build.zig
@@ -17,16 +17,11 @@
 // along with this program.  If not, see .
 
 const std = @import("std");
-
 const builtin = @import("builtin");
 
-const jsruntime_path = "vendor/zig-js-runtime/";
-const jsruntime = @import("vendor/zig-js-runtime/build.zig");
-const jsruntime_pkgs = jsruntime.packages(jsruntime_path);
-
 /// Do not rename this constant. It is scanned by some scripts to determine
 /// which zig version to install.
-const recommended_zig_version = jsruntime.recommended_zig_version;
+const recommended_zig_version = "0.14.0";
 
 pub fn build(b: *std.Build) !void {
     switch (comptime builtin.zig_version.order(std.SemanticVersion.parse(recommended_zig_version) catch unreachable)) {
@@ -42,193 +37,190 @@ pub fn build(b: *std.Build) !void {
         },
     }
 
+    var opts = b.addOptions();
+    opts.addOption(
+        []const u8,
+        "git_commit",
+        b.option([]const u8, "git_commit", "Current git commit") orelse "dev",
+    );
+
     const target = b.standardTargetOptions(.{});
-    const mode = b.standardOptimizeOption(.{});
+    const optimize = b.standardOptimizeOption(.{});
 
-    const options = jsruntime.buildOptions(b);
-
-    // browser
-    // -------
-
-    // compile and install
-    const exe = b.addExecutable(.{
-        .name = "lightpanda",
-        .root_source_file = b.path("src/main.zig"),
-        .target = target,
-        .optimize = mode,
-    });
-    try common(b, exe, options);
     {
-        var opt = b.addOptions();
-        opt.addOption(
-            []const u8,
-            "git_commit",
-            b.option([]const u8, "git_commit", "Current git commit") orelse "dev",
-        );
-        exe.root_module.addImport("build_info", opt.createModule());
-    }
-    b.installArtifact(exe);
+        // browser
+        // -------
 
-    // run
-    const run_cmd = b.addRunArtifact(exe);
-    if (b.args) |args| {
-        run_cmd.addArgs(args);
+        // compile and install
+        const exe = b.addExecutable(.{
+            .name = "lightpanda",
+            .target = target,
+            .optimize = optimize,
+            .root_source_file = b.path("src/main.zig"),
+        });
+
+        try common(b, opts, exe);
+        b.installArtifact(exe);
+
+        // run
+        const run_cmd = b.addRunArtifact(exe);
+        if (b.args) |args| {
+            run_cmd.addArgs(args);
+        }
+
+        // step
+        const run_step = b.step("run", "Run the app");
+        run_step.dependOn(&run_cmd.step);
     }
 
-    // step
-    const run_step = b.step("run", "Run the app");
-    run_step.dependOn(&run_cmd.step);
-
-    // shell
-    // -----
-
-    // compile and install
-    const shell = b.addExecutable(.{
-        .name = "lightpanda-shell",
-        .root_source_file = b.path("src/main_shell.zig"),
-        .target = target,
-        .optimize = mode,
-    });
-    try common(b, shell, options);
-    try jsruntime_pkgs.add_shell(shell);
-
-    // run
-    const shell_cmd = b.addRunArtifact(shell);
-    if (b.args) |args| {
-        shell_cmd.addArgs(args);
+    {
+        // get v8
+        // -------
+        const v8 = b.dependency("v8", .{.target = target, .optimize = optimize});
+        const get_v8 = b.addRunArtifact(v8.artifact("get-v8"));
+        const get_step = b.step("get-v8", "Get v8");
+        get_step.dependOn(&get_v8.step);
     }
 
-    // step
-    const shell_step = b.step("shell", "Run JS shell");
-    shell_step.dependOn(&shell_cmd.step);
-
-    // test
-    // ----
-
-    // compile
-    const tests = b.addTest(.{
-        .root_source_file = b.path("src/main_tests.zig"),
-        .test_runner = .{ .path = b.path("src/main_tests.zig"), .mode = .simple },
-        .target = target,
-        .optimize = mode,
-    });
-    try common(b, tests, options);
-
-    // add jsruntime pretty deps
-    tests.root_module.addAnonymousImport("pretty", .{
-        .root_source_file = b.path("vendor/zig-js-runtime/src/pretty.zig"),
-    });
-
-    const run_tests = b.addRunArtifact(tests);
-    if (b.args) |args| {
-        run_tests.addArgs(args);
+    {
+        // build v8
+        // -------
+        const v8 = b.dependency("v8", .{.target = target, .optimize = optimize});
+        const build_v8 = b.addRunArtifact(v8.artifact("build-v8"));
+        const build_step = b.step("build-v8", "Build v8");
+        build_step.dependOn(&build_v8.step);
     }
 
-    // step
-    const test_step = b.step("test", "Run unit tests");
-    test_step.dependOn(&run_tests.step);
+    {
+        // tests
+        // ----
 
-    // unittest
-    // ----
+        // compile
+        const tests = b.addTest(.{
+            .root_source_file = b.path("src/main.zig"),
+            .test_runner = .{ .path = b.path("src/test_runner.zig"), .mode = .simple },
+            .target = target,
+            .optimize = optimize,
+        });
+        try common(b, opts, tests);
 
-    // compile
-    const unit_tests = b.addTest(.{
-        .root_source_file = b.path("src/main_unit_tests.zig"),
-        .test_runner = .{ .path = b.path("src/test_runner.zig"), .mode = .simple },
-        .target = target,
-        .optimize = mode,
-    });
-    try common(b, unit_tests, options);
+        const run_tests = b.addRunArtifact(tests);
+        if (b.args) |args| {
+            run_tests.addArgs(args);
+        }
 
-    const run_unit_tests = b.addRunArtifact(unit_tests);
-    if (b.args) |args| {
-        run_unit_tests.addArgs(args);
+        // step
+        const tests_step = b.step("test", "Run unit tests");
+        tests_step.dependOn(&run_tests.step);
     }
 
-    // step
-    const unit_test_step = b.step("unittest", "Run unit tests");
-    unit_test_step.dependOn(&run_unit_tests.step);
+    {
+        // wpt
+        // -----
 
-    // wpt
-    // -----
+        // compile and install
+        const wpt = b.addExecutable(.{
+            .name = "lightpanda-wpt",
+            .root_source_file = b.path("src/main_wpt.zig"),
+            .target = target,
+            .optimize = optimize,
+        });
+        try common(b, opts, wpt);
 
-    // compile and install
-    const wpt = b.addExecutable(.{
-        .name = "lightpanda-wpt",
-        .root_source_file = b.path("src/main_wpt.zig"),
-        .target = target,
-        .optimize = mode,
-    });
-    try common(b, wpt, options);
-
-    // run
-    const wpt_cmd = b.addRunArtifact(wpt);
-    if (b.args) |args| {
-        wpt_cmd.addArgs(args);
+        // run
+        const wpt_cmd = b.addRunArtifact(wpt);
+        if (b.args) |args| {
+            wpt_cmd.addArgs(args);
+        }
+        // step
+        const wpt_step = b.step("wpt", "WPT tests");
+        wpt_step.dependOn(&wpt_cmd.step);
     }
-    // step
-    const wpt_step = b.step("wpt", "WPT tests");
-    wpt_step.dependOn(&wpt_cmd.step);
 }
 
-fn common(
-    b: *std.Build,
-    step: *std.Build.Step.Compile,
-    options: jsruntime.Options,
-) !void {
-    const target = step.root_module.resolved_target.?;
-    const optimize = step.root_module.optimize.?;
+fn common(b: *std.Build, opts: *std.Build.Step.Options, step: *std.Build.Step.Compile) !void {
+    const mod = step.root_module;
+    const target = mod.resolved_target.?;
+    const optimize = mod.optimize.?;
     const dep_opts = .{ .target = target, .optimize = optimize };
 
-    const jsruntimemod = try jsruntime_pkgs.module(
-        b,
-        options,
-        step.root_module.optimize.?,
-        target,
+    try moduleNetSurf(b, step, target);
+    mod.addImport("tls", b.dependency("tls", dep_opts).module("tls"));
+    mod.addImport("tigerbeetle-io", b.dependency("tigerbeetle_io", .{}).module("tigerbeetle_io"));
+
+    {
+        // v8
+        const v8_opts = b.addOptions();
+        v8_opts.addOption(bool, "inspector_subtype", false);
+
+        const v8_mod = b.dependency("v8", dep_opts).module("v8");
+        v8_mod.addOptions("default_exports", v8_opts);
+        mod.addImport("v8", v8_mod);
+    }
+
+    const mode_str: []const u8 = if (mod.optimize.? == .Debug) "debug" else "release";
+
+    // FIXME: we are tied to native v8 builds, currently:
+    // - aarch64-macos
+    // - x86_64-linux
+    const os = target.result.os.tag;
+    const arch = target.result.cpu.arch;
+    switch (os) {
+        .macos => {},
+        .linux => {
+            // TODO: why do we need it? It should be linked already when we built v8
+            mod.link_libcpp = true;
+        },
+        else => return error.OsNotSupported,
+    }
+
+    const lib_path = try std.fmt.allocPrint(
+        mod.owner.allocator,
+        "v8/build/{s}-{s}/{s}/ninja/obj/zig/libc_v8.a",
+        .{ @tagName(arch), @tagName(os), mode_str },
     );
-    step.root_module.addImport("jsruntime", jsruntimemod);
-
-    const netsurf = try moduleNetSurf(b, target);
-    netsurf.addImport("jsruntime", jsruntimemod);
-    step.root_module.addImport("netsurf", netsurf);
-
-    step.root_module.addImport("tls", b.dependency("tls", dep_opts).module("tls"));
+    mod.addObjectFile(mod.owner.path(lib_path));
+    mod.addImport("build_info", opts.createModule());
 }
 
-fn moduleNetSurf(b: *std.Build, target: std.Build.ResolvedTarget) !*std.Build.Module {
-    const mod = b.addModule("netsurf", .{
-        .root_source_file = b.path("src/netsurf/netsurf.zig"),
-        .target = target,
-    });
-
+fn moduleNetSurf(b: *std.Build, step: *std.Build.Step.Compile, target: std.Build.ResolvedTarget) !void {
     const os = target.result.os.tag;
     const arch = target.result.cpu.arch;
 
     // iconv
     const libiconv_lib_path = try std.fmt.allocPrint(
-        mod.owner.allocator,
+        b.allocator,
         "vendor/libiconv/out/{s}-{s}/lib/libiconv.a",
         .{ @tagName(os), @tagName(arch) },
     );
     const libiconv_include_path = try std.fmt.allocPrint(
-        mod.owner.allocator,
+        b.allocator,
         "vendor/libiconv/out/{s}-{s}/lib/libiconv.a",
         .{ @tagName(os), @tagName(arch) },
     );
-    mod.addObjectFile(b.path(libiconv_lib_path));
-    mod.addIncludePath(b.path(libiconv_include_path));
+    step.addObjectFile(b.path(libiconv_lib_path));
+    step.addIncludePath(b.path(libiconv_include_path));
 
-    // mimalloc
-    mod.addImport("mimalloc", (try moduleMimalloc(b, target)));
+    {
+        // mimalloc
+        const mimalloc = "vendor/mimalloc";
+        const lib_path = try std.fmt.allocPrint(
+            b.allocator,
+            mimalloc ++ "/out/{s}-{s}/lib/libmimalloc.a",
+            .{ @tagName(os), @tagName(arch) },
+        );
+        step.addObjectFile(b.path(lib_path));
+        step.addIncludePath(b.path(mimalloc ++ "/include"));
+    }
 
     // netsurf libs
     const ns = "vendor/netsurf";
     const ns_include_path = try std.fmt.allocPrint(
-        mod.owner.allocator,
+        b.allocator,
         ns ++ "/out/{s}-{s}/include",
         .{ @tagName(os), @tagName(arch) },
     );
-    mod.addIncludePath(b.path(ns_include_path));
+    step.addIncludePath(b.path(ns_include_path));
 
     const libs: [4][]const u8 = .{
         "libdom",
@@ -238,34 +230,11 @@ fn moduleNetSurf(b: *std.Build, target: std.Build.ResolvedTarget) !*std.Build.Mo
     };
     inline for (libs) |lib| {
         const ns_lib_path = try std.fmt.allocPrint(
-            mod.owner.allocator,
+            b.allocator,
             ns ++ "/out/{s}-{s}/lib/" ++ lib ++ ".a",
             .{ @tagName(os), @tagName(arch) },
         );
-        mod.addObjectFile(b.path(ns_lib_path));
-        mod.addIncludePath(b.path(ns ++ "/" ++ lib ++ "/src"));
+        step.addObjectFile(b.path(ns_lib_path));
+        step.addIncludePath(b.path(ns ++ "/" ++ lib ++ "/src"));
     }
-
-    return mod;
-}
-
-fn moduleMimalloc(b: *std.Build, target: std.Build.ResolvedTarget) !*std.Build.Module {
-    const mod = b.addModule("mimalloc", .{
-        .root_source_file = b.path("src/mimalloc/mimalloc.zig"),
-        .target = target,
-    });
-
-    const os = target.result.os.tag;
-    const arch = target.result.cpu.arch;
-
-    const mimalloc = "vendor/mimalloc";
-    const lib_path = try std.fmt.allocPrint(
-        mod.owner.allocator,
-        mimalloc ++ "/out/{s}-{s}/lib/libmimalloc.a",
-        .{ @tagName(os), @tagName(arch) },
-    );
-    mod.addObjectFile(b.path(lib_path));
-    mod.addIncludePath(b.path(mimalloc ++ "/include"));
-
-    return mod;
 }
diff --git a/build.zig.zon b/build.zig.zon
index 7a3981e4..029fc3fb 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -6,7 +6,17 @@
     .dependencies = .{
         .tls = .{
             .url = "https://github.com/ianic/tls.zig/archive/b29a8b45fc59fc2d202769c4f54509bb9e17d0a2.tar.gz",
-            .hash = "1220e6fd39920dd6e28b2bc06688787a39430f8856f0597cd77c44ca868c6c54fb86",
+            .hash = "tls-0.1.0-ER2e0uAxBQDm_TmSDdbiiyvAZoh4ejlDD4hW8Fl813xE",
         },
+        .tigerbeetle_io = .{
+            .url = "https://github.com/lightpanda-io/tigerbeetle-io/archive/61d9652f1a957b7f4db723ea6aa0ce9635e840ce.tar.gz",
+            .hash = "tigerbeetle_io-0.0.0-ViLgxpyRBAB5BMfIcj3KMXfbJzwARs9uSl8aRy2OXULd",
+        },
+        .v8 = .{
+            .url = "https://github.com/karlseguin/zig-v8-fork/archive/508f25e9e4f78763a30590370aeb14763ce72998.tar.gz",
+            .hash = "v8-0.0.0-xddH6wPYIADmtOi_cczeBDRPvnYuSr3wtrRkeNrDRXYI",
+        },
+        //.v8 = .{ .path = "../zig-v8-fork" },
+        //.tigerbeetle_io = .{ .path = "../tigerbeetle-io" },
     },
 }
diff --git a/src/apiweb.zig b/src/apiweb.zig
deleted file mode 100644
index 8df1bd6d..00000000
--- a/src/apiweb.zig
+++ /dev/null
@@ -1,47 +0,0 @@
-// Copyright (C) 2023-2024  Lightpanda (Selecy SAS)
-//
-// Francis Bouvier 
-// Pierre Tachoire 
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as
-// published by the Free Software Foundation, either version 3 of the
-// License, or (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program.  If not, see .
-
-const generate = @import("generate.zig");
-
-const Console = @import("jsruntime").Console;
-
-const DOM = @import("dom/dom.zig");
-const HTML = @import("html/html.zig");
-const Events = @import("events/event.zig");
-const XHR = @import("xhr/xhr.zig");
-const Storage = @import("storage/storage.zig");
-const URL = @import("url/url.zig");
-const Iterators = @import("iterator/iterator.zig");
-const XMLSerializer = @import("xmlserializer/xmlserializer.zig");
-
-pub const HTMLDocument = @import("html/document.zig").HTMLDocument;
-
-// Interfaces
-pub const Interfaces = generate.Tuple(.{
-    Console,
-    DOM.Interfaces,
-    Events.Interfaces,
-    HTML.Interfaces,
-    XHR.Interfaces,
-    Storage.Interfaces,
-    URL.Interfaces,
-    Iterators.Interfaces,
-    XMLSerializer.Interfaces,
-}){};
-
-pub const UserContext = @import("user_context.zig").UserContext;
diff --git a/src/app.zig b/src/app.zig
index 5c90b546..6bb883f9 100644
--- a/src/app.zig
+++ b/src/app.zig
@@ -1,7 +1,8 @@
 const std = @import("std");
-
-const Loop = @import("jsruntime").Loop;
 const Allocator = std.mem.Allocator;
+
+const js = @import("runtime/js.zig");
+const Loop = @import("runtime/loop.zig").Loop;
 const HttpClient = @import("http/client.zig").Client;
 const Telemetry = @import("telemetry/telemetry.zig").Telemetry;
 
@@ -11,6 +12,7 @@ const log = std.log.scoped(.app);
 // might need.
 pub const App = struct {
     loop: *Loop,
+    config: Config,
     allocator: Allocator,
     telemetry: Telemetry,
     http_client: HttpClient,
@@ -24,8 +26,9 @@ pub const App = struct {
     };
 
     pub const Config = struct {
-        tls_verify_host: bool = true,
         run_mode: RunMode,
+        gc_hints: bool = false,
+        tls_verify_host: bool = true,
     };
 
     pub fn init(allocator: Allocator, config: Config) !*App {
@@ -48,6 +51,7 @@ pub const App = struct {
             .http_client = try HttpClient.init(allocator, 5, .{
                 .tls_verify_host = config.tls_verify_host,
             }),
+            .config = config,
         };
         app.telemetry = Telemetry.init(app, config.run_mode);
 
diff --git a/src/browser/browser.zig b/src/browser/browser.zig
index 8181e350..679fdc52 100644
--- a/src/browser/browser.zig
+++ b/src/browser/browser.zig
@@ -21,31 +21,26 @@ const builtin = @import("builtin");
 
 const Allocator = std.mem.Allocator;
 
-const Types = @import("root").Types;
-
-const parser = @import("netsurf");
 const Dump = @import("dump.zig");
 const Mime = @import("mime.zig").Mime;
+const parser = @import("netsurf.zig");
 
-const jsruntime = @import("jsruntime");
-const Loop = jsruntime.Loop;
-const Env = jsruntime.Env;
-const Module = jsruntime.Module;
+const Window = @import("html/window.zig").Window;
+const Walker = @import("dom/walker.zig").WalkerDepthFirst;
 
+const Env = @import("env.zig").Env;
 const App = @import("../app.zig").App;
-const apiweb = @import("../apiweb.zig");
-
-const Window = @import("../html/window.zig").Window;
-const Walker = @import("../dom/walker.zig").WalkerDepthFirst;
 
 const URL = @import("../url.zig").URL;
-const storage = @import("../storage/storage.zig");
-const Notification = @import("../notification.zig").Notification;
 
 const http = @import("../http/client.zig");
-const UserContext = @import("../user_context.zig").UserContext;
+const storage = @import("storage/storage.zig");
+const Loop = @import("../runtime/loop.zig").Loop;
+const SessionState = @import("env.zig").SessionState;
+const HttpClient = @import("../http/client.zig").Client;
+const Notification = @import("../notification.zig").Notification;
 
-const polyfill = @import("../polyfill/polyfill.zig");
+const polyfill = @import("polyfill/polyfill.zig");
 
 const log = std.log.scoped(.browser);
 
@@ -56,6 +51,7 @@ pub const user_agent = "Lightpanda/1.0";
 // A browser contains only one session.
 // TODO allow multiple sessions per browser.
 pub const Browser = struct {
+    env: *Env,
     app: *App,
     session: ?*Session,
     allocator: Allocator,
@@ -65,10 +61,17 @@ pub const Browser = struct {
 
     const SessionPool = std.heap.MemoryPool(Session);
 
-    pub fn init(app: *App) Browser {
+    pub fn init(app: *App) !Browser {
         const allocator = app.allocator;
+
+        const env = try Env.init(allocator, .{
+            .gc_hints = app.config.gc_hints,
+        });
+        errdefer env.deinit();
+
         return .{
             .app = app,
+            .env = env,
             .session = null,
             .allocator = allocator,
             .http_client = &app.http_client,
@@ -79,6 +82,7 @@ pub const Browser = struct {
 
     pub fn deinit(self: *Browser) void {
         self.closeSession();
+        self.env.deinit();
         self.session_pool.deinit();
         self.page_arena.deinit();
     }
@@ -101,10 +105,7 @@ pub const Browser = struct {
     }
 
     pub fn runMicrotasks(self: *const Browser) void {
-        // if no session exists, there is nothing to do.
-        if (self.session == null) return;
-
-        return self.session.?.env.runMicrotasks();
+        return self.env.runMicrotasks();
     }
 };
 
@@ -113,8 +114,11 @@ pub const Browser = struct {
 // You can create successively multiple pages for a session, but you must
 // deinit a page before running another one.
 pub const Session = struct {
-    app: *App,
+    state: SessionState,
+    executor: *Env.Executor,
+    inspector: Env.Inspector,
 
+    app: *App,
     browser: *Browser,
 
     // The arena is used only to bound the js env init b/c it leaks memory.
@@ -124,9 +128,6 @@ pub const Session = struct {
     // all others Session deps use directly self.alloc and not the arena.
     arena: std.heap.ArenaAllocator,
 
-    env: Env,
-    inspector: jsruntime.Inspector,
-
     window: Window,
 
     // TODO move the shed/jar to the browser?
@@ -136,8 +137,6 @@ pub const Session = struct {
     page: ?Page = null,
     http_client: *http.Client,
 
-    jstypes: [Types.len]usize = undefined,
-
     // recipient of notification, passed as the first parameter to notify
     notify_ctx: *anyopaque,
     notify_func: *const fn (ctx: *anyopaque, notification: *const Notification) anyerror!void,
@@ -159,48 +158,59 @@ pub const Session = struct {
         const allocator = app.allocator;
         self.* = .{
             .app = app,
-            .env = undefined,
             .browser = browser,
             .notify_ctx = any_ctx,
             .inspector = undefined,
             .notify_func = ContextStruct.notify,
             .http_client = browser.http_client,
+            .executor = undefined,
             .storage_shed = storage.Shed.init(allocator),
             .arena = std.heap.ArenaAllocator.init(allocator),
             .cookie_jar = storage.CookieJar.init(allocator),
             .window = Window.create(null, .{ .agent = user_agent }),
+            .state = .{
+                .loop = app.loop,
+                .document = null,
+                .http_client = browser.http_client,
+
+                // we'll set this immediately after
+                .cookie_jar = undefined,
+
+                // nothing should be used on the state until we have a page
+                // at which point we'll set these fields
+                .renderer = undefined,
+                .url = undefined,
+                .arena = undefined,
+            },
         };
+        self.state.cookie_jar = &self.cookie_jar;
+        errdefer self.arena.deinit();
 
-        const arena = self.arena.allocator();
-        Env.init(&self.env, arena, app.loop, null);
-        errdefer self.env.deinit();
-        try self.env.load(&self.jstypes);
+        self.executor = try browser.env.startExecutor(Window, &self.state, self);
+        errdefer browser.env.stopExecutor(self.executor);
+        self.inspector = try Env.Inspector.init(self.arena.allocator(), self.executor, ctx);
 
-        // const ctx_opaque = @as(*anyopaque, @ptrCast(ctx));
-        self.inspector = try jsruntime.Inspector.init(
-            arena,
-            &self.env,
-            any_ctx,
-            ContextStruct.onInspectorResponse,
-            ContextStruct.onInspectorEvent,
-        );
-        self.env.setInspector(self.inspector);
-        try self.env.setModuleLoadFn(self, Session.fetchModule);
+        self.microtaskLoop();
     }
 
     fn deinit(self: *Session) void {
+        self.app.loop.resetZig();
         if (self.page != null) {
             self.removePage();
         }
-        self.env.deinit();
+        self.inspector.deinit();
         self.arena.deinit();
         self.cookie_jar.deinit();
         self.storage_shed.deinit();
+        self.browser.env.stopExecutor(self.executor);
     }
 
-    fn fetchModule(ctx: *anyopaque, referrer: ?jsruntime.Module, specifier: []const u8) !jsruntime.Module {
-        _ = referrer;
+    fn microtaskLoop(self: *Session) void {
+        self.browser.runMicrotasks();
+        self.app.loop.zigTimeout(1 * std.time.ns_per_ms, *Session, self, microtaskLoop);
+    }
 
+    pub fn fetchModuleSource(ctx: *anyopaque, specifier: []const u8) ![]const u8 {
         const self: *Session = @ptrCast(@alignCast(ctx));
         const page = &(self.page orelse return error.NoPage);
 
@@ -209,16 +219,15 @@ pub const Session = struct {
         // Use the page_arena for this, which has a more appropriate lifetime
         // and which has more retained memory between sessions and pages.
         const arena = self.browser.page_arena.allocator();
-        const body = try page.fetchData(
+        return try page.fetchData(
             arena,
             specifier,
             if (page.current_script) |s| s.src else null,
         );
-        return self.env.compileModule(body, specifier);
     }
 
-    pub fn callInspector(self: *Session, msg: []const u8) void {
-        self.inspector.send(self.env, msg);
+    pub fn callInspector(self: *const Session, msg: []const u8) void {
+        self.inspector.send(msg);
     }
 
     // NOTE: the caller is not the owner of the returned value,
@@ -232,19 +241,14 @@ pub const Session = struct {
         const page = &self.page.?;
 
         // start JS env
-        log.debug("start js env", .{});
-        try self.env.start();
+        log.debug("start new js scope", .{});
+        self.state.arena = self.browser.page_arena.allocator();
+        errdefer self.state.arena = undefined;
 
-        if (comptime builtin.is_test == false) {
-            // By not loading this during tests, we aren't required to load
-            // all of the interfaces into zig-js-runtime.
-            log.debug("setup global env", .{});
-            try self.env.bindGlobal(&self.window);
-        }
+        try self.executor.startScope(&self.window);
 
         // load polyfills
-        // TODO: change to 'env' when https://github.com/lightpanda-io/zig-js-runtime/pull/285 lands
-        try polyfill.load(self.arena.allocator(), &self.env);
+        try polyfill.load(self.arena.allocator(), self.executor);
 
         // inspector
         self.contextCreated(page, aux_data);
@@ -254,11 +258,10 @@ pub const Session = struct {
 
     pub fn removePage(self: *Session) void {
         std.debug.assert(self.page != null);
-
         // Reset all existing callbacks.
         self.app.loop.resetJS();
+        self.executor.endScope();
 
-        self.env.stop();
         // TODO unload document: https://html.spec.whatwg.org/#unloading-documents
 
         self.window.replaceLocation(.{ .url = null }) catch |e| {
@@ -267,6 +270,7 @@ pub const Session = struct {
 
         // clear netsurf memory arena.
         parser.deinit();
+        self.state.arena = undefined;
 
         self.page = null;
     }
@@ -277,7 +281,7 @@ pub const Session = struct {
 
     fn contextCreated(self: *Session, page: *Page, aux_data: ?[]const u8) void {
         log.debug("inspector context created", .{});
-        self.inspector.contextCreated(&self.env, "", (page.origin() catch "://") orelse "://", aux_data);
+        self.inspector.contextCreated(self.executor, "", (page.origin() catch "://") orelse "://", aux_data);
     }
 
     fn notify(self: *const Session, notification: *const Notification) void {
@@ -332,19 +336,16 @@ pub const Page = struct {
 
     pub fn wait(self: *Page) !void {
         // try catch
-        var try_catch: jsruntime.TryCatch = undefined;
-        try_catch.init(&self.session.env);
+        var try_catch: Env.TryCatch = undefined;
+        try_catch.init(self.session.executor);
         defer try_catch.deinit();
 
-        self.session.env.wait() catch |err| {
-            // the js env could not be started if the document wasn't an HTML.
-            if (err == error.EnvNotStarted) return;
-
-            const arena = self.arena;
-            if (try try_catch.err(arena, &self.session.env)) |msg| {
-                defer arena.free(msg);
+        self.session.app.loop.run() catch |err| {
+            if (try try_catch.err(self.arena)) |msg| {
                 log.info("wait error: {s}", .{msg});
                 return;
+            } else {
+                log.info("wait error: {any}", .{err});
             }
         };
         log.debug("wait: OK", .{});
@@ -397,7 +398,7 @@ pub const Page = struct {
         try session.cookie_jar.populateFromResponse(&url.uri, &header);
 
         // TODO handle fragment in url.
-        try self.session.window.replaceLocation(.{ .url = try url.toWebApi(arena) });
+        try session.window.replaceLocation(.{ .url = try url.toWebApi(arena) });
 
         log.info("GET {any} {d}", .{ url, header.status });
 
@@ -414,7 +415,6 @@ pub const Page = struct {
 
         log.debug("header content-type: {s}", .{ct});
         var mime = try Mime.parse(arena, ct);
-        defer mime.deinit();
 
         if (mime.isHTML()) {
             try self.loadHTMLDoc(&response, mime.charset orelse "utf-8", aux_data);
@@ -485,7 +485,7 @@ pub const Page = struct {
         // https://html.spec.whatwg.org/#reporting-document-loading-status
 
         // inject the URL to the document including the fragment.
-        try parser.documentSetDocumentURI(doc, if (self.url) |*url| url.raw else "about:blank");
+        try parser.documentSetDocumentURI(doc, self.url.?.raw);
 
         const session = self.session;
         // TODO set the referrer to the document.
@@ -499,14 +499,13 @@ pub const Page = struct {
         // inspector
         session.contextCreated(self, aux_data);
 
-        // replace the user context document with the new one.
-        try session.env.setUserContext(.{
-            .url = @ptrCast(&self.url.?),
-            .document = html_doc,
-            .renderer = @ptrCast(&self.renderer),
-            .cookie_jar = @ptrCast(&self.session.cookie_jar),
-            .http_client = @ptrCast(self.session.http_client),
-        });
+        {
+            // update the sessions state
+            const state = &session.state;
+            state.url = &self.url.?;
+            state.document = html_doc;
+            state.renderer = &self.renderer;
+        }
 
         // browse the DOM tree to retrieve scripts
         // TODO execute the synchronous scripts during the HTL parsing.
@@ -643,7 +642,7 @@ pub const Page = struct {
         // TODO handle charset attribute
         const opt_text = try parser.nodeTextContent(parser.elementToNode(s.element));
         if (opt_text) |text| {
-            try s.eval(self.arena, &self.session.env, text);
+            try s.eval(self.arena, self.session, text);
             return;
         }
 
@@ -711,7 +710,7 @@ pub const Page = struct {
     fn fetchScript(self: *const Page, s: *const Script) !void {
         const arena = self.arena;
         const body = try self.fetchData(arena, s.src, null);
-        try s.eval(arena, &self.session.env, body);
+        try s.eval(arena, self.session, body);
     }
 
     fn newHTTPRequest(self: *const Page, method: http.Request.Method, url: *const URL, opts: storage.cookie.LookupOpts) !http.Request {
@@ -769,24 +768,24 @@ pub const Page = struct {
             return .unknown;
         }
 
-        fn eval(self: Script, arena: Allocator, env: *const Env, body: []const u8) !void {
-            var try_catch: jsruntime.TryCatch = undefined;
-            try_catch.init(env);
+        fn eval(self: Script, arena: Allocator, session: *Session, body: []const u8) !void {
+            var try_catch: Env.TryCatch = undefined;
+            try_catch.init(session.executor);
             defer try_catch.deinit();
 
             const res = switch (self.kind) {
                 .unknown => return error.UnknownScript,
-                .javascript => env.exec(body, self.src),
-                .module => env.module(body, self.src),
+                .javascript => session.executor.exec(body, self.src),
+                .module => session.executor.module(body, self.src),
             } catch {
-                if (try try_catch.err(arena, env)) |msg| {
+                if (try try_catch.err(arena)) |msg| {
                     log.info("eval script {s}: {s}", .{ self.src, msg });
                 }
                 return FetchError.JsErr;
             };
 
             if (builtin.mode == .Debug) {
-                const msg = try res.toString(arena, env);
+                const msg = try res.toString(arena);
                 log.debug("eval script {s}: {s}", .{ self.src, msg });
             }
         }
@@ -810,7 +809,7 @@ const FlatRenderer = struct {
     // given an index, get the element
     elements: std.ArrayListUnmanaged(u64),
 
-    const Element = @import("../dom/element.zig").Element;
+    const Element = @import("dom/element.zig").Element;
 
     // we expect allocator to be an arena
     pub fn init(allocator: Allocator) FlatRenderer {
diff --git a/src/browser/console/console.zig b/src/browser/console/console.zig
new file mode 100644
index 00000000..59f56594
--- /dev/null
+++ b/src/browser/console/console.zig
@@ -0,0 +1,28 @@
+// Copyright (C) 2023-2024  Lightpanda (Selecy SAS)
+//
+// Francis Bouvier 
+// Pierre Tachoire 
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see .
+
+const std = @import("std");
+const log = std.log.scoped(.console);
+
+pub const Console = struct {
+    // TODO: configurable writer
+
+    pub fn _log(_: *const Console, str: []const u8) void {
+        log.debug("{s}\n", .{str});
+    }
+};
diff --git a/src/css/README.md b/src/browser/css/README.md
similarity index 100%
rename from src/css/README.md
rename to src/browser/css/README.md
diff --git a/src/css/css.zig b/src/browser/css/css.zig
similarity index 100%
rename from src/css/css.zig
rename to src/browser/css/css.zig
diff --git a/src/css/libdom.zig b/src/browser/css/libdom.zig
similarity index 98%
rename from src/css/libdom.zig
rename to src/browser/css/libdom.zig
index 21333726..44307c63 100644
--- a/src/css/libdom.zig
+++ b/src/browser/css/libdom.zig
@@ -18,7 +18,7 @@
 
 const std = @import("std");
 
-const parser = @import("netsurf");
+const parser = @import("../netsurf.zig");
 
 // Node implementation with Netsurf Libdom C lib.
 pub const Node = struct {
diff --git a/src/css/libdom_test.zig b/src/browser/css/libdom_test.zig
similarity index 99%
rename from src/css/libdom_test.zig
rename to src/browser/css/libdom_test.zig
index c0cdbb3f..4cd267e0 100644
--- a/src/css/libdom_test.zig
+++ b/src/browser/css/libdom_test.zig
@@ -19,7 +19,7 @@
 const std = @import("std");
 const css = @import("css.zig");
 const Node = @import("libdom.zig").Node;
-const parser = @import("netsurf");
+const parser = @import("../netsurf.zig");
 
 const Matcher = struct {
     const Nodes = std.ArrayList(Node);
diff --git a/src/css/match_test.zig b/src/browser/css/match_test.zig
similarity index 100%
rename from src/css/match_test.zig
rename to src/browser/css/match_test.zig
diff --git a/src/css/parser.zig b/src/browser/css/parser.zig
similarity index 100%
rename from src/css/parser.zig
rename to src/browser/css/parser.zig
diff --git a/src/css/selector.zig b/src/browser/css/selector.zig
similarity index 100%
rename from src/css/selector.zig
rename to src/browser/css/selector.zig
diff --git a/src/dom/attribute.zig b/src/browser/dom/attribute.zig
similarity index 62%
rename from src/dom/attribute.zig
rename to src/browser/dom/attribute.zig
index d85f87a0..62b765af 100644
--- a/src/dom/attribute.zig
+++ b/src/browser/dom/attribute.zig
@@ -18,11 +18,7 @@
 
 const std = @import("std");
 
-const jsruntime = @import("jsruntime");
-const Case = jsruntime.test_utils.Case;
-const checkCases = jsruntime.test_utils.checkCases;
-
-const parser = @import("netsurf");
+const parser = @import("../netsurf.zig");
 
 const Node = @import("node.zig").Node;
 const DOMException = @import("exceptions.zig").DOMException;
@@ -31,7 +27,6 @@ const DOMException = @import("exceptions.zig").DOMException;
 pub const Attr = struct {
     pub const Self = parser.Attribute;
     pub const prototype = *Node;
-    pub const mem_guarantied = true;
 
     pub fn get_namespaceURI(self: *parser.Attribute) !?[]const u8 {
         return try parser.nodeGetNamespace(parser.attributeToNode(self));
@@ -70,34 +65,33 @@ pub const Attr = struct {
 // Tests
 // -----
 
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var getters = [_]Case{
-        .{ .src = "let a = document.createAttributeNS('foo', 'bar')", .ex = "undefined" },
-        .{ .src = "a.namespaceURI", .ex = "foo" },
-        .{ .src = "a.prefix", .ex = "null" },
-        .{ .src = "a.localName", .ex = "bar" },
-        .{ .src = "a.name", .ex = "bar" },
-        .{ .src = "a.value", .ex = "" },
+const testing = @import("../../testing.zig");
+test "Browser.DOM.Attribute" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
+
+    try runner.testCases(&.{
+        .{ "let a = document.createAttributeNS('foo', 'bar')", "undefined" },
+        .{ "a.namespaceURI", "foo" },
+        .{ "a.prefix", "null" },
+        .{ "a.localName", "bar" },
+        .{ "a.name", "bar" },
+        .{ "a.value", "" },
         // TODO: libdom has a bug here: the created attr has no parent, it
         // causes a panic w/ libdom when setting the value.
-        //.{ .src = "a.value = 'nok'", .ex = "nok" },
-        .{ .src = "a.ownerElement", .ex = "null" },
-    };
-    try checkCases(js_env, &getters);
+        //.{ "a.value = 'nok'", "nok" },
+        .{ "a.ownerElement", "null" },
+    }, .{});
 
-    var attr = [_]Case{
-        .{ .src = "let b = document.getElementById('link').getAttributeNode('class')", .ex = "undefined" },
-        .{ .src = "b.name", .ex = "class" },
-        .{ .src = "b.value", .ex = "ok" },
-        .{ .src = "b.value = 'nok'", .ex = "nok" },
-        .{ .src = "b.value", .ex = "nok" },
-        .{ .src = "b.value = null", .ex = "null" },
-        .{ .src = "b.value", .ex = "null" },
-        .{ .src = "b.value = 'ok'", .ex = "ok" },
-        .{ .src = "b.ownerElement.id", .ex = "link" },
-    };
-    try checkCases(js_env, &attr);
+    try runner.testCases(&.{
+        .{ "let b = document.getElementById('link').getAttributeNode('class')", "undefined" },
+        .{ "b.name", "class" },
+        .{ "b.value", "ok" },
+        .{ "b.value = 'nok'", "nok" },
+        .{ "b.value", "nok" },
+        .{ "b.value = null", "null" },
+        .{ "b.value", "null" },
+        .{ "b.value = 'ok'", "ok" },
+        .{ "b.ownerElement.id", "link" },
+    }, .{});
 }
diff --git a/src/dom/cdata_section.zig b/src/browser/dom/cdata_section.zig
similarity index 93%
rename from src/dom/cdata_section.zig
rename to src/browser/dom/cdata_section.zig
index c8ff6107..51017b2e 100644
--- a/src/dom/cdata_section.zig
+++ b/src/browser/dom/cdata_section.zig
@@ -18,7 +18,7 @@
 
 const std = @import("std");
 
-const parser = @import("netsurf");
+const parser = @import("../netsurf.zig");
 
 const Text = @import("text.zig").Text;
 
@@ -26,5 +26,4 @@ const Text = @import("text.zig").Text;
 pub const CDATASection = struct {
     pub const Self = parser.CDATASection;
     pub const prototype = *Text;
-    pub const mem_guarantied = true;
 };
diff --git a/src/dom/character_data.zig b/src/browser/dom/character_data.zig
similarity index 53%
rename from src/dom/character_data.zig
rename to src/browser/dom/character_data.zig
index 372395c2..3b4a3b57 100644
--- a/src/dom/character_data.zig
+++ b/src/browser/dom/character_data.zig
@@ -18,11 +18,7 @@
 
 const std = @import("std");
 
-const jsruntime = @import("jsruntime");
-const Case = jsruntime.test_utils.Case;
-const checkCases = jsruntime.test_utils.checkCases;
-
-const parser = @import("netsurf");
+const parser = @import("../netsurf.zig");
 
 const Node = @import("node.zig").Node;
 const Comment = @import("comment.zig").Comment;
@@ -42,7 +38,6 @@ pub const Interfaces = .{
 pub const CharacterData = struct {
     pub const Self = parser.CharacterData;
     pub const prototype = *Node;
-    pub const mem_guarantied = true;
 
     // JS funcs
     // --------
@@ -106,74 +101,65 @@ pub const CharacterData = struct {
 // Tests
 // -----
 
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var get_data = [_]Case{
-        .{ .src = "let link = document.getElementById('link')", .ex = "undefined" },
-        .{ .src = "let cdata = link.firstChild", .ex = "undefined" },
-        .{ .src = "cdata.data", .ex = "OK" },
-    };
-    try checkCases(js_env, &get_data);
+const testing = @import("../../testing.zig");
+test "Browser.DOM.CharacterData" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
 
-    var set_data = [_]Case{
-        .{ .src = "cdata.data = 'OK modified'", .ex = "OK modified" },
-        .{ .src = "cdata.data === 'OK modified'", .ex = "true" },
-        .{ .src = "cdata.data = 'OK'", .ex = "OK" },
-    };
-    try checkCases(js_env, &set_data);
+    try runner.testCases(&.{
+        .{ "let link = document.getElementById('link')", "undefined" },
+        .{ "let cdata = link.firstChild", "undefined" },
+        .{ "cdata.data", "OK" },
+    }, .{});
 
-    var get_length = [_]Case{
-        .{ .src = "cdata.length === 2", .ex = "true" },
-    };
-    try checkCases(js_env, &get_length);
+    try runner.testCases(&.{
+        .{ "cdata.data = 'OK modified'", "OK modified" },
+        .{ "cdata.data === 'OK modified'", "true" },
+        .{ "cdata.data = 'OK'", "OK" },
+    }, .{});
 
-    var get_next_elem_sibling = [_]Case{
-        .{ .src = "cdata.nextElementSibling === null", .ex = "true" },
+    try runner.testCases(&.{
+        .{ "cdata.length === 2", "true" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "cdata.nextElementSibling === null", "true" },
         // create a next element
-        .{ .src = "let next = document.createElement('a')", .ex = "undefined" },
-        .{ .src = "link.appendChild(next, cdata) !== undefined", .ex = "true" },
-        .{ .src = "cdata.nextElementSibling.localName === 'a' ", .ex = "true" },
-    };
-    try checkCases(js_env, &get_next_elem_sibling);
+        .{ "let next = document.createElement('a')", "undefined" },
+        .{ "link.appendChild(next, cdata) !== undefined", "true" },
+        .{ "cdata.nextElementSibling.localName === 'a' ", "true" },
+    }, .{});
 
-    var get_prev_elem_sibling = [_]Case{
-        .{ .src = "cdata.previousElementSibling === null", .ex = "true" },
+    try runner.testCases(&.{
+        .{ "cdata.previousElementSibling === null", "true" },
         // create a prev element
-        .{ .src = "let prev = document.createElement('div')", .ex = "undefined" },
-        .{ .src = "link.insertBefore(prev, cdata) !== undefined", .ex = "true" },
-        .{ .src = "cdata.previousElementSibling.localName === 'div' ", .ex = "true" },
-    };
-    try checkCases(js_env, &get_prev_elem_sibling);
+        .{ "let prev = document.createElement('div')", "undefined" },
+        .{ "link.insertBefore(prev, cdata) !== undefined", "true" },
+        .{ "cdata.previousElementSibling.localName === 'div' ", "true" },
+    }, .{});
 
-    var append_data = [_]Case{
-        .{ .src = "cdata.appendData(' modified')", .ex = "undefined" },
-        .{ .src = "cdata.data === 'OK modified' ", .ex = "true" },
-    };
-    try checkCases(js_env, &append_data);
+    try runner.testCases(&.{
+        .{ "cdata.appendData(' modified')", "undefined" },
+        .{ "cdata.data === 'OK modified' ", "true" },
+    }, .{});
 
-    var delete_data = [_]Case{
-        .{ .src = "cdata.deleteData('OK'.length, ' modified'.length)", .ex = "undefined" },
-        .{ .src = "cdata.data == 'OK'", .ex = "true" },
-    };
-    try checkCases(js_env, &delete_data);
+    try runner.testCases(&.{
+        .{ "cdata.deleteData('OK'.length, ' modified'.length)", "undefined" },
+        .{ "cdata.data == 'OK'", "true" },
+    }, .{});
 
-    var insert_data = [_]Case{
-        .{ .src = "cdata.insertData('OK'.length-1, 'modified')", .ex = "undefined" },
-        .{ .src = "cdata.data == 'OmodifiedK'", .ex = "true" },
-    };
-    try checkCases(js_env, &insert_data);
+    try runner.testCases(&.{
+        .{ "cdata.insertData('OK'.length-1, 'modified')", "undefined" },
+        .{ "cdata.data == 'OmodifiedK'", "true" },
+    }, .{});
 
-    var replace_data = [_]Case{
-        .{ .src = "cdata.replaceData('OK'.length-1, 'modified'.length, 'replaced')", .ex = "undefined" },
-        .{ .src = "cdata.data == 'OreplacedK'", .ex = "true" },
-    };
-    try checkCases(js_env, &replace_data);
+    try runner.testCases(&.{
+        .{ "cdata.replaceData('OK'.length-1, 'modified'.length, 'replaced')", "undefined" },
+        .{ "cdata.data == 'OreplacedK'", "true" },
+    }, .{});
 
-    var substring_data = [_]Case{
-        .{ .src = "cdata.substringData('OK'.length-1, 'replaced'.length) == 'replaced'", .ex = "true" },
-        .{ .src = "cdata.substringData('OK'.length-1, 0) == ''", .ex = "true" },
-    };
-    try checkCases(js_env, &substring_data);
+    try runner.testCases(&.{
+        .{ "cdata.substringData('OK'.length-1, 'replaced'.length) == 'replaced'", "true" },
+        .{ "cdata.substringData('OK'.length-1, 0) == ''", "true" },
+    }, .{});
 }
diff --git a/src/dom/comment.zig b/src/browser/dom/comment.zig
similarity index 58%
rename from src/dom/comment.zig
rename to src/browser/dom/comment.zig
index fe4111bc..43009d6d 100644
--- a/src/dom/comment.zig
+++ b/src/browser/dom/comment.zig
@@ -17,25 +17,20 @@
 // along with this program.  If not, see .
 const std = @import("std");
 
-const parser = @import("netsurf");
-
-const jsruntime = @import("jsruntime");
-const Case = jsruntime.test_utils.Case;
-const checkCases = jsruntime.test_utils.checkCases;
+const parser = @import("../netsurf.zig");
 
 const CharacterData = @import("character_data.zig").CharacterData;
 
-const UserContext = @import("../user_context.zig").UserContext;
+const SessionState = @import("../env.zig").SessionState;
 
 // https://dom.spec.whatwg.org/#interface-comment
 pub const Comment = struct {
     pub const Self = parser.Comment;
     pub const prototype = *CharacterData;
-    pub const mem_guarantied = true;
 
-    pub fn constructor(userctx: UserContext, data: ?[]const u8) !*parser.Comment {
+    pub fn constructor(state: *const SessionState, data: ?[]const u8) !*parser.Comment {
         return parser.documentCreateComment(
-            parser.documentHTMLToDocument(userctx.document),
+            parser.documentHTMLToDocument(state.document.?),
             data orelse "",
         );
     }
@@ -44,16 +39,16 @@ pub const Comment = struct {
 // Tests
 // -----
 
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var constructor = [_]Case{
-        .{ .src = "let comment = new Comment('foo')", .ex = "undefined" },
-        .{ .src = "comment.data", .ex = "foo" },
+const testing = @import("../../testing.zig");
+test "Browser.DOM.Comment" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
 
-        .{ .src = "let emptycomment = new Comment()", .ex = "undefined" },
-        .{ .src = "emptycomment.data", .ex = "" },
-    };
-    try checkCases(js_env, &constructor);
+    try runner.testCases(&.{
+        .{ "let comment = new Comment('foo')", "undefined" },
+        .{ "comment.data", "foo" },
+
+        .{ "let emptycomment = new Comment()", "undefined" },
+        .{ "emptycomment.data", "" },
+    }, .{});
 }
diff --git a/src/dom/css.zig b/src/browser/dom/css.zig
similarity index 98%
rename from src/dom/css.zig
rename to src/browser/dom/css.zig
index 0432e83e..50c262e4 100644
--- a/src/dom/css.zig
+++ b/src/browser/dom/css.zig
@@ -18,7 +18,7 @@
 
 const std = @import("std");
 
-const parser = @import("netsurf");
+const parser = @import("../netsurf.zig");
 
 const css = @import("../css/css.zig");
 const Node = @import("../css/libdom.zig").Node;
diff --git a/src/browser/dom/document.zig b/src/browser/dom/document.zig
new file mode 100644
index 00000000..9f414220
--- /dev/null
+++ b/src/browser/dom/document.zig
@@ -0,0 +1,444 @@
+// Copyright (C) 2023-2024  Lightpanda (Selecy SAS)
+//
+// Francis Bouvier 
+// Pierre Tachoire 
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see .
+
+const std = @import("std");
+
+const parser = @import("../netsurf.zig");
+const SessionState = @import("../env.zig").SessionState;
+
+const Node = @import("node.zig").Node;
+const NodeList = @import("nodelist.zig").NodeList;
+const NodeUnion = @import("node.zig").Union;
+
+const collection = @import("html_collection.zig");
+const css = @import("css.zig");
+
+const Element = @import("element.zig").Element;
+const ElementUnion = @import("element.zig").Union;
+
+const DocumentType = @import("document_type.zig").DocumentType;
+const DocumentFragment = @import("document_fragment.zig").DocumentFragment;
+const DOMImplementation = @import("implementation.zig").DOMImplementation;
+
+// WEB IDL https://dom.spec.whatwg.org/#document
+pub const Document = struct {
+    pub const Self = parser.Document;
+    pub const prototype = *Node;
+
+    pub fn constructor(state: *const SessionState) !*parser.DocumentHTML {
+        const doc = try parser.documentCreateDocument(
+            try parser.documentHTMLGetTitle(state.document.?),
+        );
+
+        // we have to work w/ document instead of html document.
+        const ddoc = parser.documentHTMLToDocument(doc);
+        const ccur = parser.documentHTMLToDocument(state.document.?);
+        try parser.documentSetDocumentURI(ddoc, try parser.documentGetDocumentURI(ccur));
+        try parser.documentSetInputEncoding(ddoc, try parser.documentGetInputEncoding(ccur));
+
+        return doc;
+    }
+
+    // JS funcs
+    // --------
+    pub fn get_implementation(_: *parser.Document) DOMImplementation {
+        return DOMImplementation{};
+    }
+
+    pub fn get_documentElement(self: *parser.Document) !?ElementUnion {
+        const e = try parser.documentGetDocumentElement(self);
+        if (e == null) return null;
+        return try Element.toInterface(e.?);
+    }
+
+    pub fn get_documentURI(self: *parser.Document) ![]const u8 {
+        return try parser.documentGetDocumentURI(self);
+    }
+
+    pub fn get_URL(self: *parser.Document) ![]const u8 {
+        return try get_documentURI(self);
+    }
+
+    // TODO implement contentType
+    pub fn get_contentType(self: *parser.Document) []const u8 {
+        _ = self;
+        return "text/html";
+    }
+
+    // TODO implement compactMode
+    pub fn get_compatMode(self: *parser.Document) []const u8 {
+        _ = self;
+        return "CSS1Compat";
+    }
+
+    pub fn get_characterSet(self: *parser.Document) ![]const u8 {
+        return try parser.documentGetInputEncoding(self);
+    }
+
+    // alias of get_characterSet
+    pub fn get_charset(self: *parser.Document) ![]const u8 {
+        return try get_characterSet(self);
+    }
+
+    // alias of get_characterSet
+    pub fn get_inputEncoding(self: *parser.Document) ![]const u8 {
+        return try get_characterSet(self);
+    }
+
+    pub fn get_doctype(self: *parser.Document) !?*parser.DocumentType {
+        return try parser.documentGetDoctype(self);
+    }
+
+    pub fn _createEvent(_: *parser.Document, eventCstr: []const u8) !*parser.Event {
+        // TODO: for now only "Event" constructor is supported
+        // see table on https://dom.spec.whatwg.org/#dom-document-createevent $2
+        if (std.ascii.eqlIgnoreCase(eventCstr, "Event") or std.ascii.eqlIgnoreCase(eventCstr, "Events")) {
+            return try parser.eventCreate();
+        }
+        return parser.DOMError.NotSupported;
+    }
+
+    pub fn _getElementById(self: *parser.Document, id: []const u8) !?ElementUnion {
+        const e = try parser.documentGetElementById(self, id) orelse return null;
+        return try Element.toInterface(e);
+    }
+
+    pub fn _createElement(self: *parser.Document, tag_name: []const u8) !ElementUnion {
+        const e = try parser.documentCreateElement(self, tag_name);
+        return try Element.toInterface(e);
+    }
+
+    pub fn _createElementNS(self: *parser.Document, ns: []const u8, tag_name: []const u8) !ElementUnion {
+        const e = try parser.documentCreateElementNS(self, ns, tag_name);
+        return try Element.toInterface(e);
+    }
+
+    // We can't simply use libdom dom_document_get_elements_by_tag_name here.
+    // Indeed, netsurf implemented a previous dom spec when
+    // getElementsByTagName returned a NodeList.
+    // But since
+    // https://github.com/whatwg/dom/commit/190700b7c12ecfd3b5ebdb359ab1d6ea9cbf7749
+    // the spec changed to return an HTMLCollection instead.
+    // That's why we reimplemented getElementsByTagName by using an
+    // HTMLCollection in zig here.
+    pub fn _getElementsByTagName(
+        self: *parser.Document,
+        state: *SessionState,
+        tag_name: []const u8,
+    ) !collection.HTMLCollection {
+        return try collection.HTMLCollectionByTagName(state.arena, parser.documentToNode(self), tag_name, true);
+    }
+
+    pub fn _getElementsByClassName(
+        self: *parser.Document,
+        state: *SessionState,
+        classNames: []const u8,
+    ) !collection.HTMLCollection {
+        const allocator = state.arena;
+        return try collection.HTMLCollectionByClassName(allocator, parser.documentToNode(self), classNames, true);
+    }
+
+    pub fn _createDocumentFragment(self: *parser.Document) !*parser.DocumentFragment {
+        return try parser.documentCreateDocumentFragment(self);
+    }
+
+    pub fn _createTextNode(self: *parser.Document, data: []const u8) !*parser.Text {
+        return try parser.documentCreateTextNode(self, data);
+    }
+
+    pub fn _createCDATASection(self: *parser.Document, data: []const u8) !*parser.CDATASection {
+        return try parser.documentCreateCDATASection(self, data);
+    }
+
+    pub fn _createComment(self: *parser.Document, data: []const u8) !*parser.Comment {
+        return try parser.documentCreateComment(self, data);
+    }
+
+    pub fn _createProcessingInstruction(self: *parser.Document, target: []const u8, data: []const u8) !*parser.ProcessingInstruction {
+        return try parser.documentCreateProcessingInstruction(self, target, data);
+    }
+
+    pub fn _importNode(self: *parser.Document, node: *parser.Node, deep: ?bool) !NodeUnion {
+        const n = try parser.documentImportNode(self, node, deep orelse false);
+        return try Node.toInterface(n);
+    }
+
+    pub fn _adoptNode(self: *parser.Document, node: *parser.Node) !NodeUnion {
+        const n = try parser.documentAdoptNode(self, node);
+        return try Node.toInterface(n);
+    }
+
+    pub fn _createAttribute(self: *parser.Document, name: []const u8) !*parser.Attribute {
+        return try parser.documentCreateAttribute(self, name);
+    }
+
+    pub fn _createAttributeNS(self: *parser.Document, ns: []const u8, qname: []const u8) !*parser.Attribute {
+        return try parser.documentCreateAttributeNS(self, ns, qname);
+    }
+
+    // ParentNode
+    // https://dom.spec.whatwg.org/#parentnode
+    pub fn get_children(self: *parser.Document) !collection.HTMLCollection {
+        return try collection.HTMLCollectionChildren(parser.documentToNode(self), false);
+    }
+
+    pub fn get_firstElementChild(self: *parser.Document) !?ElementUnion {
+        const elt = try parser.documentGetDocumentElement(self) orelse return null;
+        return try Element.toInterface(elt);
+    }
+
+    pub fn get_lastElementChild(self: *parser.Document) !?ElementUnion {
+        const elt = try parser.documentGetDocumentElement(self) orelse return null;
+        return try Element.toInterface(elt);
+    }
+
+    pub fn get_childElementCount(self: *parser.Document) !u32 {
+        _ = try parser.documentGetDocumentElement(self) orelse return 0;
+        return 1;
+    }
+
+    pub fn _querySelector(self: *parser.Document, state: *SessionState, selector: []const u8) !?ElementUnion {
+        if (selector.len == 0) return null;
+
+        const allocator = state.arena;
+        const n = try css.querySelector(allocator, parser.documentToNode(self), selector);
+
+        if (n == null) return null;
+
+        return try Element.toInterface(parser.nodeToElement(n.?));
+    }
+
+    pub fn _querySelectorAll(self: *parser.Document, state: *SessionState, selector: []const u8) !NodeList {
+        const allocator = state.arena;
+        return css.querySelectorAll(allocator, parser.documentToNode(self), selector);
+    }
+
+    // TODO according with https://dom.spec.whatwg.org/#parentnode, the
+    // function must accept either node or string.
+    // blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
+    pub fn _prepend(self: *parser.Document, nodes: []const *parser.Node) !void {
+        return Node.prepend(parser.documentToNode(self), nodes);
+    }
+
+    // TODO according with https://dom.spec.whatwg.org/#parentnode, the
+    // function must accept either node or string.
+    // blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
+    pub fn _append(self: *parser.Document, nodes: []const *parser.Node) !void {
+        return Node.append(parser.documentToNode(self), nodes);
+    }
+
+    // TODO according with https://dom.spec.whatwg.org/#parentnode, the
+    // function must accept either node or string.
+    // blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
+    pub fn _replaceChildren(self: *parser.Document, nodes: []const *parser.Node) !void {
+        return Node.replaceChildren(parser.documentToNode(self), nodes);
+    }
+
+    pub fn deinit(_: *parser.Document, _: std.mem.Allocator) void {}
+};
+
+const testing = @import("../../testing.zig");
+test "Browser.DOM.Document" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
+
+    try runner.testCases(&.{
+        .{ "document.__proto__.__proto__.constructor.name", "Document" },
+        .{ "document.__proto__.__proto__.__proto__.constructor.name", "Node" },
+        .{ "document.__proto__.__proto__.__proto__.__proto__.constructor.name", "EventTarget" },
+
+        .{ "let newdoc = new Document()", "undefined" },
+        .{ "newdoc.documentElement", "null" },
+        .{ "newdoc.children.length", "0" },
+        .{ "newdoc.getElementsByTagName('*').length", "0" },
+        .{ "newdoc.getElementsByTagName('*').item(0)", "null" },
+        .{ "newdoc.inputEncoding === document.inputEncoding", "true" },
+        .{ "newdoc.documentURI === document.documentURI", "true" },
+        .{ "newdoc.URL === document.URL", "true" },
+        .{ "newdoc.compatMode === document.compatMode", "true" },
+        .{ "newdoc.characterSet === document.characterSet", "true" },
+        .{ "newdoc.charset === document.charset", "true" },
+        .{ "newdoc.contentType === document.contentType", "true" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "let getElementById = document.getElementById('content')", "undefined" },
+        .{ "getElementById.constructor.name", "HTMLDivElement" },
+        .{ "getElementById.localName", "div" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "let getElementsByTagName = document.getElementsByTagName('p')", "undefined" },
+        .{ "getElementsByTagName.length", "2" },
+        .{ "getElementsByTagName.item(0).localName", "p" },
+        .{ "getElementsByTagName.item(1).localName", "p" },
+        .{ "let getElementsByTagNameAll = document.getElementsByTagName('*')", "undefined" },
+        .{ "getElementsByTagNameAll.length", "8" },
+        .{ "getElementsByTagNameAll.item(0).localName", "html" },
+        .{ "getElementsByTagNameAll.item(7).localName", "p" },
+        .{ "getElementsByTagNameAll.namedItem('para-empty-child').localName", "span" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "let ok = document.getElementsByClassName('ok')", "undefined" },
+        .{ "ok.length", "2" },
+        .{ "let empty = document.getElementsByClassName('empty')", "undefined" },
+        .{ "empty.length", "1" },
+        .{ "let emptyok = document.getElementsByClassName('empty ok')", "undefined" },
+        .{ "emptyok.length", "1" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "let e = document.documentElement", "undefined" },
+        .{ "e.localName", "html" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "document.characterSet", "UTF-8" },
+        .{ "document.charset", "UTF-8" },
+        .{ "document.inputEncoding", "UTF-8" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "document.compatMode", "CSS1Compat" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "document.contentType", "text/html" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "document.documentURI", "about:blank" },
+        .{ "document.URL", "about:blank" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "let impl = document.implementation", "undefined" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "let d = new Document()", "undefined" },
+        .{ "d.characterSet", "UTF-8" },
+        .{ "d.URL", "about:blank" },
+        .{ "d.documentURI", "about:blank" },
+        .{ "d.compatMode", "CSS1Compat" },
+        .{ "d.contentType", "text/html" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "var v = document.createDocumentFragment()", "undefined" },
+        .{ "v.nodeName", "#document-fragment" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "var v = document.createTextNode('foo')", "undefined" },
+        .{ "v.nodeName", "#text" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "var v = document.createCDATASection('foo')", "undefined" },
+        .{ "v.nodeName", "#cdata-section" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "var v = document.createComment('foo')", "undefined" },
+        .{ "v.nodeName", "#comment" },
+        .{ "let v2 = v.cloneNode()", "undefined" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "let pi = document.createProcessingInstruction('foo', 'bar')", "undefined" },
+        .{ "pi.target", "foo" },
+        .{ "let pi2 = pi.cloneNode()", "undefined" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "let nimp = document.getElementById('content')", "undefined" },
+        .{ "var v = document.importNode(nimp)", "undefined" },
+        .{ "v.nodeName", "DIV" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "var v = document.createAttribute('foo')", "undefined" },
+        .{ "v.nodeName", "foo" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "document.children.length", "1" },
+        .{ "document.children.item(0).nodeName", "HTML" },
+        .{ "document.firstElementChild.nodeName", "HTML" },
+        .{ "document.lastElementChild.nodeName", "HTML" },
+        .{ "document.childElementCount", "1" },
+
+        .{ "let nd = new Document()", "undefined" },
+        .{ "nd.children.length", "0" },
+        .{ "nd.children.item(0)", "null" },
+        .{ "nd.firstElementChild", "null" },
+        .{ "nd.lastElementChild", "null" },
+        .{ "nd.childElementCount", "0" },
+
+        .{ "let emptydoc = document.createElement('html')", "undefined" },
+        .{ "emptydoc.prepend(document.createElement('html'))", "undefined" },
+
+        .{ "let emptydoc2 = document.createElement('html')", "undefined" },
+        .{ "emptydoc2.append(document.createElement('html'))", "undefined" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "document.querySelector('')", "null" },
+        .{ "document.querySelector('*').nodeName", "HTML" },
+        .{ "document.querySelector('#content').id", "content" },
+        .{ "document.querySelector('#para').id", "para" },
+        .{ "document.querySelector('.ok').id", "link" },
+        .{ "document.querySelector('a ~ p').id", "para-empty" },
+        .{ "document.querySelector(':root').nodeName", "HTML" },
+
+        .{ "document.querySelectorAll('p').length", "2" },
+        .{
+            \\  Array.from(document.querySelectorAll('#content > p#para-empty'))
+            \\    .map(row => row.querySelector('span').textContent)
+            \\    .length;
+            ,
+            "1",
+        },
+    }, .{});
+
+    // this test breaks the doc structure, keep it at the end of the test
+    // suite.
+    try runner.testCases(&.{
+        .{ "let nadop = document.getElementById('content')", "undefined" },
+        .{ "var v = document.adoptNode(nadop)", "undefined" },
+        .{ "v.nodeName", "DIV" },
+    }, .{});
+
+    const Case = testing.JsRunner.Case;
+    const tags = comptime parser.Tag.all();
+    var createElements: [(tags.len) * 2]Case = undefined;
+    inline for (tags, 0..) |tag, i| {
+        const tag_name = @tagName(tag);
+        createElements[i * 2] = Case{
+            "var " ++ tag_name ++ "Elem = document.createElement('" ++ tag_name ++ "')",
+            "undefined",
+        };
+        createElements[(i * 2) + 1] = Case{
+            tag_name ++ "Elem.localName",
+            tag_name,
+        };
+    }
+    try runner.testCases(&createElements, .{});
+}
diff --git a/src/dom/document_fragment.zig b/src/browser/dom/document_fragment.zig
similarity index 60%
rename from src/dom/document_fragment.zig
rename to src/browser/dom/document_fragment.zig
index 574e8eb1..e4fbf015 100644
--- a/src/dom/document_fragment.zig
+++ b/src/browser/dom/document_fragment.zig
@@ -18,39 +18,30 @@
 
 const std = @import("std");
 
-const parser = @import("netsurf");
-
-const jsruntime = @import("jsruntime");
-const Case = jsruntime.test_utils.Case;
-const checkCases = jsruntime.test_utils.checkCases;
+const parser = @import("../netsurf.zig");
+const SessionState = @import("../env.zig").SessionState;
 
 const Node = @import("node.zig").Node;
 
-const UserContext = @import("../user_context.zig").UserContext;
-
 // WEB IDL https://dom.spec.whatwg.org/#documentfragment
 pub const DocumentFragment = struct {
     pub const Self = parser.DocumentFragment;
     pub const prototype = *Node;
-    pub const mem_guarantied = true;
 
-    pub fn constructor(userctx: UserContext) !*parser.DocumentFragment {
+    pub fn constructor(state: *const SessionState) !*parser.DocumentFragment {
         return parser.documentCreateDocumentFragment(
-            parser.documentHTMLToDocument(userctx.document),
+            parser.documentHTMLToDocument(state.document.?),
         );
     }
 };
 
-// Tests
-// -----
+const testing = @import("../../testing.zig");
+test "Browser.DOM.DocumentFragment" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
 
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var constructor = [_]Case{
-        .{ .src = "const dc = new DocumentFragment()", .ex = "undefined" },
-        .{ .src = "dc.constructor.name", .ex = "DocumentFragment" },
-    };
-    try checkCases(js_env, &constructor);
+    try runner.testCases(&.{
+        .{ "const dc = new DocumentFragment()", "undefined" },
+        .{ "dc.constructor.name", "DocumentFragment" },
+    }, .{});
 }
diff --git a/src/dom/document_type.zig b/src/browser/dom/document_type.zig
similarity index 95%
rename from src/dom/document_type.zig
rename to src/browser/dom/document_type.zig
index cd40a732..0749b8bc 100644
--- a/src/dom/document_type.zig
+++ b/src/browser/dom/document_type.zig
@@ -18,7 +18,7 @@
 
 const std = @import("std");
 
-const parser = @import("netsurf");
+const parser = @import("../netsurf.zig");
 
 const Node = @import("node.zig").Node;
 
@@ -26,7 +26,6 @@ const Node = @import("node.zig").Node;
 pub const DocumentType = struct {
     pub const Self = parser.DocumentType;
     pub const prototype = *Node;
-    pub const mem_guarantied = true;
 
     pub fn get_name(self: *parser.DocumentType) ![]const u8 {
         return try parser.documentTypeGetName(self);
diff --git a/src/dom/dom.zig b/src/browser/dom/dom.zig
similarity index 95%
rename from src/dom/dom.zig
rename to src/browser/dom/dom.zig
index 76a4a185..07e57c06 100644
--- a/src/dom/dom.zig
+++ b/src/browser/dom/dom.zig
@@ -22,7 +22,7 @@ const DOMImplementation = @import("implementation.zig").DOMImplementation;
 const NamedNodeMap = @import("namednodemap.zig").NamedNodeMap;
 const DOMTokenList = @import("token_list.zig").DOMTokenList;
 const NodeList = @import("nodelist.zig");
-const Nod = @import("node.zig");
+const Node = @import("node.zig");
 const MutationObserver = @import("mutation_observer.zig");
 
 pub const Interfaces = .{
@@ -32,7 +32,7 @@ pub const Interfaces = .{
     NamedNodeMap,
     DOMTokenList,
     NodeList.Interfaces,
-    Nod.Node,
-    Nod.Interfaces,
+    Node.Node,
+    Node.Interfaces,
     MutationObserver.Interfaces,
 };
diff --git a/src/dom/element.zig b/src/browser/dom/element.zig
similarity index 54%
rename from src/dom/element.zig
rename to src/browser/dom/element.zig
index 0453a283..3faa1874 100644
--- a/src/dom/element.zig
+++ b/src/browser/dom/element.zig
@@ -18,22 +18,17 @@
 
 const std = @import("std");
 
-const parser = @import("netsurf");
-
-const jsruntime = @import("jsruntime");
-const Case = jsruntime.test_utils.Case;
-const checkCases = jsruntime.test_utils.checkCases;
-const Variadic = jsruntime.Variadic;
+const parser = @import("../netsurf.zig");
+const SessionState = @import("../env.zig").SessionState;
 
 const collection = @import("html_collection.zig");
-const dump = @import("../browser/dump.zig");
+const dump = @import("../dump.zig");
 const css = @import("css.zig");
 
 const Node = @import("node.zig").Node;
 const Walker = @import("walker.zig").WalkerDepthFirst;
 const NodeList = @import("nodelist.zig").NodeList;
 const HTMLElem = @import("../html/elements.zig");
-const UserContext = @import("../user_context.zig").UserContext;
 pub const Union = @import("../html/elements.zig").Union;
 
 const DOMException = @import("exceptions.zig").DOMException;
@@ -42,7 +37,6 @@ const DOMException = @import("exceptions.zig").DOMException;
 pub const Element = struct {
     pub const Self = parser.Element;
     pub const prototype = *Node;
-    pub const mem_guarantied = true;
 
     pub const DOMRect = struct {
         x: f64,
@@ -106,8 +100,8 @@ pub const Element = struct {
         return try parser.nodeGetAttributes(parser.elementToNode(self));
     }
 
-    pub fn get_innerHTML(self: *parser.Element, alloc: std.mem.Allocator) ![]const u8 {
-        var buf = std.ArrayList(u8).init(alloc);
+    pub fn get_innerHTML(self: *parser.Element, state: *SessionState) ![]const u8 {
+        var buf = std.ArrayList(u8).init(state.arena);
         defer buf.deinit();
 
         try dump.writeChildren(parser.elementToNode(self), buf.writer());
@@ -116,8 +110,8 @@ pub const Element = struct {
         return buf.toOwnedSlice();
     }
 
-    pub fn get_outerHTML(self: *parser.Element, alloc: std.mem.Allocator) ![]const u8 {
-        var buf = std.ArrayList(u8).init(alloc);
+    pub fn get_outerHTML(self: *parser.Element, state: *SessionState) ![]const u8 {
+        var buf = std.ArrayList(u8).init(state.arena);
         defer buf.deinit();
 
         try dump.writeNode(parser.elementToNode(self), buf.writer());
@@ -232,11 +226,11 @@ pub const Element = struct {
 
     pub fn _getElementsByTagName(
         self: *parser.Element,
-        alloc: std.mem.Allocator,
+        state: *SessionState,
         tag_name: []const u8,
     ) !collection.HTMLCollection {
         return try collection.HTMLCollectionByTagName(
-            alloc,
+            state.arena,
             parser.elementToNode(self),
             tag_name,
             false,
@@ -245,11 +239,11 @@ pub const Element = struct {
 
     pub fn _getElementsByClassName(
         self: *parser.Element,
-        alloc: std.mem.Allocator,
+        state: *SessionState,
         classNames: []const u8,
     ) !collection.HTMLCollection {
         return try collection.HTMLCollectionByClassName(
-            alloc,
+            state.arena,
             parser.elementToNode(self),
             classNames,
             false,
@@ -312,51 +306,51 @@ pub const Element = struct {
         }
     }
 
-    pub fn _querySelector(self: *parser.Element, alloc: std.mem.Allocator, selector: []const u8) !?Union {
+    pub fn _querySelector(self: *parser.Element, state: *SessionState, selector: []const u8) !?Union {
         if (selector.len == 0) return null;
 
-        const n = try css.querySelector(alloc, parser.elementToNode(self), selector);
+        const n = try css.querySelector(state.arena, parser.elementToNode(self), selector);
 
         if (n == null) return null;
 
         return try toInterface(parser.nodeToElement(n.?));
     }
 
-    pub fn _querySelectorAll(self: *parser.Element, alloc: std.mem.Allocator, selector: []const u8) !NodeList {
-        return css.querySelectorAll(alloc, parser.elementToNode(self), selector);
+    pub fn _querySelectorAll(self: *parser.Element, state: *SessionState, selector: []const u8) !NodeList {
+        return css.querySelectorAll(state.arena, parser.elementToNode(self), selector);
     }
 
     // TODO according with https://dom.spec.whatwg.org/#parentnode, the
     // function must accept either node or string.
     // blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
-    pub fn _prepend(self: *parser.Element, nodes: ?Variadic(*parser.Node)) !void {
+    pub fn _prepend(self: *parser.Element, nodes: []const *parser.Node) !void {
         return Node.prepend(parser.elementToNode(self), nodes);
     }
 
     // TODO according with https://dom.spec.whatwg.org/#parentnode, the
     // function must accept either node or string.
     // blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
-    pub fn _append(self: *parser.Element, nodes: ?Variadic(*parser.Node)) !void {
+    pub fn _append(self: *parser.Element, nodes: []const *parser.Node) !void {
         return Node.append(parser.elementToNode(self), nodes);
     }
 
     // TODO according with https://dom.spec.whatwg.org/#parentnode, the
     // function must accept either node or string.
     // blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
-    pub fn _replaceChildren(self: *parser.Element, nodes: ?Variadic(*parser.Node)) !void {
+    pub fn _replaceChildren(self: *parser.Element, nodes: []const *parser.Node) !void {
         return Node.replaceChildren(parser.elementToNode(self), nodes);
     }
 
-    pub fn _getBoundingClientRect(self: *parser.Element, user_context: UserContext) !DOMRect {
-        return user_context.renderer.getRect(self);
+    pub fn _getBoundingClientRect(self: *parser.Element, state: *SessionState) !DOMRect {
+        return state.renderer.getRect(self);
     }
 
-    pub fn get_clientWidth(_: *parser.Element, user_context: UserContext) u32 {
-        return user_context.renderer.width();
+    pub fn get_clientWidth(_: *parser.Element, state: *SessionState) u32 {
+        return state.renderer.width();
     }
 
-    pub fn get_clientHeight(_: *parser.Element, user_context: UserContext) u32 {
-        return user_context.renderer.height();
+    pub fn get_clientHeight(_: *parser.Element, state: *SessionState) u32 {
+        return state.renderer.height();
     }
 
     pub fn deinit(_: *parser.Element, _: std.mem.Allocator) void {}
@@ -365,172 +359,161 @@ pub const Element = struct {
 // Tests
 // -----
 
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var getters = [_]Case{
-        .{ .src = "let g = document.getElementById('content')", .ex = "undefined" },
-        .{ .src = "g.namespaceURI", .ex = "http://www.w3.org/1999/xhtml" },
-        .{ .src = "g.prefix", .ex = "null" },
-        .{ .src = "g.localName", .ex = "div" },
-        .{ .src = "g.tagName", .ex = "DIV" },
-    };
-    try checkCases(js_env, &getters);
+const testing = @import("../../testing.zig");
+test "Browser.DOM.Element" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
 
-    var gettersetters = [_]Case{
-        .{ .src = "let gs = document.getElementById('content')", .ex = "undefined" },
-        .{ .src = "gs.id", .ex = "content" },
-        .{ .src = "gs.id = 'foo'", .ex = "foo" },
-        .{ .src = "gs.id", .ex = "foo" },
-        .{ .src = "gs.id = 'content'", .ex = "content" },
-        .{ .src = "gs.className", .ex = "" },
-        .{ .src = "let gs2 = document.getElementById('para-empty')", .ex = "undefined" },
-        .{ .src = "gs2.className", .ex = "ok empty" },
-        .{ .src = "gs2.className = 'foo bar baz'", .ex = "foo bar baz" },
-        .{ .src = "gs2.className", .ex = "foo bar baz" },
-        .{ .src = "gs2.className = 'ok empty'", .ex = "ok empty" },
-        .{ .src = "let cl = gs2.classList", .ex = "undefined" },
-        .{ .src = "cl.length", .ex = "2" },
-    };
-    try checkCases(js_env, &gettersetters);
+    try runner.testCases(&.{
+        .{ "let g = document.getElementById('content')", "undefined" },
+        .{ "g.namespaceURI", "http://www.w3.org/1999/xhtml" },
+        .{ "g.prefix", "null" },
+        .{ "g.localName", "div" },
+        .{ "g.tagName", "DIV" },
+    }, .{});
 
-    var attribute = [_]Case{
-        .{ .src = "let a = document.getElementById('content')", .ex = "undefined" },
-        .{ .src = "a.hasAttributes()", .ex = "true" },
-        .{ .src = "a.attributes.length", .ex = "1" },
+    try runner.testCases(&.{
+        .{ "let gs = document.getElementById('content')", "undefined" },
+        .{ "gs.id", "content" },
+        .{ "gs.id = 'foo'", "foo" },
+        .{ "gs.id", "foo" },
+        .{ "gs.id = 'content'", "content" },
+        .{ "gs.className", "" },
+        .{ "let gs2 = document.getElementById('para-empty')", "undefined" },
+        .{ "gs2.className", "ok empty" },
+        .{ "gs2.className = 'foo bar baz'", "foo bar baz" },
+        .{ "gs2.className", "foo bar baz" },
+        .{ "gs2.className = 'ok empty'", "ok empty" },
+        .{ "let cl = gs2.classList", "undefined" },
+        .{ "cl.length", "2" },
+    }, .{});
 
-        .{ .src = "a.getAttribute('id')", .ex = "content" },
+    try runner.testCases(&.{
+        .{ "let a = document.getElementById('content')", "undefined" },
+        .{ "a.hasAttributes()", "true" },
+        .{ "a.attributes.length", "1" },
 
-        .{ .src = "a.hasAttribute('foo')", .ex = "false" },
-        .{ .src = "a.getAttribute('foo')", .ex = "null" },
+        .{ "a.getAttribute('id')", "content" },
 
-        .{ .src = "a.setAttribute('foo', 'bar')", .ex = "undefined" },
-        .{ .src = "a.hasAttribute('foo')", .ex = "true" },
-        .{ .src = "a.getAttribute('foo')", .ex = "bar" },
+        .{ "a.hasAttribute('foo')", "false" },
+        .{ "a.getAttribute('foo')", "null" },
 
-        .{ .src = "a.setAttribute('foo', 'baz')", .ex = "undefined" },
-        .{ .src = "a.hasAttribute('foo')", .ex = "true" },
-        .{ .src = "a.getAttribute('foo')", .ex = "baz" },
+        .{ "a.setAttribute('foo', 'bar')", "undefined" },
+        .{ "a.hasAttribute('foo')", "true" },
+        .{ "a.getAttribute('foo')", "bar" },
 
-        .{ .src = "a.removeAttribute('foo')", .ex = "undefined" },
-        .{ .src = "a.hasAttribute('foo')", .ex = "false" },
-        .{ .src = "a.getAttribute('foo')", .ex = "null" },
-    };
-    try checkCases(js_env, &attribute);
+        .{ "a.setAttribute('foo', 'baz')", "undefined" },
+        .{ "a.hasAttribute('foo')", "true" },
+        .{ "a.getAttribute('foo')", "baz" },
 
-    var toggleAttr = [_]Case{
-        .{ .src = "let b = document.getElementById('content')", .ex = "undefined" },
-        .{ .src = "b.toggleAttribute('foo')", .ex = "true" },
-        .{ .src = "b.hasAttribute('foo')", .ex = "true" },
-        .{ .src = "b.getAttribute('foo')", .ex = "" },
+        .{ "a.removeAttribute('foo')", "undefined" },
+        .{ "a.hasAttribute('foo')", "false" },
+        .{ "a.getAttribute('foo')", "null" },
+    }, .{});
 
-        .{ .src = "b.toggleAttribute('foo')", .ex = "false" },
-        .{ .src = "b.hasAttribute('foo')", .ex = "false" },
-    };
-    try checkCases(js_env, &toggleAttr);
+    try runner.testCases(&.{
+        .{ "let b = document.getElementById('content')", "undefined" },
+        .{ "b.toggleAttribute('foo')", "true" },
+        .{ "b.hasAttribute('foo')", "true" },
+        .{ "b.getAttribute('foo')", "" },
 
-    var parentNode = [_]Case{
-        .{ .src = "let c = document.getElementById('content')", .ex = "undefined" },
-        .{ .src = "c.children.length", .ex = "3" },
-        .{ .src = "c.firstElementChild.nodeName", .ex = "A" },
-        .{ .src = "c.lastElementChild.nodeName", .ex = "P" },
-        .{ .src = "c.childElementCount", .ex = "3" },
+        .{ "b.toggleAttribute('foo')", "false" },
+        .{ "b.hasAttribute('foo')", "false" },
+    }, .{});
 
-        .{ .src = "c.prepend(document.createTextNode('foo'))", .ex = "undefined" },
-        .{ .src = "c.append(document.createTextNode('bar'))", .ex = "undefined" },
-    };
-    try checkCases(js_env, &parentNode);
+    try runner.testCases(&.{
+        .{ "let c = document.getElementById('content')", "undefined" },
+        .{ "c.children.length", "3" },
+        .{ "c.firstElementChild.nodeName", "A" },
+        .{ "c.lastElementChild.nodeName", "P" },
+        .{ "c.childElementCount", "3" },
 
-    var elementSibling = [_]Case{
-        .{ .src = "let d = document.getElementById('para')", .ex = "undefined" },
-        .{ .src = "d.previousElementSibling.nodeName", .ex = "P" },
-        .{ .src = "d.nextElementSibling", .ex = "null" },
-    };
-    try checkCases(js_env, &elementSibling);
+        .{ "c.prepend(document.createTextNode('foo'))", "undefined" },
+        .{ "c.append(document.createTextNode('bar'))", "undefined" },
+    }, .{});
 
-    var querySelector = [_]Case{
-        .{ .src = "let e = document.getElementById('content')", .ex = "undefined" },
-        .{ .src = "e.querySelector('foo')", .ex = "null" },
-        .{ .src = "e.querySelector('#foo')", .ex = "null" },
-        .{ .src = "e.querySelector('#link').id", .ex = "link" },
-        .{ .src = "e.querySelector('#para').id", .ex = "para" },
-        .{ .src = "e.querySelector('*').id", .ex = "link" },
-        .{ .src = "e.querySelector('')", .ex = "null" },
-        .{ .src = "e.querySelector('*').id", .ex = "link" },
-        .{ .src = "e.querySelector('#content')", .ex = "null" },
-        .{ .src = "e.querySelector('#para').id", .ex = "para" },
-        .{ .src = "e.querySelector('.ok').id", .ex = "link" },
-        .{ .src = "e.querySelector('a ~ p').id", .ex = "para-empty" },
+    try runner.testCases(&.{
+        .{ "let d = document.getElementById('para')", "undefined" },
+        .{ "d.previousElementSibling.nodeName", "P" },
+        .{ "d.nextElementSibling", "null" },
+    }, .{});
 
-        .{ .src = "e.querySelectorAll('foo').length", .ex = "0" },
-        .{ .src = "e.querySelectorAll('#foo').length", .ex = "0" },
-        .{ .src = "e.querySelectorAll('#link').length", .ex = "1" },
-        .{ .src = "e.querySelectorAll('#link').item(0).id", .ex = "link" },
-        .{ .src = "e.querySelectorAll('#para').length", .ex = "1" },
-        .{ .src = "e.querySelectorAll('#para').item(0).id", .ex = "para" },
-        .{ .src = "e.querySelectorAll('*').length", .ex = "4" },
-        .{ .src = "e.querySelectorAll('p').length", .ex = "2" },
-        .{ .src = "e.querySelectorAll('.ok').item(0).id", .ex = "link" },
-    };
-    try checkCases(js_env, &querySelector);
+    try runner.testCases(&.{
+        .{ "let e = document.getElementById('content')", "undefined" },
+        .{ "e.querySelector('foo')", "null" },
+        .{ "e.querySelector('#foo')", "null" },
+        .{ "e.querySelector('#link').id", "link" },
+        .{ "e.querySelector('#para').id", "para" },
+        .{ "e.querySelector('*').id", "link" },
+        .{ "e.querySelector('')", "null" },
+        .{ "e.querySelector('*').id", "link" },
+        .{ "e.querySelector('#content')", "null" },
+        .{ "e.querySelector('#para').id", "para" },
+        .{ "e.querySelector('.ok').id", "link" },
+        .{ "e.querySelector('a ~ p').id", "para-empty" },
 
-    var attrNode = [_]Case{
-        .{ .src = "let f = document.getElementById('content')", .ex = "undefined" },
-        .{ .src = "let ff = document.createAttribute('foo')", .ex = "undefined" },
-        .{ .src = "f.setAttributeNode(ff)", .ex = "null" },
-        .{ .src = "f.getAttributeNode('foo').name", .ex = "foo" },
-        .{ .src = "f.removeAttributeNode(ff).name", .ex = "foo" },
-        .{ .src = "f.getAttributeNode('bar')", .ex = "null" },
-    };
-    try checkCases(js_env, &attrNode);
+        .{ "e.querySelectorAll('foo').length", "0" },
+        .{ "e.querySelectorAll('#foo').length", "0" },
+        .{ "e.querySelectorAll('#link').length", "1" },
+        .{ "e.querySelectorAll('#link').item(0).id", "link" },
+        .{ "e.querySelectorAll('#para').length", "1" },
+        .{ "e.querySelectorAll('#para').item(0).id", "para" },
+        .{ "e.querySelectorAll('*').length", "4" },
+        .{ "e.querySelectorAll('p').length", "2" },
+        .{ "e.querySelectorAll('.ok').item(0).id", "link" },
+    }, .{});
 
-    var innerHTML = [_]Case{
-        .{ .src = "document.getElementById('para').innerHTML", .ex = " And" },
-        .{ .src = "document.getElementById('para-empty').innerHTML.trim()", .ex = "" },
+    try runner.testCases(&.{
+        .{ "let f = document.getElementById('content')", "undefined" },
+        .{ "let ff = document.createAttribute('foo')", "undefined" },
+        .{ "f.setAttributeNode(ff)", "null" },
+        .{ "f.getAttributeNode('foo').name", "foo" },
+        .{ "f.removeAttributeNode(ff).name", "foo" },
+        .{ "f.getAttributeNode('bar')", "null" },
+    }, .{});
 
-        .{ .src = "let h = document.getElementById('para-empty')", .ex = "undefined" },
-        .{ .src = "const prev = h.innerHTML", .ex = "undefined" },
-        .{ .src = "h.innerHTML = 'hello world
'", .ex = "hello world
" },
-        .{ .src = "h.innerHTML", .ex = "hello world
" },
-        .{ .src = "h.firstChild.nodeName", .ex = "P" },
-        .{ .src = "h.firstChild.id", .ex = "hello" },
-        .{ .src = "h.firstChild.textContent", .ex = "hello world" },
-        .{ .src = "h.innerHTML = prev; true", .ex = "true" },
-        .{ .src = "document.getElementById('para-empty').innerHTML.trim()", .ex = "" },
-    };
-    try checkCases(js_env, &innerHTML);
+    try runner.testCases(&.{
+        .{ "document.getElementById('para').innerHTML", " And" },
+        .{ "document.getElementById('para-empty').innerHTML.trim()", "" },
 
-    var outerHTML = [_]Case{
-        .{ .src = "document.getElementById('para').outerHTML", .ex = " And
" },
-    };
+        .{ "let h = document.getElementById('para-empty')", "undefined" },
+        .{ "const prev = h.innerHTML", "undefined" },
+        .{ "h.innerHTML = 'hello world
'", "hello world
" },
+        .{ "h.innerHTML", "hello world
" },
+        .{ "h.firstChild.nodeName", "P" },
+        .{ "h.firstChild.id", "hello" },
+        .{ "h.firstChild.textContent", "hello world" },
+        .{ "h.innerHTML = prev; true", "true" },
+        .{ "document.getElementById('para-empty').innerHTML.trim()", "" },
+    }, .{});
 
-    var getBoundingClientRect = [_]Case{
-        .{ .src = "document.getElementById('para').clientWidth", .ex = "0" },
-        .{ .src = "document.getElementById('para').clientHeight", .ex = "1" },
+    try runner.testCases(&.{
+        .{ "document.getElementById('para').outerHTML", " And
" },
+    }, .{});
 
-        .{ .src = "let r1 = document.getElementById('para').getBoundingClientRect()", .ex = "undefined" },
-        .{ .src = "r1.x", .ex = "1" },
-        .{ .src = "r1.y", .ex = "0" },
-        .{ .src = "r1.width", .ex = "1" },
-        .{ .src = "r1.height", .ex = "1" },
+    try runner.testCases(&.{
+        .{ "document.getElementById('para').clientWidth", "0" },
+        .{ "document.getElementById('para').clientHeight", "1" },
 
-        .{ .src = "let r2 = document.getElementById('content').getBoundingClientRect()", .ex = "undefined" },
-        .{ .src = "r2.x", .ex = "2" },
-        .{ .src = "r2.y", .ex = "0" },
-        .{ .src = "r2.width", .ex = "1" },
-        .{ .src = "r2.height", .ex = "1" },
+        .{ "let r1 = document.getElementById('para').getBoundingClientRect()", "undefined" },
+        .{ "r1.x", "1" },
+        .{ "r1.y", "0" },
+        .{ "r1.width", "1" },
+        .{ "r1.height", "1" },
 
-        .{ .src = "let r3 = document.getElementById('para').getBoundingClientRect()", .ex = "undefined" },
-        .{ .src = "r3.x", .ex = "1" },
-        .{ .src = "r3.y", .ex = "0" },
-        .{ .src = "r3.width", .ex = "1" },
-        .{ .src = "r3.height", .ex = "1" },
+        .{ "let r2 = document.getElementById('content').getBoundingClientRect()", "undefined" },
+        .{ "r2.x", "2" },
+        .{ "r2.y", "0" },
+        .{ "r2.width", "1" },
+        .{ "r2.height", "1" },
 
-        .{ .src = "document.getElementById('para').clientWidth", .ex = "2" },
-        .{ .src = "document.getElementById('para').clientHeight", .ex = "1" },
-    };
-    try checkCases(js_env, &getBoundingClientRect);
+        .{ "let r3 = document.getElementById('para').getBoundingClientRect()", "undefined" },
+        .{ "r3.x", "1" },
+        .{ "r3.y", "0" },
+        .{ "r3.width", "1" },
+        .{ "r3.height", "1" },
 
-    try checkCases(js_env, &outerHTML);
+        .{ "document.getElementById('para').clientWidth", "2" },
+        .{ "document.getElementById('para').clientHeight", "1" },
+    }, .{});
 }
diff --git a/src/browser/dom/event_target.zig b/src/browser/dom/event_target.zig
new file mode 100644
index 00000000..f77b8c6b
--- /dev/null
+++ b/src/browser/dom/event_target.zig
@@ -0,0 +1,226 @@
+// Copyright (C) 2023-2024  Lightpanda (Selecy SAS)
+//
+// Francis Bouvier 
+// Pierre Tachoire 
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see .
+
+const std = @import("std");
+
+const Env = @import("../env.zig").Env;
+const parser = @import("../netsurf.zig");
+const SessionState = @import("../env.zig").SessionState;
+
+const EventHandler = @import("../events/event.zig").EventHandler;
+
+const DOMException = @import("exceptions.zig").DOMException;
+const Nod = @import("node.zig");
+
+// EventTarget interfaces
+pub const Union = Nod.Union;
+
+// EventTarget implementation
+pub const EventTarget = struct {
+    pub const Self = parser.EventTarget;
+    pub const Exception = DOMException;
+
+    pub fn toInterface(et: *parser.EventTarget) !Union {
+        // NOTE: for now we state that all EventTarget are Nodes
+        // TODO: handle other types (eg. Window)
+        return Nod.Node.toInterface(@as(*parser.Node, @ptrCast(et)));
+    }
+
+    // JS funcs
+    // --------
+
+    pub fn _addEventListener(
+        self: *parser.EventTarget,
+        state: *SessionState,
+        eventType: []const u8,
+        cbk: Env.Callback,
+        capture: ?bool,
+        // TODO: hanle EventListenerOptions
+        // see #https://github.com/lightpanda-io/jsruntime-lib/issues/114
+    ) !void {
+        // check if event target has already this listener
+        const lst = try parser.eventTargetHasListener(
+            self,
+            eventType,
+            capture orelse false,
+            cbk.id,
+        );
+        if (lst != null) {
+            return;
+        }
+
+        try parser.eventTargetAddEventListener(
+            self,
+            state.arena,
+            eventType,
+            EventHandler,
+            .{ .cbk = cbk },
+            capture orelse false,
+        );
+    }
+
+    pub fn _removeEventListener(
+        self: *parser.EventTarget,
+        state: *SessionState,
+        eventType: []const u8,
+        cbk: Env.Callback,
+        capture: ?bool,
+        // TODO: hanle EventListenerOptions
+        // see #https://github.com/lightpanda-io/jsruntime-lib/issues/114
+    ) !void {
+        // check if event target has already this listener
+        const lst = try parser.eventTargetHasListener(
+            self,
+            eventType,
+            capture orelse false,
+            cbk.id,
+        );
+        if (lst == null) {
+            return;
+        }
+
+        // remove listener
+        try parser.eventTargetRemoveEventListener(
+            self,
+            state.arena,
+            eventType,
+            lst.?,
+            capture orelse false,
+        );
+    }
+
+    pub fn _dispatchEvent(self: *parser.EventTarget, event: *parser.Event) !bool {
+        return try parser.eventTargetDispatchEvent(self, event);
+    }
+
+    pub fn deinit(self: *parser.EventTarget, state: *SessionState) void {
+        parser.eventTargetRemoveAllEventListeners(self, state.arena) catch unreachable;
+    }
+};
+
+const testing = @import("../../testing.zig");
+test "Browser.DOM.EventTarget" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
+
+    try runner.testCases(&.{
+        .{ "let content = document.getElementById('content')", "undefined" },
+        .{ "let para = document.getElementById('para')", "undefined" },
+        // NOTE: as some event properties will change during the event dispatching phases
+        // we need to copy thoses values in order to check them afterwards
+        .{
+            \\ var nb = 0; var evt; var phase; var cur;
+            \\ function cbk(event) {
+            \\   evt = event;
+            \\   phase = event.eventPhase;
+            \\   cur = event.currentTarget;
+            \\   nb ++;
+            \\ }
+            ,
+            "undefined",
+        },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "content.addEventListener('basic', cbk)", "undefined" },
+        .{ "content.dispatchEvent(new Event('basic'))", "true" },
+        .{ "nb", "1" },
+        .{ "evt instanceof Event", "true" },
+        .{ "evt.type", "basic" },
+        .{ "phase", "2" },
+        .{ "cur.getAttribute('id')", "content" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "nb = 0; evt = undefined; phase = undefined; cur = undefined", "undefined" },
+        .{ "para.dispatchEvent(new Event('basic'))", "true" },
+        .{ "nb", "0" }, // handler is not called, no capture, not the target, no bubbling
+        .{ "evt === undefined", "true" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "nb  = 0", "0" },
+        .{ "content.addEventListener('basic', cbk)", "undefined" },
+        .{ "content.dispatchEvent(new Event('basic'))", "true" },
+        .{ "nb", "1" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "nb  = 0", "0" },
+        .{ "content.addEventListener('basic', cbk, true)", "undefined" },
+        .{ "content.dispatchEvent(new Event('basic'))", "true" },
+        .{ "nb", "2" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "nb  = 0", "0" },
+        .{ "content.removeEventListener('basic', cbk)", "undefined" },
+        .{ "content.dispatchEvent(new Event('basic'))", "true" },
+        .{ "nb", "1" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "nb  = 0", "0" },
+        .{ "content.removeEventListener('basic', cbk, true)", "undefined" },
+        .{ "content.dispatchEvent(new Event('basic'))", "true" },
+        .{ "nb", "0" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "nb = 0; evt = undefined; phase = undefined; cur = undefined", "undefined" },
+        .{ "content.addEventListener('capture', cbk, true)", "undefined" },
+        .{ "content.dispatchEvent(new Event('capture'))", "true" },
+        .{ "nb", "1" },
+        .{ "evt instanceof Event", "true" },
+        .{ "evt.type", "capture" },
+        .{ "phase", "2" },
+        .{ "cur.getAttribute('id')", "content" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "nb = 0; evt = undefined; phase = undefined; cur = undefined", "undefined" },
+        .{ "para.dispatchEvent(new Event('capture'))", "true" },
+        .{ "nb", "1" },
+        .{ "evt instanceof Event", "true" },
+        .{ "evt.type", "capture" },
+        .{ "phase", "1" },
+        .{ "cur.getAttribute('id')", "content" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "nb = 0; evt = undefined; phase = undefined; cur = undefined", "undefined" },
+        .{ "content.addEventListener('bubbles', cbk)", "undefined" },
+        .{ "content.dispatchEvent(new Event('bubbles', {bubbles: true}))", "true" },
+        .{ "nb", "1" },
+        .{ "evt instanceof Event", "true" },
+        .{ "evt.type", "bubbles" },
+        .{ "evt.bubbles", "true" },
+        .{ "phase", "2" },
+        .{ "cur.getAttribute('id')", "content" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "nb = 0; evt = undefined; phase = undefined; cur = undefined", "undefined" },
+        .{ "para.dispatchEvent(new Event('bubbles', {bubbles: true}))", "true" },
+        .{ "nb", "1" },
+        .{ "evt instanceof Event", "true" },
+        .{ "evt.type", "bubbles" },
+        .{ "phase", "3" },
+        .{ "cur.getAttribute('id')", "content" },
+    }, .{});
+}
diff --git a/src/dom/exceptions.zig b/src/browser/dom/exceptions.zig
similarity index 81%
rename from src/dom/exceptions.zig
rename to src/browser/dom/exceptions.zig
index 209f4345..d35aa342 100644
--- a/src/dom/exceptions.zig
+++ b/src/browser/dom/exceptions.zig
@@ -19,19 +19,13 @@
 const std = @import("std");
 const allocPrint = std.fmt.allocPrint;
 
-const jsruntime = @import("jsruntime");
-const Case = jsruntime.test_utils.Case;
-const checkCases = jsruntime.test_utils.checkCases;
-
-const parser = @import("netsurf");
+const parser = @import("../netsurf.zig");
 
 // https://webidl.spec.whatwg.org/#idl-DOMException
 pub const DOMException = struct {
     err: parser.DOMError,
     str: []const u8,
 
-    pub const mem_guarantied = true;
-
     pub const ErrorSet = parser.DOMError;
 
     // static attributes
@@ -62,7 +56,7 @@ pub const DOMException = struct {
     pub const _DATA_CLONE_ERR = 25;
 
     // TODO: deinit
-    pub fn init(alloc: std.mem.Allocator, err: anyerror, callerName: []const u8) anyerror!DOMException {
+    pub fn init(alloc: std.mem.Allocator, err: anyerror, callerName: []const u8) !DOMException {
         const errCast = @as(parser.DOMError, @errorCast(err));
         const errName = DOMException.name(errCast);
         const str = switch (errCast) {
@@ -120,7 +114,7 @@ pub const DOMException = struct {
 
     // JS properties and methods
 
-    pub fn get_code(self: DOMException) u8 {
+    pub fn get_code(self: *const DOMException) u8 {
         return switch (self.err) {
             error.IndexSize => 1,
             error.StringSize => 2,
@@ -157,38 +151,41 @@ pub const DOMException = struct {
         };
     }
 
-    pub fn get_name(self: DOMException) []const u8 {
+    pub fn get_name(self: *const DOMException) []const u8 {
         return DOMException.name(self.err);
     }
 
-    pub fn get_message(self: DOMException) []const u8 {
+    pub fn get_message(self: *const DOMException) []const u8 {
         const errName = DOMException.name(self.err);
         return self.str[errName.len + 2 ..];
     }
 
-    pub fn _toString(self: DOMException) []const u8 {
+    pub fn _toString(self: *const DOMException) []const u8 {
         return self.str;
     }
 };
 
-// Tests
-// -----
+const testing = @import("../../testing.zig");
+test "Browser.DOM.Exception" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
 
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
     const err = "Failed to execute 'appendChild' on 'Node': The new child element contains the parent.";
-    var cases = [_]Case{
-        .{ .src = "let content = document.getElementById('content')", .ex = "undefined" },
-        .{ .src = "let link = document.getElementById('link')", .ex = "undefined" },
+    try runner.testCases(&.{
+        .{ "let content = document.getElementById('content')", "undefined" },
+        .{ "let link = document.getElementById('link')", "undefined" },
         // HierarchyRequestError
-        .{ .src = "var HierarchyRequestError; try {link.appendChild(content)} catch (error) {HierarchyRequestError = error} HierarchyRequestError.name", .ex = "HierarchyRequestError" },
-        .{ .src = "HierarchyRequestError.code", .ex = "3" },
-        .{ .src = "HierarchyRequestError.message", .ex = err },
-        .{ .src = "HierarchyRequestError.toString()", .ex = "HierarchyRequestError: " ++ err },
-        .{ .src = "HierarchyRequestError instanceof DOMException", .ex = "true" },
-        .{ .src = "HierarchyRequestError instanceof Error", .ex = "true" },
-    };
-    try checkCases(js_env, &cases);
+        .{
+            \\ var he;
+            \\ try { link.appendChild(content) } catch (error) { he = error}
+            \\ he.name
+            ,
+            "HierarchyRequestError",
+        },
+        .{ "he.code", "3" },
+        .{ "he.message", err },
+        .{ "he.toString()", "HierarchyRequestError: " ++ err },
+        .{ "he instanceof DOMException", "true" },
+        .{ "he instanceof Error", "true" },
+    }, .{});
 }
diff --git a/src/dom/html_collection.zig b/src/browser/dom/html_collection.zig
similarity index 77%
rename from src/dom/html_collection.zig
rename to src/browser/dom/html_collection.zig
index 298d6b72..39572b09 100644
--- a/src/dom/html_collection.zig
+++ b/src/browser/dom/html_collection.zig
@@ -18,12 +18,7 @@
 
 const std = @import("std");
 
-const parser = @import("netsurf");
-
-const jsruntime = @import("jsruntime");
-const Case = jsruntime.test_utils.Case;
-const checkCases = jsruntime.test_utils.checkCases;
-const generate = @import("../generate.zig");
+const parser = @import("../netsurf.zig");
 
 const utils = @import("utils.z");
 const Element = @import("element.zig").Element;
@@ -279,8 +274,6 @@ pub fn HTMLCollectionByAnchors(
 }
 
 pub const HTMLCollectionIterator = struct {
-    pub const mem_guarantied = true;
-
     coll: *HTMLCollection,
     index: u32 = 0,
 
@@ -311,8 +304,6 @@ pub const HTMLCollectionIterator = struct {
 // dom_html_collection expects a comparison function callback as arguement.
 // But we wanted a dynamically comparison here, according to the match tagname.
 pub const HTMLCollection = struct {
-    pub const mem_guarantied = true;
-
     matcher: Matcher,
     walker: Walker,
 
@@ -412,7 +403,14 @@ pub const HTMLCollection = struct {
         return try Element.toInterface(e);
     }
 
-    pub fn _namedItem(self: *HTMLCollection, name: []const u8) !?Union {
+    pub fn indexed_get(self: *HTMLCollection, index: u32, has_value: *bool) !?Union {
+        return (try self._item(index)) orelse {
+            has_value.* = false;
+            return null;
+        };
+    }
+
+    pub fn _namedItem(self: *const HTMLCollection, name: []const u8) !?Union {
         if (self.root == null) return null;
         if (name.len == 0) return null;
 
@@ -443,6 +441,13 @@ pub const HTMLCollection = struct {
         return null;
     }
 
+    pub fn named_get(self: *HTMLCollection, name: []const u8, has_value: *bool) !?Union {
+        return (try self._namedItem(name)) orelse {
+            has_value.* = false;
+            return null;
+        };
+    }
+
     fn item_name(elt: *parser.Element) !?[]const u8 {
         if (try parser.elementGetAttribute(elt, "id")) |v| {
             return v;
@@ -454,24 +459,6 @@ pub const HTMLCollection = struct {
         return null;
     }
 
-    pub fn postAttach(self: *HTMLCollection, alloc: std.mem.Allocator, js_obj: jsruntime.JSObject) !void {
-        const ln = try self.get_length();
-        var i: u32 = 0;
-        while (i < ln) {
-            defer i += 1;
-            const k = try std.fmt.allocPrint(alloc, "{d}", .{i});
-            try self.array_like_keys.append(alloc, k);
-
-            const node = try self.item(i) orelse unreachable;
-            const e = @as(*parser.Element, @ptrCast(node));
-            try js_obj.set(k, e);
-
-            if (try item_name(e)) |name| {
-                try js_obj.set(name, e);
-            }
-        }
-    }
-
     pub fn deinit(self: *HTMLCollection, alloc: std.mem.Allocator) void {
         for (self.array_like_keys_) |k| alloc.free(k);
         self.array_like_keys.deinit(alloc);
@@ -479,56 +466,53 @@ pub const HTMLCollection = struct {
     }
 };
 
-// Tests
-// -----
+const testing = @import("../../testing.zig");
+test "Browser.DOM.HTMLCollection" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
 
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var getElementsByTagName = [_]Case{
-        .{ .src = "let getElementsByTagName = document.getElementsByTagName('p')", .ex = "undefined" },
-        .{ .src = "getElementsByTagName.length", .ex = "2" },
-        .{ .src = "let getElementsByTagNameCI = document.getElementsByTagName('P')", .ex = "undefined" },
-        .{ .src = "getElementsByTagNameCI.length", .ex = "2" },
-        .{ .src = "getElementsByTagName.item(0).localName", .ex = "p" },
-        .{ .src = "getElementsByTagName.item(1).localName", .ex = "p" },
-        .{ .src = "let getElementsByTagNameAll = document.getElementsByTagName('*')", .ex = "undefined" },
-        .{ .src = "getElementsByTagNameAll.length", .ex = "8" },
-        .{ .src = "getElementsByTagNameAll.item(0).localName", .ex = "html" },
-        .{ .src = "getElementsByTagNameAll.item(0).localName", .ex = "html" },
-        .{ .src = "getElementsByTagNameAll.item(1).localName", .ex = "head" },
-        .{ .src = "getElementsByTagNameAll.item(0).localName", .ex = "html" },
-        .{ .src = "getElementsByTagNameAll.item(2).localName", .ex = "body" },
-        .{ .src = "getElementsByTagNameAll.item(3).localName", .ex = "div" },
-        .{ .src = "getElementsByTagNameAll.item(7).localName", .ex = "p" },
-        .{ .src = "getElementsByTagNameAll.namedItem('para-empty-child').localName", .ex = "span" },
+    try runner.testCases(&.{
+        .{ "let getElementsByTagName = document.getElementsByTagName('p')", "undefined" },
+        .{ "getElementsByTagName.length", "2" },
+        .{ "let getElementsByTagNameCI = document.getElementsByTagName('P')", "undefined" },
+        .{ "getElementsByTagNameCI.length", "2" },
+        .{ "getElementsByTagName.item(0).localName", "p" },
+        .{ "getElementsByTagName.item(1).localName", "p" },
+        .{ "let getElementsByTagNameAll = document.getElementsByTagName('*')", "undefined" },
+        .{ "getElementsByTagNameAll.length", "8" },
+        .{ "getElementsByTagNameAll.item(0).localName", "html" },
+        .{ "getElementsByTagNameAll.item(0).localName", "html" },
+        .{ "getElementsByTagNameAll.item(1).localName", "head" },
+        .{ "getElementsByTagNameAll.item(0).localName", "html" },
+        .{ "getElementsByTagNameAll.item(2).localName", "body" },
+        .{ "getElementsByTagNameAll.item(3).localName", "div" },
+        .{ "getElementsByTagNameAll.item(7).localName", "p" },
+        .{ "getElementsByTagNameAll.namedItem('para-empty-child').localName", "span" },
 
         // array like
-        .{ .src = "getElementsByTagNameAll[0].localName", .ex = "html" },
-        .{ .src = "getElementsByTagNameAll[7].localName", .ex = "p" },
-        .{ .src = "getElementsByTagNameAll[8]", .ex = "undefined" },
-        .{ .src = "getElementsByTagNameAll['para-empty-child'].localName", .ex = "span" },
-        .{ .src = "getElementsByTagNameAll['foo']", .ex = "undefined" },
+        .{ "getElementsByTagNameAll[0].localName", "html" },
+        .{ "getElementsByTagNameAll[7].localName", "p" },
+        .{ "getElementsByTagNameAll[8]", "undefined" },
+        .{ "getElementsByTagNameAll['para-empty-child'].localName", "span" },
+        .{ "getElementsByTagNameAll['foo']", "undefined" },
 
-        .{ .src = "document.getElementById('content').getElementsByTagName('*').length", .ex = "4" },
-        .{ .src = "document.getElementById('content').getElementsByTagName('p').length", .ex = "2" },
-        .{ .src = "document.getElementById('content').getElementsByTagName('div').length", .ex = "0" },
+        .{ "document.getElementById('content').getElementsByTagName('*').length", "4" },
+        .{ "document.getElementById('content').getElementsByTagName('p').length", "2" },
+        .{ "document.getElementById('content').getElementsByTagName('div').length", "0" },
 
-        .{ .src = "document.children.length", .ex = "1" },
-        .{ .src = "document.getElementById('content').children.length", .ex = "3" },
+        .{ "document.children.length", "1" },
+        .{ "document.getElementById('content').children.length", "3" },
 
         // check liveness
-        .{ .src = "let content = document.getElementById('content')", .ex = "undefined" },
-        .{ .src = "let pe = document.getElementById('para-empty')", .ex = "undefined" },
-        .{ .src = "let p = document.createElement('p')", .ex = "undefined" },
-        .{ .src = "p.textContent = 'OK live'", .ex = "OK live" },
-        .{ .src = "getElementsByTagName.item(1).textContent", .ex = " And" },
-        .{ .src = "content.appendChild(p) != undefined", .ex = "true" },
-        .{ .src = "getElementsByTagName.length", .ex = "3" },
-        .{ .src = "getElementsByTagName.item(2).textContent", .ex = "OK live" },
-        .{ .src = "content.insertBefore(p, pe) != undefined", .ex = "true" },
-        .{ .src = "getElementsByTagName.item(0).textContent", .ex = "OK live" },
-    };
-    try checkCases(js_env, &getElementsByTagName);
+        .{ "let content = document.getElementById('content')", "undefined" },
+        .{ "let pe = document.getElementById('para-empty')", "undefined" },
+        .{ "let p = document.createElement('p')", "undefined" },
+        .{ "p.textContent = 'OK live'", "OK live" },
+        .{ "getElementsByTagName.item(1).textContent", " And" },
+        .{ "content.appendChild(p) != undefined", "true" },
+        .{ "getElementsByTagName.length", "3" },
+        .{ "getElementsByTagName.item(2).textContent", "OK live" },
+        .{ "content.insertBefore(p, pe) != undefined", "true" },
+        .{ "getElementsByTagName.item(0).textContent", "OK live" },
+    }, .{});
 }
diff --git a/src/dom/implementation.zig b/src/browser/dom/implementation.zig
similarity index 61%
rename from src/dom/implementation.zig
rename to src/browser/dom/implementation.zig
index ec90014f..ee71de73 100644
--- a/src/dom/implementation.zig
+++ b/src/browser/dom/implementation.zig
@@ -18,11 +18,8 @@
 
 const std = @import("std");
 
-const parser = @import("netsurf");
-
-const jsruntime = @import("jsruntime");
-const Case = jsruntime.test_utils.Case;
-const checkCases = jsruntime.test_utils.checkCases;
+const parser = @import("../netsurf.zig");
+const SessionState = @import("../env.zig").SessionState;
 
 const Document = @import("document.zig").Document;
 const DocumentType = @import("document_type.zig").DocumentType;
@@ -30,47 +27,47 @@ const DOMException = @import("exceptions.zig").DOMException;
 
 // WEB IDL https://dom.spec.whatwg.org/#domimplementation
 pub const DOMImplementation = struct {
-    pub const mem_guarantied = true;
-
     pub const Exception = DOMException;
 
     pub fn _createDocumentType(
         _: *DOMImplementation,
-        alloc: std.mem.Allocator,
+        state: *SessionState,
         qname: []const u8,
         publicId: []const u8,
         systemId: []const u8,
     ) !*parser.DocumentType {
-        const cqname = try alloc.dupeZ(u8, qname);
-        defer alloc.free(cqname);
+        const allocator = state.arena;
+        const cqname = try allocator.dupeZ(u8, qname);
+        defer allocator.free(cqname);
 
-        const cpublicId = try alloc.dupeZ(u8, publicId);
-        defer alloc.free(cpublicId);
+        const cpublicId = try allocator.dupeZ(u8, publicId);
+        defer allocator.free(cpublicId);
 
-        const csystemId = try alloc.dupeZ(u8, systemId);
-        defer alloc.free(csystemId);
+        const csystemId = try allocator.dupeZ(u8, systemId);
+        defer allocator.free(csystemId);
 
         return try parser.domImplementationCreateDocumentType(cqname, cpublicId, csystemId);
     }
 
     pub fn _createDocument(
         _: *DOMImplementation,
-        alloc: std.mem.Allocator,
+        state: *SessionState,
         namespace: ?[]const u8,
         qname: ?[]const u8,
         doctype: ?*parser.DocumentType,
     ) !*parser.Document {
+        const allocator = state.arena;
         var cnamespace: ?[:0]const u8 = null;
         if (namespace) |ns| {
-            cnamespace = try alloc.dupeZ(u8, ns);
+            cnamespace = try allocator.dupeZ(u8, ns);
         }
-        defer if (cnamespace) |v| alloc.free(v);
+        defer if (cnamespace) |v| allocator.free(v);
 
         var cqname: ?[:0]const u8 = null;
         if (qname) |qn| {
-            cqname = try alloc.dupeZ(u8, qn);
+            cqname = try allocator.dupeZ(u8, qn);
         }
-        defer if (cqname) |v| alloc.free(v);
+        defer if (cqname) |v| allocator.free(v);
 
         return try parser.domImplementationCreateDocument(cnamespace, cqname, doctype);
     }
@@ -89,17 +86,17 @@ pub const DOMImplementation = struct {
 // Tests
 // -----
 
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var getImplementation = [_]Case{
-        .{ .src = "let impl = document.implementation", .ex = "undefined" },
-        .{ .src = "impl.createHTMLDocument();", .ex = "[object HTMLDocument]" },
-        .{ .src = "impl.createHTMLDocument('foo');", .ex = "[object HTMLDocument]" },
-        .{ .src = "impl.createDocument(null, 'foo');", .ex = "[object Document]" },
-        .{ .src = "impl.createDocumentType('foo', 'bar', 'baz')", .ex = "[object DocumentType]" },
-        .{ .src = "impl.hasFeature()", .ex = "true" },
-    };
-    try checkCases(js_env, &getImplementation);
+const testing = @import("../../testing.zig");
+test "Browser.DOM.Implementation" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
+
+    try runner.testCases(&.{
+        .{ "let impl = document.implementation", "undefined" },
+        .{ "impl.createHTMLDocument();", "[object HTMLDocument]" },
+        .{ "impl.createHTMLDocument('foo');", "[object HTMLDocument]" },
+        .{ "impl.createDocument(null, 'foo');", "[object Document]" },
+        .{ "impl.createDocumentType('foo', 'bar', 'baz')", "[object DocumentType]" },
+        .{ "impl.hasFeature()", "true" },
+    }, .{});
 }
diff --git a/src/dom/mutation_observer.zig b/src/browser/dom/mutation_observer.zig
similarity index 67%
rename from src/dom/mutation_observer.zig
rename to src/browser/dom/mutation_observer.zig
index f5003686..74f29c63 100644
--- a/src/dom/mutation_observer.zig
+++ b/src/browser/dom/mutation_observer.zig
@@ -18,14 +18,10 @@
 
 const std = @import("std");
 
-const parser = @import("netsurf");
-
-const jsruntime = @import("jsruntime");
-const Callback = jsruntime.Callback;
-const CallbackResult = jsruntime.CallbackResult;
-const Case = jsruntime.test_utils.Case;
-const checkCases = jsruntime.test_utils.checkCases;
+const parser = @import("../netsurf.zig");
+const SessionState = @import("../env.zig").SessionState;
 
+const Env = @import("../env.zig").Env;
 const NodeList = @import("nodelist.zig").NodeList;
 
 pub const Interfaces = .{
@@ -40,20 +36,18 @@ const log = std.log.scoped(.events);
 
 // WEB IDL https://dom.spec.whatwg.org/#interface-mutationobserver
 pub const MutationObserver = struct {
-    cbk: Callback,
+    cbk: Env.Callback,
     observers: Observers,
 
-    pub const mem_guarantied = true;
-
     const Observer = struct {
         node: *parser.Node,
         options: MutationObserverInit,
     };
 
     const deinitFunc = struct {
-        fn deinit(ctx: ?*anyopaque, alloc: std.mem.Allocator) void {
+        fn deinit(ctx: ?*anyopaque, allocator: std.mem.Allocator) void {
             const o: *Observer = @ptrCast(@alignCast(ctx));
-            alloc.destroy(o);
+            allocator.destroy(o);
         }
     }.deinit;
 
@@ -78,7 +72,7 @@ pub const MutationObserver = struct {
         }
     };
 
-    pub fn constructor(cbk: Callback) !MutationObserver {
+    pub fn constructor(cbk: Env.Callback) !MutationObserver {
         return MutationObserver{
             .cbk = cbk,
             .observers = .{},
@@ -90,22 +84,23 @@ pub const MutationObserver = struct {
         return opt orelse .{};
     }
 
-    pub fn _observe(self: *MutationObserver, alloc: std.mem.Allocator, node: *parser.Node, options: ?MutationObserverInit) !void {
-        const o = try alloc.create(Observer);
+    pub fn _observe(self: *MutationObserver, state: *SessionState, node: *parser.Node, options: ?MutationObserverInit) !void {
+        const arena = state.arena;
+        const o = try arena.create(Observer);
         o.* = .{
             .node = node,
             .options = resolveOptions(options),
         };
-        errdefer alloc.destroy(o);
+        errdefer arena.destroy(o);
 
         // register the new observer.
-        try self.observers.append(alloc, o);
+        try self.observers.append(arena, o);
 
         // register node's events.
         if (o.options.childList or o.options.subtree) {
             try parser.eventTargetAddEventListener(
                 parser.toEventTarget(parser.Node, node),
-                alloc,
+                arena,
                 "DOMNodeInserted",
                 EventHandler,
                 .{ .cbk = self.cbk, .data = o, .deinitFunc = deinitFunc },
@@ -113,7 +108,7 @@ pub const MutationObserver = struct {
             );
             try parser.eventTargetAddEventListener(
                 parser.toEventTarget(parser.Node, node),
-                alloc,
+                arena,
                 "DOMNodeRemoved",
                 EventHandler,
                 .{ .cbk = self.cbk, .data = o, .deinitFunc = deinitFunc },
@@ -123,7 +118,7 @@ pub const MutationObserver = struct {
         if (o.options.attr()) {
             try parser.eventTargetAddEventListener(
                 parser.toEventTarget(parser.Node, node),
-                alloc,
+                arena,
                 "DOMAttrModified",
                 EventHandler,
                 .{ .cbk = self.cbk, .data = o, .deinitFunc = deinitFunc },
@@ -133,7 +128,7 @@ pub const MutationObserver = struct {
         if (o.options.cdata()) {
             try parser.eventTargetAddEventListener(
                 parser.toEventTarget(parser.Node, node),
-                alloc,
+                arena,
                 "DOMCharacterDataModified",
                 EventHandler,
                 .{ .cbk = self.cbk, .data = o, .deinitFunc = deinitFunc },
@@ -143,7 +138,7 @@ pub const MutationObserver = struct {
         if (o.options.subtree) {
             try parser.eventTargetAddEventListener(
                 parser.toEventTarget(parser.Node, node),
-                alloc,
+                arena,
                 "DOMSubtreeModified",
                 EventHandler,
                 .{ .cbk = self.cbk, .data = o, .deinitFunc = deinitFunc },
@@ -157,14 +152,17 @@ pub const MutationObserver = struct {
         // TODO unregister listeners.
     }
 
-    pub fn deinit(self: *MutationObserver, alloc: std.mem.Allocator) void {
+    pub fn deinit(self: *MutationObserver, state: *SessionState) void {
+        const arena = state.arena;
         // TODO unregister listeners.
-        for (self.observers.items) |o| alloc.destroy(o);
-        self.observers.deinit(alloc);
+        for (self.observers.items) |o| {
+            arena.destroy(o);
+        }
+        self.observers.deinit(arena);
     }
 
     // TODO
-    pub fn _takeRecords(_: MutationObserver) ?[]const u8 {
+    pub fn _takeRecords(_: *const MutationObserver) ?[]const u8 {
         return &[_]u8{};
     }
 };
@@ -173,18 +171,17 @@ pub const MutationObserver = struct {
 pub const MutationRecords = struct {
     first: ?MutationRecord = null,
 
-    pub const mem_guarantied = true;
-
-    pub fn get_length(self: *MutationRecords) u32 {
+    pub fn get_length(self: *const MutationRecords) u32 {
         if (self.first == null) return 0;
 
         return 1;
     }
-
-    pub fn postAttach(self: *MutationRecords, js_obj: jsruntime.JSObject) !void {
-        if (self.first) |mr| {
-            try js_obj.set("0", mr);
-        }
+    pub fn indexed_get(self: *const MutationRecords, i: u32, has_value: *bool) ?MutationRecord {
+        _ = i;
+        return self.first orelse {
+            has_value.* = false;
+            return null;
+        };
     }
 };
 
@@ -199,41 +196,39 @@ pub const MutationRecord = struct {
     attributeNamespace: ?[]const u8 = null,
     oldValue: ?[]const u8 = null,
 
-    pub const mem_guarantied = true;
-
-    pub fn get_type(self: MutationRecord) []const u8 {
+    pub fn get_type(self: *const MutationRecord) []const u8 {
         return self.type;
     }
 
-    pub fn get_addedNodes(self: MutationRecord) NodeList {
+    pub fn get_addedNodes(self: *const MutationRecord) NodeList {
         return self.addedNodes;
     }
 
-    pub fn get_removedNodes(self: MutationRecord) NodeList {
+    pub fn get_removedNodes(self: *const MutationRecord) NodeList {
         return self.addedNodes;
     }
 
-    pub fn get_target(self: MutationRecord) *parser.Node {
+    pub fn get_target(self: *const MutationRecord) *parser.Node {
         return self.target;
     }
 
-    pub fn get_attributeName(self: MutationRecord) ?[]const u8 {
+    pub fn get_attributeName(self: *const MutationRecord) ?[]const u8 {
         return self.attributeName;
     }
 
-    pub fn get_attributeNamespace(self: MutationRecord) ?[]const u8 {
+    pub fn get_attributeNamespace(self: *const MutationRecord) ?[]const u8 {
         return self.attributeNamespace;
     }
 
-    pub fn get_previousSibling(self: MutationRecord) ?*parser.Node {
+    pub fn get_previousSibling(self: *const MutationRecord) ?*parser.Node {
         return self.previousSibling;
     }
 
-    pub fn get_nextSibling(self: MutationRecord) ?*parser.Node {
+    pub fn get_nextSibling(self: *const MutationRecord) ?*parser.Node {
         return self.nextSibling;
     }
 
-    pub fn get_oldValue(self: MutationRecord) ?[]const u8 {
+    pub fn get_oldValue(self: *const MutationRecord) ?[]const u8 {
         return self.oldValue;
     }
 };
@@ -283,7 +278,7 @@ const EventHandler = struct {
         const muevt = parser.eventToMutationEvent(evt.?);
 
         // TODO get the allocator by another way?
-        const alloc = data.cbk.nat_ctx.alloc;
+        const alloc = data.cbk.executor.call_arena.allocator();
 
         if (std.mem.eql(u8, t, "DOMAttrModified")) {
             mrs.first = .{
@@ -340,66 +335,63 @@ const EventHandler = struct {
             return;
         }
 
-        var res = CallbackResult.init(alloc);
-        defer res.deinit();
-
         // TODO pass MutationRecords and MutationObserver
-        data.cbk.trycall(.{mrs}, &res) catch |e| log.err("mutation event handler error: {any}", .{e});
-
-        // in case of function error, we log the result and the trace.
-        if (!res.success) {
-            log.info("mutation observer event handler error: {s}", .{res.result orelse "unknown"});
-            log.debug("{s}", .{res.stack orelse "no stack trace"});
-        }
+        var result: Env.Callback.Result = undefined;
+        data.cbk.tryCall(.{mrs}, &result) catch {
+            log.err("mutation observer callback error: {s}", .{result.exception});
+            log.debug("stack:\n{s}", .{result.stack orelse "???"});
+        };
     }
 }.handle;
 
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var constructor = [_]Case{
-        .{ .src = "new MutationObserver(() => {}).observe(document, { childList: true });", .ex = "undefined" },
-    };
-    try checkCases(js_env, &constructor);
+const testing = @import("../../testing.zig");
+test "Browser.DOM.MutationObserver" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
 
-    var attr = [_]Case{
-        .{ .src = 
-        \\var nb = 0;
-        \\var mrs;
-        \\new MutationObserver((mu) => {
-        \\    mrs = mu;
-        \\    nb++;
-        \\}).observe(document.firstElementChild, { attributes: true, attributeOldValue: true });
-        \\document.firstElementChild.setAttribute("foo", "bar");
-        \\// ignored b/c it's about another target.
-        \\document.firstElementChild.firstChild.setAttribute("foo", "bar");
-        \\nb;
-        , .ex = "1" },
-        .{ .src = "mrs[0].type", .ex = "attributes" },
-        .{ .src = "mrs[0].target == document.firstElementChild", .ex = "true" },
-        .{ .src = "mrs[0].target.getAttribute('foo')", .ex = "bar" },
-        .{ .src = "mrs[0].attributeName", .ex = "foo" },
-        .{ .src = "mrs[0].oldValue", .ex = "null" },
-    };
-    try checkCases(js_env, &attr);
+    try runner.testCases(&.{
+        .{ "new MutationObserver(() => {}).observe(document, { childList: true });", "undefined" },
+    }, .{});
 
-    var cdata = [_]Case{
-        .{ .src = 
-        \\var node = document.getElementById("para").firstChild;
-        \\var nb2 = 0;
-        \\var mrs2;
-        \\new MutationObserver((mu) => {
-        \\    mrs2 = mu;
-        \\    nb2++;
-        \\}).observe(node, { characterData: true, characterDataOldValue: true });
-        \\node.data = "foo";
-        \\nb2;
-        , .ex = "1" },
-        .{ .src = "mrs2[0].type", .ex = "characterData" },
-        .{ .src = "mrs2[0].target == node", .ex = "true" },
-        .{ .src = "mrs2[0].target.data", .ex = "foo" },
-        .{ .src = "mrs2[0].oldValue", .ex = " And" },
-    };
-    try checkCases(js_env, &cdata);
+    try runner.testCases(&.{
+        .{
+            \\ var nb = 0;
+            \\ var mrs;
+            \\ new MutationObserver((mu) => {
+            \\    mrs = mu;
+            \\    nb++;
+            \\ }).observe(document.firstElementChild, { attributes: true, attributeOldValue: true });
+            \\ document.firstElementChild.setAttribute("foo", "bar");
+            \\ // ignored b/c it's about another target.
+            \\ document.firstElementChild.firstChild.setAttribute("foo", "bar");
+            \\ nb;
+            ,
+            "1",
+        },
+        .{ "mrs[0].type", "attributes" },
+        .{ "mrs[0].target == document.firstElementChild", "true" },
+        .{ "mrs[0].target.getAttribute('foo')", "bar" },
+        .{ "mrs[0].attributeName", "foo" },
+        .{ "mrs[0].oldValue", "null" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{
+            \\ var node = document.getElementById("para").firstChild;
+            \\ var nb2 = 0;
+            \\ var mrs2;
+            \\ new MutationObserver((mu) => {
+            \\     mrs2 = mu;
+            \\     nb2++;
+            \\ }).observe(node, { characterData: true, characterDataOldValue: true });
+            \\ node.data = "foo";
+            \\ nb2;
+            ,
+            "1",
+        },
+        .{ "mrs2[0].type", "characterData" },
+        .{ "mrs2[0].target == node", "true" },
+        .{ "mrs2[0].target.data", "foo" },
+        .{ "mrs2[0].oldValue", " And" },
+    }, .{});
 }
diff --git a/src/dom/namednodemap.zig b/src/browser/dom/namednodemap.zig
similarity index 76%
rename from src/dom/namednodemap.zig
rename to src/browser/dom/namednodemap.zig
index 67840659..76c1b2d3 100644
--- a/src/dom/namednodemap.zig
+++ b/src/browser/dom/namednodemap.zig
@@ -18,18 +18,13 @@
 
 const std = @import("std");
 
-const parser = @import("netsurf");
-
-const jsruntime = @import("jsruntime");
-const Case = jsruntime.test_utils.Case;
-const checkCases = jsruntime.test_utils.checkCases;
+const parser = @import("../netsurf.zig");
 
 const DOMException = @import("exceptions.zig").DOMException;
 
 // WEB IDL https://dom.spec.whatwg.org/#namednodemap
 pub const NamedNodeMap = struct {
     pub const Self = parser.NamedNodeMap;
-    pub const mem_guarantied = true;
 
     pub const Exception = DOMException;
 
@@ -80,18 +75,18 @@ pub const NamedNodeMap = struct {
 // Tests
 // -----
 
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var setItem = [_]Case{
-        .{ .src = "let a = document.getElementById('content').attributes", .ex = "undefined" },
-        .{ .src = "a.length", .ex = "1" },
-        .{ .src = "a.item(0)", .ex = "[object Attr]" },
-        .{ .src = "a.item(1)", .ex = "null" },
-        .{ .src = "a.getNamedItem('id')", .ex = "[object Attr]" },
-        .{ .src = "a.getNamedItem('foo')", .ex = "null" },
-        .{ .src = "a.setNamedItem(a.getNamedItem('id'))", .ex = "[object Attr]" },
-    };
-    try checkCases(js_env, &setItem);
+const testing = @import("../../testing.zig");
+test "Browser.DOM.NamedNodeMap" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
+
+    try runner.testCases(&.{
+        .{ "let a = document.getElementById('content').attributes", "undefined" },
+        .{ "a.length", "1" },
+        .{ "a.item(0)", "[object Attr]" },
+        .{ "a.item(1)", "null" },
+        .{ "a.getNamedItem('id')", "[object Attr]" },
+        .{ "a.getNamedItem('foo')", "null" },
+        .{ "a.setNamedItem(a.getNamedItem('id'))", "[object Attr]" },
+    }, .{});
 }
diff --git a/src/dom/node.zig b/src/browser/dom/node.zig
similarity index 55%
rename from src/dom/node.zig
rename to src/browser/dom/node.zig
index 3e0a7561..3b836dfd 100644
--- a/src/dom/node.zig
+++ b/src/browser/dom/node.zig
@@ -22,12 +22,11 @@ const jsruntime = @import("jsruntime");
 const Case = jsruntime.test_utils.Case;
 const checkCases = jsruntime.test_utils.checkCases;
 const runScript = jsruntime.test_utils.runScript;
-const Variadic = jsruntime.Variadic;
 
-const generate = @import("../generate.zig");
-
-const parser = @import("netsurf");
+const parser = @import("../netsurf.zig");
+const generate = @import("../../runtime/generate.zig");
 
+const SessionState = @import("../env.zig").SessionState;
 const EventTarget = @import("event_target.zig").EventTarget;
 
 // DOM
@@ -66,7 +65,6 @@ pub const Union = generate.Union(Interfaces);
 pub const Node = struct {
     pub const Self = parser.Node;
     pub const prototype = *EventTarget;
-    pub const mem_guarantied = true;
 
     pub fn toInterface(node: *parser.Node) !Union {
         return switch (try parser.nodeType(node)) {
@@ -262,13 +260,14 @@ pub const Node = struct {
         return try parser.nodeHasChildNodes(self);
     }
 
-    pub fn get_childNodes(self: *parser.Node, alloc: std.mem.Allocator) !NodeList {
+    pub fn get_childNodes(self: *parser.Node, state: *SessionState) !NodeList {
+        const allocator = state.arena;
         var list = NodeList.init();
-        errdefer list.deinit(alloc);
+        errdefer list.deinit(allocator);
 
         var n = try parser.nodeFirstChild(self) orelse return list;
         while (true) {
-            try list.append(alloc, n);
+            try list.append(allocator, n);
             n = try parser.nodeNextSibling(n) orelse return list;
         }
     }
@@ -327,11 +326,10 @@ pub const Node = struct {
     // For now, it checks only if new nodes are not self.
     // TODO implements the others contraints.
     // see https://dom.spec.whatwg.org/#concept-node-tree
-    pub fn hierarchy(self: *parser.Node, nodes: ?Variadic(*parser.Node)) !bool {
-        if (nodes == null) return true;
-        if (nodes.?.slice.len == 0) return true;
+    pub fn hierarchy(self: *parser.Node, nodes: []const *parser.Node) !bool {
+        if (nodes.len == 0) return true;
 
-        for (nodes.?.slice) |node| if (self == node) return false;
+        for (nodes) |node| if (self == node) return false;
 
         return true;
     }
@@ -339,22 +337,21 @@ pub const Node = struct {
     // TODO according with https://dom.spec.whatwg.org/#parentnode, the
     // function must accept either node or string.
     // blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
-    pub fn prepend(self: *parser.Node, nodes: ?Variadic(*parser.Node)) !void {
-        if (nodes == null) return;
-        if (nodes.?.slice.len == 0) return;
+    pub fn prepend(self: *parser.Node, nodes: []const *parser.Node) !void {
+        if (nodes.len == 0) return;
 
         // check hierarchy
         if (!try hierarchy(self, nodes)) return parser.DOMError.HierarchyRequest;
 
         const first = try parser.nodeFirstChild(self);
         if (first == null) {
-            for (nodes.?.slice) |node| {
+            for (nodes) |node| {
                 _ = try parser.nodeAppendChild(self, node);
             }
             return;
         }
 
-        for (nodes.?.slice) |node| {
+        for (nodes) |node| {
             _ = try parser.nodeInsertBefore(self, node, first.?);
         }
     }
@@ -362,14 +359,13 @@ pub const Node = struct {
     // TODO according with https://dom.spec.whatwg.org/#parentnode, the
     // function must accept either node or string.
     // blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
-    pub fn append(self: *parser.Node, nodes: ?Variadic(*parser.Node)) !void {
-        if (nodes == null) return;
-        if (nodes.?.slice.len == 0) return;
+    pub fn append(self: *parser.Node, nodes: []const *parser.Node) !void {
+        if (nodes.len == 0) return;
 
         // check hierarchy
         if (!try hierarchy(self, nodes)) return parser.DOMError.HierarchyRequest;
 
-        for (nodes.?.slice) |node| {
+        for (nodes) |node| {
             _ = try parser.nodeAppendChild(self, node);
         }
     }
@@ -377,9 +373,8 @@ pub const Node = struct {
     // TODO according with https://dom.spec.whatwg.org/#parentnode, the
     // function must accept either node or string.
     // blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
-    pub fn replaceChildren(self: *parser.Node, nodes: ?Variadic(*parser.Node)) !void {
-        if (nodes == null) return;
-        if (nodes.?.slice.len == 0) return;
+    pub fn replaceChildren(self: *parser.Node, nodes: []const *parser.Node) !void {
+        if (nodes.len == 0) return;
 
         // check hierarchy
         if (!try hierarchy(self, nodes)) return parser.DOMError.HierarchyRequest;
@@ -388,7 +383,7 @@ pub const Node = struct {
         try removeChildren(self);
 
         // add new children
-        for (nodes.?.slice) |node| {
+        for (nodes) |node| {
             _ = try parser.nodeAppendChild(self, node);
         }
     }
@@ -411,219 +406,189 @@ pub const Node = struct {
     pub fn deinit(_: *parser.Node, _: std.mem.Allocator) void {}
 };
 
-// Tests
-// -----
+const testing = @import("../../testing.zig");
+test "Browser.DOM.node" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
 
-pub fn testExecFn(
-    alloc: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
+    try runner.exec(
+        \\ function trimAndReplace(str) {
+        \\   str = str.replace(/(\r\n|\n|\r)/gm,'');
+        \\   str = str.replace(/\s+/g, ' ');
+        \\   str = str.trim();
+        \\   return str;
+        \\ }
+    );
 
-    // helper functions
-    const trim_and_replace =
-        \\function trimAndReplace(str) {
-        \\str = str.replace(/(\r\n|\n|\r)/gm,'');
-        \\str = str.replace(/\s+/g, ' ');
-        \\str = str.trim();
-        \\return str;
-        \\}
-    ;
-    try runScript(js_env, alloc, trim_and_replace, "proto_test");
+    try runner.testCases(&.{
+        .{ "document.body.compareDocumentPosition(document.firstChild); ", "10" },
+        .{ "document.getElementById(\"para-empty\").compareDocumentPosition(document.getElementById(\"content\"));", "10" },
+        .{ "document.getElementById(\"content\").compareDocumentPosition(document.getElementById(\"para-empty\"));", "20" },
+        .{ "document.getElementById(\"link\").compareDocumentPosition(document.getElementById(\"link\"));", "0" },
+        .{ "document.getElementById(\"para-empty\").compareDocumentPosition(document.getElementById(\"link\"));", "2" },
+        .{ "document.getElementById(\"link\").compareDocumentPosition(document.getElementById(\"para-empty\"));", "4" },
+    }, .{});
 
-    var node_compare_document_position = [_]Case{
-        .{ .src = "document.body.compareDocumentPosition(document.firstChild); ", .ex = "10" },
-        .{ .src = "document.getElementById(\"para-empty\").compareDocumentPosition(document.getElementById(\"content\"));", .ex = "10" },
-        .{ .src = "document.getElementById(\"content\").compareDocumentPosition(document.getElementById(\"para-empty\"));", .ex = "20" },
-        .{ .src = "document.getElementById(\"link\").compareDocumentPosition(document.getElementById(\"link\"));", .ex = "0" },
-        .{ .src = "document.getElementById(\"para-empty\").compareDocumentPosition(document.getElementById(\"link\"));", .ex = "2" },
-        .{ .src = "document.getElementById(\"link\").compareDocumentPosition(document.getElementById(\"para-empty\"));", .ex = "4" },
-    };
-    try checkCases(js_env, &node_compare_document_position);
+    try runner.testCases(&.{
+        .{ "document.getElementById('content').getRootNode().__proto__.constructor.name", "HTMLDocument" },
+    }, .{});
 
-    var get_root_node = [_]Case{
-        .{ .src = "document.getElementById('content').getRootNode().__proto__.constructor.name", .ex = "HTMLDocument" },
-    };
-    try checkCases(js_env, &get_root_node);
-
-    var first_child = [_]Case{
+    try runner.testCases(&.{
         // for next test cases
-        .{ .src = "let content = document.getElementById('content')", .ex = "undefined" },
-        .{ .src = "let link = document.getElementById('link')", .ex = "undefined" },
-        .{ .src = "let first_child = content.firstChild.nextSibling", .ex = "undefined" }, // nextSibling because of line return \n
+        .{ "let content = document.getElementById('content')", "undefined" },
+        .{ "let link = document.getElementById('link')", "undefined" },
+        .{ "let first_child = content.firstChild.nextSibling", "undefined" }, // nextSibling because of line return \n
 
-        .{ .src = "let body_first_child = document.body.firstChild", .ex = "undefined" },
-        .{ .src = "body_first_child.localName", .ex = "div" },
-        .{ .src = "body_first_child.__proto__.constructor.name", .ex = "HTMLDivElement" },
-        .{ .src = "document.getElementById('para-empty').firstChild.firstChild", .ex = "null" },
-    };
-    try checkCases(js_env, &first_child);
+        .{ "let body_first_child = document.body.firstChild", "undefined" },
+        .{ "body_first_child.localName", "div" },
+        .{ "body_first_child.__proto__.constructor.name", "HTMLDivElement" },
+        .{ "document.getElementById('para-empty').firstChild.firstChild", "null" },
+    }, .{});
 
-    var last_child = [_]Case{
-        .{ .src = "let last_child = content.lastChild.previousSibling", .ex = "undefined" }, // previousSibling because of line return \n
-        .{ .src = "last_child.__proto__.constructor.name", .ex = "Comment" },
-    };
-    try checkCases(js_env, &last_child);
+    try runner.testCases(&.{
+        .{ "let last_child = content.lastChild.previousSibling", "undefined" }, // previousSibling because of line return \n
+        .{ "last_child.__proto__.constructor.name", "Comment" },
+    }, .{});
 
-    var next_sibling = [_]Case{
-        .{ .src = "let next_sibling = link.nextSibling.nextSibling", .ex = "undefined" },
-        .{ .src = "next_sibling.localName", .ex = "p" },
-        .{ .src = "next_sibling.__proto__.constructor.name", .ex = "HTMLParagraphElement" },
-        .{ .src = "content.nextSibling.nextSibling", .ex = "null" },
-    };
-    try checkCases(js_env, &next_sibling);
+    try runner.testCases(&.{
+        .{ "let next_sibling = link.nextSibling.nextSibling", "undefined" },
+        .{ "next_sibling.localName", "p" },
+        .{ "next_sibling.__proto__.constructor.name", "HTMLParagraphElement" },
+        .{ "content.nextSibling.nextSibling", "null" },
+    }, .{});
 
-    var prev_sibling = [_]Case{
-        .{ .src = "let prev_sibling = document.getElementById('para-empty').previousSibling.previousSibling", .ex = "undefined" },
-        .{ .src = "prev_sibling.localName", .ex = "a" },
-        .{ .src = "prev_sibling.__proto__.constructor.name", .ex = "HTMLAnchorElement" },
-        .{ .src = "content.previousSibling", .ex = "null" },
-    };
-    try checkCases(js_env, &prev_sibling);
+    try runner.testCases(&.{
+        .{ "let prev_sibling = document.getElementById('para-empty').previousSibling.previousSibling", "undefined" },
+        .{ "prev_sibling.localName", "a" },
+        .{ "prev_sibling.__proto__.constructor.name", "HTMLAnchorElement" },
+        .{ "content.previousSibling", "null" },
+    }, .{});
 
-    var parent = [_]Case{
-        .{ .src = "let parent = document.getElementById('para').parentElement", .ex = "undefined" },
-        .{ .src = "parent.localName", .ex = "div" },
-        .{ .src = "parent.__proto__.constructor.name", .ex = "HTMLDivElement" },
-        .{ .src = "let h = content.parentElement.parentElement", .ex = "undefined" },
-        .{ .src = "h.parentElement", .ex = "null" },
-        .{ .src = "h.parentNode.__proto__.constructor.name", .ex = "HTMLDocument" },
-    };
-    try checkCases(js_env, &parent);
+    try runner.testCases(&.{
+        .{ "let parent = document.getElementById('para').parentElement", "undefined" },
+        .{ "parent.localName", "div" },
+        .{ "parent.__proto__.constructor.name", "HTMLDivElement" },
+        .{ "let h = content.parentElement.parentElement", "undefined" },
+        .{ "h.parentElement", "null" },
+        .{ "h.parentNode.__proto__.constructor.name", "HTMLDocument" },
+    }, .{});
 
-    var node_name = [_]Case{
-        .{ .src = "first_child.nodeName === 'A'", .ex = "true" },
-        .{ .src = "link.firstChild.nodeName === '#text'", .ex = "true" },
-        .{ .src = "last_child.nodeName === '#comment'", .ex = "true" },
-        .{ .src = "document.nodeName === '#document'", .ex = "true" },
-    };
-    try checkCases(js_env, &node_name);
+    try runner.testCases(&.{
+        .{ "first_child.nodeName === 'A'", "true" },
+        .{ "link.firstChild.nodeName === '#text'", "true" },
+        .{ "last_child.nodeName === '#comment'", "true" },
+        .{ "document.nodeName === '#document'", "true" },
+    }, .{});
 
-    var node_type = [_]Case{
-        .{ .src = "first_child.nodeType === 1", .ex = "true" },
-        .{ .src = "link.firstChild.nodeType === 3", .ex = "true" },
-        .{ .src = "last_child.nodeType === 8", .ex = "true" },
-        .{ .src = "document.nodeType === 9", .ex = "true" },
-    };
-    try checkCases(js_env, &node_type);
+    try runner.testCases(&.{
+        .{ "first_child.nodeType === 1", "true" },
+        .{ "link.firstChild.nodeType === 3", "true" },
+        .{ "last_child.nodeType === 8", "true" },
+        .{ "document.nodeType === 9", "true" },
+    }, .{});
 
-    var owner = [_]Case{
-        .{ .src = "let owner = content.ownerDocument", .ex = "undefined" },
-        .{ .src = "owner.__proto__.constructor.name", .ex = "HTMLDocument" },
-        .{ .src = "document.ownerDocument", .ex = "null" },
-        .{ .src = "let owner2 = document.createElement('div').ownerDocument", .ex = "undefined" },
-        .{ .src = "owner2.__proto__.constructor.name", .ex = "HTMLDocument" },
-    };
-    try checkCases(js_env, &owner);
+    try runner.testCases(&.{
+        .{ "let owner = content.ownerDocument", "undefined" },
+        .{ "owner.__proto__.constructor.name", "HTMLDocument" },
+        .{ "document.ownerDocument", "null" },
+        .{ "let owner2 = document.createElement('div').ownerDocument", "undefined" },
+        .{ "owner2.__proto__.constructor.name", "HTMLDocument" },
+    }, .{});
 
-    var connected = [_]Case{
-        .{ .src = "content.isConnected", .ex = "true" },
-        .{ .src = "document.isConnected", .ex = "true" },
-        .{ .src = "document.createElement('div').isConnected", .ex = "false" },
-    };
-    try checkCases(js_env, &connected);
+    try runner.testCases(&.{
+        .{ "content.isConnected", "true" },
+        .{ "document.isConnected", "true" },
+        .{ "document.createElement('div').isConnected", "false" },
+    }, .{});
 
-    var node_value = [_]Case{
-        .{ .src = "last_child.nodeValue === 'comment'", .ex = "true" },
-        .{ .src = "link.nodeValue === null", .ex = "true" },
-        .{ .src = "let text = link.firstChild", .ex = "undefined" },
-        .{ .src = "text.nodeValue === 'OK'", .ex = "true" },
-        .{ .src = "text.nodeValue = 'OK modified'", .ex = "OK modified" },
-        .{ .src = "text.nodeValue === 'OK modified'", .ex = "true" },
-        .{ .src = "link.nodeValue = 'nothing'", .ex = "nothing" },
-    };
-    try checkCases(js_env, &node_value);
+    try runner.testCases(&.{
+        .{ "last_child.nodeValue === 'comment'", "true" },
+        .{ "link.nodeValue === null", "true" },
+        .{ "let text = link.firstChild", "undefined" },
+        .{ "text.nodeValue === 'OK'", "true" },
+        .{ "text.nodeValue = 'OK modified'", "OK modified" },
+        .{ "text.nodeValue === 'OK modified'", "true" },
+        .{ "link.nodeValue = 'nothing'", "nothing" },
+    }, .{});
 
-    var node_text_content = [_]Case{
-        .{ .src = "text.textContent === 'OK modified'", .ex = "true" },
-        .{ .src = "trimAndReplace(content.textContent) === 'OK modified And'", .ex = "true" },
-        .{ .src = "text.textContent = 'OK'", .ex = "OK" },
-        .{ .src = "text.textContent", .ex = "OK" },
-        .{ .src = "trimAndReplace(document.getElementById('para-empty').textContent)", .ex = "" },
-        .{ .src = "document.getElementById('para-empty').textContent = 'OK'", .ex = "OK" },
-        .{ .src = "document.getElementById('para-empty').firstChild.nodeName === '#text'", .ex = "true" },
-    };
-    try checkCases(js_env, &node_text_content);
+    try runner.testCases(&.{
+        .{ "text.textContent === 'OK modified'", "true" },
+        .{ "trimAndReplace(content.textContent) === 'OK modified And'", "true" },
+        .{ "text.textContent = 'OK'", "OK" },
+        .{ "text.textContent", "OK" },
+        .{ "trimAndReplace(document.getElementById('para-empty').textContent)", "" },
+        .{ "document.getElementById('para-empty').textContent = 'OK'", "OK" },
+        .{ "document.getElementById('para-empty').firstChild.nodeName === '#text'", "true" },
+    }, .{});
 
-    var node_append_child = [_]Case{
-        .{ .src = "let append = document.createElement('h1')", .ex = "undefined" },
-        .{ .src = "content.appendChild(append).toString()", .ex = "[object HTMLHeadingElement]" },
-        .{ .src = "content.lastChild.__proto__.constructor.name", .ex = "HTMLHeadingElement" },
-        .{ .src = "content.appendChild(link).toString()", .ex = "[object HTMLAnchorElement]" },
-    };
-    try checkCases(js_env, &node_append_child);
+    try runner.testCases(&.{
+        .{ "let append = document.createElement('h1')", "undefined" },
+        .{ "content.appendChild(append).toString()", "[object HTMLHeadingElement]" },
+        .{ "content.lastChild.__proto__.constructor.name", "HTMLHeadingElement" },
+        .{ "content.appendChild(link).toString()", "[object HTMLAnchorElement]" },
+    }, .{});
 
-    var node_clone = [_]Case{
-        .{ .src = "let clone = link.cloneNode()", .ex = "undefined" },
-        .{ .src = "clone.toString()", .ex = "[object HTMLAnchorElement]" },
-        .{ .src = "clone.parentNode === null", .ex = "true" },
-        .{ .src = "clone.firstChild === null", .ex = "true" },
-        .{ .src = "let clone_deep = link.cloneNode(true)", .ex = "undefined" },
-        .{ .src = "clone_deep.firstChild.nodeName === '#text'", .ex = "true" },
-    };
-    try checkCases(js_env, &node_clone);
+    try runner.testCases(&.{
+        .{ "let clone = link.cloneNode()", "undefined" },
+        .{ "clone.toString()", "[object HTMLAnchorElement]" },
+        .{ "clone.parentNode === null", "true" },
+        .{ "clone.firstChild === null", "true" },
+        .{ "let clone_deep = link.cloneNode(true)", "undefined" },
+        .{ "clone_deep.firstChild.nodeName === '#text'", "true" },
+    }, .{});
 
-    var node_contains = [_]Case{
-        .{ .src = "link.contains(text)", .ex = "true" },
-        .{ .src = "text.contains(link)", .ex = "false" },
-    };
-    try checkCases(js_env, &node_contains);
+    try runner.testCases(&.{
+        .{ "link.contains(text)", "true" },
+        .{ "text.contains(link)", "false" },
+    }, .{});
 
-    var node_has_child_nodes = [_]Case{
-        .{ .src = "link.hasChildNodes()", .ex = "true" },
-        .{ .src = "text.hasChildNodes()", .ex = "false" },
-    };
-    try checkCases(js_env, &node_has_child_nodes);
+    try runner.testCases(&.{
+        .{ "link.hasChildNodes()", "true" },
+        .{ "text.hasChildNodes()", "false" },
+    }, .{});
 
-    var node_child_nodes = [_]Case{
-        .{ .src = "link.childNodes.length", .ex = "1" },
-        .{ .src = "text.childNodes.length", .ex = "0" },
-    };
-    try checkCases(js_env, &node_child_nodes);
+    try runner.testCases(&.{
+        .{ "link.childNodes.length", "1" },
+        .{ "text.childNodes.length", "0" },
+    }, .{});
 
-    var node_insert_before = [_]Case{
-        .{ .src = "let insertBefore = document.createElement('a')", .ex = "undefined" },
-        .{ .src = "link.insertBefore(insertBefore, text) !== undefined", .ex = "true" },
-        .{ .src = "link.firstChild.localName === 'a'", .ex = "true" },
-    };
-    try checkCases(js_env, &node_insert_before);
+    try runner.testCases(&.{
+        .{ "let insertBefore = document.createElement('a')", "undefined" },
+        .{ "link.insertBefore(insertBefore, text) !== undefined", "true" },
+        .{ "link.firstChild.localName === 'a'", "true" },
+    }, .{});
 
-    var node_is_default_namespace = [_]Case{
+    try runner.testCases(&.{
         // TODO: does not seems to work
-        // .{ .src = "link.isDefaultNamespace('')", .ex = "true" },
-        .{ .src = "link.isDefaultNamespace('false')", .ex = "false" },
-    };
-    try checkCases(js_env, &node_is_default_namespace);
+        // .{ "link.isDefaultNamespace('')", "true" },
+        .{ "link.isDefaultNamespace('false')", "false" },
+    }, .{});
 
-    var node_is_equal_node = [_]Case{
-        .{ .src = "let equal1 = document.createElement('a')", .ex = "undefined" },
-        .{ .src = "let equal2 = document.createElement('a')", .ex = "undefined" },
-        .{ .src = "equal1.textContent = 'is equal'", .ex = "is equal" },
-        .{ .src = "equal2.textContent = 'is equal'", .ex = "is equal" },
+    try runner.testCases(&.{
+        .{ "let equal1 = document.createElement('a')", "undefined" },
+        .{ "let equal2 = document.createElement('a')", "undefined" },
+        .{ "equal1.textContent = 'is equal'", "is equal" },
+        .{ "equal2.textContent = 'is equal'", "is equal" },
         // TODO: does not seems to work
-        // .{ .src = "equal1.isEqualNode(equal2)", .ex = "true" },
-    };
-    try checkCases(js_env, &node_is_equal_node);
+        // .{ "equal1.isEqualNode(equal2)", "true" },
+    }, .{});
 
-    var node_is_same_node = [_]Case{
-        .{ .src = "document.body.isSameNode(document.body)", .ex = "true" },
-    };
-    try checkCases(js_env, &node_is_same_node);
+    try runner.testCases(&.{
+        .{ "document.body.isSameNode(document.body)", "true" },
+    }, .{});
 
-    var node_normalize = [_]Case{
+    try runner.testCases(&.{
         // TODO: no test
-        .{ .src = "link.normalize()", .ex = "undefined" },
-    };
-    try checkCases(js_env, &node_normalize);
+        .{ "link.normalize()", "undefined" },
+    }, .{});
 
-    var node_remove_child = [_]Case{
-        .{ .src = "content.removeChild(append) !== undefined", .ex = "true" },
-        .{ .src = "last_child.__proto__.constructor.name !== 'HTMLHeadingElement'", .ex = "true" },
-    };
-    try checkCases(js_env, &node_remove_child);
+    try runner.testCases(&.{
+        .{ "content.removeChild(append) !== undefined", "true" },
+        .{ "last_child.__proto__.constructor.name !== 'HTMLHeadingElement'", "true" },
+    }, .{});
 
-    var node_replace_child = [_]Case{
-        .{ .src = "let replace = document.createElement('div')", .ex = "undefined" },
-        .{ .src = "link.replaceChild(replace, insertBefore) !== undefined", .ex = "true" },
-    };
-    try checkCases(js_env, &node_replace_child);
+    try runner.testCases(&.{
+        .{ "let replace = document.createElement('div')", "undefined" },
+        .{ "link.replaceChild(replace, insertBefore) !== undefined", "true" },
+    }, .{});
 }
diff --git a/src/dom/nodelist.zig b/src/browser/dom/nodelist.zig
similarity index 70%
rename from src/dom/nodelist.zig
rename to src/browser/dom/nodelist.zig
index 5dfc7031..ef75d8a5 100644
--- a/src/dom/nodelist.zig
+++ b/src/browser/dom/nodelist.zig
@@ -18,11 +18,10 @@
 
 const std = @import("std");
 
-const parser = @import("netsurf");
+const parser = @import("../netsurf.zig");
 
 const jsruntime = @import("jsruntime");
-const Callback = jsruntime.Callback;
-const CallbackResult = jsruntime.CallbackResult;
+const Callback = @import("../env.zig").Callback;
 const Case = jsruntime.test_utils.Case;
 const checkCases = jsruntime.test_utils.checkCases;
 
@@ -41,8 +40,6 @@ pub const Interfaces = .{
 };
 
 pub const NodeListIterator = struct {
-    pub const mem_guarantied = true;
-
     coll: *NodeList,
     index: u32 = 0,
 
@@ -69,8 +66,6 @@ pub const NodeListIterator = struct {
 };
 
 pub const NodeListEntriesIterator = struct {
-    pub const mem_guarantied = true;
-
     coll: *NodeList,
     index: u32 = 0,
 
@@ -104,7 +99,6 @@ pub const NodeListEntriesIterator = struct {
 // implementation allows only static nodelist.
 // see https://dom.spec.whatwg.org/#old-style-collections
 pub const NodeList = struct {
-    pub const mem_guarantied = true;
     pub const Exception = DOMException;
 
     const NodesArrayList = std.ArrayListUnmanaged(*parser.Node);
@@ -130,7 +124,7 @@ pub const NodeList = struct {
         return @intCast(self.nodes.items.len);
     }
 
-    pub fn _item(self: *NodeList, index: u32) !?NodeUnion {
+    pub fn _item(self: *const NodeList, index: u32) !?NodeUnion {
         if (index >= self.nodes.items.len) {
             return null;
         }
@@ -139,17 +133,21 @@ pub const NodeList = struct {
         return try Node.toInterface(n);
     }
 
-    pub fn _forEach(self: *NodeList, alloc: std.mem.Allocator, cbk: Callback) !void { // TODO handle thisArg
-        var res = CallbackResult.init(alloc);
-        defer res.deinit();
+    // TODO entries() https://developer.mozilla.org/en-US/docs/Web/API/NodeList/entries
+    pub fn indexed_get(self: *const NodeList, index: u32, has_value: *bool) !?NodeUnion {
+        return (try self._item(index)) orelse {
+            has_value.* = false;
+            return null;
+        };
+    }
 
+    pub fn _forEach(self: *NodeList, cbk: Callback) !void { // TODO handle thisArg
         for (self.nodes.items, 0..) |n, i| {
             const ii: u32 = @intCast(i);
-            cbk.trycall(.{ n, ii, self }, &res) catch |e| {
-                log.err("callback error: {s}", .{res.result orelse "unknown"});
-                log.debug("{s}", .{res.stack orelse "no stack trace"});
-
-                return e;
+            var result: Callback.Result = undefined;
+            cbk.tryCall(.{ n, ii, self }, &result) catch {
+                log.err("callback error: {s}", .{result.exception});
+                log.debug("stack:\n{s}", .{result.stack orelse "???"});
             };
         }
     }
@@ -169,40 +167,25 @@ pub const NodeList = struct {
     pub fn _symbol_iterator(self: *NodeList) NodeListIterator {
         return self._values();
     }
-
-    // TODO entries() https://developer.mozilla.org/en-US/docs/Web/API/NodeList/entries
-
-    pub fn postAttach(self: *NodeList, alloc: std.mem.Allocator, js_obj: jsruntime.JSObject) !void {
-        const ln = self.get_length();
-        var i: u32 = 0;
-        while (i < ln) {
-            defer i += 1;
-            const k = try std.fmt.allocPrint(alloc, "{d}", .{i});
-
-            const node = try self._item(i) orelse unreachable;
-            try js_obj.set(k, node);
-        }
-    }
 };
 
-// Tests
-// -----
+const testing = @import("../../testing.zig");
+test "Browser.DOM.NodeList" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
 
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var childnodes = [_]Case{
-        .{ .src = "let list = document.getElementById('content').childNodes", .ex = "undefined" },
-        .{ .src = "list.length", .ex = "9" },
-        .{ .src = "list[0].__proto__.constructor.name", .ex = "Text" },
-        .{ .src = 
-        \\let i = 0;
-        \\list.forEach(function (n, idx) {
-        \\  i += idx;
-        \\});
-        \\i;
-        , .ex = "36" },
-    };
-    try checkCases(js_env, &childnodes);
+    try runner.testCases(&.{
+        .{ "let list = document.getElementById('content').childNodes", "undefined" },
+        .{ "list.length", "9" },
+        .{ "list[0].__proto__.constructor.name", "Text" },
+        .{
+            \\  let i = 0;
+            \\  list.forEach(function (n, idx) {
+            \\    i += idx;
+            \\  });
+            \\  i;
+            ,
+            "36",
+        },
+    }, .{});
 }
diff --git a/src/dom/processing_instruction.zig b/src/browser/dom/processing_instruction.zig
similarity index 71%
rename from src/dom/processing_instruction.zig
rename to src/browser/dom/processing_instruction.zig
index fc932ec5..5e66ac92 100644
--- a/src/dom/processing_instruction.zig
+++ b/src/browser/dom/processing_instruction.zig
@@ -18,11 +18,7 @@
 
 const std = @import("std");
 
-const jsruntime = @import("jsruntime");
-const Case = jsruntime.test_utils.Case;
-const checkCases = jsruntime.test_utils.checkCases;
-
-const parser = @import("netsurf");
+const parser = @import("../netsurf.zig");
 const Node = @import("node.zig").Node;
 
 // https://dom.spec.whatwg.org/#processinginstruction
@@ -32,7 +28,6 @@ pub const ProcessingInstruction = struct {
     // TODO for libdom processing instruction inherit from node.
     // But the spec says it must inherit from CDATA.
     pub const prototype = *Node;
-    pub const mem_guarantied = true;
 
     pub fn get_target(self: *parser.ProcessingInstruction) ![]const u8 {
         // libdom stores the ProcessingInstruction target in the node's name.
@@ -52,18 +47,18 @@ pub const ProcessingInstruction = struct {
     }
 };
 
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var createProcessingInstruction = [_]Case{
-        .{ .src = "let pi = document.createProcessingInstruction('foo', 'bar')", .ex = "undefined" },
-        .{ .src = "pi.target", .ex = "foo" },
-        .{ .src = "pi.data", .ex = "bar" },
-        .{ .src = "pi.data = 'foo'", .ex = "foo" },
-        .{ .src = "pi.data", .ex = "foo" },
+const testing = @import("../../testing.zig");
+test "Browser.DOM.ProcessingInstruction" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
 
-        .{ .src = "let pi2 = pi.cloneNode()", .ex = "undefined" },
-    };
-    try checkCases(js_env, &createProcessingInstruction);
+    try runner.testCases(&.{
+        .{ "let pi = document.createProcessingInstruction('foo', 'bar')", "undefined" },
+        .{ "pi.target", "foo" },
+        .{ "pi.data", "bar" },
+        .{ "pi.data = 'foo'", "foo" },
+        .{ "pi.data", "foo" },
+
+        .{ "let pi2 = pi.cloneNode()", "undefined" },
+    }, .{});
 }
diff --git a/src/dom/text.zig b/src/browser/dom/text.zig
similarity index 53%
rename from src/dom/text.zig
rename to src/browser/dom/text.zig
index a2d3f9cf..83df3c72 100644
--- a/src/dom/text.zig
+++ b/src/browser/dom/text.zig
@@ -18,17 +18,12 @@
 
 const std = @import("std");
 
-const jsruntime = @import("jsruntime");
-const Case = jsruntime.test_utils.Case;
-const checkCases = jsruntime.test_utils.checkCases;
-
-const parser = @import("netsurf");
+const parser = @import("../netsurf.zig");
+const SessionState = @import("../env.zig").SessionState;
 
 const CharacterData = @import("character_data.zig").CharacterData;
 const CDATASection = @import("cdata_section.zig").CDATASection;
 
-const UserContext = @import("../user_context.zig").UserContext;
-
 // Text interfaces
 pub const Interfaces = .{
     CDATASection,
@@ -37,11 +32,10 @@ pub const Interfaces = .{
 pub const Text = struct {
     pub const Self = parser.Text;
     pub const prototype = *CharacterData;
-    pub const mem_guarantied = true;
 
-    pub fn constructor(userctx: UserContext, data: ?[]const u8) !*parser.Text {
+    pub fn constructor(state: *const SessionState, data: ?[]const u8) !*parser.Text {
         return parser.documentCreateTextNode(
-            parser.documentHTMLToDocument(userctx.document),
+            parser.documentHTMLToDocument(state.document.?),
             data orelse "",
         );
     }
@@ -66,30 +60,28 @@ pub const Text = struct {
 // Tests
 // -----
 
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var constructor = [_]Case{
-        .{ .src = "let t = new Text('foo')", .ex = "undefined" },
-        .{ .src = "t.data", .ex = "foo" },
+const testing = @import("../../testing.zig");
+test "Browser.DOM.Text" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
 
-        .{ .src = "let emptyt = new Text()", .ex = "undefined" },
-        .{ .src = "emptyt.data", .ex = "" },
-    };
-    try checkCases(js_env, &constructor);
+    try runner.testCases(&.{
+        .{ "let t = new Text('foo')", "undefined" },
+        .{ "t.data", "foo" },
 
-    var get_whole_text = [_]Case{
-        .{ .src = "let text = document.getElementById('link').firstChild", .ex = "undefined" },
-        .{ .src = "text.wholeText === 'OK'", .ex = "true" },
-    };
-    try checkCases(js_env, &get_whole_text);
+        .{ "let emptyt = new Text()", "undefined" },
+        .{ "emptyt.data", "" },
+    }, .{});
 
-    var split_text = [_]Case{
-        .{ .src = "text.data = 'OK modified'", .ex = "OK modified" },
-        .{ .src = "let split = text.splitText('OK'.length)", .ex = "undefined" },
-        .{ .src = "split.data === ' modified'", .ex = "true" },
-        .{ .src = "text.data === 'OK'", .ex = "true" },
-    };
-    try checkCases(js_env, &split_text);
+    try runner.testCases(&.{
+        .{ "let text = document.getElementById('link').firstChild", "undefined" },
+        .{ "text.wholeText === 'OK'", "true" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "text.data = 'OK modified'", "OK modified" },
+        .{ "let split = text.splitText('OK'.length)", "undefined" },
+        .{ "split.data === ' modified'", "true" },
+        .{ "text.data === 'OK'", "true" },
+    }, .{});
 }
diff --git a/src/dom/token_list.zig b/src/browser/dom/token_list.zig
similarity index 56%
rename from src/dom/token_list.zig
rename to src/browser/dom/token_list.zig
index 0ed75997..14e2fe5f 100644
--- a/src/dom/token_list.zig
+++ b/src/browser/dom/token_list.zig
@@ -18,12 +18,7 @@
 
 const std = @import("std");
 
-const parser = @import("netsurf");
-
-const jsruntime = @import("jsruntime");
-const Case = jsruntime.test_utils.Case;
-const checkCases = jsruntime.test_utils.checkCases;
-const Variadic = jsruntime.Variadic;
+const parser = @import("../netsurf.zig");
 
 const DOMException = @import("exceptions.zig").DOMException;
 
@@ -31,7 +26,6 @@ const DOMException = @import("exceptions.zig").DOMException;
 pub const DOMTokenList = struct {
     pub const Self = parser.TokenList;
     pub const Exception = DOMException;
-    pub const mem_guarantied = true;
 
     pub fn get_length(self: *parser.TokenList) !u32 {
         return parser.tokenListGetLength(self);
@@ -45,16 +39,14 @@ pub const DOMTokenList = struct {
         return parser.tokenListContains(self, token);
     }
 
-    pub fn _add(self: *parser.TokenList, tokens: ?Variadic([]const u8)) !void {
-        if (tokens == null) return;
-        for (tokens.?.slice) |token| {
+    pub fn _add(self: *parser.TokenList, tokens: []const []const u8) !void {
+        for (tokens) |token| {
             try parser.tokenListAdd(self, token);
         }
     }
 
-    pub fn _remove(self: *parser.TokenList, tokens: ?Variadic([]const u8)) !void {
-        if (tokens == null) return;
-        for (tokens.?.slice) |token| {
+    pub fn _remove(self: *parser.TokenList, tokens: []const []const u8) !void {
+        for (tokens) |token| {
             try parser.tokenListRemove(self, token);
         }
     }
@@ -113,52 +105,49 @@ pub const DOMTokenList = struct {
 // Tests
 // -----
 
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var dynamiclist = [_]Case{
-        .{ .src = "let gs = document.getElementById('para-empty')", .ex = "undefined" },
-        .{ .src = "let cl = gs.classList", .ex = "undefined" },
-        .{ .src = "gs.className", .ex = "ok empty" },
-        .{ .src = "cl.value", .ex = "ok empty" },
-        .{ .src = "cl.length", .ex = "2" },
-        .{ .src = "gs.className = 'foo bar baz'", .ex = "foo bar baz" },
-        .{ .src = "gs.className", .ex = "foo bar baz" },
-        .{ .src = "cl.length", .ex = "3" },
-        .{ .src = "gs.className = 'ok empty'", .ex = "ok empty" },
-        .{ .src = "cl.length", .ex = "2" },
-    };
-    try checkCases(js_env, &dynamiclist);
+const testing = @import("../../testing.zig");
+test "Browser.DOM.TokenList" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
 
-    var testcases = [_]Case{
-        .{ .src = "let cl2 = gs.classList", .ex = "undefined" },
-        .{ .src = "cl2.length", .ex = "2" },
-        .{ .src = "cl2.item(0)", .ex = "ok" },
-        .{ .src = "cl2.item(1)", .ex = "empty" },
-        .{ .src = "cl2.contains('ok')", .ex = "true" },
-        .{ .src = "cl2.contains('nok')", .ex = "false" },
-        .{ .src = "cl2.add('foo', 'bar', 'baz')", .ex = "undefined" },
-        .{ .src = "cl2.length", .ex = "5" },
-        .{ .src = "cl2.remove('foo', 'bar', 'baz')", .ex = "undefined" },
-        .{ .src = "cl2.length", .ex = "2" },
-    };
-    try checkCases(js_env, &testcases);
+    try runner.testCases(&.{
+        .{ "let gs = document.getElementById('para-empty')", "undefined" },
+        .{ "let cl = gs.classList", "undefined" },
+        .{ "gs.className", "ok empty" },
+        .{ "cl.value", "ok empty" },
+        .{ "cl.length", "2" },
+        .{ "gs.className = 'foo bar baz'", "foo bar baz" },
+        .{ "gs.className", "foo bar baz" },
+        .{ "cl.length", "3" },
+        .{ "gs.className = 'ok empty'", "ok empty" },
+        .{ "cl.length", "2" },
+    }, .{});
 
-    var toogle = [_]Case{
-        .{ .src = "let cl3 = gs.classList", .ex = "undefined" },
-        .{ .src = "cl3.toggle('ok')", .ex = "false" },
-        .{ .src = "cl3.toggle('ok')", .ex = "true" },
-        .{ .src = "cl3.length", .ex = "2" },
-    };
-    try checkCases(js_env, &toogle);
+    try runner.testCases(&.{
+        .{ "let cl2 = gs.classList", "undefined" },
+        .{ "cl2.length", "2" },
+        .{ "cl2.item(0)", "ok" },
+        .{ "cl2.item(1)", "empty" },
+        .{ "cl2.contains('ok')", "true" },
+        .{ "cl2.contains('nok')", "false" },
+        .{ "cl2.add('foo', 'bar', 'baz')", "undefined" },
+        .{ "cl2.length", "5" },
+        .{ "cl2.remove('foo', 'bar', 'baz')", "undefined" },
+        .{ "cl2.length", "2" },
+    }, .{});
 
-    var replace = [_]Case{
-        .{ .src = "let cl4 = gs.classList", .ex = "undefined" },
-        .{ .src = "cl4.replace('ok', 'nok')", .ex = "true" },
-        .{ .src = "cl4.value", .ex = "empty nok" },
-        .{ .src = "cl4.replace('nok', 'ok')", .ex = "true" },
-        .{ .src = "cl4.value", .ex = "empty ok" },
-    };
-    try checkCases(js_env, &replace);
+    try runner.testCases(&.{
+        .{ "let cl3 = gs.classList", "undefined" },
+        .{ "cl3.toggle('ok')", "false" },
+        .{ "cl3.toggle('ok')", "true" },
+        .{ "cl3.length", "2" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "let cl4 = gs.classList", "undefined" },
+        .{ "cl4.replace('ok', 'nok')", "true" },
+        .{ "cl4.value", "empty nok" },
+        .{ "cl4.replace('nok', 'ok')", "true" },
+        .{ "cl4.value", "empty ok" },
+    }, .{});
 }
diff --git a/src/dom/walker.zig b/src/browser/dom/walker.zig
similarity index 98%
rename from src/dom/walker.zig
rename to src/browser/dom/walker.zig
index 6f2c2fba..ad7ba5f7 100644
--- a/src/dom/walker.zig
+++ b/src/browser/dom/walker.zig
@@ -18,7 +18,7 @@
 
 const std = @import("std");
 
-const parser = @import("netsurf");
+const parser = @import("../netsurf.zig");
 
 pub const Walker = union(enum) {
     walkerDepthFirst: WalkerDepthFirst,
diff --git a/src/browser/dump.zig b/src/browser/dump.zig
index 23fff552..4c4e7996 100644
--- a/src/browser/dump.zig
+++ b/src/browser/dump.zig
@@ -19,8 +19,8 @@
 const std = @import("std");
 const File = std.fs.File;
 
-const parser = @import("netsurf");
-const Walker = @import("../dom/walker.zig").WalkerChildren;
+const parser = @import("netsurf.zig");
+const Walker = @import("dom/walker.zig").WalkerChildren;
 
 // writer must be a std.io.Writer
 pub fn writeHTML(doc: *parser.Document, writer: anytype) !void {
diff --git a/src/browser/env.zig b/src/browser/env.zig
new file mode 100644
index 00000000..6df3fc71
--- /dev/null
+++ b/src/browser/env.zig
@@ -0,0 +1,35 @@
+const std = @import("std");
+
+const parser = @import("netsurf.zig");
+const URL = @import("../url.zig").URL;
+const js = @import("../runtime/js.zig");
+const storage = @import("storage/storage.zig");
+const generate = @import("../runtime/generate.zig");
+const Loop = @import("../runtime/loop.zig").Loop;
+const HttpClient = @import("../http/client.zig").Client;
+const Renderer = @import("browser.zig").Renderer;
+
+const Interfaces = generate.Tuple(.{
+    @import("console/console.zig").Console,
+    @import("dom/dom.zig").Interfaces,
+    @import("events/event.zig").Interfaces,
+    @import("html/html.zig").Interfaces,
+    @import("iterator/iterator.zig").Interfaces,
+    @import("storage/storage.zig").Interfaces,
+    @import("url/url.zig").Interfaces,
+    @import("xhr/xhr.zig").Interfaces,
+    @import("xmlserializer/xmlserializer.zig").Interfaces,
+});
+
+pub const Callback = Env.Callback;
+pub const Env = js.Env(*SessionState, Interfaces{});
+
+pub const SessionState = struct {
+    loop: *Loop,
+    url: *const URL,
+    renderer: *Renderer,
+    arena: std.mem.Allocator,
+    http_client: *HttpClient,
+    cookie_jar: *storage.CookieJar,
+    document: ?*parser.DocumentHTML,
+};
diff --git a/src/browser/events/event.zig b/src/browser/events/event.zig
new file mode 100644
index 00000000..f199f175
--- /dev/null
+++ b/src/browser/events/event.zig
@@ -0,0 +1,245 @@
+// Copyright (C) 2023-2024  Lightpanda (Selecy SAS)
+//
+// Francis Bouvier 
+// Pierre Tachoire 
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see .
+
+const std = @import("std");
+
+const parser = @import("../netsurf.zig");
+const Callback = @import("../env.zig").Callback;
+const generate = @import("../../runtime/generate.zig");
+
+const DOMException = @import("../dom/exceptions.zig").DOMException;
+const EventTarget = @import("../dom/event_target.zig").EventTarget;
+const EventTargetUnion = @import("../dom/event_target.zig").Union;
+
+const ProgressEvent = @import("../xhr/progress_event.zig").ProgressEvent;
+
+const log = std.log.scoped(.events);
+
+// Event interfaces
+pub const Interfaces = .{
+    Event,
+    ProgressEvent,
+};
+
+pub const Union = generate.Union(Interfaces);
+
+// https://dom.spec.whatwg.org/#event
+pub const Event = struct {
+    pub const Self = parser.Event;
+    pub const Exception = DOMException;
+
+    pub const EventInit = parser.EventInit;
+
+    // JS
+    // --
+
+    pub const _CAPTURING_PHASE = 1;
+    pub const _AT_TARGET = 2;
+    pub const _BUBBLING_PHASE = 3;
+
+    pub fn toInterface(evt: *parser.Event) !Union {
+        return switch (try parser.eventGetInternalType(evt)) {
+            .event => .{ .Event = evt },
+            .progress_event => .{ .ProgressEvent = @as(*ProgressEvent, @ptrCast(evt)).* },
+        };
+    }
+
+    pub fn constructor(eventType: []const u8, opts: ?EventInit) !*parser.Event {
+        const event = try parser.eventCreate();
+        try parser.eventInit(event, eventType, opts orelse EventInit{});
+        return event;
+    }
+
+    // Getters
+
+    pub fn get_type(self: *parser.Event) ![]const u8 {
+        return try parser.eventType(self);
+    }
+
+    pub fn get_target(self: *parser.Event) !?EventTargetUnion {
+        const et = try parser.eventTarget(self);
+        if (et == null) return null;
+        return try EventTarget.toInterface(et.?);
+    }
+
+    pub fn get_currentTarget(self: *parser.Event) !?EventTargetUnion {
+        const et = try parser.eventCurrentTarget(self);
+        if (et == null) return null;
+        return try EventTarget.toInterface(et.?);
+    }
+
+    pub fn get_eventPhase(self: *parser.Event) !u8 {
+        return try parser.eventPhase(self);
+    }
+
+    pub fn get_bubbles(self: *parser.Event) !bool {
+        return try parser.eventBubbles(self);
+    }
+
+    pub fn get_cancelable(self: *parser.Event) !bool {
+        return try parser.eventCancelable(self);
+    }
+
+    pub fn get_defaultPrevented(self: *parser.Event) !bool {
+        return try parser.eventDefaultPrevented(self);
+    }
+
+    pub fn get_isTrusted(self: *parser.Event) !bool {
+        return try parser.eventIsTrusted(self);
+    }
+
+    pub fn get_timestamp(self: *parser.Event) !u32 {
+        return try parser.eventTimestamp(self);
+    }
+
+    // Methods
+
+    pub fn _initEvent(
+        self: *parser.Event,
+        eventType: []const u8,
+        bubbles: ?bool,
+        cancelable: ?bool,
+    ) !void {
+        const opts = EventInit{
+            .bubbles = bubbles orelse false,
+            .cancelable = cancelable orelse false,
+        };
+        return try parser.eventInit(self, eventType, opts);
+    }
+
+    pub fn _stopPropagation(self: *parser.Event) !void {
+        return try parser.eventStopPropagation(self);
+    }
+
+    pub fn _stopImmediatePropagation(self: *parser.Event) !void {
+        return try parser.eventStopImmediatePropagation(self);
+    }
+
+    pub fn _preventDefault(self: *parser.Event) !void {
+        return try parser.eventPreventDefault(self);
+    }
+};
+
+pub const EventHandler = struct {
+    fn handle(event: ?*parser.Event, data: parser.EventHandlerData) void {
+        var result: Callback.Result = undefined;
+        data.cbk.tryCall(.{if (event) |evt| Event.toInterface(evt) catch unreachable else null}, &result) catch {
+            log.err("event handler error: {s}", .{result.exception});
+            log.debug("stack:\n{s}", .{result.stack orelse "???"});
+        };
+    }
+}.handle;
+
+const testing = @import("../../testing.zig");
+test "Browser.Event" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
+
+    try runner.testCases(&.{
+        .{ "let content = document.getElementById('content')", "undefined" },
+        .{ "let para = document.getElementById('para')", "undefined" },
+        .{ "var nb = 0; var evt", "undefined" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{
+            \\ content.addEventListener('target', function(e) {
+            \\  evt = e; nb = nb + 1;
+            \\  e.preventDefault();
+            \\ })
+            ,
+            "undefined",
+        },
+        .{ "content.dispatchEvent(new Event('target', {bubbles: true, cancelable: true}))", "false" },
+        .{ "nb", "1" },
+        .{ "evt.target === content", "true" },
+        .{ "evt.bubbles", "true" },
+        .{ "evt.cancelable", "true" },
+        .{ "evt.defaultPrevented", "true" },
+        .{ "evt.isTrusted", "true" },
+        .{ "evt.timestamp > 1704063600", "true" }, // 2024/01/01 00:00
+        // event.type, event.currentTarget, event.phase checked in EventTarget
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "nb = 0", "0" },
+        .{
+            \\ content.addEventListener('stop',function(e) {
+            \\    e.stopPropagation();
+            \\    nb = nb + 1;
+            \\  }, true)
+            ,
+            "undefined",
+        },
+        // the following event listener will not be invoked
+        .{
+            \\  para.addEventListener('stop',function(e) {
+            \\    nb = nb + 1;
+            \\  })
+            ,
+            "undefined",
+        },
+        .{ "para.dispatchEvent(new Event('stop'))", "true" },
+        .{ "nb", "1" }, // will be 2 if event was not stopped at content event listener
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "nb = 0", "0" },
+        .{
+            \\  content.addEventListener('immediate', function(e) {
+            \\    e.stopImmediatePropagation();
+            \\    nb = nb + 1;
+            \\  })
+            ,
+            "undefined",
+        },
+        // the following event listener will not be invoked
+        .{
+            \\  content.addEventListener('immediate', function(e) {
+            \\    nb = nb + 1;
+            \\  })
+            ,
+            "undefined",
+        },
+        .{ "content.dispatchEvent(new Event('immediate'))", "true" },
+        .{ "nb", "1" }, // will be 2 if event was not stopped at first content event listener
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "nb = 0", "0" },
+        .{
+            \\  content.addEventListener('legacy', function(e) {
+            \\     evt = e; nb = nb + 1;
+            \\  })
+            ,
+            "undefined",
+        },
+        .{ "let evtLegacy = document.createEvent('Event')", "undefined" },
+        .{ "evtLegacy.initEvent('legacy')", "undefined" },
+        .{ "content.dispatchEvent(evtLegacy)", "true" },
+        .{ "nb", "1" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "var nb = 0; var evt = null; function cbk(event) { nb ++; evt=event; }", "undefined" },
+        .{ "document.addEventListener('count', cbk)", "undefined" },
+        .{ "document.removeEventListener('count', cbk)", "undefined" },
+        .{ "document.dispatchEvent(new Event('count'))", "true" },
+        .{ "nb", "0" },
+    }, .{});
+}
diff --git a/src/html/document.zig b/src/browser/html/document.zig
similarity index 57%
rename from src/html/document.zig
rename to src/browser/html/document.zig
index 19567078..c2504659 100644
--- a/src/html/document.zig
+++ b/src/browser/html/document.zig
@@ -18,11 +18,8 @@
 
 const std = @import("std");
 
-const parser = @import("netsurf");
-
-const jsruntime = @import("jsruntime");
-const Case = jsruntime.test_utils.Case;
-const checkCases = jsruntime.test_utils.checkCases;
+const parser = @import("../netsurf.zig");
+const SessionState = @import("../env.zig").SessionState;
 
 const Node = @import("../dom/node.zig").Node;
 const Document = @import("../dom/document.zig").Document;
@@ -32,15 +29,12 @@ const Location = @import("location.zig").Location;
 
 const collection = @import("../dom/html_collection.zig");
 const Walker = @import("../dom/walker.zig").WalkerDepthFirst;
-
-const UserContext = @import("../user_context.zig").UserContext;
 const Cookie = @import("../storage/cookie.zig").Cookie;
 
 // WEB IDL https://html.spec.whatwg.org/#the-document-object
 pub const HTMLDocument = struct {
     pub const Self = parser.DocumentHTML;
     pub const prototype = *Document;
-    pub const mem_guarantied = true;
 
     pub const sub_type = "node";
 
@@ -84,20 +78,18 @@ pub const HTMLDocument = struct {
         }
     }
 
-    pub fn get_cookie(_: *parser.DocumentHTML, arena: std.mem.Allocator, userctx: UserContext) ![]const u8 {
+    pub fn get_cookie(_: *parser.DocumentHTML, state: *SessionState) ![]const u8 {
         var buf: std.ArrayListUnmanaged(u8) = .{};
-        try userctx.cookie_jar.forRequest(&userctx.url.uri, buf.writer(arena), .{ .navigation = true });
+        try state.cookie_jar.forRequest(&state.url.uri, buf.writer(state.arena), .{ .navigation = true });
         return buf.items;
     }
 
-    pub fn set_cookie(_: *parser.DocumentHTML, userctx: UserContext, cookie_str: []const u8) ![]const u8 {
+    pub fn set_cookie(_: *parser.DocumentHTML, cookie_str: []const u8, state: *SessionState) ![]const u8 {
         // we use the cookie jar's allocator to parse the cookie because it
         // outlives the page's arena.
-        const c = try Cookie.parse(userctx.cookie_jar.allocator, &userctx.url.uri, cookie_str);
+        const c = try Cookie.parse(state.cookie_jar.allocator, &state.url.uri, cookie_str);
         errdefer c.deinit();
-
-        try userctx.cookie_jar.add(c, std.time.timestamp());
-
+        try state.cookie_jar.add(c, std.time.timestamp());
         return cookie_str;
     }
 
@@ -110,44 +102,45 @@ pub const HTMLDocument = struct {
         return v;
     }
 
-    pub fn _getElementsByName(self: *parser.DocumentHTML, alloc: std.mem.Allocator, name: []const u8) !NodeList {
+    pub fn _getElementsByName(self: *parser.DocumentHTML, state: *SessionState, name: []const u8) !NodeList {
+        const arena = state.arena;
         var list = NodeList.init();
-        errdefer list.deinit(alloc);
+        errdefer list.deinit(arena);
 
         if (name.len == 0) return list;
 
         const root = parser.documentHTMLToNode(self);
-        var c = try collection.HTMLCollectionByName(alloc, root, name, false);
+        var c = try collection.HTMLCollectionByName(arena, root, name, false);
 
         const ln = try c.get_length();
         var i: u32 = 0;
         while (i < ln) {
             const n = try c.item(i) orelse break;
-            try list.append(alloc, n);
+            try list.append(arena, n);
             i += 1;
         }
 
         return list;
     }
 
-    pub fn get_images(self: *parser.DocumentHTML, alloc: std.mem.Allocator) !collection.HTMLCollection {
-        return try collection.HTMLCollectionByTagName(alloc, parser.documentHTMLToNode(self), "img", false);
+    pub fn get_images(self: *parser.DocumentHTML, state: *SessionState) !collection.HTMLCollection {
+        return try collection.HTMLCollectionByTagName(state.arena, parser.documentHTMLToNode(self), "img", false);
     }
 
-    pub fn get_embeds(self: *parser.DocumentHTML, alloc: std.mem.Allocator) !collection.HTMLCollection {
-        return try collection.HTMLCollectionByTagName(alloc, parser.documentHTMLToNode(self), "embed", false);
+    pub fn get_embeds(self: *parser.DocumentHTML, state: *SessionState) !collection.HTMLCollection {
+        return try collection.HTMLCollectionByTagName(state.arena, parser.documentHTMLToNode(self), "embed", false);
     }
 
-    pub fn get_plugins(self: *parser.DocumentHTML, alloc: std.mem.Allocator) !collection.HTMLCollection {
-        return get_embeds(self, alloc);
+    pub fn get_plugins(self: *parser.DocumentHTML, state: *SessionState) !collection.HTMLCollection {
+        return get_embeds(self, state);
     }
 
-    pub fn get_forms(self: *parser.DocumentHTML, alloc: std.mem.Allocator) !collection.HTMLCollection {
-        return try collection.HTMLCollectionByTagName(alloc, parser.documentHTMLToNode(self), "form", false);
+    pub fn get_forms(self: *parser.DocumentHTML, state: *SessionState) !collection.HTMLCollection {
+        return try collection.HTMLCollectionByTagName(state.arena, parser.documentHTMLToNode(self), "form", false);
     }
 
-    pub fn get_scripts(self: *parser.DocumentHTML, alloc: std.mem.Allocator) !collection.HTMLCollection {
-        return try collection.HTMLCollectionByTagName(alloc, parser.documentHTMLToNode(self), "script", false);
+    pub fn get_scripts(self: *parser.DocumentHTML, state: *SessionState) !collection.HTMLCollection {
+        return try collection.HTMLCollectionByTagName(state.arena, parser.documentHTMLToNode(self), "script", false);
     }
 
     pub fn get_applets(_: *parser.DocumentHTML) !collection.HTMLCollection {
@@ -218,62 +211,57 @@ pub const HTMLDocument = struct {
     pub fn set_bgColor(_: *parser.DocumentHTML, _: []const u8) []const u8 {
         return "";
     }
-
-    pub fn deinit(_: *parser.DocumentHTML, _: std.mem.Allocator) void {}
 };
 
 // Tests
 // -----
 
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var constructor = [_]Case{
-        .{ .src = "document.__proto__.constructor.name", .ex = "HTMLDocument" },
-        .{ .src = "document.__proto__.__proto__.constructor.name", .ex = "Document" },
-        .{ .src = "document.body.localName == 'body'", .ex = "true" },
-    };
-    try checkCases(js_env, &constructor);
+const testing = @import("../../testing.zig");
 
-    var getters = [_]Case{
-        .{ .src = "document.domain", .ex = "" },
-        .{ .src = "document.referrer", .ex = "" },
-        .{ .src = "document.title", .ex = "" },
-        .{ .src = "document.body.localName", .ex = "body" },
-        .{ .src = "document.head.localName", .ex = "head" },
-        .{ .src = "document.images.length", .ex = "0" },
-        .{ .src = "document.embeds.length", .ex = "0" },
-        .{ .src = "document.plugins.length", .ex = "0" },
-        .{ .src = "document.scripts.length", .ex = "0" },
-        .{ .src = "document.forms.length", .ex = "0" },
-        .{ .src = "document.links.length", .ex = "1" },
-        .{ .src = "document.applets.length", .ex = "0" },
-        .{ .src = "document.anchors.length", .ex = "0" },
-        .{ .src = "document.all.length", .ex = "8" },
-        .{ .src = "document.currentScript", .ex = "null" },
-    };
-    try checkCases(js_env, &getters);
+test "Browser.HTML.Document" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
 
-    var titles = [_]Case{
-        .{ .src = "document.title = 'foo'", .ex = "foo" },
-        .{ .src = "document.title", .ex = "foo" },
-        .{ .src = "document.title = ''", .ex = "" },
-    };
-    try checkCases(js_env, &titles);
+    try runner.testCases(&.{
+        .{ "document.__proto__.constructor.name", "HTMLDocument" },
+        .{ "document.__proto__.__proto__.constructor.name", "Document" },
+        .{ "document.body.localName == 'body'", "true" },
+    }, .{});
 
-    var getElementsByName = [_]Case{
-        .{ .src = "document.getElementById('link').setAttribute('name', 'foo')", .ex = "undefined" },
-        .{ .src = "let list = document.getElementsByName('foo')", .ex = "undefined" },
-        .{ .src = "list.length", .ex = "1" },
-    };
-    try checkCases(js_env, &getElementsByName);
+    try runner.testCases(&.{
+        .{ "document.domain", "" },
+        .{ "document.referrer", "" },
+        .{ "document.title", "" },
+        .{ "document.body.localName", "body" },
+        .{ "document.head.localName", "head" },
+        .{ "document.images.length", "0" },
+        .{ "document.embeds.length", "0" },
+        .{ "document.plugins.length", "0" },
+        .{ "document.scripts.length", "0" },
+        .{ "document.forms.length", "0" },
+        .{ "document.links.length", "1" },
+        .{ "document.applets.length", "0" },
+        .{ "document.anchors.length", "0" },
+        .{ "document.all.length", "8" },
+        .{ "document.currentScript", "null" },
+    }, .{});
 
-    var cookie = [_]Case{
-        .{ .src = "document.cookie", .ex = "" },
-        .{ .src = "document.cookie = 'name=Oeschger; SameSite=None; Secure'", .ex = "name=Oeschger; SameSite=None; Secure" },
-        .{ .src = "document.cookie = 'favorite_food=tripe; SameSite=None; Secure'", .ex = "favorite_food=tripe; SameSite=None; Secure" },
-        .{ .src = "document.cookie", .ex = "name=Oeschger; favorite_food=tripe" },
-    };
-    try checkCases(js_env, &cookie);
+    try runner.testCases(&.{
+        .{ "document.title = 'foo'", "foo" },
+        .{ "document.title", "foo" },
+        .{ "document.title = ''", "" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "document.getElementById('link').setAttribute('name', 'foo')", "undefined" },
+        .{ "let list = document.getElementsByName('foo')", "undefined" },
+        .{ "list.length", "1" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "document.cookie", "" },
+        .{ "document.cookie = 'name=Oeschger; SameSite=None; Secure'", "name=Oeschger; SameSite=None; Secure" },
+        .{ "document.cookie = 'favorite_food=tripe; SameSite=None; Secure'", "favorite_food=tripe; SameSite=None; Secure" },
+        .{ "document.cookie", "name=Oeschger; favorite_food=tripe" },
+    }, .{});
 }
diff --git a/src/html/elements.zig b/src/browser/html/elements.zig
similarity index 69%
rename from src/html/elements.zig
rename to src/browser/html/elements.zig
index d0ff13a4..f1858cfe 100644
--- a/src/html/elements.zig
+++ b/src/browser/html/elements.zig
@@ -17,16 +17,13 @@
 // along with this program.  If not, see .
 const std = @import("std");
 
-const parser = @import("netsurf");
-const generate = @import("../generate.zig");
+const parser = @import("../netsurf.zig");
+const generate = @import("../../runtime/generate.zig");
+const SessionState = @import("../env.zig").SessionState;
 
-const jsruntime = @import("jsruntime");
-const Case = jsruntime.test_utils.Case;
-const checkCases = jsruntime.test_utils.checkCases;
-
-const Element = @import("../dom/element.zig").Element;
 const URL = @import("../url/url.zig").URL;
 const Node = @import("../dom/node.zig").Node;
+const Element = @import("../dom/element.zig").Element;
 
 // HTMLElement interfaces
 pub const Interfaces = .{
@@ -105,14 +102,11 @@ pub const Union = generate.Union(Interfaces);
 // Abstract class
 // --------------
 
-const CSSProperties = struct {
-    pub const mem_guarantied = true;
-};
+const CSSProperties = struct {};
 
 pub const HTMLElement = struct {
     pub const Self = parser.ElementHTML;
     pub const prototype = *Element;
-    pub const mem_guarantied = true;
 
     pub fn get_style(_: *parser.ElementHTML) CSSProperties {
         return .{};
@@ -148,7 +142,6 @@ pub const HTMLElement = struct {
 pub const HTMLMediaElement = struct {
     pub const Self = parser.MediaElement;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 // HTML elements
@@ -157,14 +150,12 @@ pub const HTMLMediaElement = struct {
 pub const HTMLUnknownElement = struct {
     pub const Self = parser.Unknown;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 // https://html.spec.whatwg.org/#the-a-element
 pub const HTMLAnchorElement = struct {
     pub const Self = parser.Anchor;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 
     pub fn get_target(self: *parser.Anchor) ![]const u8 {
         return try parser.anchorGetTarget(self);
@@ -174,7 +165,7 @@ pub const HTMLAnchorElement = struct {
         return try parser.anchorSetTarget(self, href);
     }
 
-    pub fn get_download(_: *parser.Anchor) ![]const u8 {
+    pub fn get_download(_: *const parser.Anchor) ![]const u8 {
         return ""; // TODO
     }
 
@@ -218,47 +209,41 @@ pub const HTMLAnchorElement = struct {
         return try parser.nodeSetTextContent(parser.anchorToNode(self), v);
     }
 
-    inline fn url(self: *parser.Anchor, alloc: std.mem.Allocator) !URL {
+    inline fn url(self: *parser.Anchor, state: *SessionState) !URL {
         const href = try parser.anchorGetHref(self);
-        return URL.constructor(alloc, href, null); // TODO inject base url
+        return URL.constructor(state, href, null); // TODO inject base url
     }
 
     // TODO return a disposable string
-    pub fn get_origin(self: *parser.Anchor, alloc: std.mem.Allocator) ![]const u8 {
-        var u = try url(self, alloc);
-        defer u.deinit(alloc);
-
-        return try u.get_origin(alloc);
+    pub fn get_origin(self: *parser.Anchor, state: *SessionState) ![]const u8 {
+        var u = try url(self, state);
+        return try u.get_origin(state);
     }
 
     // TODO return a disposable string
-    pub fn get_protocol(self: *parser.Anchor, alloc: std.mem.Allocator) ![]const u8 {
-        var u = try url(self, alloc);
-        defer u.deinit(alloc);
-
-        return u.get_protocol(alloc);
+    pub fn get_protocol(self: *parser.Anchor, state: *SessionState) ![]const u8 {
+        var u = try url(self, state);
+        return u.get_protocol(state);
     }
 
-    pub fn set_protocol(self: *parser.Anchor, alloc: std.mem.Allocator, v: []const u8) !void {
-        var u = try url(self, alloc);
-        defer u.deinit(alloc);
+    pub fn set_protocol(self: *parser.Anchor, v: []const u8, state: *SessionState) !void {
+        const arena = state.arena;
+        var u = try url(self, state);
 
         u.uri.scheme = v;
-        const href = try u.format(alloc);
-        defer alloc.free(href);
+        const href = try u.format(arena);
+        defer arena.free(href);
 
         try parser.anchorSetHref(self, href);
     }
 
     // TODO return a disposable string
-    pub fn get_host(self: *parser.Anchor, alloc: std.mem.Allocator) ![]const u8 {
-        var u = try url(self, alloc);
-        defer u.deinit(alloc);
-
-        return try u.get_host(alloc);
+    pub fn get_host(self: *parser.Anchor, state: *SessionState) ![]const u8 {
+        var u = try url(self, state);
+        return try u.get_host(state);
     }
 
-    pub fn set_host(self: *parser.Anchor, alloc: std.mem.Allocator, v: []const u8) !void {
+    pub fn set_host(self: *parser.Anchor, v: []const u8, state: *SessionState) !void {
         // search : separator
         var p: ?u16 = null;
         var h: []const u8 = undefined;
@@ -270,8 +255,8 @@ pub const HTMLAnchorElement = struct {
             }
         }
 
-        var u = try url(self, alloc);
-        defer u.deinit(alloc);
+        const arena = state.arena;
+        var u = try url(self, state);
 
         if (p) |pp| {
             u.uri.host = .{ .raw = h };
@@ -281,40 +266,34 @@ pub const HTMLAnchorElement = struct {
             u.uri.port = null;
         }
 
-        const href = try u.format(alloc);
-        defer alloc.free(href);
-
+        const href = try u.format(arena);
+        defer arena.free(href);
         try parser.anchorSetHref(self, href);
     }
 
     // TODO return a disposable string
-    pub fn get_hostname(self: *parser.Anchor, alloc: std.mem.Allocator) ![]const u8 {
-        var u = try url(self, alloc);
-        defer u.deinit(alloc);
-
-        return try alloc.dupe(u8, u.get_hostname());
+    pub fn get_hostname(self: *parser.Anchor, state: *SessionState) ![]const u8 {
+        var u = try url(self, state);
+        return try state.arena.dupe(u8, u.get_hostname());
     }
 
-    pub fn set_hostname(self: *parser.Anchor, alloc: std.mem.Allocator, v: []const u8) !void {
-        var u = try url(self, alloc);
-        defer u.deinit(alloc);
-
+    pub fn set_hostname(self: *parser.Anchor, v: []const u8, state: *SessionState) !void {
+        const arena = state.arena;
+        var u = try url(self, state);
         u.uri.host = .{ .raw = v };
-        const href = try u.format(alloc);
+        const href = try u.format(arena);
         try parser.anchorSetHref(self, href);
     }
 
     // TODO return a disposable string
-    pub fn get_port(self: *parser.Anchor, alloc: std.mem.Allocator) ![]const u8 {
-        var u = try url(self, alloc);
-        defer u.deinit(alloc);
-
-        return try u.get_port(alloc);
+    pub fn get_port(self: *parser.Anchor, state: *SessionState) ![]const u8 {
+        var u = try url(self, state);
+        return try u.get_port(state);
     }
 
-    pub fn set_port(self: *parser.Anchor, alloc: std.mem.Allocator, v: ?[]const u8) !void {
-        var u = try url(self, alloc);
-        defer u.deinit(alloc);
+    pub fn set_port(self: *parser.Anchor, v: ?[]const u8, state: *SessionState) !void {
+        const arena = state.arena;
+        var u = try url(self, state);
 
         if (v != null and v.?.len > 0) {
             u.uri.port = try std.fmt.parseInt(u16, v.?, 10);
@@ -322,407 +301,346 @@ pub const HTMLAnchorElement = struct {
             u.uri.port = null;
         }
 
-        const href = try u.format(alloc);
-        defer alloc.free(href);
-
+        const href = try u.format(arena);
+        defer arena.free(href);
         try parser.anchorSetHref(self, href);
     }
 
     // TODO return a disposable string
-    pub fn get_username(self: *parser.Anchor, alloc: std.mem.Allocator) ![]const u8 {
-        var u = try url(self, alloc);
-        defer u.deinit(alloc);
-
-        return try alloc.dupe(u8, u.get_username());
+    pub fn get_username(self: *parser.Anchor, state: *SessionState) ![]const u8 {
+        var u = try url(self, state);
+        return try state.arena.dupe(u8, u.get_username());
     }
 
-    pub fn set_username(self: *parser.Anchor, alloc: std.mem.Allocator, v: ?[]const u8) !void {
-        var u = try url(self, alloc);
-        defer u.deinit(alloc);
+    pub fn set_username(self: *parser.Anchor, v: ?[]const u8, state: *SessionState) !void {
+        const arena = state.arena;
+        var u = try url(self, state);
 
         if (v) |vv| {
             u.uri.user = .{ .raw = vv };
         } else {
             u.uri.user = null;
         }
-        const href = try u.format(alloc);
-        defer alloc.free(href);
+        const href = try u.format(arena);
+        defer arena.free(href);
 
         try parser.anchorSetHref(self, href);
     }
 
     // TODO return a disposable string
-    pub fn get_password(self: *parser.Anchor, alloc: std.mem.Allocator) ![]const u8 {
-        var u = try url(self, alloc);
-        defer u.deinit(alloc);
-
-        return try alloc.dupe(u8, u.get_password());
+    pub fn get_password(self: *parser.Anchor, state: *SessionState) ![]const u8 {
+        var u = try url(self, state);
+        return try state.arena.dupe(u8, u.get_password());
     }
 
-    pub fn set_password(self: *parser.Anchor, alloc: std.mem.Allocator, v: ?[]const u8) !void {
-        var u = try url(self, alloc);
-        defer u.deinit(alloc);
+    pub fn set_password(self: *parser.Anchor, v: ?[]const u8, state: *SessionState) !void {
+        const arena = state.arena;
+        var u = try url(self, state);
 
         if (v) |vv| {
             u.uri.password = .{ .raw = vv };
         } else {
             u.uri.password = null;
         }
-        const href = try u.format(alloc);
-        defer alloc.free(href);
+        const href = try u.format(arena);
+        defer arena.free(href);
 
         try parser.anchorSetHref(self, href);
     }
 
     // TODO return a disposable string
-    pub fn get_pathname(self: *parser.Anchor, alloc: std.mem.Allocator) ![]const u8 {
-        var u = try url(self, alloc);
-        defer u.deinit(alloc);
-
-        return try alloc.dupe(u8, u.get_pathname());
+    pub fn get_pathname(self: *parser.Anchor, state: *SessionState) ![]const u8 {
+        var u = try url(self, state);
+        return try state.arena.dupe(u8, u.get_pathname());
     }
 
-    pub fn set_pathname(self: *parser.Anchor, alloc: std.mem.Allocator, v: []const u8) !void {
-        var u = try url(self, alloc);
-        defer u.deinit(alloc);
-
+    pub fn set_pathname(self: *parser.Anchor, v: []const u8, state: *SessionState) !void {
+        const arena = state.arena;
+        var u = try url(self, state);
         u.uri.path = .{ .raw = v };
-        const href = try u.format(alloc);
-        defer alloc.free(href);
+        const href = try u.format(arena);
+        defer arena.free(href);
 
         try parser.anchorSetHref(self, href);
     }
 
     // TODO return a disposable string
-    pub fn get_search(self: *parser.Anchor, alloc: std.mem.Allocator) ![]const u8 {
-        var u = try url(self, alloc);
-        defer u.deinit(alloc);
-
-        return try u.get_search(alloc);
+    pub fn get_search(self: *parser.Anchor, state: *SessionState) ![]const u8 {
+        var u = try url(self, state);
+        return try u.get_search(state);
     }
 
-    pub fn set_search(self: *parser.Anchor, alloc: std.mem.Allocator, v: ?[]const u8) !void {
-        var u = try url(self, alloc);
-        defer u.deinit(alloc);
+    pub fn set_search(self: *parser.Anchor, v: ?[]const u8, state: *SessionState) !void {
+        const arena = state.arena;
+        var u = try url(self, state);
 
         if (v) |vv| {
             u.uri.query = .{ .raw = vv };
         } else {
             u.uri.query = null;
         }
-        const href = try u.format(alloc);
-        defer alloc.free(href);
+        const href = try u.format(arena);
+        defer arena.free(href);
 
         try parser.anchorSetHref(self, href);
     }
 
     // TODO return a disposable string
-    pub fn get_hash(self: *parser.Anchor, alloc: std.mem.Allocator) ![]const u8 {
-        var u = try url(self, alloc);
-        defer u.deinit(alloc);
-
-        return try u.get_hash(alloc);
+    pub fn get_hash(self: *parser.Anchor, state: *SessionState) ![]const u8 {
+        var u = try url(self, state);
+        return try u.get_hash(state);
     }
 
-    pub fn set_hash(self: *parser.Anchor, alloc: std.mem.Allocator, v: ?[]const u8) !void {
-        var u = try url(self, alloc);
-        defer u.deinit(alloc);
+    pub fn set_hash(self: *parser.Anchor, v: ?[]const u8, state: *SessionState) !void {
+        const arena = state.arena;
+        var u = try url(self, state);
 
         if (v) |vv| {
             u.uri.fragment = .{ .raw = vv };
         } else {
             u.uri.fragment = null;
         }
-        const href = try u.format(alloc);
-        defer alloc.free(href);
+        const href = try u.format(arena);
+        defer arena.free(href);
 
         try parser.anchorSetHref(self, href);
     }
-
-    pub fn deinit(_: *parser.Anchor, _: std.mem.Allocator) void {}
 };
 
 pub const HTMLAppletElement = struct {
     pub const Self = parser.Applet;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLAreaElement = struct {
     pub const Self = parser.Area;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLAudioElement = struct {
     pub const Self = parser.Audio;
     pub const prototype = *HTMLMediaElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLBRElement = struct {
     pub const Self = parser.BR;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLBaseElement = struct {
     pub const Self = parser.Base;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLBodyElement = struct {
     pub const Self = parser.Body;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLButtonElement = struct {
     pub const Self = parser.Button;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLCanvasElement = struct {
     pub const Self = parser.Canvas;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLDListElement = struct {
     pub const Self = parser.DList;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLDataElement = struct {
     pub const Self = parser.Data;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLDataListElement = struct {
     pub const Self = parser.DataList;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLDialogElement = struct {
     pub const Self = parser.Dialog;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLDirectoryElement = struct {
     pub const Self = parser.Directory;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLDivElement = struct {
     pub const Self = parser.Div;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLEmbedElement = struct {
     pub const Self = parser.Embed;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLFieldSetElement = struct {
     pub const Self = parser.FieldSet;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLFontElement = struct {
     pub const Self = parser.Font;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLFormElement = struct {
     pub const Self = parser.Form;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLFrameElement = struct {
     pub const Self = parser.Frame;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLFrameSetElement = struct {
     pub const Self = parser.FrameSet;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLHRElement = struct {
     pub const Self = parser.HR;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLHeadElement = struct {
     pub const Self = parser.Head;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLHeadingElement = struct {
     pub const Self = parser.Heading;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLHtmlElement = struct {
     pub const Self = parser.Html;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLIFrameElement = struct {
     pub const Self = parser.IFrame;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLImageElement = struct {
     pub const Self = parser.Image;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLInputElement = struct {
     pub const Self = parser.Input;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLLIElement = struct {
     pub const Self = parser.LI;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLLabelElement = struct {
     pub const Self = parser.Label;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLLegendElement = struct {
     pub const Self = parser.Legend;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLLinkElement = struct {
     pub const Self = parser.Link;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLMapElement = struct {
     pub const Self = parser.Map;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLMetaElement = struct {
     pub const Self = parser.Meta;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLMeterElement = struct {
     pub const Self = parser.Meter;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLModElement = struct {
     pub const Self = parser.Mod;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLOListElement = struct {
     pub const Self = parser.OList;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLObjectElement = struct {
     pub const Self = parser.Object;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLOptGroupElement = struct {
     pub const Self = parser.OptGroup;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLOptionElement = struct {
     pub const Self = parser.Option;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLOutputElement = struct {
     pub const Self = parser.Output;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLParagraphElement = struct {
     pub const Self = parser.Paragraph;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLParamElement = struct {
     pub const Self = parser.Param;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLPictureElement = struct {
     pub const Self = parser.Picture;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLPreElement = struct {
     pub const Self = parser.Pre;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLProgressElement = struct {
     pub const Self = parser.Progress;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLQuoteElement = struct {
     pub const Self = parser.Quote;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 // https://html.spec.whatwg.org/#the-script-element
 pub const HTMLScriptElement = struct {
     pub const Self = parser.Script;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 
     pub fn get_src(self: *parser.Script) !?[]const u8 {
         return try parser.elementGetAttribute(
@@ -837,103 +755,86 @@ pub const HTMLScriptElement = struct {
 pub const HTMLSelectElement = struct {
     pub const Self = parser.Select;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLSourceElement = struct {
     pub const Self = parser.Source;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLSpanElement = struct {
     pub const Self = parser.Span;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLStyleElement = struct {
     pub const Self = parser.Style;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLTableElement = struct {
     pub const Self = parser.Table;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLTableCaptionElement = struct {
     pub const Self = parser.TableCaption;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLTableCellElement = struct {
     pub const Self = parser.TableCell;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLTableColElement = struct {
     pub const Self = parser.TableCol;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLTableRowElement = struct {
     pub const Self = parser.TableRow;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLTableSectionElement = struct {
     pub const Self = parser.TableSection;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLTemplateElement = struct {
     pub const Self = parser.Template;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLTextAreaElement = struct {
     pub const Self = parser.TextArea;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLTimeElement = struct {
     pub const Self = parser.Time;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLTitleElement = struct {
     pub const Self = parser.Title;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLTrackElement = struct {
     pub const Self = parser.Track;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLUListElement = struct {
     pub const Self = parser.UList;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub const HTMLVideoElement = struct {
     pub const Self = parser.Video;
     pub const prototype = *HTMLElement;
-    pub const mem_guarantied = true;
 };
 
 pub fn toInterface(comptime T: type, e: *parser.Element) !T {
@@ -1010,89 +911,84 @@ pub fn toInterface(comptime T: type, e: *parser.Element) !T {
     };
 }
 
-// Tests
-// -----
+const testing = @import("../../testing.zig");
+test "Browser.HTML.Element" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
 
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var anchor = [_]Case{
-        .{ .src = "let a = document.getElementById('link')", .ex = "undefined" },
-        .{ .src = "a.target", .ex = "" },
-        .{ .src = "a.target = '_blank'", .ex = "_blank" },
-        .{ .src = "a.target", .ex = "_blank" },
-        .{ .src = "a.target = ''", .ex = "" },
+    try runner.testCases(&.{
+        .{ "let a = document.getElementById('link')", "undefined" },
+        .{ "a.target", "" },
+        .{ "a.target = '_blank'", "_blank" },
+        .{ "a.target", "_blank" },
+        .{ "a.target = ''", "" },
 
-        .{ .src = "a.href", .ex = "foo" },
-        .{ .src = "a.href = 'https://lightpanda.io/'", .ex = "https://lightpanda.io/" },
-        .{ .src = "a.href", .ex = "https://lightpanda.io/" },
+        .{ "a.href", "foo" },
+        .{ "a.href = 'https://lightpanda.io/'", "https://lightpanda.io/" },
+        .{ "a.href", "https://lightpanda.io/" },
 
-        .{ .src = "a.origin", .ex = "https://lightpanda.io" },
+        .{ "a.origin", "https://lightpanda.io" },
 
-        .{ .src = "a.host = 'lightpanda.io:443'", .ex = "lightpanda.io:443" },
-        .{ .src = "a.host", .ex = "lightpanda.io:443" },
-        .{ .src = "a.port", .ex = "443" },
-        .{ .src = "a.hostname", .ex = "lightpanda.io" },
+        .{ "a.host = 'lightpanda.io:443'", "lightpanda.io:443" },
+        .{ "a.host", "lightpanda.io:443" },
+        .{ "a.port", "443" },
+        .{ "a.hostname", "lightpanda.io" },
 
-        .{ .src = "a.host = 'lightpanda.io'", .ex = "lightpanda.io" },
-        .{ .src = "a.host", .ex = "lightpanda.io" },
-        .{ .src = "a.port", .ex = "" },
-        .{ .src = "a.hostname", .ex = "lightpanda.io" },
+        .{ "a.host = 'lightpanda.io'", "lightpanda.io" },
+        .{ "a.host", "lightpanda.io" },
+        .{ "a.port", "" },
+        .{ "a.hostname", "lightpanda.io" },
 
-        .{ .src = "a.host", .ex = "lightpanda.io" },
-        .{ .src = "a.hostname", .ex = "lightpanda.io" },
-        .{ .src = "a.hostname = 'foo.bar'", .ex = "foo.bar" },
-        .{ .src = "a.href", .ex = "https://foo.bar/" },
+        .{ "a.host", "lightpanda.io" },
+        .{ "a.hostname", "lightpanda.io" },
+        .{ "a.hostname = 'foo.bar'", "foo.bar" },
+        .{ "a.href", "https://foo.bar/" },
 
-        .{ .src = "a.search", .ex = "" },
-        .{ .src = "a.search = 'q=bar'", .ex = "q=bar" },
-        .{ .src = "a.search", .ex = "?q=bar" },
-        .{ .src = "a.href", .ex = "https://foo.bar/?q=bar" },
+        .{ "a.search", "" },
+        .{ "a.search = 'q=bar'", "q=bar" },
+        .{ "a.search", "?q=bar" },
+        .{ "a.href", "https://foo.bar/?q=bar" },
 
-        .{ .src = "a.hash", .ex = "" },
-        .{ .src = "a.hash = 'frag'", .ex = "frag" },
-        .{ .src = "a.hash", .ex = "#frag" },
-        .{ .src = "a.href", .ex = "https://foo.bar/?q=bar#frag" },
+        .{ "a.hash", "" },
+        .{ "a.hash = 'frag'", "frag" },
+        .{ "a.hash", "#frag" },
+        .{ "a.href", "https://foo.bar/?q=bar#frag" },
 
-        .{ .src = "a.port", .ex = "" },
-        .{ .src = "a.port = '443'", .ex = "443" },
-        .{ .src = "a.host", .ex = "foo.bar:443" },
-        .{ .src = "a.hostname", .ex = "foo.bar" },
-        .{ .src = "a.href", .ex = "https://foo.bar:443/?q=bar#frag" },
-        .{ .src = "a.port = null", .ex = "null" },
-        .{ .src = "a.href", .ex = "https://foo.bar/?q=bar#frag" },
+        .{ "a.port", "" },
+        .{ "a.port = '443'", "443" },
+        .{ "a.host", "foo.bar:443" },
+        .{ "a.hostname", "foo.bar" },
+        .{ "a.href", "https://foo.bar:443/?q=bar#frag" },
+        .{ "a.port = null", "null" },
+        .{ "a.href", "https://foo.bar/?q=bar#frag" },
 
-        .{ .src = "a.href = 'foo'", .ex = "foo" },
+        .{ "a.href = 'foo'", "foo" },
 
-        .{ .src = "a.type", .ex = "" },
-        .{ .src = "a.type = 'text/html'", .ex = "text/html" },
-        .{ .src = "a.type", .ex = "text/html" },
-        .{ .src = "a.type = ''", .ex = "" },
+        .{ "a.type", "" },
+        .{ "a.type = 'text/html'", "text/html" },
+        .{ "a.type", "text/html" },
+        .{ "a.type = ''", "" },
 
-        .{ .src = "a.text", .ex = "OK" },
-        .{ .src = "a.text = 'foo'", .ex = "foo" },
-        .{ .src = "a.text", .ex = "foo" },
-        .{ .src = "a.text = 'OK'", .ex = "OK" },
-    };
-    try checkCases(js_env, &anchor);
+        .{ "a.text", "OK" },
+        .{ "a.text = 'foo'", "foo" },
+        .{ "a.text", "foo" },
+        .{ "a.text = 'OK'", "OK" },
+    }, .{});
 
-    var script = [_]Case{
-        .{ .src = "let script = document.createElement('script')", .ex = "undefined" },
-        .{ .src = "script.src = 'foo.bar'", .ex = "foo.bar" },
+    try runner.testCases(&.{
+        .{ "let script = document.createElement('script')", "undefined" },
+        .{ "script.src = 'foo.bar'", "foo.bar" },
 
-        .{ .src = "script.async = true", .ex = "true" },
-        .{ .src = "script.async", .ex = "true" },
-        .{ .src = "script.async = false", .ex = "false" },
-        .{ .src = "script.async", .ex = "false" },
-    };
-    try checkCases(js_env, &script);
+        .{ "script.async = true", "true" },
+        .{ "script.async", "true" },
+        .{ "script.async = false", "false" },
+        .{ "script.async", "false" },
+    }, .{});
 
-    var innertext = [_]Case{
-        .{ .src = "const backup = document.getElementById('content')", .ex = "undefined" },
-        .{ .src = "document.getElementById('content').innerText = 'foo';", .ex = "foo" },
-        .{ .src = "document.getElementById('content').innerText", .ex = "foo" },
-        .{ .src = "document.getElementById('content').innerHTML = backup; true;", .ex = "true" },
-    };
-    try checkCases(js_env, &innertext);
+    try runner.testCases(&.{
+        .{ "const backup = document.getElementById('content')", "undefined" },
+        .{ "document.getElementById('content').innerText = 'foo';", "foo" },
+        .{ "document.getElementById('content').innerText", "foo" },
+        .{ "document.getElementById('content').innerHTML = backup; true;", "true" },
+    }, .{});
 }
diff --git a/src/html/history.zig b/src/browser/html/history.zig
similarity index 73%
rename from src/html/history.zig
rename to src/browser/html/history.zig
index f3e96dc0..8cdf0206 100644
--- a/src/html/history.zig
+++ b/src/browser/html/history.zig
@@ -26,8 +26,6 @@ const checkCases = jsruntime.test_utils.checkCases;
 
 // https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-history-interface
 pub const History = struct {
-    pub const mem_guarantied = true;
-
     const ScrollRestorationMode = enum {
         auto,
         manual,
@@ -98,31 +96,31 @@ pub const History = struct {
 // Tests
 // -----
 
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var history = [_]Case{
-        .{ .src = "history.scrollRestoration", .ex = "auto" },
-        .{ .src = "history.scrollRestoration = 'manual'", .ex = "manual" },
-        .{ .src = "history.scrollRestoration = 'foo'", .ex = "foo" },
-        .{ .src = "history.scrollRestoration", .ex = "manual" },
-        .{ .src = "history.scrollRestoration = 'auto'", .ex = "auto" },
-        .{ .src = "history.scrollRestoration", .ex = "auto" },
+const testing = @import("../../testing.zig");
+test "Browser.HTML.History" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
 
-        .{ .src = "history.state", .ex = "null" },
+    try runner.testCases(&.{
+        .{ "history.scrollRestoration", "auto" },
+        .{ "history.scrollRestoration = 'manual'", "manual" },
+        .{ "history.scrollRestoration = 'foo'", "foo" },
+        .{ "history.scrollRestoration", "manual" },
+        .{ "history.scrollRestoration = 'auto'", "auto" },
+        .{ "history.scrollRestoration", "auto" },
 
-        .{ .src = "history.pushState({}, null, '')", .ex = "undefined" },
+        .{ "history.state", "null" },
 
-        .{ .src = "history.replaceState({}, null, '')", .ex = "undefined" },
+        .{ "history.pushState({}, null, '')", "undefined" },
 
-        .{ .src = "history.go()", .ex = "undefined" },
-        .{ .src = "history.go(1)", .ex = "undefined" },
-        .{ .src = "history.go(-1)", .ex = "undefined" },
+        .{ "history.replaceState({}, null, '')", "undefined" },
 
-        .{ .src = "history.forward()", .ex = "undefined" },
+        .{ "history.go()", "undefined" },
+        .{ "history.go(1)", "undefined" },
+        .{ "history.go(-1)", "undefined" },
 
-        .{ .src = "history.back()", .ex = "undefined" },
-    };
-    try checkCases(js_env, &history);
+        .{ "history.forward()", "undefined" },
+
+        .{ "history.back()", "undefined" },
+    }, .{});
 }
diff --git a/src/html/html.zig b/src/browser/html/html.zig
similarity index 96%
rename from src/html/html.zig
rename to src/browser/html/html.zig
index 9aae1de2..513e3fe5 100644
--- a/src/html/html.zig
+++ b/src/browser/html/html.zig
@@ -16,8 +16,6 @@
 // You should have received a copy of the GNU Affero General Public License
 // along with this program.  If not, see .
 
-const generate = @import("../generate.zig");
-
 const HTMLDocument = @import("document.zig").HTMLDocument;
 const HTMLElem = @import("elements.zig");
 const Window = @import("window.zig").Window;
diff --git a/src/browser/html/location.zig b/src/browser/html/location.zig
new file mode 100644
index 00000000..9f229fb7
--- /dev/null
+++ b/src/browser/html/location.zig
@@ -0,0 +1,115 @@
+// Copyright (C) 2023-2024  Lightpanda (Selecy SAS)
+//
+// Francis Bouvier 
+// Pierre Tachoire 
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see .
+
+const std = @import("std");
+
+const SessionState = @import("../env.zig").SessionState;
+
+const builtin = @import("builtin");
+const jsruntime = @import("jsruntime");
+
+const URL = @import("../url/url.zig").URL;
+
+const Case = jsruntime.test_utils.Case;
+const checkCases = jsruntime.test_utils.checkCases;
+
+// https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-location-interface
+pub const Location = struct {
+    url: ?URL = null,
+
+    pub fn get_href(self: *Location, state: *SessionState) ![]const u8 {
+        if (self.url) |*u| return u.get_href(state);
+        return "";
+    }
+
+    pub fn get_protocol(self: *Location, state: *SessionState) ![]const u8 {
+        if (self.url) |*u| return u.get_protocol(state);
+        return "";
+    }
+
+    pub fn get_host(self: *Location, state: *SessionState) ![]const u8 {
+        if (self.url) |*u| return u.get_host(state);
+        return "";
+    }
+
+    pub fn get_hostname(self: *Location) []const u8 {
+        if (self.url) |*u| return u.get_hostname();
+        return "";
+    }
+
+    pub fn get_port(self: *Location, state: *SessionState) ![]const u8 {
+        if (self.url) |*u| return u.get_port(state);
+        return "";
+    }
+
+    pub fn get_pathname(self: *Location) []const u8 {
+        if (self.url) |*u| return u.get_pathname();
+        return "";
+    }
+
+    pub fn get_search(self: *Location, state: *SessionState) ![]const u8 {
+        if (self.url) |*u| return u.get_search(state);
+        return "";
+    }
+
+    pub fn get_hash(self: *Location, state: *SessionState) ![]const u8 {
+        if (self.url) |*u| return u.get_hash(state);
+        return "";
+    }
+
+    pub fn get_origin(self: *Location, state: *SessionState) ![]const u8 {
+        if (self.url) |*u| return u.get_origin(state);
+        return "";
+    }
+
+    // TODO
+    pub fn _assign(_: *Location, url: []const u8) !void {
+        _ = url;
+    }
+
+    // TODO
+    pub fn _replace(_: *Location, url: []const u8) !void {
+        _ = url;
+    }
+
+    // TODO
+    pub fn _reload(_: *Location) !void {}
+
+    pub fn _toString(self: *Location, state: *SessionState) ![]const u8 {
+        return try self.get_href(state);
+    }
+};
+
+const testing = @import("../../testing.zig");
+test "Browser.HTML.Location" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
+
+    try runner.testCases(&.{
+        .{ "location.href", "https://lightpanda.io/opensource-browser/" },
+        .{ "document.location.href", "https://lightpanda.io/opensource-browser/" },
+
+        .{ "location.host", "lightpanda.io" },
+        .{ "location.hostname", "lightpanda.io" },
+        .{ "location.origin", "https://lightpanda.io" },
+        .{ "location.pathname", "/opensource-browser/" },
+        .{ "location.hash", "" },
+        .{ "location.port", "" },
+        .{ "location.search", "" },
+    }, .{});
+}
diff --git a/src/html/navigator.zig b/src/browser/html/navigator.zig
similarity index 87%
rename from src/html/navigator.zig
rename to src/browser/html/navigator.zig
index aebb7b19..f05fef2b 100644
--- a/src/html/navigator.zig
+++ b/src/browser/html/navigator.zig
@@ -26,8 +26,6 @@ const checkCases = jsruntime.test_utils.checkCases;
 
 // https://html.spec.whatwg.org/multipage/system-state.html#navigator
 pub const Navigator = struct {
-    pub const mem_guarantied = true;
-
     agent: []const u8 = "Lightpanda/1.0",
     version: []const u8 = "1.0",
     vendor: []const u8 = "",
@@ -89,14 +87,14 @@ pub const Navigator = struct {
 // Tests
 // -----
 
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var navigator = [_]Case{
-        .{ .src = "navigator.userAgent", .ex = "Lightpanda/1.0" },
-        .{ .src = "navigator.appVersion", .ex = "1.0" },
-        .{ .src = "navigator.language", .ex = "en-US" },
-    };
-    try checkCases(js_env, &navigator);
+const testing = @import("../../testing.zig");
+test "Browser.HTML.Navigator" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
+
+    try runner.testCases(&.{
+        .{ "navigator.userAgent", "Lightpanda/1.0" },
+        .{ "navigator.appVersion", "1.0" },
+        .{ "navigator.language", "en-US" },
+    }, .{});
 }
diff --git a/src/html/window.zig b/src/browser/html/window.zig
similarity index 87%
rename from src/html/window.zig
rename to src/browser/html/window.zig
index 36b580ba..ff662237 100644
--- a/src/html/window.zig
+++ b/src/browser/html/window.zig
@@ -18,17 +18,14 @@
 
 const std = @import("std");
 
-const parser = @import("netsurf");
-const jsruntime = @import("jsruntime");
-const Callback = jsruntime.Callback;
-const CallbackArg = jsruntime.CallbackArg;
-const Loop = jsruntime.Loop;
+const parser = @import("../netsurf.zig");
+const Callback = @import("../env.zig").Callback;
+const SessionState = @import("../env.zig").SessionState;
 
-const URL = @import("../../../url.zig").URL;
-const EventTarget = @import("../dom/event_target.zig").EventTarget;
 const Navigator = @import("navigator.zig").Navigator;
 const History = @import("history.zig").History;
 const Location = @import("location.zig").Location;
+const EventTarget = @import("../dom/event_target.zig").EventTarget;
 
 const storage = @import("../storage/storage.zig");
 
@@ -36,14 +33,13 @@ const storage = @import("../storage/storage.zig");
 // https://html.spec.whatwg.org/multipage/nav-history-apis.html#window
 pub const Window = struct {
     pub const prototype = *EventTarget;
-    pub const mem_guarantied = true;
     pub const global_type = true;
 
     // Extend libdom event target for pure zig struct.
     base: parser.EventTargetTBase = parser.EventTargetTBase{},
 
     document: ?*parser.DocumentHTML = null,
-    target: []const u8,
+    target: []const u8 = "",
     history: History = .{},
     location: Location = .{},
     storage_shelf: ?*storage.Shelf = null,
@@ -53,7 +49,7 @@ pub const Window = struct {
     timeoutid: u32 = 0,
     timeoutids: [512]u64 = undefined,
 
-    navigator: Navigator,
+    navigator: Navigator = .{},
 
     pub fn create(target: ?[]const u8, navigator: ?Navigator) Window {
         return .{
@@ -121,11 +117,11 @@ pub const Window = struct {
     }
 
     // TODO handle callback arguments.
-    pub fn _setTimeout(self: *Window, loop: *Loop, cbk: Callback, delay: ?u32) !u32 {
+    pub fn _setTimeout(self: *Window, state: *SessionState, cbk: Callback, delay: ?u32) !u32 {
         if (self.timeoutid >= self.timeoutids.len) return error.TooMuchTimeout;
 
         const ddelay: u63 = delay orelse 0;
-        const id = try loop.timeout(ddelay * std.time.ns_per_ms, cbk);
+        const id = try state.loop.timeout(ddelay * std.time.ns_per_ms, cbk);
 
         self.timeoutids[self.timeoutid] = id;
         defer self.timeoutid += 1;
@@ -133,12 +129,12 @@ pub const Window = struct {
         return self.timeoutid;
     }
 
-    pub fn _clearTimeout(self: *Window, loop: *Loop, id: u32) !void {
+    pub fn _clearTimeout(self: *Window, state: *SessionState, id: u32) !void {
         // I do would prefer return an error in this case, but it seems some JS
         // uses invalid id, in particular id 0.
         // So we silently ignore invalid id for now.
         if (id >= self.timeoutid) return;
 
-        try loop.cancel(self.timeoutids[id], null);
+        try state.loop.cancel(self.timeoutids[id], null);
     }
 };
diff --git a/src/iterator/iterator.zig b/src/browser/iterator/iterator.zig
similarity index 97%
rename from src/iterator/iterator.zig
rename to src/browser/iterator/iterator.zig
index d582be11..aa248509 100644
--- a/src/iterator/iterator.zig
+++ b/src/browser/iterator/iterator.zig
@@ -5,8 +5,6 @@ pub const Interfaces = .{
 };
 
 pub const U32Iterator = struct {
-    pub const mem_guarantied = true;
-
     length: u32,
     index: u32 = 0,
 
diff --git a/src/mimalloc/mimalloc.zig b/src/browser/mimalloc.zig
similarity index 100%
rename from src/mimalloc/mimalloc.zig
rename to src/browser/mimalloc.zig
diff --git a/src/browser/mime.zig b/src/browser/mime.zig
index 2f885d5f..33e14cba 100644
--- a/src/browser/mime.zig
+++ b/src/browser/mime.zig
@@ -23,7 +23,6 @@ pub const Mime = struct {
     content_type: ContentType,
     params: []const u8 = "",
     charset: ?[]const u8 = null,
-    arena: std.heap.ArenaAllocator,
 
     pub const ContentTypeEnum = enum {
         text_xml,
@@ -39,19 +38,15 @@ pub const Mime = struct {
         other: struct { type: []const u8, sub_type: []const u8 },
     };
 
-    pub fn parse(allocator: Allocator, input: []const u8) !Mime {
+    pub fn parse(arena: Allocator, input: []const u8) !Mime {
         if (input.len > 255) {
             return error.TooBig;
         }
-
-        var arena = std.heap.ArenaAllocator.init(allocator);
-        errdefer arena.deinit();
-
         var trimmed = trim(input);
 
         const content_type, const type_len = try parseContentType(trimmed);
         if (type_len >= trimmed.len) {
-            return .{ .arena = arena, .content_type = content_type };
+            return .{ .content_type = content_type };
         }
 
         const params = trimLeft(trimmed[type_len..]);
@@ -70,24 +65,19 @@ pub const Mime = struct {
 
             switch (name.len) {
                 7 => if (isCaseEqual("charset", name)) {
-                    charset = try parseValue(arena.allocator(), value);
+                    charset = try parseValue(arena, value);
                 },
                 else => {},
             }
         }
 
         return .{
-            .arena = arena,
             .params = params,
             .charset = charset,
             .content_type = content_type,
         };
     }
 
-    pub fn deinit(self: *Mime) void {
-        self.arena.deinit();
-    }
-
     pub fn isHTML(self: *const Mime) bool {
         return self.content_type == .text_html;
     }
@@ -158,7 +148,7 @@ pub const Mime = struct {
         break :blk v;
     };
 
-    fn parseValue(allocator: Allocator, value: []const u8) ![]const u8 {
+    fn parseValue(arena: Allocator, value: []const u8) ![]const u8 {
         if (value[0] != '"') {
             return value;
         }
@@ -191,7 +181,7 @@ pub const Mime = struct {
         }
 
         value_pos = 1;
-        const owned = try allocator.alloc(u8, unescaped_len);
+        const owned = try arena.alloc(u8, unescaped_len);
         for (0..unescaped_len) |i| {
             switch (value[value_pos]) {
                 '"' => break,
@@ -344,8 +334,9 @@ test "Mime: parse charset" {
 test "Mime: isHTML" {
     const isHTML = struct {
         fn isHTML(expected: bool, input: []const u8) !void {
-            var mime = try Mime.parse(testing.allocator, input);
-            defer mime.deinit();
+            var arena = std.heap.ArenaAllocator.init(testing.allocator);
+            defer arena.deinit();
+            var mime = try Mime.parse(arena.allocator(), input);
             try testing.expectEqual(expected, mime.isHTML());
         }
     }.isHTML;
@@ -364,8 +355,10 @@ const Expectation = struct {
 };
 
 fn expect(expected: Expectation, input: []const u8) !void {
-    var actual = try Mime.parse(testing.allocator, input);
-    defer actual.deinit();
+    var arena = std.heap.ArenaAllocator.init(testing.allocator);
+    defer arena.deinit();
+
+    const actual = try Mime.parse(arena.allocator(), input);
 
     try testing.expectEqual(
         std.meta.activeTag(expected.content_type),
diff --git a/src/netsurf/netsurf.zig b/src/browser/netsurf.zig
similarity index 99%
rename from src/netsurf/netsurf.zig
rename to src/browser/netsurf.zig
index 92c0bbea..eef41155 100644
--- a/src/netsurf/netsurf.zig
+++ b/src/browser/netsurf.zig
@@ -27,9 +27,10 @@ const c = @cImport({
     @cInclude("events/mouse_event.h");
 });
 
-const mimalloc = @import("mimalloc");
+const mimalloc = @import("mimalloc.zig");
 
-const Callback = @import("jsruntime").Callback;
+const Callback = @import("env.zig").Callback;
+const SessionState = @import("env.zig").SessionState;
 
 // init initializes netsurf lib.
 // init starts a mimalloc heap arena for the netsurf session. The caller must
@@ -617,7 +618,7 @@ pub fn eventTargetHasListener(
             defer c.dom_event_listener_unref(listener);
             const ehd = EventHandlerDataInternal.fromListener(listener);
             if (ehd) |d| {
-                if (cbk_id == d.data.cbk.id()) {
+                if (cbk_id == d.data.cbk.id) {
                     return lst;
                 }
             }
@@ -669,7 +670,7 @@ pub const EventHandlerData = struct {
     // deinitFunc implements the data deinitialization.
     deinitFunc: ?DeinitFunc = null,
 
-    pub const DeinitFunc = *const fn (data: ?*anyopaque, alloc: std.mem.Allocator) void;
+    pub const DeinitFunc = *const fn (data: ?*anyopaque, allocator: std.mem.Allocator) void;
 };
 
 // EventHandlerDataInternal groups the EventHandlerFunc and the EventHandlerData.
@@ -687,8 +688,9 @@ const EventHandlerDataInternal = struct {
     }
 
     fn deinit(self: *EventHandlerDataInternal, alloc: std.mem.Allocator) void {
-        if (self.data.deinitFunc) |d| d(self.data.data, alloc);
-        self.data.cbk.deinit(alloc);
+        if (self.data.deinitFunc) |d| {
+            d(self.data.data, alloc);
+        }
         alloc.destroy(self);
     }
 
@@ -723,7 +725,7 @@ pub fn eventTargetAddEventListener(
     // When a function is used as an event handler, its this parameter is bound
     // to the DOM element on which the listener is placed.
     // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this#this_in_dom_event_handlers
-    try ehd.data.cbk.setThisArg(et);
+    try ehd.data.cbk.setThis(et);
 
     const ctx = @as(*anyopaque, @ptrCast(ehd));
     var listener: ?*EventListener = undefined;
diff --git a/src/polyfill/fetch.js b/src/browser/polyfill/fetch.js
similarity index 100%
rename from src/polyfill/fetch.js
rename to src/browser/polyfill/fetch.js
diff --git a/src/browser/polyfill/fetch.zig b/src/browser/polyfill/fetch.zig
new file mode 100644
index 00000000..ca6c2c12
--- /dev/null
+++ b/src/browser/polyfill/fetch.zig
@@ -0,0 +1,47 @@
+const std = @import("std");
+// fetch.js code comes from
+// https://github.com/JakeChampion/fetch/blob/main/fetch.js
+//
+// The original code source is available in MIT license.
+//
+// The script comes from the built version from npm.
+// You can get the package with the command:
+//
+// wget $(npm view whatwg-fetch dist.tarball)
+//
+// The source is the content of `package/dist/fetch.umd.js` file.
+pub const source = @embedFile("fetch.js");
+
+const testing = @import("../../testing.zig");
+test "Browser.fetch" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
+
+    try @import("polyfill.zig").load(testing.allocator, runner.executor);
+
+    try runner.testCases(&.{
+        .{
+            \\  var ok = false;
+            \\  const request = new Request("http://127.0.0.1:9582/loader");
+            \\  fetch(request).then((response) => { ok = response.ok; });
+            \\  false;
+            ,
+            "false",
+        },
+        // all events have been resolved.
+        .{ "ok", "true" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{
+            \\  var ok2 = false;
+            \\  const request2 = new Request("http://127.0.0.1:9582/loader");
+            \\  (async function () { resp = await fetch(request2); ok2 = resp.ok; }());
+            \\  false;
+            ,
+            "false",
+        },
+        // all events have been resolved.
+        .{ "ok2", "true" },
+    }, .{});
+}
diff --git a/src/polyfill/polyfill.zig b/src/browser/polyfill/polyfill.zig
similarity index 72%
rename from src/polyfill/polyfill.zig
rename to src/browser/polyfill/polyfill.zig
index 65bb0590..9e682ea2 100644
--- a/src/polyfill/polyfill.zig
+++ b/src/browser/polyfill/polyfill.zig
@@ -19,10 +19,8 @@
 const std = @import("std");
 const builtin = @import("builtin");
 
-const jsruntime = @import("jsruntime");
-const Env = jsruntime.Env;
-
-const fetch = @import("fetch.zig").fetch_polyfill;
+const Allocator = std.mem.Allocator;
+const Env = @import("../env.zig").Env;
 
 const log = std.log.scoped(.polyfill);
 
@@ -33,23 +31,23 @@ const modules = [_]struct {
     .{ .name = "polyfill-fetch", .source = @import("fetch.zig").source },
 };
 
-pub fn load(alloc: std.mem.Allocator, env: *const Env) !void {
-    var try_catch: jsruntime.TryCatch = undefined;
-    try_catch.init(env);
+pub fn load(allocator: Allocator, executor: *Env.Executor) !void {
+    var try_catch: Env.TryCatch = undefined;
+    try_catch.init(executor);
     defer try_catch.deinit();
 
     for (modules) |m| {
-        const res = env.exec(m.source, m.name) catch {
-            if (try try_catch.err(alloc, env)) |msg| {
-                defer alloc.free(msg);
+        const res = executor.exec(m.source, m.name) catch |err| {
+            if (try try_catch.err(allocator)) |msg| {
+                defer allocator.free(msg);
                 log.err("load {s}: {s}", .{ m.name, msg });
             }
-            return;
+            return err;
         };
 
         if (builtin.mode == .Debug) {
-            const msg = try res.toString(alloc, env);
-            defer alloc.free(msg);
+            const msg = try res.toString(allocator);
+            defer allocator.free(msg);
             log.debug("load {s}: {s}", .{ m.name, msg });
         }
     }
diff --git a/src/storage/cookie.zig b/src/browser/storage/cookie.zig
similarity index 99%
rename from src/storage/cookie.zig
rename to src/browser/storage/cookie.zig
index e8da2869..dc483cf7 100644
--- a/src/storage/cookie.zig
+++ b/src/browser/storage/cookie.zig
@@ -3,9 +3,9 @@ const Uri = std.Uri;
 const Allocator = std.mem.Allocator;
 const ArenaAllocator = std.heap.ArenaAllocator;
 
-const http = @import("../http/client.zig");
-const DateTime = @import("../datetime.zig").DateTime;
-const public_suffix_list = @import("../data/public_suffix_list.zig").lookup;
+const http = @import("../../http/client.zig");
+const DateTime = @import("../../datetime.zig").DateTime;
+const public_suffix_list = @import("../../data/public_suffix_list.zig").lookup;
 
 const log = std.log.scoped(.cookie);
 
@@ -464,7 +464,7 @@ fn trimRight(str: []const u8) []const u8 {
     return std.mem.trimLeft(u8, str, &std.ascii.whitespace);
 }
 
-const testing = @import("../testing.zig");
+const testing = @import("../../testing.zig");
 test "cookie: findSecondLevelDomain" {
     const cases = [_]struct { []const u8, []const u8 }{
         .{ "", "" },
diff --git a/src/storage/storage.zig b/src/browser/storage/storage.zig
similarity index 88%
rename from src/storage/storage.zig
rename to src/browser/storage/storage.zig
index 17fb8878..b984a815 100644
--- a/src/storage/storage.zig
+++ b/src/browser/storage/storage.zig
@@ -21,7 +21,7 @@ const std = @import("std");
 const jsruntime = @import("jsruntime");
 const Case = jsruntime.test_utils.Case;
 const checkCases = jsruntime.test_utils.checkCases;
-const DOMError = @import("netsurf").DOMError;
+const DOMError = @import("../netsurf.zig").DOMError;
 
 const log = std.log.scoped(.storage);
 
@@ -103,7 +103,6 @@ pub const Bucket = struct {
 
 // https://html.spec.whatwg.org/multipage/webstorage.html#the-storage-interface
 pub const Bottle = struct {
-    pub const mem_guarantied = true;
     const Map = std.StringHashMapUnmanaged([]const u8);
 
     // allocator is stored. we don't use the JS env allocator b/c the storage
@@ -216,27 +215,27 @@ pub const Bottle = struct {
 // Tests
 // -----
 
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var storage = [_]Case{
-        .{ .src = "localStorage.length", .ex = "0" },
+const testing = @import("../../testing.zig");
+test "Browser.Storage.LocalStorage" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
 
-        .{ .src = "localStorage.setItem('foo', 'bar')", .ex = "undefined" },
-        .{ .src = "localStorage.length", .ex = "1" },
-        .{ .src = "localStorage.getItem('foo')", .ex = "bar" },
-        .{ .src = "localStorage.removeItem('foo')", .ex = "undefined" },
-        .{ .src = "localStorage.length", .ex = "0" },
+    try runner.testCases(&.{
+        .{ "localStorage.length", "0" },
 
-        // .{ .src = "localStorage['foo'] = 'bar'", .ex = "undefined" },
-        // .{ .src = "localStorage['foo']", .ex = "bar" },
-        // .{ .src = "localStorage.length", .ex = "1" },
+        .{ "localStorage.setItem('foo', 'bar')", "undefined" },
+        .{ "localStorage.length", "1" },
+        .{ "localStorage.getItem('foo')", "bar" },
+        .{ "localStorage.removeItem('foo')", "undefined" },
+        .{ "localStorage.length", "0" },
 
-        .{ .src = "localStorage.clear()", .ex = "undefined" },
-        .{ .src = "localStorage.length", .ex = "0" },
-    };
-    try checkCases(js_env, &storage);
+        // .{ "localStorage['foo'] = 'bar'", "undefined" },
+        // .{ "localStorage['foo']", "bar" },
+        // .{ "localStorage.length", "1" },
+
+        .{ "localStorage.clear()", "undefined" },
+        .{ "localStorage.length", "0" },
+    }, .{});
 }
 
 test "storage bottle" {
diff --git a/src/url/query.zig b/src/browser/url/query.zig
similarity index 99%
rename from src/url/query.zig
rename to src/browser/url/query.zig
index 14fec1b6..b8afa834 100644
--- a/src/url/query.zig
+++ b/src/browser/url/query.zig
@@ -18,8 +18,8 @@
 
 const std = @import("std");
 
-const Reader = @import("../str/parser.zig").Reader;
-const asUint = @import("../str/parser.zig").asUint;
+const Reader = @import("../../str/parser.zig").Reader;
+const asUint = @import("../../str/parser.zig").asUint;
 
 // Values is a map with string key of string values.
 pub const Values = struct {
diff --git a/src/url/url.zig b/src/browser/url/url.zig
similarity index 62%
rename from src/url/url.zig
rename to src/browser/url/url.zig
index 2e28210d..82bec035 100644
--- a/src/url/url.zig
+++ b/src/browser/url/url.zig
@@ -17,10 +17,7 @@
 // along with this program.  If not, see .
 
 const std = @import("std");
-
-const jsruntime = @import("jsruntime");
-const Case = jsruntime.test_utils.Case;
-const checkCases = jsruntime.test_utils.checkCases;
+const SessionState = @import("../env.zig").SessionState;
 
 const query = @import("query.zig");
 
@@ -31,14 +28,14 @@ pub const Interfaces = .{
 
 // https://url.spec.whatwg.org/#url
 //
-// TODO we could avoid many of these getter string allocation in two differents
+// TODO we could avoid many of these getter string allocatoration in two differents
 // way:
 //
 // 1. We can eventually get the slice of scheme *with* the following char in
 // the underlying string. But I don't know if it's possible and how to do that.
 // I mean, if the rawuri contains `https://foo.bar`, uri.scheme is a slice
 // containing only `https`. I want `https:` so, in theory, I don't need to
-// allocate data, I should be able to retrieve the scheme + the following `:`
+// allocatorate data, I should be able to retrieve the scheme + the following `:`
 // from rawuri.
 //
 // 2. The other way would bu to copy the `std.Uri` code to ahve a dedicated
@@ -47,9 +44,8 @@ pub const URL = struct {
     uri: std.Uri,
     search_params: URLSearchParams,
 
-    pub const mem_guarantied = true;
-
-    pub fn constructor(arena: std.mem.Allocator, url: []const u8, base: ?[]const u8) !URL {
+    pub fn constructor(state: *SessionState, url: []const u8, base: ?[]const u8) !URL {
+        const arena = state.arena;
         const raw = try std.mem.concat(arena, u8, &[_][]const u8{ url, base orelse "" });
         errdefer arena.free(raw);
 
@@ -60,22 +56,18 @@ pub const URL = struct {
     pub fn init(arena: std.mem.Allocator, uri: std.Uri) !URL {
         return .{
             .uri = uri,
-            .search_params = try URLSearchParams.constructor(
+            .search_params = try URLSearchParams.init(
                 arena,
                 uriComponentNullStr(uri.query),
             ),
         };
     }
 
-    pub fn deinit(self: *URL, alloc: std.mem.Allocator) void {
-        self.search_params.deinit(alloc);
-    }
-
     // the caller must free the returned string.
     // TODO return a disposable string
     // https://github.com/lightpanda-io/jsruntime-lib/issues/195
-    pub fn get_origin(self: *URL, alloc: std.mem.Allocator) ![]const u8 {
-        var buf = std.ArrayList(u8).init(alloc);
+    pub fn get_origin(self: *URL, state: *SessionState) ![]const u8 {
+        var buf = std.ArrayList(u8).init(state.arena);
         defer buf.deinit();
 
         try self.uri.writeToStream(.{
@@ -95,21 +87,22 @@ pub const URL = struct {
     // the caller must free the returned string.
     // TODO return a disposable string
     // https://github.com/lightpanda-io/jsruntime-lib/issues/195
-    pub fn get_href(self: *URL, alloc: std.mem.Allocator) ![]const u8 {
+    pub fn get_href(self: *URL, state: *SessionState) ![]const u8 {
+        const arena = state.arena;
         // retrieve the query search from search_params.
         const cur = self.uri.query;
         defer self.uri.query = cur;
-        var q = std.ArrayList(u8).init(alloc);
+        var q = std.ArrayList(u8).init(arena);
         defer q.deinit();
         try self.search_params.values.encode(q.writer());
         self.uri.query = .{ .percent_encoded = q.items };
 
-        return try self.format(alloc);
+        return try self.format(arena);
     }
 
     // format the url with all its components.
-    pub fn format(self: *URL, alloc: std.mem.Allocator) ![]const u8 {
-        var buf = std.ArrayList(u8).init(alloc);
+    pub fn format(self: *URL, arena: std.mem.Allocator) ![]const u8 {
+        var buf = std.ArrayList(u8).init(arena);
         defer buf.deinit();
 
         try self.uri.writeToStream(.{
@@ -126,8 +119,8 @@ pub const URL = struct {
     // the caller must free the returned string.
     // TODO return a disposable string
     // https://github.com/lightpanda-io/jsruntime-lib/issues/195
-    pub fn get_protocol(self: *URL, alloc: std.mem.Allocator) ![]const u8 {
-        return try std.mem.concat(alloc, u8, &[_][]const u8{ self.uri.scheme, ":" });
+    pub fn get_protocol(self: *URL, state: *SessionState) ![]const u8 {
+        return try std.mem.concat(state.arena, u8, &[_][]const u8{ self.uri.scheme, ":" });
     }
 
     pub fn get_username(self: *URL) []const u8 {
@@ -141,8 +134,8 @@ pub const URL = struct {
     // the caller must free the returned string.
     // TODO return a disposable string
     // https://github.com/lightpanda-io/jsruntime-lib/issues/195
-    pub fn get_host(self: *URL, alloc: std.mem.Allocator) ![]const u8 {
-        var buf = std.ArrayList(u8).init(alloc);
+    pub fn get_host(self: *URL, state: *SessionState) ![]const u8 {
+        var buf = std.ArrayList(u8).init(state.arena);
         defer buf.deinit();
 
         try self.uri.writeToStream(.{
@@ -163,10 +156,11 @@ pub const URL = struct {
     // the caller must free the returned string.
     // TODO return a disposable string
     // https://github.com/lightpanda-io/jsruntime-lib/issues/195
-    pub fn get_port(self: *URL, alloc: std.mem.Allocator) ![]const u8 {
-        if (self.uri.port == null) return try alloc.dupe(u8, "");
+    pub fn get_port(self: *URL, state: *SessionState) ![]const u8 {
+        const arena = state.arena;
+        if (self.uri.port == null) return try arena.dupe(u8, "");
 
-        var buf = std.ArrayList(u8).init(alloc);
+        var buf = std.ArrayList(u8).init(arena);
         defer buf.deinit();
 
         try std.fmt.formatInt(self.uri.port.?, 10, .lower, .{}, buf.writer());
@@ -181,32 +175,34 @@ pub const URL = struct {
     // the caller must free the returned string.
     // TODO return a disposable string
     // https://github.com/lightpanda-io/jsruntime-lib/issues/195
-    pub fn get_search(self: *URL, alloc: std.mem.Allocator) ![]const u8 {
-        if (self.search_params.get_size() == 0) return try alloc.dupe(u8, "");
+    pub fn get_search(self: *URL, state: *SessionState) ![]const u8 {
+        const arena = state.arena;
+        if (self.search_params.get_size() == 0) return try arena.dupe(u8, "");
 
         var buf: std.ArrayListUnmanaged(u8) = .{};
-        defer buf.deinit(alloc);
+        defer buf.deinit(arena);
 
-        try buf.append(alloc, '?');
-        try self.search_params.values.encode(buf.writer(alloc));
-        return buf.toOwnedSlice(alloc);
+        try buf.append(arena, '?');
+        try self.search_params.values.encode(buf.writer(arena));
+        return buf.toOwnedSlice(arena);
     }
 
     // the caller must free the returned string.
     // TODO return a disposable string
     // https://github.com/lightpanda-io/jsruntime-lib/issues/195
-    pub fn get_hash(self: *URL, alloc: std.mem.Allocator) ![]const u8 {
-        if (self.uri.fragment == null) return try alloc.dupe(u8, "");
+    pub fn get_hash(self: *URL, state: *SessionState) ![]const u8 {
+        const arena = state.arena;
+        if (self.uri.fragment == null) return try arena.dupe(u8, "");
 
-        return try std.mem.concat(alloc, u8, &[_][]const u8{ "#", uriComponentNullStr(self.uri.fragment) });
+        return try std.mem.concat(arena, u8, &[_][]const u8{ "#", uriComponentNullStr(self.uri.fragment) });
     }
 
     pub fn get_searchParams(self: *URL) *URLSearchParams {
         return &self.search_params;
     }
 
-    pub fn _toJSON(self: *URL, alloc: std.mem.Allocator) ![]const u8 {
-        return try self.get_href(alloc);
+    pub fn _toJSON(self: *URL, state: *SessionState) ![]const u8 {
+        return try self.get_href(state);
     }
 };
 
@@ -230,16 +226,14 @@ fn uriComponentStr(c: std.Uri.Component) []const u8 {
 pub const URLSearchParams = struct {
     values: query.Values,
 
-    pub const mem_guarantied = true;
-
-    pub fn constructor(alloc: std.mem.Allocator, init: ?[]const u8) !URLSearchParams {
-        return .{
-            .values = try query.parseQuery(alloc, init orelse ""),
-        };
+    pub fn constructor(state: *SessionState, qs: ?[]const u8) !URLSearchParams {
+        return init(state.arena, qs);
     }
 
-    pub fn deinit(self: *URLSearchParams, _: std.mem.Allocator) void {
-        self.values.deinit();
+    pub fn init(arena: std.mem.Allocator, qs: ?[]const u8) !URLSearchParams {
+        return .{
+            .values = try query.parseQuery(arena, qs orelse ""),
+        };
     }
 
     pub fn get_size(self: *URLSearchParams) u32 {
@@ -269,47 +263,43 @@ pub const URLSearchParams = struct {
     pub fn _sort(_: *URLSearchParams) void {}
 };
 
-// Tests
-// -----
+const testing = @import("../../testing.zig");
+test "Browser.URL" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
 
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var url = [_]Case{
-        .{ .src = "var url = new URL('https://foo.bar/path?query#fragment')", .ex = "undefined" },
-        .{ .src = "url.origin", .ex = "https://foo.bar" },
-        .{ .src = "url.href", .ex = "https://foo.bar/path?query#fragment" },
-        .{ .src = "url.protocol", .ex = "https:" },
-        .{ .src = "url.username", .ex = "" },
-        .{ .src = "url.password", .ex = "" },
-        .{ .src = "url.host", .ex = "foo.bar" },
-        .{ .src = "url.hostname", .ex = "foo.bar" },
-        .{ .src = "url.port", .ex = "" },
-        .{ .src = "url.pathname", .ex = "/path" },
-        .{ .src = "url.search", .ex = "?query" },
-        .{ .src = "url.hash", .ex = "#fragment" },
-        .{ .src = "url.searchParams.get('query')", .ex = "" },
-    };
-    try checkCases(js_env, &url);
+    try runner.testCases(&.{
+        .{ "var url = new URL('https://foo.bar/path?query#fragment')", "undefined" },
+        .{ "url.origin", "https://foo.bar" },
+        .{ "url.href", "https://foo.bar/path?query#fragment" },
+        .{ "url.protocol", "https:" },
+        .{ "url.username", "" },
+        .{ "url.password", "" },
+        .{ "url.host", "foo.bar" },
+        .{ "url.hostname", "foo.bar" },
+        .{ "url.port", "" },
+        .{ "url.pathname", "/path" },
+        .{ "url.search", "?query" },
+        .{ "url.hash", "#fragment" },
+        .{ "url.searchParams.get('query')", "" },
+    }, .{});
 
-    var qs = [_]Case{
-        .{ .src = "var url = new URL('https://foo.bar/path?a=~&b=%7E#fragment')", .ex = "undefined" },
-        .{ .src = "url.searchParams.get('a')", .ex = "~" },
-        .{ .src = "url.searchParams.get('b')", .ex = "~" },
-        .{ .src = "url.searchParams.append('c', 'foo')", .ex = "undefined" },
-        .{ .src = "url.searchParams.get('c')", .ex = "foo" },
-        .{ .src = "url.searchParams.size", .ex = "3" },
+    try runner.testCases(&.{
+        .{ "var url = new URL('https://foo.bar/path?a=~&b=%7E#fragment')", "undefined" },
+        .{ "url.searchParams.get('a')", "~" },
+        .{ "url.searchParams.get('b')", "~" },
+        .{ "url.searchParams.append('c', 'foo')", "undefined" },
+        .{ "url.searchParams.get('c')", "foo" },
+        .{ "url.searchParams.size", "3" },
 
         // search is dynamic
-        .{ .src = "url.search", .ex = "?a=%7E&b=%7E&c=foo" },
+        .{ "url.search", "?a=%7E&b=%7E&c=foo" },
         // href is dynamic
-        .{ .src = "url.href", .ex = "https://foo.bar/path?a=%7E&b=%7E&c=foo#fragment" },
+        .{ "url.href", "https://foo.bar/path?a=%7E&b=%7E&c=foo#fragment" },
 
-        .{ .src = "url.searchParams.delete('c', 'foo')", .ex = "undefined" },
-        .{ .src = "url.searchParams.get('c')", .ex = "" },
-        .{ .src = "url.searchParams.delete('a')", .ex = "undefined" },
-        .{ .src = "url.searchParams.get('a')", .ex = "" },
-    };
-    try checkCases(js_env, &qs);
+        .{ "url.searchParams.delete('c', 'foo')", "undefined" },
+        .{ "url.searchParams.get('c')", "" },
+        .{ "url.searchParams.delete('a')", "undefined" },
+        .{ "url.searchParams.get('a')", "" },
+    }, .{});
 }
diff --git a/src/xhr/event_target.zig b/src/browser/xhr/event_target.zig
similarity index 64%
rename from src/xhr/event_target.zig
rename to src/browser/xhr/event_target.zig
index 69bab2d5..02feb929 100644
--- a/src/xhr/event_target.zig
+++ b/src/browser/xhr/event_target.zig
@@ -18,19 +18,19 @@
 
 const std = @import("std");
 
-const jsruntime = @import("jsruntime");
-const Callback = jsruntime.Callback;
+const Env = @import("../env.zig").Env;
+const Callback = Env.Callback;
 
 const EventTarget = @import("../dom/event_target.zig").EventTarget;
 const EventHandler = @import("../events/event.zig").EventHandler;
 
-const parser = @import("netsurf");
+const parser = @import("../netsurf.zig");
+const SessionState = @import("../env.zig").SessionState;
 
 const log = std.log.scoped(.xhr);
 
 pub const XMLHttpRequestEventTarget = struct {
     pub const prototype = *EventTarget;
-    pub const mem_guarantied = true;
 
     // Extend libdom event target for pure zig struct.
     base: parser.EventTargetTBase = parser.EventTargetTBase{},
@@ -60,7 +60,7 @@ pub const XMLHttpRequestEventTarget = struct {
     fn unregister(self: *XMLHttpRequestEventTarget, alloc: std.mem.Allocator, typ: []const u8, cbk: Callback) !void {
         const et = @as(*parser.EventTarget, @ptrCast(self));
         // check if event target has already this listener
-        const lst = try parser.eventTargetHasListener(et, typ, false, cbk.id());
+        const lst = try parser.eventTargetHasListener(et, typ, false, cbk.id);
         if (lst == null) {
             return;
         }
@@ -88,39 +88,46 @@ pub const XMLHttpRequestEventTarget = struct {
         return self.onloadend_cbk;
     }
 
-    pub fn set_onloadstart(self: *XMLHttpRequestEventTarget, alloc: std.mem.Allocator, handler: Callback) !void {
-        if (self.onloadstart_cbk) |cbk| try self.unregister(alloc, "loadstart", cbk);
-        try self.register(alloc, "loadstart", handler);
+    pub fn set_onloadstart(self: *XMLHttpRequestEventTarget, handler: Callback, state: *SessionState) !void {
+        const arena = state.arena;
+        if (self.onloadstart_cbk) |cbk| try self.unregister(arena, "loadstart", cbk);
+        try self.register(arena, "loadstart", handler);
         self.onloadstart_cbk = handler;
     }
-    pub fn set_onprogress(self: *XMLHttpRequestEventTarget, alloc: std.mem.Allocator, handler: Callback) !void {
-        if (self.onprogress_cbk) |cbk| try self.unregister(alloc, "progress", cbk);
-        try self.register(alloc, "progress", handler);
+    pub fn set_onprogress(self: *XMLHttpRequestEventTarget, handler: Callback, state: *SessionState) !void {
+        const arena = state.arena;
+        if (self.onprogress_cbk) |cbk| try self.unregister(arena, "progress", cbk);
+        try self.register(arena, "progress", handler);
         self.onprogress_cbk = handler;
     }
-    pub fn set_onabort(self: *XMLHttpRequestEventTarget, alloc: std.mem.Allocator, handler: Callback) !void {
-        if (self.onabort_cbk) |cbk| try self.unregister(alloc, "abort", cbk);
-        try self.register(alloc, "abort", handler);
+    pub fn set_onabort(self: *XMLHttpRequestEventTarget, handler: Callback, state: *SessionState) !void {
+        const arena = state.arena;
+        if (self.onabort_cbk) |cbk| try self.unregister(arena, "abort", cbk);
+        try self.register(arena, "abort", handler);
         self.onabort_cbk = handler;
     }
-    pub fn set_onload(self: *XMLHttpRequestEventTarget, alloc: std.mem.Allocator, handler: Callback) !void {
-        if (self.onload_cbk) |cbk| try self.unregister(alloc, "load", cbk);
-        try self.register(alloc, "load", handler);
+    pub fn set_onload(self: *XMLHttpRequestEventTarget, handler: Callback, state: *SessionState) !void {
+        const arena = state.arena;
+        if (self.onload_cbk) |cbk| try self.unregister(arena, "load", cbk);
+        try self.register(arena, "load", handler);
         self.onload_cbk = handler;
     }
-    pub fn set_ontimeout(self: *XMLHttpRequestEventTarget, alloc: std.mem.Allocator, handler: Callback) !void {
-        if (self.ontimeout_cbk) |cbk| try self.unregister(alloc, "timeout", cbk);
-        try self.register(alloc, "timeout", handler);
+    pub fn set_ontimeout(self: *XMLHttpRequestEventTarget, handler: Callback, state: *SessionState) !void {
+        const arena = state.arena;
+        if (self.ontimeout_cbk) |cbk| try self.unregister(arena, "timeout", cbk);
+        try self.register(arena, "timeout", handler);
         self.ontimeout_cbk = handler;
     }
-    pub fn set_onloadend(self: *XMLHttpRequestEventTarget, alloc: std.mem.Allocator, handler: Callback) !void {
-        if (self.onloadend_cbk) |cbk| try self.unregister(alloc, "loadend", cbk);
-        try self.register(alloc, "loadend", handler);
+    pub fn set_onloadend(self: *XMLHttpRequestEventTarget, handler: Callback, state: *SessionState) !void {
+        const arena = state.arena;
+        if (self.onloadend_cbk) |cbk| try self.unregister(arena, "loadend", cbk);
+        try self.register(arena, "loadend", handler);
         self.onloadend_cbk = handler;
     }
 
-    pub fn deinit(self: *XMLHttpRequestEventTarget, alloc: std.mem.Allocator) void {
-        parser.eventTargetRemoveAllEventListeners(@as(*parser.EventTarget, @ptrCast(self)), alloc) catch |e| {
+    pub fn deinit(self: *XMLHttpRequestEventTarget, state: *SessionState) void {
+        const arena = state.arena;
+        parser.eventTargetRemoveAllEventListeners(@as(*parser.EventTarget, @ptrCast(self)), arena) catch |e| {
             log.err("remove all listeners: {any}", .{e});
         };
     }
diff --git a/src/xhr/progress_event.zig b/src/browser/xhr/progress_event.zig
similarity index 63%
rename from src/xhr/progress_event.zig
rename to src/browser/xhr/progress_event.zig
index d985c76f..b920cda1 100644
--- a/src/xhr/progress_event.zig
+++ b/src/browser/xhr/progress_event.zig
@@ -18,11 +18,7 @@
 
 const std = @import("std");
 
-const jsruntime = @import("jsruntime");
-const Case = jsruntime.test_utils.Case;
-const checkCases = jsruntime.test_utils.checkCases;
-
-const parser = @import("netsurf");
+const parser = @import("../netsurf.zig");
 const Event = @import("../events/event.zig").Event;
 
 const DOMException = @import("../dom/exceptions.zig").DOMException;
@@ -30,7 +26,6 @@ const DOMException = @import("../dom/exceptions.zig").DOMException;
 pub const ProgressEvent = struct {
     pub const prototype = *Event;
     pub const Exception = DOMException;
-    pub const mem_guarantied = true;
 
     pub const EventInit = struct {
         lengthComputable: bool = false,
@@ -59,32 +54,32 @@ pub const ProgressEvent = struct {
         };
     }
 
-    pub fn get_lengthComputable(self: ProgressEvent) bool {
+    pub fn get_lengthComputable(self: *const ProgressEvent) bool {
         return self.lengthComputable;
     }
 
-    pub fn get_loaded(self: ProgressEvent) u64 {
+    pub fn get_loaded(self: *const ProgressEvent) u64 {
         return self.loaded;
     }
 
-    pub fn get_total(self: ProgressEvent) u64 {
+    pub fn get_total(self: *const ProgressEvent) u64 {
         return self.total;
     }
 };
 
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var progress_event = [_]Case{
-        .{ .src = "let pevt = new ProgressEvent('foo');", .ex = "undefined" },
-        .{ .src = "pevt.loaded", .ex = "0" },
-        .{ .src = "pevt instanceof ProgressEvent", .ex = "true" },
-        .{ .src = "var nnb = 0; var eevt = null; function ccbk(event) { nnb ++; eevt = event; }", .ex = "undefined" },
-        .{ .src = "document.addEventListener('foo', ccbk)", .ex = "undefined" },
-        .{ .src = "document.dispatchEvent(pevt)", .ex = "true" },
-        .{ .src = "eevt.type", .ex = "foo" },
-        .{ .src = "eevt instanceof ProgressEvent", .ex = "true" },
-    };
-    try checkCases(js_env, &progress_event);
+const testing = @import("../../testing.zig");
+test "Browser.XHR.ProgressEvent" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
+
+    try runner.testCases(&.{
+        .{ "let pevt = new ProgressEvent('foo');", "undefined" },
+        .{ "pevt.loaded", "0" },
+        .{ "pevt instanceof ProgressEvent", "true" },
+        .{ "var nnb = 0; var eevt = null; function ccbk(event) { nnb ++; eevt = event; }", "undefined" },
+        .{ "document.addEventListener('foo', ccbk)", "undefined" },
+        .{ "document.dispatchEvent(pevt)", "true" },
+        .{ "eevt.type", "foo" },
+        .{ "eevt instanceof ProgressEvent", "true" },
+    }, .{});
 }
diff --git a/src/xhr/xhr.zig b/src/browser/xhr/xhr.zig
similarity index 76%
rename from src/xhr/xhr.zig
rename to src/browser/xhr/xhr.zig
index 9c4e82b3..4c5de760 100644
--- a/src/xhr/xhr.zig
+++ b/src/browser/xhr/xhr.zig
@@ -17,27 +17,20 @@
 // along with this program.  If not, see .
 
 const std = @import("std");
+const Allocator = std.mem.Allocator;
 
-const jsruntime = @import("jsruntime");
-const Case = jsruntime.test_utils.Case;
-const checkCases = jsruntime.test_utils.checkCases;
-
-const DOMError = @import("netsurf").DOMError;
+const DOMError = @import("../netsurf.zig").DOMError;
 const DOMException = @import("../dom/exceptions.zig").DOMException;
 
 const ProgressEvent = @import("progress_event.zig").ProgressEvent;
 const XMLHttpRequestEventTarget = @import("event_target.zig").XMLHttpRequestEventTarget;
 
-const Mime = @import("../browser/mime.zig").Mime;
-
-const Loop = jsruntime.Loop;
-const URL = @import("../url.zig").URL;
-const http = @import("../http/client.zig");
-
-const parser = @import("netsurf");
-
+const URL = @import("../../url.zig").URL;
+const Mime = @import("../mime.zig").Mime;
+const parser = @import("../netsurf.zig");
+const http = @import("../../http/client.zig");
+const SessionState = @import("../env.zig").SessionState;
 const CookieJar = @import("../storage/storage.zig").CookieJar;
-const UserContext = @import("../user_context.zig").UserContext;
 
 const log = std.log.scoped(.xhr);
 
@@ -51,7 +44,6 @@ pub const Interfaces = .{
 
 pub const XMLHttpRequestUpload = struct {
     pub const prototype = *XMLHttpRequestEventTarget;
-    pub const mem_guarantied = true;
 
     proto: XMLHttpRequestEventTarget = XMLHttpRequestEventTarget{},
 };
@@ -83,7 +75,7 @@ pub const XMLHttpRequestBodyInit = union(XMLHttpRequestBodyInitTag) {
 
     // Duplicate the body content.
     // The caller owns the allocated string.
-    fn dupe(self: XMLHttpRequestBodyInit, alloc: std.mem.Allocator) ![]const u8 {
+    fn dupe(self: XMLHttpRequestBodyInit, alloc: Allocator) ![]const u8 {
         return switch (self) {
             .Blob => error.NotImplemented,
             .BufferSource => error.NotImplemented,
@@ -96,7 +88,7 @@ pub const XMLHttpRequestBodyInit = union(XMLHttpRequestBodyInitTag) {
 
 pub const XMLHttpRequest = struct {
     proto: XMLHttpRequestEventTarget = XMLHttpRequestEventTarget{},
-    alloc: std.mem.Allocator,
+    arena: Allocator,
     client: *http.Client,
     request: ?http.Request = null,
 
@@ -134,10 +126,6 @@ pub const XMLHttpRequest = struct {
     response_type: ResponseType = .Empty,
     response_headers: Headers,
 
-    // used by zig client to parse response headers.
-    // use 16KB for headers buffer size.
-    response_header_buffer: [1024 * 16]u8 = undefined,
-
     response_status: u16 = 0,
 
     // TODO uncomment this field causes casting issue with
@@ -151,7 +139,6 @@ pub const XMLHttpRequest = struct {
     send_flag: bool = false,
 
     pub const prototype = *XMLHttpRequestEventTarget;
-    pub const mem_guarantied = true;
 
     const State = enum(u16) {
         unsent = 0,
@@ -174,41 +161,41 @@ pub const XMLHttpRequest = struct {
     const JSONValue = std.json.Value;
 
     const Headers = struct {
-        alloc: std.mem.Allocator,
         list: List,
+        arena: Allocator,
 
         const List = std.ArrayListUnmanaged(std.http.Header);
 
-        fn init(alloc: std.mem.Allocator) Headers {
+        fn init(arena: Allocator) Headers {
             return .{
-                .alloc = alloc,
-                .list = List{},
+                .arena = arena,
+                .list = .{},
             };
         }
 
         fn deinit(self: *Headers) void {
             self.free();
-            self.list.deinit(self.alloc);
+            self.list.deinit(self.arena);
         }
 
         fn append(self: *Headers, k: []const u8, v: []const u8) !void {
             // duplicate strings
-            const kk = try self.alloc.dupe(u8, k);
-            const vv = try self.alloc.dupe(u8, v);
-            try self.list.append(self.alloc, .{ .name = kk, .value = vv });
+            const kk = try self.arena.dupe(u8, k);
+            const vv = try self.arena.dupe(u8, v);
+            try self.list.append(self.arena, .{ .name = kk, .value = vv });
         }
 
         // free all strings allocated.
         fn free(self: *Headers) void {
             for (self.list.items) |h| {
-                self.alloc.free(h.name);
-                self.alloc.free(h.value);
+                self.arena.free(h.name);
+                self.arena.free(h.value);
             }
         }
 
         fn clearAndFree(self: *Headers) void {
             self.free();
-            self.list.clearAndFree(self.alloc);
+            self.list.clearAndFree(self.arena);
         }
 
         fn has(self: Headers, k: []const u8) bool {
@@ -236,8 +223,8 @@ pub const XMLHttpRequest = struct {
             for (self.list.items, 0..) |h, i| {
                 if (std.ascii.eqlIgnoreCase(k, h.name)) {
                     const hh = self.list.swapRemove(i);
-                    self.alloc.free(hh.name);
-                    self.alloc.free(hh.value);
+                    self.arena.free(hh.name);
+                    self.arena.free(hh.value);
                 }
             }
             self.append(k, v);
@@ -286,17 +273,18 @@ pub const XMLHttpRequest = struct {
 
     const min_delay: u64 = 50000000; // 50ms
 
-    pub fn constructor(alloc: std.mem.Allocator, userctx: UserContext) !XMLHttpRequest {
+    pub fn constructor(session_state: *SessionState) !XMLHttpRequest {
+        const arena = session_state.arena;
         return .{
-            .alloc = alloc,
-            .headers = Headers.init(alloc),
-            .response_headers = Headers.init(alloc),
+            .arena = arena,
+            .headers = Headers.init(arena),
+            .response_headers = Headers.init(arena),
             .method = undefined,
             .state = .unsent,
             .url = null,
-            .origin_url = userctx.url,
-            .client = userctx.http_client,
-            .cookie_jar = userctx.cookie_jar,
+            .origin_url = session_state.url,
+            .client = session_state.http_client,
+            .cookie_jar = session_state.cookie_jar,
         };
     }
 
@@ -307,10 +295,7 @@ pub const XMLHttpRequest = struct {
 
         self.response_obj = null;
         self.response_type = .Empty;
-        if (self.response_mime) |*mime| {
-            mime.deinit();
-            self.response_mime = null;
-        }
+        self.response_mime = null;
 
         // TODO should we clearRetainingCapacity instead?
         self.headers.clearAndFree();
@@ -322,7 +307,7 @@ pub const XMLHttpRequest = struct {
         self.priv_state = .new;
     }
 
-    pub fn deinit(self: *XMLHttpRequest, alloc: std.mem.Allocator) void {
+    pub fn deinit(self: *XMLHttpRequest, alloc: Allocator) void {
         self.reset();
         self.headers.deinit();
         self.response_headers.deinit();
@@ -362,7 +347,6 @@ pub const XMLHttpRequest = struct {
 
     pub fn _open(
         self: *XMLHttpRequest,
-        alloc: std.mem.Allocator,
         method: []const u8,
         url: []const u8,
         asyn: ?bool,
@@ -375,12 +359,13 @@ pub const XMLHttpRequest = struct {
         // TODO If this’s relevant global object is a Window object and its
         // associated Document is not fully active, then throw an
         // "InvalidStateError" DOMException.
-
-        self.method = try validMethod(method);
-
         self.reset();
 
-        self.url = try self.origin_url.resolve(alloc, url);
+        self.method = try validMethod(method);
+        const arena = self.arena;
+
+        self.url = try self.origin_url.resolve(arena, url);
+
         log.debug("open url ({s})", .{self.url.?});
         self.sync = if (asyn) |b| !b else false;
 
@@ -466,7 +451,7 @@ pub const XMLHttpRequest = struct {
     }
 
     // TODO body can be either a XMLHttpRequestBodyInit or a document
-    pub fn _send(self: *XMLHttpRequest, loop: *Loop, alloc: std.mem.Allocator, body: ?[]const u8) !void {
+    pub fn _send(self: *XMLHttpRequest, session_state: *SessionState, body: ?[]const u8) !void {
         if (self.state != .opened) return DOMError.InvalidState;
         if (self.send_flag) return DOMError.InvalidState;
 
@@ -485,7 +470,7 @@ pub const XMLHttpRequest = struct {
 
         {
             var arr: std.ArrayListUnmanaged(u8) = .{};
-            try self.cookie_jar.forRequest(&self.url.?.uri, arr.writer(alloc), .{
+            try self.cookie_jar.forRequest(&self.url.?.uri, arr.writer(session_state.arena), .{
                 .navigation = false,
                 .origin_uri = &self.origin_url.uri,
             });
@@ -501,12 +486,12 @@ pub const XMLHttpRequest = struct {
         // var used_body: ?XMLHttpRequestBodyInit = null;
         if (body) |b| {
             if (self.method != .GET and self.method != .HEAD) {
-                request.body = try alloc.dupe(u8, b);
+                request.body = try session_state.arena.dupe(u8, b);
                 try request.addHeader("Content-Type", "text/plain; charset=UTF-8", .{});
             }
         }
 
-        try request.sendAsync(loop, self, .{});
+        try request.sendAsync(session_state.loop, self, .{});
     }
 
     pub fn onHttpResponse(self: *XMLHttpRequest, progress_: anyerror!http.Progress) !void {
@@ -526,8 +511,15 @@ pub const XMLHttpRequest = struct {
             }
 
             // extract a mime type from headers.
-            const ct = header.get("content-type") orelse "text/xml";
-            self.response_mime = Mime.parse(self.alloc, ct) catch |e| return self.onErr(e);
+            {
+                var raw: []const u8 = "text/xml";
+                if (header.get("content-type")) |ct| {
+                    raw = try self.arena.dupe(u8, ct);
+                }
+                self.response_mime = Mime.parse(self.arena, raw) catch |e| {
+                    return self.onErr(e);
+                };
+            }
 
             // TODO handle override mime type
             self.state = .headers_received;
@@ -545,7 +537,7 @@ pub const XMLHttpRequest = struct {
         }
 
         if (progress.data) |data| {
-            try self.response_bytes.appendSlice(self.alloc, data);
+            try self.response_bytes.appendSlice(self.arena, data);
         }
 
         const loaded = self.response_bytes.items.len;
@@ -636,7 +628,7 @@ pub const XMLHttpRequest = struct {
         return url.raw;
     }
 
-    pub fn get_responseXML(self: *XMLHttpRequest, alloc: std.mem.Allocator) !?Response {
+    pub fn get_responseXML(self: *XMLHttpRequest) !?Response {
         if (self.response_type != .Empty and self.response_type != .Document) {
             return DOMError.InvalidState;
         }
@@ -652,7 +644,7 @@ pub const XMLHttpRequest = struct {
             };
         }
 
-        self.setResponseObjDocument(alloc);
+        self.setResponseObjDocument();
 
         if (self.response_obj) |obj| {
             return switch (obj) {
@@ -665,7 +657,7 @@ pub const XMLHttpRequest = struct {
     }
 
     // https://xhr.spec.whatwg.org/#the-response-attribute
-    pub fn get_response(self: *XMLHttpRequest, alloc: std.mem.Allocator) !?Response {
+    pub fn get_response(self: *XMLHttpRequest) !?Response {
         if (self.response_type == .Empty or self.response_type == .Text) {
             if (self.state == .loading or self.state == .done) {
                 return .{ .Text = try self.get_responseText() };
@@ -703,7 +695,7 @@ pub const XMLHttpRequest = struct {
         // Otherwise, if this’s response type is "document", set a
         // document response for this.
         if (self.response_type == .Document) {
-            self.setResponseObjDocument(alloc);
+            self.setResponseObjDocument();
         }
 
         if (self.response_type == .JSON) {
@@ -712,7 +704,7 @@ pub const XMLHttpRequest = struct {
             // TODO Let jsonObject be the result of running parse JSON from bytes
             // on this’s received bytes. If that threw an exception, then return
             // null.
-            self.setResponseObjJSON(alloc);
+            self.setResponseObjJSON();
         }
 
         if (self.response_obj) |obj| {
@@ -731,19 +723,23 @@ pub const XMLHttpRequest = struct {
     // If the par sing fails, a Failure is stored in response_obj.
     // TODO parse XML.
     // https://xhr.spec.whatwg.org/#response-object
-    fn setResponseObjDocument(self: *XMLHttpRequest, alloc: std.mem.Allocator) void {
+    fn setResponseObjDocument(self: *XMLHttpRequest) void {
         const response_mime = &self.response_mime.?;
         const isHTML = response_mime.isHTML();
 
         // TODO If finalMIME is not an HTML MIME type or an XML MIME type, then
         // return.
-        if (!isHTML) return;
-
-        const ccharset = alloc.dupeZ(u8, response_mime.charset orelse "utf-8") catch {
-            self.response_obj = .{ .Failure = true };
+        if (!isHTML) {
             return;
-        };
-        defer alloc.free(ccharset);
+        }
+
+        var ccharset: [:0]const u8 = "utf-8";
+        if (response_mime.charset) |rc| {
+            ccharset = self.arena.dupeZ(u8, rc) catch {
+                self.response_obj = .{ .Failure = true };
+                return;
+            };
+        }
 
         var fbs = std.io.fixedBufferStream(self.response_bytes.items);
         const doc = parser.documentHTMLParse(fbs.reader(), ccharset) catch {
@@ -760,12 +756,12 @@ pub const XMLHttpRequest = struct {
     }
 
     // setResponseObjJSON parses the received bytes as a std.json.Value.
-    fn setResponseObjJSON(self: *XMLHttpRequest, alloc: std.mem.Allocator) void {
+    fn setResponseObjJSON(self: *XMLHttpRequest) void {
         // TODO should we use parseFromSliceLeaky if we expect the allocator is
         // already an arena?
         const p = std.json.parseFromSlice(
             JSONValue,
-            alloc,
+            self.arena,
             self.response_bytes.items,
             .{},
         ) catch |e| {
@@ -790,14 +786,12 @@ pub const XMLHttpRequest = struct {
     // TODO change the return type to express the string ownership and let
     // jsruntime free the string once copied to v8.
     // see https://github.com/lightpanda-io/jsruntime-lib/issues/195
-    pub fn _getAllResponseHeaders(self: *XMLHttpRequest, alloc: std.mem.Allocator) ![]const u8 {
+    pub fn _getAllResponseHeaders(self: *XMLHttpRequest) ![]const u8 {
         if (self.response_headers.list.items.len == 0) return "";
         self.response_headers.sort();
 
         var buf: std.ArrayListUnmanaged(u8) = .{};
-        errdefer buf.deinit(alloc);
-
-        const w = buf.writer(alloc);
+        const w = buf.writer(self.arena);
 
         for (self.response_headers.list.items) |entry| {
             if (entry.value.len == 0) continue;
@@ -822,101 +816,96 @@ pub const XMLHttpRequest = struct {
     }
 };
 
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var send = [_]Case{
-        .{ .src = "var nb = 0; var evt = null; function cbk(event) { nb ++; evt = event; }", .ex = "undefined" },
-        .{ .src = "const req = new XMLHttpRequest()", .ex = "undefined" },
+const testing = @import("../../testing.zig");
+test "Browser.XHR.XMLHttpRequest" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
 
-        .{ .src = "req.onload = cbk", .ex = "function cbk(event) { nb ++; evt = event; }" },
-        // Getter returning a callback crashes.
-        // blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/200
-        // .{ .src = "req.onload", .ex = "function cbk(event) { nb ++; evt = event; }" },
-        //.{ .src = "req.onload = cbk", .ex = "function cbk(event) { nb ++; evt = event; }" },
+    try runner.testCases(&.{
+        .{ "var nb = 0; var evt = null; function cbk(event) { nb ++; evt = event; }", "undefined" },
+        .{ "const req = new XMLHttpRequest()", "undefined" },
 
-        .{ .src = "req.open('GET', 'https://httpbin.io/html')", .ex = "undefined" },
-        .{ .src = "req.setRequestHeader('User-Agent', 'lightpanda/1.0')", .ex = "undefined" },
+        .{ "req.onload = cbk", "function cbk(event) { nb ++; evt = event; }" },
+
+        .{ "req.onload", "function cbk(event) { nb ++; evt = event; }" },
+        .{ "req.onload = cbk", "function cbk(event) { nb ++; evt = event; }" },
+
+        .{ "req.open('GET', 'https://127.0.0.1:9581/xhr')", "undefined" },
+        .{ "req.setRequestHeader('User-Agent', 'lightpanda/1.0')", "undefined" },
 
         // ensure open resets values
-        .{ .src = "req.status", .ex = "0" },
-        .{ .src = "req.statusText", .ex = "" },
-        .{ .src = "req.getAllResponseHeaders()", .ex = "" },
-        .{ .src = "req.getResponseHeader('Content-Type')", .ex = "null" },
-        .{ .src = "req.responseText", .ex = "" },
+        .{ "req.status  ", "0" },
+        .{ "req.statusText", "" },
+        .{ "req.getAllResponseHeaders()", "" },
+        .{ "req.getResponseHeader('Content-Type')", "null" },
+        .{ "req.responseText", "" },
 
-        .{ .src = "req.send(); nb", .ex = "0" },
+        .{ "req.send(); nb", "0" },
+
+        // Each case executed waits for all loop callback calls.
+        // So the url has been retrieved.
+        .{ "nb", "1" },
+        .{ "evt.type", "load" },
+        .{ "evt.loaded > 0", "true" },
+        .{ "evt instanceof ProgressEvent", "true" },
+        .{ "req.status", "200" },
+        .{ "req.statusText", "OK" },
+        .{ "req.getResponseHeader('Content-Type')", "text/html; charset=utf-8" },
+        .{ "req.getAllResponseHeaders().length", "61" },
+        .{ "req.responseText.length", "100" },
+        .{ "req.response.length == req.responseText.length", "true" },
+        .{ "req.responseXML instanceof Document", "true" },
+    }, .{});
+
+    try runner.testCases(&.{
+        .{ "const req2 = new XMLHttpRequest()", "undefined" },
+        .{ "req2.open('GET', 'https://127.0.0.1:9581/xhr')", "undefined" },
+        .{ "req2.responseType = 'document'", "document" },
+
+        .{ "req2.send()", "undefined" },
 
         // Each case executed waits for all loop callaback calls.
         // So the url has been retrieved.
-        .{ .src = "nb", .ex = "1" },
-        .{ .src = "evt.type", .ex = "load" },
-        .{ .src = "evt.loaded > 0", .ex = "true" },
-        .{ .src = "evt instanceof ProgressEvent", .ex = "true" },
-        .{ .src = "req.status", .ex = "200" },
-        .{ .src = "req.statusText", .ex = "OK" },
-        .{ .src = "req.getResponseHeader('Content-Type')", .ex = "text/html; charset=utf-8" },
-        .{ .src = "req.getAllResponseHeaders().length > 64", .ex = "true" },
-        .{ .src = "req.responseText.length > 64", .ex = "true" },
-        .{ .src = "req.response.length == req.responseText.length", .ex = "true" },
-        .{ .src = "req.responseXML instanceof Document", .ex = "true" },
-    };
-    try checkCases(js_env, &send);
+        .{ "req2.status", "200" },
+        .{ "req2.statusText", "OK" },
+        .{ "req2.response instanceof Document", "true" },
+        .{ "req2.responseXML instanceof Document", "true" },
+    }, .{});
 
-    var document = [_]Case{
-        .{ .src = "const req2 = new XMLHttpRequest()", .ex = "undefined" },
-        .{ .src = "req2.open('GET', 'https://httpbin.io/html')", .ex = "undefined" },
-        .{ .src = "req2.responseType = 'document'", .ex = "document" },
+    try runner.testCases(&.{
+        .{ "const req3 = new XMLHttpRequest()", "undefined" },
+        .{ "req3.open('GET', 'https://127.0.0.1:9581/xhr/json')", "undefined" },
+        .{ "req3.responseType = 'json'", "json" },
 
-        .{ .src = "req2.send()", .ex = "undefined" },
+        .{ "req3.send()", "undefined" },
 
         // Each case executed waits for all loop callaback calls.
         // So the url has been retrieved.
-        .{ .src = "req2.status", .ex = "200" },
-        .{ .src = "req2.statusText", .ex = "OK" },
-        .{ .src = "req2.response instanceof Document", .ex = "true" },
-        .{ .src = "req2.responseXML instanceof Document", .ex = "true" },
-    };
-    try checkCases(js_env, &document);
+        .{ "req3.status", "200" },
+        .{ "req3.statusText", "OK" },
+        .{ "req3.response.over", "9000!!!" },
+    }, .{});
 
-    var json = [_]Case{
-        .{ .src = "const req3 = new XMLHttpRequest()", .ex = "undefined" },
-        .{ .src = "req3.open('GET', 'https://httpbin.io/json')", .ex = "undefined" },
-        .{ .src = "req3.responseType = 'json'", .ex = "json" },
-
-        .{ .src = "req3.send()", .ex = "undefined" },
+    try runner.testCases(&.{
+        .{ "const req4 = new XMLHttpRequest()", "undefined" },
+        .{ "req4.open('POST', 'https://127.0.0.1:9581/xhr')", "undefined" },
+        .{ "req4.send('foo')", "undefined" },
 
         // Each case executed waits for all loop callaback calls.
         // So the url has been retrieved.
-        .{ .src = "req3.status", .ex = "200" },
-        .{ .src = "req3.statusText", .ex = "OK" },
-        .{ .src = "req3.response.slideshow.author", .ex = "Yours Truly" },
-    };
-    try checkCases(js_env, &json);
+        .{ "req4.status", "200" },
+        .{ "req4.statusText", "OK" },
+        .{ "req4.responseText.length > 64", "true" },
+    }, .{});
 
-    var post = [_]Case{
-        .{ .src = "const req4 = new XMLHttpRequest()", .ex = "undefined" },
-        .{ .src = "req4.open('POST', 'https://httpbin.io/post')", .ex = "undefined" },
-        .{ .src = "req4.send('foo')", .ex = "undefined" },
+    try runner.testCases(&.{
+        .{ "const req5 = new XMLHttpRequest()", "undefined" },
+        .{ "req5.open('GET', 'https://127.0.0.1:9581/xhr')", "undefined" },
+        .{ "var status = 0; req5.onload = function () { status = this.status };", "function () { status = this.status }" },
+        .{ "req5.send()", "undefined" },
 
         // Each case executed waits for all loop callaback calls.
         // So the url has been retrieved.
-        .{ .src = "req4.status", .ex = "200" },
-        .{ .src = "req4.statusText", .ex = "OK" },
-        .{ .src = "req4.responseText.length > 64", .ex = "true" },
-    };
-    try checkCases(js_env, &post);
-
-    var cbk = [_]Case{
-        .{ .src = "const req5 = new XMLHttpRequest()", .ex = "undefined" },
-        .{ .src = "req5.open('GET', 'https://httpbin.io/json')", .ex = "undefined" },
-        .{ .src = "var status = 0; req5.onload = function () { status = this.status };", .ex = "function () { status = this.status }" },
-        .{ .src = "req5.send()", .ex = "undefined" },
-
-        // Each case executed waits for all loop callaback calls.
-        // So the url has been retrieved.
-        .{ .src = "status", .ex = "200" },
-    };
-    try checkCases(js_env, &cbk);
+        .{ "status", "200" },
+    }, .{});
 }
diff --git a/src/xmlserializer/xmlserializer.zig b/src/browser/xmlserializer/xmlserializer.zig
similarity index 54%
rename from src/xmlserializer/xmlserializer.zig
rename to src/browser/xmlserializer/xmlserializer.zig
index a0153558..2d2a5207 100644
--- a/src/xmlserializer/xmlserializer.zig
+++ b/src/browser/xmlserializer/xmlserializer.zig
@@ -18,14 +18,11 @@
 //
 const std = @import("std");
 
-const jsruntime = @import("jsruntime");
-const Case = jsruntime.test_utils.Case;
-const checkCases = jsruntime.test_utils.checkCases;
+const SessionState = @import("../env.zig").SessionState;
 
-const DOMError = @import("netsurf").DOMError;
-
-const parser = @import("netsurf");
-const dump = @import("../browser/dump.zig");
+const dump = @import("../dump.zig");
+const parser = @import("../netsurf.zig");
+const DOMError = parser.DOMError;
 
 pub const Interfaces = .{
     XMLSerializer,
@@ -33,39 +30,28 @@ pub const Interfaces = .{
 
 // https://w3c.github.io/DOM-Parsing/#dom-xmlserializer-constructor
 pub const XMLSerializer = struct {
-    pub const mem_guarantied = true;
-
     pub fn constructor() !XMLSerializer {
         return .{};
     }
 
-    pub fn deinit(_: *XMLSerializer, _: std.mem.Allocator) void {}
-
-    pub fn _serializeToString(_: XMLSerializer, alloc: std.mem.Allocator, root: *parser.Node) ![]const u8 {
-        var buf = std.ArrayList(u8).init(alloc);
-        defer buf.deinit();
-
+    pub fn _serializeToString(_: *const XMLSerializer, state: *SessionState, root: *parser.Node) ![]const u8 {
+        var buf = std.ArrayList(u8).init(state.arena);
         if (try parser.nodeType(root) == .document) {
             try dump.writeHTML(@as(*parser.Document, @ptrCast(root)), buf.writer());
         } else {
             try dump.writeNode(root, buf.writer());
         }
-        // TODO express the caller owned the slice.
-        // https://github.com/lightpanda-io/jsruntime-lib/issues/195
-        return try buf.toOwnedSlice();
+        return buf.items;
     }
 };
 
-// Tests
-// -----
+const testing = @import("../../testing.zig");
+test "Browser.XMLSerializer" {
+    var runner = try testing.jsRunner(testing.allocator, .{});
+    defer runner.deinit();
 
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var serializer = [_]Case{
-        .{ .src = "const s = new XMLSerializer()", .ex = "undefined" },
-        .{ .src = "s.serializeToString(document.getElementById('para'))", .ex = " And
" },
-    };
-    try checkCases(js_env, &serializer);
+    try runner.testCases(&.{
+        .{ "const s = new XMLSerializer()", "undefined" },
+        .{ "s.serializeToString(document.getElementById('para'))", " And
" },
+    }, .{});
 }
diff --git a/src/cdp/Node.zig b/src/cdp/Node.zig
index f609be49..a7a6b658 100644
--- a/src/cdp/Node.zig
+++ b/src/cdp/Node.zig
@@ -17,9 +17,10 @@
 // along with this program.  If not, see .
 
 const std = @import("std");
-const parser = @import("netsurf");
 const Allocator = std.mem.Allocator;
 
+const parser = @import("../browser/netsurf.zig");
+
 pub const Id = u32;
 
 const log = std.log.scoped(.cdp_node);
diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig
index ff00b5e9..146fc84e 100644
--- a/src/cdp/cdp.zig
+++ b/src/cdp/cdp.zig
@@ -72,13 +72,16 @@ pub fn CDPT(comptime TypeProvider: type) type {
         pub const Browser = TypeProvider.Browser;
         pub const Session = TypeProvider.Session;
 
-        pub fn init(app: *App, client: TypeProvider.Client) Self {
+        pub fn init(app: *App, client: TypeProvider.Client) !Self {
             const allocator = app.allocator;
+            const browser = try Browser.init(app);
+            errdefer browser.deinit();
+
             return .{
                 .client = client,
+                .browser = browser,
                 .allocator = allocator,
                 .browser_context = null,
-                .browser = Browser.init(app),
                 .message_arena = std.heap.ArenaAllocator.init(allocator),
                 .browser_context_pool = std.heap.MemoryPool(BrowserContext(Self)).init(allocator),
             };
diff --git a/src/cdp/domains/dom.zig b/src/cdp/domains/dom.zig
index 80e6b96b..e85ee7fe 100644
--- a/src/cdp/domains/dom.zig
+++ b/src/cdp/domains/dom.zig
@@ -17,10 +17,10 @@
 // along with this program.  If not, see .
 
 const std = @import("std");
-const parser = @import("netsurf");
 const Node = @import("../Node.zig");
-const css = @import("../../dom/css.zig");
-const dom_node = @import("../../dom/node.zig");
+const css = @import("../../browser/dom/css.zig");
+const parser = @import("../../browser/netsurf.zig");
+const dom_node = @import("../../browser/dom/node.zig");
 
 pub fn processMessage(cmd: anytype) !void {
     const action = std.meta.stringToEnum(enum {
@@ -134,17 +134,20 @@ fn resolveNode(cmd: anytype) !void {
 
     // node._node is a *parser.Node we need this to be able to find its most derived type e.g. Node -> Element -> HTMLElement
     // So we use the Node.Union when retrieve the value from the environment
-    const jsValue = try bc.session.env.findOrAddValue(try dom_node.Node.toInterface(node._node));
-    const remoteObject = try bc.session.inspector.getRemoteObject(&bc.session.env, jsValue, params.objectGroup orelse "");
-    defer remoteObject.deinit();
+    const remote_object = try bc.session.inspector.getRemoteObject(
+        bc.session.executor,
+        params.objectGroup orelse "",
+        try dom_node.Node.toInterface(node._node),
+    );
+    defer remote_object.deinit();
 
     const arena = cmd.arena;
     return cmd.sendResult(.{ .object = .{
-        .type = try remoteObject.getType(arena),
-        .subtype = try remoteObject.getSubtype(arena),
-        .className = try remoteObject.getClassName(arena),
-        .description = try remoteObject.getDescription(arena),
-        .objectId = try remoteObject.getObjectId(arena),
+        .type = try remote_object.getType(arena),
+        .subtype = try remote_object.getSubtype(arena),
+        .className = try remote_object.getClassName(arena),
+        .description = try remote_object.getDescription(arena),
+        .objectId = try remote_object.getObjectId(arena),
     } }, .{});
 }
 
diff --git a/src/cdp/testing.zig b/src/cdp/testing.zig
index c2c833e3..1f15caf6 100644
--- a/src/cdp/testing.zig
+++ b/src/cdp/testing.zig
@@ -23,9 +23,9 @@ const Allocator = std.mem.Allocator;
 const Testing = @This();
 
 const main = @import("cdp.zig");
-const parser = @import("netsurf");
 const URL = @import("../url.zig").URL;
 const App = @import("../app.zig").App;
+const parser = @import("../browser/netsurf.zig");
 
 const base = @import("../testing.zig");
 pub const allocator = base.allocator;
@@ -40,7 +40,7 @@ const Browser = struct {
     session: ?*Session = null,
     arena: std.heap.ArenaAllocator,
 
-    pub fn init(app: *App) Browser {
+    pub fn init(app: *App) !Browser {
         return .{
             .arena = std.heap.ArenaAllocator.init(app.allocator),
         };
@@ -61,8 +61,8 @@ const Browser = struct {
         self.session.?.* = .{
             .page = null,
             .arena = arena,
-            .env = Env{},
-            .inspector = Inspector{},
+            .executor = .{},
+            .inspector = .{},
         };
         return self.session.?;
     }
@@ -78,7 +78,7 @@ const Browser = struct {
 const Session = struct {
     page: ?Page = null,
     arena: Allocator,
-    env: Env,
+    executor: Executor,
     inspector: Inspector,
 
     pub fn currentPage(self: *Session) ?*Page {
@@ -107,19 +107,19 @@ const Session = struct {
     }
 };
 
-const Env = struct {
-    pub fn findOrAddValue(self: *Env, value: anytype) !@TypeOf(value) { // ?
-        _ = self;
-        return value;
-    }
-};
+const Executor = struct {};
 
 const Inspector = struct {
-    pub fn getRemoteObject(self: Inspector, env: *Env, jsValue: anytype, groupName: []const u8) !RemoteObject {
+    pub fn getRemoteObject(
+        self: *const Inspector,
+        executor: Executor,
+        group: []const u8,
+        value: anytype,
+    ) !RemoteObject {
         _ = self;
-        _ = env;
-        _ = jsValue;
-        _ = groupName;
+        _ = executor;
+        _ = group;
+        _ = value;
         return RemoteObject{};
     }
 };
@@ -217,7 +217,7 @@ const TestContext = struct {
             self.client = Client.init(self.arena.allocator());
             // Don't use the arena here. We want to detect leaks in CDP.
             // The arena is only for test-specific stuff
-            self.cdp_ = TestCDP.init(self.app, &self.client.?);
+            self.cdp_ = try TestCDP.init(self.app, &self.client.?);
         }
         return &self.cdp_.?;
     }
diff --git a/src/dom/document.zig b/src/dom/document.zig
deleted file mode 100644
index 0b8d0cd8..00000000
--- a/src/dom/document.zig
+++ /dev/null
@@ -1,468 +0,0 @@
-// Copyright (C) 2023-2024  Lightpanda (Selecy SAS)
-//
-// Francis Bouvier 
-// Pierre Tachoire 
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as
-// published by the Free Software Foundation, either version 3 of the
-// License, or (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program.  If not, see .
-
-const std = @import("std");
-
-const parser = @import("netsurf");
-
-const jsruntime = @import("jsruntime");
-const Case = jsruntime.test_utils.Case;
-const checkCases = jsruntime.test_utils.checkCases;
-const Variadic = jsruntime.Variadic;
-
-const Node = @import("node.zig").Node;
-const NodeList = @import("nodelist.zig").NodeList;
-const NodeUnion = @import("node.zig").Union;
-
-const collection = @import("html_collection.zig");
-const css = @import("css.zig");
-
-const Element = @import("element.zig").Element;
-const ElementUnion = @import("element.zig").Union;
-
-const DocumentType = @import("document_type.zig").DocumentType;
-const DocumentFragment = @import("document_fragment.zig").DocumentFragment;
-const DOMImplementation = @import("implementation.zig").DOMImplementation;
-
-const UserContext = @import("../user_context.zig").UserContext;
-
-// WEB IDL https://dom.spec.whatwg.org/#document
-pub const Document = struct {
-    pub const Self = parser.Document;
-    pub const prototype = *Node;
-    pub const mem_guarantied = true;
-
-    pub fn constructor(userctx: UserContext) !*parser.DocumentHTML {
-        const doc = try parser.documentCreateDocument(
-            try parser.documentHTMLGetTitle(userctx.document),
-        );
-
-        // we have to work w/ document instead of html document.
-        const ddoc = parser.documentHTMLToDocument(doc);
-        const ccur = parser.documentHTMLToDocument(userctx.document);
-        try parser.documentSetDocumentURI(ddoc, try parser.documentGetDocumentURI(ccur));
-        try parser.documentSetInputEncoding(ddoc, try parser.documentGetInputEncoding(ccur));
-
-        return doc;
-    }
-
-    // JS funcs
-    // --------
-    pub fn get_implementation(_: *parser.Document) DOMImplementation {
-        return DOMImplementation{};
-    }
-
-    pub fn get_documentElement(self: *parser.Document) !?ElementUnion {
-        const e = try parser.documentGetDocumentElement(self);
-        if (e == null) return null;
-        return try Element.toInterface(e.?);
-    }
-
-    pub fn get_documentURI(self: *parser.Document) ![]const u8 {
-        return try parser.documentGetDocumentURI(self);
-    }
-
-    pub fn get_URL(self: *parser.Document) ![]const u8 {
-        return try get_documentURI(self);
-    }
-
-    // TODO implement contentType
-    pub fn get_contentType(self: *parser.Document) []const u8 {
-        _ = self;
-        return "text/html";
-    }
-
-    // TODO implement compactMode
-    pub fn get_compatMode(self: *parser.Document) []const u8 {
-        _ = self;
-        return "CSS1Compat";
-    }
-
-    pub fn get_characterSet(self: *parser.Document) ![]const u8 {
-        return try parser.documentGetInputEncoding(self);
-    }
-
-    // alias of get_characterSet
-    pub fn get_charset(self: *parser.Document) ![]const u8 {
-        return try get_characterSet(self);
-    }
-
-    // alias of get_characterSet
-    pub fn get_inputEncoding(self: *parser.Document) ![]const u8 {
-        return try get_characterSet(self);
-    }
-
-    pub fn get_doctype(self: *parser.Document) !?*parser.DocumentType {
-        return try parser.documentGetDoctype(self);
-    }
-
-    pub fn _createEvent(_: *parser.Document, eventCstr: []const u8) !*parser.Event {
-        // TODO: for now only "Event" constructor is supported
-        // see table on https://dom.spec.whatwg.org/#dom-document-createevent $2
-        if (std.ascii.eqlIgnoreCase(eventCstr, "Event") or std.ascii.eqlIgnoreCase(eventCstr, "Events")) {
-            return try parser.eventCreate();
-        }
-        return parser.DOMError.NotSupported;
-    }
-
-    pub fn _getElementById(self: *parser.Document, id: []const u8) !?ElementUnion {
-        const e = try parser.documentGetElementById(self, id) orelse return null;
-        return try Element.toInterface(e);
-    }
-
-    pub fn _createElement(self: *parser.Document, tag_name: []const u8) !ElementUnion {
-        const e = try parser.documentCreateElement(self, tag_name);
-        return try Element.toInterface(e);
-    }
-
-    pub fn _createElementNS(self: *parser.Document, ns: []const u8, tag_name: []const u8) !ElementUnion {
-        const e = try parser.documentCreateElementNS(self, ns, tag_name);
-        return try Element.toInterface(e);
-    }
-
-    // We can't simply use libdom dom_document_get_elements_by_tag_name here.
-    // Indeed, netsurf implemented a previous dom spec when
-    // getElementsByTagName returned a NodeList.
-    // But since
-    // https://github.com/whatwg/dom/commit/190700b7c12ecfd3b5ebdb359ab1d6ea9cbf7749
-    // the spec changed to return an HTMLCollection instead.
-    // That's why we reimplemented getElementsByTagName by using an
-    // HTMLCollection in zig here.
-    pub fn _getElementsByTagName(
-        self: *parser.Document,
-        alloc: std.mem.Allocator,
-        tag_name: []const u8,
-    ) !collection.HTMLCollection {
-        return try collection.HTMLCollectionByTagName(alloc, parser.documentToNode(self), tag_name, true);
-    }
-
-    pub fn _getElementsByClassName(
-        self: *parser.Document,
-        alloc: std.mem.Allocator,
-        classNames: []const u8,
-    ) !collection.HTMLCollection {
-        return try collection.HTMLCollectionByClassName(alloc, parser.documentToNode(self), classNames, true);
-    }
-
-    pub fn _createDocumentFragment(self: *parser.Document) !*parser.DocumentFragment {
-        return try parser.documentCreateDocumentFragment(self);
-    }
-
-    pub fn _createTextNode(self: *parser.Document, data: []const u8) !*parser.Text {
-        return try parser.documentCreateTextNode(self, data);
-    }
-
-    pub fn _createCDATASection(self: *parser.Document, data: []const u8) !*parser.CDATASection {
-        return try parser.documentCreateCDATASection(self, data);
-    }
-
-    pub fn _createComment(self: *parser.Document, data: []const u8) !*parser.Comment {
-        return try parser.documentCreateComment(self, data);
-    }
-
-    pub fn _createProcessingInstruction(self: *parser.Document, target: []const u8, data: []const u8) !*parser.ProcessingInstruction {
-        return try parser.documentCreateProcessingInstruction(self, target, data);
-    }
-
-    pub fn _importNode(self: *parser.Document, node: *parser.Node, deep: ?bool) !NodeUnion {
-        const n = try parser.documentImportNode(self, node, deep orelse false);
-        return try Node.toInterface(n);
-    }
-
-    pub fn _adoptNode(self: *parser.Document, node: *parser.Node) !NodeUnion {
-        const n = try parser.documentAdoptNode(self, node);
-        return try Node.toInterface(n);
-    }
-
-    pub fn _createAttribute(self: *parser.Document, name: []const u8) !*parser.Attribute {
-        return try parser.documentCreateAttribute(self, name);
-    }
-
-    pub fn _createAttributeNS(self: *parser.Document, ns: []const u8, qname: []const u8) !*parser.Attribute {
-        return try parser.documentCreateAttributeNS(self, ns, qname);
-    }
-
-    // ParentNode
-    // https://dom.spec.whatwg.org/#parentnode
-    pub fn get_children(self: *parser.Document) !collection.HTMLCollection {
-        return try collection.HTMLCollectionChildren(parser.documentToNode(self), false);
-    }
-
-    pub fn get_firstElementChild(self: *parser.Document) !?ElementUnion {
-        const elt = try parser.documentGetDocumentElement(self) orelse return null;
-        return try Element.toInterface(elt);
-    }
-
-    pub fn get_lastElementChild(self: *parser.Document) !?ElementUnion {
-        const elt = try parser.documentGetDocumentElement(self) orelse return null;
-        return try Element.toInterface(elt);
-    }
-
-    pub fn get_childElementCount(self: *parser.Document) !u32 {
-        _ = try parser.documentGetDocumentElement(self) orelse return 0;
-        return 1;
-    }
-
-    pub fn _querySelector(self: *parser.Document, alloc: std.mem.Allocator, selector: []const u8) !?ElementUnion {
-        if (selector.len == 0) return null;
-
-        const n = try css.querySelector(alloc, parser.documentToNode(self), selector);
-
-        if (n == null) return null;
-
-        return try Element.toInterface(parser.nodeToElement(n.?));
-    }
-
-    pub fn _querySelectorAll(self: *parser.Document, alloc: std.mem.Allocator, selector: []const u8) !NodeList {
-        return css.querySelectorAll(alloc, parser.documentToNode(self), selector);
-    }
-
-    // TODO according with https://dom.spec.whatwg.org/#parentnode, the
-    // function must accept either node or string.
-    // blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
-    pub fn _prepend(self: *parser.Document, nodes: ?Variadic(*parser.Node)) !void {
-        return Node.prepend(parser.documentToNode(self), nodes);
-    }
-
-    // TODO according with https://dom.spec.whatwg.org/#parentnode, the
-    // function must accept either node or string.
-    // blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
-    pub fn _append(self: *parser.Document, nodes: ?Variadic(*parser.Node)) !void {
-        return Node.append(parser.documentToNode(self), nodes);
-    }
-
-    // TODO according with https://dom.spec.whatwg.org/#parentnode, the
-    // function must accept either node or string.
-    // blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
-    pub fn _replaceChildren(self: *parser.Document, nodes: ?Variadic(*parser.Node)) !void {
-        return Node.replaceChildren(parser.documentToNode(self), nodes);
-    }
-
-    pub fn deinit(_: *parser.Document, _: std.mem.Allocator) void {}
-};
-
-// Tests
-// -----
-
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var constructor = [_]Case{
-        .{ .src = "document.__proto__.__proto__.constructor.name", .ex = "Document" },
-        .{ .src = "document.__proto__.__proto__.__proto__.constructor.name", .ex = "Node" },
-        .{ .src = "document.__proto__.__proto__.__proto__.__proto__.constructor.name", .ex = "EventTarget" },
-
-        .{ .src = "let newdoc = new Document()", .ex = "undefined" },
-        .{ .src = "newdoc.documentElement", .ex = "null" },
-        .{ .src = "newdoc.children.length", .ex = "0" },
-        .{ .src = "newdoc.getElementsByTagName('*').length", .ex = "0" },
-        .{ .src = "newdoc.getElementsByTagName('*').item(0)", .ex = "null" },
-        .{ .src = "newdoc.inputEncoding === document.inputEncoding", .ex = "true" },
-        .{ .src = "newdoc.documentURI === document.documentURI", .ex = "true" },
-        .{ .src = "newdoc.URL === document.URL", .ex = "true" },
-        .{ .src = "newdoc.compatMode === document.compatMode", .ex = "true" },
-        .{ .src = "newdoc.characterSet === document.characterSet", .ex = "true" },
-        .{ .src = "newdoc.charset === document.charset", .ex = "true" },
-        .{ .src = "newdoc.contentType === document.contentType", .ex = "true" },
-    };
-    try checkCases(js_env, &constructor);
-
-    var getElementById = [_]Case{
-        .{ .src = "let getElementById = document.getElementById('content')", .ex = "undefined" },
-        .{ .src = "getElementById.constructor.name", .ex = "HTMLDivElement" },
-        .{ .src = "getElementById.localName", .ex = "div" },
-    };
-    try checkCases(js_env, &getElementById);
-
-    var getElementsByTagName = [_]Case{
-        .{ .src = "let getElementsByTagName = document.getElementsByTagName('p')", .ex = "undefined" },
-        .{ .src = "getElementsByTagName.length", .ex = "2" },
-        .{ .src = "getElementsByTagName.item(0).localName", .ex = "p" },
-        .{ .src = "getElementsByTagName.item(1).localName", .ex = "p" },
-        .{ .src = "let getElementsByTagNameAll = document.getElementsByTagName('*')", .ex = "undefined" },
-        .{ .src = "getElementsByTagNameAll.length", .ex = "8" },
-        .{ .src = "getElementsByTagNameAll.item(0).localName", .ex = "html" },
-        .{ .src = "getElementsByTagNameAll.item(7).localName", .ex = "p" },
-        .{ .src = "getElementsByTagNameAll.namedItem('para-empty-child').localName", .ex = "span" },
-    };
-    try checkCases(js_env, &getElementsByTagName);
-
-    var getElementsByClassName = [_]Case{
-        .{ .src = "let ok = document.getElementsByClassName('ok')", .ex = "undefined" },
-        .{ .src = "ok.length", .ex = "2" },
-        .{ .src = "let empty = document.getElementsByClassName('empty')", .ex = "undefined" },
-        .{ .src = "empty.length", .ex = "1" },
-        .{ .src = "let emptyok = document.getElementsByClassName('empty ok')", .ex = "undefined" },
-        .{ .src = "emptyok.length", .ex = "1" },
-    };
-    try checkCases(js_env, &getElementsByClassName);
-
-    var getDocumentElement = [_]Case{
-        .{ .src = "let e = document.documentElement", .ex = "undefined" },
-        .{ .src = "e.localName", .ex = "html" },
-    };
-    try checkCases(js_env, &getDocumentElement);
-
-    var getCharacterSet = [_]Case{
-        .{ .src = "document.characterSet", .ex = "UTF-8" },
-        .{ .src = "document.charset", .ex = "UTF-8" },
-        .{ .src = "document.inputEncoding", .ex = "UTF-8" },
-    };
-    try checkCases(js_env, &getCharacterSet);
-
-    var getCompatMode = [_]Case{
-        .{ .src = "document.compatMode", .ex = "CSS1Compat" },
-    };
-    try checkCases(js_env, &getCompatMode);
-
-    var getContentType = [_]Case{
-        .{ .src = "document.contentType", .ex = "text/html" },
-    };
-    try checkCases(js_env, &getContentType);
-
-    var getDocumentURI = [_]Case{
-        .{ .src = "document.documentURI", .ex = "about:blank" },
-        .{ .src = "document.URL", .ex = "about:blank" },
-    };
-    try checkCases(js_env, &getDocumentURI);
-
-    var getImplementation = [_]Case{
-        .{ .src = "let impl = document.implementation", .ex = "undefined" },
-    };
-    try checkCases(js_env, &getImplementation);
-
-    var new = [_]Case{
-        .{ .src = "let d = new Document()", .ex = "undefined" },
-        .{ .src = "d.characterSet", .ex = "UTF-8" },
-        .{ .src = "d.URL", .ex = "about:blank" },
-        .{ .src = "d.documentURI", .ex = "about:blank" },
-        .{ .src = "d.compatMode", .ex = "CSS1Compat" },
-        .{ .src = "d.contentType", .ex = "text/html" },
-    };
-    try checkCases(js_env, &new);
-
-    var createDocumentFragment = [_]Case{
-        .{ .src = "var v = document.createDocumentFragment()", .ex = "undefined" },
-        .{ .src = "v.nodeName", .ex = "#document-fragment" },
-    };
-    try checkCases(js_env, &createDocumentFragment);
-
-    var createTextNode = [_]Case{
-        .{ .src = "var v = document.createTextNode('foo')", .ex = "undefined" },
-        .{ .src = "v.nodeName", .ex = "#text" },
-    };
-    try checkCases(js_env, &createTextNode);
-
-    var createCDATASection = [_]Case{
-        .{ .src = "var v = document.createCDATASection('foo')", .ex = "undefined" },
-        .{ .src = "v.nodeName", .ex = "#cdata-section" },
-    };
-    try checkCases(js_env, &createCDATASection);
-
-    var createComment = [_]Case{
-        .{ .src = "var v = document.createComment('foo')", .ex = "undefined" },
-        .{ .src = "v.nodeName", .ex = "#comment" },
-        .{ .src = "let v2 = v.cloneNode()", .ex = "undefined" },
-    };
-    try checkCases(js_env, &createComment);
-
-    var createProcessingInstruction = [_]Case{
-        .{ .src = "let pi = document.createProcessingInstruction('foo', 'bar')", .ex = "undefined" },
-        .{ .src = "pi.target", .ex = "foo" },
-        .{ .src = "let pi2 = pi.cloneNode()", .ex = "undefined" },
-    };
-    try checkCases(js_env, &createProcessingInstruction);
-
-    var importNode = [_]Case{
-        .{ .src = "let nimp = document.getElementById('content')", .ex = "undefined" },
-        .{ .src = "var v = document.importNode(nimp)", .ex = "undefined" },
-        .{ .src = "v.nodeName", .ex = "DIV" },
-    };
-    try checkCases(js_env, &importNode);
-
-    var createAttr = [_]Case{
-        .{ .src = "var v = document.createAttribute('foo')", .ex = "undefined" },
-        .{ .src = "v.nodeName", .ex = "foo" },
-    };
-    try checkCases(js_env, &createAttr);
-
-    var parentNode = [_]Case{
-        .{ .src = "document.children.length", .ex = "1" },
-        .{ .src = "document.children.item(0).nodeName", .ex = "HTML" },
-        .{ .src = "document.firstElementChild.nodeName", .ex = "HTML" },
-        .{ .src = "document.lastElementChild.nodeName", .ex = "HTML" },
-        .{ .src = "document.childElementCount", .ex = "1" },
-
-        .{ .src = "let nd = new Document()", .ex = "undefined" },
-        .{ .src = "nd.children.length", .ex = "0" },
-        .{ .src = "nd.children.item(0)", .ex = "null" },
-        .{ .src = "nd.firstElementChild", .ex = "null" },
-        .{ .src = "nd.lastElementChild", .ex = "null" },
-        .{ .src = "nd.childElementCount", .ex = "0" },
-
-        .{ .src = "let emptydoc = document.createElement('html')", .ex = "undefined" },
-        .{ .src = "emptydoc.prepend(document.createElement('html'))", .ex = "undefined" },
-
-        .{ .src = "let emptydoc2 = document.createElement('html')", .ex = "undefined" },
-        .{ .src = "emptydoc2.append(document.createElement('html'))", .ex = "undefined" },
-    };
-    try checkCases(js_env, &parentNode);
-
-    var querySelector = [_]Case{
-        .{ .src = "document.querySelector('')", .ex = "null" },
-        .{ .src = "document.querySelector('*').nodeName", .ex = "HTML" },
-        .{ .src = "document.querySelector('#content').id", .ex = "content" },
-        .{ .src = "document.querySelector('#para').id", .ex = "para" },
-        .{ .src = "document.querySelector('.ok').id", .ex = "link" },
-        .{ .src = "document.querySelector('a ~ p').id", .ex = "para-empty" },
-        .{ .src = "document.querySelector(':root').nodeName", .ex = "HTML" },
-
-        .{ .src = "document.querySelectorAll('p').length", .ex = "2" },
-        .{ .src = 
-        \\Array.from(document.querySelectorAll('#content > p#para-empty'))
-        \\.map(row => row.querySelector('span').textContent)
-        \\.length;
-        , .ex = "1" },
-    };
-    try checkCases(js_env, &querySelector);
-
-    // this test breaks the doc structure, keep it at the end of the test
-    // suite.
-    var adoptNode = [_]Case{
-        .{ .src = "let nadop = document.getElementById('content')", .ex = "undefined" },
-        .{ .src = "var v = document.adoptNode(nadop)", .ex = "undefined" },
-        .{ .src = "v.nodeName", .ex = "DIV" },
-    };
-    try checkCases(js_env, &adoptNode);
-
-    const tags = comptime parser.Tag.all();
-    var createElements: [(tags.len) * 2]Case = undefined;
-    inline for (tags, 0..) |tag, i| {
-        const tag_name = @tagName(tag);
-        createElements[i * 2] = Case{
-            .src = "var " ++ tag_name ++ "Elem = document.createElement('" ++ tag_name ++ "')",
-            .ex = "undefined",
-        };
-        createElements[(i * 2) + 1] = Case{
-            .src = tag_name ++ "Elem.localName",
-            .ex = tag_name,
-        };
-    }
-    try checkCases(js_env, &createElements);
-}
diff --git a/src/dom/event_target.zig b/src/dom/event_target.zig
deleted file mode 100644
index ca688993..00000000
--- a/src/dom/event_target.zig
+++ /dev/null
@@ -1,243 +0,0 @@
-// Copyright (C) 2023-2024  Lightpanda (Selecy SAS)
-//
-// Francis Bouvier 
-// Pierre Tachoire 
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as
-// published by the Free Software Foundation, either version 3 of the
-// License, or (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program.  If not, see .
-
-const std = @import("std");
-
-const jsruntime = @import("jsruntime");
-const Callback = jsruntime.Callback;
-const JSObjectID = jsruntime.JSObjectID;
-const Case = jsruntime.test_utils.Case;
-const checkCases = jsruntime.test_utils.checkCases;
-
-const parser = @import("netsurf");
-const EventHandler = @import("../events/event.zig").EventHandler;
-
-const DOMException = @import("exceptions.zig").DOMException;
-const Nod = @import("node.zig");
-
-// EventTarget interfaces
-pub const Union = Nod.Union;
-
-// EventTarget implementation
-pub const EventTarget = struct {
-    pub const Self = parser.EventTarget;
-    pub const Exception = DOMException;
-    pub const mem_guarantied = true;
-
-    pub fn toInterface(et: *parser.EventTarget) !Union {
-        // NOTE: for now we state that all EventTarget are Nodes
-        // TODO: handle other types (eg. Window)
-        return Nod.Node.toInterface(@as(*parser.Node, @ptrCast(et)));
-    }
-
-    // JS funcs
-    // --------
-
-    pub fn _addEventListener(
-        self: *parser.EventTarget,
-        alloc: std.mem.Allocator,
-        eventType: []const u8,
-        cbk: Callback,
-        capture: ?bool,
-        // TODO: hanle EventListenerOptions
-        // see #https://github.com/lightpanda-io/jsruntime-lib/issues/114
-    ) !void {
-
-        // check if event target has already this listener
-        const lst = try parser.eventTargetHasListener(
-            self,
-            eventType,
-            capture orelse false,
-            cbk.id(),
-        );
-        if (lst != null) {
-            return;
-        }
-
-        try parser.eventTargetAddEventListener(
-            self,
-            alloc,
-            eventType,
-            EventHandler,
-            .{ .cbk = cbk },
-            capture orelse false,
-        );
-    }
-
-    pub fn _removeEventListener(
-        self: *parser.EventTarget,
-        alloc: std.mem.Allocator,
-        eventType: []const u8,
-        cbk_id: JSObjectID,
-        capture: ?bool,
-        // TODO: hanle EventListenerOptions
-        // see #https://github.com/lightpanda-io/jsruntime-lib/issues/114
-    ) !void {
-
-        // check if event target has already this listener
-        const lst = try parser.eventTargetHasListener(
-            self,
-            eventType,
-            capture orelse false,
-            cbk_id.get(),
-        );
-        if (lst == null) {
-            return;
-        }
-
-        // remove listener
-        try parser.eventTargetRemoveEventListener(
-            self,
-            alloc,
-            eventType,
-            lst.?,
-            capture orelse false,
-        );
-    }
-
-    pub fn _dispatchEvent(self: *parser.EventTarget, event: *parser.Event) !bool {
-        return try parser.eventTargetDispatchEvent(self, event);
-    }
-
-    pub fn deinit(self: *parser.EventTarget, alloc: std.mem.Allocator) void {
-        parser.eventTargetRemoveAllEventListeners(self, alloc) catch unreachable;
-    }
-};
-
-// Tests
-// -----
-
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var common = [_]Case{
-        .{ .src = "let content = document.getElementById('content')", .ex = "undefined" },
-        .{ .src = "let para = document.getElementById('para')", .ex = "undefined" },
-        // NOTE: as some event properties will change during the event dispatching phases
-        // we need to copy thoses values in order to check them afterwards
-        .{ .src = 
-        \\var nb = 0; var evt; var phase; var cur;
-        \\function cbk(event) {
-        \\evt = event;
-        \\phase = event.eventPhase;
-        \\cur = event.currentTarget;
-        \\nb ++;
-        \\}
-        , .ex = "undefined" },
-    };
-    try checkCases(js_env, &common);
-
-    var basic = [_]Case{
-        .{ .src = "content.addEventListener('basic', cbk)", .ex = "undefined" },
-        .{ .src = "content.dispatchEvent(new Event('basic'))", .ex = "true" },
-        .{ .src = "nb", .ex = "1" },
-        .{ .src = "evt instanceof Event", .ex = "true" },
-        .{ .src = "evt.type", .ex = "basic" },
-        .{ .src = "phase", .ex = "2" },
-        .{ .src = "cur.getAttribute('id')", .ex = "content" },
-    };
-    try checkCases(js_env, &basic);
-
-    var basic_child = [_]Case{
-        .{ .src = "nb = 0; evt = undefined; phase = undefined; cur = undefined", .ex = "undefined" },
-        .{ .src = "para.dispatchEvent(new Event('basic'))", .ex = "true" },
-        .{ .src = "nb", .ex = "0" }, // handler is not called, no capture, not the target, no bubbling
-        .{ .src = "evt === undefined", .ex = "true" },
-    };
-    try checkCases(js_env, &basic_child);
-
-    var basic_twice = [_]Case{
-        .{ .src = "nb  = 0", .ex = "0" },
-        .{ .src = "content.addEventListener('basic', cbk)", .ex = "undefined" },
-        .{ .src = "content.dispatchEvent(new Event('basic'))", .ex = "true" },
-        .{ .src = "nb", .ex = "1" },
-    };
-    try checkCases(js_env, &basic_twice);
-
-    var basic_twice_capture = [_]Case{
-        .{ .src = "nb  = 0", .ex = "0" },
-        .{ .src = "content.addEventListener('basic', cbk, true)", .ex = "undefined" },
-        .{ .src = "content.dispatchEvent(new Event('basic'))", .ex = "true" },
-        .{ .src = "nb", .ex = "2" },
-    };
-    try checkCases(js_env, &basic_twice_capture);
-
-    var basic_remove = [_]Case{
-        .{ .src = "nb  = 0", .ex = "0" },
-        .{ .src = "content.removeEventListener('basic', cbk)", .ex = "undefined" },
-        .{ .src = "content.dispatchEvent(new Event('basic'))", .ex = "true" },
-        .{ .src = "nb", .ex = "1" },
-    };
-    try checkCases(js_env, &basic_remove);
-
-    var basic_capture_remove = [_]Case{
-        .{ .src = "nb  = 0", .ex = "0" },
-        .{ .src = "content.removeEventListener('basic', cbk, true)", .ex = "undefined" },
-        .{ .src = "content.dispatchEvent(new Event('basic'))", .ex = "true" },
-        .{ .src = "nb", .ex = "0" },
-    };
-    try checkCases(js_env, &basic_capture_remove);
-
-    var capture = [_]Case{
-        .{ .src = "nb = 0; evt = undefined; phase = undefined; cur = undefined", .ex = "undefined" },
-        .{ .src = "content.addEventListener('capture', cbk, true)", .ex = "undefined" },
-        .{ .src = "content.dispatchEvent(new Event('capture'))", .ex = "true" },
-        .{ .src = "nb", .ex = "1" },
-        .{ .src = "evt instanceof Event", .ex = "true" },
-        .{ .src = "evt.type", .ex = "capture" },
-        .{ .src = "phase", .ex = "2" },
-        .{ .src = "cur.getAttribute('id')", .ex = "content" },
-    };
-    try checkCases(js_env, &capture);
-
-    var capture_child = [_]Case{
-        .{ .src = "nb = 0; evt = undefined; phase = undefined; cur = undefined", .ex = "undefined" },
-        .{ .src = "para.dispatchEvent(new Event('capture'))", .ex = "true" },
-        .{ .src = "nb", .ex = "1" },
-        .{ .src = "evt instanceof Event", .ex = "true" },
-        .{ .src = "evt.type", .ex = "capture" },
-        .{ .src = "phase", .ex = "1" },
-        .{ .src = "cur.getAttribute('id')", .ex = "content" },
-    };
-    try checkCases(js_env, &capture_child);
-
-    var bubbles = [_]Case{
-        .{ .src = "nb = 0; evt = undefined; phase = undefined; cur = undefined", .ex = "undefined" },
-        .{ .src = "content.addEventListener('bubbles', cbk)", .ex = "undefined" },
-        .{ .src = "content.dispatchEvent(new Event('bubbles', {bubbles: true}))", .ex = "true" },
-        .{ .src = "nb", .ex = "1" },
-        .{ .src = "evt instanceof Event", .ex = "true" },
-        .{ .src = "evt.type", .ex = "bubbles" },
-        .{ .src = "evt.bubbles", .ex = "true" },
-        .{ .src = "phase", .ex = "2" },
-        .{ .src = "cur.getAttribute('id')", .ex = "content" },
-    };
-    try checkCases(js_env, &bubbles);
-
-    var bubbles_child = [_]Case{
-        .{ .src = "nb = 0; evt = undefined; phase = undefined; cur = undefined", .ex = "undefined" },
-        .{ .src = "para.dispatchEvent(new Event('bubbles', {bubbles: true}))", .ex = "true" },
-        .{ .src = "nb", .ex = "1" },
-        .{ .src = "evt instanceof Event", .ex = "true" },
-        .{ .src = "evt.type", .ex = "bubbles" },
-        .{ .src = "phase", .ex = "3" },
-        .{ .src = "cur.getAttribute('id')", .ex = "content" },
-    };
-    try checkCases(js_env, &bubbles_child);
-}
diff --git a/src/events/event.zig b/src/events/event.zig
deleted file mode 100644
index 35069ceb..00000000
--- a/src/events/event.zig
+++ /dev/null
@@ -1,263 +0,0 @@
-// Copyright (C) 2023-2024  Lightpanda (Selecy SAS)
-//
-// Francis Bouvier 
-// Pierre Tachoire 
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as
-// published by the Free Software Foundation, either version 3 of the
-// License, or (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program.  If not, see .
-
-const std = @import("std");
-
-const generate = @import("../generate.zig");
-
-const jsruntime = @import("jsruntime");
-const Callback = jsruntime.Callback;
-const CallbackResult = jsruntime.CallbackResult;
-const Case = jsruntime.test_utils.Case;
-const checkCases = jsruntime.test_utils.checkCases;
-
-const parser = @import("netsurf");
-
-const DOMException = @import("../dom/exceptions.zig").DOMException;
-const EventTarget = @import("../dom/event_target.zig").EventTarget;
-const EventTargetUnion = @import("../dom/event_target.zig").Union;
-
-const ProgressEvent = @import("../xhr/progress_event.zig").ProgressEvent;
-
-const log = std.log.scoped(.events);
-
-// Event interfaces
-pub const Interfaces = .{
-    Event,
-    ProgressEvent,
-};
-
-pub const Union = generate.Union(Interfaces);
-
-// https://dom.spec.whatwg.org/#event
-pub const Event = struct {
-    pub const Self = parser.Event;
-    pub const Exception = DOMException;
-    pub const mem_guarantied = true;
-
-    pub const EventInit = parser.EventInit;
-
-    // JS
-    // --
-
-    pub const _CAPTURING_PHASE = 1;
-    pub const _AT_TARGET = 2;
-    pub const _BUBBLING_PHASE = 3;
-
-    pub fn toInterface(evt: *parser.Event) !Union {
-        return switch (try parser.eventGetInternalType(evt)) {
-            .event => .{ .Event = evt },
-            .progress_event => .{ .ProgressEvent = @as(*ProgressEvent, @ptrCast(evt)).* },
-        };
-    }
-
-    pub fn constructor(eventType: []const u8, opts: ?EventInit) !*parser.Event {
-        const event = try parser.eventCreate();
-        try parser.eventInit(event, eventType, opts orelse EventInit{});
-        return event;
-    }
-
-    // Getters
-
-    pub fn get_type(self: *parser.Event) ![]const u8 {
-        return try parser.eventType(self);
-    }
-
-    pub fn get_target(self: *parser.Event) !?EventTargetUnion {
-        const et = try parser.eventTarget(self);
-        if (et == null) return null;
-        return try EventTarget.toInterface(et.?);
-    }
-
-    pub fn get_currentTarget(self: *parser.Event) !?EventTargetUnion {
-        const et = try parser.eventCurrentTarget(self);
-        if (et == null) return null;
-        return try EventTarget.toInterface(et.?);
-    }
-
-    pub fn get_eventPhase(self: *parser.Event) !u8 {
-        return try parser.eventPhase(self);
-    }
-
-    pub fn get_bubbles(self: *parser.Event) !bool {
-        return try parser.eventBubbles(self);
-    }
-
-    pub fn get_cancelable(self: *parser.Event) !bool {
-        return try parser.eventCancelable(self);
-    }
-
-    pub fn get_defaultPrevented(self: *parser.Event) !bool {
-        return try parser.eventDefaultPrevented(self);
-    }
-
-    pub fn get_isTrusted(self: *parser.Event) !bool {
-        return try parser.eventIsTrusted(self);
-    }
-
-    pub fn get_timestamp(self: *parser.Event) !u32 {
-        return try parser.eventTimestamp(self);
-    }
-
-    // Methods
-
-    pub fn _initEvent(
-        self: *parser.Event,
-        eventType: []const u8,
-        bubbles: ?bool,
-        cancelable: ?bool,
-    ) !void {
-        const opts = EventInit{
-            .bubbles = bubbles orelse false,
-            .cancelable = cancelable orelse false,
-        };
-        return try parser.eventInit(self, eventType, opts);
-    }
-
-    pub fn _stopPropagation(self: *parser.Event) !void {
-        return try parser.eventStopPropagation(self);
-    }
-
-    pub fn _stopImmediatePropagation(self: *parser.Event) !void {
-        return try parser.eventStopImmediatePropagation(self);
-    }
-
-    pub fn _preventDefault(self: *parser.Event) !void {
-        return try parser.eventPreventDefault(self);
-    }
-};
-
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var common = [_]Case{
-        .{ .src = "let content = document.getElementById('content')", .ex = "undefined" },
-        .{ .src = "let para = document.getElementById('para')", .ex = "undefined" },
-        .{ .src = "var nb = 0; var evt", .ex = "undefined" },
-    };
-    try checkCases(js_env, &common);
-
-    var basic = [_]Case{
-        .{ .src = 
-        \\content.addEventListener('target',
-        \\function(e) {
-        \\evt = e; nb = nb + 1;
-        \\e.preventDefault();
-        \\})
-        , .ex = "undefined" },
-        .{ .src = "content.dispatchEvent(new Event('target', {bubbles: true, cancelable: true}))", .ex = "false" },
-        .{ .src = "nb", .ex = "1" },
-        .{ .src = "evt.target === content", .ex = "true" },
-        .{ .src = "evt.bubbles", .ex = "true" },
-        .{ .src = "evt.cancelable", .ex = "true" },
-        .{ .src = "evt.defaultPrevented", .ex = "true" },
-        .{ .src = "evt.isTrusted", .ex = "true" },
-        .{ .src = "evt.timestamp > 1704063600", .ex = "true" }, // 2024/01/01 00:00
-        // event.type, event.currentTarget, event.phase checked in EventTarget
-    };
-    try checkCases(js_env, &basic);
-
-    var stop = [_]Case{
-        .{ .src = "nb = 0", .ex = "0" },
-        .{ .src = 
-        \\content.addEventListener('stop',
-        \\function(e) {
-        \\e.stopPropagation();
-        \\nb = nb + 1;
-        \\}, true)
-        , .ex = "undefined" },
-        // the following event listener will not be invoked
-        .{ .src = 
-        \\para.addEventListener('stop',
-        \\function(e) {
-        \\nb = nb + 1;
-        \\})
-        , .ex = "undefined" },
-        .{ .src = "para.dispatchEvent(new Event('stop'))", .ex = "true" },
-        .{ .src = "nb", .ex = "1" }, // will be 2 if event was not stopped at content event listener
-    };
-    try checkCases(js_env, &stop);
-
-    var stop_immediate = [_]Case{
-        .{ .src = "nb = 0", .ex = "0" },
-        .{ .src = 
-        \\content.addEventListener('immediate',
-        \\function(e) {
-        \\e.stopImmediatePropagation();
-        \\nb = nb + 1;
-        \\})
-        , .ex = "undefined" },
-        // the following event listener will not be invoked
-        .{ .src = 
-        \\content.addEventListener('immediate',
-        \\function(e) {
-        \\nb = nb + 1;
-        \\})
-        , .ex = "undefined" },
-        .{ .src = "content.dispatchEvent(new Event('immediate'))", .ex = "true" },
-        .{ .src = "nb", .ex = "1" }, // will be 2 if event was not stopped at first content event listener
-    };
-    try checkCases(js_env, &stop_immediate);
-
-    var legacy = [_]Case{
-        .{ .src = "nb = 0", .ex = "0" },
-        .{ .src = 
-        \\content.addEventListener('legacy',
-        \\function(e) {
-        \\evt = e; nb = nb + 1;
-        \\})
-        , .ex = "undefined" },
-        .{ .src = "let evtLegacy = document.createEvent('Event')", .ex = "undefined" },
-        .{ .src = "evtLegacy.initEvent('legacy')", .ex = "undefined" },
-        .{ .src = "content.dispatchEvent(evtLegacy)", .ex = "true" },
-        .{ .src = "nb", .ex = "1" },
-    };
-    try checkCases(js_env, &legacy);
-
-    var remove = [_]Case{
-        .{ .src = "var nb = 0; var evt = null; function cbk(event) { nb ++; evt=event; }", .ex = "undefined" },
-        .{ .src = "document.addEventListener('count', cbk)", .ex = "undefined" },
-        .{ .src = "document.removeEventListener('count', cbk)", .ex = "undefined" },
-        .{ .src = "document.dispatchEvent(new Event('count'))", .ex = "true" },
-        .{ .src = "nb", .ex = "0" },
-    };
-    try checkCases(js_env, &remove);
-}
-
-pub const EventHandler = struct {
-    fn handle(event: ?*parser.Event, data: parser.EventHandlerData) void {
-        // TODO get the allocator by another way?
-        var res = CallbackResult.init(data.cbk.nat_ctx.alloc);
-        defer res.deinit();
-
-        if (event) |evt| {
-            data.cbk.trycall(.{
-                Event.toInterface(evt) catch unreachable,
-            }, &res) catch |e| log.err("event handler error: {any}", .{e});
-        } else {
-            data.cbk.trycall(.{event}, &res) catch |e| log.err("event handler error (null event): {any}", .{e});
-        }
-
-        // in case of function error, we log the result and the trace.
-        if (!res.success) {
-            log.info("event handler error try catch: {s}", .{res.result orelse "unknown"});
-            log.debug("{s}", .{res.stack orelse "no stack trace"});
-        }
-    }
-}.handle;
diff --git a/src/html/location.zig b/src/html/location.zig
deleted file mode 100644
index 95a62dae..00000000
--- a/src/html/location.zig
+++ /dev/null
@@ -1,129 +0,0 @@
-// Copyright (C) 2023-2024  Lightpanda (Selecy SAS)
-//
-// Francis Bouvier 
-// Pierre Tachoire 
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as
-// published by the Free Software Foundation, either version 3 of the
-// License, or (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program.  If not, see .
-
-const std = @import("std");
-
-const builtin = @import("builtin");
-const jsruntime = @import("jsruntime");
-
-const URL = @import("../url/url.zig").URL;
-
-const Case = jsruntime.test_utils.Case;
-const checkCases = jsruntime.test_utils.checkCases;
-
-// https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-location-interface
-pub const Location = struct {
-    pub const mem_guarantied = true;
-
-    url: ?URL = null,
-
-    pub fn deinit(_: *Location, _: std.mem.Allocator) void {}
-
-    pub fn get_href(self: *Location, alloc: std.mem.Allocator) ![]const u8 {
-        if (self.url) |*u| return u.get_href(alloc);
-
-        return "";
-    }
-
-    pub fn get_protocol(self: *Location, alloc: std.mem.Allocator) ![]const u8 {
-        if (self.url) |*u| return u.get_protocol(alloc);
-
-        return "";
-    }
-
-    pub fn get_host(self: *Location, alloc: std.mem.Allocator) ![]const u8 {
-        if (self.url) |*u| return u.get_host(alloc);
-
-        return "";
-    }
-
-    pub fn get_hostname(self: *Location) []const u8 {
-        if (self.url) |*u| return u.get_hostname();
-
-        return "";
-    }
-
-    pub fn get_port(self: *Location, alloc: std.mem.Allocator) ![]const u8 {
-        if (self.url) |*u| return u.get_port(alloc);
-
-        return "";
-    }
-
-    pub fn get_pathname(self: *Location) []const u8 {
-        if (self.url) |*u| return u.get_pathname();
-
-        return "";
-    }
-
-    pub fn get_search(self: *Location, alloc: std.mem.Allocator) ![]const u8 {
-        if (self.url) |*u| return u.get_search(alloc);
-
-        return "";
-    }
-
-    pub fn get_hash(self: *Location, alloc: std.mem.Allocator) ![]const u8 {
-        if (self.url) |*u| return u.get_hash(alloc);
-
-        return "";
-    }
-
-    pub fn get_origin(self: *Location, alloc: std.mem.Allocator) ![]const u8 {
-        if (self.url) |*u| return u.get_origin(alloc);
-
-        return "";
-    }
-
-    // TODO
-    pub fn _assign(_: *Location, url: []const u8) !void {
-        _ = url;
-    }
-
-    // TODO
-    pub fn _replace(_: *Location, url: []const u8) !void {
-        _ = url;
-    }
-
-    // TODO
-    pub fn _reload(_: *Location) !void {}
-
-    pub fn _toString(self: *Location, alloc: std.mem.Allocator) ![]const u8 {
-        return try self.get_href(alloc);
-    }
-};
-
-// Tests
-// -----
-
-pub fn testExecFn(
-    _: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    var location = [_]Case{
-        .{ .src = "location.href", .ex = "https://lightpanda.io/opensource-browser/" },
-        .{ .src = "document.location.href", .ex = "https://lightpanda.io/opensource-browser/" },
-
-        .{ .src = "location.host", .ex = "lightpanda.io" },
-        .{ .src = "location.hostname", .ex = "lightpanda.io" },
-        .{ .src = "location.origin", .ex = "https://lightpanda.io" },
-        .{ .src = "location.pathname", .ex = "/opensource-browser/" },
-        .{ .src = "location.hash", .ex = "" },
-        .{ .src = "location.port", .ex = "" },
-        .{ .src = "location.search", .ex = "" },
-    };
-    try checkCases(js_env, &location);
-}
diff --git a/src/http/client.zig b/src/http/client.zig
index b6a4c4f4..936669cd 100644
--- a/src/http/client.zig
+++ b/src/http/client.zig
@@ -27,9 +27,8 @@ const MemoryPool = std.heap.MemoryPool;
 const ArenaAllocator = std.heap.ArenaAllocator;
 
 const tls = @import("tls");
-const jsruntime = @import("jsruntime");
-const IO = jsruntime.IO;
-const Loop = jsruntime.Loop;
+const IO = @import("../runtime/loop.zig").IO;
+const Loop = @import("../runtime/loop.zig").Loop;
 
 const log = std.log.scoped(.http_client);
 
@@ -53,7 +52,7 @@ pub const Client = struct {
     };
 
     pub fn init(allocator: Allocator, max_concurrent: usize, opts: Opts) !Client {
-        var root_ca = try tls.config.CertBundle.fromSystem(allocator);
+        var root_ca: tls.config.CertBundle = if (builtin.is_test) .{} else try tls.config.CertBundle.fromSystem(allocator);
         errdefer root_ca.deinit(allocator);
 
         const state_pool = try StatePool.init(allocator, max_concurrent);
@@ -69,7 +68,9 @@ pub const Client = struct {
 
     pub fn deinit(self: *Client) void {
         const allocator = self.allocator;
-        self.root_ca.deinit(allocator);
+        if (builtin.is_test == false) {
+            self.root_ca.deinit(allocator);
+        }
         self.state_pool.deinit(allocator);
     }
 
@@ -1907,7 +1908,7 @@ test "HttpClient: sync GET redirect" {
 }
 
 test "HttpClient: async connect error" {
-    var loop = try jsruntime.Loop.init(testing.allocator);
+    var loop = try Loop.init(testing.allocator);
     defer loop.deinit();
 
     const Handler = struct {
@@ -2193,7 +2194,7 @@ const TestResponse = struct {
 };
 
 const CaptureHandler = struct {
-    loop: jsruntime.Loop,
+    loop: Loop,
     reset: Thread.ResetEvent,
     response: TestResponse,
 
@@ -2201,7 +2202,7 @@ const CaptureHandler = struct {
         return .{
             .reset = .{},
             .response = TestResponse.init(),
-            .loop = try jsruntime.Loop.init(testing.allocator),
+            .loop = try Loop.init(testing.allocator),
         };
     }
 
diff --git a/src/main.zig b/src/main.zig
index 80b5a8f6..fbb5152a 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -20,25 +20,19 @@ const std = @import("std");
 const builtin = @import("builtin");
 const Allocator = std.mem.Allocator;
 
-const jsruntime = @import("jsruntime");
-
-const App = @import("app.zig").App;
-const Browser = @import("browser/browser.zig").Browser;
 const server = @import("server.zig");
+const App = @import("app.zig").App;
+const Platform = @import("runtime/js.zig").Platform;
+const Browser = @import("browser/browser.zig").Browser;
 
-const parser = @import("netsurf");
-const apiweb = @import("apiweb.zig");
-
-pub const Types = jsruntime.reflect(apiweb.Interfaces);
-pub const UserContext = apiweb.UserContext;
-pub const IO = @import("asyncio").Wrapper(jsruntime.Loop);
+const parser = @import("browser/netsurf.zig");
 const version = @import("build_info").git_commit;
 
 const log = std.log.scoped(.cli);
 
 pub const std_options = std.Options{
     // Set the log level to info
-    .log_level = .debug,
+    .log_level = .info,
 
     // Define logFn to override the std implementation
     .logFn = logFn,
@@ -60,23 +54,34 @@ pub fn main() !void {
     const args = try parseArgs(args_arena.allocator());
 
     switch (args.mode) {
-        .help => args.printUsageAndExit(args.mode.help),
+        .help => {
+            args.printUsageAndExit(args.mode.help);
+            return std.process.cleanExit();
+        },
         .version => {
             std.debug.print("{s}\n", .{version});
             return std.process.cleanExit();
         },
+        else => {},
+    }
+
+    const platform = Platform.init();
+    defer platform.deinit();
+
+    var app = try App.init(alloc, .{
+        .run_mode = args.mode,
+        .gc_hints = args.gcHints(),
+        .tls_verify_host = args.tlsVerifyHost(),
+    });
+    defer app.deinit();
+    app.telemetry.record(.{ .run = {} });
+
+    switch (args.mode) {
         .serve => |opts| {
             const address = std.net.Address.parseIp4(opts.host, opts.port) catch |err| {
                 log.err("address (host:port) {any}\n", .{err});
                 return args.printUsageAndExit(false);
             };
-            var app = try App.init(alloc, .{
-                .run_mode = args.mode,
-                .tls_verify_host = opts.tls_verify_host,
-            });
-            defer app.deinit();
-
-            app.telemetry.record(.{ .run = {} });
 
             const timeout = std.time.ns_per_s * @as(u64, opts.timeout);
             server.run(app, address, timeout) catch |err| {
@@ -88,19 +93,8 @@ pub fn main() !void {
             log.debug("Fetch mode: url {s}, dump {any}", .{ opts.url, opts.dump });
             const url = try @import("url.zig").URL.parse(opts.url, null);
 
-            var app = try App.init(alloc, .{
-                .run_mode = args.mode,
-                .tls_verify_host = opts.tls_verify_host,
-            });
-            defer app.deinit();
-            app.telemetry.record(.{ .run = {} });
-
-            // vm
-            const vm = jsruntime.VM.init();
-            defer vm.deinit();
-
             // browser
-            var browser = Browser.init(app);
+            var browser = try Browser.init(app);
             defer browser.deinit();
 
             var session = try browser.newSession({});
@@ -126,6 +120,7 @@ pub fn main() !void {
                 try page.dump(std.io.getStdOut());
             }
         },
+        else => unreachable,
     }
 }
 
@@ -133,6 +128,21 @@ const Command = struct {
     mode: Mode,
     exec_name: []const u8,
 
+    fn gcHints(self: *const Command) bool {
+        return switch (self.mode) {
+            .serve => |opts| opts.gc_hints,
+            else => false,
+        };
+    }
+
+    fn tlsVerifyHost(self: *const Command) bool {
+        return switch (self.mode) {
+            .serve => |opts| opts.tls_verify_host,
+            .fetch => |opts| opts.tls_verify_host,
+            else => true,
+        };
+    }
+
     const Mode = union(App.RunMode) {
         help: bool, // false when being printed because of an error
         fetch: Fetch,
@@ -144,6 +154,7 @@ const Command = struct {
         host: []const u8,
         port: u16,
         timeout: u16,
+        gc_hints: bool,
         tls_verify_host: bool,
     };
 
@@ -187,6 +198,9 @@ const Command = struct {
             \\--timeout       Inactivity timeout in seconds before disconnecting clients
             \\                Defaults to 3 (seconds)
             \\
+            \\--gc_hints      Encourage V8 to cleanup garbage for each new browser context.
+            \\                Defaults to false
+            \\
             \\--insecure_disable_tls_host_verification
             \\                Disables host verification on all HTTP requests.
             \\                This is an advanced option which should only be
@@ -266,6 +280,11 @@ fn inferMode(opt: []const u8) ?App.RunMode {
     if (std.mem.eql(u8, opt, "--timeout")) {
         return .serve;
     }
+
+    if (std.mem.eql(u8, opt, "--gc_hints")) {
+        return .serve;
+    }
+
     return null;
 }
 
@@ -276,6 +295,7 @@ fn parseServeArgs(
     var host: []const u8 = "127.0.0.1";
     var port: u16 = 9222;
     var timeout: u16 = 3;
+    var gc_hints = false;
     var tls_verify_host = true;
 
     while (args.next()) |opt| {
@@ -319,6 +339,11 @@ fn parseServeArgs(
             continue;
         }
 
+        if (std.mem.eql(u8, "--gc_hints", opt)) {
+            gc_hints = true;
+            continue;
+        }
+
         log.err("Unknown option to serve command: '{s}'", .{opt});
         return error.UnkownOption;
     }
@@ -327,6 +352,7 @@ fn parseServeArgs(
         .host = host,
         .port = port,
         .timeout = timeout,
+        .gc_hints = gc_hints,
         .tls_verify_host = tls_verify_host,
     };
 }
@@ -388,3 +414,192 @@ fn logFn(
     // default std log function.
     std.log.defaultLog(level, scope, format, args);
 }
+
+test {
+    std.testing.refAllDecls(@This());
+}
+
+var test_wg: std.Thread.WaitGroup = .{};
+test "tests:beforeAll" {
+    try parser.init();
+    test_wg.startMany(3);
+    _ = Platform.init();
+
+    {
+        const address = try std.net.Address.parseIp("127.0.0.1", 9582);
+        const thread = try std.Thread.spawn(.{}, serveHTTP, .{address});
+        thread.detach();
+    }
+
+    {
+        const address = try std.net.Address.parseIp("127.0.0.1", 9581);
+        const thread = try std.Thread.spawn(.{}, serveHTTPS, .{address});
+        thread.detach();
+    }
+
+    {
+        const address = try std.net.Address.parseIp("127.0.0.1", 9583);
+        const thread = try std.Thread.spawn(.{}, serveCDP, .{address});
+        thread.detach();
+    }
+
+    // need to wait for the servers to be listening, else tests will fail because
+    // they aren't able to connect.
+    test_wg.wait();
+}
+
+test "tests:afterAll" {
+    parser.deinit();
+}
+
+fn serveHTTP(address: std.net.Address) !void {
+    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+    defer arena.deinit();
+
+    var listener = try address.listen(.{ .reuse_address = true });
+    defer listener.deinit();
+
+    test_wg.finish();
+
+    var read_buffer: [1024]u8 = undefined;
+    ACCEPT: while (true) {
+        defer _ = arena.reset(.{ .free_all = {} });
+        const aa = arena.allocator();
+
+        var conn = try listener.accept();
+        defer conn.stream.close();
+        var http_server = std.http.Server.init(conn, &read_buffer);
+
+        while (http_server.state == .ready) {
+            var request = http_server.receiveHead() catch |err| switch (err) {
+                error.HttpConnectionClosing => continue :ACCEPT,
+                else => {
+                    std.debug.print("Test HTTP Server error: {}\n", .{err});
+                    return err;
+                },
+            };
+
+            const path = request.head.target;
+            if (std.mem.eql(u8, path, "/loader")) {
+                try request.respond("Hello!", .{});
+            } else if (std.mem.eql(u8, path, "/http_client/simple")) {
+                try request.respond("", .{});
+            } else if (std.mem.eql(u8, path, "/http_client/redirect")) {
+                try request.respond("", .{
+                    .status = .moved_permanently,
+                    .extra_headers = &.{.{ .name = "LOCATION", .value = "../http_client/echo" }},
+                });
+            } else if (std.mem.eql(u8, path, "/http_client/redirect/secure")) {
+                try request.respond("", .{
+                    .status = .moved_permanently,
+                    .extra_headers = &.{.{ .name = "LOCATION", .value = "https://127.0.0.1:9581/http_client/body" }},
+                });
+            } else if (std.mem.eql(u8, path, "/http_client/echo")) {
+                var headers: std.ArrayListUnmanaged(std.http.Header) = .{};
+
+                var it = request.iterateHeaders();
+                while (it.next()) |hdr| {
+                    try headers.append(aa, .{
+                        .name = try std.fmt.allocPrint(aa, "_{s}", .{hdr.name}),
+                        .value = hdr.value,
+                    });
+                }
+
+                try request.respond("over 9000!", .{
+                    .status = .created,
+                    .extra_headers = headers.items,
+                });
+            }
+        }
+    }
+}
+
+// This is a lot of work for testing TLS, but the TLS (async) code is complicated
+// This "server" is written specifically to test the client. It assumes the client
+// isn't a jerk.
+fn serveHTTPS(address: std.net.Address) !void {
+    const tls = @import("tls");
+
+    var listener = try address.listen(.{ .reuse_address = true });
+    defer listener.deinit();
+
+    var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
+    defer arena.deinit();
+
+    test_wg.finish();
+
+    var seed: u64 = undefined;
+    std.posix.getrandom(std.mem.asBytes(&seed)) catch unreachable;
+    var r = std.Random.DefaultPrng.init(seed);
+    const rand = r.random();
+
+    var read_buffer: [1024]u8 = undefined;
+    while (true) {
+        // defer _ = arena.reset(.{ .retain_with_limit = 1024 });
+        // const aa = arena.allocator();
+
+        const stream = blk: {
+            const conn = try listener.accept();
+            break :blk conn.stream;
+        };
+        defer stream.close();
+
+        var conn = try tls.server(stream, .{ .auth = null });
+        defer conn.close() catch {};
+
+        var pos: usize = 0;
+        while (true) {
+            const n = try conn.read(read_buffer[pos..]);
+            if (n == 0) {
+                break;
+            }
+            pos += n;
+            const header_end = std.mem.indexOf(u8, read_buffer[0..pos], "\r\n\r\n") orelse {
+                continue;
+            };
+            var it = std.mem.splitScalar(u8, read_buffer[0..header_end], ' ');
+            _ = it.next() orelse unreachable; // method
+            const path = it.next() orelse unreachable;
+
+            var response: []const u8 = undefined;
+            if (std.mem.eql(u8, path, "/http_client/simple")) {
+                response = "HTTP/1.1 200 \r\nContent-Length: 0\r\n\r\n";
+            } else if (std.mem.eql(u8, path, "/http_client/body")) {
+                response = "HTTP/1.1 201 CREATED\r\nContent-Length: 20\r\n   Another :  HEaDer  \r\n\r\n1234567890abcdefhijk";
+            } else if (std.mem.eql(u8, path, "/http_client/redirect/insecure")) {
+                response = "HTTP/1.1 307 GOTO\r\nLocation: http://127.0.0.1:9582/http_client/redirect\r\n\r\n";
+            } else if (std.mem.eql(u8, path, "/xhr")) {
+                response = "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: 100\r\n\r\n" ++ ("1234567890" ** 10);
+            } else if (std.mem.eql(u8, path, "/xhr/json")) {
+                response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 18\r\n\r\n{\"over\":\"9000!!!\"}";
+            } else {
+                // should not have an unknown path
+                unreachable;
+            }
+
+            var unsent = response;
+            while (unsent.len > 0) {
+                const to_send = rand.intRangeAtMost(usize, 1, unsent.len);
+                const sent = try conn.write(unsent[0..to_send]);
+                unsent = unsent[sent..];
+                std.time.sleep(std.time.ns_per_us * 5);
+            }
+            break;
+        }
+    }
+}
+
+fn serveCDP(address: std.net.Address) !void {
+    var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
+    var app = try App.init(gpa.allocator(), .{
+        .run_mode = .serve,
+        .tls_verify_host = false,
+    });
+    defer app.deinit();
+
+    test_wg.finish();
+    server.run(app, address, std.time.ns_per_s * 2) catch |err| {
+        std.debug.print("CDP server error: {}", .{err});
+        return err;
+    };
+}
diff --git a/src/main_tests.zig b/src/main_tests.zig
deleted file mode 100644
index 035e9d8f..00000000
--- a/src/main_tests.zig
+++ /dev/null
@@ -1,420 +0,0 @@
-// Copyright (C) 2023-2024  Lightpanda (Selecy SAS)
-//
-// Francis Bouvier 
-// Pierre Tachoire 
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as
-// published by the Free Software Foundation, either version 3 of the
-// License, or (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program.  If not, see .
-
-const std = @import("std");
-const builtin = @import("builtin");
-
-const jsruntime = @import("jsruntime");
-const generate = @import("generate.zig");
-const pretty = @import("pretty");
-
-const parser = @import("netsurf");
-const apiweb = @import("apiweb.zig");
-const browser = @import("browser/browser.zig");
-const Window = @import("html/window.zig").Window;
-const xhr = @import("xhr/xhr.zig");
-const storage = @import("storage/storage.zig");
-const URL = @import("url.zig").URL;
-
-const documentTestExecFn = @import("dom/document.zig").testExecFn;
-const HTMLDocumentTestExecFn = @import("html/document.zig").testExecFn;
-const nodeTestExecFn = @import("dom/node.zig").testExecFn;
-const characterDataTestExecFn = @import("dom/character_data.zig").testExecFn;
-const textTestExecFn = @import("dom/text.zig").testExecFn;
-const elementTestExecFn = @import("dom/element.zig").testExecFn;
-const HTMLCollectionTestExecFn = @import("dom/html_collection.zig").testExecFn;
-const DOMExceptionTestExecFn = @import("dom/exceptions.zig").testExecFn;
-const DOMImplementationExecFn = @import("dom/implementation.zig").testExecFn;
-const NamedNodeMapExecFn = @import("dom/namednodemap.zig").testExecFn;
-const DOMTokenListExecFn = @import("dom/token_list.zig").testExecFn;
-const NodeListTestExecFn = @import("dom/nodelist.zig").testExecFn;
-const AttrTestExecFn = @import("dom/attribute.zig").testExecFn;
-const EventTargetTestExecFn = @import("dom/event_target.zig").testExecFn;
-const ProcessingInstructionTestExecFn = @import("dom/processing_instruction.zig").testExecFn;
-const CommentTestExecFn = @import("dom/comment.zig").testExecFn;
-const DocumentFragmentTestExecFn = @import("dom/document_fragment.zig").testExecFn;
-const EventTestExecFn = @import("events/event.zig").testExecFn;
-const XHRTestExecFn = xhr.testExecFn;
-const ProgressEventTestExecFn = @import("xhr/progress_event.zig").testExecFn;
-const StorageTestExecFn = storage.testExecFn;
-const URLTestExecFn = @import("url/url.zig").testExecFn;
-const HTMLElementTestExecFn = @import("html/elements.zig").testExecFn;
-const MutationObserverTestExecFn = @import("dom/mutation_observer.zig").testExecFn;
-
-pub const Types = jsruntime.reflect(apiweb.Interfaces);
-pub const UserContext = @import("user_context.zig").UserContext;
-pub const IO = @import("asyncio").Wrapper(jsruntime.Loop);
-
-var doc: *parser.DocumentHTML = undefined;
-
-fn testExecFn(
-    alloc: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-    comptime execFn: jsruntime.ContextExecFn,
-) anyerror!void {
-    try parser.init();
-    defer parser.deinit();
-
-    // start JS env
-    try js_env.start();
-    defer js_env.stop();
-
-    var storageShelf = storage.Shelf.init(alloc);
-    defer storageShelf.deinit();
-
-    // document
-    const file = try std.fs.cwd().openFile("test.html", .{});
-    defer file.close();
-
-    doc = try parser.documentHTMLParse(file.reader(), "UTF-8");
-    defer parser.documentHTMLClose(doc) catch |err| {
-        std.debug.print("documentHTMLClose error: {s}\n", .{@errorName(err)});
-    };
-
-    var http_client = try @import("http/client.zig").Client.init(alloc, 5, .{});
-    defer http_client.deinit();
-
-    // alias global as self and window
-    var window = Window.create(null, null);
-
-    const url = try URL.parse("https://lightpanda.io/opensource-browser/", null);
-
-    var cookie_jar = storage.CookieJar.init(alloc);
-    defer cookie_jar.deinit();
-
-    var renderer = browser.Renderer.init(alloc);
-    defer renderer.elements.deinit(alloc);
-    defer renderer.positions.deinit(alloc);
-
-    try js_env.setUserContext(.{
-        .url = &url,
-        .document = doc,
-        .renderer = &renderer,
-        .cookie_jar = &cookie_jar,
-        .http_client = &http_client,
-    });
-
-    try window.replaceLocation(.{ .url = try url.toWebApi(alloc) });
-
-    try window.replaceDocument(doc);
-    window.setStorageShelf(&storageShelf);
-
-    try js_env.bindGlobal(window);
-
-    // run test
-    try execFn(alloc, js_env);
-}
-
-fn testsAllExecFn(
-    alloc: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    const testFns = [_]jsruntime.ContextExecFn{
-        documentTestExecFn,
-        HTMLDocumentTestExecFn,
-        nodeTestExecFn,
-        characterDataTestExecFn,
-        textTestExecFn,
-        elementTestExecFn,
-        HTMLCollectionTestExecFn,
-        DOMExceptionTestExecFn,
-        DOMImplementationExecFn,
-        NamedNodeMapExecFn,
-        DOMTokenListExecFn,
-        NodeListTestExecFn,
-        AttrTestExecFn,
-        CommentTestExecFn,
-        DocumentFragmentTestExecFn,
-        EventTargetTestExecFn,
-        EventTestExecFn,
-        XHRTestExecFn,
-        ProgressEventTestExecFn,
-        ProcessingInstructionTestExecFn,
-        StorageTestExecFn,
-        URLTestExecFn,
-        HTMLElementTestExecFn,
-        MutationObserverTestExecFn,
-        @import("polyfill/fetch.zig").testExecFn,
-        @import("html/navigator.zig").testExecFn,
-        @import("html/history.zig").testExecFn,
-        @import("html/location.zig").testExecFn,
-        @import("xmlserializer/xmlserializer.zig").testExecFn,
-    };
-
-    inline for (testFns) |testFn| {
-        try testExecFn(alloc, js_env, testFn);
-    }
-}
-
-const usage =
-    \\usage: test [options]
-    \\  Run the tests. By default the command will run both js and unit tests.
-    \\
-    \\  -h, --help       Print this help message and exit.
-    \\  --browser        run only browser js tests
-    \\  --unit           run only js unit tests
-    \\  --json           bench result is formatted in JSON.
-    \\                   only browser tests are benchmarked.
-    \\
-;
-
-// Out list all the ouputs handled by benchmark result and written on stdout.
-const Out = enum {
-    text,
-    json,
-};
-
-// Which tests must be run.
-const Run = enum {
-    all,
-    browser,
-    unit,
-};
-
-pub fn main() !void {
-    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
-    defer _ = gpa.deinit();
-    const gpa_alloc = gpa.allocator();
-
-    var args = try std.process.argsWithAllocator(gpa_alloc);
-    defer args.deinit();
-
-    // ignore the exec name.
-    _ = args.next().?;
-
-    var out: Out = .text;
-    var run: Run = .all;
-
-    while (args.next()) |arg| {
-        if (std.mem.eql(u8, "-h", arg) or std.mem.eql(u8, "--help", arg)) {
-            try std.io.getStdErr().writer().print(usage, .{});
-            std.posix.exit(0);
-        }
-        if (std.mem.eql(u8, "--json", arg)) {
-            out = .json;
-            continue;
-        }
-        if (std.mem.eql(u8, "--browser", arg)) {
-            run = .browser;
-            continue;
-        }
-        if (std.mem.eql(u8, "--unit", arg)) {
-            run = .unit;
-            continue;
-        }
-    }
-
-    // run js tests
-    if (run == .all or run == .browser) try run_js(out);
-
-    // run standard unit tests.
-    if (run == .all or run == .unit) {
-        std.debug.print("\n", .{});
-        for (builtin.test_functions) |test_fn| {
-            if (std.mem.startsWith(u8, test_fn.name, "http.client.test")) {
-                // covered by unit test, needs a dummy server started, which
-                // main_test doesn't do.
-                continue;
-            }
-
-            try parser.init();
-            defer parser.deinit();
-
-            std.testing.allocator_instance = .{};
-            try test_fn.func();
-
-            if (std.testing.allocator_instance.deinit() == .leak) {
-                std.debug.print("======Memory Leak: {s}======\n", .{test_fn.name});
-            } else {
-                std.debug.print("{s}\tOK\n", .{test_fn.name});
-            }
-        }
-    }
-}
-
-// Run js test and display the output depending of the output parameter.
-fn run_js(out: Out) !void {
-    var bench_alloc = jsruntime.bench_allocator(std.testing.allocator);
-
-    const start = try std.time.Instant.now();
-
-    // run js exectuion tests
-    try testJSRuntime(bench_alloc.allocator());
-
-    const duration = std.time.Instant.since(try std.time.Instant.now(), start);
-    const stats = bench_alloc.stats();
-
-    // get and display the results
-    if (out == .json) {
-        const res = [_]struct {
-            name: []const u8,
-            bench: struct {
-                duration: u64,
-
-                alloc_nb: usize,
-                realloc_nb: usize,
-                alloc_size: usize,
-            },
-        }{
-            .{ .name = "browser", .bench = .{
-                .duration = duration,
-                .alloc_nb = stats.alloc_nb,
-                .realloc_nb = stats.realloc_nb,
-                .alloc_size = stats.alloc_size,
-            } },
-            // TODO get libdom bench info.
-            .{ .name = "libdom", .bench = .{
-                .duration = duration,
-                .alloc_nb = 0,
-                .realloc_nb = 0,
-                .alloc_size = 0,
-            } },
-            // TODO get v8 bench info.
-            .{ .name = "v8", .bench = .{
-                .duration = duration,
-                .alloc_nb = 0,
-                .realloc_nb = 0,
-                .alloc_size = 0,
-            } },
-            // TODO get main bench info.
-            .{ .name = "main", .bench = .{
-                .duration = duration,
-                .alloc_nb = 0,
-                .realloc_nb = 0,
-                .alloc_size = 0,
-            } },
-        };
-
-        try std.json.stringify(res, .{ .whitespace = .indent_2 }, std.io.getStdOut().writer());
-        return;
-    }
-
-    // display console result by default
-    const dur = pretty.Measure{ .unit = "ms", .value = duration / ms };
-    const size = pretty.Measure{ .unit = "kb", .value = stats.alloc_size / kb };
-
-    const zerosize = pretty.Measure{ .unit = "kb", .value = 0 };
-
-    // benchmark table
-    const row_shape = .{ []const u8, pretty.Measure, u64, u64, pretty.Measure };
-    const table = try pretty.GenerateTable(4, row_shape, pretty.TableConf{ .margin_left = "  " });
-    const header = .{ "FUNCTION", "DURATION", "ALLOCATIONS (nb)", "RE-ALLOCATIONS (nb)", "HEAP SIZE" };
-    var t = table.init("Benchmark lightpanda 🚀", header);
-    try t.addRow(.{ "browser", dur, stats.alloc_nb, stats.realloc_nb, size });
-    try t.addRow(.{ "libdom", dur, 0, 0, zerosize }); // TODO get libdom bench info.
-    try t.addRow(.{ "v8", dur, 0, 0, zerosize }); // TODO get v8 bench info.
-    try t.addRow(.{ "main", dur, 0, 0, zerosize }); // TODO get main bench info.
-    try t.render(std.io.getStdOut().writer());
-}
-
-const kb = 1024;
-const ms = std.time.ns_per_ms;
-
-test {
-    const dumpTest = @import("browser/dump.zig");
-    std.testing.refAllDecls(dumpTest);
-
-    const mimeTest = @import("browser/mime.zig");
-    std.testing.refAllDecls(mimeTest);
-
-    const cssTest = @import("css/css.zig");
-    std.testing.refAllDecls(cssTest);
-
-    const cssParserTest = @import("css/parser.zig");
-    std.testing.refAllDecls(cssParserTest);
-
-    const cssMatchTest = @import("css/match_test.zig");
-    std.testing.refAllDecls(cssMatchTest);
-
-    const cssLibdomTest = @import("css/libdom_test.zig");
-    std.testing.refAllDecls(cssLibdomTest);
-
-    const queryTest = @import("url/query.zig");
-    std.testing.refAllDecls(queryTest);
-
-    std.testing.refAllDecls(@import("generate.zig"));
-}
-
-fn testJSRuntime(alloc: std.mem.Allocator) !void {
-    // create JS vm
-    const vm = jsruntime.VM.init();
-    defer vm.deinit();
-
-    var arena_alloc = std.heap.ArenaAllocator.init(alloc);
-    defer arena_alloc.deinit();
-
-    try jsruntime.loadEnv(&arena_alloc, null, testsAllExecFn);
-}
-
-test "DocumentHTMLParseFromStr" {
-    const file = try std.fs.cwd().openFile("test.html", .{});
-    defer file.close();
-
-    const str = try file.readToEndAlloc(std.testing.allocator, std.math.maxInt(u32));
-    defer std.testing.allocator.free(str);
-
-    doc = try parser.documentHTMLParseFromStr(str);
-    parser.documentHTMLClose(doc) catch {};
-}
-
-// https://github.com/lightpanda-io/libdom/issues/4
-test "bug document html parsing #4" {
-    const file = try std.fs.cwd().openFile("tests/html/bug-html-parsing-4.html", .{});
-    defer file.close();
-
-    doc = try parser.documentHTMLParse(file.reader(), "UTF-8");
-    parser.documentHTMLClose(doc) catch {};
-}
-
-test "Window is a libdom event target" {
-    var window = Window.create(null, null);
-
-    const event = try parser.eventCreate();
-    try parser.eventInit(event, "foo", .{});
-
-    const et = parser.toEventTarget(Window, &window);
-    _ = try parser.eventTargetDispatchEvent(et, event);
-}
-
-test "DocumentHTML is a libdom event target" {
-    doc = try parser.documentHTMLParseFromStr("");
-    parser.documentHTMLClose(doc) catch {};
-
-    const event = try parser.eventCreate();
-    try parser.eventInit(event, "foo", .{});
-
-    const et = parser.toEventTarget(parser.DocumentHTML, doc);
-    _ = try parser.eventTargetDispatchEvent(et, event);
-}
-
-test "XMLHttpRequest.validMethod" {
-    // valid methods
-    for ([_][]const u8{ "get", "GET", "head", "HEAD" }) |tc| {
-        _ = try xhr.XMLHttpRequest.validMethod(tc);
-    }
-
-    // forbidden
-    for ([_][]const u8{ "connect", "CONNECT" }) |tc| {
-        try std.testing.expectError(parser.DOMError.Security, xhr.XMLHttpRequest.validMethod(tc));
-    }
-
-    // syntax
-    for ([_][]const u8{ "foo", "BAR" }) |tc| {
-        try std.testing.expectError(parser.DOMError.Syntax, xhr.XMLHttpRequest.validMethod(tc));
-    }
-}
diff --git a/src/main_unit_tests.zig b/src/main_unit_tests.zig
deleted file mode 100644
index 23c2f9c6..00000000
--- a/src/main_unit_tests.zig
+++ /dev/null
@@ -1,224 +0,0 @@
-// Copyright (C) 2023-2024  Lightpanda (Selecy SAS)
-//
-// Francis Bouvier 
-// Pierre Tachoire 
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as
-// published by the Free Software Foundation, either version 3 of the
-// License, or (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program.  If not, see .
-
-const std = @import("std");
-const tls = @import("tls");
-const parser = @import("netsurf");
-const builtin = @import("builtin");
-
-const Allocator = std.mem.Allocator;
-
-test {
-    std.testing.refAllDecls(@import("url/query.zig"));
-    std.testing.refAllDecls(@import("browser/dump.zig"));
-    std.testing.refAllDecls(@import("browser/mime.zig"));
-    std.testing.refAllDecls(@import("css/css.zig"));
-    std.testing.refAllDecls(@import("css/libdom_test.zig"));
-    std.testing.refAllDecls(@import("css/match_test.zig"));
-    std.testing.refAllDecls(@import("css/parser.zig"));
-    std.testing.refAllDecls(@import("generate.zig"));
-    std.testing.refAllDecls(@import("storage/storage.zig"));
-    std.testing.refAllDecls(@import("storage/cookie.zig"));
-    std.testing.refAllDecls(@import("iterator/iterator.zig"));
-    std.testing.refAllDecls(@import("server.zig"));
-    std.testing.refAllDecls(@import("cdp/cdp.zig"));
-    std.testing.refAllDecls(@import("log.zig"));
-    std.testing.refAllDecls(@import("datetime.zig"));
-    std.testing.refAllDecls(@import("telemetry/telemetry.zig"));
-    std.testing.refAllDecls(@import("http/client.zig"));
-}
-
-var wg: std.Thread.WaitGroup = .{};
-var gpa = std.heap.GeneralPurposeAllocator(.{}){};
-test "tests:beforeAll" {
-    try parser.init();
-    wg.startMany(3);
-
-    {
-        const address = try std.net.Address.parseIp("127.0.0.1", 9582);
-        const thread = try std.Thread.spawn(.{}, serveHTTP, .{address});
-        thread.detach();
-    }
-
-    {
-        const address = try std.net.Address.parseIp("127.0.0.1", 9581);
-        const thread = try std.Thread.spawn(.{}, serveHTTPS, .{address});
-        thread.detach();
-    }
-
-    {
-        const address = try std.net.Address.parseIp("127.0.0.1", 9583);
-        const thread = try std.Thread.spawn(.{}, serveCDP, .{address});
-        thread.detach();
-    }
-
-    // need to wait for the servers to be listening, else tests will fail because
-    // they aren't able to connect.
-    wg.wait();
-}
-
-test "tests:afterAll" {
-    parser.deinit();
-}
-
-fn serveHTTP(address: std.net.Address) !void {
-    const allocator = gpa.allocator();
-    var arena = std.heap.ArenaAllocator.init(allocator);
-    defer arena.deinit();
-
-    var listener = try address.listen(.{ .reuse_address = true });
-    defer listener.deinit();
-
-    wg.finish();
-
-    var read_buffer: [1024]u8 = undefined;
-    ACCEPT: while (true) {
-        defer _ = arena.reset(.{ .retain_with_limit = 1024 });
-        const aa = arena.allocator();
-
-        var conn = try listener.accept();
-        defer conn.stream.close();
-        var server = std.http.Server.init(conn, &read_buffer);
-
-        while (server.state == .ready) {
-            var request = server.receiveHead() catch |err| switch (err) {
-                error.HttpConnectionClosing => continue :ACCEPT,
-                else => {
-                    std.debug.print("Test HTTP Server error: {}\n", .{err});
-                    return err;
-                },
-            };
-
-            const path = request.head.target;
-            if (std.mem.eql(u8, path, "/loader")) {
-                try request.respond("Hello!", .{});
-            } else if (std.mem.eql(u8, path, "/http_client/simple")) {
-                try request.respond("", .{});
-            } else if (std.mem.eql(u8, path, "/http_client/redirect")) {
-                try request.respond("", .{
-                    .status = .moved_permanently,
-                    .extra_headers = &.{.{ .name = "LOCATION", .value = "../http_client/echo" }},
-                });
-            } else if (std.mem.eql(u8, path, "/http_client/redirect/secure")) {
-                try request.respond("", .{
-                    .status = .moved_permanently,
-                    .extra_headers = &.{.{ .name = "LOCATION", .value = "https://127.0.0.1:9581/http_client/body" }},
-                });
-            } else if (std.mem.eql(u8, path, "/http_client/echo")) {
-                var headers: std.ArrayListUnmanaged(std.http.Header) = .{};
-
-                var it = request.iterateHeaders();
-                while (it.next()) |hdr| {
-                    try headers.append(aa, .{
-                        .name = try std.fmt.allocPrint(aa, "_{s}", .{hdr.name}),
-                        .value = hdr.value,
-                    });
-                }
-
-                try request.respond("over 9000!", .{
-                    .status = .created,
-                    .extra_headers = headers.items,
-                });
-            }
-        }
-    }
-}
-
-// This is a lot of work for testing TLS, but the TLS (async) code is complicated
-// This "server" is written specifically to test the client. It assumes the client
-// isn't a jerk.
-fn serveHTTPS(address: std.net.Address) !void {
-    const allocator = gpa.allocator();
-
-    var listener = try address.listen(.{ .reuse_address = true });
-    defer listener.deinit();
-
-    var arena = std.heap.ArenaAllocator.init(allocator);
-    defer arena.deinit();
-
-    wg.finish();
-
-    var seed: u64 = undefined;
-    std.posix.getrandom(std.mem.asBytes(&seed)) catch unreachable;
-    var r = std.Random.DefaultPrng.init(seed);
-    const rand = r.random();
-
-    var read_buffer: [1024]u8 = undefined;
-    while (true) {
-        // defer _ = arena.reset(.{ .retain_with_limit = 1024 });
-        // const aa = arena.allocator();
-
-        const stream = blk: {
-            const conn = try listener.accept();
-            break :blk conn.stream;
-        };
-        defer stream.close();
-
-        var conn = try tls.server(stream, .{ .auth = null });
-        defer conn.close() catch {};
-
-        var pos: usize = 0;
-        while (true) {
-            const n = try conn.read(read_buffer[pos..]);
-            if (n == 0) {
-                break;
-            }
-            pos += n;
-            const header_end = std.mem.indexOf(u8, read_buffer[0..pos], "\r\n\r\n") orelse {
-                continue;
-            };
-            var it = std.mem.splitScalar(u8, read_buffer[0..header_end], ' ');
-            _ = it.next() orelse unreachable; // method
-            const path = it.next() orelse unreachable;
-
-            var response: []const u8 = undefined;
-            if (std.mem.eql(u8, path, "/http_client/simple")) {
-                response = "HTTP/1.1 200 \r\nContent-Length: 0\r\n\r\n";
-            } else if (std.mem.eql(u8, path, "/http_client/body")) {
-                response = "HTTP/1.1 201 CREATED\r\nContent-Length: 20\r\n   Another :  HEaDer  \r\n\r\n1234567890abcdefhijk";
-            } else if (std.mem.eql(u8, path, "/http_client/redirect/insecure")) {
-                response = "HTTP/1.1 307 GOTO\r\nLocation: http://127.0.0.1:9582/http_client/redirect\r\n\r\n";
-            } else {
-                // should not have an unknown path
-                unreachable;
-            }
-
-            var unsent = response;
-            while (unsent.len > 0) {
-                const to_send = rand.intRangeAtMost(usize, 1, unsent.len);
-                const sent = try conn.write(unsent[0..to_send]);
-                unsent = unsent[sent..];
-                std.time.sleep(std.time.ns_per_us * 5);
-            }
-            break;
-        }
-    }
-}
-
-fn serveCDP(address: std.net.Address) !void {
-    const App = @import("app.zig").App;
-    var app = try App.init(gpa.allocator(), .{ .run_mode = .serve });
-    defer app.deinit();
-
-    const server = @import("server.zig");
-    wg.finish();
-    server.run(app, address, std.time.ns_per_s * 2) catch |err| {
-        std.debug.print("CDP server error: {}", .{err});
-        return err;
-    };
-}
diff --git a/src/main_wpt.zig b/src/main_wpt.zig
index 7cf2f077..5237c197 100644
--- a/src/main_wpt.zig
+++ b/src/main_wpt.zig
@@ -18,14 +18,10 @@
 
 const std = @import("std");
 
-const jsruntime = @import("jsruntime");
-
-const Suite = @import("wpt/testcase.zig").Suite;
-const FileLoader = @import("wpt/fileloader.zig").FileLoader;
 const wpt = @import("wpt/run.zig");
-
-const apiweb = @import("apiweb.zig");
-const HTMLElem = @import("html/elements.zig");
+const Suite = @import("wpt/testcase.zig").Suite;
+const Platform = @import("runtime/js.zig").Platform;
+const FileLoader = @import("wpt/fileloader.zig").FileLoader;
 
 const wpt_dir = "tests/wpt";
 
@@ -47,10 +43,10 @@ const Out = enum {
     text,
 };
 
-pub const Types = jsruntime.reflect(apiweb.Interfaces);
-pub const GlobalType = apiweb.GlobalType;
-pub const UserContext = apiweb.UserContext;
-pub const IO = @import("asyncio").Wrapper(jsruntime.Loop);
+pub const std_options = std.Options{
+    // Set the log level to info
+    .log_level = .info,
+};
 
 // TODO For now the WPT tests run is specific to WPT.
 // It manually load js framwork libs, and run the first script w/ js content in
@@ -122,8 +118,8 @@ pub fn main() !void {
     }
 
     // initialize VM JS lib.
-    const vm = jsruntime.VM.init();
-    defer vm.deinit();
+    const platform = Platform.init();
+    defer platform.deinit();
 
     // prepare libraries to load on each test case.
     var loader = FileLoader.init(alloc, wpt_dir);
@@ -142,7 +138,7 @@ pub fn main() !void {
         var arena = std.heap.ArenaAllocator.init(alloc);
         defer arena.deinit();
 
-        const res = wpt.run(&arena, wpt_dir, tc, &loader) catch |err| {
+        const res = wpt.run(arena.allocator(), wpt_dir, tc, &loader) catch |err| {
             const suite = try Suite.init(alloc, tc, false, @errorName(err));
             try results.append(suite);
 
@@ -152,9 +148,8 @@ pub fn main() !void {
             failures += 1;
             continue;
         };
-        defer res.deinit(arena.allocator());
 
-        const suite = try Suite.init(alloc, tc, res.ok, res.msg orelse "");
+        const suite = try Suite.init(alloc, tc, true, res);
         try results.append(suite);
 
         if (out == .json) {
diff --git a/src/polyfill/fetch.zig b/src/polyfill/fetch.zig
deleted file mode 100644
index 5cf8b99d..00000000
--- a/src/polyfill/fetch.zig
+++ /dev/null
@@ -1,55 +0,0 @@
-const std = @import("std");
-const jsruntime = @import("jsruntime");
-const Case = jsruntime.test_utils.Case;
-const checkCases = jsruntime.test_utils.checkCases;
-
-// fetch.js code comes from
-// https://github.com/JakeChampion/fetch/blob/main/fetch.js
-//
-// The original code source is available in MIT license.
-//
-// The script comes from the built version from npm.
-// You can get the package with the command:
-//
-// wget $(npm view whatwg-fetch dist.tarball)
-//
-// The source is the content of `package/dist/fetch.umd.js` file.
-pub const source = @embedFile("fetch.js");
-
-pub fn testExecFn(
-    alloc: std.mem.Allocator,
-    js_env: *jsruntime.Env,
-) anyerror!void {
-    try @import("polyfill.zig").load(alloc, js_env);
-
-    var fetch = [_]Case{
-        .{
-            .src =
-            \\var ok = false;
-            \\const request = new Request("https://httpbin.io/json");
-            \\fetch(request)
-            \\ .then((response) => { ok = response.ok; });
-            \\false;
-            ,
-            .ex = "false",
-        },
-        // all events have been resolved.
-        .{ .src = "ok", .ex = "true" },
-    };
-    try checkCases(js_env, &fetch);
-
-    var fetch2 = [_]Case{
-        .{
-            .src =
-            \\var ok2 = false;
-            \\const request2 = new Request("https://httpbin.io/json");
-            \\(async function () { resp = await fetch(request2); ok2 = resp.ok; }());
-            \\false;
-            ,
-            .ex = "false",
-        },
-        // all events have been resolved.
-        .{ .src = "ok2", .ex = "true" },
-    };
-    try checkCases(js_env, &fetch2);
-}
diff --git a/src/generate.zig b/src/runtime/generate.zig
similarity index 96%
rename from src/generate.zig
rename to src/runtime/generate.zig
index 9b5c164e..1e1c5193 100644
--- a/src/generate.zig
+++ b/src/runtime/generate.zig
@@ -25,7 +25,7 @@ const Type = std.builtin.Type;
 // -----
 
 // Generate a flatten tagged Union from a Tuple
-pub fn Union(interfaces: anytype) type {
+pub fn Union(comptime interfaces: anytype) type {
     // @setEvalBranchQuota(10000);
     const tuple = Tuple(interfaces){};
     const fields = std.meta.fields(@TypeOf(tuple));
@@ -93,7 +93,7 @@ pub fn Union(interfaces: anytype) type {
 // Flattens and depuplicates a list of nested tuples. For example
 // input: {A, B, {C, B, D}, {A, E}}
 // output {A, B, C, D, E}
-pub fn Tuple(args: anytype) type {
+pub fn Tuple(comptime args: anytype) type {
     @setEvalBranchQuota(100000);
 
     const count = countInterfaces(args, 0);
@@ -188,7 +188,7 @@ test "generate.Union" {
     const value = Union(.{ Astruct, Bstruct, .{Cstruct} });
     const ti = @typeInfo(value).@"union";
     try std.testing.expectEqual(3, ti.fields.len);
-    try std.testing.expectEqualStrings("*generate.test.generate.Union.Astruct.Other", @typeName(ti.fields[0].type));
+    try std.testing.expectEqualStrings("*runtime.generate.test.generate.Union.Astruct.Other", @typeName(ti.fields[0].type));
     try std.testing.expectEqualStrings(ti.fields[0].name, "Astruct");
     try std.testing.expectEqual(Bstruct, ti.fields[1].type);
     try std.testing.expectEqualStrings(ti.fields[1].name, "Bstruct");
diff --git a/src/runtime/js.zig b/src/runtime/js.zig
new file mode 100644
index 00000000..ca48019b
--- /dev/null
+++ b/src/runtime/js.zig
@@ -0,0 +1,2200 @@
+// Copyright 2023-2024 Lightpanda (Selecy SAS)
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+const std = @import("std");
+const builtin = @import("builtin");
+const v8 = @import("v8");
+
+const Allocator = std.mem.Allocator;
+const ArenaAllocator = std.heap.ArenaAllocator;
+
+const log = std.log.scoped(.js);
+
+pub const Platform = struct {
+    inner: v8.Platform,
+
+    pub fn init() Platform {
+        const platform = v8.Platform.initDefault(0, true);
+        v8.initV8Platform(platform);
+        v8.initV8();
+        return .{ .inner = platform };
+    }
+
+    pub fn deinit(self: Platform) void {
+        _ = v8.deinitV8();
+        v8.deinitV8Platform();
+        self.inner.deinit();
+    }
+};
+
+pub fn Env(comptime S: type, comptime types: anytype) type {
+    const Types = @typeInfo(@TypeOf(types)).@"struct".fields;
+
+    // Imagine we have a type Cat which has a getter:
+    //
+    //    fn get_owner(self: *Cat) *Owner {
+    //        return self.owner;
+    //    }
+    //
+    // When we're execute caller.getter, we'll end up doing something like:
+    //   const res = @call(.auto, Cat.get_owner, .{cat_instance});
+    //
+    // How do we turn `res`, which is an *Owner, into something we can return
+    // to v8? We need the ObjectTemplate associated with Owner. How do we
+    // get that? Well, we store all the ObjectTemplates in an array that's
+    // tied to env. So we do something like:
+    //
+    //    env.templates[index_id_of_owner].initInstance(...);
+    //
+    // But how do we get that `index_id_of_owner` ??
+    // This is where `type_lookup` comes from. We create a struct that looks like:
+    //
+    // const TypeLookup = struct {
+    //     comptime cat: usize = 0,
+    //     comptime owner: usize = 1,
+    //     ...
+    // }
+    //
+    // With this type, which is passed into callProperty, we can do:
+    //
+    //  const index_id = @field(type_lookup, @typeName(@TypeOf(res));
+    //
+    const TypeLookup = comptime blk: {
+        var fields: [Types.len]std.builtin.Type.StructField = undefined;
+        for (Types, 0..) |s, i| {
+
+            // This prototype type check has nothing to do with building our
+            // TypeLookup. But we put it here, early, so that the rest of the
+            // code doesn't have to worry about checking if Struct.Prototype is
+            // a pointer.
+            const Struct = @field(types, s.name);
+            if (@hasDecl(Struct, "prototype") and @typeInfo(Struct.prototype) != .pointer) {
+                @compileError(std.fmt.comptimePrint("Prototype '{s}' for type '{s} must be a pointer", .{ @typeName(Struct.prototype), @typeName(Struct) }));
+            }
+            const R = Receiver(@field(types, s.name));
+            fields[i] = .{
+                .name = @typeName(R),
+                .type = usize,
+                .is_comptime = true,
+                .alignment = @alignOf(usize),
+                .default_value_ptr = @ptrCast(&i),
+            };
+        }
+        break :blk @Type(.{ .@"struct" = .{
+            .layout = .auto,
+            .decls = &.{},
+            .is_tuple = false,
+            .fields = &fields,
+        } });
+    };
+
+    // Creates a list where the index of a type contains its prototype index
+    //   const Animal = struct{};
+    //   const Cat = struct{
+    //       pub const prototype = *Animal;
+    // };
+    //
+    // Would create an array: [0, 0]
+    // Animal, at index, 0, has no prototype, so we set it to itself
+    // Cat, at index 1, has an Animal prototype, so we set it to 0.
+    //
+    // When we're trying to pass an argument to a Zig function, we'll know the
+    // target type (the function parameter type), and we'll have a
+    // TaggedAnyOpaque which will have the index of the type of that parameter.
+    // We'll use the PROTOTYPE_TABLE to see if the TaggedAnyType should be
+    // cast to a prototype.
+    const PROTOTYPE_TABLE = comptime blk: {
+        var table: [Types.len]u16 = undefined;
+        const TYPE_LOOKUP = TypeLookup{};
+        for (Types, 0..) |s, i| {
+            var prototype_index = i;
+            const Struct = @field(types, s.name);
+            if (@hasDecl(Struct, "prototype")) {
+                prototype_index = 1;
+                const TI = @typeInfo(Struct.prototype);
+                const proto_name = @typeName(Receiver(TI.pointer.child));
+                prototype_index = @field(TYPE_LOOKUP, proto_name);
+            }
+            table[i] = prototype_index;
+        }
+        break :blk table;
+    };
+
+    return struct {
+        allocator: Allocator,
+
+        // the global isolate
+        isolate: v8.Isolate,
+
+        // When we create JS objects/methods/properties we can associate
+        // abitrary data. It'll be this value.
+        callback_data: v8.BigInt,
+
+        // this is the global scope that all our classes are defined in
+        global_scope: v8.HandleScope,
+
+        // just kept around because we need to free it on deinit
+        isolate_params: v8.CreateParams,
+
+        // Given a type, we can lookup its index in TYPE_LOOKUP and then have
+        // access to its TunctionTemplate (the thing we need to create an instance
+        // of it)
+        // I.e.:
+        // const index = @field(TYPE_LOOKUP, @typeName(type_name))
+        // const template = templates[index];
+        templates: [Types.len]v8.FunctionTemplate,
+
+        // Given a type index (retrieved via the TYPE_LOOKUP), we can retrieve
+        // the index of its prototype. Types without a prototype have their own
+        // index.
+        prototype_lookup: [Types.len]u16,
+
+        // Sessions are cheap, we mostly do this so we can get a stable pointer
+        executor_pool: std.heap.MemoryPool(Executor),
+
+        // Send a LowMemory
+        gc_hints: bool,
+
+        const Self = @This();
+
+        const State = S;
+        const TYPE_LOOKUP = TypeLookup{};
+
+        const Opts = struct {
+            gc_hints: bool = false,
+        };
+
+        pub fn init(allocator: Allocator, opts: Opts) !*Self {
+            var params = v8.initCreateParams();
+            params.array_buffer_allocator = v8.createDefaultArrayBufferAllocator();
+            errdefer v8.destroyArrayBufferAllocator(params.array_buffer_allocator.?);
+
+            var isolate = v8.Isolate.init(¶ms);
+            errdefer isolate.deinit();
+
+            isolate.enter();
+            errdefer isolate.exit();
+
+            var global_scope: v8.HandleScope = undefined;
+            v8.HandleScope.init(&global_scope, isolate);
+            errdefer global_scope.deinit();
+
+            const env = try allocator.create(Self);
+            errdefer allocator.destroy(env);
+
+            env.* = .{
+                .isolate = isolate,
+                .templates = undefined,
+                .allocator = allocator,
+                .isolate_params = params,
+                .gc_hints = opts.gc_hints,
+                .global_scope = global_scope,
+                .prototype_lookup = undefined,
+                .executor_pool = std.heap.MemoryPool(Executor).init(allocator),
+                .callback_data = isolate.initBigIntU64(@intCast(@intFromPtr(env))),
+            };
+
+            // Populate our templates lookup. generateClass creates the
+            // v8.FunctionTemplate, which we store in our env.templates.
+            // The ordering doesn't matter. What matters is that, given a type
+            // we can get its index via: @field(TYPE_LOOKUP, type_name)
+            const templates = &env.templates;
+            inline for (Types, 0..) |s, i| {
+                templates[i] = env.generateClass(@field(types, s.name));
+            }
+
+            // Above, we've created all our our FunctionTemplates. Now that we
+            // have them all, we can hookup the prototype.
+            inline for (Types, 0..) |s, i| {
+                const Struct = @field(types, s.name);
+                if (@hasDecl(Struct, "prototype")) {
+                    const TI = @typeInfo(Struct.prototype);
+                    const proto_name = @typeName(Receiver(TI.pointer.child));
+                    if (@hasField(TypeLookup, proto_name) == false) {
+                        @compileError(std.fmt.comptimePrint("Prototype '{s}' for '{s}' is undefined", .{ proto_name, @typeName(Struct) }));
+                    }
+                    // Hey, look! This is our first real usage of the TYPE_LOOKUP.
+                    // Just like we said above, given a type, we can get its
+                    // template index.
+                    const proto_index = @field(TYPE_LOOKUP, proto_name);
+                    templates[i].inherit(templates[proto_index]);
+                }
+            }
+
+            return env;
+        }
+
+        pub fn deinit(self: *Self) void {
+            self.global_scope.deinit();
+            self.isolate.exit();
+            self.isolate.deinit();
+            self.executor_pool.deinit();
+            v8.destroyArrayBufferAllocator(self.isolate_params.array_buffer_allocator.?);
+            self.allocator.destroy(self);
+        }
+
+        pub fn runMicrotasks(self: *const Self) void {
+            self.isolate.performMicrotasksCheckpoint();
+        }
+
+        pub fn startExecutor(self: *Self, comptime Global: type, state: State, module_loader: anytype) !*Executor {
+            const isolate = self.isolate;
+            const templates = &self.templates;
+
+            var handle_scope: v8.HandleScope = undefined;
+            v8.HandleScope.init(&handle_scope, isolate);
+
+            const globals = v8.FunctionTemplate.initDefault(isolate);
+
+            const global_template = globals.getInstanceTemplate();
+            global_template.setInternalFieldCount(1);
+            self.attachClass(Global, globals);
+
+            inline for (Types, 0..) |s, i| {
+                const Struct = @field(types, s.name);
+                const class_name = v8.String.initUtf8(isolate, comptime classNameForStruct(Struct));
+                global_template.set(class_name.toName(), templates[i], v8.PropertyAttribute.None);
+            }
+
+            // The global is its own Object and has to have its prototype chain setup.
+            if (@hasDecl(Global, "prototype")) {
+                const proto_type = Receiver(@typeInfo(Global.prototype).pointer.child);
+                const proto_name = @typeName(proto_type);
+                const proto_index = @field(TYPE_LOOKUP, proto_name);
+                globals.inherit(templates[proto_index]);
+            }
+
+            const context = v8.Context.init(isolate, global_template, null);
+            context.enter();
+            errdefer context.exit();
+
+            // This shouldn't be necessary, but it is:
+            // https://groups.google.com/g/v8-users/c/qAQQBmbi--8
+            // TODO: see if newer V8 engines have a way around this.
+            inline for (Types, 0..) |s, i| {
+                const Struct = @field(types, s.name);
+
+                if (@hasDecl(Struct, "prototype")) {
+                    const proto_type = Receiver(@typeInfo(Struct.prototype).pointer.child);
+                    const proto_name = @typeName(proto_type);
+                    if (@hasField(TypeLookup, proto_name) == false) {
+                        @compileError("Type '" ++ @typeName(Struct) ++ "' defines an unknown prototype: " ++ proto_name);
+                    }
+
+                    const proto_index = @field(TYPE_LOOKUP, proto_name);
+                    const proto_obj = templates[proto_index].getFunction(context).toObject();
+
+                    const self_obj = templates[i].getFunction(context).toObject();
+                    _ = self_obj.setPrototype(context, proto_obj);
+                }
+            }
+
+            const executor = try self.executor_pool.create();
+            errdefer self.executor_pool.destroy(executor);
+
+            {
+                // Given a context, we can get our executor.
+                // (we store a pointer to our executor in the context's
+                // embeddeder data)
+                const data = isolate.initBigIntU64(@intCast(@intFromPtr(executor)));
+                context.setEmbedderData(1, data);
+            }
+
+            const allocator = self.allocator;
+
+            executor.* = .{
+                .state = state,
+                .context = context,
+                .isolate = isolate,
+                .templates = templates,
+                .handle_scope = handle_scope,
+                .call_arena = ArenaAllocator.init(allocator),
+                .scope_arena = ArenaAllocator.init(allocator),
+                .module_loader = .{
+                    .ptr = @ptrCast(module_loader),
+                    .func = @TypeOf(module_loader.*).fetchModuleSource,
+                },
+            };
+
+            errdefer self.stopExecutor(executor);
+
+            // Custom exception
+            // NOTE: there is no way in v8 to subclass the Error built-in type
+            // TODO: this is an horrible hack
+            inline for (Types) |s| {
+                const Struct = @field(types, s.name);
+                if (@hasDecl(Struct, "ErrorSet")) {
+                    const script = comptime classNameForStruct(Struct) ++ ".prototype.__proto__ = Error.prototype";
+                    _ = try executor.exec(script, "errorSubclass");
+                }
+            }
+
+            return executor;
+        }
+
+        pub fn stopExecutor(self: *Self, executor: *Executor) void {
+            executor.deinit();
+            self.executor_pool.destroy(executor);
+            if (self.gc_hints) {
+                self.isolate.lowMemoryNotification();
+            }
+        }
+
+        fn generateClass(self: *Self, comptime Struct: type) v8.FunctionTemplate {
+            const template = self.generateConstructor(Struct);
+            self.attachClass(Struct, template);
+            return template;
+        }
+
+        // Normally this is called from generateClass. Where generateClass creates
+        // the constructor (hence, the FunctionTemplate), attachClass adds all
+        // of its functions, getters, setters, ...
+        // But it's extracted from generateClass because we also have 1 global
+        // object (i.e. the Window), which gets attached not only to the Window
+        // constructor/FunctionTemplate as normal, but also through the default
+        // FunctionTemplate of the isolate (in startExecutor)
+        fn attachClass(self: *Self, comptime Struct: type, template: v8.FunctionTemplate) void {
+            const template_proto = template.getPrototypeTemplate();
+
+            inline for (@typeInfo(Struct).@"struct".decls) |declaration| {
+                const name = declaration.name;
+                if (comptime name[0] == '_') {
+                    switch (@typeInfo(@TypeOf(@field(Struct, name)))) {
+                        .@"fn" => self.generateMethod(Struct, name, template_proto),
+                        else => self.generateAttribute(Struct, name, template, template_proto),
+                    }
+                } else if (comptime std.mem.startsWith(u8, name, "get_")) {
+                    self.generateProperty(Struct, name[4..], template_proto);
+                }
+            }
+
+            if (@hasDecl(Struct, "get_symbol_toStringTag") == false) {
+                // If this WAS defined, then we would have created it in generateProperty.
+                // But if it isn't, we create a default one
+                const key = v8.Symbol.getToStringTag(self.isolate).toName();
+                template_proto.setGetter(key, struct {
+                    fn stringTag(_: ?*const v8.C_Name, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) void {
+                        const info = v8.PropertyCallbackInfo.initFromV8(raw_info);
+                        const class_name = v8.String.initUtf8(info.getIsolate(), comptime classNameForStruct(Struct));
+                        info.getReturnValue().set(class_name);
+                    }
+                }.stringTag);
+            }
+
+            self.generateIndexer(Struct, template_proto);
+            self.generateNamedIndexer(Struct, template_proto);
+        }
+
+        fn generateConstructor(self: *Self, comptime Struct: type) v8.FunctionTemplate {
+            const template = v8.FunctionTemplate.initCallbackData(self.isolate, struct {
+                fn callback(raw_info: ?*const v8.C_FunctionCallbackInfo) callconv(.c) void {
+                    const info = v8.FunctionCallbackInfo.initFromV8(raw_info);
+                    var caller = Caller(Self).init(info);
+                    defer caller.deinit();
+
+                    if (@hasDecl(Struct, "constructor") == false) {
+                        // handle this early, so we can create a named_function without
+                        // hassling over whether the constructor actually exists
+                        const isolate = caller.isolate;
+                        const js_exception = isolate.throwException(createException(isolate, "illegal constructor"));
+                        info.getReturnValue().set(js_exception);
+                        return;
+                    }
+
+                    const named_function = NamedFunction(Struct, Struct.constructor, "constructor"){};
+                    caller.constructor(named_function, info) catch |err| {
+                        caller.handleError(named_function, err, info);
+                    };
+                }
+            }.callback, self.callback_data);
+
+            template.getInstanceTemplate().setInternalFieldCount(1);
+
+            const class_name = v8.String.initUtf8(self.isolate, comptime classNameForStruct(Struct));
+            template.setClassName(class_name);
+            return template;
+        }
+
+        fn generateMethod(self: *Self, comptime Struct: type, comptime name: []const u8, template_proto: v8.ObjectTemplate) void {
+            var js_name: v8.Name = undefined;
+            if (comptime std.mem.eql(u8, name, "_symbol_iterator")) {
+                js_name = v8.Symbol.getIterator(self.isolate).toName();
+            } else {
+                js_name = v8.String.initUtf8(self.isolate, name[1..]).toName();
+            }
+            const function_template = v8.FunctionTemplate.initCallbackData(self.isolate, struct {
+                fn callback(raw_info: ?*const v8.C_FunctionCallbackInfo) callconv(.c) void {
+                    const info = v8.FunctionCallbackInfo.initFromV8(raw_info);
+                    var caller = Caller(Self).init(info);
+                    defer caller.deinit();
+
+                    const named_function = NamedFunction(Struct, @field(Struct, name), name){};
+                    caller.method(named_function, info) catch |err| {
+                        caller.handleError(named_function, err, info);
+                    };
+                }
+            }.callback, self.callback_data);
+            template_proto.set(js_name, function_template, v8.PropertyAttribute.None);
+        }
+
+        fn generateAttribute(self: *Self, comptime Struct: type, comptime name: []const u8, template: v8.FunctionTemplate, template_proto: v8.ObjectTemplate) void {
+            const zig_value = @field(Struct, name);
+            const js_value = simpleZigValueToJs(self.isolate, zig_value, true);
+
+            const js_name = v8.String.initUtf8(self.isolate, name[1..]).toName();
+
+            // apply it both to the type itself
+            template.set(js_name, js_value, v8.PropertyAttribute.ReadOnly + v8.PropertyAttribute.DontDelete);
+
+            // andto instances of the type
+            template_proto.set(js_name, js_value, v8.PropertyAttribute.ReadOnly + v8.PropertyAttribute.DontDelete);
+        }
+
+        fn generateProperty(self: *Self, comptime Struct: type, comptime name: []const u8, template_proto: v8.ObjectTemplate) void {
+            const getter = @field(Struct, "get_" ++ name);
+            const param_count = @typeInfo(@TypeOf(getter)).@"fn".params.len;
+
+            var js_name: v8.Name = undefined;
+            if (comptime std.mem.eql(u8, name, "symbol_toStringTag")) {
+                if (param_count != 0) {
+                    @compileError(@typeName(Struct) ++ ".get_symbol_toStringTag() cannot take any parameters");
+                }
+                js_name = v8.Symbol.getToStringTag(self.isolate).toName();
+            } else {
+                js_name = v8.String.initUtf8(self.isolate, name).toName();
+            }
+
+            const getter_callback = struct {
+                fn callback(_: ?*const v8.C_Name, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) void {
+                    const info = v8.PropertyCallbackInfo.initFromV8(raw_info);
+                    var caller = Caller(Self).init(info);
+                    defer caller.deinit();
+
+                    const named_function = NamedFunction(Struct, getter, "get_" ++ name){};
+                    caller.getter(named_function, info) catch |err| {
+                        caller.handleError(named_function, err, info);
+                    };
+                }
+            }.callback;
+
+            const setter_name = "set_" ++ name;
+            if (@hasDecl(Struct, setter_name) == false) {
+                template_proto.setGetterData(js_name, getter_callback, self.callback_data);
+                return;
+            }
+
+            const setter = @field(Struct, setter_name);
+            const setter_callback = struct {
+                fn callback(_: ?*const v8.C_Name, raw_value: ?*const v8.C_Value, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) void {
+                    const info = v8.PropertyCallbackInfo.initFromV8(raw_info);
+                    var caller = Caller(Self).init(info);
+                    defer caller.deinit();
+
+                    const js_value = v8.Value{ .handle = raw_value.? };
+                    const named_function = NamedFunction(Struct, setter, "set_" ++ name){};
+                    caller.setter(named_function, js_value, info) catch |err| {
+                        caller.handleError(named_function, err, info);
+                    };
+                }
+            }.callback;
+            template_proto.setGetterAndSetterData(js_name, getter_callback, setter_callback, self.callback_data);
+        }
+
+        fn generateIndexer(self: *Self, comptime Struct: type, template_proto: v8.ObjectTemplate) void {
+            var has_one = false;
+            var configuration = v8.IndexedPropertyHandlerConfiguration{};
+
+            if (@hasDecl(Struct, "indexed_get")) {
+                has_one = true;
+                configuration.getter = struct {
+                    fn callback(idx: u32, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) void {
+                        const info = v8.PropertyCallbackInfo.initFromV8(raw_info);
+                        var caller = Caller(Self).init(info);
+                        defer caller.deinit();
+
+                        const named_function = NamedFunction(Struct, Struct.indexed_get, "indexed_get"){};
+                        caller.getIndex(named_function, idx, info) catch |err| {
+                            caller.handleError(named_function, err, info);
+                        };
+                    }
+                }.callback;
+            }
+
+            if (@hasDecl(Struct, "indexed_set")) {
+                has_one = true;
+                configuration.setter = struct {
+                    fn callback(idx: u32, raw_value: ?*const v8.C_Value, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) void {
+                        const info = v8.PropertyCallbackInfo.initFromV8(raw_info);
+                        var caller = Caller(Self).init(info);
+                        defer caller.deinit();
+
+                        const js_value = v8.Value{ .handle = raw_value.? };
+                        const named_function = NamedFunction(Struct, Struct.indexed_set, "indexed_set"){};
+                        caller.setIndex(named_function, idx, js_value, info) catch |err| {
+                            caller.handleError(named_function, err, info);
+                        };
+                    }
+                }.callback;
+            }
+
+            if (has_one) {
+                template_proto.setIndexedProperty(configuration, self.callback_data);
+            }
+        }
+
+        fn generateNamedIndexer(self: *Self, comptime Struct: type, template_proto: v8.ObjectTemplate) void {
+            var has_one = false;
+            var configuration = v8.NamedPropertyHandlerConfiguration{
+                // This is really cool. Without this, we'd intercept _all_ properties
+                // even those explictly set. So, node.length for example would get routed
+                // to our `named_get`, rather than a `get_length`. This might be
+                // useful if we run into a type that we can't model properly in Zig.
+                .flags = v8.PropertyHandlerFlags.OnlyInterceptStrings | v8.PropertyHandlerFlags.NonMasking,
+            };
+
+            if (@hasDecl(Struct, "named_get")) {
+                has_one = true;
+                configuration.getter = struct {
+                    fn callback(c_name: ?*const v8.C_Name, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) void {
+                        const info = v8.PropertyCallbackInfo.initFromV8(raw_info);
+                        var caller = Caller(Self).init(info);
+                        defer caller.deinit();
+
+                        const named_function = NamedFunction(Struct, Struct.named_get, "named_get"){};
+                        caller.getNamedIndex(named_function, .{ .handle = c_name.? }, info) catch |err| {
+                            caller.handleError(named_function, err, info);
+                        };
+                    }
+                }.callback;
+            }
+
+            if (@hasDecl(Struct, "named_set")) {
+                has_one = true;
+                configuration.setter = struct {
+                    fn callback(c_name: ?*const v8.C_Name, raw_value: ?*const v8.C_Value, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) void {
+                        const info = v8.PropertyCallbackInfo.initFromV8(raw_info);
+                        var caller = Caller(Self).init(info);
+                        defer caller.deinit();
+
+                        const js_value = v8.Value{ .handle = raw_value.? };
+                        const named_function = NamedFunction(Struct, Struct.named_set, "named_set"){};
+                        caller.setNamedIndex(named_function, .{ .handle = c_name.? }, js_value, info) catch |err| {
+                            caller.handleError(named_function, err, info);
+                        };
+                    }
+                }.callback;
+            }
+
+            if (has_one) {
+                template_proto.setNamedProperty(configuration, self.callback_data);
+            }
+        }
+
+        // Turns a Zig value into a JS one.
+        fn zigValueToJs(
+            templates: []v8.FunctionTemplate,
+            isolate: v8.Isolate,
+            context: v8.Context,
+            value: anytype,
+        ) anyerror!v8.Value {
+            // Check if it's a "simple" type. This is extractd so that it can be
+            // reused by other parts of the code. "simple" types only require an
+            // isolate to create
+            if (simpleZigValueToJs(isolate, value, false)) |js_value| {
+                return js_value;
+            }
+            const T = @TypeOf(value);
+            switch (@typeInfo(T)) {
+                .void, .bool, .int, .comptime_int, .float, .comptime_float, .array => {
+                    // Need to do this to keep the compiler happy
+                    // If this was the case, simpleZigValueToJs would
+                    // have handled it
+                    unreachable;
+                },
+                .pointer => |ptr| switch (ptr.size) {
+                    .one => {
+                        const type_name = @typeName(ptr.child);
+                        if (@hasField(TypeLookup, type_name)) {
+                            const template = templates[@field(TYPE_LOOKUP, type_name)];
+                            const js_obj = try Executor.mapZigInstanceToJs(context, template, value);
+                            return js_obj.toValue();
+                        }
+
+                        const one_info = @typeInfo(ptr.child);
+                        if (one_info == .array and one_info.array.child == u8) {
+                            // Need to do this to keep the compiler happy
+                            // If this was the case, simpleZigValueToJs would
+                            // have handled it
+                            unreachable;
+                        }
+                        @compileLog(T);
+                    },
+                    .slice => {
+                        if (ptr.child == u8) {
+                            // Need to do this to keep the compiler happy
+                            // If this was the case, simpleZigValueToJs would
+                            // have handled it
+                            unreachable;
+                        }
+                        var js_arr = v8.Array.init(isolate, @intCast(value.len));
+                        var js_obj = js_arr.castTo(v8.Object);
+
+                        for (value, 0..) |v, i| {
+                            const js_val = try zigValueToJs(templates, isolate, context, v);
+                            if (js_obj.setValueAtIndex(context, @intCast(i), js_val) == false) {
+                                return error.FailedToCreateArray;
+                            }
+                        }
+                        return js_obj.toValue();
+                    },
+                    else => {},
+                },
+                .@"struct" => |s| {
+                    const type_name = @typeName(T);
+                    if (@hasField(TypeLookup, type_name)) {
+                        const template = templates[@field(TYPE_LOOKUP, type_name)];
+                        const js_obj = try Executor.mapZigInstanceToJs(context, template, value);
+                        return js_obj.toValue();
+                    }
+
+                    if (T == Callback) {
+                        // we're returnig a callback
+                        return value.func.toValue();
+                    }
+
+                    // return the struct as a JS object
+                    const js_obj = v8.Object.init(isolate);
+                    inline for (s.fields) |f| {
+                        const js_val = try zigValueToJs(templates, isolate, context, @field(value, f.name));
+                        const key = v8.String.initUtf8(isolate, f.name);
+                        if (!js_obj.setValue(context, key, js_val)) {
+                            return error.CreateObjectFailure;
+                        }
+                    }
+                    return js_obj.toValue();
+                },
+                .@"union" => |un| {
+                    if (T == std.json.Value) {
+                        return zigJsonToJs(isolate, context, value);
+                    }
+                    if (un.tag_type) |UnionTagType| {
+                        inline for (un.fields) |field| {
+                            if (value == @field(UnionTagType, field.name)) {
+                                return zigValueToJs(templates, isolate, context, @field(value, field.name));
+                            }
+                        }
+                        unreachable;
+                    }
+                    @compileError("Cannot use untagged union: " ++ @typeName(T));
+                },
+                .optional => {
+                    if (value) |v| {
+                        return zigValueToJs(templates, isolate, context, v);
+                    }
+                    return v8.initNull(isolate).toValue();
+                },
+                .error_union => return zigValueToJs(templates, isolate, context, value catch |err| return err),
+                else => {},
+            }
+            @compileLog(@typeInfo(T));
+            @compileError("A function returns an unsupported type: " ++ @typeName(T));
+        }
+
+        const PersistentObject = v8.Persistent(v8.Object);
+        const PersistentFunction = v8.Persistent(v8.Function);
+
+        pub const Executor = struct {
+            state: State,
+            isolate: v8.Isolate,
+
+            handle_scope: v8.HandleScope,
+
+            // @intFromPtr of our Executor is stored in this context, so given
+            // a context, we can always get the Executor back.
+            context: v8.Context,
+
+            // Arena whose lifetime is for a single getter/setter/function/etc.
+            // Largely used to get strings out of V8, like a stack trace from
+            // a TryCatch. The allocator will be owned by the Scope, but the
+            // arena itself is owned by the Executor so that we can re-use it
+            // from scope to scope.
+            call_arena: ArenaAllocator,
+
+            // Arena whose lifetime is for a single page load, aka a Scope. Where
+            // the call_arena lives for a single function call, the scope_arena
+            // lives for the lifetime of the entire page. The allocator will be
+            // owned by the Scope, but the arena itself is owned by the Executor
+            // so that we can re-use it from scope to scope.
+            scope_arena: ArenaAllocator,
+
+            // When we need to load a resource (i.e. an external script), we call
+            // this function to get the source. This is always a refernece to the
+            // Browser Session's fetchModuleSource, but we use a funciton pointer
+            // since this js module is decoupled from the browser implementation.
+            module_loader: ModuleLoader,
+
+            // A Scope maps to a Browser's Page. Here though, it's only a
+            // mechanism to organization page-specific memory. The Executor
+            // does all the work, but having all page-specific data structures
+            // grouped together helps keep things clean.
+            scope: ?Scope = null,
+
+            templates: []v8.FunctionTemplate,
+
+            const ModuleLoader = struct { ptr: *anyopaque, func: *const fn (ptr: *anyopaque, specifier: []const u8) anyerror![]const u8 };
+
+            // not public, must be destroyed via env.stopExecutor()
+            fn deinit(self: *Executor) void {
+                if (self.scope) |*s| {
+                    s.deinit();
+                }
+                self.context.exit();
+                self.handle_scope.deinit();
+                self.call_arena.deinit();
+                self.scope_arena.deinit();
+            }
+
+            pub fn exec(self: *Executor, src: []const u8, name: ?[]const u8) !Value {
+                const isolate = self.isolate;
+                const context = self.context;
+
+                var origin: ?v8.ScriptOrigin = null;
+                if (name) |n| {
+                    const scr_name = v8.String.initUtf8(isolate, n);
+                    origin = v8.ScriptOrigin.initDefault(isolate, scr_name.toValue());
+                }
+                const scr_js = v8.String.initUtf8(isolate, src);
+                const scr = v8.Script.compile(context, scr_js, origin) catch {
+                    return error.CompilationError;
+                };
+
+                const value = scr.run(context) catch {
+                    return error.ExecutionError;
+                };
+
+                return self.createValue(value);
+            }
+
+            // compile and eval a JS module
+            // It doesn't wait for callbacks execution
+            pub fn module(self: *Executor, src: []const u8, name: []const u8) !Value {
+                const context = self.context;
+                const m = try self.compileModule(src, name);
+
+                // instantiate
+                // TODO handle ResolveModuleCallback parameters to load module's
+                // dependencies.
+                const ok = m.instantiate(context, resolveModuleCallback) catch {
+                    return error.ExecutionError;
+                };
+
+                if (!ok) {
+                    return error.ModuleInstantiationError;
+                }
+
+                // evaluate
+                const value = m.evaluate(context) catch return error.ExecutionError;
+                return self.createValue(value);
+            }
+
+            fn compileModule(self: *Executor, src: []const u8, name: []const u8) !v8.Module {
+                const isolate = self.isolate;
+
+                // compile
+                const script_name = v8.String.initUtf8(isolate, name);
+                const script_source = v8.String.initUtf8(isolate, src);
+
+                const origin = v8.ScriptOrigin.init(
+                    self.isolate,
+                    script_name.toValue(),
+                    0, // resource_line_offset
+                    0, // resource_column_offset
+                    false, // resource_is_shared_cross_origin
+                    -1, // script_id
+                    null, // source_map_url
+                    false, // resource_is_opaque
+                    false, // is_wasm
+                    true, // is_module
+                    null, // host_defined_options
+                );
+
+                var script_comp_source: v8.ScriptCompilerSource = undefined;
+                v8.ScriptCompilerSource.init(&script_comp_source, script_source, origin, null);
+                defer script_comp_source.deinit();
+
+                return v8.ScriptCompiler.compileModule(
+                    isolate,
+                    &script_comp_source,
+                    .kNoCompileOptions,
+                    .kNoCacheNoReason,
+                ) catch return error.CompilationError;
+            }
+
+            pub fn startScope(self: *Executor, global: anytype) !void {
+                std.debug.assert(self.scope == null);
+
+                var handle_scope: v8.HandleScope = undefined;
+                v8.HandleScope.init(&handle_scope, self.isolate);
+                self.scope = Scope{
+                    .handle_scope = handle_scope,
+                    .arena = self.scope_arena.allocator(),
+                    .call_arena = self.call_arena.allocator(),
+                };
+                _ = try self._mapZigInstanceToJs(self.context.getGlobal(), global);
+            }
+
+            pub fn endScope(self: *Executor) void {
+                self.scope.?.deinit();
+                self.scope = null;
+                _ = self.scope_arena.reset(.{ .retain_with_limit = 1024 * 16 });
+            }
+
+            fn createValue(self: *const Executor, value: v8.Value) Value {
+                return .{
+                    .value = value,
+                    .executor = self,
+                };
+            }
+
+            fn zigValueToJs(self: *const Executor, value: anytype) !v8.Value {
+                return Self.zigValueToJs(self.templates, self.isolate, self.context, value);
+            }
+
+            // An instance of the exeuctor is stored in the execution context.
+            // Code that only has the context can call this function, which
+            // will extract the executor to map the Zig instance to an JS value.
+            fn mapZigInstanceToJs(context: v8.Context, js_obj_or_template: anytype, value: anytype) !PersistentObject {
+                const executor: *Executor = @ptrFromInt(context.getEmbedderData(1).castTo(v8.BigInt).getUint64());
+                return executor._mapZigInstanceToJs(js_obj_or_template, value);
+            }
+
+            fn _mapZigInstanceToJs(self: *Executor, js_obj_or_template: anytype, value: anytype) !PersistentObject {
+                const scope = &self.scope.?;
+                const context = self.context;
+                const scope_arena = scope.arena;
+
+                const T = @TypeOf(value);
+                switch (@typeInfo(T)) {
+                    .@"struct" => {
+                        const heap = try scope_arena.create(T);
+                        heap.* = value;
+                        return self._mapZigInstanceToJs(js_obj_or_template, heap);
+                    },
+                    .pointer => |ptr| {
+                        const gop = try scope.identity_map.getOrPut(scope_arena, @intFromPtr(value));
+                        if (gop.found_existing) {
+                            return gop.value_ptr.*;
+                        }
+
+                        const js_obj = switch (@TypeOf(js_obj_or_template)) {
+                            v8.Object => js_obj_or_template,
+                            v8.FunctionTemplate => js_obj_or_template.getInstanceTemplate().initInstance(context),
+                            else => @compileError("mapZigInstanceToJs requires a v8.Object (constructors) or v8.FunctionTemplate, got: " ++ @typeName(@TypeOf(js_obj_or_template))),
+                        };
+
+                        const tao = try scope_arena.create(TaggedAnyOpaque);
+                        tao.* = .{
+                            .ptr = value,
+                            .index = @field(TYPE_LOOKUP, @typeName(ptr.child)),
+                            .sub_type = if (@hasDecl(ptr.child, "sub_type")) ptr.child.sub_type else null,
+                            .offset = if (@typeInfo(ptr.child) != .@"opaque" and @hasField(ptr.child, "proto")) @offsetOf(ptr.child, "proto") else -1,
+                        };
+
+                        const isolate = self.isolate;
+                        js_obj.setInternalField(0, v8.External.init(isolate, tao));
+                        const js_persistent = PersistentObject.init(isolate, js_obj);
+                        gop.value_ptr.* = js_persistent;
+                        return js_persistent;
+                    },
+                    else => @compileError("Expected a struct or pointer, got " ++ @typeName(T) ++ " (constructors must return struct or pointers)"),
+                }
+            }
+
+            fn resolveModuleCallback(
+                c_context: ?*const v8.C_Context,
+                c_specifier: ?*const v8.C_String,
+                import_attributes: ?*const v8.C_FixedArray,
+                referrer: ?*const v8.C_Module,
+            ) callconv(.C) ?*const v8.C_Module {
+                _ = import_attributes;
+                _ = referrer;
+
+                std.debug.assert(c_context != null);
+                const context = v8.Context{ .handle = c_context.? };
+
+                const self: *Executor = @ptrFromInt(context.getEmbedderData(1).castTo(v8.BigInt).getUint64());
+
+                var buf: [1024]u8 = undefined;
+                var fba = std.heap.FixedBufferAllocator.init(&buf);
+
+                // build the specifier value.
+                const specifier = valueToString(
+                    fba.allocator(),
+                    .{ .handle = c_specifier.? },
+                    self.isolate,
+                    context,
+                ) catch |e| {
+                    log.err("resolveModuleCallback: get ref str: {any}", .{e});
+                    return null;
+                };
+
+                // not currently needed
+                // const referrer_module = if (referrer) |ref| v8.Module{ .handle = ref } else null;
+                const module_loader = self.module_loader;
+                const source = module_loader.func(module_loader.ptr, specifier) catch |err| {
+                    log.err("fetchModuleSource for '{s}' fetch error: {}", .{ specifier, err });
+                    return null;
+                };
+
+                const m = self.compileModule(source, specifier) catch |err| {
+                    log.err("fetchModuleSource for '{s}' compile error: {}", .{ specifier, err });
+                    return null;
+                };
+                return m.handle;
+            }
+        };
+
+        // Loosely maps to a Browser Page. Executor does all the work, this just
+        // contains all the data structures / memory we need for a page. It helps
+        // to keep things organized. I.e. we have a single nullable,
+        //   scope: ?Scope = null
+        // in executor, rather than having one for each of these.
+        pub const Scope = struct {
+            arena: Allocator,
+            call_arena: Allocator,
+            handle_scope: v8.HandleScope,
+            callbacks: std.ArrayListUnmanaged(v8.Persistent(v8.Function)) = .{},
+            identity_map: std.AutoHashMapUnmanaged(usize, PersistentObject) = .{},
+
+            fn deinit(self: *Scope) void {
+                var it = self.identity_map.valueIterator();
+                while (it.next()) |p| {
+                    p.deinit();
+                }
+                for (self.callbacks.items) |*cb| {
+                    cb.deinit();
+                }
+                self.handle_scope.deinit();
+            }
+
+            fn trackCallback(self: *Scope, pf: PersistentFunction) !void {
+                return self.callbacks.append(self.arena, pf);
+            }
+        };
+
+        pub const Callback = struct {
+            id: usize,
+            executor: *Executor,
+            this: ?v8.Object = null,
+            func: PersistentFunction,
+
+            const _CALLBACK_ID_KLUDGE = true;
+
+            pub const Result = struct {
+                stack: ?[]const u8,
+                exception: []const u8,
+            };
+
+            pub fn setThis(self: *Callback, value: anytype) !void {
+                const persistent_object = self.executor.scope.?.identity_map.get(@intFromPtr(value)) orelse {
+                    return error.InvalidThisForCallback;
+                };
+                self.this = persistent_object.castToObject();
+            }
+
+            pub fn call(self: *const Callback, args: anytype) !void {
+                return self.callWithThis(self.this orelse self.executor.context.getGlobal(), args);
+            }
+
+            pub fn tryCall(self: *const Callback, args: anytype, result: *Result) !void {
+                var try_catch: TryCatch = undefined;
+                try_catch.init(self.executor);
+                defer try_catch.deinit();
+
+                self.call(args) catch |err| {
+                    if (try_catch.hasCaught()) {
+                        const allocator = self.executor.scope.?.call_arena;
+                        result.stack = try_catch.stack(allocator) catch null;
+                        result.exception = (try_catch.exception(allocator) catch @errorName(err)) orelse @errorName(err);
+                    } else {
+                        result.stack = null;
+                        result.exception = @errorName(err);
+                    }
+                    return err;
+                };
+            }
+
+            fn callWithThis(self: *const @This(), js_this: v8.Object, args: anytype) !void {
+                const executor = self.executor;
+
+                const aargs = if (comptime @typeInfo(@TypeOf(args)) == .null) struct {}{} else args;
+                const fields = @typeInfo(@TypeOf(aargs)).@"struct".fields;
+                var js_args: [fields.len]v8.Value = undefined;
+                inline for (fields, 0..) |f, i| {
+                    js_args[i] = try executor.zigValueToJs(@field(aargs, f.name));
+                }
+                _ = self.func.castToFunction().call(executor.context, js_this, &js_args);
+            }
+        };
+
+        pub const TryCatch = struct {
+            inner: v8.TryCatch,
+            executor: *const Executor,
+
+            pub fn init(self: *TryCatch, executor: *const Executor) void {
+                self.executor = executor;
+                self.inner.init(executor.isolate);
+            }
+
+            pub fn hasCaught(self: TryCatch) bool {
+                return self.inner.hasCaught();
+            }
+
+            // the caller needs to deinit the string returned
+            pub fn exception(self: TryCatch, allocator: Allocator) !?[]const u8 {
+                const msg = self.inner.getException() orelse return null;
+                const executor = self.executor;
+                return try valueToString(allocator, msg, executor.isolate, executor.context);
+            }
+
+            // the caller needs to deinit the string returned
+            pub fn stack(self: TryCatch, allocator: Allocator) !?[]const u8 {
+                const executor = self.executor;
+                const s = self.inner.getStackTrace(executor.context) orelse return null;
+                return try valueToString(allocator, s, executor.isolate, executor.context);
+            }
+
+            // a shorthand method to return either the entire stack message
+            // or just the exception message
+            // - in Debug mode return the stack if available
+            // - otherwhise return the exception if available
+            // the caller needs to deinit the string returned
+            pub fn err(self: TryCatch, allocator: Allocator) !?[]const u8 {
+                if (builtin.mode == .Debug) {
+                    if (try self.stack(allocator)) |msg| {
+                        return msg;
+                    }
+                }
+                return try self.exception(allocator);
+            }
+
+            pub fn deinit(self: *TryCatch) void {
+                self.inner.deinit();
+            }
+        };
+
+        pub const Inspector = struct {
+            isolate: v8.Isolate,
+            inner: *v8.Inspector,
+            session: v8.InspectorSession,
+
+            // We expect allocator to be an arena
+            pub fn init(allocator: Allocator, executor: *const Executor, ctx: anytype) !Inspector {
+                const ContextT = @TypeOf(ctx);
+
+                const InspectorContainer = switch (@typeInfo(ContextT)) {
+                    .@"struct" => ContextT,
+                    .pointer => |ptr| ptr.child,
+                    .void => NoopInspector,
+                    else => @compileError("invalid context type"),
+                };
+
+                // If necessary, turn a void context into something we can safely ptrCast
+                const safe_context: *anyopaque = if (ContextT == void) @constCast(@ptrCast(&{})) else ctx;
+
+                const isolate = executor.isolate;
+                const channel = v8.InspectorChannel.init(safe_context, InspectorContainer.onInspectorResponse, InspectorContainer.onInspectorEvent, isolate);
+
+                const client = v8.InspectorClient.init();
+
+                const inner = try allocator.create(v8.Inspector);
+                v8.Inspector.init(inner, client, channel, isolate);
+                return .{ .inner = inner, .isolate = isolate, .session = inner.connect() };
+            }
+
+            pub fn deinit(self: *const Inspector) void {
+                self.session.deinit();
+                self.inner.deinit();
+            }
+
+            pub fn send(self: *const Inspector, msg: []const u8) void {
+                self.session.dispatchProtocolMessage(self.isolate, msg);
+            }
+
+            pub fn contextCreated(
+                self: *const Inspector,
+                executor: *const Executor,
+                name: []const u8,
+                origin: []const u8,
+                aux_data: ?[]const u8,
+            ) void {
+                self.inner.contextCreated(executor.context, name, origin, aux_data);
+            }
+
+            // Retrieves the RemoteObject for a given value.
+            // The value is loaded through the Executor's mapZigInstanceToJs function,
+            // just like a method return value. Therefore, if we've mapped this
+            // value before, we'll get the existing JS PersistedObject and if not
+            // we'll create it and track it for cleanup when the scope ends.
+            pub fn getRemoteObject(
+                self: *const Inspector,
+                executor: *const Executor,
+                group: []const u8,
+                value: anytype,
+            ) !RemoteObject {
+                const js_value = try zigValueToJs(
+                    executor.templates,
+                    executor.isolate,
+                    executor.context,
+                    value,
+                );
+
+                // We do not want to expose this as a parameter for now
+                const generate_preview = false;
+                return self.session.wrapObject(
+                    executor.isolate,
+                    executor.context,
+                    js_value,
+                    group,
+                    generate_preview,
+                );
+            }
+        };
+
+        pub const RemoteObject = v8.RemoteObject;
+
+        pub const Value = struct {
+            value: v8.Value,
+            executor: *const Executor,
+
+            // the caller needs to deinit the string returned
+            pub fn toString(self: Value, allocator: Allocator) ![]const u8 {
+                const executor = self.executor;
+                return valueToString(allocator, self.value, executor.isolate, executor.context);
+            }
+        };
+
+        // Reverses the mapZigInstanceToJs, making sure that our TaggedAnyOpaque
+        // contains a ptr to the correct type.
+        fn typeTaggedAnyOpaque(comptime named_function: anytype, comptime R: type, op: ?*anyopaque) !R {
+            const ti = @typeInfo(R);
+            if (ti != .pointer) {
+                @compileError(std.fmt.comptimePrint(
+                    "{s} has a non-pointer Zig parameter type: {s}",
+                    .{ named_function.full_name, @typeName(R) },
+                ));
+            }
+
+            const type_name = @typeName(ti.pointer.child);
+            if (@hasField(TypeLookup, type_name) == false) {
+                @compileError(std.fmt.comptimePrint(
+                    "{s} has an unknown Zig type: {s}",
+                    .{ named_function.full_name, @typeName(R) },
+                ));
+            }
+
+            const toa: *TaggedAnyOpaque = @alignCast(@ptrCast(op));
+            const expected_type_index = @field(TYPE_LOOKUP, @typeName(ti.pointer.child));
+
+            var type_index = toa.index;
+            if (type_index == expected_type_index) {
+                return @alignCast(@ptrCast(toa.ptr));
+            }
+
+            // search through the prototype tree
+            while (true) {
+                const prototype_index = PROTOTYPE_TABLE[type_index];
+                if (prototype_index == expected_type_index) {
+                    // -1 is a sentinel value used for non-composition prototype
+                    // This is used with netsurf and we just unsafely cast one
+                    // type to another
+                    const offset = toa.offset;
+                    if (offset == -1) {
+                        return @alignCast(@ptrCast(toa.ptr));
+                    }
+
+                    // A non-negative offset means we're using composition prototype
+                    // (i.e. our struct has a "proto" field). the offset
+                    // reresents the byte offset of the field. We can use that
+                    // + the toa.ptr to get the field
+                    return @ptrFromInt(@intFromPtr(toa.ptr) + @as(usize, @intCast(offset)));
+                }
+                if (prototype_index == type_index) {
+                    return error.InvalidArgument;
+                }
+                type_index = prototype_index;
+            }
+        }
+    };
+}
+
+fn Caller(comptime E: type) type {
+    const State = E.State;
+    const TYPE_LOOKUP = E.TYPE_LOOKUP;
+    const TypeLookup = @TypeOf(TYPE_LOOKUP);
+
+    return struct {
+        env: *E,
+        context: v8.Context,
+        isolate: v8.Isolate,
+        executor: *E.Executor,
+        call_allocator: Allocator,
+
+        const Self = @This();
+
+        fn init(info: anytype) Self {
+            const isolate = info.getIsolate();
+            const env: *E = @ptrFromInt(info.getData().castTo(v8.BigInt).getUint64());
+
+            const context = isolate.getCurrentContext();
+            const executor: *E.Executor = @ptrFromInt(context.getEmbedderData(1).castTo(v8.BigInt).getUint64());
+
+            return .{
+                .env = env,
+                .isolate = isolate,
+                .context = context,
+                .executor = executor,
+                .call_allocator = executor.scope.?.call_arena,
+            };
+        }
+
+        fn deinit(self: *Self) void {
+            _ = self.executor.call_arena.reset(.{ .retain_with_limit = 4096 });
+        }
+
+        fn constructor(self: *Self, comptime named_function: anytype, info: v8.FunctionCallbackInfo) !void {
+            const S = named_function.S;
+            const args = try self.getArgs(named_function, 0, info);
+            const res = @call(.auto, S.constructor, args);
+
+            const ReturnType = @typeInfo(@TypeOf(S.constructor)).@"fn".return_type orelse {
+                @compileError(@typeName(S) ++ " has a constructor without a return type");
+            };
+
+            const this = info.getThis();
+            if (@typeInfo(ReturnType) == .error_union) {
+                const non_error_res = res catch |err| return err;
+                _ = try E.Executor.mapZigInstanceToJs(self.context, this, non_error_res);
+            } else {
+                _ = try E.Executor.mapZigInstanceToJs(self.context, this, res);
+            }
+            info.getReturnValue().set(this);
+        }
+
+        fn method(self: *Self, comptime named_function: anytype, info: v8.FunctionCallbackInfo) !void {
+            const S = named_function.S;
+            comptime assertSelfReceiver(named_function);
+
+            var args = try self.getArgs(named_function, 1, info);
+            const external = info.getThis().getInternalField(0).castTo(v8.External);
+            const zig_instance = try E.typeTaggedAnyOpaque(named_function, *Receiver(S), external.get());
+
+            // inject 'self' as the first parameter
+            @field(args, "0") = zig_instance;
+
+            const res = @call(.auto, named_function.func, args);
+            info.getReturnValue().set(try self.zigValueToJs(res));
+        }
+
+        fn getter(self: *Self, comptime named_function: anytype, info: v8.PropertyCallbackInfo) !void {
+            const S = named_function.S;
+            const Getter = @TypeOf(named_function.func);
+            if (@typeInfo(Getter).@"fn".return_type == null) {
+                @compileError(@typeName(S) ++ " has a getter without a return type: " ++ @typeName(Getter));
+            }
+
+            var args: ParamterTypes(Getter) = undefined;
+            const arg_fields = @typeInfo(@TypeOf(args)).@"struct".fields;
+            switch (arg_fields.len) {
+                0 => {}, // getters _can_ be parameterless
+                1, 2 => {
+                    const external = info.getThis().getInternalField(0).castTo(v8.External);
+                    const zig_instance = try E.typeTaggedAnyOpaque(named_function, *Receiver(S), external.get());
+                    comptime assertSelfReceiver(named_function);
+                    @field(args, "0") = zig_instance;
+                    if (comptime arg_fields.len == 2) {
+                        comptime assertIsStateArg(named_function, 1);
+                        @field(args, "1") = self.executor.state;
+                    }
+                },
+                else => @compileError(named_function.full_name + " has too many parmaters: " ++ @typeName(named_function.func)),
+            }
+            const res = @call(.auto, named_function.func, args);
+            info.getReturnValue().set(try self.zigValueToJs(res));
+        }
+
+        fn setter(self: *Self, comptime named_function: anytype, js_value: v8.Value, info: v8.PropertyCallbackInfo) !void {
+            const S = named_function.S;
+            comptime assertSelfReceiver(named_function);
+
+            const external = info.getThis().getInternalField(0).castTo(v8.External);
+            const zig_instance = try E.typeTaggedAnyOpaque(named_function, *Receiver(S), external.get());
+
+            const Setter = @TypeOf(named_function.func);
+            var args: ParamterTypes(Setter) = undefined;
+            const arg_fields = @typeInfo(@TypeOf(args)).@"struct".fields;
+            switch (arg_fields.len) {
+                0 => unreachable, // assertSelfReceiver make sure of this
+                1 => @compileError(named_function.full_name ++ " only has 1 parameter"),
+                2, 3 => {
+                    @field(args, "0") = zig_instance;
+                    @field(args, "1") = try self.jsValueToZig(named_function, arg_fields[1].type, js_value);
+                    if (comptime arg_fields.len == 3) {
+                        comptime assertIsStateArg(named_function, 2);
+                        @field(args, "2") = self.executor.state;
+                    }
+                },
+                else => @compileError(named_function.full_name ++ " setter with more than 3 parameters, why?"),
+            }
+
+            if (@typeInfo(Setter).@"fn".return_type) |return_type| {
+                if (@typeInfo(return_type) == .error_union) {
+                    _ = try @call(.auto, named_function.func, args);
+                    return;
+                }
+            }
+            _ = @call(.auto, named_function.func, args);
+        }
+
+        fn getIndex(self: *Self, comptime named_function: anytype, idx: u32, info: v8.PropertyCallbackInfo) !void {
+            const S = named_function.S;
+            const IndexedGet = @TypeOf(named_function.func);
+            if (@typeInfo(IndexedGet).@"fn".return_type == null) {
+                @compileError(named_function.full_name ++ " must have a return type");
+            }
+
+            var has_value = true;
+
+            var args: ParamterTypes(IndexedGet) = undefined;
+            const arg_fields = @typeInfo(@TypeOf(args)).@"struct".fields;
+            switch (arg_fields.len) {
+                0, 1, 2 => @compileError(named_function.full_name ++ " must take at least a u32 and *bool parameter"),
+                3, 4 => {
+                    const external = info.getThis().getInternalField(0).castTo(v8.External);
+                    const zig_instance = try E.typeTaggedAnyOpaque(named_function, *Receiver(S), external.get());
+                    comptime assertSelfReceiver(named_function);
+                    @field(args, "0") = zig_instance;
+                    @field(args, "1") = idx;
+                    @field(args, "2") = &has_value;
+                    if (comptime arg_fields.len == 4) {
+                        comptime assertIsStateArg(named_function, 3);
+                        @field(args, "3") = self.executor.state;
+                    }
+                },
+                else => @compileError(named_function.full_name ++ " has too many parmaters"),
+            }
+
+            const res = @call(.auto, S.indexed_get, args);
+            if (has_value == false) {
+                // for an indexed parameter, say nodes[10000], we should return
+                // undefined, not null, if the index is out of rante
+                info.getReturnValue().set(try self.zigValueToJs({}));
+            } else {
+                info.getReturnValue().set(try self.zigValueToJs(res));
+            }
+        }
+
+        fn setIndex(self: *Self, comptime named_function: anytype, idx: u32, js_value: v8.Value, info: v8.PropertyCallbackInfo) !void {
+            const S = named_function.S;
+            comptime assertSelfReceiver(named_function);
+
+            const external = info.getThis().getInternalField(0).castTo(v8.External);
+            const zig_instance = try E.typeTaggedAnyOpaque(named_function, *Receiver(S), external.get());
+
+            const IndexedSet = @TypeOf(named_function.func);
+            var args: ParamterTypes(IndexedSet) = undefined;
+            const arg_fields = @typeInfo(@TypeOf(args)).@"struct".fields;
+            switch (arg_fields.len) {
+                0, 1, 2 => @compileError(named_function.full_name ++ " must take at least a u32 parameter and a value"),
+                3, 4 => {
+                    @field(args, "0") = zig_instance;
+                    @field(args, "1") = idx;
+                    @field(args, "2") = try self.jsValueToZig(named_function, arg_fields[2].type, js_value);
+                    if (comptime arg_fields.len == 4) {
+                        comptime assertIsStateArg(named_function, 3);
+                        @field(args, "3") = self.executor.state;
+                    }
+                },
+                else => @compileError(named_function.full_name ++ " has too many parmaters"),
+            }
+
+            switch (@typeInfo(@typeInfo(IndexedSet).@"fn".return_type.?)) {
+                .error_union => |eu| {
+                    if (eu.payload == void) {
+                        return @call(.auto, S.indexed_set, args);
+                    }
+                },
+                .void => return @call(.auto, S.indexed_set, args),
+                else => {},
+            }
+            @compileError(named_function.full_name ++ " cannot have a return type");
+        }
+
+        fn getNamedIndex(self: *Self, comptime named_function: anytype, name: v8.Name, info: v8.PropertyCallbackInfo) !void {
+            const S = named_function.S;
+            const NamedGet = @TypeOf(named_function.func);
+            if (@typeInfo(NamedGet).@"fn".return_type == null) {
+                @compileError(named_function.full_name ++ " must have a return type");
+            }
+
+            var has_value = true;
+            var args: ParamterTypes(NamedGet) = undefined;
+            const arg_fields = @typeInfo(@TypeOf(args)).@"struct".fields;
+            switch (arg_fields.len) {
+                0, 1, 2 => @compileError(named_function.full_name ++ " must take at least a u32 and *bool parameter"),
+                3, 4 => {
+                    const external = info.getThis().getInternalField(0).castTo(v8.External);
+                    const zig_instance = try E.typeTaggedAnyOpaque(named_function, *Receiver(S), external.get());
+                    comptime assertSelfReceiver(named_function);
+                    @field(args, "0") = zig_instance;
+                    @field(args, "1") = try self.nameToString(name);
+                    @field(args, "2") = &has_value;
+                    if (comptime arg_fields.len == 4) {
+                        comptime assertIsStateArg(named_function, 3);
+                        @field(args, "3") = self.executor.state;
+                    }
+                },
+                else => @compileError(named_function.full_name ++ " has too many parmaters"),
+            }
+
+            const res = @call(.auto, S.named_get, args);
+            if (has_value == false) {
+                // for an indexed parameter, say nodes[10000], we should return
+                // undefined, not null, if the index is out of rante
+                info.getReturnValue().set(try self.zigValueToJs({}));
+            } else {
+                info.getReturnValue().set(try self.zigValueToJs(res));
+            }
+        }
+
+        fn setNamedIndex(self: *Self, comptime named_function: anytype, name: v8.Name, js_value: v8.Value, info: v8.PropertyCallbackInfo) !void {
+            const S = named_function.S;
+            comptime assertSelfReceiver(named_function);
+
+            const external = info.getThis().getInternalField(0).castTo(v8.External);
+            const zig_instance = try E.typeTaggedAnyOpaque(named_function, *Receiver(S), external.get());
+
+            const IndexedSet = @TypeOf(named_function.func);
+            var args: ParamterTypes(IndexedSet) = undefined;
+            const arg_fields = @typeInfo(@TypeOf(args)).@"struct".fields;
+            switch (arg_fields.len) {
+                0, 1, 2 => @compileError(named_function.full_name ++ " must take at least an u32 parameter and a value"),
+                3, 4 => {
+                    @field(args, "0") = zig_instance;
+                    @field(args, "1") = try self.nameToString(name);
+                    @field(args, "2") = try self.jsValueToZig(named_function, arg_fields[2].type, js_value);
+                    if (comptime arg_fields.len == 4) {
+                        comptime assertIsStateArg(named_function, 3);
+                        @field(args, "3") = self.executor.state;
+                    }
+                },
+                else => @compileError(named_function.full_name ++ " has too many parmaters"),
+            }
+
+            switch (@typeInfo(@typeInfo(IndexedSet).@"fn".return_type.?)) {
+                .error_union => |eu| {
+                    if (eu.payload == void) {
+                        return @call(.auto, S.named_set, args);
+                    }
+                },
+                .void => return @call(.auto, S.named_set, args),
+                else => {},
+            }
+            @compileError(named_function.full_name ++ " cannot have a return type");
+        }
+
+        fn nameToString(self: *Self, name: v8.Name) ![]const u8 {
+            return valueToString(self.call_allocator, .{ .handle = name.handle }, self.isolate, self.context);
+        }
+
+        fn assertSelfReceiver(comptime named_function: anytype) void {
+            const params = @typeInfo(@TypeOf(named_function.func)).@"fn".params;
+            if (params.len == 0) {
+                @compileError(named_function.full_name ++ " must have a self parameter");
+            }
+            const R = Receiver(named_function.S);
+
+            const first_param = params[0].type.?;
+            if (first_param != *R and first_param != *const R) {
+                @compileError(std.fmt.comptimePrint("The first parameter to {s} must be a *{s} or *const {s}. Got: {s}", .{ named_function.full_name, @typeName(R), @typeName(R), @typeName(first_param) }));
+            }
+        }
+
+        fn assertIsStateArg(comptime named_function: anytype, index: comptime_int) void {
+            const F = @TypeOf(named_function.func);
+            const params = @typeInfo(F).@"fn".params;
+
+            const param = params[index].type.?;
+            if (param != State) {
+                @compileError(std.fmt.comptimePrint("The {d} parameter to {s} must be a {s}. Got: {s}", .{ index, named_function.full_name, @typeName(State), @typeName(param) }));
+            }
+        }
+
+        fn handleError(self: *Self, comptime named_function: anytype, err: anyerror, info: anytype) void {
+            const isolate = self.isolate;
+            var js_err: ?v8.Value = switch (err) {
+                error.InvalidArgument => createTypeException(isolate, "invalid argument"),
+                error.OutOfMemory => createException(isolate, "out of memory"),
+                else => blk: {
+                    // if (@typeInfo(@TypeOf(func)) == .void) {
+                    //     // func will be void in the case of a type without a
+                    //     // constructor. In such cases the error will always
+                    //     // be error.IllegalConstructor, which the above case
+                    //     // will handle. So it should be impossible for us to
+                    //     // get here.
+                    //     // We add this code to satisfy the compiler.
+                    //     unreachable;
+                    // }
+
+                    const return_type = @typeInfo(@TypeOf(named_function.func)).@"fn".return_type orelse {
+                        // void return type;
+                        break :blk null;
+                    };
+
+                    if (@typeInfo(return_type) != .error_union) {
+                        // type defines a custom exception, but this function should
+                        // not fail. We failed somewhere inside of js.zig and
+                        // should return the error as-is, since it isn't related
+                        // to our Struct
+                        break :blk null;
+                    }
+
+                    const function_error_set = @typeInfo(return_type).error_union.error_set;
+
+                    const Exception = comptime getCustomException(named_function.S) orelse break :blk null;
+                    if (function_error_set == Exception or isErrorSetException(Exception, err)) {
+                        const custom_exception = Exception.init(self.call_allocator, err, named_function.js_name) catch |init_err| {
+                            switch (init_err) {
+                                // if a custom exceptions' init wants to return a
+                                // different error, we need to think about how to
+                                // handle that failure.
+                                error.OutOfMemory => break :blk createException(isolate, "out of memory"),
+                            }
+                        };
+                        // ughh..how to handle an error here?
+                        break :blk self.zigValueToJs(custom_exception) catch createException(isolate, "internal error");
+                    }
+                    // this error isn't part of a custom exception
+                    break :blk null;
+                },
+            };
+
+            if (js_err == null) {
+                js_err = createException(isolate, @errorName(err));
+            }
+            const js_exception = isolate.throwException(js_err.?);
+            info.getReturnValue().setValueHandle(js_exception.handle);
+        }
+
+        // walk the prototype chain to see if a type declares a custom Exception
+        fn getCustomException(comptime Struct: type) ?type {
+            var S = Struct;
+            while (true) {
+                if (@hasDecl(S, "Exception")) {
+                    return S.Exception;
+                }
+                if (@hasDecl(S, "prototype") == false) {
+                    return null;
+                }
+                // long ago, we validated that every prototype declaration
+                // is a pointer.
+                S = @typeInfo(S.prototype).pointer.child;
+            }
+        }
+
+        // Does the error we want to return belong to the custom exeception's ErrorSet
+        fn isErrorSetException(comptime Exception: type, err: anytype) bool {
+            const Entry = std.meta.Tuple(&.{ []const u8, void });
+            const error_set = @typeInfo(Exception.ErrorSet).error_set.?;
+            const entries = comptime blk: {
+                var kv: [error_set.len]Entry = undefined;
+                for (error_set, 0..) |e, i| {
+                    kv[i] = .{ e.name, {} };
+                }
+                break :blk kv;
+            };
+            const lookup = std.StaticStringMap(void).initComptime(entries);
+            return lookup.has(@errorName(err));
+        }
+
+        // If we call a method in javascript: cat.lives('nine');
+        //
+        // Then we'd expect a Zig function with 2 parameters: a self and the string.
+        // In this case, offset == 1. Offset is always 1 for setters or methods.
+        //
+        // Offset is always 0 for constructors.
+        //
+        // For constructors, setters and methods, we can further increase offset + 1
+        // if the first parameter is an instance of State.
+        //
+        // Finally, if the JS function is called with _more_ parameters and
+        // the last parameter in Zig is an array, we'll try to slurp the additional
+        // parameters into the array.
+        fn getArgs(self: *const Self, comptime named_function: anytype, comptime offset: usize, info: anytype) !ParamterTypes(@TypeOf(named_function.func)) {
+            const F = @TypeOf(named_function.func);
+            const zig_function_parameters = @typeInfo(F).@"fn".params;
+
+            var args: ParamterTypes(F) = undefined;
+            if (zig_function_parameters.len == 0) {
+                return args;
+            }
+
+            const adjusted_offset = blk: {
+                if (zig_function_parameters.len > offset and comptime isState(zig_function_parameters[offset].type.?)) {
+                    @field(args, std.fmt.comptimePrint("{d}", .{offset})) = self.executor.state;
+                    break :blk offset + 1;
+                } else {
+                    break :blk offset;
+                }
+            };
+
+            const js_parameter_count = info.length();
+            const expected_js_parameters = zig_function_parameters.len - adjusted_offset;
+
+            var is_variadic = false;
+            const last_parameter_index = zig_function_parameters.len - 1;
+            {
+                // This is going to get complicated. If the last Zig paremeter
+                // is a slice AND the corresponding javascript parameter is
+                // NOT an an array, then we'll treat it as a variadic.
+
+                const last_parameter_type = zig_function_parameters[last_parameter_index].type.?;
+                const last_parameter_type_info = @typeInfo(last_parameter_type);
+                if (last_parameter_type_info == .pointer and last_parameter_type_info.pointer.size == .slice) {
+                    const slice_type = last_parameter_type_info.pointer.child;
+                    const corresponding_js_index = last_parameter_index - adjusted_offset;
+                    const corresponding_js_value = info.getArg(@as(u32, @intCast(corresponding_js_index)));
+                    if (corresponding_js_value.isArray() == false and slice_type != u8) {
+                        const arr = try self.call_allocator.alloc(last_parameter_type_info.pointer.child, js_parameter_count - expected_js_parameters + 1);
+                        for (arr, corresponding_js_index..) |*a, i| {
+                            const js_value = info.getArg(@as(u32, @intCast(i)));
+                            a.* = try self.jsValueToZig(named_function, slice_type, js_value);
+                        }
+
+                        is_variadic = true;
+                        @field(args, tupleFieldName(last_parameter_index)) = arr;
+                    }
+                }
+            }
+
+            inline for (zig_function_parameters[adjusted_offset..], 0..) |param, i| {
+                const field_index = comptime i + adjusted_offset;
+                if (comptime field_index == last_parameter_index) {
+                    if (is_variadic) {
+                        break;
+                    }
+                }
+
+                if (comptime isState(param.type.?)) {
+                    @compileError("State must be the 2nd parameter: " ++ named_function.full_name);
+                } else if (i >= js_parameter_count) {
+                    if (@typeInfo(param.type.?) != .optional) {
+                        return error.TypeError;
+                    }
+                    @field(args, tupleFieldName(field_index)) = null;
+                } else {
+                    const js_value = info.getArg(@as(u32, @intCast(i)));
+                    @field(args, tupleFieldName(field_index)) = self.jsValueToZig(named_function, param.type.?, js_value) catch {
+                        return error.InvalidArgument;
+                    };
+                }
+            }
+
+            return args;
+        }
+
+        fn jsValueToZig(self: *const Self, comptime named_function: anytype, comptime T: type, js_value: v8.Value) !T {
+            switch (@typeInfo(T)) {
+                .optional => |o| {
+                    if (js_value.isNull() or js_value.isUndefined()) {
+                        return null;
+                    }
+                    return try self.jsValueToZig(named_function, o.child, js_value);
+                },
+                .float => |f| switch (f.bits) {
+                    0...32 => return js_value.toF32(self.context),
+                    33...64 => return js_value.toF64(self.context),
+                    else => {},
+                },
+                .int => return jsIntToZig(T, js_value, self.context),
+                .bool => return js_value.toBool(self.isolate),
+                .pointer => |ptr| switch (ptr.size) {
+                    .one => {
+                        if (!js_value.isObject()) {
+                            return error.InvalidArgument;
+                        }
+                        if (@hasField(TypeLookup, @typeName(ptr.child))) {
+                            const obj = js_value.castTo(v8.Object);
+                            if (obj.internalFieldCount() == 0) {
+                                return error.InvalidArgument;
+                            }
+                            return E.typeTaggedAnyOpaque(named_function, *Receiver(ptr.child), obj.getInternalField(0).castTo(v8.External).get());
+                        }
+                    },
+                    .slice => {
+                        if (ptr.child == u8) {
+                            return valueToString(self.call_allocator, js_value, self.isolate, self.context);
+                        }
+
+                        // TODO: TypedArray
+                        // if (js_value.isArrayBufferView()) {
+                        //     const abv = js_value.castTo(v8.ArrayBufferView);
+                        //     const ab = abv.getBuffer();
+                        //     const bs = v8.BackingStore.sharedPtrGet(&ab.getBackingStore());
+                        //     const data = bs.getData();
+                        //     var arr = @as([*]i32, @alignCast(@ptrCast(data)))[0..2];
+                        //     std.debug.print("{d} {d} {d}\n", .{arr[0], arr[1], bs.getByteLength()});
+                        //     arr[1] = 3333;
+                        //     return &.{};
+                        // }
+
+                        if (!js_value.isArray()) {
+                            return error.InvalidArgument;
+                        }
+
+                        const context = self.context;
+                        const js_arr = js_value.castTo(v8.Array);
+                        const js_obj = js_arr.castTo(v8.Object);
+
+                        // Newer version of V8 appear to have an optimized way
+                        // to do this (V8::Array has an iterate method on it)
+                        const arr = try self.call_allocator.alloc(ptr.child, js_arr.length());
+                        for (arr, 0..) |*a, i| {
+                            a.* = try self.jsValueToZig(named_function, ptr.child, try js_obj.getAtIndex(context, @intCast(i)));
+                        }
+                        return arr;
+                    },
+                    else => {},
+                },
+                .@"struct" => |s| {
+                    if (@hasDecl(T, "_CALLBACK_ID_KLUDGE")) {
+                        if (!js_value.isFunction()) {
+                            return error.InvalidArgument;
+                        }
+
+                        const executor = self.executor;
+                        const func = v8.Persistent(v8.Function).init(self.isolate, js_value.castTo(v8.Function));
+                        try executor.scope.?.trackCallback(func);
+
+                        return .{
+                            .func = func,
+                            .executor = executor,
+                            .id = js_value.castTo(v8.Object).getIdentityHash(),
+                        };
+                    }
+
+                    if (!js_value.isObject()) {
+                        return error.InvalidArgument;
+                    }
+                    const context = self.context;
+                    const isolate = self.isolate;
+                    const js_obj = js_value.castTo(v8.Object);
+
+                    var value: T = undefined;
+                    inline for (s.fields) |field| {
+                        const name = field.name;
+                        const key = v8.String.initUtf8(isolate, name);
+                        if (js_obj.has(context, key.toValue())) {
+                            @field(value, name) = try self.jsValueToZig(named_function, field.type, try js_obj.getValue(context, key));
+                        } else if (@typeInfo(field.type) == .optional) {
+                            @field(value, name) = null;
+                        } else {
+                            if (field.defaultValue()) |dflt| {
+                                @field(value, name) = dflt;
+                            } else {
+                                return error.JSWrongObject;
+                            }
+                        }
+                    }
+                    return value;
+                },
+                else => {},
+            }
+
+            @compileError(std.fmt.comptimePrint("{s} has an unsupported parameter type: {s}", .{ named_function.full_name, @typeName(T) }));
+        }
+
+        fn jsIntToZig(comptime T: type, js_value: v8.Value, context: v8.Context) !T {
+            const n = @typeInfo(T).int;
+            switch (n.signedness) {
+                .signed => switch (n.bits) {
+                    8 => return jsSignedIntToZig(i8, -128, 127, try js_value.toI32(context)),
+                    16 => return jsSignedIntToZig(i16, -32_768, 32_767, try js_value.toI32(context)),
+                    32 => return jsSignedIntToZig(i32, -2_147_483_648, 2_147_483_647, try js_value.toI32(context)),
+                    64 => {
+                        if (js_value.isBigInt()) {
+                            const v = js_value.castTo(v8.BigInt);
+                            return v.getInt64();
+                        }
+                        return jsSignedIntToZig(i64, -2_147_483_648, 2_147_483_647, try js_value.toI32(context));
+                    },
+                    else => {},
+                },
+                .unsigned => switch (n.bits) {
+                    8 => return jsUnsignedIntToZig(u8, 255, try js_value.toU32(context)),
+                    16 => return jsUnsignedIntToZig(u16, 65_535, try js_value.toU32(context)),
+                    32 => return jsUnsignedIntToZig(u32, 4_294_967_295, try js_value.toU32(context)),
+                    64 => {
+                        if (js_value.isBigInt()) {
+                            const v = js_value.castTo(v8.BigInt);
+                            return v.getUint64();
+                        }
+                        return jsUnsignedIntToZig(u64, 4_294_967_295, try js_value.toU32(context));
+                    },
+                    else => {},
+                },
+            }
+            @compileError("Only i8, i16, i32, i64, u8, u16, u32 and u64 are supported");
+        }
+
+        fn jsSignedIntToZig(comptime T: type, comptime min: comptime_int, max: comptime_int, maybe: i32) !T {
+            if (maybe >= min and maybe <= max) {
+                return @intCast(maybe);
+            }
+            return error.InvalidArgument;
+        }
+
+        fn jsUnsignedIntToZig(comptime T: type, max: comptime_int, maybe: u32) !T {
+            if (maybe <= max) {
+                return @intCast(maybe);
+            }
+            return error.InvalidArgument;
+        }
+
+        fn zigValueToJs(self: *const Self, value: anytype) !v8.Value {
+            return self.executor.zigValueToJs(value);
+        }
+
+        fn isState(comptime T: type) bool {
+            const ti = @typeInfo(State);
+            const Const_State = if (ti == .pointer) *const ti.pointer.child else State;
+            return T == State or T == Const_State;
+        }
+    };
+}
+
+// These are simple types that we can convert to JS with only an isolate. This
+// is separated from the Caller's zigValueToJs to make it available when we
+// don't have a caller (i.e., when setting static attributes on types)
+fn simpleZigValueToJs(isolate: v8.Isolate, value: anytype, comptime fail: bool) if (fail) v8.Value else ?v8.Value {
+    switch (@typeInfo(@TypeOf(value))) {
+        .void => return v8.initUndefined(isolate).toValue(),
+        .bool => return v8.getValue(if (value) v8.initTrue(isolate) else v8.initFalse(isolate)),
+        .int => |n| switch (n.signedness) {
+            .signed => {
+                if (value >= -2_147_483_648 and value <= 2_147_483_647) {
+                    return v8.Integer.initI32(isolate, @intCast(value)).toValue();
+                }
+                if (comptime n.bits <= 64) {
+                    return v8.getValue(v8.BigInt.initI64(isolate, @intCast(value)));
+                }
+                @compileError(@typeName(value) ++ " is not supported");
+            },
+            .unsigned => {
+                if (value <= 4_294_967_295) {
+                    return v8.Integer.initU32(isolate, @intCast(value)).toValue();
+                }
+                if (comptime n.bits <= 64) {
+                    return v8.getValue(v8.BigInt.initU64(isolate, @intCast(value)));
+                }
+                @compileError(@typeName(value) ++ " is not supported");
+            },
+        },
+        .comptime_int => {
+            if (value >= 0) {
+                if (value <= 4_294_967_295) {
+                    return v8.Integer.initU32(isolate, @intCast(value)).toValue();
+                }
+                return v8.BigInt.initU64(isolate, @intCast(value)).toValue();
+            }
+            if (value >= -2_147_483_648) {
+                return v8.Integer.initI32(isolate, @intCast(value)).toValue();
+            }
+            return v8.BigInt.initI64(isolate, @intCast(value)).toValue();
+        },
+        .comptime_float => return v8.Number.init(isolate, value).toValue(),
+        .float => |f| switch (f.bits) {
+            64 => return v8.Number.init(isolate, value).toValue(),
+            32 => return v8.Number.init(isolate, @floatCast(value)).toValue(),
+            else => @compileError(@typeName(value) ++ " is not supported"),
+        },
+        .pointer => |ptr| {
+            if (ptr.size == .slice and ptr.child == u8) {
+                return v8.String.initUtf8(isolate, value).toValue();
+            }
+            if (ptr.size == .one) {
+                const one_info = @typeInfo(ptr.child);
+                if (one_info == .array and one_info.array.child == u8) {
+                    return v8.String.initUtf8(isolate, value).toValue();
+                }
+            }
+        },
+        .array => return simpleZigValueToJs(isolate, &value, fail),
+        .optional => {
+            if (value) |v| {
+                return simpleZigValueToJs(isolate, v, fail);
+            }
+            return v8.initNull(isolate).toValue();
+        },
+        .@"union" => return simpleZigValueToJs(isolate, std.meta.activeTag(value), fail),
+        else => {},
+    }
+    if (fail) {
+        @compileError("Unsupported Zig type " ++ @typeName(@TypeOf(value)));
+    }
+    return null;
+}
+
+pub fn zigJsonToJs(isolate: v8.Isolate, context: v8.Context, value: std.json.Value) !v8.Value {
+    switch (value) {
+        .bool => |v| return simpleZigValueToJs(isolate, v, true),
+        .float => |v| return simpleZigValueToJs(isolate, v, true),
+        .integer => |v| return simpleZigValueToJs(isolate, v, true),
+        .string => |v| return simpleZigValueToJs(isolate, v, true),
+        .null => return isolate.initNull().toValue(),
+
+        // TODO handle number_string.
+        // It is used to represent too big numbers.
+        .number_string => return error.TODO,
+
+        .array => |v| {
+            const a = v8.Array.init(isolate, @intCast(v.items.len));
+            const obj = a.castTo(v8.Object);
+            for (v.items, 0..) |array_value, i| {
+                const js_val = try zigJsonToJs(isolate, context, array_value);
+                if (!obj.setValueAtIndex(context, @intCast(i), js_val)) {
+                    return error.JSObjectSetValue;
+                }
+            }
+            return obj.toValue();
+        },
+        .object => |v| {
+            var obj = v8.Object.init(isolate);
+            var it = v.iterator();
+            while (it.next()) |kv| {
+                const js_key = v8.String.initUtf8(isolate, kv.key_ptr.*);
+                const js_val = try zigJsonToJs(isolate, context, kv.value_ptr.*);
+                if (!obj.setValue(context, js_key, js_val)) {
+                    return error.JSObjectSetValue;
+                }
+            }
+            return obj.toValue();
+        },
+    }
+}
+
+// Takes a function, and returns a tuple for its argument. Used when we
+// @call a function
+fn ParamterTypes(comptime F: type) type {
+    const params = @typeInfo(F).@"fn".params;
+    var fields: [params.len]std.builtin.Type.StructField = undefined;
+
+    inline for (params, 0..) |param, i| {
+        fields[i] = .{
+            .name = tupleFieldName(i),
+            .type = param.type.?,
+            .default_value_ptr = null,
+            .is_comptime = false,
+            .alignment = @alignOf(param.type.?),
+        };
+    }
+
+    return @Type(.{ .@"struct" = .{
+        .layout = .auto,
+        .decls = &.{},
+        .fields = &fields,
+        .is_tuple = true,
+    } });
+}
+
+fn tupleFieldName(comptime i: usize) [:0]const u8 {
+    return std.fmt.comptimePrint("{d}", .{i});
+}
+
+fn createException(isolate: v8.Isolate, msg: []const u8) v8.Value {
+    return v8.Exception.initError(v8.String.initUtf8(isolate, msg));
+}
+
+fn createTypeException(isolate: v8.Isolate, msg: []const u8) v8.Value {
+    return v8.Exception.initTypeError(v8.String.initUtf8(isolate, msg));
+}
+
+fn classNameForStruct(comptime Struct: type) []const u8 {
+    if (@hasDecl(Struct, "js_name")) {
+        return Struct.js_name;
+    }
+    @setEvalBranchQuota(10_000);
+    const full_name = @typeName(Struct);
+    const last = std.mem.lastIndexOfScalar(u8, full_name, '.') orelse return full_name;
+    return full_name[last + 1 ..];
+}
+
+// When we return a Zig object to V8, we put it on the heap and pass it into
+// v8 as an *anyopaque (i.e. void *). When V8 gives us back the value, say, as a
+// function parameter, we know what type it _should_ be. Above, in Caller.method
+// (for example), we know all the parameter types. So if a Zig function takes
+// a single parameter (its receiver), we know what that type is.
+//
+// In a simple/perfect world, we could use this knowledge to cast the *anyopaque
+// to the parameter type:
+//   const arg: @typeInfo(@TypeOf(function)).@"fn".params[0] = @ptrCast(v8_data);
+//
+// But there are 2 reasons we can't do that.
+//
+// == Reason 1 ==
+// The JS code might pass the wrong type:
+//
+//   var cat = new Cat();
+//   cat.setOwner(new Cat());
+//
+// The zig _setOwner method expects the 2nd paramter to be an *Owner, but
+// the JS code passed a *Cat.
+//
+// To solve this issue, we tag every returned value so that we can check what
+// type it is. In the above case, we'd expect an *Owner, but the tag would tell
+// us that we got a *Cat. We use the type index in our Types lookup as the tag.
+//
+// == Reason 2 ==
+// Because of prototype inheritance, even "correct" code can be a challenge. For
+// example, say the above JavaScript is fixed:
+//
+//   var cat = new Cat();
+//   cat.setOwner(new Owner("Leto"));
+//
+// The issue is that setOwner might not expect an *Owner, but rather a
+// *Person, which is the prototype for Owner. Now our Zig code is expecting
+// a *Person, but it was (correctly) given an *Owner.
+// For this reason, we also store the prototype's type index.
+//
+// One of the prototype mechanisms that we support is via composition. Owner
+// can have a "proto: *Person" field. For this reason, we also store the offset
+// of the proto field, so that, given an intFromPtr(*Owner) we can access it's
+// proto field.
+//
+// The other prototype mechanism that we support is for netsurf, where we just
+// cast one type to another. In this case, we'll store an offset of -1 (as a
+// sentinel to indicate that we should just cast directly).
+const TaggedAnyOpaque = struct {
+    // The type of object this is. The type is captured as an index, which
+    // corresponds to both a field in TYPE_LOOKUP and the index of
+    // PROTOTYPE_TABLE
+    index: u16,
+
+    // If this type has composition-based prototype, represents the byte-offset
+    // from ptr where the `proto` field is located. The value -1 represents
+    // unsafe prototype where we can just cast ptr to the destination type
+    // (this is used extensively with netsuf.)
+    offset: i32,
+
+    // Ptr to the Zig instance. We'll know its possible type based on the context
+    // where it's called, but it's exact type might be
+    ptr: *anyopaque,
+
+    // When we're asked to describe an object via the Inspector, we _must_ include
+    // the proper subtype (and description) fields in the returned JSON.
+    // V8 will give us a Value and ask us for the subtype. From the v8.Value we
+    // can get a v8.Object, and from the v8.Object, we can get out TaggedAnyOpque
+    sub_type: ?[*c]const u8,
+};
+
+fn valueToString(allocator: Allocator, value: v8.Value, isolate: v8.Isolate, context: v8.Context) ![]u8 {
+    const str = try value.toString(context);
+    const len = str.lenUtf8(isolate);
+    const buf = try allocator.alloc(u8, len);
+    const n = str.writeUtf8(isolate, buf);
+    std.debug.assert(n == len);
+    return buf;
+}
+
+pub const ObjectId = struct {
+    id: usize,
+
+    pub fn set(obj: v8.Object) ObjectId {
+        return .{ .id = obj.getIdentityHash() };
+    }
+
+    pub fn get(self: ObjectId) usize {
+        return self.id;
+    }
+};
+
+const NoopInspector = struct {
+    pub fn onInspectorResponse(_: *anyopaque, _: u32, _: []const u8) void {}
+    pub fn onInspectorEvent(_: *anyopaque, _: []const u8) void {}
+};
+
+// If we have a struct:
+// const Cat = struct {
+//    pub fn meow(self: *Cat) void { ... }
+// }
+// The obviously, the receiver of its methods are going to be a *Cat (or *const Cat)
+//
+// However, we can also do:
+// const Cat = struct {
+//    pub const Self = OtherImpl;
+//    pub fn meow(self: *OtherImpl) void { ... }
+// }
+// In which case, as we see above, the receiver is derived from the Self declaration
+fn Receiver(comptime S: type) type {
+    return if (@hasDecl(S, "Self")) S.Self else S;
+}
+
+// We want the function name, or more precisely, the "Struct.function" for
+// displaying helpful @compileError.
+// However, there's no way to get the name from a std.Builtin.Fn,
+// so we capture it early, when we iterate through the declarations.
+fn NamedFunction(comptime S: type, comptime function: anytype, comptime name: []const u8) type {
+    const full_name = @typeName(S) ++ "." ++ name;
+    const js_name = if (name[0] == '_') name[1..] else name;
+    return struct {
+        S: type = S,
+        full_name: []const u8 = full_name,
+        func: @TypeOf(function) = function,
+        js_name: []const u8 = js_name,
+    };
+}
+
+pub export fn v8_inspector__Client__IMPL__valueSubtype(
+    _: *v8.c.InspectorClientImpl,
+    c_value: *const v8.C_Value,
+) callconv(.C) [*c]const u8 {
+    const external_entry = getTaggedAnyOpaque(.{ .handle = c_value }) orelse return null;
+    return if (external_entry.sub_type) |st| st else null;
+}
+
+pub export fn v8_inspector__Client__IMPL__descriptionForValueSubtype(
+    _: *v8.c.InspectorClientImpl,
+    context: *const v8.C_Context,
+    c_value: *const v8.C_Value,
+) callconv(.C) [*c]const u8 {
+    _ = context;
+
+    // We _must_ include a non-null description in order for the subtype value
+    // to be included. Besides that, I don't know if the value has any meaning
+    const external_entry = getTaggedAnyOpaque(.{ .handle = c_value }) orelse return null;
+    return if (external_entry.sub_type == null) null else "";
+}
+
+fn getTaggedAnyOpaque(value: v8.Value) ?*TaggedAnyOpaque {
+    if (value.isObject() == false) {
+        return null;
+    }
+    const obj = value.castTo(v8.Object);
+    if (obj.internalFieldCount() == 0) {
+        return null;
+    }
+
+    const external_data = obj.getInternalField(0).castTo(v8.External).get().?;
+    return @alignCast(@ptrCast(external_data));
+}
diff --git a/src/runtime/loop.zig b/src/runtime/loop.zig
new file mode 100644
index 00000000..e9ce9c52
--- /dev/null
+++ b/src/runtime/loop.zig
@@ -0,0 +1,429 @@
+// Copyright 2023-2024 Lightpanda (Selecy SAS)
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+const std = @import("std");
+const MemoryPool = std.heap.MemoryPool;
+
+pub const IO = @import("tigerbeetle-io").IO;
+
+const JSCallback = @import("../browser/env.zig").Env.Callback;
+
+const log = std.log.scoped(.loop);
+
+// SingleThreaded I/O Loop based on Tigerbeetle io_uring loop.
+// On Linux it's using io_uring.
+// On MacOS and Windows it's using kqueue/IOCP with a ring design.
+// This is a thread-unsafe version without any lock on shared resources,
+// use it only on a single thread.
+// The loop provides I/O APIs based on callbacks.
+// I/O APIs based on async/await might be added in the future.
+pub const Loop = struct {
+    alloc: std.mem.Allocator, // TODO: unmanaged version ?
+    io: IO,
+    js_events_nb: usize,
+    zig_events_nb: usize,
+    cbk_error: bool = false,
+
+    // js_ctx_id is incremented each time the loop is reset for JS.
+    // All JS callbacks store an initial js_ctx_id and compare before execution.
+    // If a ctx is outdated, the callback is ignored.
+    // This is a weak way to cancel all future JS callbacks.
+    js_ctx_id: u32 = 0,
+
+    // zig_ctx_id is incremented each time the loop is reset for Zig.
+    // All Zig callbacks store an initial zig_ctx_id and compare before execution.
+    // If a ctx is outdated, the callback is ignored.
+    // This is a weak way to cancel all future Zig callbacks.
+    zig_ctx_id: u32 = 0,
+
+    cancel_pool: MemoryPool(ContextCancel),
+    timeout_pool: MemoryPool(ContextTimeout),
+    event_callback_pool: MemoryPool(EventCallbackContext),
+
+    const Self = @This();
+    pub const Completion = IO.Completion;
+
+    pub const ConnectError = IO.ConnectError;
+    pub const RecvError = IO.RecvError;
+    pub const SendError = IO.SendError;
+
+    pub fn init(alloc: std.mem.Allocator) !Self {
+        return Self{
+            .alloc = alloc,
+            .io = try IO.init(32, 0),
+            .js_events_nb = 0,
+            .zig_events_nb = 0,
+            .cancel_pool = MemoryPool(ContextCancel).init(alloc),
+            .timeout_pool = MemoryPool(ContextTimeout).init(alloc),
+            .event_callback_pool = MemoryPool(EventCallbackContext).init(alloc),
+        };
+    }
+
+    pub fn deinit(self: *Self) void {
+        // run tail events. We do run the tail events to ensure all the
+        // contexts are correcly free.
+        while (self.eventsNb(.js) > 0 or self.eventsNb(.zig) > 0) {
+            self.io.run_for_ns(10 * std.time.ns_per_ms) catch |err| {
+                log.err("deinit run tail events: {any}", .{err});
+                break;
+            };
+        }
+        self.cancelAll();
+        self.io.deinit();
+        self.cancel_pool.deinit();
+        self.timeout_pool.deinit();
+        self.event_callback_pool.deinit();
+    }
+
+    // Retrieve all registred I/O events completed by OS kernel,
+    // and execute sequentially their callbacks.
+    // Stops when there is no more I/O events registered on the loop.
+    // Note that I/O events callbacks might register more I/O events
+    // on the go when they are executed (ie. nested I/O events).
+    pub fn run(self: *Self) !void {
+        while (self.eventsNb(.js) > 0) {
+            try self.io.run_for_ns(10 * std.time.ns_per_ms);
+            // at each iteration we might have new events registred by previous callbacks
+        }
+        // TODO: return instead immediatly on the first JS callback error
+        // and let the caller decide what to do next
+        // (typically retrieve the exception through the TryCatch and
+        // continue the execution of callbacks with a new call to loop.run)
+        if (self.cbk_error) {
+            return error.JSExecCallback;
+        }
+    }
+
+    const Event = enum { js, zig };
+
+    fn eventsPtr(self: *Self, comptime event: Event) *usize {
+        return switch (event) {
+            .zig => &self.zig_events_nb,
+            .js => &self.js_events_nb,
+        };
+    }
+
+    // Register events atomically
+    // - add 1 event and return previous value
+    fn addEvent(self: *Self, comptime event: Event) void {
+        _ = @atomicRmw(usize, self.eventsPtr(event), .Add, 1, .acq_rel);
+    }
+    // - remove 1 event and return previous value
+    fn removeEvent(self: *Self, comptime event: Event) void {
+        _ = @atomicRmw(usize, self.eventsPtr(event), .Sub, 1, .acq_rel);
+    }
+    // - get the number of current events
+    fn eventsNb(self: *Self, comptime event: Event) usize {
+        return @atomicLoad(usize, self.eventsPtr(event), .seq_cst);
+    }
+    fn resetEvents(self: *Self, comptime event: Event) void {
+        @atomicStore(usize, self.eventsPtr(event), 0, .unordered);
+    }
+
+    // JS callbacks APIs
+    // -----------------
+
+    // Timeout
+
+    const ContextTimeout = struct {
+        loop: *Self,
+        js_cbk: ?JSCallback,
+        js_ctx_id: u32,
+    };
+
+    fn timeoutCallback(
+        ctx: *ContextTimeout,
+        completion: *IO.Completion,
+        result: IO.TimeoutError!void,
+    ) void {
+        const loop = ctx.loop;
+        defer {
+            loop.removeEvent(.js);
+            loop.timeout_pool.destroy(ctx);
+            loop.alloc.destroy(completion);
+        }
+
+        // If the loop's context id has changed, don't call the js callback
+        // function. The callback's memory has already be cleaned and the
+        // events nb reset.
+        if (ctx.js_ctx_id != loop.js_ctx_id) return;
+
+        // TODO: return the error to the callback
+        result catch |err| {
+            switch (err) {
+                error.Canceled => {},
+                else => log.err("timeout callback: {any}", .{err}),
+            }
+            return;
+        };
+
+        // js callback
+        if (ctx.js_cbk) |*js_cbk| {
+            js_cbk.call(null) catch {
+                ctx.loop.cbk_error = true;
+            };
+        }
+    }
+
+    pub fn timeout(self: *Self, nanoseconds: u63, js_cbk: ?JSCallback) !usize {
+        const completion = try self.alloc.create(Completion);
+        errdefer self.alloc.destroy(completion);
+        completion.* = undefined;
+
+        const ctx = try self.timeout_pool.create();
+        errdefer self.timeout_pool.destroy(ctx);
+        ctx.* = ContextTimeout{
+            .loop = self,
+            .js_cbk = js_cbk,
+            .js_ctx_id = self.js_ctx_id,
+        };
+
+        self.addEvent(.js);
+        self.io.timeout(*ContextTimeout, ctx, timeoutCallback, completion, nanoseconds);
+        return @intFromPtr(completion);
+    }
+
+    const ContextCancel = struct {
+        loop: *Self,
+        js_cbk: ?JSCallback,
+        js_ctx_id: u32,
+    };
+
+    fn cancelCallback(
+        ctx: *ContextCancel,
+        completion: *IO.Completion,
+        result: IO.CancelOneError!void,
+    ) void {
+        const loop = ctx.loop;
+
+        defer {
+            loop.removeEvent(.js);
+            loop.cancel_pool.destroy(ctx);
+            loop.alloc.destroy(completion);
+        }
+
+        // If the loop's context id has changed, don't call the js callback
+        // function. The callback's memory has already be cleaned and the
+        // events nb reset.
+        if (ctx.js_ctx_id != loop.js_ctx_id) return;
+
+        // TODO: return the error to the callback
+        result catch |err| {
+            switch (err) {
+                error.NotFound => log.debug("cancel callback: {any}", .{err}),
+                else => log.err("cancel callback: {any}", .{err}),
+            }
+            return;
+        };
+
+        // js callback
+        if (ctx.js_cbk) |*js_cbk| {
+            js_cbk.call(null) catch {
+                ctx.loop.cbk_error = true;
+            };
+        }
+    }
+
+    pub fn cancel(self: *Self, id: usize, js_cbk: ?JSCallback) !void {
+        if (IO.supports_cancel == false) {
+            return;
+        }
+        const comp_cancel: *IO.Completion = @ptrFromInt(id);
+
+        const completion = try self.alloc.create(Completion);
+        errdefer self.alloc.destroy(completion);
+        completion.* = undefined;
+
+        const ctx = self.alloc.create(ContextCancel) catch unreachable;
+        ctx.* = ContextCancel{
+            .loop = self,
+            .js_cbk = js_cbk,
+            .js_ctx_id = self.js_ctx_id,
+        };
+
+        self.addEvent(.js);
+        self.io.cancel_one(*ContextCancel, ctx, cancelCallback, completion, comp_cancel);
+    }
+
+    fn cancelAll(self: *Self) void {
+        self.resetEvents(.js);
+        self.resetEvents(.zig);
+        self.io.cancel_all();
+    }
+
+    // Reset all existing JS callbacks.
+    pub fn resetJS(self: *Self) void {
+        self.js_ctx_id += 1;
+    }
+
+    // Reset all existing Zig callbacks.
+    pub fn resetZig(self: *Self) void {
+        self.zig_ctx_id += 1;
+    }
+
+    // IO callbacks APIs
+    // -----------------
+
+    // Connect
+
+    pub fn connect(
+        self: *Self,
+        comptime Ctx: type,
+        ctx: *Ctx,
+        completion: *Completion,
+        comptime cbk: fn (ctx: *Ctx, _: *Completion, res: ConnectError!void) void,
+        socket: std.posix.socket_t,
+        address: std.net.Address,
+    ) !void {
+        const onConnect = struct {
+            fn onConnect(callback: *EventCallbackContext, completion_: *Completion, res: ConnectError!void) void {
+                defer callback.loop.event_callback_pool.destroy(callback);
+                callback.loop.removeEvent(.js);
+                cbk(@alignCast(@ptrCast(callback.ctx)), completion_, res);
+            }
+        }.onConnect;
+
+        const callback = try self.event_callback_pool.create();
+        errdefer self.event_callback_pool.destroy(callback);
+        callback.* = .{ .loop = self, .ctx = ctx };
+
+        self.addEvent(.js);
+        self.io.connect(*EventCallbackContext, callback, onConnect, completion, socket, address);
+    }
+
+    // Send
+
+    pub fn send(
+        self: *Self,
+        comptime Ctx: type,
+        ctx: *Ctx,
+        completion: *Completion,
+        comptime cbk: fn (ctx: *Ctx, completion: *Completion, res: SendError!usize) void,
+        socket: std.posix.socket_t,
+        buf: []const u8,
+    ) !void {
+        const onSend = struct {
+            fn onSend(callback: *EventCallbackContext, completion_: *Completion, res: SendError!usize) void {
+                defer callback.loop.event_callback_pool.destroy(callback);
+                callback.loop.removeEvent(.js);
+                cbk(@alignCast(@ptrCast(callback.ctx)), completion_, res);
+            }
+        }.onSend;
+
+        const callback = try self.event_callback_pool.create();
+        errdefer self.event_callback_pool.destroy(callback);
+        callback.* = .{ .loop = self, .ctx = ctx };
+
+        self.addEvent(.js);
+        self.io.send(*EventCallbackContext, callback, onSend, completion, socket, buf);
+    }
+
+    // Recv
+
+    pub fn recv(
+        self: *Self,
+        comptime Ctx: type,
+        ctx: *Ctx,
+        completion: *Completion,
+        comptime cbk: fn (ctx: *Ctx, completion: *Completion, res: RecvError!usize) void,
+        socket: std.posix.socket_t,
+        buf: []u8,
+    ) !void {
+        const onRecv = struct {
+            fn onRecv(callback: *EventCallbackContext, completion_: *Completion, res: RecvError!usize) void {
+                defer callback.loop.event_callback_pool.destroy(callback);
+                callback.loop.removeEvent(.js);
+                cbk(@alignCast(@ptrCast(callback.ctx)), completion_, res);
+            }
+        }.onRecv;
+
+        const callback = try self.event_callback_pool.create();
+        errdefer self.event_callback_pool.destroy(callback);
+        callback.* = .{ .loop = self, .ctx = ctx };
+
+        self.addEvent(.js);
+        self.io.recv(*EventCallbackContext, callback, onRecv, completion, socket, buf);
+    }
+
+    // Zig timeout
+
+    const ContextZigTimeout = struct {
+        loop: *Self,
+        zig_ctx_id: u32,
+        context: *anyopaque,
+        callback: *const fn (
+            context: ?*anyopaque,
+        ) void,
+    };
+
+    fn zigTimeoutCallback(
+        ctx: *ContextZigTimeout,
+        completion: *IO.Completion,
+        result: IO.TimeoutError!void,
+    ) void {
+        const loop = ctx.loop;
+        defer {
+            loop.removeEvent(.zig);
+            loop.alloc.destroy(ctx);
+            loop.alloc.destroy(completion);
+        }
+
+        // If the loop's context id has changed, don't call the js callback
+        // function. The callback's memory has already be cleaned and the
+        // events nb reset.
+        if (ctx.zig_ctx_id != loop.zig_ctx_id) return;
+
+        result catch |err| {
+            switch (err) {
+                error.Canceled => {},
+                else => log.err("zig timeout callback: {any}", .{err}),
+            }
+            return;
+        };
+
+        // callback
+        ctx.callback(ctx.context);
+    }
+
+    // zigTimeout performs a timeout but the callback is a zig function.
+    pub fn zigTimeout(
+        self: *Self,
+        nanoseconds: u63,
+        comptime Context: type,
+        context: Context,
+        comptime callback: fn (context: Context) void,
+    ) void {
+        const completion = self.alloc.create(IO.Completion) catch unreachable;
+        completion.* = undefined;
+        const ctxtimeout = self.alloc.create(ContextZigTimeout) catch unreachable;
+        ctxtimeout.* = ContextZigTimeout{
+            .loop = self,
+            .zig_ctx_id = self.zig_ctx_id,
+            .context = context,
+            .callback = struct {
+                fn wrapper(ctx: ?*anyopaque) void {
+                    callback(@ptrCast(@alignCast(ctx)));
+                }
+            }.wrapper,
+        };
+
+        self.addEvent(.zig);
+        self.io.timeout(*ContextZigTimeout, ctxtimeout, zigTimeoutCallback, completion, nanoseconds);
+    }
+};
+
+const EventCallbackContext = struct {
+    ctx: *anyopaque,
+    loop: *Loop,
+};
diff --git a/src/server.zig b/src/server.zig
index a071b441..471c2cf2 100644
--- a/src/server.zig
+++ b/src/server.zig
@@ -25,14 +25,15 @@ const posix = std.posix;
 const Allocator = std.mem.Allocator;
 const ArenaAllocator = std.heap.ArenaAllocator;
 
-const jsruntime = @import("jsruntime");
-const Completion = jsruntime.IO.Completion;
-const AcceptError = jsruntime.IO.AcceptError;
-const RecvError = jsruntime.IO.RecvError;
-const SendError = jsruntime.IO.SendError;
-const CloseError = jsruntime.IO.CloseError;
-const CancelError = jsruntime.IO.CancelOneError;
-const TimeoutError = jsruntime.IO.TimeoutError;
+const IO = @import("runtime/loop.zig").IO;
+const Completion = IO.Completion;
+const AcceptError = IO.AcceptError;
+const RecvError = IO.RecvError;
+const SendError = IO.SendError;
+const CloseError = IO.CloseError;
+const CancelError = IO.CancelOneError;
+const TimeoutError = IO.TimeoutError;
+const Loop = @import("runtime/loop.zig").Loop;
 
 const App = @import("app.zig").App;
 const CDP = @import("cdp/cdp.zig").CDP;
@@ -51,7 +52,7 @@ const MAX_MESSAGE_SIZE = 256 * 1024 + 14;
 const Server = struct {
     app: *App,
     allocator: Allocator,
-    loop: *jsruntime.Loop,
+    loop: *Loop,
 
     // internal fields
     listener: posix.socket_t,
@@ -453,7 +454,7 @@ pub const Client = struct {
         };
 
         self.mode = .websocket;
-        self.cdp = CDP.init(self.server.app, self);
+        self.cdp = try CDP.init(self.server.app, self);
         return self.send(arena, response);
     }
 
@@ -1023,10 +1024,6 @@ pub fn run(
     try posix.bind(listener, &address.any, address.getOsSockLen());
     try posix.listen(listener, 1);
 
-    // create v8 vm
-    const vm = jsruntime.VM.init();
-    defer vm.deinit();
-
     var loop = app.loop;
     const allocator = app.allocator;
     const json_version_response = try buildJSONVersionResponse(allocator, address);
@@ -1451,7 +1448,7 @@ const MockCDP = struct {
 
     allocator: Allocator = testing.allocator,
 
-    fn init(_: Allocator, client: anytype, loop: *jsruntime.Loop) MockCDP {
+    fn init(_: Allocator, client: anytype, loop: *Loop) MockCDP {
         _ = loop;
         _ = client;
         return .{};
diff --git a/src/test_runner.zig b/src/test_runner.zig
index f43b5735..07323ba4 100644
--- a/src/test_runner.zig
+++ b/src/test_runner.zig
@@ -26,10 +26,6 @@ const BORDER = "=" ** 80;
 // use in custom panic handler
 var current_test: ?[]const u8 = null;
 
-const jsruntime = @import("jsruntime");
-pub const Types = jsruntime.reflect(@import("generate.zig").Tuple(.{}){});
-pub const UserContext = @import("user_context.zig").UserContext;
-
 pub const std_options = std.Options{
     .log_level = .warn,
 
diff --git a/src/testing.zig b/src/testing.zig
index 633d9087..6fe94110 100644
--- a/src/testing.zig
+++ b/src/testing.zig
@@ -17,8 +17,7 @@
 // along with this program.  If not, see .
 
 const std = @import("std");
-
-const parser = @import("netsurf");
+const parser = @import("browser/netsurf.zig");
 pub const allocator = std.testing.allocator;
 pub const expectError = std.testing.expectError;
 pub const expectString = std.testing.expectEqualStrings;
@@ -217,13 +216,13 @@ pub const Document = struct {
     }
 
     pub fn querySelectorAll(self: *Document, selector: []const u8) ![]const *parser.Node {
-        const css = @import("dom/css.zig");
+        const css = @import("browser/dom/css.zig");
         const node_list = try css.querySelectorAll(self.arena.allocator(), self.asNode(), selector);
         return node_list.nodes.items;
     }
 
     pub fn querySelector(self: *Document, selector: []const u8) !?*parser.Node {
-        const css = @import("dom/css.zig");
+        const css = @import("browser/dom/css.zig");
         return css.querySelector(self.arena.allocator(), self.asNode(), selector);
     }
 
@@ -350,3 +349,162 @@ fn isJsonValue(a: std.json.Value, b: std.json.Value) bool {
         },
     }
 }
+
+pub const JsRunner = struct {
+    const URL = @import("url.zig").URL;
+    const Env = @import("browser/env.zig").Env;
+    const Loop = @import("runtime/loop.zig").Loop;
+    const HttpClient = @import("http/client.zig").Client;
+    const storage = @import("browser/storage/storage.zig");
+    const Window = @import("browser/html/window.zig").Window;
+    const Renderer = @import("browser/browser.zig").Renderer;
+    const SessionState = @import("browser/env.zig").SessionState;
+
+    url: URL,
+    env: *Env,
+    loop: Loop,
+    window: Window,
+    state: SessionState,
+    arena: Allocator,
+    renderer: Renderer,
+    http_client: HttpClient,
+    executor: *Env.Executor,
+    storage_shelf: storage.Shelf,
+    cookie_jar: storage.CookieJar,
+
+    fn init(parent_allocator: Allocator, opts: RunnerOpts) !*JsRunner {
+        parser.deinit();
+        try parser.init();
+
+        const aa = try parent_allocator.create(std.heap.ArenaAllocator);
+        aa.* = std.heap.ArenaAllocator.init(parent_allocator);
+        errdefer aa.deinit();
+
+        const arena = aa.allocator();
+        const runner = try arena.create(JsRunner);
+        runner.arena = arena;
+
+        runner.env = try Env.init(arena, .{});
+        errdefer runner.env.deinit();
+
+        runner.url = try URL.parse("https://lightpanda.io/opensource-browser/", null);
+        errdefer runner.env.deinit();
+
+        runner.renderer = Renderer.init(arena);
+
+        runner.cookie_jar = storage.CookieJar.init(arena);
+        runner.loop = try Loop.init(arena);
+        errdefer runner.loop.deinit();
+
+        var html = std.io.fixedBufferStream(opts.html);
+        const document = try parser.documentHTMLParse(html.reader(), "UTF-8");
+
+        runner.state = .{
+            .arena = arena,
+            .loop = &runner.loop,
+            .document = document,
+            .url = &runner.url,
+            .renderer = &runner.renderer,
+            .cookie_jar = &runner.cookie_jar,
+            .http_client = &runner.http_client,
+        };
+
+        runner.window = .{};
+        try runner.window.replaceDocument(document);
+        try runner.window.replaceLocation(.{
+            .url = try runner.url.toWebApi(arena),
+        });
+
+        runner.storage_shelf = storage.Shelf.init(arena);
+        runner.window.setStorageShelf(&runner.storage_shelf);
+
+        runner.http_client = try HttpClient.init(arena, 1, .{
+            .tls_verify_host = false,
+        });
+
+        runner.executor = try runner.env.startExecutor(Window, &runner.state, runner);
+        errdefer runner.env.stopExecutor(runner.executor);
+
+        try runner.executor.startScope(&runner.window);
+        return runner;
+    }
+
+    pub fn deinit(self: *JsRunner) void {
+        self.loop.deinit();
+        self.executor.endScope();
+        self.env.deinit();
+        self.http_client.deinit();
+        self.storage_shelf.deinit();
+
+        const arena: *std.heap.ArenaAllocator = @ptrCast(@alignCast(self.arena.ptr));
+        arena.deinit();
+        arena.child_allocator.destroy(arena);
+    }
+
+    const RunOpts = struct {};
+    pub const Case = std.meta.Tuple(&.{ []const u8, []const u8 });
+    pub fn testCases(self: *JsRunner, cases: []const Case, _: RunOpts) !void {
+        for (cases, 0..) |case, i| {
+            var try_catch: Env.TryCatch = undefined;
+            try_catch.init(self.executor);
+            defer try_catch.deinit();
+
+            const value = self.executor.exec(case.@"0", null) catch |err| {
+                if (try try_catch.err(self.arena)) |msg| {
+                    std.debug.print("{s}\n\nCase: {d}\n{s}\n", .{ msg, i + 1, case.@"0" });
+                }
+                return err;
+            };
+            try self.loop.run();
+
+            const actual = try value.toString(self.arena);
+            if (std.mem.eql(u8, case.@"1", actual) == false) {
+                std.debug.print("Expected:\n{s}\n\nGot:\n{s}\n\nCase: {d}\n{s}\n", .{ case.@"1", actual, i + 1, case.@"0" });
+                return error.UnexpectedResult;
+            }
+        }
+    }
+
+    pub fn exec(self: *JsRunner, src: []const u8) !void {
+        _ = try self.eval(src);
+    }
+
+    pub fn eval(self: *JsRunner, src: []const u8) !Env.Value {
+        var try_catch: Env.TryCatch = undefined;
+        try_catch.init(self.executor);
+        defer try_catch.deinit();
+
+        return self.executor.exec(src, null) catch |err| {
+            if (try try_catch.err(self.arena)) |msg| {
+                std.debug.print("Error runnign script: {s}\n", .{msg});
+            }
+            return err;
+        };
+    }
+
+    pub fn fetchModuleSource(ctx: *anyopaque, specifier: []const u8) ![]const u8 {
+        _ = ctx;
+        _ = specifier;
+        return error.DummyModuleLoader;
+    }
+};
+
+
+const RunnerOpts = struct {
+    html: []const u8 =
+        \\ 
+        \\   
OK
+        \\   
+        \\     
+        \\   
+        \\   
 And
+        \\   
+        \\