@@ -7,12 +7,12 @@ const { pipeline } = require('stream/promises')
77
88const FST_ERR_SSE_UNKNOWN_KIND = createError (
99 'FST_ERR_SSE_UNKNOWN_KIND' ,
10- "@fastify/sse: unknown sse kind '%s'. Use 'only' (SSE-only route), 'dual' (route serves both SSE and non-SSE), or omit for back-compat."
10+ "@fastify/sse: unknown sse kind '%s'. Use 'only' (SSE-only route), 'dual' (route serves both SSE and non-SSE), 'manual' (handler decides at runtime, no Accept negotiation), or omit for back-compat."
1111)
1212
1313const FST_ERR_SSE_INVALID_OPTION = createError (
1414 'FST_ERR_SSE_INVALID_OPTION' ,
15- "@fastify/sse: unsupported value for route option 'sse': %s. Use true, 'dual', 'only', or an options object."
15+ "@fastify/sse: unsupported value for route option 'sse': %s. Use true, 'dual', 'only', 'manual', or an options object."
1616)
1717
1818const FST_ERR_SSE_LEGACY_MISUSE = createError (
@@ -208,17 +208,24 @@ function clientAcceptsSSE (acceptHeader, options) {
208208 * reply.sse is undefined)
209209 * - `sse: 'only'` → kind 'only' (SSE-only: lenient gate, returns 406
210210 * Not Acceptable to clients that explicitly refuse SSE)
211- * - `sse: { ... }` → object form; same kinds via `kind: 'dual' | 'only'`,
212- * or `kind` omitted = 'legacy' for back-compat with
213- * existing `{ heartbeat, serializer, ... }` shapes
211+ * - `sse: 'manual'` → kind 'manual' (no Accept negotiation: `reply.sse` is
212+ * always attached and the handler decides at runtime
213+ * whether to stream — by calling `reply.sse.*` — or to
214+ * return a normal response. The connection is closed
215+ * automatically when the handler resolves or throws.)
216+ * - `sse: { ... }` → object form; same kinds via
217+ * `kind: 'dual' | 'only' | 'manual'`, or `kind` omitted
218+ * = 'legacy' for back-compat with existing
219+ * `{ heartbeat, serializer, ... }` shapes
214220 */
215221function resolveSSEConfig ( sseField ) {
216222 if ( sseField === true ) return { kind : 'legacy' , options : { } }
217223 if ( sseField === 'dual' ) return { kind : 'dual' , options : { } }
218224 if ( sseField === 'only' ) return { kind : 'only' , options : { } }
225+ if ( sseField === 'manual' ) return { kind : 'manual' , options : { } }
219226 if ( typeof sseField === 'object' && sseField !== null ) {
220227 const kind = sseField . kind ?? 'legacy'
221- if ( kind !== 'legacy' && kind !== 'dual' && kind !== 'only' ) {
228+ if ( kind !== 'legacy' && kind !== 'dual' && kind !== 'only' && kind !== 'manual' ) {
222229 throw new FST_ERR_SSE_UNKNOWN_KIND ( kind )
223230 }
224231 return { kind, options : sseField }
@@ -317,6 +324,7 @@ class SSEContext {
317324 this . #keepAlive = false
318325 this . #headersSent = false
319326 this . heartbeatTimer = null
327+ this . heartbeatInterval = options . heartbeatInterval
320328 this . closeCallbacks = [ ]
321329 this . serializer = options . serializer
322330
@@ -334,11 +342,6 @@ class SSEContext {
334342 // Log as info since client disconnections are normal
335343 this . reply . log . info ( { err : error } , 'SSE connection closed' )
336344 } )
337-
338- // Start heartbeat if enabled
339- if ( options . heartbeatInterval > 0 ) {
340- this . startHeartbeat ( options . heartbeatInterval )
341- }
342345 }
343346
344347 /**
@@ -435,6 +438,15 @@ class SSEContext {
435438
436439 this . reply . raw . writeHead ( 200 )
437440 this . #headersSent = true
441+
442+ // Start the heartbeat only once the response is committed as SSE.
443+ // Deferring it until here means a handler that decorates reply.sse
444+ // but ultimately returns a non-SSE response (e.g. a 'manual' route
445+ // that branches on the request body) is never interrupted by a
446+ // heartbeat write corrupting its payload.
447+ if ( this . heartbeatInterval > 0 ) {
448+ this . startHeartbeat ( this . heartbeatInterval )
449+ }
438450 }
439451 }
440452
@@ -658,6 +670,11 @@ async function fastifySSE (fastify, opts) {
658670 // (which is undefined because the gate refused), the
659671 // plugin rethrows with a message that names
660672 // `sse: 'only'` as the likely fix.
673+ // 'manual' — No Accept negotiation at all. `reply.sse` is always
674+ // attached and the handler decides at runtime whether to
675+ // stream. This supports clients (OpenAI-style streaming
676+ // APIs and other LLM endpoints) that signal streaming via
677+ // the request body rather than the Accept header.
661678 if ( kind === 'only' ) {
662679 if ( ! clientAcceptsSSE ( acceptHeader ) ) {
663680 return reply . code ( 406 ) . type ( 'application/json' ) . send ( {
@@ -667,8 +684,7 @@ async function fastifySSE (fastify, opts) {
667684 } )
668685 }
669686 // Fall through to SSE setup.
670- } else {
671- // 'dual' or 'legacy'
687+ } else if ( kind === 'dual' || kind === 'legacy' ) {
672688 if ( ! clientAcceptsSSE ( acceptHeader , { strict : true } ) ) {
673689 if ( kind === 'legacy' ) {
674690 try {
@@ -685,6 +701,7 @@ async function fastifySSE (fastify, opts) {
685701 }
686702 // Fall through to SSE setup.
687703 }
704+ // 'manual' — skip negotiation entirely and fall through to SSE setup.
688705
689706 // Set up SSE context. Headers are written lazily on the first send.
690707 const context = new SSEContext ( {
0 commit comments