0

Di chuyển một thư viện OSS TypeScript thực tế từ tsup sang tsdown

Phần migration cấu hình thì nhỏ. Phần thú vị là giữ nguyên package contract.

Hoi hoi! 👋

Mình là @nyaomaru, một frontend engineer và mùa này khá thích đi tìm nấm. 😸🍄

Gần đây, mình đã chuyển cấu hình build của is-kit từ tsup sang tsdown.

Ban đầu, mình nghĩ đây sẽ là một thay đổi rất nhỏ.

Chỉ cần xoá tsup.
Chỉ cần cài tsdown.
Chỉ cần đổi config.
Chạy build.
Xong! 🎉

Và thật ra...

Phần migration config đúng là nhỏ.

Nhưng có một vấn đề khá thú vị xuất hiện.

Lần build đầu tiên với tsdown chạy thành công, nhưng lại âm thầm thay đổi những file vốn đã là một phần của public contract của package.

Vì vậy, bài viết này không hẳn là một bài giới thiệu về tsdown.

Mình muốn chia sẻ chuyện gì đã xảy ra khi chuyển một thư viện TypeScript thực tế, đã được publish, từ tsup sang tsdown, điều gì thực sự thay đổi, và mình đã kiểm tra thế nào để đảm bảo consumer vẫn nhận được cùng một package.

Cùng xem nhé! 👀


🤔 Tại sao lại migrate một package vốn đã build bình thường?

is-kit là một thư viện TypeScript zero-dependency dành cho runtime type guards.

Cấu hình build của nó vốn khá... nhàm chán.

Và build system nhàm chán thường là chuyện tốt. 😸

Package này có:

  • một entry point
  • output ESM và CJS
  • bundled declaration files
  • exports được khai báo rõ trong package.json
  • declaration banner
  • smoke test cho packed package

Trước khi migrate, cấu hình là:

Environment Value
Package is-kit@1.14.2
Bundler tsup@8.5.1
Entry src/index.ts
Output ESM + CJS + bundled declarations
Target esnext

Mình không migrate vì build đang hỏng.

Lý do chủ yếu là maintenance.

tsdown được xây dựng trên Rolldown, có ecosystem đang phát triển tích cực, và được thiết kế rõ ràng như một migration path cho những project hiện đang dùng tsup.

Vì vậy câu hỏi không phải là:

tsdown có build được thư viện này không?

Mà là:

Mình có thể chuyển build setup sang tsdown mà không làm thay đổi những gì consumer hiện tại nhận được không?

Đối với một package đã được publish, đây là câu hỏi hữu ích hơn nhiều.


📦 Package contract mà mình cần giữ nguyên

Với một application, đổi tên một output file đôi khi chẳng quan trọng lắm.

Nhưng với library, đó có thể là breaking change.

is-kit đã expose file thông qua package exports được khai báo rõ ràng.

Các path quan trọng về cơ bản là:

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.js"
    }
  }
}

Vì vậy mình muốn migration vẫn giữ được:

dist/index.mjs
dist/index.js
dist/index.d.ts

cùng với runtime behavior ESM/CJS hiện có, declaration compatibility, export set và declaration banner.

Nói cách khác:

Source code không phải contract ở đây. Packed npm package mới là contract.

Sự khác biệt này rất nhanh chóng trở nên quan trọng.


🛠️ Migration trông gần như quá đơn giản

Mình thay tsup bằng tsdown, rồi đổi:

tsup.config.ts

thành:

tsdown.config.mts

Mình cố ý dùng .mts.

Bản thân package không có "type": "module", và dùng extension dành riêng cho ESM giúp tránh việc Node diễn giải lại config file và tạo ra các warning liên quan.

Hầu hết các option quan trọng gần như map trực tiếp sang nhau.

import { defineConfig } from "tsdown";

export default defineConfig({
  entry: ["src/index.ts"],
  format: ["esm", "cjs"],
  dts: true,
  clean: true,
  outDir: "dist",
  target: "esnext",
  banner: {
    dts: dtsBanner,
  },
});

Declaration banner cũng tiếp tục chỉ áp dụng cho dts với:

banner: { dts: dtsBanner }

Cho đến đây, mọi thứ vẫn rất đơn giản.

Rồi mình chạy build.

Build pass.

Nhưng package contract thì sai. 🙀


💥 Build đầu tiên thành công nhưng tên file bị thay đổi

Đây là phần thú vị nhất.

Trước khi migrate, các file output quan trọng là:

Build Main generated files
tsup index.mjs, index.js, index.d.ts
mặc định khi migrate sang tsdown index.mjs, index.cjs, index.d.mts, index.d.cts

Build với tsdown hoàn thành bình thường với exit code 0.

Nhưng package.json hiện tại của mình vẫn kỳ vọng:

require → ./dist/index.js
types   → ./dist/index.d.ts

Những file này không còn khớp với output được generate nữa.

Nếu mình dừng lại ở:

pnpm build

rồi publish package,

consumer dùng CJS và TypeScript resolution có thể đã bị break.

Đây là bài học quan trọng nhất từ lần migration này:

Build thành công ≠ package contract được giữ nguyên.

Bundler chỉ biết nó có generate được output hay không.

Điều đó không tự động có nghĩa output mới vẫn giữ đúng mọi lời hứa mà package đã publish trước đó dành cho consumer.


🔧 Giữ public contract bằng outExtensions

Migration guide của tsdown có nói rõ việc đổi tên từ outExtension sang outExtensions.

Với is-kit, mình dùng option này để giữ nguyên tên file cũ:

outExtensions: ({ format }) => ({
  dts: format === 'cjs' ? '.d.ts' : '.d.mts',
  js: format === 'cjs' ? '.js' : '.mjs',
}),

Sau đó output quan trọng trở thành:

dist/index.mjs
dist/index.js
dist/index.d.mts
dist/index.d.ts

và các export hiện tại tiếp tục resolve đúng.

Mình không thực sự xem behavior mặc định ban đầu của tsdown là bug.

tsdown chọn extension dựa trên package type và output format để tránh việc module bị diễn giải mơ hồ.

Điều đó hoàn toàn hợp lý.

Nhưng với một package đã tồn tại, default mới dù hợp lý vẫn là một thay đổi.

Nếu consumer đã phụ thuộc vào tên file của bạn, thì những tên file đó đã là một phần của compatibility surface.


🧪 Kiểm tra package thật sự được publish bằng smoke test

Đây là lúc packed-package smoke test đã có sẵn trong is-kit phát huy tác dụng.

Mình vốn đã có command:

pnpm test:package

Nó sẽ build package, chạy npm pack, cài tarball được generate vào một project tạm thời, rồi verify package từ góc nhìn của consumer thật.

Thay vì test:

src/index.ts

nó test:

is-kit-1.14.2.tgz
        ↓
temporary consumer
        ↓
npm install

Một library hoàn toàn có thể hoạt động hoàn hảo trong chính repository của nó nhưng vẫn publish ra một package bị hỏng.

Vì vậy smoke test này kiểm tra artifact mà user thực sự sẽ cài.

Sau khi migrate, mình mở rộng test để verify thêm nhiều phần của package contract 👇

Contract Result
exports["."].import → ./dist/index.mjs ✅ pass
exports["."].require → ./dist/index.js ✅ pass
exports["."].types → ./dist/index.d.ts ✅ pass
Runtime dependencies ✅ 0
ESM exports ✅ cùng 83 exports
CJS exports ✅ cùng 83 exports
Declaration banner ✅ được giữ nguyên
ESM runtime import ✅ pass
CJS runtime require ✅ pass
Packed TypeScript consumer ✅ pass

Mình cũng cài packed package vào các temporary consumer project dùng TypeScript từ v5.7 đến v7.0.

Tất cả đều resolve và consume declaration files được generate thành công.

Đây không phải là test xem mỗi phiên bản TypeScript có thể generate declaration thông qua tsdown hay không.

Nó test artifact mà user thực sự cài.

Vì vậy nếu một thay đổi build trong tương lai vô tình làm thay đổi extension, export path, declaration file hoặc runtime behavior, smoke test sẽ bắt được vấn đề trước khi publish.

Với một library migration như thế này, điều đó có ý nghĩa hơn nhiều so với việc chỉ assert rằng "build exited successfully".


📏 Output thực tế lại lớn hơn

Mình cũng tò mò về artifact size.

Và kết quả khá bất ngờ.

Metric tsup tsdown Difference
JS + dts total 116,503 B 144,007 B +23.6%
ESM JavaScript 15,787 B 29,410 B +86.3%
CJS JavaScript 19,318 B 31,155 B +61.3%
dts, one format 40,699 B 41,721 B +2.5%
npm pack tarball 37,226 B 42,366 B +13.8%

Với config này, output từ tsdown giữ lại nhiều comment và region marker hơn output cũ từ tsup.

Vì vậy raw JavaScript tăng kích thước khá rõ.

Compression làm giảm khác biệt trong npm tarball thực tế, nhưng không xoá hoàn toàn.

Với is-kit, đây không phải vấn đề đáng lo.

Nó là một utility library nhỏ, zero-dependency, và chúng ta chỉ đang nói đến thêm vài KB trong tarball cuối cùng.

Nhưng đây vẫn là một lời nhắc khá hữu ích:

Bundler mới hơn hoặc nhanh hơn không tự động đồng nghĩa với artifact nhỏ hơn.

