Асинхронная конфигурация

Конфигурационный файл Rollup не ограничивается обычным объектом. Вместо статической структуры допускается использование:

  • функции;
  • асинхронной функции;
  • Promise;
  • массива промисов;
  • динамической генерации параметров.

Благодаря этому конфигурация превращается в полноценный программный модуль, способный:

  • загружать внешние данные;
  • анализировать файловую систему;
  • вычислять окружение;
  • формировать сборки динамически;
  • создавать конфигурации для монорепозиториев;
  • интегрироваться с API и CI/CD.

Асинхронная конфигурация особенно полезна при сложной инфраструктуре сборки.


Асинхронный экспорт через Promise

Rollup умеет ожидать завершения промиса, экспортированного из конфигурационного файла.

Простейший пример:

export default Promise.resolve({
    input: 'src/index.js',

    output: {
        file: 'dist/bundle.js',
        format: 'esm'
    }
});

Rollup дождётся выполнения промиса и только после этого начнёт сборку.

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


Асинхронная функция конфигурации

Наиболее распространённый вариант:

export default async () => {
    return {
        input: 'src/index.js',

        output: {
            file: 'dist/bundle.js',
            format: 'esm'
        }
    };
};

Rollup вызывает функцию автоматически.

Преимущества такого подхода:

  • доступ к await;
  • выполнение предварительных вычислений;
  • возможность условной генерации сборок;
  • асинхронное чтение конфигурационных файлов;
  • загрузка удалённых данных.

Использование параметров командной строки

Функция конфигурации получает аргументы.

export default async commandLineArgs => {
    console.log(commandLineArgs);

    return {
        input: 'src/index.js',

        output: {
            file: 'dist/app.js',
            format: 'esm'
        }
    };
};

При запуске:

rollup -c --environment BUILD:production

аргументы будут доступны внутри конфигурации.


Объект окружения

Вторым параметром передаётся информация о среде выполнения.

export default async (args, context) => {
    console.log(context);

    return {
        input: 'src/index.js',

        output: {
            file: 'dist/app.js',
            format: 'esm'
        }
    };
};

Внутри context доступны сведения о:

  • watch-режиме;
  • CLI;
  • параметрах запуска;
  • внутреннем окружении Rollup.

Асинхронное чтение JSON

Одна из самых распространённых задач — загрузка внешнего JSON-файла.

import { readFile } from 'node:fs/promises';

export default async () => {
    const pkg = JSON.parse(
        await readFile('./package.json', 'utf8')
    );

    return {
        input: pkg.source,

        output: {
            file: pkg.main,
            format: 'cjs'
        }
    };
};

Такой подход позволяет:

  • избегать дублирования данных;
  • использовать package.json как единый источник конфигурации;
  • автоматически синхронизировать пути сборки.

Загрузка переменных окружения

Асинхронная конфигурация часто используется вместе с .env.

import dotenv from 'dotenv';

export default async () => {
    dotenv.config();

    const production =
        process.env.NODE_ENV === 'production';

    return {
        input: 'src/index.js',

        output: {
            file: production
                ? 'dist/prod.js'
                : 'dist/dev.js',

            format: 'esm'
        }
    };
};

Динамическое переключение конфигураций

Асинхронная функция особенно удобна для ветвления логики.

export default async () => {
    const production =
        process.env.NODE_ENV === 'production';

    if (production) {
        return {
            input: 'src/index.js',

            output: {
                file: 'dist/app.min.js',
                format: 'esm'
            }
        };
    }

    return {
        input: 'src/index.js',

        output: {
            file: 'dist/app.js',
            format: 'esm'
        }
    };
};

Генерация массива конфигураций

Rollup поддерживает множественные сборки.

Асинхронная функция может вернуть массив:

export default async () => {
    return [
        {
            input: 'src/index.js',

            output: {
                file: 'dist/index.esm.js',
                format: 'esm'
            }
        },

        {
            input: 'src/index.js',

            output: {
                file: 'dist/index.cjs.js',
                format: 'cjs'
            }
        }
    ];
};

