Une seule application Astro sur Node et Cloudflare Workers : pièges à connaître en matière de configuration
Configurations Astro doubles pour Node et Workers : suppression des doublons avec React, alias edge avec Prisma, fichiers externes avec Vite, gestion de la mémoire en CI, et point d’entrée fetch pour les Workers.
Une même base de code est déployée sur Node (Docker auto-hébergé) ainsi que sur Cloudflare Workers (edge). L’arborescence partagée se trouve à côté de astro.config.mjs et astro.config.cloudflare.mjs.
Cette configuration est viable. Pour l’atteindre, il a fallu effectuer plusieurs sessions de débogage ciblées, chacune révélant une configuration propre à Cloudflare dont le fichier Node n’avait jamais eu besoin. Les tutoriels de démarrage documentent rarement ces écarts.
Les deux fichiers de configuration sont 90 % identiques, et c’est là le problème
Diviser les configurations dans un seul fichier semble ordonné au premier abord. Cependant, cela devient complexe lorsque l’on a également besoin d’un autre adaptateur, d’une autre politique pour les fichiers externes, d’un autre annuaire d’alias, ainsi que de paramètres de mémoire distincts pour la compilation. Une longue instruction conditionnelle couvrant tout cela est pire à lire qu’un couple de fichiers séparés.
Le compromis réside dans la maintenance : les paramètres partagés doivent être mis à jour manuellement. La résolution des modules React constitue un point critique, c’est pourquoi le fichier Cloudflare contient un commentaire d’avertissement :
// 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.
N’oubliez pas : React resolve.dedupe vise la correction, non la perfection. Des copies dupliquées de React provoquent des erreurs dès l’utilisation du premier hook, et le stack trace indique généralement votre composant plutôt que la configuration du bundler.
Le client généré par Prisma ne fonctionne pas avec workerd
Le client Prisma 7 repose sur des importations de souschemins Node tels que #main-entry-point. Une compilation Rollup pour workerd ne peut pas gérer cela. Préférez aliaser le module directement vers l’entrée d’exécution 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,
},
}
La manière dont le chemin est déterminé est importante. À partir de Prisma 7.8, les fichiers .prisma/client/ générés se trouvent à l’intérieur du package @prisma/client. Avec pnpm, cela donne une adresse chiffrée du type .pnpm/@prisma+client@<hash>/node_modules/.prisma/client/, et non un répertoire node_modules racine fixe. Un chemin saisi une fois sur un ordinateur portable échoue souvent avec une autre configuration de hoist ou un hash différent. Se référer à @prisma/client/package.json permet de surmonter ces différences.
La liste des éléments externes, et une entrée délibérément absente
Tout ce qui ne fonctionne qu’avec Node doit rester en dehors du bundle workerd. De nombreux projets n’ont besoin que d’une petite liste d’exclusions :
const NODE_ONLY_EXTERNALS = ['ioredis'];
ioredis est chargé via une import() dynamique après l’appel à isCloudflareRuntime(), de sorte que les Workers ne téléchargent jamais ce chunk, ce qui garantit la sécurité en le rendant externe.
pg n’apparaît pas intentionnellement sur cette liste. @prisma/adapter-pg charge pg grâce à une importation statique, et PrismaPg s’exécute toujours sur le chemin Cloudflare Hyperdrive, ce qui oblige le pilote à être inclus dans le bundle. Lorsque nodejs_compat est activé, ce client TCP utilise la couche de compatibilité Node de Cloudflare. Si pg avait été marqué comme externe, cela aurait provoqué l’erreur Uncaught Error: No such module "chunks/pg" lors du chargement du worker.
Une règle essentielle : considérez une dépendance comme externe uniquement lorsque chaque route qui l’importe est dynamique et conditionnée. Une seule importation statique quelque part suffit à transformer une compilation réussie en une panne après déploiement plus difficile à diagnostiquer.
L’adaptateur écrase vos éléments externes, il faut donc les ajouter à nouveau
Ce problème a été le plus difficile à identifier. À l’intérieur de astro:build:setup, @astrojs/cloudflare force vite.ssr.noExternal = true et réinitialise vite.build.rollupOptions.external en ['sharp']. Tout ce que vous avez configuré dans ssr.external disparaît avant le démarrage de Rollup.
Un plugin Vite avec enforce: 'post' qui réécrit les éléments externes permet de contourner ce problème :
{
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
},
}
Plusieurs valeurs sont requises : external peut déjà être un tableau, une chaîne de caractères, un RegExp, une fonction ou undefined, et une future version de l’adaptateur pourrait à nouveau changer cela. La note indiquée à côté du plugin précise qu’il disparaîtra une fois que @astrojs/cloudflare cesse de remplacer ssr.external — il s’agit d’une correction temporaire pour le comportement d’une version donnée.
La compilation a manqué de mémoire en CI mais pas localement
En regroupant toutes les entrées SSR dans un seul bundle workerd, on dépasse la mémoire par défaut de Node de 2 GB. Le CI sur Cloudflare plante autour de 1,99 GB, alors que l’ordinateur portable d’un développeur fonctionne sans problème — c’est un type de bug particulièrement gênant.
Deux paramètres Vite ont permis de soulager cette pression :
build: {
sourcemap: false,
reportCompressedSize: false,
},
Les cartes sources prennent beaucoup de mémoire, et les workers les ignorent. reportCompressedSize alloue également une copie compressée de chaque morceau uniquement pour afficher un tableau de synthèse plus lisible. Aucune de ces fonctionnalités ne s’avère rentable sur ce système cible.
L’entrée worker effectue deux tâches que Node n’a pas besoin de faire
Node vous fournit gratuitement un cycle de vie pour les requêtes. Avec les Workers, vous devez l’implémenter vous-même :
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) existe parce qu’il n’y a pas de process.env chez les Workers. Les bindings apparaissent sous forme d’arguments des gestionnaires, ce qui nécessite un mécanisme de liaison par requête pour accéder aux configurations. Lors du portage d’un service Node, de nombreux fichiers doivent être modifiés ; il vaut mieux mettre en place ce mécanisme de liaison dès le début plutôt que de chercher ensuite des lectures dispersées de process.env.FOO.
ctx.waitUntil(disposeRequestDb()) s’occupe du nettoyage : le client de base de données est libéré après l’envoi de la réponse. Un nettoyage anticipé pourrait libérer un client dont les flux en continu dépendent encore.
Vaut-il la peine de répéter l’opération avec deux cibles ?
Oui — à condition que la seconde cible ait une fonction bien définie. Workers n’est pas un outil gratuit permettant de « déployer partout ». Il ajoute une autre chaîne de construction avec des modes d’échec distincts, et la plupart d’entre eux se manifestent lors du déploiement plutôt que pendant les tests.
Le travail reste gérable lorsque la divergence est limitée : deux configurations plus un module d’entrée. Le code de domaine doit éviter d’utiliser des instructions comme if (isWorkers) dès lors que le cache, le stockage et la base de données sont déjà gérés par des adaptateurs. Ces liens manquent ? Créez-les avant d’ajouter le deuxième environnement de exécution. Faire l’inverse entraîne des vérifications en temps de exécution dans les flux de validation et d’autres services essentiels.