This section explains how to install and run the code locally on your machine.
npm ci
Edit and save any source file. This runs both tsc --build --watch
and webpack dev server in parallel. Browse to: http://localhost:3000
npm run dev
npm run build
npm run clean
npm test
This can be done per workspace or in groups. Often, I update my dev tool chain shared by all projects at once. Here's a few scenarios:
# all workspaces implicitly
npm update --save #--save to persist package.json after update. This is traditional npm stuff.
# OR update/install ALL workspaces
npm install package@<new_version> -ws
This will bring up to date any module with a minor/patch change (or updates based on your depedency configuration. i.e. ^...
latest minor/patch, or ~...
: latest patch or *
latest version.
npm update --save -w <workspace> [-w additional_workspace(s)]
# OR (install specific new version)
npm install package@<version> -w <workspace> [-w additional_workspace(s)]
Use -w
argument to target workspaces. I often use this when updating web only projects w/ specific dependencies only used by those workspaces. (Like: testing-library/react@15
for example).
npm outdated
now works with all workspaces. The output lets you know which workspace requires the update. This can be useful when deciding how to install/update dependencies at a macro level.
Package Current Wanted Latest Location Depended by
@stripe/stripe-js 2.4.0 2.4.0 3.3.0 node_modules/@stripe/stripe-js blazer-ui@npm:@iwsllc/blazer-ui@0.2.46
@testing-library/react 14.3.1 14.3.1 15.0.6 node_modules/@testing-library/react public@npm:@iwsllc/blazer-public@0.2.46
@testing-library/react 14.3.1 14.3.1 15.0.6 node_modules/@testing-library/react manager@npm:@iwsllc/blazer-manager@0.2.46
@testing-library/react 14.3.1 14.3.1 15.0.6 node_modules/@testing-library/react blazer-ui@npm:@iwsllc/blazer-ui@0.2.46
Here is an example project: blazer-ui (a package library) and public, manager (entry point apps that share blazer-ui). The Depended by column tells you which workpace needs the update. To update these, I could simply:
npm i -D @testing-library/react@15 -w apps/public -w apps/manager -w packages/blazer-ui
npm i -D stripe-js@3 -w package/blazer-ui
The changes would be made and persisted to each package.json.
NOTE for Docker: I didn't setup a docker-compose service for this, but you can run it in docker just the same by running docker compose run web /bin/sh
and then interactively running the commands below.
# Build first if you haven't already. Docker is already built.
npm run build # if necessary
# npm start [any args]
npm start test-arg
# OUTPUT:
args: test-arg
add 2+3: 5
divide 2/3: 0.6666666666666666
multiply 2*3: 6
multiplyRef 2*3: 6
Ideally, we run the web app in docker so we can pair it with other service dependencies like an API or whatever. In this example, you can see this with:
docker compose up
Browse to: http://localhost:3000
If you make changes to any code in the lib-ui
, lib-shared
, or web
, you'll see an immediate browser refresh with the latest change.
We're combining both Typescript's build system with npm workspaces to simplify monorepo configuration.
With this monorepo using npm workspaces, each workspace will automatically link under node_modules/{package_name}
making it available to any module in the repository by package name. So for example, the workspace packages/lib-main
named @potatoes/lib-main
will be available to any other project in the repository.
Organizationally, the entry-point app ./apps/app
and ./apps/web
live under ./apps
in the repository, and the dependencies are organized under ./packages
. This is purely by preference. Workspaces can exist in any subfolder.
I've intentionally avoided CRA/react-scripts
in this scenario for flexibility. This is my preference. But note that I'm using ts-loader
rather than babel-typescript
to render the output. This allows me to use projectReferences
which is part of Typescript's own monorepo solution. The idea is: you can use references
in tsconfig.json to build dependencies as needed within a monorepo. More on that later.
Also, to keep things consistent, I've elected to use ESM (type: 'module'
) for all projects across the repository. This allows me to "share" code directly (between Node and Browser projects) without the build tooling complaining about ESM vs non-ESM code. i.e. We have a shared project/workspace with browser-friendly code and shared TS Types. It's technically ESM, but ts-loader doesn't care when rendering a browser bundle.
All relative path module imports must include extensions.
For Node projects, all files must use the .mjs|.cjs
extension to indicate CommonJS or ESM. With Typescript layered into this, that makes file extensions on disk: mts|.cts
respecively, and when you import, use the extension generated in the output: i.e. (relative import: import from './module.mjs'
(imports ./module.mts
), or Package imports: import from '@scope/package/Module'
). We exclude extensions for package imports because package.json
exports: {...}
handles the mapping extension for us.
For browser projects, I'm simply using .ts/.tsx
. But it also means that within a package, all imports must resolve using file extensions like: import from './relative.js'
(imports ./relative.ts
). And Package imports: import from '@potatoes/lib-ui/Thing'
Did I mention code-splitting? All of these projects are built with ESM and are capable of code splitting via sub-path imports. I've intentionally excluded all barrel files (index/entry points that only re-export other modules). This forces you to import direct modules only. Take a peek at the lib-shared
exports config
i.e. Rather than:
(Importing module from the root package index)
import { Thing } from '@potatoes/lib-ui'
Use subpaths:
import { Thing } from '@potatoes/lib-ui/Thing'
or (for React code-splitting)
lazy(() => import('@potatoes/lib-ui/Thing'))
Typescript has its own type of monorepo dependency management through the use of references
. This allows you to define a dependency tree for each project. And with
composite
and incremental
set to true
, it improves build performance by only building what changes in the tree. I like to think of it as top/down (entrypoint app referencing down to dependencies), and I treat each workspace as a "project".
With all the projects having their own tsconfig, to build the entire monorepo tree, you can setup a single tsconfig.json at the root of the monorepo with references to each entry point project you want to build via the tsc
command. This root tsconfig will also ignore all files with files: []
.
There are a couple things to know first before actually setting these configs up. First: VSCode can ONLY look at tsconfig.json
. This means for each project, tsconfig
needs to include all files: test, code, everything. But for the build, you only want to track code files, so I like to create a second tsconfig.build.json
that excludes tests and mocks. The main caveat with this is: yes, you can extend tsconfig.json
, but references
are unique to each tsconfig
file. So tsconfig.build.json
needs its own references
to its dependencies' tsconfig.build.json
files. You can see this in the project here.