website/docs/guides/javascript/node-handbook.mdx
import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem';
Utilizing JavaScript (and TypeScript) in a monorepo can be a daunting task, especially when using Node.js, as there are many ways to structure your code and to configure your tools. With this handbook, we'll help guide you through this process.
:::info
This guide is a living document and will continue to be updated over time!
:::
For this part of the handbook, we'll be focusing on moon, our task runner. To start, languages in moon act like plugins, where their functionality and support is not enabled unless explicitly configured. We follow this approach to avoid unnecessary overhead.
To enable JavaScript support via Node.js, define the node setting
in .moon/toolchains.*, even if an empty object. The
javascript toolchain must also be enabled, and configured to
use Bun as the package manager.
# Enable JavaScript
javascript:
packageManager: 'pnpm'
# Enable Node.js and pnpm
node: {}
pnpm: {}
Or by pinning a node version in .prototools in the workspace root.
node = "24.0.0"
pnpm = "7.29.0"
This will enable the Node.js toolchain and provide the following automations around its ecosystem:
package.json have changed, or
the lockfile has changed, since the last time a task has ran.
package.json workspaces into account and install modules in the correct
location; either the workspace root, in a project, or both.dependencies,
devDependencies, and peerDependencies in package.json.
package.json scripts.When a language is enabled, moon by default will assume that the language's binary is available
within the current environment (typically on PATH). This has the downside of requiring all
developers and machines to manually install the correct version of the language, and to stay in
sync.
Instead, you can utilize moon's toolchain, which will download and install the language in the background, and ensure every task is executed using the exact version across all machines.
Enabling the toolchain is as simple as defining the node.version
setting.
# Enable Node.js toolchain with an explicit version
node:
version: '18.0.0'
Versions can also be defined with
.prototools.
package.json scriptsIf you're looking to prototype moon, or reduce the migration effort to moon tasks, you can configure
moon to inherit package.json scripts, and internally convert them to moon tasks. This can be
achieved with the javascript.inferTasksFromScripts
setting.
javascript:
inferTasksFromScripts: true
Or you can run scripts through npm run (or pnpm, yarn) calls.
tasks:
build:
command: 'npm run build'
JavaScript monorepo's work best when projects are split into applications and packages, with each
project containing its own package.json and dependencies. A root package.json must also exist
that pieces all projects together through workspaces.
For small repositories, the following structure typically works well:
/
├── .moon/
├── package.json
├── apps/
│ ├── client/
| | ├── ...
│ | └── package.json
│ └── server/
| ├── ...
│ └── package.json
└── packages/
├── components/
| ├── ...
│ └── package.json
├── theme/
| ├── ...
│ └── package.json
└── utils/
├── ...
└── package.json
For large repositories, grouping projects by team or department helps with ownership and organization. With this structure, applications and libraries can be nested at any depth.
/
├── .moon/
├── package.json
├── infra/
│ └── ...
├── internal/
│ └── ...
├── payments/
│ └── ...
└── shared/
└── ...
Applications are runnable or executable, like an HTTP server, and are pieced together with packages and its own encapsulated code. They represent the whole, while packages are the pieces. Applications can import and depend on packages, but they must not import and depend on other applications.
In moon, you can denote a project as an application using the layer
setting in moon.*.
layer: 'application'
Packages (also known as a libraries) are self-contained reusable pieces of code, and are the suggested pattern for code sharing. Packages can import and depend on other packages, but they must not import and depend on applications!
In moon, you can denote a project as a library using the layer
setting in moon.*.
layer: 'library'
Every tool that you'll utilize in a repository will have its own configuration file. This will be a lot of config files, but regardless of what tool it is, where the config file should go will fall into 1 of these categories:
Dependencies, also known as node modules, are required by all projects, and are installed through a package manager like npm, pnpm, or yarn. It doesn't matter which package manager you choose, but we highly suggest choosing one that has proper workspaces support. If you're unfamiliar with workspaces, they will:
package.json's in a repository using glob patterns.package.json's at once, in the required locations.node_modules (to emulate an installed package).node_modules when applicable.All of this functionality enables robust monorepo support, and can be enabled with the following:
import PackageWorkspaces from '../../partials/node/package-workspaces.mdx';
<PackageWorkspaces />:::info
Package workspaces are not a requirement for monorepos, but they do solve an array of problems around module resolution, avoiding duplicate packages in bundles, and general interoperability. Proceed with caution for non-workspaces setups!
:::
The following common commands can be used for adding, removing, or managing dependencies in a workspace. View the package manager's official documentation for a thorough list of commands.
import WsCommands from './partials/workspace-commands.mdx';
<WsCommands />While not a strict guideline to follow, we've found that installing universal developer tool related
dependencies (Babel, ESLint, Jest, TypeScript, etc) in the root package.json as devDependencies
to be a good pattern for consistency, quality, and the health of the repository. It provides the
following benefits:
With that being said, this does not include development dependencies that are unique to a project!
Product, application, and or framework specific packages should be installed as production
dependencies in a project's package.json. We've found this pattern to work well for the
following reasons:
One of the primary reasons to use a monorepo is to easily share code between projects. When code is co-located within the same repository, it avoids the overhead of the "build -> version -> publish to registry -> upgrade in consumer" workflow (when the code is located in an external repository).
Co-locating code also provides the benefit of fast iteration, fast adoption, and easier migration (when making breaking changes for example).
With package workspaces, code sharing is a breeze. As mentioned above,
every project that contains a package.json that is part of the workspace, will be symlinked into
node_modules. Because of this, these packages can easily be imported using their package.json
name.
// Imports from /packages/utils/package.json
import utils from '@company/utils';
Because packages are symlinked into node_modules, we can depend on them as if they were normal npm
packages, but with 1 key difference. Since these packages aren't published, they do not have a
version to reference, and instead, we can use the special workspace:^ version (yarn and pnpm only,
use * for npm).
{
"name": "@company/consumer",
"dependencies": {
"@company/provider": "workspace:^"
}
}
The workspace: version basically means "use the package found in the current workspace". The :^
determines the version range to substitute with when publishing. For example, the workspace:^
above would be replaced with version of @company/provider as ^<version> when the
@company/consumer package is published.
There's also workspace:~ and workspace:* which substitutes to ~<version> and <version>
respectively. We suggest using :^ so that version ranges can be deduped.
When sharing packages in a monorepo, there's typically 3 different kinds of packages:
A local only package is just that, it's only available locally to the repository and is not published to a registry, and is not available to external repositories. For teams and companies that utilize a single repository, this will be the most common type of package.
A benefit of local packages is that they do not require a build step, as source files can be
imported directly (when configured correctly). This avoids a lot of
package.json overhead, especially in regards to exports, imports, and other import patterns.
An internal package is published to a private registry, and is not available to the public.
Published packages are far more strict than local packages, as the package.json structure plays a
much larger role for downstream consumers, as it dictates how files are imported, where they can be
found, what type of formats are supported (CJS, ESM), so on and so forth.
Published packages require a build step, for both source code and TypeScript types (when applicable). We suggest using esbuild or Packemon to handle this entire flow. With that being said, local projects can still import their source files.
An external package is structured similarly to an internal package, but instead of publishing to a private registry, it's published to the npm public registry.
External packages are primarily for open source projects, and require the repository to also be public.
Co-locating packages is great, but how do you import and use them effectively? The easiest solution is to configure resolver aliases within your bundler (Webpack, Vite, etc). By doing so, you enable the following functionality:
<Tabs groupId="bundler" defaultValue="vite" values={[ { label: 'Vite', value: 'vite' }, { label: 'Webpack', value: 'webpack' }, ]}
<TabItem value="vite">
import path from 'path';
import { defineConfig } from 'vite';
export default defineConfig({
// ...
resolve: {
alias: {
'@company/utils': path.join(__dirname, '../packages/utils/src'),
},
},
});
const path = require('path');
module.exports = {
// ...
resolve: {
alias: {
'@company/utils': path.join(__dirname, '../packages/utils/src'),
},
},
};
:::info
When configuring aliases, we suggest using the package.json name as the alias! This ensures that
on the consuming side, you're using the package as if it's a normal node module, and avoids
deviating from the ecosystem.
:::
We suggest using TypeScript project references. Luckily, we have an in-depth guide on how to properly and efficiently integrate them!