Skip to content
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

Support .mjs output #18442

Closed
demurgos opened this issue Sep 13, 2017 · 203 comments
Closed

Support .mjs output #18442

demurgos opened this issue Sep 13, 2017 · 203 comments
Labels
Domain: ES Modules The issue relates to import/export style module behavior In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@demurgos
Copy link

demurgos commented Sep 13, 2017

Experimental support for ES modules just landed in Node 8.5 (changelog, PR).
Since ES modules have some parsing and semantic differences, Node decided to use the mjs extension for ES modules (while js is for the "script" target and commonjs modules).

The current Typescript version (2.5.2) supports ES modules emission but uses the js extension by default. It means that to use it with Node, a post-compilation step is required to change the extension from js to mjs. This adds undesirable complexity to use native ES modules support with Node.

A solution would be to add a compiler option to output *.mjs files when emitting ES modules.

Edit (2018-03-22): The propositions below are a bit outdated. I recommend reading the issue to see the progression. See this comment for my current proposition.

Notes:

  • It is possible that new parse goals will be added in the next versions of the ES spec. Letting the user provide the extension explicitly (as opposed to a boolean switch) would allow to be forward compatible with other new extension.
  • It should still be possible to emit ES modules with the *.js extension, many tools rely on the js extension.
  • Another solution would be to have a different extension for the source files: *.mts files would compile to *.mjs, this would be similar to *.tsx and *.jsx.
@kitsonk
Copy link
Contributor

kitsonk commented Sep 13, 2017

See #10939 (and #9839, #9551, #7926, #7699 and #9670) and this comment.

@demurgos
Copy link
Author

@kitsonk
Thanks for posting these link but I am not sure if they are relevant, could you explain them? I found these while checking if the issue was already opened: most of the issues you listed are about input files. This issue is about compiling ts files to mjs instead of js (adding an options to change the compiler output).

@kitsonk
Copy link
Contributor

kitsonk commented Sep 14, 2017

Basically those say that TypeScript does not want to concern itself with managing extensions like that, as it is already complicated enough. In particular the output extensions run afoul of the TypeScript non-goal of:

  1. Provide an end-to-end build pipeline. Instead, make the system extensible so that external tools can use the compiler for more complex build workflows.

If you want .mjs files, it would be best to do something like:

$ npm install renamer -g
$ renamer -regex --find '\.js^' --replace '.mjs' './outDir/**/*.js'

@DanielRosenwasser DanielRosenwasser added In Discussion Not yet reached consensus Suggestion An idea for TypeScript labels Sep 14, 2017
@demurgos
Copy link
Author

demurgos commented Sep 15, 2017

Thank you very much for clarifying your comment.
I understand that adding any new feature is a burden since it means that it then must be supported for a long time, but I believe that the benefits of the mjs support are worth it: it won't turn tsc in a complex build tool but help TS users.

The long term goal of the Node team is to allow users to author their code using ES modules without paying a cost due to the commonJs modules and script target. This is one of the reasons why the proposals to require "use module" or an export statement (even an empty one) were dropped in favour of using a new extension. See the Node EPS discussions in nodejs/node-eps#57 and nodejs/node-eps#60. If a post-compilation step to rename the files is still required in two years only because .mjs was introduced later then it will just add another burden to remember for years (like the BOM or the different line endings)... Even if there are some discussions to support ES modules with the .js extension ("module" property), the current position is that - if such support is added - it should be used as a fallback for old tooling. .mjs is promoted for new code.

Complex build tools have their place for assets management, dead-code elimination, bundling, minification, etc. These all fall out of scope for TS, but being able to produce runnable code does not. Big projects have their own workflows and use the TS library (either directly or through plugins for other task runners), adding the renaming step is not a big deal for them. On the other hand, a sizeable chunk of projects using Typescript are small/medium sized libraries. These libraries usually only need a single tool: tsc. If they want to support native ES modules, they'll currently have to come up with a command similar to the one posted above by @kitsonk. The problems are that it raises the barrier to entry, causes duplicate effort and makes the build more expensive.

