ChicagoDave/tsf: Multi-target typescript build tool


Publish workspace packages to npm without rewriting your imports by hand.

tsf is a multi-target TypeScript build tool for monorepos. It compiles one source into multiple outputs — rewriting @scope/pkg workspace imports to relative paths for npm, preserving them for local dev, and bundling them for browsers. Declarations included.

You have a TypeScript monorepo. Locally, import { Thing } from '@scope/core' resolves via workspace symlinks. But when you npm publish, that import means nothing — consumers need real relative paths.

Today your options are:

  • A custom build script that rewrites imports, manages declarations, and coordinates builds across packages (often hundreds of lines of bash)
  • A bundler that inlines everything, losing tree-shaking for your consumers
  • Manual parallel tsconfig files per output target, kept in sync by hand

tsf replaces all of that with a config file:

{
  "projects": ["packages/*/tsconfig.json"],
  "targets": {
    "local": {
      "module": "commonjs",
      "outDir": "dist",
      "imports": "preserve",
      "declarations": true
    },
    "npm": {
      "module": "commonjs",
      "outDir": "dist-npm",
      "imports": "relative",
      "declarations": true,
      "condition": "publish"
    }
  }
}

The local target keeps workspace imports intact for development. The npm target rewrites them to relative paths — only for packages that have publishConfig. One source, two outputs.

Your source code:

import { createLogger } from '@myorg/logger';
import { validate } from '@myorg/schema';

Local build (imports: "preserve") — unchanged:

const { createLogger } = require("@myorg/logger");
const { validate } = require("@myorg/schema");

npm build (imports: "relative") — rewritten:

const { createLogger } = require("../logger/dist-npm/index.js");
const { validate } = require("../schema/dist-npm/index.js");

Declarations are rewritten too. No manual work. No build script.

pnpm add -D tsf
# or
npm install -D tsf
tsf init          # Generate config from existing project
tsf build         # Build default target
tsf build --all   # Build all targets
tsf version 1.0.0 --condition publish  # Set version on npm packages
tsf publish                            # Publish to npm in dependency order
tsf list --condition publish           # List npm packages
tsf info          # Show resolved build plan

Option Description
module Output module format: commonjs, esnext, es2020, es2022, node16, nodenext
format Bundler format: cjs, esm, iife, umd
outDir Output directory (relative to each package)
outFile Single output file (alternative to outDir)
imports Import resolution strategy (see below)
declarations Generate .d.ts files
condition Conditional target — "publish" only applies to packages with publishConfig
transpiler Compiler: tsc (default), esbuild, swc
bundler Bundler: esbuild, rollup (requires imports: "bundle")
banner Prepend to output (e.g., "#!/usr/bin/env node" for CLI tools)
external Dependencies to exclude from bundling

Add ts-forge.json in any package directory:

{
  "targets": {
    "local": { "skip": true }
  }
}

Import Resolution Strategies

Strategy Use Case What Happens
preserve Local dev Imports left as-is
relative npm publish @scope/pkg → relative paths
bundle Browser/CLI All imports inlined
specifier-map Deno Rewritten per import map

Build targets across all packages in dependency order.

tsf build                       # Build default (unconditional) targets
tsf build --all                 # Build all targets
tsf build --target npm          # Build specific target
tsf build --condition publish   # Build targets matching condition
tsf build --all --clean         # Clean output dirs first
tsf build --all --no-check      # Skip type checking
tsf build --watch               # Watch mode
tsf build --parallel 4          # Limit concurrency
tsf build --all --sync-package-json  # Sync package.json after build

Type-check all projects without emitting.

Display the resolved build plan: packages, dependency order, and targets with per-target package counts.

Generate ts-forge.config.json by detecting existing project structure. Reads package.json, tsconfig.json, and workspace configuration. Safe to re-run — merges new targets without overwriting existing config.

Generate main, types, and exports fields in each package’s package.json from target configuration. Preserves all other fields. Publish-conditioned targets are preferred for field values.

Verify build outputs:

  • Entry points declared in package.json exist on disk
  • Declaration files (.d.ts) exist alongside JavaScript files
  • No workspace specifiers (@scope/pkg) leaked into non-preserve output

Exit code 1 if any errors found.

tsf version | --bump [options]

Set or bump version in package.json for workspace packages.

tsf version 0.9.64-beta                     # Set all packages to explicit version
tsf version 0.9.64-beta --condition publish  # Only npm-published packages
tsf version --bump patch                     # Increment patch version
tsf version --bump prerelease --preid beta   # Bump prerelease suffix
tsf version 0.9.64-beta --filter @scope/pkg  # Specific package(s)
tsf version 0.9.64-beta --changed --condition publish  # Only changed packages
tsf version 0.9.64-beta --dry-run           # Preview without writing

Option Description
Explicit version string (mutually exclusive with --bump)
--bump Semver increment: major, minor, patch, prerelease
--preid Prerelease identifier (default: beta)
--condition Only packages matching target condition (e.g., publish)
--filter Restrict to specific package(s), repeatable
--changed Only bump packages that changed since last npm publish
--dry-run Show changes without writing

Publish workspace packages to npm in dependency order. Only publishes packages that have publishConfig in their package.json. Verifies npm login before publishing.

tsf publish                          # publish all publishable packages
tsf publish --dry-run                # preview without publishing
tsf publish --tag beta               # publish with npm dist-tag
tsf publish --filter @scope/pkg      # specific package(s)

Option Description
--tag npm dist-tag (default: latest)
--condition Only packages matching target condition
--filter Restrict to specific package(s), repeatable
--dry-run Pass --dry-run to npm publish

Show packages that have changed since their last npm publish. Compares each package against the npm registry — a package is “changed” if it has never been published, its local version differs, or it has git changes since the version tag.

tsf changed                        # all packages
tsf changed --condition publish    # only npm-published packages

Option Description
--condition Only packages matching target condition
--filter Restrict to specific package(s), repeatable

List workspace packages, one per line, in dependency order. Useful for scripting and verifying which packages match a condition.

tsf list                        # all packages
tsf list --condition publish    # only npm-published packages

Option Description
--condition Only packages matching target condition
--filter Restrict to specific package(s), repeatable

Generate .github/workflows/tsf.yml with auto-detected package manager setup and Node.js version matrix.

tsf detects pnpm, npm, and yarn workspaces. It respects pnpm-workspace.yaml exclusion patterns (e.g., !packages/forge).

Targets with condition: "publish" automatically apply only to packages that have publishConfig in their package.json. Other packages are skipped.

$ tsf info
Targets:
  local: commonjs → dist, imports=preserve
  npm: commonjs → dist-npm, imports=relative [condition: publish] (18 packages)

tsf caches builds based on source content, package.json version, target config, and dependency cache keys. Unchanged packages are skipped on subsequent builds. Version bumps automatically invalidate the cache. Use --clean to bypass the cache entirely. Cache is stored in .tsf-cache/ at the workspace root.

pnpm install
pnpm build
pnpm test

MIT



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *