Создание npm пакета на TypeScript с поддержкой CommonJS и ESM

Источник: «Create npm package with CommonJS and ESM support in TypeScript»
Если необходимо создать npm пакет и гарантировать, что его смогут использовать все желающие, нужно, чтобы он поддерживал CommonJS (CJS) и ECMAScript Modules (ESM). Рассмотрим, как создать такой пакет используя TypeScript.

CommonJS и ESM

При создании приложений на JavaScript есть выбор из двух систем модулей: CommonJS и ECMAScript Modules. Несмотря на недавний рост популярности ESM, CommonJS по-прежнему широко используется, не говоря уже, что в Node.js она используется по умолчанию. Чтобы убедиться, что ваш пакет npm может быть использован всеми, необходимо поддерживать обе системы модулей.

Подготовка проекта

CommonJS и ESM несовместимы друг с другом. Чтобы поддерживать оба варианта, пакет должен содержать две версии кода: одну для CommonJS и одну для ESM. Для поддержки использования в TypeScript потребуется включить определения типов. Поскольку форма экспортируемого API одинакова, можно использовать один и тот же TypeScript для CommonJS и ESM.

Вот глобальная структура кода, которая должна быть создана в пакете:

project
├── dist // сгенерированный пакет
│ ├── cjs
│ │ └── Код CJS
│ ├── esm
│ │ └── Код ESM
│ └── types
│ └── Определения типа TypeScript
└── src // исходный код
└── Исходный код на TypeScript

В следующих разделах описано, как настроить проект для получения такого результата.

Настройка TypeScript

TypeScript может одновременно создавать только один код. Чтобы создать код CommonJS и ESM, необходимы два разных конфигурационных файла TypeScript (tsconfig.json). Хорошая новость заключается в том, что можно повторно использовать общие настройки и задать только отдельные части для CJS и ESM.

Начнём с создания базового конфигурационного файла TypeScript с общими настройками (tsconfig.base.json):

{
"compilerOptions": {
"lib": [
"esnext"
],
"declaration": true,
"declarationDir": "./dist/types",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"baseUrl": ".",
"rootDir": "./src"
},
"include": [
"src"
],
"exclude": [
"dist",
"node_modules"
]
}

В этом файле, помимо прочего, указывается местоположение исходного кода, папки для исключения и каталог для определения типов.

Далее создадим два конфигурационных файла для CommonJS и ESM.

tsconfig.cjs.json:

{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"outDir": "./dist/cjs",
"target": "ES2015"
}
}

tsconfig.esm.json:

{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "esnext",
"outDir": "./dist/esm",
"target": "esnext"
}
}

Конфигурация package.json

После настройки TypeScript перейдите к конфигурации package.json. Здесь вам потребуется сделать несколько вещей. Нужно указать точки входа для пользователей CJS и ESM и определить скрипты для сборки пакета в обоих форматах.

Определение точек входа

Начнём с определения точек входа для пользователей CJS и ESM.

{
"name": "ts-cjs-esm",
"version": "1.0.0",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.mjs",
"types": "./dist/types/index.d.ts",
"files": [
"dist"
]
// ...
}

Есть основная точка входа для обращения к коду CJS и точка входа модуля для обращения к коду ESM. Оба формата используют одни и те же определения типов TypeScript. К сожалению, свойство main отменяет свойство module в Node.js, поэтому нужен дополнительный способ указать, как различные пользователи должны загружать библиотеку. Это можно сделать с помощью свойства exports.

{
"name": "ts-cjs-esm",
"version": "1.0.0",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.mjs",
"types": "./dist/types/index.d.ts",
"exports": {
".": {
"require": "./dist/cjs/index.js",
"import": "./dist/esm/index.mjs"
}
},
"files": [
"dist"
]
// ...
}

Используя свойство exports, можно указать, как различные пользователи должны загружать библиотеку. Свойство require указывает на код CJS, а свойство import — на код ESM.

Расширение файлов .mjs vs .js