It raises the barrier to entry because newcomers can no longer simply use tsc -p && node index.mjs (--experimental-modules will no longer be required when the use-case for this issue will be relevant) and run their code to try out Typescript. For existing projects, the "build and run" command is usually already defined as an npm script or gulp/grunt/webpack/whatever task, writing this command (or understanding it) will be more difficult: for some persons it will be trivial, for others it'll require a few hours of research. The rate of change in the JS ecosystem is already pretty high, let's try to not worsen it.
Now, a related problem is that it may cause some inconsistencies and duplication: different projects will use different ways to rename their file. I discovered renamer thanks to the message above, but I would have written a gulp task otherwise. Someone else would have used a POSIX-only shell command with broken edge cases or rolled their own helper Node script. To be honest, I think that most good projects would have a good implementation but this lack of standard way to deal with file renames when no build system is already there may increase the "barrier to entry" problem.
Finally, once the manual renaming is configured, it still represents an additional cost. I don't have any measures but for medium projects and using Node to rename the files, it may be longer to start the VM than running the file rename: why not doing it in the same process as TS? Also: what about --watch? How do I make it play well with my custom command? Do you have to have to watch the build directory to rename as the files are emitted?

So far, most of my arguments were about the build complexity: having it done once in TS is better than having each project coming up with its own solution. There is another important reason to have ES modules working out of the box, and I think that it aligns with the goals of TS:

  1. Align with current and future ECMAScript proposals.
  2. Preserve runtime behavior of all JavaScript code.

ES modules are part of the spec and the TS syntax uses them. These modules have a specified runtime behaviour that cannot be fully replicated in commonJs. It affects among other things circular dependencies, early errors, mutation of exported namespace, etc. Many people won't care, until it bites them. When setting the module option to commonjs, TS does a pretty good job of generating an output with the expected behaviour but it does not trump using real ES modules. So we are in a situation where we can either set an option and have a good approximation using commonJs but if we want the exact spec and runtime behavior we have to jump through hoops and loops.

One last argument is that even if browsers support native ES modules without requiring .mjs, real-world usage of native ES modules will start server-side with Node because you can control its version. Node is important for the whole ecosystem so it would be damageable to just ignore that it uses .mjs for ES modules. You can't just treat it as a "platform detail" when this platform is used by the majority of the TS audience.

To summarize, here are my main points:

  • Now that native support exists for ES modules, people will want to use it out-of-the-box.
  • Node settled on .mjs and hopes that it'll gain traction. .mjs is Node-specific, but Node is important
  • Many libraries only need tsc, requiring a second tool for renaming increases barrier to entry and causes duplicated effort (of varying quality).
  • Using a post-compilation command leads to higher build times and does not play well with the watch mode.
  • Using native ES modules is the only way to match the spec exactly, TS should not make it hard to use

@SMotaal
Copy link

SMotaal commented Sep 28, 2017

Just an Opinion

As a very strong fan of TypeScript, and obviously a user of node (I guess even TypeScript is) I would really like to see some agreement between them on an issue that has had so much disagreements over many years of rational and sometimes awkward discussions.

I would have loved it if node had a transition phase to make common js (the non-standard) become .cjs over a period of two years.

That said, mjs seems to be the current direction, obviously that non-js file extension (either one) itself does not matter, but the ability for TypeScript to provide a way to control this inline as the originator of the transpiled files is something that really falls to TypeScript.

That said (again), mjs as a locked extension is kind of very opinionated, someone must have realized that forcing js was okay, it was already called js, but forcing mjs, come on, even my spelling checker is nagging me on that one. I hope nodejs will become a little more flexible on that one.

Proposal

Please make it possible to specify mjs as a module format that simply means compiling es2015 modules and calling them mjs :)

@trxcllnt
Copy link

trxcllnt commented Oct 3, 2017

It's important to note here that .mjs isn't only about file extensions. running node with the
--experimental-modules flag also means that ES6 module specifiers must be valid urls, so the extension has to be included in the import statement, e.g. import x from './x.mjs

If I'm understanding this right, renaming the files isn't a solution, TS would also need to compile module specifiers with the .mjs extension in the ES2015+/mjs mode. We're keen to support newer versions of node by publishing non-downleveled iterators as es2015+ modules for IxJS, so it would be great to get an answer on this soon.

@dpogue
Copy link

dpogue commented Oct 3, 2017

