Lesson 08 / 14
Workspaces
Keeping multiple packages in a single repository, the workspace declaration, resolving local packages through links, topological build order, and versioning strategies.
Contents
In the previous lessons, every package came from outside: requested with a range in the manifest, installed into a directory, resolved from there. For packages a team writes itself, this cycle is heavy — every fix made in a library has to be published before it can be tried in the application that uses it.
The measurement library has reached this point. The formatting helper was pulled out into its own package, and a report tool that presents the measurement result was added. All three are the same team’s product and change together. This lesson covers how the three are kept in a single repository.
The Multi-Package Repository
The arrangement that keeps multiple packages in a single version-control repository is called a monorepo. The problems it solves are concrete:
Split changes. Adding a parameter to a library and updating the tools that use it is a multi-step operation across separate repositories: publish, wait, update the range, install. In a single repository, this is a single commit.
Version lag. In separate repositories, there is always a gap between the library’s latest state and the state the application is using. In a single repository, this gap is zero.
Duplicated configuration. Linter rules, formatting settings, and the test setup are kept in one place.
There is a cost too. The repository grows; which packages are affected by each change has to be computed; test time tends toward the sum of all packages; versioning and release decisions get more complex. Not worth paying if the packages do not change together. The rule of thumb: a single repository suits packages updated together in the same change; separate repositories suit packages that evolve independently.
The Workspace Declaration
The manifest at the root says which subdirectories are packages, and marks itself as not to be published:
{ "name": "measurement-repo", "private": true, "workspaces": ["packages/*"] }
The private field is a safeguard: the root directory is not a library, and
publishing it by accident is not wanted. The workspaces field tells the installation
tool to treat every directory matching the listed pattern as a workspace.
The three packages in the repository are these:
{ "name": "format-helper", "version": "1.4.0", "type": "module", "main": "index.mjs" }
{ "name": "text-measurer", "version": "0.1.0", "type": "module", "exports": { ".": "./src/index.mjs" }, "dependencies": { "format-helper": "^1.4.0" } }
{ "name": "report-tool", "version": "0.2.0", "type": "module", "main": "index.mjs", "dependencies": { "text-measurer": "^0.1.0" } }
The point worth noting: the dependencies use no special notation stating they are
local. Package text-measurer requests format-helper by its bare name and a range,
the same as any package coming from outside. Locality is a decision made by
installation, not the manifest: if the installer sees the requested name is a
workspace in the repository and its version fits the range, it does not go to the
registry.
How the Links Are Installed
A local package is placed not by copying it into the tree, but by a symbolic link:
$ for l in node_modules/*; do printf '%s -> %s\n' "$l" "$(readlink "$l")"; done node_modules/format-helper -> ../packages/format node_modules/report-tool -> ../packages/report node_modules/text-measurer -> ../packages/measurer
The symbolic link concept was defined in the links lesson of the Introduction to Linux
course: an entry that points at a target without being the target itself. The
consequence here is decisive. The resolution rule looks for the name in
node_modules and does not check whether the entry it finds is a link; the directory
it finds is the source tree itself. A change made in the source is visible to the
consumer without a reinstall.
// file: packages/format/index.mjs export function decimal(value, digits = 2) { return value.toFixed(digits); }
// file: packages/measurer/src/index.mjs -- segmentation and statistics modules sit in the same directory import { decimal } from 'format-helper'; import { splitSentences, splitWords } from './segmentation.mjs'; import { averageLength, longestWord } from './statistics.mjs'; export function measure(text) { const words = splitWords(text); return { wordCount: words.length, sentenceCount: splitSentences(text).length, averageLength: decimal(averageLength(words)), longest: longestWord(words), }; }
// file: packages/report/index.mjs import { measure } from 'text-measurer'; export function report(text) { const m = measure(text); return `${m.wordCount} words / ${m.sentenceCount} sentences / avg. ${m.averageLength}`; }
// file: try.mjs -- repo root import { report } from 'report-tool'; console.log(report('A module carries its own scope. A script runs in global scope.'));
$ node try.mjs 12 words / 2 sentences / avg. 4.08
Three packages found each other by their bare names without any of them being published. This arrangement has a silent trap: an import that works locally may not work once published. The directory reached through the link is the entire source tree; the published archive carries only the files listed in the manifest. If the exports map or file list is missing something, the error only shows up after publishing — which is why checking the archive’s contents before publishing has to be a habit in multi-package repositories.
Build Order
Since the packages depend on one another, bulk operations performed on them — build,
test, publish — cannot run in an arbitrary order. Testing report-tool requires
text-measurer to be ready first.
The correct order is the topological sort of the dependency graph. This sort, introduced in the Data Structures course, applies directly here:
// file: order.mjs -- repo root, reads the packages/ directory import { readdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; const root = 'packages'; const packages = new Map(); for (const dir of (await readdir(root)).sort()) { const manifest = JSON.parse(await readFile(join(root, dir, 'package.json'), 'utf8')); packages.set(manifest.name, Object.keys(manifest.dependencies ?? {})); } // Topological sort by depth-first search: dependencies are published first. const result = []; const state = new Map(); function visit(name) { if (state.get(name) === 'done') return; if (state.get(name) === 'visiting') throw new Error(`cycle: ${name}`); state.set(name, 'visiting'); for (const dependency of packages.get(name) ?? []) { if (packages.has(dependency)) visit(dependency); } state.set(name, 'done'); result.push(name); } for (const name of packages.keys()) visit(name); console.log(result.join(' -> '));
$ node order.mjs format-helper -> text-measurer -> report-tool
Two details of the script matter. Dependencies not present in the repository are skipped: the order of external packages does not concern us, they are already installed. Second, visit state has three values; reaching a “visiting” node again means a cycle, and an error is raised. Unlike a circular dependency between modules, one between packages cannot be resolved at all.
The same graph answers a second question too: which packages are affected by a change made in one package? The answer is found by walking the graph in the reverse direction, and it lets only the affected packages be tested. This is the method that keeps test time manageable in large repositories.
Versioning Strategy
The versions of packages in the repository are managed in two ways.
Unified versioning. All packages carry the same number and are published together. The publish decision is single and easy to track; in exchange, a package that has not changed at all also gets a new version, forcing consumers into an unnecessary upgrade.
Independent versioning. Each package carries its own number and is published only when it changes — honest toward the consumer; in exchange, it requires tracking which package is at which version and keeping in-repo ranges up to date.
The choice depends on who the consumer is. If the packages are only used within the repository, unified versioning makes work easier. If they are published outward, independent versioning is the only choice faithful to the contract established in the previous lesson: a version number carries meaning only when it announces a real change.
Summary
- A multi-package repository lets packages that change together be updated in a single commit and share a single configuration; the cost goes unpaid if the packages evolve independently.
- The workspace list in the root manifest states which directories are packages; dependencies use no special notation to mark themselves as local.
- Local packages are placed in the tree with a symbolic link; a change made in the source is visible without requiring a reinstall.
- Because the entire source tree is reachable through the link, files missing from the published archive are only noticed after publishing.
- Bulk operations on packages run in the topological order of the dependency graph; walking in the reverse direction gives the packages affected by a change.
Next Step
Most of the packages in the tree were not chosen directly; they are there because they are some other package’s dependency. These packages run code too, and they can execute scripts during installation. The next lesson measures the size of this transitive mass, and covers the defensive sides of dependency selection, locking, and verification.
To keep your progress and take notes, Log in
My notes
Log in to take notes.