Главная / Статьи / Одно приложение Astro на Node и Cloudflare Workers: важные нюансы конфигурации

Одно приложение Astro на Node и Cloudflare Workers: важные нюансы конфигурации

Двойные настройки Astro для Node и Worker: устранение дубликатов в React, псевдонимы на краю сети Prisma, внешние зависимости Vite, ограничение памяти в CI и точка запуска fetch для Worker.

1027 слов

Один кодовый базис развертывается на Node (с самостоятельным хостингом в Docker) и на Cloudflare Workers (в режиме edge). Общая структура файлов находится рядом с astro.config.mjs и astro.config.cloudflare.mjs.

Такая конфигурация работоспособна. Чтобы её настроить, потребовалось несколько этапов тщательной отладки; на каждом из них выявлялась настройка, актуальная только для Cloudflare и не нуждавшаяся в файле для Node. В учебных материалах для начинающих редко описываются такие нюансы.

Две конфигурации на 90% идентичны, и в этом проблема

Использование веток внутри одного файла конфигурации сначала кажется удобным. Однако ситуация усложняется, когда требуются дополнительные адаптеры, другая политика обработки внешних зависимостей, отдельная карта псевдонимов и разные настройки памяти для процесса сборки. Длинные условия, охватывающие всё это, делают код менее читаемым, чем два отдельных файла.

Компромисс заключается в трудоемкости обслуживания: совместные настройки необходимо вручную синхронизировать. Проблема возникает при разрешении модулей React, поэтому в файле Cloudflare присутствует предупреждающий комментарий:

// Must mirror astro.config.mjs's React handling. Without dedupe the
// production Rollup client build resolves react-dom's internal react to a
// different chunk than the islands' react, yielding two React instances ->
// "Cannot read properties of null (reading 'useEffect')" when IslandHydrator
// calls createRoot().render() on a hooked component.

Помните: функция React resolve.dedupe обеспечивает корректность, но не идеальную работу. Дубликаты копий React могут привести к сбоям уже при первом использовании хуков, и стек ошибок обычно указывает на ваш компонент, а не на конфигурацию бандлера.

Генерируемый клиент Prisma не работает с workerd

Клиент Prisma 7 использует импорты по подпутям Node, такие как #main-entry-point. Сборка с помощью Rollup для workerd не может обработать такие пути. Вместо этого нужно напрямую указать алиас модуля на входную точку Edge:

const PRISMA_CLIENT_DIR = path.dirname(require.resolve('@prisma/client/package.json'));
const PRISMA_EDGE_ENTRY = path.resolve(PRISMA_CLIENT_DIR, '../../.prisma/client/edge.js');
resolve: {
  alias: {
    '.prisma/client/default': PRISMA_EDGE_ENTRY,
  },
}

Важно то, как формируется путь. Начиная с Prisma 7.8, генерируемые файлы .prisma/client/ находятся внутри пакета @prisma/client. При использовании pnpm это приводит к хэшированному пути вида .pnpm/@prisma+client@<hash>/node_modules/.prisma/client/, а не к фиксированной папке node_modules. Путь, введённый один раз на ноутбуке, часто перестаёт работать при другой структуре хранения или разном хэше. Ориентация на @prisma/client/package.json позволяет преодолеть эти различия.

Список внешних зависимостей и одна намеренно отсутствующая запись

Всё, что требуется только Node, должно оставаться вне пакета workerd. Многим проектам достаточно небольшого списка исключений:

const NODE_ONLY_EXTERNALS = ['ioredis'];

ioredis подключается с помощью динамического import() после вызова функции isCloudflareRuntime(), поэтому Worker’ы никогда не загружают этот модуль, и его вынесение наружу остается безопасным.

pg намеренно не входит в этот список. Библиотека @prisma/adapter-pg загружает pg с помощью статического импорта, а класс PrismaPg продолжает работать по пути Cloudflare Hyperdrive, поэтому драйвер должен находиться внутри пакета. При включенном режиме nodejs_compat этот TCP-клиент использует слой совместимости Node от Cloudflare. Если бы pg был отнесен к внешним модулям, при загрузке Worker’а возникала бы ошибка Uncaught Error: No such module "chunks/pg".

