-
Notifications
You must be signed in to change notification settings - Fork 509
Fix require() to return mutable exports for Node.js compat #5847
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
jasnell
left a comment
There was a problem hiding this 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.
This comment was marked as off-topic.
This comment was marked as off-topic.
|
@jasnell I investigated and the EXPORT_DEFAULT mechanism is working correctly - the issue is much more in depth than it initially appeared.
I don't see any other way of solving this than the current approach. |
|
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. |
|
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. |
a74d0bf to
028bd7b
Compare
6dc810f to
41a9458
Compare
41a9458 to
f7c4cd0
Compare
| if (Cloudflare.compatibilityFlags.require_returns_default_export) { | ||
| strictEqual(foo, 1); | ||
| } else { | ||
| strictEqual(foo.default, 1); |
There was a problem hiding this comment.
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)
vicb
left a comment
There was a problem hiding this 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.
vicb
left a comment
There was a problem hiding this 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: |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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,
};There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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...)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)); | ||
| } |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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'); |
There was a problem hiding this comment.
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'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| const timersPromises = await import('node:timers/promises'); | |
| const { default: timersPromises } = await import('node:timers/promises'); |
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.