TypeScript already allows you to include a .js extension on your imports (even when you're actually importing .ts TypeScript files). The change needed would be to expand that to also allow .mjs.

@trxcllnt
Copy link

trxcllnt commented Oct 3, 2017

It's not about the source files including the extension, it's about the compiled files.

If I compile import x from './x' into ES5/CommonJS, TS emits:

var x = require("./x");

If I compile it to ES2015/ESModules with an .mjs extension, TS should emit:

import x from "./x.mjs"

If a library is trying to support both old and new node with CommonJS and ESM side-by-side (as *.js and *.mjs extensions respectively), node only imports the .mjs files if the extension is included in the module specifier.

@SMotaal
Copy link

SMotaal commented Oct 3, 2017

There are some potential updates in the works… including a --loader option that would you to specify a file with a resolve hook that will take the specifier and parent module path, then it could handle extension prioritization as needed. It seems to be very flexible but their goal is to make it declarative and not have people overloading the actual loader functions.

In terms of picking mjs over js, that is only the case in the CJS loader system which uses the now legacy Module prototype with it's hooks, the ones that everyone likes override all the time.

In the current --experimental-modules release, the ESM loader is hard coded to '.mjs' or falls back to the CJS loader.

What I've seen so far over the past few days makes me believe that the ESM loading system will take over with pluggable CJS loading (unless opting to use the legacy-modules by flag or based on the main file).

Check out this PR/discussion

@SMotaal
Copy link

SMotaal commented Oct 3, 2017

@trxcllnt I guess regarding the part about baking the extension right into the import… this applies to import/export everywhere now as part of loader specs.

When ES2015 modules were a spec'd, there were no loader specs, the notion that the extension can change from source file to .js made it a more natural way to go and everyone got too comfortable there. But all loaders (especially web browsers) realized that no-extension means potential security and not to mention load-time drawbacks.

So if TypeScript would continue to work without third-party tooling, it will need to find a strategy to write out standard (not ISO but platform-specific) out-of-the-"compiler"-box projects that will just work in either node or browsers at least depending on the compilerOptions intent specified by the user, and do so without asking the user to mockup some hack to get it to work.

This is just an opinion, but honestly, it feels like it for TypeScript to address.

Checkout this MDN reference but make sure you notice the part where it first said "excluding the .js extension" then in the examples included the extension anyway.

@bmeck
Copy link

bmeck commented Oct 3, 2017

I'd like to include here that the IANA has an Internet Draft which specifically adds .mjs for COMMON usage that represents the Module goal of ECMAScript . .mjs is not purely a Node.js concern and is even included in an example for browser specs and is supported by a variety MIME DBs already like shared-mime-info.

@daflair can you expand on what

(unless opting to use the legacy-modules by flag or based on the main file).

means?

The next step after that PR is to setup per package loaders to be able to guard against global loader mutation. So, it might be going the direction you think.

When ES2015 modules were a spec'd, there were no loader specs, the notion that the extension can change from source file to .js made it a more natural way to go and everyone got too comfortable there. But all loaders (especially web browsers) realized that no-extension means potential security and not to mention load-time drawbacks.

Interestingly the WHATWG Loader Spec had a very early version in the ECMAScript spec that was removed at the last minute before ES2015! There are interesting other things like the original CommonJS Spec which mandated not to have extensions. We should probably avoid dwelling on the past so much since the different loaders vary so much on these opinions.

Node's EP specced a superset of the WHATWG resolve algorithm that does do various Node idioms like file extension completion. However, the browser has a subset that is safe to use in Node. Still, even with that subset there are problems with dependency trees since "bare" imports are waiting on userland feedback (you can get involved in the hook here), but is mostly left up to intelligent servers and service workers for now.

I'd recommend trying to compile down to the WHATWG compatible specifiers except for bare specifiers for now.

@SMotaal
Copy link

SMotaal commented Oct 3, 2017

@bmeck what I meant by:

unless opting to use the legacy-modules by flag or based on the main file

I was making an assumption that at some point you will have to use something like --disable-es-modules or --legacy-modules which is really an option that simply does the opposite of --experimental-modules (when this flag is the default behaviour) to resort the current loader system and completely bypass anything related to the new loader (ie legacy applications that simply find ways to be incompatible with this existential change to their eco system).

But now that I dug in a little deeper in your recent PR's this might not be your intent…

So it is best to ask you about your intent here 😉?

@bmeck
Copy link

bmeck commented Oct 3, 2017

@daflair I'm still not understanding. ESM support in --experimental-modules completely avoids touching CJS except by overtaking the defaulting of .mjs to CJS (it now throws). I can't think of a reason to introduce such a flag. Also, it would be a very hard sell to change the default behavior from CJS to ESM or vice versa and I doubt that will ever happen.

@SMotaal
Copy link

SMotaal commented Oct 3, 2017

Okey, when we get to the point where --experimental-modules is no longer needed, at that point, will the CJS loading system that exists today still remain (mostly) unchanged?

If so, at that point, without the above flag and without any other flags like --loader, …etc :

a. Would running with a .mjs entry do what it does today with the flag and the CJS loader will simply pass it to the ESM loader? meaning that all dependencies will be marshalled by the ESM loader (even if it delegates something to the CJS loader).

b. Would running with a .js entry always imply CJS (again no special flags) and this also means that ESM loader is essentially completely idle for the lifespan of the process (assuming there will be no require() support for es-modules in the future).

@bmeck
Copy link

bmeck commented Oct 3, 2017

Okey, when we get to the point where --experimental-modules is no longer needed, at that point, will the CJS loading system that exists today still remain (mostly) unchanged?

Yes, it is unchanged except the .mjs reservation.

a. Would running with a .mjs entry do what it does today with the flag and the CJS loader will simply pass it to the ESM loader? meaning that all dependencies will be marshalled by the ESM loader (even if it delegates something to the CJS loader).

No, the 2 loaders are decoupled except when ESM defers to CJS. Loading via import will always go through the --loader hooks. Loading via require will not go through the --loader hooks. Use import for green code going forward.

b. Would running with a .js entry always imply CJS (again no special flags) and this also means that ESM loader is essentially completely idle for the lifespan of the process (assuming there will be no require() support for es-modules in the future).

Yes .js is CJS. However, you can use import() to get a hold of ESM since that is available in all JS (even eval).

@mhegazy mhegazy added the Domain: ES Modules The issue relates to import/export style module behavior label Oct 3, 2017
@kitsonk
Copy link
Contributor

kitsonk commented Oct 3, 2017

And people wonder why the TypeScript team wants to avoid getting into this area at the moment... 🙄 No it is, yes it isn't, no it is Even if TypeScript gets further into an extension mangling business it would wait until it was clear that --experimental would be retired as a flag and established as a default with appropriate battle hardened semantics.

@bmeck
Copy link

bmeck commented Oct 4, 2017

@kitsonk

🙄 No it is, yes it isn't, no it is

can you clarify what is unclear in the messaging from Node?

@trxcllnt
Copy link

trxcllnt commented Oct 4, 2017

@bmeck so one thing that seems like a major foot-gun is how node doesn't automatically select the mjs files when importing a module with --experimental-modules mode on. I understand the story on importing ESModules from the node_modules folder isn't complete yet (should it look for the mjs file path from "module" in package.json? who knows). But automatically selecting the CommonJS form of a module then converting its module.exports object to the default export means that code that works fine in other ESM environments is broken in node:

// node_modules/ix/Ix.mjs (no default export)
export class Iterable {};
// node_modules/ix/Ix.js:
module.exports = function Iterable() {};
// run with node --experimental-modules some_file.mjs
// works fine
import { default as Ix, Iterable } from 'ix/Ix.mjs';
assert(typeof Ix == 'undefined') // true, great
assert(typeof Iterable == 'function') // true, great
// broken
import { default as Ix, Iterable } from 'ix';
assert(typeof Ix == 'undefined') // false?
assert(typeof Iterable == 'function') // false :(

@bmeck
Copy link

bmeck commented Oct 4, 2017

@trxcllnt it is complete and has been laid out in depth in https://github.com/nodejs/node-eps/blob/master/002-es-modules.md

should it look for the mjs file path from "module" in package.json?

no.

But automatically selecting the CommonJS form of a module then converting its module.exports object to the default export means that code code that works fine in other ESM environments is broken in node

Correct, because this code cannot be spec compliant. There is absolutely no way to link it ahead of time since you need to eval to get the shape of module.exports. Emulations using CJS semantics like babel or typescript are not enforcing the shape of a module during Instantiate.

This style of eval based shape detection will never be possible.

In the future there may be a pragma that allows parse time instead of eval time declaration of shape, but that would still be a breaking change.

Simply put:

works fine in other ESM environments is broken in node

Is only true because those environments are not valid ESM

@trxcllnt
Copy link

trxcllnt commented Oct 4, 2017

@bmeck I'm afraid I wasn't clear. I'm not saying node shouldn't use the exports from a CommonJS module as the default when --experimental-modules is on.

I'm saying that when I run with --experimental-modules and I import a module from node_modules that has both a CommonJS and ESModules form side-by-side, I would expect node to select the ESModule over the CommonJS one.

I have this expectation because I'm explicitly running node in ESModules mode, and using an ESModule import statement to import a package that exports an ESModule. But instead because node selects the CommonJS version (and then exports becomes default export), the code doesn't do what I expected it to do.

And to clarify why this matters, we're compiling es2015+ features (generators, async generators) down to ES5/CommonJS as the .js files, and compiling the es2015/ESModules as the .mjs files. The ES5 files have all the iterator downleveling codegen necessary for ES5, but ideally we can run node with --experimental-modules and use the native-generator version of the lib automatically.

@bmeck
Copy link

bmeck commented Oct 4, 2017

@trxcllnt it does search for .mjs first. https://github.com/nodejs/node/blob/e00a488731ed333c3cd0952acdfe85729b734fa5/src/module_wrap.cc#L37

Your specifiers are different in meaning and you can see if you use import from 'ix/Ix'; that .mjs is preferred. I assume your package.json is explicitly pointing to the .js but was unsure given the example not providing one.

@trxcllnt
Copy link

trxcllnt commented Oct 4, 2017

@bmeck npm install ix to check it out for yourself, but the relevant bits of the package.json are this:

{
  "name": "ix",
  "main": "Ix.js",
  "module": "Ix.mjs"
}

We can't set "main": "Ix.mjs", as that'll throw when not using --experimental-modules. Right now we set "module" to the mjs file because most of the major bundlers (webpack, rollup, etc.) will use the "module" path over "main". But from everything I've read so far, there isn't an ESM analog to CJS's "main" entry in package.json (please correct me if I'm under-informed on this).

