Skip to content

Instantly share code, notes, and snippets.

@krutoo
Last active October 12, 2023 12:25
Show Gist options
  • Save krutoo/fd5f694dffd36320d6f8758eb8d31c11 to your computer and use it in GitHub Desktop.
Save krutoo/fd5f694dffd36320d6f8758eb8d31c11 to your computer and use it in GitHub Desktop.
Формирование NPM-пакета с поддержкой ESM/CJS/DTS и React 17/18

Формирование NPM-пакета с поддержкой ESM/CJS/DTS и React 17/18

Предисловие

Для того чтобы наверняка понимать содержание данного документа потребуется:

  • понимание того что такое 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 в package.json

В зависимости от того что вы хотите добавить в пакет вам нужно определить поле files в exports.json:

{
  "files": ["src", "dist", "README.md"]
}

Из примера выше видно что в пакет попадут как исходники так и собранные файлы.

Поле exports в package.json

Также необходимо определить поле 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 то надо дополнительно сообщить среде исполнения модулями какого именно типа они являются.

Способ 1 - добавление вложенных package.json

Один из способов этого достичь заключается в том чтобы

  • положить в dist/esm файл package.json со следующим содержимым: { "type": "commonjs" }
  • положить в dist/cjs файл package.json со следующим содержимым: { "type": "module" }

Так Node.js и другие среды будут понимать как интерпретировать модуль.

Поддержка Module Federation

Особенность подхода с вложенными package.json состоит в том что если пакет имеет peerDependencies то при использовании Module Federation будут падать ошибки о том что не удалось установить версию peer-зависимости.

Пример проблемы: webpack/webpack#13457

Решением может стать добавления во вложенные package.json полей name и version значения которых соответствуют значениями из корневого package.json.

Способ 2 - переименование файлов

Другим способом является переименование файлов модулей со сменой их расширений на mjs и cjs соответственно.

Стоит учитывать что переименование должно также учитывать source map'ы в которых как правило указан файл к которому они приложены и если меняется расширение то стоит менять его также и в source map.

Файлы типов для TypeScript

Поле types в описании каждого пути импорта описывает путь до d.ts файла.

Из примера exports видно что все типы лежат в отдельной папке а не рядом с файлами к которым они относятся. Это сделано чтобы их не дублировать и не раздувать тем самым размер пакета.

Однако опытным путем было установлено что необходимо также еще одно поле в package.json для работы с TypeScript:

{
  "typesVersions": {
    ">=4.2": {
      "*": ["dist/types/*"]
    }
  }
}

Иногда работает и без него но с ним точно работает и хуже не станет.

Оно описывает где лежат d.ts файлы если их нет рядом с целевым файлом.

В случае если d.ts лежит прямо рядом с файлом это поле можно не указывать.

Особенности работы с TypeScript

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"
}

Поддержка React 17 и 18

Если ваш исходный код использует 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.

Особенности сборки под ESM

В 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 и импортирует их проверяя что не встретит ошибок

Пример ESM-теста

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();

Пример CJS-теста

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();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment