Skip to content

Monorepo Tooling

This document defines the complete tooling stack for the StateLoom monorepo. Every tool choice is justified by the project's constraints: solo contributor, ~15–20 packages, library-first (not application-first), and strict TypeScript throughout.

Tool Stack Summary

ConcernToolWhy
Package managerpnpmStrict isolation, workspace:^ protocol, fastest
Build orchestratorTurborepoMinimal config, task caching, zero opinions
Library bundlertsupesbuild-powered, ESM+CJS+dts in one config
App bundlerViteNative framework plugins, fast HMR
Test runnerVitestVite-native, workspace-aware, Jest-compatible API
LinterESLint v9 (flat config)Single root config, typescript-eslint, extensible
FormatterPrettierUniversal standard, no config debates
TypeScriptStrict mode + project referencesMaximum type safety
VersioningChangesetsCoordinated multi-package releases
Git hookssimple-git-hooks + lint-stagedLightweight, no Husky overhead
Docs siteVitePress + Mermaid + ShikiMarkdown-first, fast, batteries-included
CIGitHub ActionsLint, typecheck, build, test, size check
Node version20 LTSStable, corepack support

Package Manager: pnpm

yaml
# pnpm-workspace.yaml
packages:
  - 'packages/*'
  - 'examples/*'

Why pnpm over npm/yarn:

  • Strict dependency resolution: Isolated node_modules prevents phantom dependencies across 15+ packages
  • Content-addressable store: Hard links mean near-zero disk duplication
  • workspace:^ protocol: Inter-package references resolve correctly during both development and publishing
  • Speed: Fastest install times of any package manager
  • Ecosystem adoption: Used by Vue, Vite, TanStack, Turborepo
ini
# .npmrc
strict-peer-dependencies=true
auto-install-peers=false
shamefully-hoist=false

Build Orchestrator: Turborepo

Turborepo provides task caching and dependency-aware parallel execution with a single config file.

jsonc
// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "tsconfig.json", "tsup.config.ts"],
      "outputs": ["dist/**"],
    },
    "test": {
      "dependsOn": ["build"],
      "inputs": ["src/**", "__tests__/**", "vitest.config.ts"],
    },
    "test:watch": {
      "cache": false,
      "persistent": true,
    },
    "lint": {
      "inputs": ["src/**", "eslint.config.ts"],
    },
    "typecheck": {
      "dependsOn": ["^build"],
      "inputs": ["src/**", "tsconfig.json"],
    },
    "dev": {
      "cache": false,
      "persistent": true,
      "dependsOn": ["^build"],
    },
    "clean": {
      "cache": false,
    },
  },
}

Key behaviors:

  • "dependsOn": ["^build"] — build dependencies before building the package
  • Task caching — unchanged packages skip rebuilds (~200ms restore vs 30s build)
  • --filter — run tasks for specific packages: turbo run test --filter=@stateloom/core
  • Affected detection — turbo run test --filter=...[origin/main] runs only changed packages + dependents

Library Bundler: tsup

tsup produces ESM + CJS dual outputs with .d.ts declarations from minimal config:

typescript
// packages/core/tsup.config.ts
import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'],
  dts: true,
  sourcemap: true,
  clean: true,
  treeshake: true,
  minify: false, // consumers handle minification
  outDir: 'dist',
});

Why tsup over Vite library mode: tsup generates .d.ts natively and handles CJS/ESM dual output with proper package.json exports field. Vite library mode requires vite-plugin-dts and more configuration.

Package.json Exports

Every package uses conditional exports:

jsonc
{
  "type": "module",
  "sideEffects": false,
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.mts",
        "default": "./dist/index.mjs",
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs",
      },
    },
  },
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.mts",
  "files": ["dist", "README.md"],
}

Test Runner: Vitest

Vitest runs in workspace mode with per-package configs:

typescript
// vitest.workspace.ts (root)
export default ['packages/*/vitest.config.ts', 'examples/*/vitest.config.ts'];
typescript
// packages/core/vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    coverage: {
      provider: 'v8',
      thresholds: {
        statements: 95,
        branches: 95,
        functions: 95,
        lines: 95,
      },
    },
  },
});

Coverage target: near 100%. Every package must maintain 95%+ coverage. Coverage reports are generated per-package and aggregated in CI.

Linting: ESLint v9 (Flat Config)

A single eslint.config.ts at the root covers all packages:

typescript
// eslint.config.ts
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.strictTypeChecked,
  ...tseslint.configs.stylisticTypeChecked,
  {
    languageOptions: {
      parserOptions: {
        projectService: true,
        tsconfigRootDir: import.meta.dirname,
      },
    },
    rules: {
      // Enforce strict TypeScript patterns
      '@typescript-eslint/no-explicit-any': 'error',
      '@typescript-eslint/no-unsafe-assignment': 'error',
      '@typescript-eslint/no-unsafe-call': 'error',
      '@typescript-eslint/no-unsafe-member-access': 'error',
      '@typescript-eslint/no-unsafe-return': 'error',
      '@typescript-eslint/consistent-type-imports': [
        'error',
        { prefer: 'type-imports', fixStyle: 'inline-type-imports' },
      ],
      '@typescript-eslint/no-unused-vars': [
        'error',
        { argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
      ],

      // No enums — use const objects + as const
      'no-restricted-syntax': [
        'error',
        {
          selector: 'TSEnumDeclaration',
          message:
            'Use `as const` objects instead of enums. See docs/contributing/coding-guidelines.',
        },
      ],
    },
  },
  {
    ignores: ['**/dist/**', '**/node_modules/**', '**/.turbo/**'],
  },
);

Formatting: Prettier

jsonc
// .prettierrc
{
  "semi": true,
  "singleQuote": true,
  "trailingComma": "all",
  "printWidth": 100,
  "tabWidth": 2,
  "arrowParens": "always",
  "endOfLine": "lf",
}

TypeScript: Strict Mode

jsonc
// tsconfig.base.json
{
  "compilerOptions": {
    "strict": true,
    "exactOptionalPropertyTypes": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true,
    "forceConsistentCasingInFileNames": true,
    "verbatimModuleSyntax": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "isolatedModules": true,
    "skipLibCheck": true,
    "lib": ["ES2022"],
  },
}

Each package extends this:

jsonc
// packages/core/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src",
  },
  "include": ["src"],
  "references": [],
}

Versioning: Changesets

jsonc
// .changeset/config.json
{
  "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
  "changelog": "@changesets/cli/changelog",
  "commit": false,
  "fixed": [],
  "linked": [["@stateloom/*"]],
  "access": "public",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": ["*-example"],
}

Release Workflow

bash
pnpm changeset                 # describe changes
pnpm changeset version         # bump versions + update CHANGELOGs
pnpm sync-example-versions     # sync example deps to new version
git add . && git commit -m "version packages"
git push                       # CI runs release workflow

Syncing Example Versions

Examples use real npm version ranges (e.g., ^1.0.0-alpha.0) instead of workspace:^ so that StackBlitz can install packages from the npm registry when loading examples directly from the GitHub repo. pnpm still resolves these to local workspace packages during development because the version range matches.

After any version bump, run:

bash
pnpm sync-example-versions              # reads version from packages/core
pnpm sync-example-versions 1.2.0-beta.0 # explicit version override

The script updates all @stateloom/* dependencies in examples/*/package.json to ^<version>.

Git Hooks: simple-git-hooks + lint-staged

jsonc
// In root package.json
{
  "simple-git-hooks": {
    "pre-commit": "pnpm lint-staged",
  },
  "lint-staged": {
    "*.{ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{json,md,yml,yaml}": ["prettier --write"],
  },
}

CI/CD: GitHub Actions

PR Checks (ci.yml)

yaml
name: CI
on: [pull_request]
jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'pnpm' }
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm typecheck
      - run: pnpm build
      - run: pnpm test

Release (release.yml)

yaml
name: Release
on:
  push:
    branches: [main]
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'pnpm', registry-url: 'https://registry.npmjs.org' }
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - run: pnpm test
      - uses: changesets/action@v1
        with:
          publish: pnpm release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

Bundle Size Check (size-check.yml)

yaml
name: Size Check
on: [pull_request]
jobs:
  size:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'pnpm' }
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - run: pnpm size

Pre-Publish Validation

Before each publish, these tools validate package correctness:

ToolPurpose
publintValidate package.json exports, files, types
arethetypeswrongCheck .d.ts resolution for ESM/CJS
size-limitTrack and enforce bundle size budgets
knipFind unused exports, dependencies, files

Root Scripts

jsonc
{
  "scripts": {
    "build": "turbo run build",
    "test": "turbo run test",
    "test:watch": "turbo run test:watch",
    "lint": "turbo run lint",
    "lint:fix": "eslint --fix .",
    "typecheck": "turbo run typecheck",
    "format": "prettier --write .",
    "clean": "turbo run clean && rm -rf node_modules",
    "changeset": "changeset",
    "version-packages": "changeset version",
    "release": "turbo run build && changeset publish",
    "new-package": "tsx scripts/new-package.ts",
    "sync-example-versions": "tsx scripts/sync-example-versions.ts",
    "size": "tsx scripts/check-bundle-size.ts",
  },
}

Documentation Site: VitePress

The docs site uses VitePress with vitepress-plugin-mermaid for browser-side diagram rendering and Shiki (built-in) for syntax highlighting. See Documentation Guidelines for details.