Support for "module" type workers.

Pretty basic, worker started with the {type: 'module'} option has 2 practical
differences:
1 - importScript isn't allowed (TypeError)
2 - the initial script is loaded as a module

Seen in various WPT tests, but also saw this usage on mastodon.
This commit is contained in:
Karl Seguin
2026-06-03 11:06:24 +08:00
parent f61b110b1c
commit 2fc9d61070
5 changed files with 86 additions and 8 deletions

View File

@@ -620,7 +620,7 @@ test "Env: Worker context " {
const frame = try session.createPage();
defer session.removePage();
const worker = try @import("../webapi/Worker.zig").init("http://localhost:9582/src/browser/tests/testing.js", frame);
const worker = try @import("../webapi/Worker.zig").init("http://localhost:9582/src/browser/tests/testing.js", null, frame);
var ls: js.Local.Scope = undefined;
worker._worker_scope.js.localScope(&ls);

View File

@@ -0,0 +1,25 @@
// A module worker (`new Worker(url, { type: "module" })`). Unlike a classic
// worker, the entry script may use top-level static `import`/`export`, and
// `importScripts()` is not supported (it throws a TypeError).
import { baseValue } from './modules/base.js';
import { importedValue, localValue } from './modules/importer.js';
export const exported = 'top-level-export-ok';
let importScriptsError = null;
try {
importScripts('./import-script1.js');
} catch (e) {
importScriptsError = e.constructor.name;
}
onmessage = function (event) {
postMessage({
echo: event.data,
baseValue: baseValue,
importedValue: importedValue,
localValue: localValue,
importScriptsError: importScriptsError,
from: 'module-worker',
});
};

View File

@@ -380,6 +380,29 @@
}
</script>
<script id="worker_module_type" type=module>
// A module-type worker: its entry script uses top-level static import (only
// valid in module workers), and importScripts() throws a TypeError.
{
const state = await testing.async();
const worker = new Worker('./module-worker.js', { type: 'module' });
worker.onmessage = function(event) {
state.resolve(event.data);
};
worker.postMessage({ greeting: 'to-module' });
await state.done((response) => {
testing.expectEqual('to-module', response.echo.greeting);
testing.expectEqual('module-worker', response.from);
testing.expectEqual('from-base', response.baseValue);
testing.expectEqual('from-base', response.importedValue);
testing.expectEqual('local', response.localValue);
testing.expectEqual('TypeError', response.importScriptsError);
});
}
</script>
<script id="worker_from_blob_url" type=module>
// A blob: worker resolves its initial script through the HTTP client's
// synthetic-scheme path against the frame's blob registry, like any URL.

View File

@@ -36,6 +36,12 @@ const IS_DEBUG = @import("builtin").mode == .Debug;
const Worker = @This();
pub const WorkerType = enum {
classic,
module,
pub const js_enum_from_string = true;
};
// used by HttpClient when generating notification
// Ultimately used by CDP to generate request/loader ids.
_frame_id: u32,
@@ -47,6 +53,7 @@ _arena: Allocator,
_worker_scope: *WorkerGlobalScope,
_url: [:0]const u8,
_type: WorkerType = .classic,
_script_loaded: bool = false,
_script_buffer: std.ArrayList(u8) = .empty,
_http_response: ?HttpClient.Response = null,
@@ -56,7 +63,11 @@ _on_error: ?js.Function.Global = null,
_on_message: ?js.Function.Global = null,
_on_messageerror: ?js.Function.Global = null,
pub fn init(url: []const u8, frame: *Frame) !*Worker {
const WorkerOptions = struct {
type: WorkerType = .classic,
};
pub fn init(url: []const u8, options: ?WorkerOptions, frame: *Frame) !*Worker {
const session = frame._session;
const arena = try session.getArena(.large, "Worker");
@@ -68,6 +79,7 @@ pub fn init(url: []const u8, frame: *Frame) !*Worker {
._proto = undefined,
._frame = frame,
._url = resolved_url,
._type = if (options) |o| o.type else .classic,
._worker_scope = undefined,
._frame_id = session.nextFrameId(),
._loader_id = session.nextLoaderId(),
@@ -195,12 +207,24 @@ fn loadInitialScript(self: *Worker, script: []const u8) !void {
try_catch.init(&ls.local);
defer try_catch.deinit();
_ = ls.local.eval(script, self._url) catch |err| {
const caught = try_catch.caughtOrError(self._arena, err);
log.err(.browser, "worker script error", .{ .url = self._url, .caught = caught });
self.fireErrorEvent(caught.exception orelse @errorName(err), null);
return;
};
// Classic workers evaluate the entry script as a classic script; module
// workers (`new Worker(url, { type: "module" })`) instantiate it as a
// module so top-level `import`/`export` work. Static imports load
// synchronously through ScriptManagerBase (client.tick sync_wait).
switch (self._type) {
.classic => _ = ls.local.eval(script, self._url) catch |err| {
const caught = try_catch.caughtOrError(self._arena, err);
log.err(.browser, "worker script error", .{ .url = self._url, .caught = caught });
self.fireErrorEvent(caught.exception orelse @errorName(err), null);
return;
},
.module => self._worker_scope.js.module(false, &ls.local, script, self._url, true) catch |err| {
const caught = try_catch.caughtOrError(self._arena, err);
log.err(.browser, "worker module error", .{ .url = self._url, .caught = caught });
self.fireErrorEvent(caught.exception orelse @errorName(err), null);
return;
},
}
ls.local.runMacrotasks();
}

View File

@@ -430,6 +430,12 @@ pub fn close(self: *WorkerGlobalScope) void {
}
pub fn importScripts(self: *WorkerGlobalScope, urls: []const [:0]const u8) !void {
if (self._worker._type == .module) {
// not allowed to be called when the worker type is module (scripts should
// use actual imports).
return error.TypeError;
}
const session = self._session;
const arena = try session.getArena(.large, "importScript");
defer session.releaseArena(arena);