diff --git a/src/workerd/api/global-scope.c++ b/src/workerd/api/global-scope.c++ index 3542c4bc714..7525b6e3a81 100644 --- a/src/workerd/api/global-scope.c++ +++ b/src/workerd/api/global-scope.c++ @@ -928,8 +928,11 @@ jsg::JsValue ServiceWorkerGlobalScope::getBuffer(jsg::Lock& js) { // that set the bufferValue, we let's check again. return p.getHandle(js); } - auto def = module.get(js, "default"_kj); - auto obj = KJ_ASSERT_NONNULL(def.tryCast()); + // When requireReturnsDefaultExport flag is enabled, resolveModule returns the + // default export directly. Otherwise it returns the module namespace. + auto obj = module.has(js, "default"_kj) + ? KJ_ASSERT_NONNULL(module.get(js, "default"_kj).tryCast()) + : module; auto buffer = obj.get(js, "Buffer"_kj); JSG_REQUIRE(buffer.isFunction(), TypeError, "Invalid node:buffer implementation"); bufferValue = jsg::JsRef(js, buffer); @@ -970,7 +973,9 @@ jsg::JsValue ServiceWorkerGlobalScope::getProcess(jsg::Lock& js) { // that set the processValue, we let's check again. return p.getHandle(js); } - auto def = module.get(js, "default"_kj); + // When requireReturnsDefaultExport flag is enabled, resolveInternalModule returns the + // default export directly. Otherwise it returns the module namespace. + auto def = module.has(js, "default"_kj) ? module.get(js, "default"_kj) : jsg::JsValue(module); JSG_REQUIRE(def.isObject(), TypeError, "Invalid node:process implementation"); processValue = jsg::JsRef(js, def); return def; diff --git a/src/workerd/api/node/tests/BUILD.bazel b/src/workerd/api/node/tests/BUILD.bazel index e72a154b83a..5118931c1dc 100644 --- a/src/workerd/api/node/tests/BUILD.bazel +++ b/src/workerd/api/node/tests/BUILD.bazel @@ -228,6 +228,18 @@ wd_test( data = ["module-create-require-test.js"], ) +wd_test( + src = "module-require-mutable-exports-test.wd-test", + args = ["--experimental"], + data = ["module-require-mutable-exports-test.js"], +) + +wd_test( + src = "module-import-mutable-test.wd-test", + args = ["--experimental"], + data = ["module-import-mutable-test.js"], +) + wd_test( src = "process-exit-test.wd-test", args = ["--experimental"], diff --git a/src/workerd/api/node/tests/module-create-require-test.js b/src/workerd/api/node/tests/module-create-require-test.js index 724a48465c0..696dc8e1594 100644 --- a/src/workerd/api/node/tests/module-create-require-test.js +++ b/src/workerd/api/node/tests/module-create-require-test.js @@ -14,14 +14,28 @@ export const doTheTest = { const baz = require('baz'); const qux = require('worker/qux'); - strictEqual(foo.default, 1); + // When require_returns_default_export flag is enabled, require() returns the + // default export directly. Otherwise it returns the namespace object. + if (Cloudflare.compatibilityFlags.require_returns_default_export) { + strictEqual(foo, 1); + } else { + strictEqual(foo.default, 1); + } strictEqual(bar, 2); strictEqual(baz, 3); strictEqual(qux, '4'); const assert = await import('node:assert'); const required = require('node:assert'); - strictEqual(assert, required); + + // When require_returns_default_export flag is enabled, require() returns the + // default export directly (assert.default === required). + // When the flag is disabled, require() returns the namespace (assert === required). + if (Cloudflare.compatibilityFlags.require_returns_default_export) { + strictEqual(assert.default, required); + } else { + strictEqual(assert, required); + } throws(() => require('invalid'), { message: 'Top-level await in module is not permitted at this time.', diff --git a/src/workerd/api/node/tests/module-import-mutable-test.js b/src/workerd/api/node/tests/module-import-mutable-test.js new file mode 100644 index 00000000000..2ac832b2c36 --- /dev/null +++ b/src/workerd/api/node/tests/module-import-mutable-test.js @@ -0,0 +1,26 @@ +// Copyright (c) 2017-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 { strictEqual, ok, doesNotThrow } from 'node:assert'; +import timersPromises from 'node:timers/promises'; + +export const testTimersPromisesMutable = { + async test() { + //const { default: timersPromises } = await import('node:timers/promises'); + const originalSetImmediate = timersPromises.setImmediate; + ok(typeof originalSetImmediate === 'function'); + + const patchedSetImmediate = async function patchedSetImmediate() { + return 'patched'; + }; + + doesNotThrow(() => { + timersPromises.setImmediate = patchedSetImmediate; + }); + + strictEqual(timersPromises.setImmediate, patchedSetImmediate); + strictEqual(await timersPromises.setImmediate(), 'patched'); + timersPromises.setImmediate = originalSetImmediate; + strictEqual(timersPromises.setImmediate, originalSetImmediate); + }, +}; diff --git a/src/workerd/api/node/tests/module-import-mutable-test.wd-test b/src/workerd/api/node/tests/module-import-mutable-test.wd-test new file mode 100644 index 00000000000..c782c4aafcd --- /dev/null +++ b/src/workerd/api/node/tests/module-import-mutable-test.wd-test @@ -0,0 +1,14 @@ +using Workerd = import "/workerd/workerd.capnp"; + +const unitTests :Workerd.Config = ( + services = [ + ( name = "module-import-mutable-test", + worker = ( + modules = [ + (name = "worker", esModule = embed "module-import-mutable-test.js") + ], + compatibilityFlags = ["nodejs_compat", "require_returns_default_export"], + ) + ), + ], +); diff --git a/src/workerd/api/node/tests/module-require-mutable-exports-test.js b/src/workerd/api/node/tests/module-require-mutable-exports-test.js new file mode 100644 index 00000000000..ad85a2af260 --- /dev/null +++ b/src/workerd/api/node/tests/module-require-mutable-exports-test.js @@ -0,0 +1,122 @@ +// Copyright (c) 2017-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 { createRequire } from 'node:module'; +import { strictEqual, ok, doesNotThrow } from 'node:assert'; + +const require = createRequire('/'); + +export const testTimersPromisesMutable = { + test() { + const timersPromises = require('node:timers/promises'); + const originalSetImmediate = timersPromises.setImmediate; + ok(typeof originalSetImmediate === 'function'); + + const patchedSetImmediate = async function patchedSetImmediate() { + return 'patched'; + }; + + doesNotThrow(() => { + timersPromises.setImmediate = patchedSetImmediate; + }); + + strictEqual(timersPromises.setImmediate, patchedSetImmediate); + timersPromises.setImmediate = originalSetImmediate; + strictEqual(timersPromises.setImmediate, originalSetImmediate); + }, +}; + +export const testTimersMutable = { + test() { + const timers = require('node:timers'); + const originalSetTimeout = timers.setTimeout; + ok(typeof originalSetTimeout === 'function'); + + const patchedSetTimeout = function patchedSetTimeout() { + return 'patched'; + }; + + doesNotThrow(() => { + timers.setTimeout = patchedSetTimeout; + }); + + strictEqual(timers.setTimeout, patchedSetTimeout); + timers.setTimeout = originalSetTimeout; + }, +}; + +export const testBufferMutable = { + test() { + const buffer = require('node:buffer'); + const originalBuffer = buffer.Buffer; + ok(typeof originalBuffer === 'function'); + + const patchedBuffer = function PatchedBuffer() { + return 'patched'; + }; + + doesNotThrow(() => { + buffer.Buffer = patchedBuffer; + }); + + strictEqual(buffer.Buffer, patchedBuffer); + buffer.Buffer = originalBuffer; + }, +}; + +export const testUtilMutable = { + test() { + const util = require('node:util'); + const originalPromisify = util.promisify; + ok(typeof originalPromisify === 'function'); + + const patchedPromisify = function patchedPromisify() { + return 'patched'; + }; + + doesNotThrow(() => { + util.promisify = patchedPromisify; + }); + + strictEqual(util.promisify, patchedPromisify); + util.promisify = originalPromisify; + }, +}; + +export const testRequireCachesMutableObject = { + test() { + const timersPromises1 = require('node:timers/promises'); + const timersPromises2 = require('node:timers/promises'); + + strictEqual(timersPromises1, timersPromises2); + + const patchedSetImmediate = async function patched() { + return 'patched'; + }; + const original = timersPromises1.setImmediate; + + timersPromises1.setImmediate = patchedSetImmediate; + strictEqual(timersPromises2.setImmediate, patchedSetImmediate); + timersPromises1.setImmediate = original; + }, +}; + +// When require_returns_default_export is enabled, require() should return the +// default export directly (which is the object with all the functions), +// not the namespace wrapper with both `default` and named exports. +export const testRequireReturnsDefaultExport = { + test() { + const timers = require('node:timers'); + // With require_returns_default_export enabled, timers should be the + // default export object directly, not the namespace wrapper. + // The default export IS the object with setTimeout, setInterval, etc. + ok(typeof timers.setTimeout === 'function'); + ok(typeof timers.setInterval === 'function'); + ok(typeof timers.clearTimeout === 'function'); + ok(typeof timers.clearInterval === 'function'); + // The namespace wrapper would have a 'default' property, but when + // we return the default export directly, there's no 'default' property + // on the returned object (unless the default export itself has one). + strictEqual(timers.default, undefined); + }, +}; diff --git a/src/workerd/api/node/tests/module-require-mutable-exports-test.wd-test b/src/workerd/api/node/tests/module-require-mutable-exports-test.wd-test new file mode 100644 index 00000000000..7b1c7e03e96 --- /dev/null +++ b/src/workerd/api/node/tests/module-require-mutable-exports-test.wd-test @@ -0,0 +1,14 @@ +using Workerd = import "/workerd/workerd.capnp"; + +const unitTests :Workerd.Config = ( + services = [ + ( name = "module-require-mutable-exports-test", + worker = ( + modules = [ + (name = "worker", esModule = embed "module-require-mutable-exports-test.js") + ], + compatibilityFlags = ["nodejs_compat", "require_returns_default_export"], + ) + ), + ], +); diff --git a/src/workerd/io/compatibility-date.capnp b/src/workerd/io/compatibility-date.capnp index 2b0d85520e8..37ba916463a 100644 --- a/src/workerd/io/compatibility-date.capnp +++ b/src/workerd/io/compatibility-date.capnp @@ -1316,4 +1316,14 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef { # Node.js-compatible versions from node:timers. setTimeout and setInterval return # Timeout objects with methods like refresh(), ref(), unref(), and hasRef(). # This flag requires nodejs_compat or nodejs_compat_v2 to be enabled. + + requireReturnsDefaultExport @154 :Bool + $compatEnableFlag("require_returns_default_export") + $compatDisableFlag("require_returns_namespace") + $experimental; + # When enabled, require() will return the default export of a module if it exists. + # If the default export does not exist, it falls back to returning the mutable + # module namespace object. This matches the behavior that Node.js uses for + # require(esm) where the default export is returned when available. + # This flag is useful for frameworks like Next.js that expect to patch module exports. } diff --git a/src/workerd/io/worker.c++ b/src/workerd/io/worker.c++ index 32a496462e5..1d9765abec7 100644 --- a/src/workerd/io/worker.c++ +++ b/src/workerd/io/worker.c++ @@ -1107,6 +1107,9 @@ Worker::Isolate::Isolate(kj::Own apiParam, if (features.getEnableNodeJsProcessV2()) { lock->setNodeJsProcessV2Enabled(); } + if (features.getRequireReturnsDefaultExport()) { + lock->setRequireReturnsDefaultExportEnabled(); + } if (features.getThrowOnUnrecognizedImportAssertion()) { lock->setThrowOnUnrecognizedImportAssertion(); } diff --git a/src/workerd/jsg/jsg.c++ b/src/workerd/jsg/jsg.c++ index 6fb57542f86..3197a91e156 100644 --- a/src/workerd/jsg/jsg.c++ +++ b/src/workerd/jsg/jsg.c++ @@ -219,6 +219,10 @@ void Lock::setNodeJsProcessV2Enabled() { IsolateBase::from(v8Isolate).setNodeJsProcessV2Enabled({}, true); } +void Lock::setRequireReturnsDefaultExportEnabled() { + IsolateBase::from(v8Isolate).setRequireReturnsDefaultExportEnabled({}, true); +} + void Lock::setThrowOnUnrecognizedImportAssertion() { IsolateBase::from(v8Isolate).setThrowOnUnrecognizedImportAssertion(); } diff --git a/src/workerd/jsg/jsg.h b/src/workerd/jsg/jsg.h index cab79368322..f4a0d15e314 100644 --- a/src/workerd/jsg/jsg.h +++ b/src/workerd/jsg/jsg.h @@ -2647,6 +2647,7 @@ class Lock { void setNodeJsCompatEnabled(); void setNodeJsProcessV2Enabled(); + void setRequireReturnsDefaultExportEnabled(); void setThrowOnUnrecognizedImportAssertion(); bool getThrowOnUnrecognizedImportAssertion() const; void setToStringTag(); diff --git a/src/workerd/jsg/jsvalue.h b/src/workerd/jsg/jsvalue.h index 61481f4521d..6388b223e25 100644 --- a/src/workerd/jsg/jsvalue.h +++ b/src/workerd/jsg/jsvalue.h @@ -465,6 +465,11 @@ class JsObject final: public JsBase { int hashCode() const; + // Returns true if this object is an ES module namespace object. + bool isModuleNamespaceObject() const { + return inner->IsModuleNamespaceObject(); + } + kj::String getConstructorName() KJ_WARN_UNUSED_RESULT; JsArray getPropertyNames(Lock& js, KeyCollectionFilter keyFilter, diff --git a/src/workerd/jsg/modules-new.c++ b/src/workerd/jsg/modules-new.c++ index 48b57c9b483..a7b917a3b9e 100644 --- a/src/workerd/jsg/modules-new.c++ +++ b/src/workerd/jsg/modules-new.c++ @@ -449,8 +449,6 @@ class IsolateModuleRegistry final { // like the CommonJS require. Returns the instantiated/evaluated module namespace. // If an empty v8::MaybeLocal is returned and the default option is given, then an // exception has been scheduled. - // Note that this returns the module namespace object. In CommonJS, the require() - // function will actually return the default export from the module namespace object. v8::MaybeLocal require( Lock& js, const ResolveContext& context, RequireOption option = RequireOption::DEFAULT) { static constexpr auto evaluate = [](Lock& js, Entry& entry, const Url& id, @@ -1600,6 +1598,13 @@ JsValue ModuleRegistry::resolve(Lock& js, ResolveContext::Source source, kj::Maybe maybeReferrer) { KJ_IF_SOME(ns, tryResolveModuleNamespace(js, specifier, type, source, maybeReferrer)) { + // When require_returns_default_export flag is enabled and the caller wants + // the "default" export, return the default export directly instead of extracting + // it from the namespace. This matches Node.js require() behavior. + // See: https://github.com/cloudflare/workerd/issues/5844 + if (isRequireReturnsDefaultExportEnabled(js) && exportName == "default"_kj) { + return ns.get(js, "default"_kj); + } return ns.get(js, exportName); } JSG_FAIL_REQUIRE(Error, kj::str("Module not found: ", specifier)); diff --git a/src/workerd/jsg/modules.c++ b/src/workerd/jsg/modules.c++ index 33d6f1e4318..9595b07688c 100644 --- a/src/workerd/jsg/modules.c++ +++ b/src/workerd/jsg/modules.c++ @@ -4,6 +4,7 @@ #include "jsg.h" #include "setup.h" +#include "util.h" #include @@ -527,7 +528,45 @@ JsValue ModuleRegistry::requireImpl(Lock& js, ModuleInfo& info, RequireImplOptio js.v8Context(), v8StrIntern(js.v8Isolate, "default")))); } - return JsValue(module->GetModuleNamespace()); + // When the flag is disabled, return the original module namespace + // to maintain backward compatibility (same object as ESM import returns). + if (!isRequireReturnsDefaultExportEnabled(js)) { + return JsValue(module->GetModuleNamespace()); + } + + // When require_returns_default_export flag is enabled: + // 1. If module has default export: return it (or a mutable copy if it's a namespace) + // 2. If no default export: return a mutable copy of the namespace + // This matches Node.js require(esm) behavior and allows monkey-patching. + // See: https://github.com/cloudflare/workerd/issues/5844 + JsObject moduleNamespace(module->GetModuleNamespace().As()); + if (moduleNamespace.has(js, "default"_kj)) { + auto defaultValue = moduleNamespace.get(js, "default"_kj); + // If the default export is itself a module namespace object (read-only), + // we need to create a mutable copy. This happens when a module does: + // import * as _default from 'other-module'; + // export default _default; + KJ_IF_SOME(defaultObj, defaultValue.tryCast()) { + if (defaultObj.isModuleNamespaceObject()) { + KJ_IF_SOME(cached, info.maybeMutableExports) { + return JsValue(cached.getHandle(js)); + } + auto mutableDefault = createMutableModuleExports(js, defaultObj); + info.maybeMutableExports = V8Ref(js.v8Isolate, mutableDefault); + return mutableDefault; + } + } + // Default export is a regular object (mutable), return it directly + return defaultValue; + } + + // No default export - return a mutable copy of the namespace. + KJ_IF_SOME(cached, info.maybeMutableExports) { + return JsValue(cached.getHandle(js)); + } + auto mutableExports = createMutableModuleExports(js, moduleNamespace); + info.maybeMutableExports = V8Ref(js.v8Isolate, mutableExports); + return mutableExports; } } // namespace workerd::jsg diff --git a/src/workerd/jsg/modules.h b/src/workerd/jsg/modules.h index fc4dd879112..79098073032 100644 --- a/src/workerd/jsg/modules.h +++ b/src/workerd/jsg/modules.h @@ -150,6 +150,12 @@ class ModuleRegistry { // For source phase imports - stores the module source object (e.g., WebAssembly.Module) kj::Maybe> maybeModuleSourceObject; + // Cache for mutable module exports wrapper when require_returns_default_export flag is enabled. + // Used to ensure require() returns the same mutable object for the same module. + // This enables frameworks like Next.js to patch built-in module exports. + // See: https://github.com/cloudflare/workerd/issues/5844 + mutable kj::Maybe> maybeMutableExports; + ModuleInfo(jsg::Lock& js, v8::Local module, kj::Maybe maybeSynthetic = kj::none); diff --git a/src/workerd/jsg/setup.h b/src/workerd/jsg/setup.h index 69aebb0460a..ac0e7ce4acf 100644 --- a/src/workerd/jsg/setup.h +++ b/src/workerd/jsg/setup.h @@ -155,6 +155,10 @@ class IsolateBase { nodeJsProcessV2Enabled = enabled; } + inline void setRequireReturnsDefaultExportEnabled(kj::Badge, bool enabled) { + requireReturnsDefaultExportEnabled = enabled; + } + inline bool areWarningsLogged() const { return maybeLogger != kj::none; } @@ -170,6 +174,10 @@ class IsolateBase { return nodeJsProcessV2Enabled; } + inline bool isRequireReturnsDefaultExportEnabled() const { + return requireReturnsDefaultExportEnabled; + } + inline bool shouldSetToStringTag() const { return setToStringTag; } @@ -339,6 +347,7 @@ class IsolateBase { bool asyncContextTrackingEnabled = false; bool nodeJsCompatEnabled = false; bool nodeJsProcessV2Enabled = false; + bool requireReturnsDefaultExportEnabled = false; bool setToStringTag = false; bool shouldSetImmutablePrototypeFlag = false; bool allowTopLevelAwait = true; diff --git a/src/workerd/jsg/util.c++ b/src/workerd/jsg/util.c++ index 9c5516493f3..12fc0f5a815 100644 --- a/src/workerd/jsg/util.c++ +++ b/src/workerd/jsg/util.c++ @@ -848,6 +848,21 @@ v8::Local newExternalTwoByteString(Lock& js, kj::ArrayPtr checkNodeSpecifier(kj::StringPtr specifier); bool isNodeJsCompatEnabled(jsg::Lock& js); bool isNodeJsProcessV2Enabled(jsg::Lock& js); +bool isRequireReturnsDefaultExportEnabled(jsg::Lock& js); // The following counter is used to track the number of times a method is called. // This is mostly useful for validating/testing v8 fast api methods, but also for