Skip to content

Conversation

@anonrig
Copy link
Member

@anonrig anonrig commented Jan 7, 2026

Fixes #5844 (with the help of our friend Claude)

ES module namespaces have read-only properties by specification, but CommonJS require() is expected to return objects with mutable properties.

@anonrig anonrig requested a review from jasnell January 7, 2026 19:18
@anonrig anonrig requested review from a team as code owners January 7, 2026 19:18
Copy link
Collaborator

@jasnell jasnell left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is likely breaking and I'm not yet convinced this is the correct fix. Specifically, we have the EXPORT_DEFAULT option and corresponding compat flag that is supposed to fix the issue. The problem is that the original implementation of require() returned the module namespace and not the default export. The compat flag is supposed to update that so that it returns the default export, which is the correct behavior. When that compat flag is not set, it's supposed to maintain the original behavior of returning the module namespace. Before applying any other fixes like this here, we need to investigate if/why the EXPORT_DEFAULT option is not being correctly set.

@codspeed-hq

This comment was marked as off-topic.

@anonrig
Copy link
Member Author

anonrig commented Jan 7, 2026

@jasnell I investigated and the EXPORT_DEFAULT mechanism is working correctly - the issue is much more in depth than it initially appeared.

  • EXPORT_DEFAULT is only set for synthetic modules (not for ESM modules)
  • Node built-in modules are ESM modules, not synthetic. They're compiled from TS to JS, so maybeSynthetic == kj::none and EXPORT_DEFAULT is intentionally not set for them.
  • Even if we set EXPORT_DEFAULT for node:timers/promises, the default export of that module will be the namespace object (which is read-only). So we'd still get the same exact error.

I don't see any other way of solving this than the current approach.

@jasnell
Copy link
Collaborator

jasnell commented Jan 7, 2026

As mentioned, the proposed approach is likely breaking. This will require more consideration on what the correct approach is. I'm working on streams stuff at the moment but will be able to come back to this either later today or tomorrow.

@jasnell
Copy link
Collaborator

jasnell commented Jan 8, 2026

Ok, yeah I remember now about the limitations on the existing compat flag. What needs to happen then is introducing another compat flag that essentially says: for any module type, when require() is used, return the default export if it exists, and if it does not, fallback to the behavior you have here. However, if this new compat flag is turned off, the current behavior should be preserved. The general idea is that require should always return the default export, regardless of the module type.

@anonrig anonrig force-pushed the yagiz/make-modules-mutable branch from 41a9458 to f7c4cd0 Compare January 9, 2026 16:15
if (Cloudflare.compatibilityFlags.require_returns_default_export) {
strictEqual(foo, 1);
} else {
strictEqual(foo.default, 1);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/cc @petebacondarwin FYI (wondering if Devin did not write code relying on that)

Copy link
Contributor

@vicb vicb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When #5844 uses require, that's before the ESBuild bundling phase.

So there should be import in the end.

Note that the issue has import based code working in Node and failing in workerd.

So I think that's something we make sure works and should test.

I won't have time to check tonight but can probably find time to check by Monday.

Another option is to merge that but as experimental until we can double check it actually cover all the use cases and only remove exp / set a default date then.

Copy link
Contributor

@vicb vicb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a repro in #5844 (comment)

This PR does not fix it

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:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... is this actually correct tho? Why wouldn't we just recursively grab the default off the returned module namespace and keep going until we either get a non namespace value or nothing. If we do get nothing, then we can fall back to the mutable wrapper.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this is meant to account for cases like in our impl of src/node/timers/promises.ts re-exporting the internal module namespace. I think this approach is a mistake. Instead, we should export a proper default object.

That is, change the impl in timers/promises.ts to something like...

import {
  setTimeout,
  setImmediate,
  setInterval,
  scheduler,
} from 'node-internal:internal_timers_promises';
export * from 'node-internal:internal_timers_promises';
export default {
  setTimeout,
  setImmediate,
  setInterval,
  scheduler,
};

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we go with this approach, whats the best way to avoid regressions?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, it would need to be something like..

import {
  setTimeout,
  setImmediate,
  setInterval,
  scheduler,
} from 'node-internal:internal_timers_promises';
export {
  setTimeout,
  setImmediate,
  setInterval,
  scheduler,
};
export default {
  setTimeout,
  setImmediate,
  setInterval,
  scheduler,
} as typeof import('node:timers/promises');

If we go with this approach, whats the best way to avoid regressions?

Regressions like what?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To make sure we don't return an imported attribute as a export default statement (just like the node:timers/promises one...)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean regressing back to exporting the namespace as the default? We could likely craft an eslint rule for that... but even more basic would just be adding appropriate new tests.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've opened a PR here: #5869

if (defaultObj.isModuleNamespaceObject()) {
KJ_IF_SOME(cached, info.maybeMutableExports) {
return JsValue(cached.getHandle(js));
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you could like simplify a bit by moving the check for the cached mutableExports out of this block and immediately after the compat flag check. The cached value would only have been set if require already happened.

requireReturnsDefaultExport @154 :Bool
$compatEnableFlag("require_returns_default_export")
$compatDisableFlag("require_returns_namespace")
$experimental;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit... once this is not experimental, it should likely be implied by the original compat flag for these cases (exportCommonJsDefaultNamespace)... or have that one implied by this one.

test() {
const timersPromises = require('node:timers/promises');
const originalSetImmediate = timersPromises.setImmediate;
ok(typeof originalSetImmediate === 'function');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommend expanding the test a bit to demonstrate that...

const timersPromises = require('node:timers/promises');
const { default: timersPromises2 } = await import('node:timers/promises');
assert.strictEqual(timersPromises, timersPromises2);

// but that...

const timersPromises3 = await import('node:timers/promises');
assert.notStrictEqual(timersPromises, timersPromises3);

Also, overriding the method on the default export or mutable wrapper should have no impact on original named export on the module namespace. That should be tested also.


export const testTimersPromisesMutable = {
async test() {
const timersPromises = await import('node:timers/promises');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const timersPromises = await import('node:timers/promises');
const { default: timersPromises } = await import('node:timers/promises');

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

🐛 Bug Report — nodeTimersPromises.setImmediate is read-only

4 participants