diff --git a/src/workerd/api/tests/wasm-source-url-test.wd-test b/src/workerd/api/tests/wasm-source-url-test.wd-test index 9f4138dd394..a7c258b5184 100644 --- a/src/workerd/api/tests/wasm-source-url-test.wd-test +++ b/src/workerd/api/tests/wasm-source-url-test.wd-test @@ -4,14 +4,16 @@ using Workerd = import "/workerd/workerd.capnp"; # must recompile it rather than keep this worker's URL. const unitTests :Workerd.Config = ( services = [ - # With the flag, the module's frames name it by its module name. + # With the flag and the legacy registry, the module's frames name it by its module name. ( name = "wasm-source-url-test", worker = ( modules = [ (name = "worker", esModule = embed "wasm-source-url-test.js"), (name = "trap.wasm", wasm = embed "wasm-source-url.wasm"), ], - compatibilityFlags = ["nodejs_compat", "experimental", "wasm_source_url_support"], + compatibilityFlags = [ + "nodejs_compat", "experimental", "wasm_source_url_support", "legacy_module_registry", + ], bindings = [ (name = "expectedFrame", text = "at trap.wasm:wasm-function[0]:0x"), (name = "childFlag", text = "no_wasm_source_url_support"), @@ -45,7 +47,9 @@ const unitTests :Workerd.Config = ( (name = "worker", esModule = embed "wasm-source-url-test.js"), (name = "trap.wasm", wasm = embed "wasm-source-url.wasm"), ], - compatibilityFlags = ["nodejs_compat", "experimental", "no_wasm_source_url_support"], + compatibilityFlags = [ + "nodejs_compat", "experimental", "no_wasm_source_url_support", "legacy_module_registry", + ], bindings = [ (name = "expectedFrame", text = "at wasm://wasm/"), (name = "childFlag", text = "wasm_source_url_support"), diff --git a/src/workerd/io/compatibility-date-test.c++ b/src/workerd/io/compatibility-date-test.c++ index ede8f3eed6b..90454b8d563 100644 --- a/src/workerd/io/compatibility-date-test.c++ +++ b/src/workerd/io/compatibility-date-test.c++ @@ -589,6 +589,33 @@ KJ_TEST("encode to full flag list") { } } +KJ_TEST("new module registry defaults on at 2026-11-17") { + auto compileFlags = [](kj::StringPtr date, kj::ArrayPtr flags) { + capnp::MallocMessageBuilder message; + auto flagList = message.initRoot>(flags.size()); + for (auto i: kj::indices(flags)) { + flagList.set(i, flags[i]); + } + auto output = message.getOrphanage().newOrphan(); + SimpleWorkerErrorReporter errorReporter; + compileCompatibilityFlags(date, flagList.asReader(), output.get(), errorReporter, + /*allowExperimentalFeatures=*/false, CompatibilityDateValidation::FUTURE_FOR_TEST, nullptr); + KJ_ASSERT(errorReporter.errors.empty()); + return capnp::clone(output.getReader()); + }; + + auto before = compileFlags("2026-11-16", {}); + KJ_EXPECT(!isNewModuleRegistryEnabled(*before)); + auto enabled = compileFlags("2026-11-17", {}); + KJ_EXPECT(isNewModuleRegistryEnabled(*enabled)); + auto optedIn = compileFlags("2026-11-16", {"new_module_registry"_kj}); + KJ_EXPECT(isNewModuleRegistryEnabled(*optedIn)); + auto optedOut = compileFlags("2026-11-17", {"legacy_module_registry"_kj}); + KJ_EXPECT(!isNewModuleRegistryEnabled(*optedOut)); + auto python = compileFlags("2026-11-17", {"python_workers"_kj}); + KJ_EXPECT(!isNewModuleRegistryEnabled(*python)); +} + KJ_TEST("compatibility dates must be Tuesday, Wednesday, or Thursday") { // List of specific flags that are allowed to use non-conformant dates // (already deployed and can't be changed for compatibility reasons) @@ -766,7 +793,7 @@ KJ_TEST("isNewModuleRegistryEnabled ignores the flag for Python workers") { KJ_EXPECT(check(true, false)); // Python workers always use the original module registry: the // new_module_registry flag is ignored for them, whether set explicitly or - // (once the flag has a default-on date) implied by the compatibility date. + // implied by the compatibility date. KJ_EXPECT(!check(true, true)); KJ_EXPECT(!check(false, true)); } diff --git a/src/workerd/io/compatibility-date.capnp b/src/workerd/io/compatibility-date.capnp index 62c6512cc09..30aa1ce279f 100644 --- a/src/workerd/io/compatibility-date.capnp +++ b/src/workerd/io/compatibility-date.capnp @@ -513,7 +513,8 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef { newModuleRegistry @52 :Bool $compatEnableFlag("new_module_registry") - $compatDisableFlag("legacy_module_registry"); + $compatDisableFlag("legacy_module_registry") + $compatEnableDate("2026-11-17"); # Enables the new module registry implementation, which handles module # specifiers as URLs, implements import.meta (url/main/resolve), supports # import attributes, and allows a registry to be shared across isolate diff --git a/src/workerd/io/features.h b/src/workerd/io/features.h index 12e42ed3250..a2deaab9f17 100644 --- a/src/workerd/io/features.h +++ b/src/workerd/io/features.h @@ -67,10 +67,10 @@ inline void configureJsgIsolate(jsg::Lock& js, CompatibilityFlags::Reader flags) // Returns whether the new module registry implementation should be used for a // worker with the given flags. Python workers always use the original module // registry: the new_module_registry flag is ignored for them, whether it was -// listed explicitly or (once the flag gains an enable date) implied by the -// worker's compatibility date. Every decision about which registry to use MUST -// go through this function rather than reading getNewModuleRegistry() directly; -// otherwise a Python worker could end up split across the two registries. +// listed explicitly or implied by the worker's compatibility date. Every decision +// about which registry to use MUST go through this function rather than reading +// getNewModuleRegistry() directly; otherwise a Python worker could end up split +// across the two registries. // // Note that Cloudflare.compatibilityFlags.new_module_registry (as observed by // user code) still reflects the configured flag value, not this function's diff --git a/src/workerd/server/factory/worker-factory.c++ b/src/workerd/server/factory/worker-factory.c++ index 7ccb5a7676e..decc1ad8e64 100644 --- a/src/workerd/server/factory/worker-factory.c++ +++ b/src/workerd/server/factory/worker-factory.c++ @@ -1567,13 +1567,14 @@ kj::Promise> factory_new_worker(const WorkerFactory& con // The file system roots use their defaults; the config does not expose mount points. auto workerFs = newWorkerFileSystem(kj::heap(), getBundleDirectory(workerSource)); + KJ_REQUIRE(experimental || moduleFallback == kj::none, + "The module fallback service is an experimental feature. " + "You must run workerd with `--experimental` to use the module fallback service."); + // Python workers never use the new module registry, whatever their flags say. bool usingNewModuleRegistry = isNewModuleRegistryEnabled(featureFlags); kj::Maybe> newModuleRegistry; if (usingNewModuleRegistry) { - KJ_REQUIRE(experimental, - "The new ModuleRegistry implementation is an experimental feature. " - "You must run workerd with `--experimental` to use this feature."); // Module URLs follow the virtual file system: a module at "/foo/bar/baz.js" in the bundle // is "file:///foo/bar/baz.js". const jsg::Url& bundleBase = workerFs->getBundleRoot(); @@ -1670,9 +1671,6 @@ kj::Promise> factory_new_worker(const WorkerFactory& con if (!usingNewModuleRegistry) { KJ_IF_SOME(fallback, moduleFallback) { - KJ_REQUIRE(experimental, - "The module fallback service is an experimental feature. " - "You must run workerd with `--experimental` to use the module fallback service."); auto& apiIsolate = isolate->getApi(); apiIsolate.setModuleFallbackCallback( [client = kj::heap(kj::str(fallback)), diff --git a/src/workerd/server/server-test.c++ b/src/workerd/server/server-test.c++ index 3b6ee815a79..c58b2caaf86 100644 --- a/src/workerd/server/server-test.c++ +++ b/src/workerd/server/server-test.c++ @@ -412,6 +412,9 @@ class Server: public kj::PtrTarget { void setDefaultCompatibilityDate(kj::String date) { options->defaultCompatibilityDate = kj::mv(date); } + void setTestCompatibilityDateOverride(kj::String date) { + options->testCompatibilityDateOverride = kj::mv(date); + } void enableDebugPort(kj::String addr) { debugPort = kj::str("loopback:", addr); } @@ -1033,6 +1036,27 @@ KJ_TEST("Server: serve Service Worker using the new module registry") { conn.httpGet200("/service-worker", "NMR: http://foo/service-worker"); } +KJ_TEST("Server: module registry enabled by date starts without experimental mode") { + TestServer test(singleWorker(R"(( + modules = [ + ( name = "worker", + esModule = + `import message from "./message.js"; + `export default { + ` fetch() { return new Response(message + ":" + import.meta.main); } + `} + ), + ( name = "message.js", esModule = "export default 'date-default';" ) + ] + ))"_kj)); + + test.server.setTestCompatibilityDateOverride(kj::str("2026-11-17")); + test.start(); + + auto conn = test.connect("test-addr"); + conn.httpGet200("/", "date-default:true"); +} + // Note: Python workers ignore the new_module_registry flag entirely — they // always use the original module registry. That decision is centralized in // isNewModuleRegistryEnabled() (io/features.h) and unit-tested in diff --git a/src/workerd/server/tests/module-fallback/module-fallback-test.mjs b/src/workerd/server/tests/module-fallback/module-fallback-test.mjs index 526aa4f7dbd..69510893f6d 100644 --- a/src/workerd/server/tests/module-fallback/module-fallback-test.mjs +++ b/src/workerd/server/tests/module-fallback/module-fallback-test.mjs @@ -88,12 +88,12 @@ function sendJson(response, value) { response.end(JSON.stringify(value)); } -async function runWorkerd(configPath) { +async function runWorkerd(configPath, extraArgs = ['--experimental']) { return await new Promise((resolve, reject) => { let output = ''; const child = spawn( env.WORKERD_BINARY, - ['test', configPath, '--experimental'], + ['test', configPath, ...extraArgs], { stdio: ['ignore', 'pipe', 'pipe'] } ); const timeout = setTimeout(() => { @@ -114,6 +114,48 @@ async function runWorkerd(configPath) { }); } +test('module fallback requires experimental mode with either registry', async (t) => { + const directory = await mkdtemp( + join(env.TEST_TMPDIR, 'module-fallback-gate-') + ); + const cases = [ + ['date-default', '2026-11-17', []], + ['explicit-opt-in', '2026-11-16', ['new_module_registry']], + ['legacy-opt-out', '2026-11-17', ['legacy_module_registry']], + ['legacy-default', '2026-11-16', []], + ]; + + for (const [name, date, flags] of cases) { + await t.test(name, async () => { + const configPath = join(directory, `${name}.capnp`); + await writeFile( + configPath, + `using Workerd = import "/workerd/workerd.capnp"; +const config :Workerd.Config = ( + services = [( + name = "worker", + worker = ( + compatibilityFlags = ${JSON.stringify(flags)}, + moduleFallback = "127.0.0.1:1", + modules = [( + name = "worker.js", + esModule = "export const check = { test() {} };" + )] + ) + )] +);` + ); + + const result = await runWorkerd(configPath, [`--compat-date=${date}`]); + assert.notEqual(result.code, 0, result.output); + assert.match( + result.output, + /The module fallback service is an experimental feature/ + ); + }); + } +}); + test('module fallback serves V1 and V2 module types', async () => { const requests = []; const handlerErrors = [];