Project Structure
I have a monorepo (using npm workspaces) that contains a directory api
(an express API written in typescript). api
uses a local package @myapp/server-lib
(typescript code).
The directory structure is:
.
├── api/
└── libs/
└── server-lib/
Problem
When I build api
using tsc
, the build output contains require
statements for @myapp/server-lib
(the server-lib
package). However, when the API is deployed the server can't resolve @myapp/server-lib
(since its not meant to be installed from the npm registry).
How can I get tsc
to compile @myapp/server-lib
removing require
statements for @myapp/server-lib
in the built code and replacing it with references to the code that was being imported?
The behavior I am looking to achieve is what next-transpile-modules
does for Next.js.
I tried to use typescript project references, that did not compile the imported @myapp/server-lib
. I also read up on why I didn't encounter this issue in my NextJS front-end (also housed in the same monorepo, relying on a different but very similar local package) and that is how I landed on next-transpile-modules
.
Would appreciate any help or tips in general on how to build a typescript project that uses a local package. Thank You!!
UPDATE (12/28/2022)
I solved this by using esbuild
to build api
into a single out.js
file. This includes all dependencies (therefore @myapp/server-lib
.
The overall build process now looks like:
npx tsc --noEmit # checks types but does not output files
node build.js # uses esbuild to build the project
Where the build.js
script is:
const nativeNodeModulesPlugin = {
name: 'native-node-modules',
setup(build) {
// If a ".node" file is imported within a module in the "file" namespace, resolve
// it to an absolute path and put it into the "node-file" virtual namespace.
build.onResolve({ filter: /\.node$/, namespace: 'file' }, args => ({
path: require.resolve(args.path, { paths: [args.resolveDir] }),
namespace: 'node-file',
}))
// Files in the "node-file" virtual namespace call "require()" on the
// path from esbuild of the ".node" file in the output directory.
build.onLoad({ filter: /.*/, namespace: 'node-file' }, args => ({
contents: `
import path from ${JSON.stringify(args.path)}
try { module.exports = require(path) }
catch {}
`,
}))
// If a ".node" file is imported within a module in the "node-file" namespace, put
// it in the "file" namespace where esbuild's default loading behavior will handle
// it. It is already an absolute path since we resolved it to one above.
build.onResolve({ filter: /\.node$/, namespace: 'node-file' }, args => ({
path: args.path,
namespace: 'file',
}))
// Tell esbuild's default loading behavior to use the "file" loader for
// these ".node" files.
let opts = build.initialOptions
opts.loader = opts.loader || {}
opts.loader['.node'] = 'file'
},
}
require("esbuild").build({
entryPoints: ["./src/server.ts"], // the entrypoint of the server
platform: "node",
target: "node16.0",
outfile: "./build/out.js", // the single file it will bundle everything into
bundle: true,
loader: {".ts": "ts"},
plugins: [nativeNodeModulesPlugin], // addresses native node modules (like fs)
})
.then((res) => console.log(`⚡ Bundled!`))
.catch(() => process.exit(1));
Solved (12/28/2022)
I solved this by using
esbuild
to buildapi
into a singleout.js
file. This includes all dependencies (therefore@myapp/server-lib
.The overall build process now looks like:
Where the
build.js
script is:On my server, the
start
script inpackage.json
is justnode out.js
and there are nodependencies
ordevDependencies
since all are bundled intoout.js
.