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;