Асинхронная генерация нескольких сборок

Конфигурации могут формироваться программно.

const formats = ['esm', 'cjs', 'umd'];

export default async () => {
    return formats.map(format => ({
        input: 'src/index.js',

        output: {
            file: `dist/bundle.${format}.js`,
            format
        }
    }));
};

Подобная схема существенно уменьшает дублирование.


Работа с файловой системой

Асинхронность особенно полезна при сканировании директорий.

import { readdir } from 'node:fs/promises';

export default async () => {
    const files = await readdir('./src/pages');

    const configs = files.map(file => ({
        input: `src/pages/${file}`,

        output: {
            file: `dist/${file}`,
            format: 'esm'
        }
    }));

    return configs;
};

Такая архитектура часто используется:

  • в SSR;
  • в multi-page приложениях;
  • в генераторах библиотек;
  • в дизайн-системах.

Автоматическое создание entry points

Rollup позволяет формировать точки входа динамически.

import { glob } from 'glob';

export default async () => {
    const entries = await glob('src/**/*.js');

    const input = Object.fromEntries(
        entries.map(file => [
            file
                .replace('src/', '')
                .replace('.js', ''),

            file
        ])
    );

    return {
        input,

        output: {
            dir: 'dist',
            format: 'esm'
        }
    };
};

Асинхронная загрузка удалённых данных

Иногда конфигурация зависит от внешнего API.

const response = await fetch(
    'https://example.com/build-config'
);

const remoteConfig = await response.json();

export default async () => {
    return {
        input: remoteConfig.entry,

        output: {
            file: remoteConfig.output,
            format: 'esm'
        }
    };
};

Подобные сценарии встречаются редко, однако возможны в:

  • облачных пайплайнах;
  • build-сервисах;
  • централизованных системах конфигурации.

Асинхронная настройка плагинов

Плагины могут зависеть от вычисляемых данных.

import replace from '@rollup/plugin-replace';

export default async () => {
    const version = process.env.APP_VERSION;

    return {
        input: 'src/index.js',

        plugins: [
            replace({
                preventAssignment: true,

                __VERSION__: JSON.stringify(version)
            })
        ],

        output: {
            file: 'dist/app.js',
            format: 'esm'
        }
    };
};

Вычисление external-зависимостей

Асинхронность позволяет автоматически анализировать зависимости.

import { readFile } from 'node:fs/promises';

export default async () => {
    const pkg = JSON.parse(
        await readFile('./package.json', 'utf8')
    );

    return {
        input: 'src/index.js',

        external: [
            ...Object.keys(pkg.dependencies || {}),
            ...Object.keys(pkg.peerDependencies || {})
        ],

        output: {
            file: 'dist/index.js',
            format: 'esm'
        }
    };
};

Это распространённый шаблон библиотечной сборки.


Асинхронная конфигурация для монорепозитория

В монорепозиториях конфигурация часто строится автоматически.

import { readdir } from 'node:fs/promises';

export default async () => {
    const packages = await readdir('./packages');

    return packages.map(name => ({
        input: `packages/${name}/src/index.js`,

        output: {
            file: `packages/${name}/dist/index.js`,
            format: 'esm'
        }
    }));
};

Условное подключение плагинов

Асинхронная логика помогает управлять тяжёлыми плагинами.

import terser from '@rollup/plugin-terser';

export default async () => {
    const production =
        process.env.NODE_ENV === 'production';

    return {
        input: 'src/index.js',

        plugins: [
            production && terser()
        ],

        output: {
            file: 'dist/app.js',
            format: 'esm'
        }
    };
};

Обычно затем применяется фильтрация:

plugins: [
    production && terser()
].filter(Boolean)

Использование top-level await

При ESM-конфигурации возможен top-level await.

import { readFile } from 'node:fs/promises';

const pkg = JSON.parse(
    await readFile('./package.json', 'utf8')
);

export default {
    input: pkg.source,

    output: {
        file: pkg.main,
        format: 'esm'
    }
};

