From ca2394cd91c7387ebdec24fb79b164acc078781a Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Mon, 12 Oct 2026 12:25:52 -0700 Subject: [PATCH] Add wasm_esm_integration_builtins compat flag for runtime-compiled Wasm Compile Wasm modules with the js-string builtins and imported string constants enabled, equivalent to the JS options `{ builtins: ['js-string'], importedStringConstants: 'wasm:js/string-constants' }`. These are strictly per-module opt-in in V8 with no global flag, so pass them via WasmModuleObject::CompileOptions in jsg::compileWasmModule, which covers all runtime-compiled Wasm: ESM import / import source in both module registries, and the deprecated Service Worker syntax wasmModule binding. A WebAssembly.Module passed to the worker loader carries compiled code that bakes in the compile-time imports of the isolate that compiled it. Record the parent's mode alongside the compiled module and recompile from the wire bytes when the child's setting differs, in either direction, so loader- provided modules respect the child's flag. The proposal is still in the implementation phase, so this is gated behind the opt-in wasm_esm_integration_builtins compat flag with no enable date. --- src/workerd/api/tests/BUILD.bazel | 17 +++++++ src/workerd/api/tests/js-string-builtins.wat | 12 +++++ .../api/tests/new-module-registry-test.js | 30 ++++++++++++ .../tests/new-module-registry-test.wd-test | 2 + .../api/tests/wasm-js-string-builtins-test.js | 17 +++++++ .../wasm-js-string-builtins-test.wd-test | 15 ++++++ .../tests/worker-loader-wasm-builtins-test.js | 49 +++++++++++++++++++ .../api/tests/worker-loader-wasm-test.js | 39 +++++++++++++++ .../api/tests/worker-loader-wasm-test.wd-test | 13 +++++ src/workerd/api/worker-loader.c++ | 1 + src/workerd/io/compatibility-date.capnp | 8 +++ src/workerd/io/worker-modules.h | 18 ++++++- src/workerd/io/worker-source.h | 5 ++ src/workerd/io/worker.c++ | 3 ++ src/workerd/jsg/jsg.c++ | 8 +++ src/workerd/jsg/jsg.h | 2 + src/workerd/jsg/modules.c++ | 7 ++- src/workerd/jsg/setup.h | 9 ++++ 18 files changed, 252 insertions(+), 3 deletions(-) create mode 100644 src/workerd/api/tests/js-string-builtins.wat create mode 100644 src/workerd/api/tests/wasm-js-string-builtins-test.js create mode 100644 src/workerd/api/tests/wasm-js-string-builtins-test.wd-test create mode 100644 src/workerd/api/tests/worker-loader-wasm-builtins-test.js diff --git a/src/workerd/api/tests/BUILD.bazel b/src/workerd/api/tests/BUILD.bazel index 78464e19791..80dd404474d 100644 --- a/src/workerd/api/tests/BUILD.bazel +++ b/src/workerd/api/tests/BUILD.bazel @@ -1,8 +1,14 @@ load("@aspect_rules_js//js:defs.bzl", "js_binary") load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project") load("@rules_shell//shell:sh_test.bzl", "sh_test") +load("//:build/wasm_tools_parse.bzl", "wasm_tools_parse") load("//:build/wd_test.bzl", "wd_test") +wasm_tools_parse( + name = "js-string-builtins.wasm", + src = "js-string-builtins.wat", +) + exports_files(["starttls-server.pem"]) # Compile all ts test sources in one project. This is needed to work around https://github.com/aspect-build/rules_ts/issues/664 – @@ -1123,10 +1129,19 @@ wd_test( data = [ "new-module-registry-test.js", "test.wasm", + ":js-string-builtins.wasm", ], lint = False, ) +wd_test( + src = "wasm-js-string-builtins-test.wd-test", + data = [ + "wasm-js-string-builtins-test.js", + ":js-string-builtins.wasm", + ], +) + wd_test( src = "new-module-registry-do-test.wd-test", args = ["--experimental"], @@ -1299,7 +1314,9 @@ wd_test( args = ["--experimental"], data = [ "test.wasm", + "worker-loader-wasm-builtins-test.js", "worker-loader-wasm-test.js", + ":js-string-builtins.wasm", ], # The test uses `import source` syntax, which eslint's parser does not yet support. lint = False, diff --git a/src/workerd/api/tests/js-string-builtins.wat b/src/workerd/api/tests/js-string-builtins.wat new file mode 100644 index 00000000000..bb76314b15f --- /dev/null +++ b/src/workerd/api/tests/js-string-builtins.wat @@ -0,0 +1,12 @@ +;; Only links if the runtime compiles Wasm with +;; { builtins: ['js-string'], importedStringConstants: 'wasm:js/string-constants' }. +(module + (import "wasm:js-string" "length" + (func $length (param externref) (result i32))) + + (import "wasm:js/string-constants" "hello world" + (global $hello externref)) + + (func (export "constantLength") (result i32) + (call $length (global.get $hello))) +) diff --git a/src/workerd/api/tests/new-module-registry-test.js b/src/workerd/api/tests/new-module-registry-test.js index 58eabeb60ed..e0e675f2de7 100644 --- a/src/workerd/api/tests/new-module-registry-test.js +++ b/src/workerd/api/tests/new-module-registry-test.js @@ -601,6 +601,36 @@ export const wasmDynamicSourcePhaseFailureTest = { }, }; +// js-string-builtins.wasm imports the js-string `length` builtin and the +// "hello world" imported string constant, so it only links if the runtime +// compiles Wasm with js-string builtins and imported string constants enabled. +// Query-distinct specifiers give each import path its own compile. +async function assertJsStringBuiltinsLink(wasm) { + ok(wasm instanceof WebAssembly.Module); + const instance = await WebAssembly.instantiate(wasm, {}); + strictEqual(instance.exports.constantLength(), 11); +} + +export const wasmJsStringBuiltinsModuleTest = { + async test() { + const { default: wasm } = await import('wasm-js-string'); + await assertJsStringBuiltinsLink(wasm); + }, +}; + +import source wasmJsStringSource from 'wasm-js-string?1'; +export const wasmJsStringBuiltinsSourcePhaseTest = { + async test() { + await assertJsStringBuiltinsLink(wasmJsStringSource); + }, +}; + +export const wasmJsStringBuiltinsDynamicSourcePhaseTest = { + async test() { + await assertJsStringBuiltinsLink(await import.source('wasm-js-string?2')); + }, +}; + export const complexModuleTest = { async test() { const { abc } = await import('complex'); diff --git a/src/workerd/api/tests/new-module-registry-test.wd-test b/src/workerd/api/tests/new-module-registry-test.wd-test index cc2c452dd4b..ab901a6350a 100644 --- a/src/workerd/api/tests/new-module-registry-test.wd-test +++ b/src/workerd/api/tests/new-module-registry-test.wd-test @@ -71,6 +71,7 @@ const unitTests :Workerd.Config = ( (name = "invalid-json", json = "1n"), (name = "wasm", wasm = embed "test.wasm"), + (name = "wasm-js-string", wasm = embed "js-string-builtins.wasm"), (name = "complex", esModule = "import * as abc from '././././././complex/complex2'; export { abc };"), (name = "complex/complex2", esModule = "import * as def from '/bundle/complex/complex3'; export { def }; export const foo = 1;"), @@ -134,6 +135,7 @@ const unitTests :Workerd.Config = ( "nodejs_compat_v2", "new_module_registry", "experimental", + "wasm_esm_integration_builtins", ], bindings = [ (name = "unsafe", unsafeEval = void), diff --git a/src/workerd/api/tests/wasm-js-string-builtins-test.js b/src/workerd/api/tests/wasm-js-string-builtins-test.js new file mode 100644 index 00000000000..2fc61c7436d --- /dev/null +++ b/src/workerd/api/tests/wasm-js-string-builtins-test.js @@ -0,0 +1,17 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 + +import { rejects } from 'node:assert'; +import wasm from 'wasm-js-string'; + +// Without the wasm_esm_integration_builtins compat flag, the `wasm:*` imports are +// ordinary imports that must be supplied by the embedder. +export const wasmJsStringBuiltinsDisabledTest = { + async test() { + await rejects(WebAssembly.instantiate(wasm, {}), { + name: 'TypeError', + message: /wasm:js-string/, + }); + }, +}; diff --git a/src/workerd/api/tests/wasm-js-string-builtins-test.wd-test b/src/workerd/api/tests/wasm-js-string-builtins-test.wd-test new file mode 100644 index 00000000000..210db393318 --- /dev/null +++ b/src/workerd/api/tests/wasm-js-string-builtins-test.wd-test @@ -0,0 +1,15 @@ +using Workerd = import "/workerd/workerd.capnp"; + +const unitTests :Workerd.Config = ( + services = [ + ( name = "wasm-js-string-builtins-test", + worker = ( + modules = [ + (name = "worker", esModule = embed "wasm-js-string-builtins-test.js"), + (name = "wasm-js-string", wasm = embed "js-string-builtins.wasm"), + ], + compatibilityFlags = ["nodejs_compat"], + ), + ), + ], +); diff --git a/src/workerd/api/tests/worker-loader-wasm-builtins-test.js b/src/workerd/api/tests/worker-loader-wasm-builtins-test.js new file mode 100644 index 00000000000..8c508bf5c22 --- /dev/null +++ b/src/workerd/api/tests/worker-loader-wasm-builtins-test.js @@ -0,0 +1,49 @@ +// Copyright (c) 2026 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +import assert from 'node:assert'; +import source jsStringWasm from './js-string.wasm'; + +// This worker compiles with wasm_esm_integration_builtins, so the module passed to the dynamic +// worker was compiled with the builtins. The child does not enable the flag, so it must not +// share the parent's compiled code. +export let wasmModuleSourceBuiltinsDisabledInChild = { + async test(ctrl, env, ctx) { + const instance = await WebAssembly.instantiate(jsStringWasm, {}); + assert.strictEqual(instance.exports.constantLength(), 11); + + for (let extraFlags of [[], ['new_module_registry']]) { + let worker = env.loader.get(`wasmBuiltinsDisabled-${extraFlags}`, () => { + return { + compatibilityDate: '2025-01-01', + compatibilityFlags: extraFlags, + allowExperimental: extraFlags.length > 0, + mainModule: 'main.js', + modules: { + 'main.js': ` + import {WorkerEntrypoint} from "cloudflare:workers"; + import source wasm from './js-string.wasm'; + + export default class extends WorkerEntrypoint { + async instantiate() { + try { + await WebAssembly.instantiate(wasm, {}); + } catch (e) { + return e.name; + } + return 'ok'; + } + } + `, + 'js-string.wasm': jsStringWasm, + }, + }; + }); + + assert.strictEqual( + await worker.getEntrypoint().instantiate(), + 'TypeError' + ); + } + }, +}; diff --git a/src/workerd/api/tests/worker-loader-wasm-test.js b/src/workerd/api/tests/worker-loader-wasm-test.js index e0703e0a0b0..239f1dfcb62 100644 --- a/src/workerd/api/tests/worker-loader-wasm-test.js +++ b/src/workerd/api/tests/worker-loader-wasm-test.js @@ -49,3 +49,42 @@ export let wasmModuleSource = { } }, }; + +// This worker compiles without wasm_esm_integration_builtins, so the module passed to the +// dynamic worker was compiled without the builtins. The child enables the flag, so it must +// recompile from the wire bytes rather than share the parent's compiled code. +import source jsStringWasm from './js-string.wasm'; +export let wasmModuleSourceBuiltinsMismatch = { + async test(ctrl, env, ctx) { + await assert.rejects(WebAssembly.instantiate(jsStringWasm, {}), { + name: 'TypeError', + }); + + for (let extraFlags of [[], ['new_module_registry']]) { + let worker = env.loader.get(`wasmBuiltinsMismatch-${extraFlags}`, () => { + return { + compatibilityDate: '2025-01-01', + compatibilityFlags: ['wasm_esm_integration_builtins', ...extraFlags], + allowExperimental: extraFlags.length > 0, + mainModule: 'main.js', + modules: { + 'main.js': ` + import {WorkerEntrypoint} from "cloudflare:workers"; + import source wasm from './js-string.wasm'; + + export default class extends WorkerEntrypoint { + async constantLength() { + const instance = await WebAssembly.instantiate(wasm, {}); + return instance.exports.constantLength(); + } + } + `, + 'js-string.wasm': jsStringWasm, + }, + }; + }); + + assert.strictEqual(await worker.getEntrypoint().constantLength(), 11); + } + }, +}; diff --git a/src/workerd/api/tests/worker-loader-wasm-test.wd-test b/src/workerd/api/tests/worker-loader-wasm-test.wd-test index d083fdcbd99..969bc2a8995 100644 --- a/src/workerd/api/tests/worker-loader-wasm-test.wd-test +++ b/src/workerd/api/tests/worker-loader-wasm-test.wd-test @@ -7,6 +7,7 @@ const unitTests :Workerd.Config = ( modules = [ (name = "worker", esModule = embed "worker-loader-wasm-test.js"), (name = "math.wasm", wasm = embed "test.wasm"), + (name = "js-string.wasm", wasm = embed "js-string-builtins.wasm"), ], compatibilityFlags = ["nodejs_compat", "experimental"], bindings = [ @@ -14,5 +15,17 @@ const unitTests :Workerd.Config = ( ], ) ), + ( name = "worker-loader-wasm-builtins-test", + worker = ( + modules = [ + (name = "worker", esModule = embed "worker-loader-wasm-builtins-test.js"), + (name = "js-string.wasm", wasm = embed "js-string-builtins.wasm"), + ], + compatibilityFlags = ["nodejs_compat", "experimental", "wasm_esm_integration_builtins"], + bindings = [ + (name = "loader", workerLoader = ()), + ], + ) + ), ], ); diff --git a/src/workerd/api/worker-loader.c++ b/src/workerd/api/worker-loader.c++ index 78d0be261b3..f4c56a2a604 100644 --- a/src/workerd/api/worker-loader.c++ +++ b/src/workerd/api/worker-loader.c++ @@ -244,6 +244,7 @@ static Worker::Script::ModuleContent extractWasmModuleContent( return Worker::Script::WasmModule{ .body = kj::arrayPtr(wireBytes.data(), wireBytes.size()), .compiledModule = kj::mv(compiled), + .compiledWithEsmIntegrationBuiltins = js.isWasmEsmIntegrationBuiltinsEnabled(), }; } diff --git a/src/workerd/io/compatibility-date.capnp b/src/workerd/io/compatibility-date.capnp index e312260dda5..2eb7f9b2e45 100644 --- a/src/workerd/io/compatibility-date.capnp +++ b/src/workerd/io/compatibility-date.capnp @@ -1686,4 +1686,12 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef { # key encapsulation helpers, getPublicKey(), SubtleCrypto.supports(), and AKP JWK support. It is # explicitly gated because the draft is still changing and workerd does not implement the full # proposal. The API may change as the draft evolves. + + wasmEsmIntegrationBuiltins @192 :Bool + $compatEnableFlag("wasm_esm_integration_builtins"); + # Compiles Wasm modules with the js-string builtins and imported string constants enabled, + # equivalent to `new WebAssembly.Module(src, { builtins: ['js-string'], + # importedStringConstants: 'wasm:js/string-constants' })`. The proposal is still in the + # implementation phase, so the feature is opt-in. Modules that don't import the `wasm:*` + # namespaces are unaffected. } diff --git a/src/workerd/io/worker-modules.h b/src/workerd/io/worker-modules.h index 062415c7c6a..aba50f019e4 100644 --- a/src/workerd/io/worker-modules.h +++ b/src/workerd/io/worker-modules.h @@ -75,6 +75,19 @@ jsg::ModuleRegistry::ModuleInfo addCapnpModule( } } // namespace modules::capnp +// The compiled code of a Wasm module provided by another isolate, if that isolate compiled it +// with the same compile options this one would use. +inline kj::Maybe sharedCompiledWasmModule( + const Worker::Script::WasmModule& content, const CompatibilityFlags::Reader& featureFlags) { + KJ_IF_SOME(compiled, content.compiledModule) { + if (content.compiledWithEsmIntegrationBuiltins == + featureFlags.getWasmEsmIntegrationBuiltins()) { + return compiled; + } + } + return kj::none; +} + // Creates an instance of the (new) ModuleRegistry. This method provides the // initialization logic that is agnostic to the Worker::Api implementation, // but accepts a callback parameter to handle the Worker::Api-specific details. @@ -172,7 +185,8 @@ kj::Arc newWorkerModuleRegistry( // (see the ESM comment above for the ownership details). If the module was // already compiled in another isolate, the compiled code is passed along to // avoid recompilation. - bundleBuilder.addWasmModule(def.name, content.body, content.compiledModule); + bundleBuilder.addWasmModule( + def.name, content.body, sharedCompiledWasmModule(content, featureFlags)); break; } KJ_CASE_ONEOF(content, Worker::Script::JsonModule) { @@ -337,7 +351,7 @@ kj::Maybe tryCompileLegacyModule(jsg::Lock& js, } KJ_CASE_ONEOF(content, Worker::Script::WasmModule) { v8::Local wasmModule; - KJ_IF_SOME(compiled, content.compiledModule) { + KJ_IF_SOME(compiled, sharedCompiledWasmModule(content, featureFlags)) { // The module was already compiled in another isolate; share the compiled code rather // than recompiling the wire bytes. auto metrics = observer.onWasmCompilationFromCacheStart(js.v8Isolate); diff --git a/src/workerd/io/worker-source.h b/src/workerd/io/worker-source.h index 31c627df98d..554d130734a 100644 --- a/src/workerd/io/worker-source.h +++ b/src/workerd/io/worker-source.h @@ -58,6 +58,11 @@ struct WorkerSource { // isolate to avoid recompiling. In this case `body` points at the module's wire bytes, which // are owned by the compiled module. kj::Maybe compiledModule; + + // Whether `compiledModule` was compiled with the wasm_esm_integration_builtins compile + // options. Compiled code is only shared with a target whose own setting matches, since the + // setting is baked into the compiled module; otherwise the target recompiles from `body`. + bool compiledWithEsmIntegrationBuiltins = false; }; struct JsonModule { // JSON-encoded content; will be parsed automatically when imported. diff --git a/src/workerd/io/worker.c++ b/src/workerd/io/worker.c++ index 88e31252937..821fb39c5d1 100644 --- a/src/workerd/io/worker.c++ +++ b/src/workerd/io/worker.c++ @@ -1215,6 +1215,9 @@ Worker::Isolate::Isolate(kj::Own apiParam, if (features.getFastJsgStruct()) { lock->setUsingFastJsgStruct(); } + if (features.getWasmEsmIntegrationBuiltins()) { + lock->setWasmEsmIntegrationBuiltinsEnabled(); + } if (impl->inspector != kj::none || ::kj::_::Debug::shouldLog(::kj::LogSeverity::INFO)) { lock->setLoggerCallback([this](jsg::Lock& js, kj::StringPtr message) { diff --git a/src/workerd/jsg/jsg.c++ b/src/workerd/jsg/jsg.c++ index 14d1628c08e..07af979cef9 100644 --- a/src/workerd/jsg/jsg.c++ +++ b/src/workerd/jsg/jsg.c++ @@ -295,6 +295,14 @@ bool Lock::getThrowOnUnrecognizedImportAssertion() const { return IsolateBase::from(v8Isolate).getThrowOnUnrecognizedImportAssertion(); } +void Lock::setWasmEsmIntegrationBuiltinsEnabled() { + IsolateBase::from(v8Isolate).setWasmEsmIntegrationBuiltinsEnabled(); +} + +bool Lock::isWasmEsmIntegrationBuiltinsEnabled() const { + return IsolateBase::from(v8Isolate).isWasmEsmIntegrationBuiltinsEnabled(); +} + void Lock::disableTopLevelAwait() { IsolateBase::from(v8Isolate).disableTopLevelAwait(); } diff --git a/src/workerd/jsg/jsg.h b/src/workerd/jsg/jsg.h index a4456d73213..3171b2b8508 100644 --- a/src/workerd/jsg/jsg.h +++ b/src/workerd/jsg/jsg.h @@ -3108,6 +3108,8 @@ class Lock { void setRequireReturnsDefaultExportEnabled(); void setThrowOnUnrecognizedImportAssertion(); bool getThrowOnUnrecognizedImportAssertion() const; + void setWasmEsmIntegrationBuiltinsEnabled(); + bool isWasmEsmIntegrationBuiltinsEnabled() const; void setToStringTag(); void setImmutablePrototype(); void setSpecCompliantPropertyAttributes(); diff --git a/src/workerd/jsg/modules.c++ b/src/workerd/jsg/modules.c++ index 32dc6e50b85..3e6ec302f35 100644 --- a/src/workerd/jsg/modules.c++ +++ b/src/workerd/jsg/modules.c++ @@ -488,8 +488,13 @@ v8::Local compileWasmModule( // destroy the observer after compilation finishes to indicate the end of the process. auto compilationObserver = observer.onWasmCompilationStart(js.v8Isolate, code.size()); + v8::WasmModuleObject::CompileOptions options; + if (js.isWasmEsmIntegrationBuiltinsEnabled()) { + options.builtins = v8::WasmModuleObject::CompileOptions::Builtins::kJsString; + options.imported_string_constants_module = "wasm:js/string-constants"; + } return jsg::check(v8::WasmModuleObject::Compile( - js.v8Isolate, std::span(code.begin(), code.size()))); + js.v8Isolate, std::span(code.begin(), code.size()), options)); } // ====================================================================================== diff --git a/src/workerd/jsg/setup.h b/src/workerd/jsg/setup.h index 74e78e2f72c..9ddb356cc91 100644 --- a/src/workerd/jsg/setup.h +++ b/src/workerd/jsg/setup.h @@ -400,6 +400,14 @@ class IsolateBase { usingFastJsgStruct = true; } + void setWasmEsmIntegrationBuiltinsEnabled() { + wasmEsmIntegrationBuiltinsEnabled = true; + } + + bool isWasmEsmIntegrationBuiltinsEnabled() const { + return wasmEsmIntegrationBuiltinsEnabled; + } + bool getUsingFastJsgStruct() const { return usingFastJsgStruct; } @@ -558,6 +566,7 @@ class IsolateBase { bool usingNewModuleRegistry = false; bool usingEnhancedErrorSerialization = false; bool usingFastJsgStruct = false; + bool wasmEsmIntegrationBuiltinsEnabled = false; bool extraMicrotaskCheckpointRequested = false; uint moduleEvaluationDepth = 0;