0

Cách viết một thư viện JavaScript với Vite + tsdown + Rolldown + pnpm

Bài viết này mình sẽ hướng dẫn tạo một thư viện JavaScript/TypeScript dùng pnpm, từ dev, test local, đến publish lên npm. Stack: TypeScript, tsdown (build với Rolldown), Vitest, pnpm, output ESM only, test local qua file: protocol.

1. Tổng quan stack

Thành phần Công cụ Vai trò
Package manager pnpm Quản lý dependency, publish
Build tsdown Bundle TypeScript → ESM, dùng Rolldown engine
Test Vitest Test runner nhanh, native ESM
Language TypeScript Type safety, auto generate .d.ts
Local test file: protocol Import lib như package thật mà không cần publish

2. Khởi tạo thư viện

mkdir my-lib
cd my-lib
pnpm init

package.json

{
  "name": "my-lib",
  "version": "0.0.1",
  "type": "module",
  "main": "./dist/index.mjs",
  "types": "./dist/index.d.mts",
  "exports": {
    ".": {
      "types": "./dist/index.d.mts",
      "import": "./dist/index.mjs"
    }
  },
  "files": ["dist"],
  "scripts": {
    "dev": "tsdown --watch",
    "build": "tsdown",
    "test": "vitest run",
    "test:watch": "vitest",
    "prepublishOnly": "pnpm build"
  },
  "packageManager": "pnpm@10.25.0"
}

Một số điểm quan trọng:

  • "type": "module" — toàn bộ project dùng ESM.
  • mainexports trỏ vào dist/index.mjs — tsdown mặc định xuất .mjs cho ESM output.
  • files: ["dist"] — chỉ publish thư mục dist, không push src/test/config lên npm.
  • prepublishOnly — tự động build trước khi publish, đảm bảo dist luôn mới nhất.

Cài dependencies

pnpm add -D tsdown typescript vitest unrun

unrun là peer dependency runtime của tsdown, cần cài tường minh.

3. Cấu hình TypeScript

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true,
    "declarationDir": "./dist",
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}
  • "moduleResolution": "bundler" — tương thích với tsdown/Rolldown.
  • "declaration": true — tạo .d.ts làm nguồn cho tsdown dùng với plugin rolldown-plugin-dts.

4. Cấu hình Build với tsdown

tsdown là bundler chuyên cho thư viện, built on Rolldown, của cùng team làm Vite.

tsdown.config.ts

import { defineConfig } from "tsdown";

export default defineConfig({
  entry: ["src/index.ts"],
  outDir: "dist",
  format: ["esm"],
  dts: true,
  clean: true,
});
  • format: ["esm"] — chỉ xuất ESM (.mjs).
  • dts: true — tự động generate type declarations qua rolldown-plugin-dts.
  • clean: true — xóa dist trước mỗi lần build.

.gitignore

node_modules
dist
*.log

Không commit dist/. Chỉ generate khi publish.

5. Viết code

src/index.ts

export function greet(name: string): string {
  return `Hello, ${name}!`;
}

export function sum(a: number, b: number): number {
  return a + b;
}

6. Viết test

vitest.config.ts

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    include: ["tests/**/*.test.ts"],
  },
});

tests/index.test.ts

import { describe, it, expect } from "vitest";
import { greet, sum } from "../src/index";

describe("greet", () => {
  it("returns greeting with name", () => {
    expect(greet("World")).toBe("Hello, World!");
  });
});

describe("sum", () => {
  it("adds two numbers", () => {
    expect(sum(1, 2)).toBe(3);
  });
});
pnpm test        # chạy test 1 lần
pnpm test:watch  # watch mode

7. Build

pnpm build

Output:

dist/
├── index.mjs        ← bundle ESM
└── index.d.mts      ← type declarations

8. Test local (giống như consumer thật)

Không cần publish lên npm để test. Dùng file: protocol.

Tạo project consumer

mkdir ../my-lib-test
cd ../my-lib-test
pnpm init

package.json của consumer

{
  "name": "my-lib-test",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "tsx src/index.ts"
  },
  "dependencies": {
    "my-lib": "file:../my-lib"
  },
  "devDependencies": {
    "tsx": "^4.19.0",
    "typescript": "^5.8.0"
  }
}

"my-lib": "file:../my-lib" — pnpm tạo symlink từ my-lib-test/node_modules/my-libmy-lib/dist. Mỗi lần build lại lib, consumer dùng code mới ngay, không cần pnpm install lại.

src/index.ts của consumer

import { greet, sum } from "my-lib";

console.log(greet("a"));
console.log("1 + 2 =", sum(1, 2));

Chạy

pnpm install
pnpm start
Hello, a!
1 + 2 = 3

9. Quy trình dev hàng ngày

# Terminal 1 — lib
cd my-lib
pnpm dev                 # watch mode, auto build khi code thay đổi

# Terminal 2 — consumer
cd my-lib-test
pnpm start               # chạy lại để test code mới

Hoặc không cần watch:

cd my-lib && pnpm build   # build một lần
cd my-lib-test && pnpm start

10. Publish lên npm

cd my-lib

# Đảm bảo đã login
pnpm whoami               # kiểm tra, nếu chưa: pnpm login

# Đảm bảo code sạch, test pass
pnpm test

# Publish (prepublishOnly sẽ tự chạy build)
pnpm publish

Lần publish đầu tiên nên thêm --access public nếu package không scoped:

pnpm publish --access public

Các lần sau tăng version rồi publish:

pnpm version patch        # 0.0.1 → 0.0.2
# hoặc: minor (0.1.0), major (1.0.0)
pnpm publish

11. Cấu trúc thư mục cuối cùng

my-lib/
├── src/
│   └── index.ts
├── tests/
│   └── index.test.ts
├── package.json
├── tsconfig.json
├── tsdown.config.ts
├── vitest.config.ts
└── .gitignore

my-lib-test/
├── src/
│   └── index.ts
├── package.json

12. Một số lưu ý

  • tsdown đang phát triển nhanh — nếu gặp lỗi peer dependency như unmet peer typescript@^6.0.0, có thể bỏ qua hoặc cài thêm unrun nếu thiếu.
  • Không bundle dependencies — nếu lib có dependency bên ngoài, thêm vào package.json dependency và config tsdown.config.ts với external: true.
  • Muốn xuất CJS? Sửa format: ["esm", "cjs"] trong tsdown.config.ts, output sẽ thêm index.cjs + index.d.cts.
  • Scoped package? Đổi "name": "@scope/my-lib" trong package.json, publish với --access public.


    Cảm ơn bác bạn đã đọc bài viết!

All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.