@trxcllnt
Copy link

trxcllnt commented Oct 4, 2017

@bmeck and also I want to clarify, you and I are on the same side of this issue. .mjs is just fine with me, as long as things have reasonably normal default behavior. I'm in this thread arguing that TS should generate imports with .mjs extensions, because it seems all ESModule import specifiers are now required per the spec to be valid URLs.

Considering the prevalence of importing libraries from node_modules in node, I was a bit surprised there wasn't clarity on this issue for ESModules. And also that it also did a bad/unexpected thing when we're explicitly trying to support both CJS and ESM in the IxJS project.

I would even have preferred node to throw/print some sort of "ambiguous import" warning like "hey dummy, you tried to get an ESModule from a package.json file which doesn't have a way of specifying ESModule mains, here's the ES5 version but maybe be more explicit next time"

@bmeck
Copy link

bmeck commented Oct 4, 2017 via email

@trxcllnt
Copy link

trxcllnt commented Oct 4, 2017

@bmeck HMMM yeah that sounds like a solution. sorry for hijacking the thread everybody.

@SMotaal
Copy link

SMotaal commented Oct 4, 2017

Awesome @trxcllnt and @bmeck, I guess we are all really trying to figure things out, so a resolution is always a great addition to a thread.

By the way, many of us nagging at Node to support ESM with .js are also nagging at TypeScript to support "module": "mjs" that can run with node directly without a --loader. Different workflows, different requirements.

