Closed shellscape closed 6 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.
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.
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.
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.
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.
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.
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.
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.
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 inoutPath
.e.g. if I have
import pkg from '../../package.json'
withinsrc/thing/index.ts
and"outDir": "dist"
in tsconfig.json,dist/package.json
will be created bytsc
.That behavior is not emulated with
tshy
and the file must be copied in a build step.