How we created an npm package from sub packages
Problem
We decided to create a monorepo for our frontend library code. Within our monorepo, we created seperate packages for logical splits in our codebase; components, composites, stateful-clients, common code. However, when release time came around we wanted to be able to publish a single package to npm that exported all functionality from our monorepo.
Solution
A metapackage. A package that imports then re-exports functionality from the other packages (from now on I will refer to these sub-packages as packlets). While this sounds simple, we ran into a number of friction points.
Issue: Combining exports from multiple packages
Our build system is non-trivial with a number of build artifacts. We build packlets in a dependency tree order (using Rush) with each packlet generating a CommonJS bundle, an ESM bundle, and an API file. Our task was to ensure the metapackage could produce an output that combined these outputs.
1.1 How to combine packlets
To create the metapackage we do not leverage the build outputs from each individual build but instead have a lone index.ts file that imports and re-exports the exports from each packlet. This means all files are transpiled and bundled twice; once when the packlet is built, and once when the metapackage is built.
/// metapackage index.ts
export * from "../packlet1";
export * from "../packlet2";
export * from "../packlet3";
This allowed us to create build artifacts that only contained the exports from the packlets we wanted to export, were treeshaken correctly, and had a single rolled-up API file based on those exports. Here is our active metapackage index.ts file.
1.2 Handling conflicting exports
We ran into an issue where two packlets exported the same thing. For example, two packlets exported a usePropsFor utility function. This meant that when we re-exported the usePropsFor component from the metapackage we would get a conflict. To solve this we had to rename the exports from the packlets. This was a simple change to the packlet's index.ts file.
/// packlet1 index.ts
export { usePropsFor as useCallingPropsFor } from "./hooks/usePropsFor";
/// packlet2 index.ts
export { usePropsFor as useChatPropsFor } from "./hooks/usePropsFor";
We ended up merging these functions into a single API function for the metapackage for ease of use, but this was not necessary.
1.3 Handling incorrect downstream imports
Now each package was being bundled and we were generating a correct API file. But one problem existed: the ESM bundle has imports that pointed to internal packlets. For example, the Call Composite composite in the metapackage would import StartCallButton from the component package. This meant that when the metapackage was installed and used, the ESM bundle would fail to resolve the import.
/// metapackage CallComposite/StartCallScreen.tsx
import { StartCallButton } from "@internal/react-components"; // <-- this import will not resolve correctly in the metapackage ESM bundle
To handle this, we had to rewrite the imports at transpile or bundle time. To do this, we choose to use ttypscript, a tool that can apply transformations to the code at transpile time, and a plugin ts-tranform-paths that can rewrite imports.
// metapackage tsconfig.json
{
"compilerOptions": {
"paths": {
"@internal/chat-component-bindings": ["../../chat-component-bindings/src"],
"@internal/calling-component-bindings": ["../../calling-component-bindings/src"],
"@internal/acs-ui-common": ["../../acs-ui-common/src"],
"@internal/calling-stateful-client": ["../../calling-stateful-client/src"],
"@internal/chat-stateful-client": ["../../chat-stateful-client/src"],
"@internal/react-components": ["../../react-components/src"],
"@internal/react-composites": ["../../react-composites/src"]
},
"plugins": [
{
"transform": "@zerollup/ts-transform-paths"
}
]
}
}
This transformed the import line to instead have a relative path to the correct file in the dist directory:
// Before
import { StartCallButton } from "@internal/react-components";
// After
import { StartCallButton } from '../react-components';
FAQs
Why not release multiple packages?
- Confusing imports - Which package contains what I need?
- Maintenance - Maintaing releases of 7+ packages
- Ensuring consistency - Users may use different versions of each package
Any Downsides?
- Dependencies - If a user only wants functionality from one packlet, they will still have to install all dependencies