Такой вариант работает только при:

  • ESM-конфигурации;
  • поддержке top-level await в Node.js.

Асинхронная конфигурация и watch-режим

Важно понимать особенность watch-режима:

  • конфигурация вычисляется при запуске Rollup;
  • при изменении исходников функция не вызывается повторно;
  • изменения внешних данных могут игнорироваться.

Например:

export default async () => {
    console.log('CONFIG EXECUTED');

    return {
        input: 'src/index.js',

        output: {
            file: 'dist/app.js',
            format: 'esm'
        }
    };
};

Сообщение обычно выводится один раз при старте watch-процесса.


Ошибки внутри асинхронной конфигурации

Ошибки автоматически пробрасываются в Rollup.

export default async () => {
    throw new Error('Invalid build configuration');
};

Rollup завершит сборку с ошибкой.


Обработка ошибок

Более безопасный вариант:

export default async () => {
    try {
        const config = await loadConfig();

        return config;
    } catch (error) {
        console.error(error);

        process.exit(1);
    }
};

Асинхронные фабрики конфигурации

Конфигурация может разделяться на отдельные генераторы.

async function createConfig(format) {
    return {
        input: 'src/index.js',

        output: {
            file: `dist/index.${format}.js`,
            format
        }
    };
}

export default async () => {
    return Promise.all([
        createConfig('esm'),
        createConfig('cjs'),
        createConfig('umd')
    ]);
};

Использование Promise.all

Асинхронная конфигурация может эффективно выполнять параллельные операции.

import { readFile } from 'node:fs/promises';

export default async () => {
    const [
        pkg,
        tsconfig
    ] = await Promise.all([
        readFile('./package.json', 'utf8'),
        readFile('./tsconfig.json', 'utf8')
    ]);

    return {
        input: 'src/index.js',

        output: {
            file: 'dist/app.js',
            format: 'esm'
        }
    };
};

Кэширование вычислений

При тяжёлых вычислениях конфигурация может использовать локальный кэш.

let cachedConfig;

export default async () => {
    if (cachedConfig) {
        return cachedConfig;
    }

    cachedConfig = {
        input: 'src/index.js',

        output: {
            file: 'dist/app.js',
            format: 'esm'
        }
    };

    return cachedConfig;
};

Ограничения асинхронной конфигурации

Несмотря на гибкость, асинхронная конфигурация имеет ряд недостатков:

Увеличение времени запуска

Каждый await замедляет старт сборки.

Особенно это заметно при:

  • сетевых запросах;
  • сканировании больших директорий;
  • анализе монорепозиториев.

Сложность отладки

Динамическая генерация усложняет понимание итоговой конфигурации.

Например:

return createConfigsFromWorkspace(
    await scanPackages()
);

В подобных системах становится труднее:

  • анализировать ошибки;
  • отслеживать итоговые параметры;
  • понимать структуру output.

Неочевидные зависимости

Конфигурация может зависеть от:

  • API;
  • состояния файловой системы;
  • переменных окружения;
  • внешних JSON-файлов.

Это ухудшает воспроизводимость сборки.


Практический шаблон промышленной конфигурации

import { readFile } from 'node:fs/promises';
import replace from '@rollup/plugin-replace';
import terser from '@rollup/plugin-terser';

export default async () => {
    const pkg = JSON.parse(
        await readFile('./package.json', 'utf8')
    );

    const production =
        process.env.NODE_ENV === 'production';

    return {
        input: pkg.source,

        external: [
            ...Object.keys(pkg.dependencies || {})
        ],

        plugins: [
            replace({
                preventAssignment: true,

                __VERSION__: JSON.stringify(
                    pkg.version
                )
            }),

            production && terser()
        ].filter(Boolean),

        output: [
            {
                file: pkg.module,
                format: 'esm'
            },

            {
                file: pkg.main,
                format: 'cjs'
            }
        ]
    };
};

Такая архитектура сочетает:

  • асинхронную загрузку данных;
  • динамическую генерацию;
  • условные плагины;
  • множественные форматы;
  • автоматическое определение external-зависимостей.