diff --git a/backend/src/routes/recipes.ts b/backend/src/routes/recipes.ts index 7805f2d..0692507 100644 --- a/backend/src/routes/recipes.ts +++ b/backend/src/routes/recipes.ts @@ -1,5 +1,5 @@ import * as fs from 'node:fs'; -import type { FastifyInstance, FastifyPluginAsync, FastifyReply } from 'fastify'; +import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; import multipart from '@fastify/multipart'; import { deleteFile } from '../utils/storage'; import { streamRecipe, type StructuredRecipe } from '../ai/recipe-generator'; @@ -8,13 +8,37 @@ import type { UserPreferences } from '../ai/prompts'; type SSESender = (event: string, data: unknown) => void; -function setupSSE(reply: FastifyReply): SSESender { +/** + * Prépare la réponse pour du Server-Sent Events. + * + * - Appelle `reply.hijack()` pour dire à Fastify "je m'occupe de la réponse + * moi-même, ne tente pas de faire reply.send()" + * - Écrit les headers SSE + les headers CORS manuellement (les hooks onSend + * de @fastify/cors ne s'exécutent pas sur une réponse hijackée) + */ +function setupSSE(request: FastifyRequest, reply: FastifyReply): SSESender { + reply.hijack(); + + const origin = request.headers.origin; + const corsHeaders: Record = {}; + if (origin) { + corsHeaders['Access-Control-Allow-Origin'] = origin; + corsHeaders['Access-Control-Allow-Credentials'] = 'true'; + corsHeaders['Vary'] = 'Origin'; + } + reply.raw.writeHead(200, { - 'Content-Type': 'text/event-stream', + 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no', // désactive le buffering nginx si en prod + ...corsHeaders, }); + + // Flush immédiat pour que le client reçoive les headers tout de suite + // (sinon certains proxies attendent le premier byte) + reply.raw.write(': ok\n\n'); + return (event, data) => { reply.raw.write(`event: ${event}\n`); reply.raw.write(`data: ${JSON.stringify(data)}\n\n`); @@ -282,7 +306,7 @@ const recipesRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => { } const preferences = parseUserPreferences(user); - const send = setupSSE(reply); + const send = setupSSE(request, reply); // Heartbeat toutes les 15s pour que les proxies ne ferment pas la connexion const heartbeat = setInterval(() => { @@ -397,14 +421,17 @@ const recipesRoutes: FastifyPluginAsync = async (fastify: FastifyInstance) => { send('done', {}); } catch (err) { fastify.log.error(err, 'create-stream failed'); - send('error', { message: (err as Error).message }); + try { + send('error', { message: (err as Error).message }); + } catch { + /* ignore si le flux est déjà fermé */ + } } finally { cleanup(); reply.raw.end(); } - - // On a déjà géré la réponse via reply.raw - return reply; + // Pas de return : on a appelé reply.hijack() donc Fastify nous laisse + // gérer la réponse nous-mêmes. }); };