Головна / Статті / Одне додатко Astro на Node та Cloudflare Workers: нюанси конфігурації

Одне додатко Astro на Node та Cloudflare Workers: нюанси конфігурації

Дві конфігурації Astro для Node та Workers: видалення дублікатів у React, едг-аліаси Prisma, зовнішні файли Vite, пам’ять у CI та точка входу fetch для Workers.

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 не може це обробити. Замість цього перенаправте модуль безпосередньо на вхідний пункт роботи:

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(), тому Workers ніколи не отримують цей фрагмент коду, і його зовнішнє розміщення залишається безпечним.

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

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

Адаптер перезаписує ваші зовнішні компоненти, тому додайте їх знову

Цю проблему виявили найповільніше. У функції 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) існує через відсутність process.env у Worker. Параметри конфігурації передаються як аргументи обробників, тому для доступу до них потрібен місток для кожного запиту. Перенесення сервісу з Node зазвичай вимагає змін багатьох файлів; створення цього мосту на ранньому етапі краще, ніж пізніше шукати розкидані виклики process.env.FOO.

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

Чи варто повторювати використання двох цілей?

Так — за умови, що друга мета має чітке призначення. Workers — це не безкоштовний варіант для розгортання всюди. Він додає ще один конвейер збірки із окремими способами збою, причому більшість з них проявляється під час розгортання, а не під час тестування.

Робота залишається керованою, коли розбіжності обмежені: дві конфігурації та один модуль входу. Код домену повинен уникати використання if (isWorkers), якщо кеш, зберігання та база даних вже реалізовані через адаптери. Якщо таких з’єднань немає? Створіть їх перед додаванням другого середовища виконання. Якщо робити навпаки, перевірки середовища виконання потраплять у процеси підтвердження та інші основні сервіси.