Для того чтобы наверняка понимать содержание данного документа потребуется:
- понимание того что такое CommonJS
- понимание того что такое ECMAScript modules
- понимание того что такое React
- понимание того что такое TypeScript
- понимание того что такое модуль в контексте Node.js
- понимание того что такое NPM-пакет
- и наверняка что-то еще =)
В данном документе я бы хотел зафиксировать свой небольшой опыт в подготовке NPM-пакета который удовлетворяет следующим условиям:
- поддерживается ESM (можно подключать через
import { hello } from '@my-pkg/hello'
) - поддерживается CJS (можно подключать через
const { hello } = require('@my-pkg/hello')
) - имеет сопутствующие
d.ts
файлы для работы с TypeScript - может работать с React 17 и 18
- может работать в Bun
- может работать в Deno
- учитывает использование с Module Federation
Алгоритм сборки зависит от конкретного пакета поэтому здесь будут описан только результат.
Большое спасибо автору статьи https://habr.com/ru/articles/695482/ на основе которой создан данный документ.
Чтобы пакет работал с Node.js как в режиме ESM так и в режиме CJS а также содержал типы для TS он должен собираться из исходников.
Исходники можно хранить по классике в ./src
а собираемые файлы которые будут непосредственно использоваться при подключении пакета в ./dist
Результирующие файлы можно не хранить в git добавив в .gitignore
.
Структура результирующих файлов может быть следующей:
.
└── dist/
├── esm/
│ └── ...модули в формате ESM
├── cjs/
│ └── ...модули в формате CommonJS
└── types/
└── ...типы для TypeScript для обоих видов модулей
В зависимости от того что вы хотите добавить в пакет вам нужно определить поле files в exports.json
:
{
"files": ["src", "dist", "README.md"]
}
Из примера выше видно что в пакет попадут как исходники так и собранные файлы.
Также необходимо определить поле exports в package.json
. В этом поле должны быть описаны пути для импорта и пути до файлов в следующем виде:
{
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"require": "./dist/cjs/index.js",
"import": "./dist/cjs/index.js",
"default": "./dist/cjs/index.js"
},
"./hello": {
"types": "./dist/types/hello.d.ts",
"require": "./dist/cjs/hello.js",
"import": "./dist/cjs/hello.js",
"default": "./dist/cjs/hello.js"
},
"./foo/bar": {
"types": "./dist/types/foo/bar/index.d.ts",
"require": "./dist/cjs/foo/bar/index.js",
"import": "./dist/cjs/foo/bar/index.js",
"default": "./dist/cjs/foo/bar/index.js"
}
}
}
Как видно из примера каждое свойство объекта exports имеет:
- ключ в виде пути относительно корня коим является каталог пакета в node_modules
- значение в виде объекта, ключи которого определяют в каком случае будет использован файл который соответствует их значениям.
Подробнее тут: https://nodejs.org/api/packages.html#conditional-exports
Методом проб и ошибок было выявлено что данная комбинация в (types, require, import, default) работает.
ВАЖНО: порядок полей должен соблюдаться именно в таком порядке. default должен быть последним, types - первым.
Если и в dist/esm и в dist/cjs файлы имеют расширение .js
то надо дополнительно сообщить среде исполнения модулями какого именно типа они являются.
Один из способов этого достичь заключается в том чтобы
- положить в dist/esm файл package.json со следующим содержимым:
{ "type": "commonjs" }
- положить в dist/cjs файл package.json со следующим содержимым:
{ "type": "module" }
Так Node.js и другие среды будут понимать как интерпретировать модуль.
Особенность подхода с вложенными package.json состоит в том что если пакет имеет peerDependencies то при использовании Module Federation будут падать ошибки о том что не удалось установить версию peer-зависимости.
Пример проблемы: webpack/webpack#13457
Решением может стать добавления во вложенные package.json полей name и version значения которых соответствуют значениями из корневого package.json.
Другим способом является переименование файлов модулей со сменой их расширений на mjs и cjs соответственно.
Стоит учитывать что переименование должно также учитывать source map'ы в которых как правило указан файл к которому они приложены и если меняется расширение то стоит менять его также и в source map.
Поле types в описании каждого пути импорта описывает путь до d.ts
файла.
Из примера exports
видно что все типы лежат в отдельной папке а не рядом с файлами к которым они относятся. Это сделано чтобы их не дублировать и не раздувать тем самым размер пакета.
Однако опытным путем было установлено что необходимо также еще одно поле в package.json для работы с TypeScript:
{
"typesVersions": {
">=4.2": {
"*": ["dist/types/*"]
}
}
}
Иногда работает и без него но с ним точно работает и хуже не станет.
Оно описывает где лежат d.ts
файлы если их нет рядом с целевым файлом.
В случае если d.ts
лежит прямо рядом с файлом это поле можно не указывать.
TypeScript при module: CommonJS
и moduleResolution: Node
не видит экспортов если они ссылаются на каталог с файлом index а не на файл с тем же именем что и экспорт сам по себе
Подробности здесь: https://stackoverflow.com/questions/77280140/why-typescript-dont-see-exports-of-package-with-module-commonjs-and-moduleres
Решением может стать смена конфигурации TypeScript на:
{
"module": "ES2022",
"moduleResolution": "Bundler"
}
Либо можно поступить так же как сделали авторы пакета rxjs - добавить package.json на каждый такой экспорт в корень пакета.
То есть если у вас есть экспорт my-pkg/foo/bar
который смотрит на файл dist/esm/foo/bar/index.js
а не на файл dist/esm/foo/bar.js
то в проекте должен присутствовать файл foo/bar/package.json
в котором будет примерно следующее:
{
"name": "my-pkg/foo/bar",
"module": "../../dist/esm/foo/bar/index.js"
}
Если ваш исходный код использует automatic JSX runtime который анонсировали в React 17 то с ESM важно учитывать один момент:
Для React 17 импорт jsx-runtime в результирующих модулях будет выглядеть следующим образом:
import { jsx } from 'react/jsx-runtime.js';
А для React 18 импорт будет выглядеть следующим образом:
import { jsx } from "react/jsx-runtime";
Как видно из примеров в одном случае расширение есть а в другом нет. Это связано с тем что описано в react/package.json в 17 и 18 версиях
Чтобы избежать этой проблемы можно собирать пакет таким образом чтобы в результирующих файлах он использовать не automatic JSX runtime а классический createElement
.
Проблема в том что если в исходниках используется automatic JSX runtime а сборка через babel или tsc настроена на классический createElement то в результате модули будут смотреть на несуществующий React.createElement импорта которого нет.
В качестве немного костыльного но рабочего решения предлагается запускать следующий скрипт после сборки ESM:
import fs from "node:fs/promises";
import glob from "fast-glob";
async function prependFile(path, content) {
const data = await fs.readFile(path);
const handle = await fs.open(path, "w+");
const insert = Buffer.from(content);
await handle.write(insert, 0, insert.length, 0);
await handle.write(data, 0, data.length, insert.length);
await handle.close();
}
const react = `import * as React from 'react';\n`;
glob("./dist/esm/**/*.js")
.then((items) =>
Promise.all(
items.map((item) =>
fs
.readFile(item, "utf-8")
.then((content) => ({ filename: item, content }))
)
)
)
.then((files) =>
files.filter((item) => item.content.includes("React.createElement"))
)
.then((files) =>
Promise.all(files.map((file) => prependFile(file.filename, react)))
);
Он добавляет в начало файла строку с импортом если в файле есть упоминание React.createElement.
В Node.js ESM не позволяет использовать Node module resolution. Это значит что подобный импорт:
import FooBar from "./foo-bar";
Не будет работать если:
- целевой файл находится в
./foo-bar.js
- целевой файл находится в
./foo-bar/index.js
- и тд
Импорты в ESM должны содержать точное имя файла включая расширение.
Для решения этой проблемы если вы используете Babel для сборки пакета можно воспользоваться небольшим плагином:
https://gist.github.com/krutoo/70dd3068c9a0b8b1105da749bc475ede
Его можно доработать под свои нужды указав обработку специфических случаев.
Для тестирования пакета можно прибегнуть к следующему алгоритму:
- собрать пакет
- упаковать пакет в tarball с помощью
npm pack
- установить пакет из tarball в отдельный специальный проект с тестами пакета
- написать CJS-тест который проходит по всем путям в exports и импортирует их проверяя что не встретит ошибок
- написать ESM-тест который проходит по всем путям в exports и импортирует их проверяя что не встретит ошибок
import fs from "node:fs/promises";
import path from "node:path";
import assert from "node:assert";
async function test() {
const pkg = JSON.parse(await fs.readFile("../package.json", "utf-8"));
for (const pathname of Object.keys(pkg.exports)) {
const specifier = path.join(pkg.name, pathname);
await assert.doesNotReject(async () => await import(specifier));
}
}
test();
const fs = require("node:fs/promises");
const path = require("node:path");
const assert = require("node:assert");
async function test() {
const pkg = JSON.parse(await fs.readFile("../package.json", "utf-8"));
for (const pathname of Object.keys(pkg.exports)) {
const specifier = path.join(pkg.name, pathname);
assert.doesNotThrow(() => require(specifier));
}
}
test();