Вы могли заметить, что ESM код имеет расширение .mjs, в то время как CJS использует .js. Это необходимо для того, чтобы отличать ESM код от CJS кода. Если пакет содержит только ESM код, в файле package.json можно задать свойство type в module, чтобы указать, что пакет содержит ESM код. Это даст указание Node.js рассматривать все файлы как модули ES. К сожалению, в данном случае это невозможно, поскольку пакет содержит как CJS, так и ESM код. Это означает, что для обозначения ESM файлов необходимо использовать расширение .mjs. Ещё одна вещь, усложняющая ситуацию, заключается в том, что TypeScript не позволяет указывать расширение выходного файла и всегда выдаёт файлы .js. Чтобы обойти это, необходимо переименовывать выходные файлы и импортировать их после сборки.

Определение npm скриптов

После того как базовая настройка пакета выполнена, давайте продолжим с созданием нескольких скриптов для сборки пакета в обоих форматах.

{
"name": "ts-cjs-esm",
// ...
"scripts": {
"build:cjs": "tsc -p tsconfig.cjs.json",
"build:esm": "tsc -p tsconfig.esm.json && npm run rename:esm",
"build": "npm run build:cjs && npm run build:esm",
"clean": "rimraf dist",
"rename:esm": "/bin/zsh ./scripts/fix-mjs.sh",
"prepack": "npm run clean && npm run build"
}
// ...
}

Начнём с двух сценариев сборки, одного для CJS (build:cjs) и одного для ESM (build:esm), каждый из которых указывает на соответствующий файл конфигурации TypeScript. Как уже упоминалось, TypeScript не позволяет указывать расширение выходного файла, поэтому после сборки необходимо переименовать полученные файлы. Скрипт rename:esm запускает сценарий shell, переименовывающий файлы .js в .mjs и обновляет ссылки на импорт. Для удобства также включаем сценарий clean для очистки каталога результатов и build — сборки пакета перед публикацией.

Установка зависимостей

В скриптах npm используются TypeScript и rimraf. Убедитесь, что они установлены в качестве dev зависимостей:

npm install --save-dev typescript rimraf

Вспомогательный скрипт

Для поддержки сборки ESM кода понадобится вспомогательный скрипт, переименовывающий итоговые файлы и обновляющий ссылки на импорт. Создадим shell скрипт fix-mjs.sh в папке scripts:

for file in ./dist/esm/*.js; do
echo "Updating $file contents..."
sed -i '' "s/\.js'/\.mjs'/g" "$file"
echo "Renaming $file to ${file%.js}.mjs..."
mv "$file" "${file%.js}.mjs"
done

Этот сценарий перебирает все .js файлы в папке dist/esm. В каждом файле он заменяет .js на .mjs в содержимом файла, а затем переименовывает файл, чтобы у него было расширение .mjs.

.gitignore и .npmignore

Если собираетесь хранить исходники в git-репозитории, добавьте в проект файл .gitignore, чтобы не включать в репозиторий ненужные файлы:

dist
node_modules

Поскольку выполняется сборка библиотеки, которая, скорее всего, будет распространяться, следует добавить файл .npmignore, чтобы исключить ненужные файлы из npm пакета:

scripts
src

Пример исходного код

Для тестирования настройки создадим простые файлы исходного кода TypeScript в папке src.

myModule.ts:

export function myFunction() {
return 'Hello World!';
}

index.ts:

export * from './myModule.js';

Обратите внимание, что при обращении к файлу myModule используется расширение .js. Это необходимо для корректной работы сборки ESM. При сборке ESM пакета вспомогательный скрипт обновляет расширение до .mjs.

Сборка пакета

Чтобы убедиться в работоспособности установки, запустим сценарий сборки:

npm run build

Если всё в порядке, в папке dist должен появиться соответствующий результат:

dist
├── cjs
│ ├── index.js
│ └── myModule.js
├── esm
│ ├── index.mjs
│ └── myModule.mjs
└── types
├── index.d.ts
└── myModule.d.ts

Подведение итогов

Создавая npm пакет, подумайте о поддержке CommonJS и ECMAScript Modules, чтобы вашим пакетом могли пользоваться все. Для поддержки CJS и ESM необходимо создать две версии кода, по одной для каждой модульной системы. Настроив проект определённым образом, можно создать пакет, поддерживающий обе системы модулей и включающий определения типов TypeScript. Таким образом, можно обеспечить бесперебойную работу для всех пользователей пакета, независимо от используемой ими системы модулей.

Дополнительные материалы

Предыдущая Статья

Версионирование API в Laravel 11

Следующая Статья

Руководство по событиям модели Laravel