fix(sse): hijack reply + manual CORS headers on SSE stream

- reply.hijack() so Fastify doesn't send default 404 after handler returns
- Set Access-Control-Allow-Origin manually (onSend hooks don't fire on raw)
- Initial ': ok' comment line to flush headers immediately
- Guard send('error') in case stream already closed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-04-08 12:57:19 +02:00
parent 460f7d334c
commit d926ad89c5

View File

@ -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<string, string> = {};
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');
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.
});
};