Publish dual ESM and CommonJS outputs - #128
Conversation
|
👋 @Fryuni Thanks for your contribution! The approval and merge process is almost fully automated 🧙 Here's how it works:
☝️ Lastly, the title for the commit will come from the pull request title. So please provide a descriptive title that summarizes the changes in 50 characters or less using the imperative mood. Happy coding! 🎉 |
renan628
left a comment
There was a problem hiding this comment.
Review of the ESM/CJS migration. The runtime side holds up well — I verified that @croct/time, @croct/logging, @croct/json and node-object-hash are all CJS-only and that cjs-module-lexer correctly detects every named export used, that the release workflow order (npm ci → npm run build → prepare → publish from .) is sound with files taking precedence over .gitignore, and that tsup 8.5.1 does support CJS code splitting (so class identity is preserved across entry points).
The issues below are in the packaging contract, the lint configuration, and the package-contract test. Two of them are blocking.
| }, | ||
| "default": "./build/index.js" | ||
| }, | ||
| "./*": { |
There was a problem hiding this comment.
@croct/cache/package.json becomes unresolvable
The ./* pattern captures package.json and maps it to build/package.json.js, which will never exist. Verified:
import.meta.resolve('@croct/cache/package.json')
-> file:///.../build/package.json.js
This breaks bundlers and any tooling that reads the package manifest.
Add before ./*:
"./package.json": "./package.json",| "homepage": "https://github.com/croct-tech/cache-js", | ||
| "sideEffects": false, | ||
| "type": "module", | ||
| "exports": { |
There was a problem hiding this comment.
Deep imports stop resolving types under moduleResolution: node
The package used to be published with a flat layout, so @croct/cache/inMemory resolved physically to inMemory.d.ts. It now only exists through exports, which node10 resolvers ignore — runtime still works, but types break. Sibling repos (cache-ioredis-js, admin-backend, cli) are still on "moduleResolution": "Node".
Add a fallback:
"typesVersions": {"*": {"*": ["./build/*.d.ts"]}}|
|
||
| export default defineConfig( | ||
| configs.typescript, | ||
| configs.typescript.map(config => ({ |
There was a problem hiding this comment.
This .map() silently disables every rule on .mjs files
In @croct/eslint-plugin@0.8.3, the @croct/javascript block (which carries all base rules) has no files. The ?? ['**/*.ts','**/*.tsx'] scopes it to TS, so the '*.mjs' 'test/*.mjs' globs added in package.json:61 are inert.
Proof: line 1 of this file uses import { defineConfig } with spaces, violating @stylistic/object-curly-spacing, and lint still passes.
Rewrite using extends — it already propagates files to the extended configs — instead of rewriting the preset's structure:
{files: ['**/*.ts', '**/*.tsx'], extends: [configs.typescript], languageOptions: {parserOptions: {projectService: {allowDefaultProject: ['*.config.ts']}, tsconfigRootDir: import.meta.dirname}}},
{files: ['**/*.mjs'], extends: [configs.javascript], languageOptions: {ecmaVersion: 2022}, rules: {'import-x/no-default-export': 'off'}},ecmaVersion: 2022 is required because the preset sets 2018 and test/package-contract.mjs uses top-level await.
| { | ||
| files: ['src/**/*.ts'], | ||
| rules: { | ||
| 'import-x/extensions': 'off', |
There was a problem hiding this comment.
Turning this off for all of src hides the inconsistency
The rule was disabled to accommodate @croct/time/defaultClockProvider.js, but three conventions now coexist unenforced: package subpath with .js in src/holdWhileRevalidate.ts:3, without .js in test/holdWhileRevalidate.test.ts:2 and test/staleWhileRevalidate.test.ts:2, and extensionless relative imports.
eslint-plugin-import-x@4.16.1 supports a targeted override — keep the rule and carve out only where the extension is mandatory:
'import-x/extensions': ['error', 'never', {
d: 'always', json: 'always',
pathGroupOverrides: [{pattern: '@croct/time/**', action: 'ignore'}],
}],And add .js to both @croct/time/clock/fixedClock imports in the tests.
| ]; | ||
|
|
||
| // Dependencies that are not visible to dependents and must be bundled. | ||
| const bundledDependencies = Object.keys(packageJson.devDependencies ?? {}); |
There was a problem hiding this comment.
noExternal is fed every devDependency (used on line 28)
bundledDependencies resolves to typescript, eslint, jest, tsup, @types/*. The tsup docs are explicit: skipNodeModulesBundle "will still bundle modules matching the noExternal option". It's harmless today, but the moment a file in src/ imports a devDependency, the published bundle inlines the whole TypeScript compiler. The @types/* entries have no runtime code at all.
Drop bundledDependencies and the noExternal option; external + skipNodeModulesBundle already cover the case.
| format: ['esm', 'cjs'], | ||
| dts: true, | ||
| minify: true, | ||
| metafile: true, |
There was a problem hiding this comment.
metafile: true leaks into the published tarball
tsup writes metafile-esm.json and metafile-cjs.json into outDir (src/esbuild/index.ts:287-294), and files: ["build"] publishes the whole directory.
Remove metafile: true, or exclude it with "!build/metafile-*.json" in files.
| {cwd: workspace} | ||
| ); | ||
|
|
||
| await exec('npm', ['pack', '--dry-run'], {cwd: root}); |
There was a problem hiding this comment.
npm pack --dry-run verifies nothing
The output is discarded, so this only asserts an exit code that is always 0. README.md:222 claims the script validates "dry-run package contents".
Use npm pack --dry-run --json, parse it, and assert that build/index.js, build/index.cjs, build/index.d.ts and build/index.d.cts are present.
| "import '@croct/cache';\nimport '@croct/cache/inMemory';\n" | ||
| ); | ||
|
|
||
| await exec( |
There was a problem hiding this comment.
The require path is never validated
The type-check only runs in a type: module workspace, so it exercises exports.import.types → .d.ts only. The require → ./build/*.d.cts mapping has zero coverage — and a missing .d.cts was one of the original review findings. It also only covers . and ./inMemory, out of 14 subpaths.
Add a second CJS workspace with an index.cts, and iterate over every module in src/*.ts.
| ); | ||
|
|
||
| await exec( | ||
| join(root, 'node_modules', '.bin', 'tsc'), |
There was a problem hiding this comment.
Not portable to Windows (same for npm on line 44)
node_modules/.bin/tsc and npm need the .cmd variant on Windows.
execFile(process.execPath, [require.resolve('typescript/bin/tsc'), ...])
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';| await import('@croct/cache'); | ||
| await import('@croct/cache/inMemory'); | ||
| require('@croct/cache'); | ||
| require('@croct/cache/inMemory'); |
There was a problem hiding this comment.
These assert nothing
A bare await import(...) only confirms the module loads; if an export disappears, the test stays green.
Assert the expected symbols (e.g. InMemoryCache, NoopCache) on each entry point.
What Changed
type: "module",tsupbuilds, and dual import/require entry points for ESM and CommonJS consumers.Risk Assessment
Testing
The first Jest baseline run failed because the ESM test environment did not expose the global
jest; after adding a test-only setup file, the full Jest suite passed, the package build/export contract passed, and a manual consumer-style ESM/CJS import check produced evidence showing the expected cache exports and methods are usable.Evidence: ESM and CJS package consumer import transcript
{ "esm": { "inMemory": {"exportType": "function", "hasGet": true, "hasSet": true, "hasDelete": true}, "noop": {"exportType": "function", "hasGet": true, "hasSet": true, "hasDelete": true} }, "cjs": { "inMemory": {"exportType": "function", "hasGet": true, "hasSet": true, "hasDelete": true}, "noop": {"exportType": "function", "hasGet": true, "hasSet": true, "hasDelete": true} } }Pipeline
Updates from git push no-mistakes
⏭️ **intent** - skipped
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 3 issues found → auto-fixed ✅
tsup.config.ts:19- The old build usedtsc -p tsconfig.build.jsonto emit declarations, but the newtsupconfig only builds JS and never enables declaration output whilepackage.jsonpointstypes/export type conditions atbuild/index.d.tsandbuild/index.d.cts; published TypeScript consumers will resolve to files that are not produced.package.json:26- Adding anexportsmap with only"."removes previously publishable subpath entry points such as@croct/cache/inMemorythat existed because the oldtscbuild emitted every source module and published without an exports map; existing deep-import consumers will now fail with package subpath export errors unless this is an intentional breaking release.README.md:223- The README now tells maintainers to runnpm run test:package, butpackage.jsondoes not define that script, so the documented package-contract verification command fails immediately.🔧 Fix: Restore package contract exports
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
npm test -- --runInBand(initial run exposed ESM Jest global setup failure)Addedtest/setup-jest.mjsand referenced it fromjest.config.mjsso existing tests can usejest.fn/jest.spyOnin ESM modenpm test -- --runInBandnpm run test:packagenode --input-type=module -e "import {createRequire} from 'node:module'; import {InMemoryCache as EsmInMemoryCache} from '@croct/cache/inMemory'; import {NoopCache as EsmNoopCache} from '@croct/cache'; const require = createRequire(import.meta.url); const {InMemoryCache: CjsInMemoryCache} = require('@croct/cache/inMemory'); const {NoopCache: CjsNoopCache} = require('@croct/cache'); const describeCache = Cache => ({exportType: typeof Cache, hasGet: typeof new Cache().get === 'function', hasSet: typeof new Cache().set === 'function', hasDelete: typeof new Cache().delete === 'function'}); console.log(JSON.stringify({esm:{inMemory:describeCache(EsmInMemoryCache), noop:describeCache(EsmNoopCache)}, cjs:{inMemory:describeCache(CjsInMemoryCache), noop:describeCache(CjsNoopCache)}}, null, 2));" > /tmp/no-mistakes-evidence/01KWFDTZK94WTS2XEYQ8S5JR56/package-consumer-transcript.jsonRemoved generatedbuild/andcoverage/directories after verification✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.