isaacs / tshy

Other
869 stars 17 forks source link

Divergence from `tsc` wrt importing package.json #61

Closed shellscape closed 4 months ago

shellscape commented 4 months ago

Using tsc in the raw, when we have an import of package.json within the code, tsc will copy that package.json file to the appropriate relative path in outPath.

e.g. if I have import pkg from '../../package.json' within src/thing/index.ts and "outDir": "dist" in tsconfig.json, dist/package.json will be created by tsc.

That behavior is not emulated with tshy and the file must be copied in a build step.

isaacs commented 4 months ago

This is not something that would be easy for tshy to do. There might be a way to do it, but I'm not sure it'd work without other ramifications, and there's a few puzzles to work out, and since there are easy enough workarounds, it's probably not worth it.

workarounds

There are 3 ways to do this today.

If either works for you, then you can skip the rest of this and stay blissfuly unaware of tsc's quirks.

workaround 1 - exports

You can leverage the fact that package.json is exported by default. This relies on selfLink not being disabled in dev, but when your module is installed, your actual package.json will always be the first matched by module name.

package.json

{
  "name": "my-package-name",
  // ...
}
// instead of this:
//   import pkg from '../package.json' assert { type: 'json' }
// do this:
import pkg from 'my-package-name/package.json' assert { type: 'json' }

Those symlinks in the dist folder are a pain in some contexts, which is why there's a way to turn them off, but if you're ok with it, that would probably work fine.

workaround 2 - imports

You can do this with relative imports, but it adds an install script to create symlinks which are required in production.

package.json

{
  "imports": {
    "#package.json": "./package.json"
  }
}

And then in your code,

// instead of this:
//   import pkg from '../package.json' assert { type: 'json' }
// do this:
import pkg from '#package.json' assert { type: 'json' }

The implementation trade-offs are covered in the readme, but tl;dr is that it has to create some added symlinks (and an install script to generate them when your module is installed), because node stops looking for package.json when it finds the first one, and tshy is using a shim package.json to set the dialect. Even if we could resolve all the paths and create a shim package.json that includes the imports field, they're not allowed to be outside the folder containing package.json, so that wouldn't work. Thus, symlinks. And since they cannot be included in a published package, they must be created at install time. Kinda gross, imo.

workaround 3 - just read the file

This is the one I actually use. It's more code in your module, but it's kind of simpler to reason about. If you're just wanting to display the version number in a cli output or something, it's fine.

src/index.ts

// instead of this:
//   import pkg from '../package.json' assert { type: 'json' }
// do this:
import { fileURLToPath } from 'node:url'
import { dirname, basename, resolve } from 'node:path'
import { readFileSync } from 'fs'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const inDist = basename(dirname(__dirname)) === 'dist'
/* c8 ignore next - build dependent */
const pj = resolve(__dirname, inDist ? '../..' : '..', 'package.json')
const pkg = JSON.parse(readFileSync(pj, 'utf8'))

The nice thing about this over using #imports or exports in package.json is that it doesn't have to walk up the file tree looking for package.json files (for #imports) or reading node_modules folders to find a matching subfolder name (for exports). You just figure out where the file is, read it, and parse it.

situation

When tsc builds into an outDir (ie, not just side-by-side with the ts files), it puts the built files in a matching folder structure to the input files. The portion of the path after the rootDir is mounted onto the outDir folder, and that's where the files go.

If an explicit rootDir is not set, then tsc will infer the rootDir to be the longest common path, in other words the deepest directory that contains all of the input files.

As a result of this, tsc alters the built folder structure when you pull in something from outside of src (or wherever your code lives). Without that external relative import, that src folder is the longest shared path of all input files. But if it does import '../package.json', then now it's one folder higher.

For example:

src/index.ts

console.log('hello')

tsconfig.json

{
  "compilerOptions": {
    "outDir": "dist",

    "declaration": true,
    "declarationMap": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "inlineSources": true,
    "jsx": "react",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "noUncheckedIndexedAccess": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "sourceMap": true,
    "strict": true,
    "target": "es2022"
  }
}

If you run tsc, then it'll create this dist folder:

dist/
├── index.d.ts
├── index.d.ts.map
├── index.js
└── index.js.map

But then if we change the index.ts file to:

import pkg from '../package.json' assert { type: 'json' }
console.log(pkg)

Then it creates this:

dist/
├── src/
│   ├── index.d.ts
│   ├── index.d.ts.map
│   ├── index.js
│   └── index.js.map
└── package.json

This is only deterministic if you explicitly add all the files to "include" in tsconfig.json, or even better, if you set a rootDir explicitly (though, then it must contain all included files).

Note: that package.json file it created there is an entire copy of your actual package.json file. So you're having tsc read a file, parse it, re-stringify it, just so that your code can stay unaware of whether it's in src or dist.

Why this is a problem for tshy

Tshy isn't just building your program, it's also managing the exports field in package.json. In order to do that, it needs to know where things will end up.

To make it deterministic (and also to filter out dialect overrides etc), tshy sets the include to only look at the files in ./src/**/*.{mts,tsx,ts} for ESM, and ./src/**/*.{cts,tsx,ts} for CommonJS, and a rootDir of ./src.

This guarantees that tshy can deterministically map ./src/whatever.ts to the resulting output files. So, if you have "tshy": { "exports": { "./whatev": "./src/whatever.ts" }} then it knows where the files will end up for various dialects, and can set the top level exports field appropriately, without having a complete map of every module imported from every input file.

I really don't see a way that tshy can do this. It'd have to copy the entire package.json file, with all the imports and exports recalculated and mapped appropriately. Beyond just being bloated, sending 3 copies of the same file with just some mechanical changes, trying to get all the paths right feels like a bug factory. The ones tshy created, easy enough, but it also lets you put whatever you want in there.

isaacs commented 4 months ago

Made the third workaround even easier: https://github.com/isaacs/package-json-from-dist

Since I do actually have this code in multiple places it seemed like a good idea.