So here is a silver lining:

// hybrid package.json
{
  
  "main": "index",
  "files": [
    "index.js",
    "index.mjs",
    "index.d.ts"
    "index.es.or.whatever"
  ],
  
}

Then if I do my work right, I would really appreciate being able to add this:

// hybrid package.json
{
  
  "scripts": {
    "compile-cjs": "tsc -m commonjs",
    "compile-mjs": "tsc -m mjs",
    "compile-whatever": "boink plug and play box yes no okay --but-follow-es-standards-please",
    "compile-all": "npm-run-all compile-cjs compile-mjs …"
  },
  
}

Instead of having to do this:


    "compile-mjs": "tsc; rename …; find-replace … from /.*?/ with $1.mjs --verbose"

Does that sound about right?

@bmeck so those index variants will resolve to mjs if imported and js (cjs) if required when things are stable without any special flags, right?
@demurgos is this what you had in mind?
@trxcllnt is that in line with your conclusions so far?
@kitsonk are we any closer to convincing you?

👍 Let's keep at it!

@jamie-pate
Copy link

adding .mts etc doesn't really help at all for package maintainers who want to provide .mjs and .cjs exports to be consumed by either audience :(

'they should use a bundler' is probably the practical answer, but there's a space between 'massive package' and 'single file package' that is underserved by that answer.

@weswigham
Copy link
Member

adding .mts etc doesn't really help at all for package maintainers who want to provide .mjs and .cjs exports to be consumed by either audience