Важное правило: считайте зависимость внешней только тогда, когда каждый маршрут, который её импортирует, является динамическим и защищённым. Один лишь статический импорт где-либо превращает корректно собранную версию в приложение, которое после развертывания выходит из строя, и отследить причину такого сбоя становится сложнее.

Адаптер перезаписывает ваши внешние зависимости, поэтому добавляйте их заново

Эту проблему удалось выявить медленнее всех. Внутри функции astro:build:setup библиотека @astrojs/cloudflare устанавливает значение vite.ssr.noExternal = true и сбрасывает значение vite.build.rollupOptions.external до ['sharp']. Всё, что вы указали в параметре ssr.external, исчезает ещё до запуска Rollup.

Чтобы решить эту проблему, можно использовать плагин Vite с параметром enforce: 'post', который восстанавливает исходные настройки внешних зависимостей:

{
  name: 'autonnel:cf-extra-externals',
  enforce: 'post',
  config(conf) {
    const existing = conf.build?.rollupOptions?.external;
    if (Array.isArray(existing)) {
      conf.build.rollupOptions.external = [...new Set([...existing, ...NODE_ONLY_EXTERNALS])];
    } else if (typeof existing === 'function') {
      const existingFn = existing;
      conf.build.rollupOptions.external = (id, parentId, isResolved) =>
        NODE_ONLY_EXTERNALS.includes(id) || existingFn(id, parentId, isResolved);
    }
    // ...string / RegExp / undefined branches
  },
}

Требуется несколько параметров: значение external может уже быть массивом, строкой, объектом RegExp, функцией или значением undefined, причем в будущих обновлениях адаптера это значение может снова измениться. В примечании рядом с плагином указано, что он должен исчезнуть, как только @astrojs/cloudflare перестанет перезаписывать значение ssr.external — это временное решение для поведения определённой версии.

В процессе сборки закончилась память в CI, но не локально

Собирание всех элементов SSR в один пакет workerd привело к превышению стандартных 2 ГБ памяти Node. Сборка в CI на Cloudflare завершилась при объёме памяти около 1,99 ГБ, в то время как на ноутбуке разработчика всё прошло без проблем — это довольно сложный тип ошибки.

Два флага Vite устранили эту проблему:

build: {
  sourcemap: false,
  reportCompressedSize: false,
},

Источниковые карты занимают много памяти, и Workerd их игнорирует. Функция reportCompressedSize также выделяет сжатую копию каждого фрагмента лишь для того, чтобы отобразить более красивую таблицу сводных данных. Ни один из этих методов не окупает себя на данной платформе.

Элемент worker выполняет два действия, которые не требуются в Node

В Node цикл жизни запроса предоставляется бесплатно. В Worker его необходимо реализовывать самостоятельно:

export default {
  async fetch(request, env, ctx) {
    setRuntimeEnv(env);
    return runWithRequestDb(async () => {
      try {
        return await ssrHandler.fetch(request, env, ctx);
      } finally {
        ctx.waitUntil(disposeRequestDb());
      }
    });
  },
  async scheduled(_event, env) { /* ... */ },
};

Функция setRuntimeEnv(env) существует потому, что в Worker отсутствует объект process.env. Параметры конфигурации поступают в виде аргументов обработчиков, поэтому для доступа к настройкам требуется механизм, работающий для каждого запроса. При портировании сервиса из Node обычно приходится изменять множество файлов; реализация такого механизма с самого начала экономит время по сравнению с позднейшим поиском разрозненных вызовов process.env.FOO.

ctx.waitUntil(disposeRequestDb()) обеспечивает очистку: клиент базы данных освобождается после отправки ответа. Очистка раньше времени может привести к освобождению клиента, от которого всё ещё зависит потоковый вывод.

Стоит ли повторять использование двух целей?

Да — при условии, что у второй цели чёткая задача. Workers — это не бесплатный инструмент для развертывания везде. Он добавляет ещё один канал сборки с уникальными способами сбоев, причём большинство из них проявляются во время развертывания, а не в тестах.

Работа остаётся управляемой, когда различия ограничены определёнными рамками: две конфигурации плюс один модуль входа. Код домена должен избегать использования if (isWorkers), если кэш, хранилище и база данных уже находятся за адаптерами. Если таких связей нет, их необходимо создать перед добавлением второго режима работы. Обратный подход приводит к тому, что проверки режима работы попадают в процессы проверки кода и другие основные сервисы.