tsdown이 도대체 뭔지 모르겠어서 정리해보는 글
tsdown은 TypeScript/JavaScript 라이브러리를 npm에 배포할 수 있는 형태로 패키징하는 라이브러리 번들러다. 다른 프로젝트에서 설치해서 사용할 라이브러리를 배포 가능한 형태로 만든다.
src/index.ts
src/theme/index.tsx
src/components/Chip/Chip.tsx
디자인 시스템의 소스가 위와 같다면, 소비자가 이 소스 파일을 직접 사용하는 것이 아니라, tsdown이 생성한 아래의 파일을 사용한다.
dist/index.js
dist/index.cjs
dist/index.d.ts
dist/index.d.cts
dist/styles.css
vite, tsc와의 차이는 무엇인지 비교해보면, vite는 주로 웹 애플리케이션의 개발 서버와 최종 앱 빌드에 사용된다. 라이브러리 모드도 지원하지만, tsdown은 TypeScript 라이브러리 배포에 필요한 기능에 더 초점을 둔다. JDS에서는 vite와 관련 플러그인이 다음 작업을 담당한다.
tsc는 TypeScript 컴파일러로, 타입을 검사하고 TypeScript를 JavaScript로 변환하며 설정에 따라 타입 선언 파일도 생성할 수 있다.
tsc -b --noEmit
JDS에서는 tsc -b --noEmit으로 실행하므로 파일을 생성하지 않고 타입 오류만 검사한다.
디자인 시스템 JDS의 진입점은 아래와 같이 정의되어 있다.
export const entry = {
index: "src/index.ts",
theme: "src/theme/index.tsx",
hooks: "src/hooks/index.ts",
utils: "src/utils/index.ts",
tokens: "src/tokens/index.ts",
};
객체 형식의 entry는 다음 구조를 가진다.
entry: {
결과물_이름: "원본_소스_경로",
}
따라서 다음과 같이 매칭된다.
| Entry key | 원본 소스 | 빌드 결과 |
|---|---|---|
index | src/index.ts | dist/index.js |
theme | src/theme/index.tsx | dist/theme.js |
hooks | src/hooks/index.ts | dist/hooks.js |
utils | src/utils/index.ts | dist/utils.js |
tokens | src/tokens/index.ts | dist/tokens.js |
폴더명이 자동으로 결과 파일명이 되는게 아니라, 객체의 key가 결과 파일명을 결정한다. 만약 원본 파일명을 theme: "src/theme/provider.tsx"와 같이 바꿨다고 해도 key가 theme이므로 결과는 동일하다.
그리고 소비자가 사용할 수 있는 import 경로는 package.json의 exports가 결정한다.
{
"exports": {
"./theme": {
"import": {
"types": "./dist/theme.d.ts",
"default": "./dist/theme.js"
},
"require": {
"types": "./dist/theme.d.cts",
"default": "./dist/theme.cjs"
}
},
"./styles": "./dist/styles.css"
}
}
정리해보면 아래와 같다.
src/theme/index.tsx - 실제 원본 소스 파일
dist/theme.js - entry key로 생성된 결과 파일
@jects/jds/theme - package.json의 공개 import 경로
| 확장자 | 의미 |
|---|---|
.js | 일반 JavaScript. ESM/CommonJS 여부는 package.json의 type에 따라 달라질 수 있음 |
.mjs | 항상 ESM으로 해석되는 JavaScript |
.cjs | 항상 CommonJS로 해석되는 JavaScript |
.d.ts | TypeScript 선언 파일, ESM/CommonJS 여부는 package.json의 type에 따라 달라질 수 있음 |
.d.mts | ESM용 TypeScript 선언 파일 |
.d.cts | CommonJS용 TypeScript 선언 파일 |
.js.map | JavaScript 결과물과 원본 소스를 연결하는 소스맵 |
.d.ts.map | 타입 선언과 원본 TypeScript를 연결하는 선언 소스맵 |
.css | 빌드된 실제 스타일 파일 |
JavaScript 코드에는 TypeScript 타입이 남아있지 않다. 타입 정보는 .d.ts에 별도로 기록된다.
// TypeScript
export const sum = (a: number, b: number): number => a + b;
// JavaScript
export const sum = (a, b) => a + b;
// .d.ts
export declare const sum: (a: number, b: number) => number;
소비자의 IDE와 TypeScript는 .d.ts를 읽어 자동완성과 타입 검사를 제공한다.
소스맵은 변환된 결과 코드의 위치와 원본 코드의 위치를 연결하는 메타 데이터이다. 현재 dist/theme.js 마지막에는 아래와 같은 주석이 있다.
//# sourceMappingURL=theme.js.map
브라우저 개발자 도구나 앱 번들러는 이 주석을 보고 theme.js.map을 찾는다.
dist/theme.js
↓ sourceMappingURL
dist/theme.js.map
↓ sources / mappings
src/theme/index.tsx
실제 theme.js.map에는 다음과 같은 내용이 들어 있다.
{
"file": "theme.js",
"sources": ["../src/theme/index.tsx"],
"sourcesContent": ["원본 index.tsx 코드"],
"mappings": "생성 코드와 원본 코드의 위치 정보"
}
sourcesContent에 원본 코드 자체가 포함되어 있어 npm 패키지에 src 폴더가 없어도 개발자 도구에서 원본 코드를 보여줄 수 있다.
exports는 소비자가 코드에서 import할 공개 모듈을 정의한다. 소스맵은 직접 import하는 모듈이 아니라, 생성된 JS의 sourceMappingURL을 보고 빌드 도구나 브라우저가 읽는 보조 파일이다.
현재 JDS는 "files": ["dist", "README.md"] 설정을 통해 dist 전체를 npm 패키지에 포함하므로 .map 파일도 패키지에 함께 포함한다. 소스맵이 패키지에 있다고 항상 프로덕션 브라우저에 공개되는 것이 아니며, 최종 앱 번들러가 소스맵을 유지하는지, 서버가 .map 파일을 제공하는지에 따라 달라진다.
JDS의 package.json 파일엔 다음 설정이 있다.
{
"type": "module"
}
이 설정은 해당 패키지 안의 .js 파일을 ESM으로 해석하겠다는 의미이다. Node.js는 확인하려는 .js 파일이 있는 폴더에서 시작해 상위 폴더로 올라가며, 처음 발견되는 package.json의 type을 참고한다. 중간에 더 가까운 package.json이 있다면 그 설정이 우선한다.
format: ["es", "cjs"],
fixedExtension: false,
format은 생성할 모듈 형식을 결정하고, fixedExtension은 결과 파일에 사용할 확장자를 결정한다. fixedExtension: false이면 tsdown은 package.json의 type을 참고하여 한쪽 결과에 .js 확장자를 사용한다.
| 결과 형식 | type: module | type 없음 / commonjs |
|---|---|---|
| ESM | theme.js | theme.mjs |
| CommonJS | theme.cjs | theme.js |
반면 fixedExtension: true이면 package의 type에 의존하지 않고 확장자 자체로 모듈 형식을 명확하게 표현한다.
ESM → theme.mjs
CommonJS → theme.cjs
.mjs는 항상 ESM으로 해석된다..cjs는 항상 CommonJS로 해석된다..js는 가장 가까운 package.json의 type에 따라 해석된다.일반 JavaScript 빌드는 tsdown.config.ts에서 수행하고, 타입 선언은 tsdown.dts.config.ts에서 별도로 만든다.
// tsdown.config.ts
dts: false
// tsdown.dts.config.ts
dts: {
emitDtsOnly: true,
sourcemap: true,
}
emitDtsOnly는 선언 파일이 아닌 결과물을 제거하고 타입 선언만 출력하기 위한 옵션이다.
일반 tsdown 빌드
├─ index.js
├─ index.cjs
└─ styles.css
emitDtsOnly 빌드의 의도
├─ index.d.ts
└─ index.d.cts
타입 선언을 생성할 때는 .css.ts를 처리할 필요가 없으므로, 타입 빌드 설정에는 vanilla-extract 플러그인을 적용하지 않는다. 또한 JDS가 사용하는 tsdown 버전에서는 emitDtsOnly와 CJS 빌드를 함께 사용했을 때 원치 않는 .cjs 파일이 남을 수 있다. 이 파일이 정상적인 JavaScript 빌드 결과를 덮어쓰지 않도록 JavaScript 빌드와 타입 빌드를 서로 다른 출력 경로에서 실행한다.
Rollup plugin은 번들러의 빌드 과정에 참여해 파일을 해석, 변환하거나 추가 결과물을 생성하는 확장 기능이다. 플러그인은 빌드 과정의 특정 시점에 실행되는 hook을 제공한다.
| Hook | 역할 |
|---|---|
resolveId | import 경로가 어떤 파일을 가리키는지 결정 |
load | 파일을 읽거나 가상 모듈 생성 |
transform | 읽은 소스 코드를 다른 코드로 변환 |
generateBundle | 최종 JS, CSS 등의 결과물 생성 |
writeBundle | 결과물 기록이 끝난 후 추가 작업 수행 |
세 도구의 관계는 다음과 같이 이해할 수 있다.
Rollup
Rolldown
tsdown
따라서 JDS는 tsdown을 사용하면서도 @vanilla-extract/rollup-plugin을 사용할 수 있다. 하지만 Rolldown이 Rollup의 모든 내부 동작까지 완전히 동일하게 구현한 것은 아니므로, 내부 구현에 강하게 의존하는 플러그인은 호환되지 않을 수 있다.
JDS는 아래와 같은 설정을 사용한다.
vanillaExtractPlugin({
identifiers: "debug",
extract: {
name: "styles.css",
},
esbuildOptions: {
tsconfig: "./tsconfig.app.json",
},
})
vanilla-extract는 .css.ts 파일에 작성된 TypeScript 스타일 코드를 빌드 시 실행하고, JavaScript에서 사용할 식별자와 실제 CSS를 생성한다.
// button.css.ts
import { style } from "@vanilla-extract/css";
export const button = style({
padding: 8,
color: "blue",
});
그리고 컴포넌트에서는 생성된 className을 사용한다.
import * as styles from "./button.css";
<button className={styles.button}>버튼</button>
빌드 과정은 대략 다음과 같다.
button.css.ts 발견
↓
vanilla-extract plugin이 TypeScript 스타일 코드 실행
↓
style() 호출을 CSS 규칙과 식별자로 변환
↓
JavaScript에는 생성된 className을 사용할 코드가 남음
↓
실제 CSS 규칙은 styles.css로 추출
vanilla-extract의 .css.ts 코드는 런타임에 브라우저에서 실행되지 않는다. 빌드 과정에서 처리되기 때문에 이를 zero-runtime CSS라고 표현한다.
identifiers는 vanilla-extract가 생성하는 CSS 식별자의 이름 형식을 결정한다.
| 설정 | 예시 | 특징 |
|---|---|---|
"debug" | button_primary_hnw5tz3 | 파일과 스타일 이름을 추적하기 쉬움 |
"short" | hnw5tz3 | 결과가 짧지만 사람이 의미를 파악하기 어려움 |
| 함수 | 사용자 정의 | 프로젝트 규칙에 맞게 직접 생성 가능 |
JDS의 "debug" 설정은 컴포넌트 스타일을 개발자 도구에서 추적하기 쉽게 만든다. 소비자 입장에서 기능적인 사용 방법이 바뀌는 것은 아니지만 다음 차이가 생긴다.
"short"보다 생성 CSS와 HTML의 식별자가 조금 길어짐extract는 여러 .css.ts 파일에서 생성된 CSS 규칙을 별도 CSS 번들로 추출한다. 그리고 name: "styles.css"는 추출할 CSS asset의 이름을 지정한다.
소비자는 package.json의 다음 export를 통해 이 파일에 접근한다.
{
"exports": {
"./styles": "./dist/styles.css"
}
}
따라서 JDS 스타일을 사용하려면 다음 import가 필요하다.
import "@jects/jds/styles";
minify는 결과 코드의 동작을 유지하면서 파일 크기를 줄이는 최적화 과정이다.
// 원본 코드
const message = "hello";
function printMessage(value) {
console.log(value);
}
printMessage(message);
// minify 후
const e="hello";function o(e){console.log(e)}o(e);
배포 파일 크기, 네트워크 전송량, 브라우저가 읽어야 할 JavaScript 양이 감소한다는 장점이 있지만, 빌드 결과를 사람이 읽기 어려움, 빌드 시간 증가, 잘못된 최적화가 있으면 런타임 문제가 일어날 수 있다는 단점도 있다.
현재 JDS의 tsdown 설정에는 minify가 지정되어 있지 않아 라이브러리 결과물이 축소되지 않는다. 라이브러리 결과를 읽고 디버깅하기 쉽고, 일반적으로 최종 애플리케이션 번들러가 배포 과정에서 다시 minify할 수 있다는 장점이 있다.
publint는 npm 패키지의 package.json과 실제 배포 파일이 올바르게 연결됐는지 검사한다.
mainmoduletypesexports{
"exports": {
"./theme": {
"import": "./dist/theme.js"
}
}
}
예를 들어 위와 같이 선언했는데 실제 dist/theme.js가 없다면 문제를 찾을 수 있다.
attw는 패키지의 타입 선언이 다양한 TypeScript 모듈 해석 방식에서 올바르게 연결되는지 검사한다.
.js와 .d.ts 대응.cjs와 .d.cts 대응exports의 types 조건두 도구는 겹치는 부분이 있지만 검사 관점이 다르다.
| 도구 | 주요 관점 | 대표적인 질문 |
|---|---|---|
| publint | npm 패키지 구조 | exports가 실제 파일을 올바르게 가리키는가? |
| attw | TypeScript 소비 환경 | ESM/CJS 소비자가 올바른 타입 선언을 읽는가? |
tsdown 자체에도 publint와 attw를 실행하는 옵션이 존재한다. 일반적인 설정은 다음과 같은 형태다.
export default defineConfig({
publint: true,
attw: true,
});
하지만 JDS는 JavaScript와 타입 선언을 서로 다른 경로에 생성하므로, 각 tsdown 프로세스가 끝난 시점에는 아직 npm에 배포할 최종 패키지 구조가 완성되지 않는다.
tsdown.config.ts
└─ JavaScript와 CSS를 dist에 생성
tsdown.dts.config.ts
└─ 타입 선언을 dist-types에 생성
build.mjs
├─ 두 빌드를 병렬로 실행
├─ 타입 선언과 선언 소스맵만 dist로 이동
├─ dist-types 삭제
└─ 완성된 패키지에 publint와 attw 실행
각 tsdown 설정에서 publint와 attw를 바로 실행하면 타입 선언이 dist에 합쳐지기 전에 검사가 시작될 수 있다. 따라서 최종 배포 구조를 완성한 다음 별도 스크립트로 두 검사를 실행하도록 구성했다.
// build.mjs
const results = await Promise.allSettled([
run(["--config", "tsdown.config.ts"]),
run(["--config", "tsdown.dts.config.ts"]),
]);
...
await import("./check-dts.mjs");
await import("./check-package.mjs");
JDS의 tsdown 빌드는 하나의 소스에서 ESM과 CommonJS 실행 파일, 각 형식에 맞는 타입 선언, 스타일과 소스맵을 생성한다. entry가 결과물의 이름을 결정하고, package.json의 exports가 소비자에게 공개할 경로를 결정한다. 별도로 생성된 결과물은 하나의 배포 구조로 합친 뒤 publint와 attw를 통해 실제 소비 환경에서 올바르게 연결되는지 결정한다.