
하나의 NPM 패키지가 CommonJS(CJS)와 ES Modules(ESM)을 동시에 지원하도록 설계된 패키지를 Dual CJS/ESM 패키지라고 한다.
.js 파일이 CJS인지 ESM인지 구분하는 방법은 package.json의 type field 또는 확장자를 보고 알 수 있다.
.js 파일의 Module System은 package.json의 type field에 따라 결정.type field의 기본값은 "commonjs" 이고, 이 때 .js 는 CJS로 간주."module" 입니다. 이 때 .js 는 ESM으로 간주..cjs / .cts는 항상 CJS로 간주..mjs / .mts는 항상 ESM으로 간주.Dual CJS/ESM 패키지의 폴더 구조는 다음과 같다.
my-package/
├── dist/ // 배포 디렉토리
│ ├── cjs/ // CommonJS 빌드
│ ├── mjs/ // ESM 빌드
├── src/ // 작업 디렉토리
│ ├── index.js // 원본 코드
└── package.json
npm publish를 실행하면 package.json의 files 필드에 지정한 경로의 파일들이 npm 레지스트리 상에 올라가 배포하게 된다.
우리는 CJS, ESM을 전부 지원할 것이기에 TS로 작성한 모듈을 dist 안에 CJS, ESM를 지원할 2가지 버전을 만들어야 한다.
먼저 package.json의 scripts 필드에 build 명령을 살짝 수정해주자.
// package.json
"scripts": {
"test:esm": "node test.mjs",
"test:cjs": "node test.cjs",
"build:esm": "tsc --project tsconfig.esm.json",
"build:cjs": "tsc --project tsconfig.cjs.json",
"build": "npm run build:esm && npm run build:cjs"
},
다음으로는 ESM, CJS 버전에 해당하는 tsconfig 파일을 준비할 차례이다.
기존의 패키지가 ESM을 지원하였기에 이름과 설정을 살짝 바꾸고, 해당 config 파일을 extends해서 CJS을 지원하는 config 파일을 작성했다.
// tsconfig.esm.json
{
"compilerOptions": {
/* Language and Environment */
"target": "ESNext", /* 어떤 ECMAScript 버전으로 컴파일할지 결정한다. */
/* Modules */
"module": "ESNext", /* import 문을 어떤 모듈 시스템으로 번역할지 결정한다. */
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
"rootDir": "./src", /* Specify the root folder within your source files. */
"outDir": "./dist/esm", /* Specify an output folder for all emitted files. */
"declarationDir": "./dist/esm/types",
/* Emit */
"resolveJsonModule": true, /* Include modules imported with .json extension. */
"declaration": true, /* .d.ts 파일을 자동으로 생성한다. */
"declarationMap": true, /* d.ts 파일에 대한 소스맵을 생성한다. */
"sourceMap": true, /* 소스맵을 생성한다. */
/* Interop Constraints */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
/* Completeness */
"skipLibCheck": false /* Skip type checking all .d.ts files. */
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
// tsconfig.cjs.json
{
"extends": "./tsconfig.esm.json",
"compilerOptions": {
"module": "CommonJS", /* CJS 모듈 시스템으로 변경 */
"outDir": "./dist/cjs", /* CommonJS 빌드 출력 경로 */
"declarationDir": "./dist/cjs/types" /* CJS 타입 정의 파일 경로 */
}
}
Dual CJS/ESM을 지원하는 버전이 완성되었고, NPM 상에 올리기만 하면 되지만 아직 부족하다.
해당 패키지를 실제로 사용하게 될 때 CJS, ESM 환경에 따라 우리가 준비해 둔 파일을 제공해야만 한다.
일반적으로 Dual CJS/ESM 패키지는 두 가지 빌드 출력을 포함해야 하며, package.json에서 main, module 속성을 사용해서 출력 경로를 지정해줄 수 있다.
main, module 속성은 package.json에서 모듈의 진입점(entry point)을 정의하는 필드로, 패키지가 Common JS나 ESM과 같은 다양한 모듈 시스템에서 어떻게 불러올 지를 지정해줄 수 있다.
CJS 환경에서 require로 불러올 때는 main에서 설정한 경로가, ESM 환경에서 import로 불러올 때는 module에 설정한 경로가 나온다.
// package.json
{
"name": "my-package",
"main": "dist/cjs/index.cs", // CommonJS 진입점
"module": "dist/esm/index.ms" // ESM 진입점
}
exports 필드를 활용하면 main, module에서 제공하는 기능을 대체하면서 더 세밀하게 각 환경별로 패키지 경로를 설정할 수 있다.
특정 조건 별로 설정을 달리 해줄 수 있는데, 자세한 설정은 이곳을 참조하자.
require, import 설정으로 각각 CJS, ESM 환경에서 사용할 때 각각 다른 파일을 불러오게끔 설정해준다.
// package.json
"exports": {
".": {
"types": {
"require": "./dist/cjs/types/index.d.ts",
"import": "./dist/esm/types/index.d.ts"
},
"default": {
"require": "./dist/cjs/index.js",
"import": "./dist/esm/index.js"
}
}
},
// package.json
{
"name": "ultra-exact-ncst",
"version": "1.2.2",
"description": "nowcast with coordinate",
"type": "module",
"exports": {
".": {
"types": {
"require": "./dist/cjs/types/index.d.ts",
"import": "./dist/esm/types/index.d.ts"
},
"default": {
"require": "./dist/cjs/index.js",
"import": "./dist/esm/index.js"
}
}
},
"scripts": {
"test:esm": "node test.mjs",
"test:cjs": "node test.cjs",
"build:esm": "tsc --project tsconfig.esm.json",
"build:cjs": "tsc --project tsconfig.cjs.json",
"build": "npm run build:esm && npm run build:cjs"
},
"author": "GulSam00",
"license": "ISC",
"Keywords": [
"nowcast",
"coordinate"
],
"dependencies": {
"axios": "^1.7.7",
"date-fns": "^4.1.0"
},
"devDependencies": {
"dotenv": "^16.4.5",
"typescript": "^5.6.3",
"ultra-exact-ncst": "^1.2.2"
},
"files": [
"dist/**/*"
]
}
// tsconfig.esm.json
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Language and Environment */
"target": "ESNext", /* 어떤 ECMAScript 버전으로 컴파일할지 결정한다. */
/* Modules */
"module": "ESNext", /* import 문을 어떤 모듈 시스템으로 번역할지 결정한다. */
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
"rootDir": "./src", /* Specify the root folder within your source files. */
"outDir": "./dist/esm", /* Specify an output folder for all emitted files. */
"declarationDir": "./dist/esm/types",
/* Emit */
"resolveJsonModule": true, /* Include modules imported with .json extension. */
"declaration": true, /* .d.ts 파일을 자동으로 생성한다. */
"declarationMap": true, /* d.ts 파일에 대한 소스맵을 생성한다. */
"sourceMap": true, /* 소스맵을 생성한다. */
/* Interop Constraints */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
/* Completeness */
"skipLibCheck": false /* Skip type checking all .d.ts files. */
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
// tsconfig.cjs.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS", /* CJS 모듈 시스템으로 변경 */
"outDir": "./dist/cjs", /* CommonJS 빌드 출력 경로 */
"declarationDir": "./dist/cjs/types" /* CJS 타입 정의 파일 경로 */
}
}