Node is the only platform which cares about using the mjs (or cjs) extension (optionally). For anywhere else, just using .js is fine. In node, you should almost never compile your package into both formats; this causes two copies of your package (potentially) to get loaded into node at runtime, which, in addition to being performance inefficient, can cause issues with state, class nominality, and unique symbols. Instead, in node, you should really just provide a format-specific convenience wrapper around a single source-of-truth package format (eg, a handwritten esm wrapper that imports the cjs core). Ergo, you really shouldn't need to compile the same source to both a .js cjs file and a .mjs esm file - doing so likely means you're quietly breaking something in node. (Now, if you're building for node and another platform, that's another story, but then you don't need the mjs extension anymore, since it's only used for format disambiguation in node.)

Worst case, if you really really badly want to make node load two full, separate copies of your library, you could always compile once into a lib/cjs dir and once into a lib/esm dir, and drop a package.json with "type": "module" into lib/esm. Still don't actually need .mjs at all, let alone output extension overrides for it. It is actually incredibly valuable to know that .ts always maps to .js - it just makes resolution logic so much more easy and reliable.

@jamie-pate
Copy link

jamie-pate commented Feb 12, 2022

Hm, if you can just have a package.json in a dist-esm folder with just {"type":"module"} inside that doesn't break things in weird and wonderful new ways then that neatly solves any objections I have.. the situation I'm thinking about is indeed the package maintainers that need to build 3 versions of the package to distribute on npm because they want to support consumption from node cjs, node esm, webpack, roll-up, <script type=module>, ie11 all at the same time with a single package...

@frank-dspeed
Copy link

frank-dspeed commented Feb 12, 2022

@jamie-pate thats exactly my observation i am researcher in that fild and member of the nodejs package-maintainance group. We Really did break evolution at some points. But the Good thing is when everything would be offered ESM Only with .js extension even without a package.json we would make the whole ecosystem healty again.

when using NPM packages you should dist only one flavor -esm -cjs -webpack everything a own package and consider even sharing packages the esm code for example could when it is a really old package you could start offering a ESM package that loads and depends on the CJS package do not even transpil that to ESM you gain nothing from that.

to load a single file CJS package inside ESM it would be enough to know that both use the same globals so we can shim like this

const resetLoader = () => {
globalThis.exports = {};
globalThis.module = { exports: globalThis.exports };
}
resetLoader()
await import('singleFile.cjs') // i do not want to over complicate it
const myCjsModule = globalThis.module.exports;
resetLoader()

there is ongoing work on the import.meta.resolve feature that will complet the above shim with resolving capabilitys

@martinheidegger
Copy link

Trying to give a practical example here: I have been working on a deep dependency in our code that has been written in CommonJs a long time ago. I used it as a test to see if I could update it to be a package that is written in Typescript and supports both to be required as cjs (to not break when I drop it in the greater application) and to offer esm for the whole system to be step-by-step updated to ESM-only code. → @tradle/typeforce

My plan was to use tsc to compile teach variant of the src folder into a cjs and mjs folder with source-maps for better debugging experiences.

|- cjs
|  \- index.js
|- mjs
|  \- index.js
\- src
   \- index.ts

In the package.json I defined both the main and module field with the respective .js file. Using two different tsconfig.json variants (1, 2) as previously suggested with a shared base file.

In order to be able to test the generated files I wanted to be able to load them directly in the CLI. import('./mjs/index.js') will fail with the obvious (node:12398) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension. error message. So using a script I fixed this to rename all .js files to .mjs. This script needed to be expanded as with the change to .mjs also I better change the type definitions to .d.mts and the source maps to .mjs.map for consistency. The ties between the source and the source-maps had both to be updated as well. A pretty interesting problem that I found was that a .mjs file can not import a relative .mjs file.

(node:12628) UnhandledPromiseRejectionWarning: Error [ERR_UNSUPPORTED_DIR_IMPORT]

Of course in typescript I can not change import { compile } from './compile' to from './compile.ts as I would be running into ts(2691): An import path cannot end with a '.ts' extension. so I had to add some additional fixture in the rename script.

The initial tests were written with tape and to run the tests I wanted to be able to use just run the tests using ts-node test/index.ts. In order for this to work I set the ./tsconfig.json to have module = commonjs as ts-node doesn't support ESM yet(?).

To make sure that all code works same I originally I tried to put the tests in src/test and compile it for each variant and run them in that variant but I havn't managed to compile the tests with the external commonjs libary (fresh-tape) as module = nodejs, which is why I put it in the test folder.

I think it would help quite a bit if typescript would build node esm modules with a .mjs ending and also replace the directory import statements with direct .mjs links.

