-
Notifications
You must be signed in to change notification settings - Fork 19
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
refactor(rgbpp-sdk): Support ESM package #246
Conversation
f1096d7
to
2e46ccf
Compare
I will test if the following changes bring any issues:
Will also test if the refactored libs can be imported/called normally in both ESM and CJS projects:
|
The lint staged commands and changeset have been updated @ShookLyngs |
The new commits 4f61ed8, 6096395 and 53fc8ee should have resolved the previous issues. If they occur again, we can update them in new comments to make the entire timeline clearer. The repo for testing the compatibility of ESM/CJS has also been updated to refence a new snapshot version ( |
5e59d02
to
37636bd
Compare
Discussion: Should we change the
|
The first solution makes sense to me and the reason can be found in the following comment #246 (comment). |
@@ -1,20 +1,19 @@ | |||
{ | |||
"compilerOptions": { | |||
"moduleResolution": "Node", | |||
"moduleResolution": "Bundler", |
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.
The TypeScript document recommends using "moduleResolution": "Node"
instead of "moduleResolution": "Bundler"
for writing a library.
In short, "moduleResolution": "bundler" is infectious, allowing code that only works in bundlers to be produced. Likewise, "moduleResolution": "nodenext" is only checking that the output works in Node.js, but in most cases, module code that works in Node.js will work in other runtimes and in bundlers.
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.
Update "moduleResolution": "NodeNext" in tsconfig.json. This requires all relative imports to contain file extensions, but we would need to refactor all our code to satisfy this requirement.
According to the above comment, the current code has added .js
extension for the relative imports, so we should use "moduleResolution": "NodeNext"
instead of "moduleResolution": "Bundler"
.
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.
My thoughts, at the beginning
According to the TS 5.0+ related documentation, both options are recommended for modern projects. However, after some tests, I think the Bundler
option is somewhat more reasonable to go with. Though this is not final, so you can continue reading. Any feedback is welcome.
Firstly, I think we have 3 paths to go:
- Specify the packages as
"type": "module"
, and specify"moduleResolution": "NodeNext"
- Keep the packages as
"type": "commonjs"
, and specify"moduleResolution": "NodeNext"
- Keep the packages as
"type": "commonjs"
, and specify"moduleResolution": "Bundler"
And I think the Bundler
option is more reasonable:
- We're in fact using
tsup
as the bundler, which transpiles/packs our source code into single CJS/ESM files. - The
Bundler
option is an easier option for migration as it prevents a huge refactor of our code.
Let's first look at the difference between NodeNext
and Bundler
.
Difference between NodeNext
and Bundler
While the two options mostly behave the same in the ways we care, and both of them are recommended options for modern projects, yet they do have differences. Refer to this PR for more: microsoft/TypeScript#51669.
The NodeNext
option requires all relative imports in ESM to include a file extension, meaning all the following import examples should be refactored to contain a file extension .js
at the end of the path:
import { a } from './a'; // Invalid
import { a } from './a.js'; // Correct
import { b } from 'pkg/b'; // Invalid
import { b } from 'pkg/b.js'; // Correct
The Bundler
option, however, doesn't treat file extensions as mandatory and will leave the problem to our bundler: tsup
. While bundling, tsup
packs all source code into a single JS file, which means in our bundled code, there are no local imports, only the package imports remain:
import { a } from './a'; // This line doesn't exist in the bundled code
import { b } from 'pkg/b'; // This line exists in the bundled code
So with the Bundler
option, we only need to deal with the package imports:
import { b } from 'pkg/b'; // Invalid
import { b } from 'pkg/b.js'; // Correct
File extension are just promises
For more discussions: microsoft/TypeScript#49083
Another thing about the file extension being mandatory is that our bundlers (tsc
and tsup
) refuse to transpile the file extension of the imports from .ts
to .js
for us while bundling. That is, if we decide to add file extensions to the relative imports in our source code, even if we're importing a .ts
file, we have to use .js
like they're some sort of promises:
import { a } from './a.js'; // There's only an "a.ts" file in the directory
However, adding .js
to package imports feels okay because they are usually just .js
files:
import { b } from 'pkg/b.js'; // The "pkg/b.js" file actually exists
The costs of migrating to ESM-first packages
What if we just want to migrate to specifying the "type": "module"
and "moduleResolution": "NodeNext"
together, as they are the most recommended settings for ESM-first packages?
Here's a branch that has done most of the refactor work, where all relative imports are refactored to include a file extension, and "type": "module"
is added to the package.json
to specify explicitly that the package is an ESM-first library:
- Commit: fedb0f6
- Snapshot:
0.0.0-20240709190558
- Changes:
- Add file extension
.js
to all relative imports. - Add
"type": "module"
topackage.json
to tag explicitly that this is an ESM-first package. - Fix some type issues or weird incompatibilities found in the
rgbpp-sdk/ckb
lib to prevent bundling failures.
- Add file extension
- Test results:
- Tested in https://github.com/ShookLyngs/test-rgbpp-pure-esm in both ESM/CJS.
- Tested in a CLI project to bundle it into a single executable application.
Use NodeNext
without migration
While testing everything, I find that we could specify the NodeNext
option as the module resolution and NOT set "type": "module"
in the package.json
, which means by default our packages are just treated as CJS. With this combination, the TSC actually reports no error about our relative imports, even if they have no file extension included.
But, the problems are:
- I'm not sure if this is a valid thing to do, or if it is simply an unresolved bug. Any documentation about this?
- If we're using this combination, what's the difference from using the
Bundler
option? As they both don't give warnings from the TSC and IDE, we still need to validate the packages after bundling them.
Conclusion
After everything, these are my thoughts about the combinations:
- Specify the packages as
"type": "module"
, and specify"moduleResolution": "NodeNext"
- We have to refactor most of the code, and the file extensions in the imports are not what they look like.
- If we decide to accept the downsides, this is the most recommended choice by the community.
- Keep the packages as
"type": "commonjs"
, and specify"moduleResolution": "NodeNext"
- Behaves the same as
Bundler
, no file extension mandatory, but weirdly, I'm not sure if this combination is a bug of TSC or if it is simply a valid choice. - If we can find more documentation about this, maybe this method is also acceptable.
- Behaves the same as
- Keep the packages as
"type": "commonjs"
, and specify"moduleResolution": "Bundler"
- The bundled code is not promised to work at runtime because the file extensions for package imports are not mandatory, therefore the packages are lacking a validation process to keep the them work smoothly.
- If we treat
tsup
as a bundler, we can use this option.
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.
Have you tried with the "module": "node16"
and "moduleResolution": "nodenext"
of the tsconfig.json
?
- module: "node16". When a codebase is compatible with Node.js’s module system, it almost always works in bundlers as well. If you’re using a third-party emitter to emit ESM outputs, ensure that you set "type": "module" in your package.json so TypeScript checks your code as ESM, which uses a stricter module resolution algorithm in Node.js than CommonJS does. As an example, let’s look at what would happen if a library were to compile with "moduleResolution": "bundler":
export * from "./utils";
Assuming ./utils.ts (or ./utils/index.ts) exists, a bundler would be fine with this code, so "moduleResolution": "bundler" doesn’t complain. Compiled with "module": "esnext", the output JavaScript for this export statement will look exactly the same as the input. If that JavaScript were published to npm, it would be usable by projects that use a bundler, but it would cause an error when run in Node.js:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../node_modules/dependency/utils' imported from .../node_modules/dependency/index.js
Did you mean to import ./utils.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.
Yes, I've tried it, a TS error will be thrown if we don't specify them in pairs (that's why I didn't mention it):
error TS5110: Option 'module' must be set to 'NodeNext' when option 'moduleResolution' is set to 'NodeNext'
You can refer to this tsconfig.json
for example: fedb0f6#diff-339644dcceb3090377506d9dad91839e1b8916140ad6a467d703427ea83c6233.
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'm giving a like to the PR because I think the current implementation works by keeping "type": "commonjs"
in the package.json
, and specify "moduleResolution": "Bundler"
in the tsconfig.json
. Also, the implementation includes the least changes to the codebase.
c913baa
to
cd3f618
Compare
5853b1f
to
8962f70
Compare
8962f70
to
80a9bd3
Compare
I have tested in JoyID, Next.js, and Node.js scripts with
|
Co-authored-by: Flouse <[email protected]>
Main Changes
ts-node
to run examples and testsckb-sdk-js
to support ESM