tsdown이 도대체 뭘까

한칙촉·2일 전

tsdown이 도대체 뭔지 모르겠어서 정리해보는 글

tsdown이란 뭘까

tsdownTypeScript/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와 관련 플러그인이 다음 작업을 담당한다.

  • React 애플리케이션 빌드
  • 개발 서버 실행
  • /api 프록시
  • tailwind css 처리
  • svg 처리
  • sentry 소스맵 업로드
  • html, js, css, 이미지 등 최종 웹사이트 생성

tsc는 TypeScript 컴파일러로, 타입을 검사하고 TypeScript를 JavaScript로 변환하며 설정에 따라 타입 선언 파일도 생성할 수 있다.

tsc -b --noEmit

JDS에서는 tsc -b --noEmit으로 실행하므로 파일을 생성하지 않고 타입 오류만 검사한다.

Entry가 배포 파일이 되는 과정

디자인 시스템 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원본 소스빌드 결과
indexsrc/index.tsdist/index.js
themesrc/theme/index.tsxdist/theme.js
hookssrc/hooks/index.tsdist/hooks.js
utilssrc/utils/index.tsdist/utils.js
tokenssrc/tokens/index.tsdist/tokens.js

폴더명이 자동으로 결과 파일명이 되는게 아니라, 객체의 key가 결과 파일명을 결정한다. 만약 원본 파일명을 theme: "src/theme/provider.tsx"와 같이 바꿨다고 해도 key가 theme이므로 결과는 동일하다.

그리고 소비자가 사용할 수 있는 import 경로는 package.jsonexports가 결정한다.

{
  "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.jsontype에 따라 달라질 수 있음
.mjs항상 ESM으로 해석되는 JavaScript
.cjs항상 CommonJS로 해석되는 JavaScript
.d.tsTypeScript 선언 파일, ESM/CommonJS 여부는 package.jsontype에 따라 달라질 수 있음
.d.mtsESM용 TypeScript 선언 파일
.d.ctsCommonJS용 TypeScript 선언 파일
.js.mapJavaScript 결과물과 원본 소스를 연결하는 소스맵
.d.ts.map타입 선언과 원본 TypeScript를 연결하는 선언 소스맵
.css빌드된 실제 스타일 파일

.d.ts

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를 읽어 자동완성과 타입 검사를 제공한다.

소스맵과 map 파일

소스맵은 변환된 결과 코드의 위치와 원본 코드의 위치를 연결하는 메타 데이터이다. 현재 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 폴더가 없어도 개발자 도구에서 원본 코드를 보여줄 수 있다.

map 파일이 exports에 없는 이유

exports는 소비자가 코드에서 import할 공개 모듈을 정의한다. 소스맵은 직접 import하는 모듈이 아니라, 생성된 JS의 sourceMappingURL을 보고 빌드 도구나 브라우저가 읽는 보조 파일이다.

현재 JDS는 "files": ["dist", "README.md"] 설정을 통해 dist 전체를 npm 패키지에 포함하므로 .map 파일도 패키지에 함께 포함한다. 소스맵이 패키지에 있다고 항상 프로덕션 브라우저에 공개되는 것이 아니며, 최종 앱 번들러가 소스맵을 유지하는지, 서버가 .map 파일을 제공하는지에 따라 달라진다.

fixedExtension

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: moduletype 없음 / commonjs
ESMtheme.jstheme.mjs
CommonJStheme.cjstheme.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

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

Rollup plugin은 번들러의 빌드 과정에 참여해 파일을 해석, 변환하거나 추가 결과물을 생성하는 확장 기능이다. 플러그인은 빌드 과정의 특정 시점에 실행되는 hook을 제공한다.

Hook역할
resolveIdimport 경로가 어떤 파일을 가리키는지 결정
load파일을 읽거나 가상 모듈 생성
transform읽은 소스 코드를 다른 코드로 변환
generateBundle최종 JS, CSS 등의 결과물 생성
writeBundle결과물 기록이 끝난 후 추가 작업 수행

Rollup / Rolldown / tsdown

세 도구의 관계는 다음과 같이 이해할 수 있다.

  • Rollup

    • JavaScript 모듈 번들러
    • 널리 사용되는 플러그인 API와 생태계를 제공
  • Rolldown

    • Rust로 구현된 고성능 번들러
    • Rollup의 설정 및 플러그인 API와의 호환성을 지향
  • tsdown

    • Rolldown을 빌드 엔진으로 사용하는 라이브러리 번들러
    • TypeScript 라이브러리용 기본 설정 제공
    • ESM/CommonJS 동시 빌드
    • 타입 선언 생성
    • 의존성 외부화와 패키지 검증 지원

따라서 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

identifiers는 vanilla-extract가 생성하는 CSS 식별자의 이름 형식을 결정한다.

설정예시특징
"debug"button_primary_hnw5tz3파일과 스타일 이름을 추적하기 쉬움
"short"hnw5tz3결과가 짧지만 사람이 의미를 파악하기 어려움
함수사용자 정의프로젝트 규칙에 맞게 직접 생성 가능

JDS의 "debug" 설정은 컴포넌트 스타일을 개발자 도구에서 추적하기 쉽게 만든다. 소비자 입장에서 기능적인 사용 방법이 바뀌는 것은 아니지만 다음 차이가 생긴다.

  • 브라우저 DOM에서 className을 읽기 쉬움
  • 스타일 문제를 추적하기 쉬움
  • 스냅샷이나 HTML 결과에 긴 className이 노출될 수 있음
  • "short"보다 생성 CSS와 HTML의 식별자가 조금 길어짐

extract

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

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와 attw

publint

publint는 npm 패키지의 package.json과 실제 배포 파일이 올바르게 연결됐는지 검사한다.

  • main
  • module
  • types
  • exports
  • 파일 존재 여부
  • ESM/CommonJS 경로 일치 여부
  • 잘못된 export 조건
  • 공개 경로와 실제 결과물의 불일치
{
  "exports": {
    "./theme": {
      "import": "./dist/theme.js"
    }
  }
}

예를 들어 위와 같이 선언했는데 실제 dist/theme.js가 없다면 문제를 찾을 수 있다.

attw - Are the types wrong?

attw는 패키지의 타입 선언이 다양한 TypeScript 모듈 해석 방식에서 올바르게 연결되는지 검사한다.

  • ESM 코드와 ESM 타입 연결
  • CommonJS 코드와 CommonJS 타입 연결
  • .js.d.ts 대응
  • .cjs.d.cts 대응
  • exportstypes 조건
  • 모듈 해석 방식에 따른 타입 접근 가능 여부

두 도구는 겹치는 부분이 있지만 검사 관점이 다르다.

도구주요 관점대표적인 질문
publintnpm 패키지 구조exports가 실제 파일을 올바르게 가리키는가?
attwTypeScript 소비 환경ESM/CJS 소비자가 올바른 타입 선언을 읽는가?

tsdown 자체에도 publintattw를 실행하는 옵션이 존재한다. 일반적인 설정은 다음과 같은 형태다.

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 설정에서 publintattw를 바로 실행하면 타입 선언이 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.jsonexports가 소비자에게 공개할 경로를 결정한다. 별도로 생성된 결과물은 하나의 배포 구조로 합친 뒤 publintattw를 통해 실제 소비 환경에서 올바르게 연결되는지 결정한다.

profile
빙글빙글돌아가는..

0개의 댓글