How to compile a local package with tsc?

909 Views Asked by At

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));

1

There are 1 best solutions below

0
On

Solved (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));

On my server, the start script in package.json is just node out.js and there are no dependencies or devDependencies since all are bundled into out.js.