@jwalton
Copy link

jwalton commented Feb 16, 2022

In order to be able to test the generated files I wanted to be able to load them directly in the CLI. import('./mjs/index.js') will fail with the obvious (node:12398) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension

@martinheidegger check out how https://github.com/jwalton/solox does this. The build process for solox is very similar to what you proposed with an esm tsconfig.json and a cjs tsconfig.json. These get used to compile the source into ./dist/esm and ./dist/cjs. There's a post-build step which adds a package.json to each of these folders; in ./dist/esm we write a package.json that contains {"type": "module"}, and in the cjs folder it's {"type": "commonjs"}. Conditional exports in the top level package.json ensure that the correct version is picked up based on whether the module is being imported or required.

The result is that all files are ".js" files, but if you clone and build this project you'll find that await import('./dist/esm/index.js') from the node REPL works exactly as you'd expect, as does require('./dist/cjs/index.js'). No rename scripts are required.

My tests are in /test, but they import from ../src, and tests run fine out of the test folder using jest and ts-jest. (If you run into problems with tests, you might want to flip things so the tsconfig.json is commonjs, and have a tsconfig.esm.json that's "module": "es6".)

Note that one potential downside of this approach (and your original approach) is that it's possible for projects to end up with two copies of your code in memory (if they both import your code and require it in different places). This approach should definitely be avoided if you have singleton instances or other global state.

@martinheidegger
Copy link

martinheidegger commented Feb 16, 2022

Thank you @jwalton for sharing notes.

There's a post-build step which adds a package.json to each of these folders; in ./dist/esm we write a package.json that contains {"type": "module"}

Oh! Thank you for pointing this out. This will indeed make my solution a lot simpler. Wouldn't this be a good feature to add to the typescript compile process?

Conditional exports in the top level package.json ensure that the correct version is picked up based on whether the module is being imported or required.

Actually, I also was under the requirement to support deep imports like import * from 'typeforce/async' as part of backwards compatibility and I needed to conditional exports per file. (manual task atm.) It would be nice to have a good automation for that as well.

In a simple experiment with solox (found here martinheidegger/solox@e0a5edd) I tried to add a simple dependency and ran into a catch-22 where lint would break without a .ts declaration and compile now breaks with. 😅

@jwalton
Copy link

jwalton commented Feb 16, 2022

In a simple experiment with solox (found here martinheidegger/solox@e0a5edd) I tried to add a simple dependency and ran into a catch-22 where lint would break without a .ts declaration and compile now breaks with. 😅

This is a idiosyncracy of typescript, and it's not very intuative, but try making this change:

-import foo from './test.ts';
+import foo from './test.js';

(i.e. change the "ts" to a "js" in your import) and your experiment should build and run correctly.

@jamie-pate
Copy link

jamie-pate commented Feb 16, 2022

For deep imports in nodejs you can use the exports property of package.json
https://nodejs.org/api/packages.html#exports

    "exports": {
        "./*": "./dist/*.js",
        "./*/*": "./dist/*/*.js"
    },

NodeJS does not recognize the "modules" property, only bundlers will recognize that.

I'm using that with the same import 'test.js'; approach in typescript and the {"type":"module"} micro package.json approach as shared above, and finally after a week of futzing i'm pretty happy with what I've got. (except that the webpack/angular part of the project still uses import 'test' because they haven't agreed on the manditory import extensions idea 😭 )

@martinheidegger
Copy link

(i.e. change the "ts" to a "js" in your import) and your experiment should build and run correctly.

Interesting. This seems to be not yet supported by ts-node, though this is actively being worked on TypeStrong/ts-node#1361 I will revisit this and post an update using yours and @jamie-pate input when that landed.

@lucasvazq
Copy link

lucasvazq commented Mar 19, 2022

I use swc to transpile my ts files to mjs.

yarn add -D @swc/cli @swc/core
yarn run swc next.config.ts -o next.config.mjs -C module.type=es6 -C sourceMaps=inline

@gfortaine
Copy link

In order to be able to test the generated files I wanted to be able to load them directly in the CLI. import('./mjs/index.js') will fail with the obvious (node:12398) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension

@martinheidegger check out how https://github.com/jwalton/solox does this. The build process for solox is very similar to what you proposed with an esm tsconfig.json and a cjs tsconfig.json. These get used to compile the source into ./dist/esm and ./dist/cjs. There's a post-build step which adds a package.json to each of these folders; in ./dist/esm we write a package.json that contains {"type": "module"}, and in the cjs folder it's {"type": "commonjs"}. Conditional exports in the top level package.json ensure that the correct version is picked up based on whether the module is being imported or required.