Nếu package size là constraint nghiêm ngặt với library của bạn, mình sẽ so sánh file output thật và quyết định minification strategy trước khi migrate.


⏱️ Còn build performance thì sao?

Mình cũng đo wall-clock build time.

Build cũ với tsup:

1.50 s

Build mới với tsdown, qua ba lần chạy:

1.22 s
1.17 s
1.18 s

Nếu chỉ nhìn những con số này, sẽ rất dễ nói:

tsdown làm build nhanh hơn! 🚀

Nhưng mình không nghĩ benchmark này đủ để kết luận như vậy.

Trong lần so sánh này, mình chỉ có một sample của tsup.

Ngoài ra đây là một library rất nhỏ chỉ có một entry, trong khi declaration generation lại chiếm phần lớn thời gian build.

Timing do chính các bundler report vào khoảng:

tsup

JavaScript: ~22 ms
dts:        ~707 ms

tsdown

complete:   ~717–755 ms

Wall-clock result tốt hơn, nhưng với chỉ một sample tsup và declaration generation chi phối build time của một library nhỏ như vậy, mình không nghĩ đây là đủ bằng chứng để nói có performance improvement đáng kể.

Và không, mình không migrate chỉ để tiết kiệm khoảng 300 ms.


🐱 Migration này thực sự nhỏ đến vậy sao?

Với is-kit, đúng vậy.

Không có thay đổi nào trong source code.

Migration chủ yếu chỉ giới hạn ở:

dependency
config
task documentation
package smoke assertions

Các option quan trọng gần như map một-một.

Nhưng mình nghĩ điều quan trọng là giải thích tại sao nó vẫn nhỏ.

is-kit có:

  • một entry
  • zero runtime dependencies
  • không có bundler plugin
  • không có CSS pipeline
  • không có code splitting phức tạp

và quan trọng nhất, nó vốn đã có cách test packed package từ góc nhìn của consumer.

Migration sẽ trở nên phức tạp hơn nếu package của bạn phụ thuộc vào custom plugins, unusual entries, CSS processing, exact source maps, generated exports, strict byte-size limits hoặc build environment Node cũ hơn.

Ngoài ra còn có một build-environment requirement đáng kiểm tra trước.

Với version mình test:

tsdown 0.23.0
Rolldown 1.2.8

Node engine requirement liên quan là:

^22.18.0 || ^24.11.0 || >=26.0.0

CI của mình dùng:

Node 22.22.0

nên không có vấn đề gì.

Nhưng nếu contributor của bạn vẫn build library bằng Node v20 hoặc một version Node v22 cũ hơn, đây là điều cần xử lý trước khi migrate.

Điều đó không nhất thiết có nghĩa library consumer của bạn cũng phải dùng Node v22.

Đây là requirement của build tool.

Dù vậy, contributor environment và CI environment cũng là một phần của migration cost. 😿


🎯 Vậy migrate từ tsup sang tsdown có đáng không?

Với is-kit, mình nghĩ là có.

Nhưng không phải vì performance.

Migration đã giữ nguyên package contract trên ESM, CJS, declarations, exports và packed-package consumer tests.

Config change nhỏ và dễ review.

Node environment hiện tại của mình đã đáp ứng build requirement mới.

Và build setup giờ đã gần hơn với Rolldown ecosystem đang tiếp tục phát triển tích cực.

Tất nhiên, migration cũng có cost thực tế:

  • artifact size tăng
  • Node build requirement tăng
  • output extensions cần được cấu hình rõ ràng

Vì vậy mình sẽ không mô tả migration này như sau:

Ai đang dùng tsup cũng nên migrate ngay sang tsdown vì nó nhanh hơn!

Đó không phải là điều experiment này cho thấy.

Theo mình, kết luận hợp lý hơn là:

Nếu project của bạn đã đáp ứng Node requirement và bạn muốn đưa build setup dần về phía Rolldown ecosystem, thì migrate một library nhỏ từ tsup sang tsdown có thể là một thay đổi hoàn toàn hợp lý.

Nhưng hãy verify package mà bạn thực sự publish.

Không chỉ source code.
Không chỉ config.
Và chắc chắn không chỉ nhìn vào dòng build màu xanh. 😸

Bởi vì bug thú vị nhất trong lần migration này xuất hiện đúng lúc build nói rằng mọi thứ đều ổn.


Nếu bạn muốn xem package trong bài viết này trong một project thực tế, is-kit là open source.

Đây là một TypeScript type guard library nhẹ, zero-dependency, tập trung vào runtime validation và safe narrowing.

Nếu bạn thấy nó hữu ích cho project của mình, rất vui nếu bạn thử dùng — và một ⭐ trên GitHub luôn được chào đón.

https://github.com/nyaomaru/is-kit

Issues và PR cũng luôn được hoan nghênh. 😸


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí