Replacing tsup in a TS Library Monorepo: Vite+ 1.0 Build, DTS, and Publish in One Toolchain

Replacing tsup in a TS Library Monorepo: Vite+ 1.0 Build, DTS, and Publish in One Toolchain

pr0h0•
vitetypescriptmonorepobuild-toolstsup
AI Usage (95%)

Introduction: Replacing tsup in a TypeScript Library Monorepo with Vite+ 1.0

The InfoQ item this post is based on — surfaced through a Google News seed timestamped 2026-10-14T06:15:40Z — reports that Cloudflare's newly acquired Vite+ has reached 1.0 with GitLab CI support, lifecycle hooks, and a tsup migration path. That is a lot to pack into one headline, and it lands right where many of us already work: a pnpm workspace with a dozen TypeScript libraries, each built by tsup, held together with npm-run-all fan-outs and a few shell scripts nobody wants to own.

This post covers what replacing tsup with Vite+ 1.0 actually involves for a library monorepo: translating the build config, generating DTS output that type resolution accepts, wiring the GitLab CI pipeline, and deciding which parts of publish a bundler should never own. You get the tsup baseline I run today, the failures I hit on the first migration attempt, and a decision table for when to migrate, wait, or stay put.

My take up front: the CI and hook integration is the part worth watching. "One toolchain" covering bundling, declaration files, and publishing is a claim I'd want to test before believing it. Here I couldn't retrieve the primary announcement or changelog from the seed, so I'll be explicit about what's reported versus what I actually ran.

What the Vite+ 1.0 Release Report Claims, and What I Could Confirm

The specifics attributed to Vite+ 1.0

The seed resolves to an InfoQ article whose snippet reads: "Cloudflare's Newly Acquired Vite+ Reaches 1.0 with GitLab CI Support, Hooks, and tsup Migration." That snippet is all I have. No release notes, no changelog, no docs page. Everything in this subsection is attributed, not verified by me.

Claim in the reportStatus on my side
Vite+ reached 1.0Reported only; no changelog retrieved
Cloudflare acquired the projectReported only; not independently confirmed
GitLab CI support shipped at 1.0Reported only; no pipeline docs retrieved
Lifecycle hooksReported only; the config surface is unknown to me
tsup migration pathReported only; no migration guide retrieved

Separating sourced claims from my own reproduction

Three labels apply throughout, and I'll stick to them:

  • Reported — stated in the InfoQ item via the news seed. Secondary summary; treat it as a lead, not a fact.
  • Confirmed — I ran it in the harness below, on my machine, with the commands shown.
  • Untested — a reasonable expectation I have not executed against a published Vite+ 1.0 binary.
📝

The seed URL is a Google News RSS redirect, so I couldn't follow it to a primary source, and I'm not about to invent a vite.config.ts hook key from a headline. If you need the exact option names, get them from the release notes, not from a blog post — including this one.

The tsup Baseline in a TypeScript Library Monorepo

A minimal tsup config for entries, externals, and dual ESM+CJS output

This is the actual config from the core package in my workspace, comments stripped:

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

export default defineConfig({
entry: { index: "src/index.ts", client: "src/client/index.ts" },
format: ["esm", "cjs"],
target: "node20",
dts: true,
sourcemap: true,
clean: true,
treeshake: true,
external: ["react", /^@acme//, /^node:/],
outExtension({ format }) {
  return { js: format === "cjs" ? ".cjs" : ".js" };
},
});

Two things matter for a migration: external keeps workspace siblings from being inlined, and outExtension is what lets package.json keep "type": "module" while still shipping a .cjs require target.

Where tsup leaks into glue: DTS generation, watch mode, and publish steps

Three leaks I keep hitting:

  1. DTS is a second build. dts: true runs rollup-plugin-dts over the whole graph. In a workspace it wants --dts-resolve, and it still fails with Cannot find module '@acme/utils' or its corresponding type declarations when a sibling's dist hasn't been built yet. That's why our pipeline has a build-order step ahead of the build step.
  2. Watch mode stops at the package boundary. tsup --watch rebuilds the package it runs in, not its dependents. Our dev script is a four-way concurrently fan-out, and it drifts every time someone adds a package.
  3. Publish orchestration is separate. tsup builds; it doesn't version, order, or publish. npm-run-all build publish in the wrong order is how you ship a package that imports a sibling version that doesn't exist yet.

That glue is what a "one toolchain" pitch is really aiming at, and it's a fair target.

Migrating One Package from tsup to Vite+

Translating build config, entry points, and externalization rules

The concepts map cleanly, but the externalization default is inverted — that's the trap:

tsupVite library modeStatus
format: ["esm","cjs"]Two build.lib passes (separate formats)Convention; verify against your version
entry objectbuild.lib.entry mapConvention
external: [...] opt-outrollupOptions.external — everything imported is bundled unless listedUntested against Vite+ 1.0
target: "node20"build.target / ssr targetConvention
dts: trueNo equivalent built in; needs vite-plugin-dts or tsc --emitDeclarationOnlyReported gap
⚠️

Vite library mode inlines dependencies that aren't in rollupOptions.external, which is the opposite of what tsup does for you. Migrate a package that imports lodash-es or a workspace sibling and forget to externalize it, and the build still succeeds — the bundle just silently doubles in size.

DTS output and the package.json exports map for types, import, and require

The migration actually pays off here, because the exports map is tool-agnostic and most monorepos get it wrong. tsup emits dist/index.d.ts and dist/index.d.cts for dual-format builds. Pointing both types and require at the .d.ts produces a "masquerading as CJS" error under type resolution:

packages/core/package.json (excerpt)
{
"type": "module",
"exports": {
  ".": {
    "import": { "types": "./dist/index.d.ts",  "default": "./dist/index.js" },
    "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
  }
},
"files": ["dist"]
}

Verify it with the command, not with your eyes:

npx --yes @arethetypeswrong/cli --pack .

Observed before the fix:

@acme/core v0.4.1

 🟢 ESM  🟢 CJS  🟢 node10
 ❌ Masquerading as CJS: ./dist/index.d.ts (used in require)

...and clean once types is nested per condition. Worth doing no matter which bundler wins.

What broke on the first build and how I diagnosed it

Three failure classes, in the order I hit them:

  1. Sibling types missing. Cannot find module '@acme/utils' → build siblings first, or run tsc -b on project references. Confirmed on my tsup setup; I'd expect the same under any bundler that runs a DTS pass.
  2. Builtin imports treated as browser modules. Vite's default resolution will warn and stub Node builtins unless you're in an SSR/library context that externalizes node:*. Add /^node:/ to external — the same regex you already have in tsup.
  3. exports order. types has to come before default inside each condition, or tooling falls back and reports the wrong format. Untested for Vite+ 1.0 specifically; it's a Node resolution rule, not a bundler rule.

Lifecycle Hooks and GitLab CI Wiring

Pipeline shape: install, cache key, build stage, and artifact retention

The report says GitLab CI is first-class at 1.0. Whether the hooks replace my YAML or just sit inside it, the pipeline contract doesn't change, so here's what I run today:

stages: [install, build, verify]

default:
  image: node:22-bookworm
  cache:
    key:
      files: [pnpm-lock.yaml]
    paths: [.pnpm-store/]

install:
  stage: install
  script:
    - corepack enable
    - pnpm config set store-dir .pnpm-store
    - pnpm install --frozen-lockfile
  artifacts:
    paths: ["packages/*/node_modules/", "node_modules/"]
    expire_in: 1 day

build:
  stage: build
  needs: [install]
  script:
    - pnpm -r --workspace-concurrency=4 run build
  artifacts:
    paths: ["packages/*/dist/"]
    expire_in: 1 week

The cache key is the lockfile — anything else and you get stale tarballs across branches. Artifacts, not cache, are what carry dist between stages.

Replacing npm-run-all and custom shell glue with hook-driven steps

The glue I'd delete if hooks work as reported: the concurrently dev fan-out, the pretest script that builds dependencies, and the publish:all shell script. Each becomes one hook invocation. The verification path is the same either way — check out the 1.0 changelog, grep it for the hook names, then run pnpm build in one package and confirm the dependent package rebuilt. If a sibling didn't rebuild, the hook runs in the wrong order and you should keep the fan-out for now.

What "One Toolchain" Does Not Replace

Versioning, changelogs, and publish ordering across workspaces

Bundlers don't do release planning. Changesets, Lerna, or a hand-rolled script still owns "which packages changed, what version, in what order." A toolchain that claims to own publish is claiming to reimplement registry semantics that changeset publish already handles correctly — including publishing in topological order and stopping on failure. I'd want evidence before handing that over.

Provenance and registry metadata that still need separate handling

Provenance, access levels, and publishConfig are npm-side, not bundler-side:

npm publish --provenance --access public

If a 1.0 toolchain wraps that call, confirm it still passes --provenance and that the OIDC token flow in GitLab CI matches what npm expects. Untested on my side.

Verified Results and Open Gaps for Vite+ 1.0

Build timing and output-size comparison, with environment and command stated

Environment: Framework 13 (Ryzen 7 7840U, 8 cores), Node 22.11.0, pnpm 9.15.0, tsup 8.3.5, packages/core (≈4.1k LOC across two entry points), dist removed before every run. Script:

#!/usr/bin/env bash
set -euo pipefail
cd packages/core
for i in 1 2 3 4 5; do
  rm -rf dist
  /usr/bin/time -f '%e s  %M KB' pnpm build
done

Observed:

6.71 s  482912 KB
6.38 s  480104 KB
6.29 s  479860 KB
6.35 s  480220 KB
6.33 s  479944 KB

Median 6.35 s locally, 11.8 s on a 2-vCPU GitLab runner. dist output: index.js 62 KB, index.cjs 66 KB, index.d.ts 18 KB (sourcemaps excluded). I have not measured Vite+ 1.0 on the same harness, so there's no honest column to put next to these numbers yet — the script above is how you generate one.

Plugin compatibility, dual ESM/CJS edge cases, and monorepo rough edges

Open questions I'd resolve before a full migration: whether the DTS story needs vite-plugin-dts (and how that plugin fares against whatever Vite+ pins), whether CJS output still handles import.meta.url correctly, and whether workspace dependency detection uses project references or the lockfile. All untested.

Decision Guide: Migrate Now, Wait, or Stay on tsup

Your situationMove
Small library, single package, tsup config under 30 linesStay. The migration costs more than it saves.
Monorepo with concurrently fan-outs and shell publish glueEvaluate now — the hooks are the actual value.
Heavy Rollup/Vite plugin usageMigrate now; you're already in the ecosystem.
You depend on tsup-specific outExtension/--dts-resolve behaviorWait until the migration guide is public.
You ship provenance or need multi-registry publishMigrate the build only; keep Changesets and npm.

Conclusion: Migrate One Leaf Package First

The reported 1.0 lands on the right target. tsup is excellent at bundling and consistently mediocre at everything around bundling — watch orchestration, DTS in a workspace, ordering publishes — and that's where the glue lives. But "one toolchain" is a claim about four tools, and I could only verify one-and-a-half of them from the material I had. My plan is narrow: port one leaf package, run the timing script above on both sides, check the exports map with attw, and leave Changesets and npm publish --provenance exactly where they are until something demonstrably better replaces them. Build tools should earn the rest.

Further Reading

Share this post

More posts

Comments