From 7afecf0f85d0cea0c0b547dd26c93b8d35d232ff Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Thu, 9 Oct 2025 11:23:19 +0200 Subject: [PATCH 1/5] move mod specifier resolution js/context => script manager --- src/browser/ScriptManager.zig | 10 ++++++++++ src/browser/html/elements.zig | 1 + src/browser/js/Context.zig | 9 +++------ src/tests/html/script/importmap.html | 24 ++++++++++++++++++++++++ 4 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 src/tests/html/script/importmap.html diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 06b6ca1d4..3ee86be69 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -248,6 +248,16 @@ pub fn addFromElement(self: *ScriptManager, element: *parser.Element, comptime c }); } +// Resolve a module specifier to an valid URL. +pub fn resolveSpecifier(_: *ScriptManager, arena: Allocator, specifier: []const u8, base: []const u8) ![:0]const u8 { + return URL.stitch( + arena, + specifier, + base, + .{ .alloc = .if_needed, .null_terminated = true }, + ); +} + pub fn getModule(self: *ScriptManager, url: [:0]const u8, referrer: []const u8) !void { const gop = try self.sync_modules.getOrPut(self.allocator, url); if (gop.found_existing) { diff --git a/src/browser/html/elements.zig b/src/browser/html/elements.zig index 0fc5502f6..e9e95fdfc 100644 --- a/src/browser/html/elements.zig +++ b/src/browser/html/elements.zig @@ -1353,6 +1353,7 @@ test "Browser: HTML.HtmlScriptElement" { try testing.htmlRunner("html/script/inline_defer.html"); try testing.htmlRunner("html/script/import.html"); try testing.htmlRunner("html/script/dynamic_import.html"); + try testing.htmlRunner("html/script/importmap.html"); } test "Browser: HTML.HtmlSlotElement" { diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 8d298f0d6..46c6261fd 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -251,11 +251,10 @@ pub fn module(self: *Context, comptime want_result: bool, src: []const u8, url: for (0..requests.length()) |i| { const req = requests.get(v8_context, @intCast(i)).castTo(v8.ModuleRequest); const specifier = try self.jsStringToZig(req.getSpecifier(), .{}); - const normalized_specifier = try @import("../../url.zig").stitch( + const normalized_specifier = try self.script_manager.?.resolveSpecifier( self.call_arena, specifier, owned_url, - .{ .alloc = .if_needed, .null_terminated = true }, ); const gop = try self.module_cache.getOrPut(self.arena, normalized_specifier); if (!gop.found_existing) { @@ -1127,11 +1126,10 @@ pub fn dynamicModuleCallback( return @constCast(self.rejectPromise("Out of memory").handle); }; - const normalized_specifier = @import("../../url.zig").stitch( + const normalized_specifier = self.script_manager.?.resolveSpecifier( self.arena, // might need to survive until the module is loaded specifier, resource, - .{ .alloc = .if_needed, .null_terminated = true }, ) catch |err| { log.err(.app, "OOM", .{ .err = err, .src = "dynamicModuleCallback3" }); return @constCast(self.rejectPromise("Out of memory").handle); @@ -1171,11 +1169,10 @@ fn _resolveModuleCallback(self: *Context, referrer: v8.Module, specifier: []cons return error.UnknownModuleReferrer; }; - const normalized_specifier = try @import("../../url.zig").stitch( + const normalized_specifier = try self.script_manager.?.resolveSpecifier( self.call_arena, specifier, referrer_path, - .{ .alloc = .if_needed, .null_terminated = true }, ); const gop = try self.module_cache.getOrPut(self.arena, normalized_specifier); diff --git a/src/tests/html/script/importmap.html b/src/tests/html/script/importmap.html new file mode 100644 index 000000000..d3f457a94 --- /dev/null +++ b/src/tests/html/script/importmap.html @@ -0,0 +1,24 @@ + + + + + + + + + + From 4bf79e4bc939223e83d44b4e745d08d7f902f8a4 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Thu, 9 Oct 2025 16:08:46 +0200 Subject: [PATCH 2/5] add importmap support --- src/browser/ScriptManager.zig | 60 +++++++++++++++++++++++++++- src/tests/html/script/importmap.html | 4 +- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 3ee86be69..9d9b505ee 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -70,6 +70,10 @@ async_module_pool: std.heap.MemoryPool(AsyncModule), // and can be requested as needed. sync_modules: std.StringHashMapUnmanaged(*SyncModule), +// Mapping between module specifier and resolution. +// see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/script/type/importmap +importmap: std.StringHashMapUnmanaged([]const u8), + const OrderList = std.DoublyLinkedList; pub fn init(browser: *Browser, page: *Page) ScriptManager { @@ -80,6 +84,7 @@ pub fn init(browser: *Browser, page: *Page) ScriptManager { .asyncs = .{}, .scripts = .{}, .deferreds = .{}, + .importmap = .empty, .sync_modules = .empty, .is_evaluating = false, .allocator = allocator, @@ -106,6 +111,8 @@ pub fn deinit(self: *ScriptManager) void { self.async_module_pool.deinit(); self.sync_modules.deinit(self.allocator); + // we don't deinit self.importmap b/c we use the page's arena for its + // allocations. } pub fn reset(self: *ScriptManager) void { @@ -115,6 +122,7 @@ pub fn reset(self: *ScriptManager) void { self.sync_module_pool.destroy(value_ptr.*); } self.sync_modules.clearRetainingCapacity(); + self.importmap.clearRetainingCapacity(); self.clearList(&self.asyncs); self.clearList(&self.scripts); @@ -164,6 +172,9 @@ pub fn addFromElement(self: *ScriptManager, element: *parser.Element, comptime c if (std.ascii.eqlIgnoreCase(script_type, "module")) { break :blk .module; } + if (std.ascii.eqlIgnoreCase(script_type, "importmap")) { + break :blk .importmap; + } // "type" could be anything, but only the above are ones we need to process. // Common other ones are application/json, application/ld+json, text/template @@ -249,7 +260,13 @@ pub fn addFromElement(self: *ScriptManager, element: *parser.Element, comptime c } // Resolve a module specifier to an valid URL. -pub fn resolveSpecifier(_: *ScriptManager, arena: Allocator, specifier: []const u8, base: []const u8) ![:0]const u8 { +pub fn resolveSpecifier(self: *ScriptManager, arena: Allocator, _specifier: []const u8, base: []const u8) ![:0]const u8 { + var specifier = _specifier; + // If the specifier is mapped in the importmap, use it to resolve the path. + if (self.importmap.get(specifier)) |s| { + specifier = s; + } + return URL.stitch( arena, specifier, @@ -462,6 +479,28 @@ fn errorCallback(ctx: *anyopaque, err: anyerror) void { script.errorCallback(err); } +fn parseImportmap(self: *ScriptManager, script: *const Script) !void { + const content = script.source.content(); + + const Imports = struct { + imports: std.json.ArrayHashMap([]const u8), + }; + + const imports = try std.json.parseFromSliceLeaky( + Imports, + self.page.arena, + content, + .{ .allocate = .alloc_always }, + ); + + var iter = imports.imports.map.iterator(); + while (iter.next()) |entry| { + try self.importmap.put(self.page.arena, entry.key_ptr.*, entry.value_ptr.*); + } + + return; +} + // A script which is pending execution. // It could be pending because: // (a) we're still downloading its content or @@ -591,6 +630,7 @@ const Script = struct { const Kind = enum { module, javascript, + importmap, }; const Callback = union(enum) { @@ -631,6 +671,23 @@ const Script = struct { .cacheable = cacheable, }); + // Handle importmap special case here: the content is a JSON containing + // imports. + if (self.kind == .importmap) { + page.script_manager.parseImportmap(self) catch |err| { + log.err(.browser, "parse importmap script", .{ + .err = err, + .src = url, + .kind = self.kind, + .cacheable = cacheable, + }); + self.executeCallback("onerror", page); + return; + }; + self.executeCallback("onload", page); + return; + } + const js_context = page.js; var try_catch: js.TryCatch = undefined; try_catch.init(js_context); @@ -644,6 +701,7 @@ const Script = struct { // We don't care about waiting for the evaluation here. js_context.module(false, content, url, cacheable) catch break :blk false; }, + .importmap => unreachable, // handled before the try/catch. } break :blk true; }; diff --git a/src/tests/html/script/importmap.html b/src/tests/html/script/importmap.html index d3f457a94..c294674fb 100644 --- a/src/tests/html/script/importmap.html +++ b/src/tests/html/script/importmap.html @@ -2,10 +2,10 @@ - From cd9466dafabc25a6775ab52829f062355562b50d Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Thu, 9 Oct 2025 16:21:55 +0200 Subject: [PATCH 3/5] free importmap on reset and don't retain capacity --- src/browser/ScriptManager.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 9d9b505ee..51ebf52a0 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -122,7 +122,9 @@ pub fn reset(self: *ScriptManager) void { self.sync_module_pool.destroy(value_ptr.*); } self.sync_modules.clearRetainingCapacity(); - self.importmap.clearRetainingCapacity(); + // Our allocator is the page arena, it's been reset. We cannot use + // clearAndRetainCapacity, since that space is no longer ours + self.importmap.clearAndFree(self.page.arena); self.clearList(&self.asyncs); self.clearList(&self.scripts); From 58cc5b46849996f3dc1bb5069062f04bb7646277 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 10 Oct 2025 08:02:45 +0200 Subject: [PATCH 4/5] typo fix --- src/tests/html/script/importmap.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/html/script/importmap.html b/src/tests/html/script/importmap.html index c294674fb..973d50806 100644 --- a/src/tests/html/script/importmap.html +++ b/src/tests/html/script/importmap.html @@ -15,7 +15,7 @@ testing.expectEqual('hello', im.greeting); -