The result is that all files are ".js" files, but if you clone and build this project you'll find that await import('./dist/esm/index.js') from the node REPL works exactly as you'd expect, as does require('./dist/cjs/index.js'). No rename scripts are required.

My tests are in /test, but they import from ../src, and tests run fine out of the test folder using jest and ts-jest. (If you run into problems with tests, you might want to flip things so the tsconfig.json is commonjs, and have a tsconfig.esm.json that's "module": "es6".)

Note that one potential downside of this approach (and your original approach) is that it's possible for projects to end up with two copies of your code in memory (if they both import your code and require it in different places). This approach should definitely be avoided if you have singleton instances or other global state.

@jwalton This proposal looks like a duplicate of probably the most insightful post on the topic to date cc @mobsense : https://www.sensedeep.com/blog/posts/2021/how-to-create-single-source-npm-module.html

By the way, for those who may be interested in having a look at the rewrite strategy path, it also seems worth considering the 2 packages below :

tsc-multi

tsukuru

@owenallenaz
Copy link

@gfortaine Excellent suggestion. By placing a { "type": "module" } into my /dist/esm folder I no longer have to have the "type": "module" at my root package.json but my ESM is now properly importing, assuming I manually specify the .js extensions, but this is close.

@gfortaine
Copy link

tsconfig-to-dual-package cc @azu

yen-tt added a commit to yext/chat-ui-react that referenced this issue Sep 12, 2023
…e), and CRA (webpack) (#38)

**Investigation:**
This PR attempts to fix the following error in alpha/sonic:
![Screenshot 2023-08-31 at 4 40 11 PM](https://github.com/yext/chat-ui-react/assets/36055303/5ec087ff-9e94-4637-9939-96a90cec9221)
This was a result of adding `"type": "module"` to our esm bundle for our library to be compatible with vite/pagesJS.
Vite, and bundlers such as webpack, follow node's module resolution algorithm. and NodeJS have certain rules on how it recognize files as ES modules (https://nodejs.org/api/packages.html#determining-module-system) -- which include adding `"type": "module"` as an option.

The fix is to update all of our paths to be explicit in order to include the mjs/js extensions, including  (e.g. `./components` to `./components/index.js` and `../icons/DualSync` to `../icons/DualSync.js`).

This currently can't be done by typescript compiler (tsc) as it's strictly a nodejs behavior and they don't want to support that ([long issue thread here](microsoft/TypeScript#18442 (comment))), which is frustrating. So we would have to do it manually or have a script to update import/export paths in the final bundle generated by tsc. This is not ideal and can be error prone.

**Solution:**
I decided to **replace tsc with rollup to bundle our library and append .mjs extension to our final esm bundle**. Using `mjs` extension instead of `.js`  also remove the need to do `"type": "module"` in our esm package.json, which previously introduce unnecessary caveats and one-off script to inject it into our esm bundle.

NOTE: the default **interop** behavior for rollup is not compatible with alpha/sonic. As such, the rollup config in this PR uses `auto` to follow Typescript's `esModuleInterop` behavior in how it transpile named, default, and dynamic imports of external deps (the issue with alpha is related to how react-textarea-autosize was imported into ChatInput)

NOTE: updated `tsconfig.json` to use `"jsx": "react"` instead of `"jsx": "react-jsx"` because of the way jsx-runtime is backported to react 16/17 without explicit exports field ([React github issue here](facebook/react#20235 (comment))). We would either need to export two separate bundles for latest and legacy versions in order to continue outputting the JSX syntactic sugar OR we could just output `React.createElement` directly. This is common for many React libraries that support older versions like React 16. (e.g. [ant design](https://github.com/ant-design/ant-design/blob/master/tsconfig.json#L15), [blueprint UI](https://github.com/palantir/blueprint/blob/develop/config/tsconfig.base.json#L9), [evergreen UI](https://github.com/segmentio/evergreen/blob/master/tsconfig.json#L6))

J=CLIP-520
TEST=manual

see that the new build works with:
- the local test-site of the component lib repo 
- pagejs/vite (released v0.6.0-alpha.38.6 to install and test)
- sonic/alpha (released v0.6.0-alpha.38.6 to install and test)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Domain: ES Modules The issue relates to import/export style module behavior In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet