Skip to content

Commit e145a5c

Browse files
CatchMe2Tony133
andauthored
feat: add 'manual' SSE mode for runtime stream/non-stream decisions (#42)
Co-authored-by: Antonio Tripodi <Tony133@users.noreply.github.com>
1 parent 3ac9998 commit e145a5c

6 files changed

Lines changed: 379 additions & 16 deletions

File tree

README.md

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ fastify.get('/events', { sse: 'only' }, handler)
7878
// handler is expected to produce a non-SSE response in that branch.
7979
fastify.get('/data', { sse: 'dual' }, handler)
8080

81+
// Manual route: no Accept negotiation. `reply.sse` is always attached and
82+
// the handler decides at runtime whether to stream (by calling reply.sse.*)
83+
// or to return a normal response. Useful for streaming APIs that signal
84+
// streaming via the request body (e.g. OpenAI-style `{ stream: true }`)
85+
// rather than the Accept header.
86+
fastify.post('/v1/chat', { sse: 'manual' }, handler)
87+
8188
// Back-compat: `sse: true` behaves like `'dual'` for routing. If the
8289
// handler is actually SSE-only and trips a TypeError on `reply.sse`
8390
// because of a wildcard Accept, the plugin rethrows with a message
@@ -87,7 +94,7 @@ fastify.get('/legacy', { sse: true }, handler)
8794
// Object form (supports per-route options):
8895
fastify.get('/events', {
8996
sse: {
90-
kind: 'only', // 'only' | 'dual' — omit for back-compat
97+
kind: 'only', // 'only' | 'dual' | 'manual' — omit for back-compat
9198
heartbeat: false, // Disable heartbeat for this route
9299
serializer: customSerializer // Custom serializer for this route
93100
}
@@ -275,6 +282,7 @@ route's declared kind:
275282
| `sse: 'dual'` | `text/event-stream` | SSE branch |
276283
| `sse: 'dual'` | `*/*`, `text/*`, missing | Non-SSE branch |
277284
| `sse: 'dual'` | `application/json` | Non-SSE branch |
285+
| `sse: 'manual'`| (any / missing) | Handler decides at runtime |
278286
| `sse: true` | (same routing as `'dual'`) | + explanatory error on misuse |
279287

280288
In short: explicit `text/event-stream` always routes to SSE; ambiguous
@@ -311,6 +319,41 @@ etc.) are not committed until the first `reply.sse.send()` /
311319
then returns a plain value falls through to Fastify's normal
312320
serialization path without corrupting the response.
313321

322+
### Dynamic Streaming Based on Request Logic (`sse: 'manual'`)
323+
324+
Some APIs decide whether to stream based on the request **body** rather
325+
than the `Accept` header. OpenAI-compatible and other LLM endpoints, for
326+
example, return a `text/event-stream` response when the request carries
327+
`{ "stream": true }` and a single JSON object otherwise — the client
328+
sends no `Accept: text/event-stream` header either way.
329+
330+
`sse: 'manual'` covers this: it skips Accept-header negotiation entirely,
331+
always attaches `reply.sse`, and lets the handler decide at runtime
332+
whether to stream. As with `'dual'`, SSE headers are committed lazily on
333+
the first `reply.sse.send()` / `reply.sse.stream()` call, so a handler
334+
that never streams falls through to Fastify's normal serialization.
335+
336+
```js
337+
fastify.post('/v1/chat/completions', { sse: 'manual' }, async (request, reply) => {
338+
const completion = await model.run(request.body)
339+
340+
if (request.body.stream) {
341+
// Stream tokens as SSE — headers commit on the first send().
342+
await reply.sse.send(completion.tokens) // async iterable / Readable / messages
343+
return
344+
}
345+
346+
// Non-streaming client — return a normal JSON response.
347+
return completion.toJSON()
348+
})
349+
```
350+
351+
The connection is closed automatically when the handler resolves or
352+
throws (call `reply.sse.keepAlive()` to keep it open for out-of-band
353+
sends, then `reply.sse.close()` when done). Because the heartbeat only
354+
starts once the response is committed as SSE, a non-streaming fallback
355+
response is never corrupted by a heartbeat write.
356+
314357

315358
### Error Handling
316359

@@ -436,6 +479,7 @@ app.get('/events', { sse: true }, async (request, reply) => {
436479
See the [examples](examples/) directory for complete working examples:
437480

438481
- [Basic Usage](examples/basic.js) - Simple SSE endpoints
482+
- [Dynamic Streaming](examples/manual-streaming.js) - `sse: 'manual'`, OpenAI-style stream-or-JSON based on the request body
439483
- More examples coming soon...
440484

441485
## Comparison with fastify-sse-v2

examples/manual-streaming.js

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
'use strict'
2+
3+
/**
4+
* Dynamic streaming with @fastify/sse (`sse: 'manual'`)
5+
*
6+
* Some APIs — OpenAI-compatible and other LLM endpoints, for example —
7+
* decide whether to stream based on the request *body* (`{ "stream": true }`)
8+
* rather than the `Accept` header. `sse: 'manual'` skips Accept negotiation
9+
* entirely: `reply.sse` is always attached and the handler decides at runtime
10+
* whether to stream or to return a normal JSON response. The connection is
11+
* closed automatically when the handler resolves or throws.
12+
*
13+
* Test endpoints with curl:
14+
*
15+
* # Streaming response (Server-Sent Events):
16+
* curl -N -X POST http://localhost:3000/v1/chat/completions \
17+
* -H "Content-Type: application/json" \
18+
* -d '{"prompt":"hello","stream":true}'
19+
*
20+
* # Non-streaming response (single JSON object):
21+
* curl -X POST http://localhost:3000/v1/chat/completions \
22+
* -H "Content-Type: application/json" \
23+
* -d '{"prompt":"hello","stream":false}'
24+
*
25+
**/
26+
27+
async function buildServer () {
28+
const fastify = require('fastify')({ logger: true })
29+
30+
await fastify.register(require('../index.js'))
31+
32+
// A tiny stand-in for a model that emits tokens one at a time.
33+
async function * generateTokens (prompt) {
34+
const tokens = `Echo: ${prompt}`.split(' ')
35+
for (let i = 0; i < tokens.length; i++) {
36+
await new Promise(resolve => setTimeout(resolve, 200))
37+
yield {
38+
id: String(i),
39+
event: 'token',
40+
data: { index: i, token: tokens[i] }
41+
}
42+
}
43+
}
44+
45+
fastify.post('/v1/chat/completions', { sse: 'manual' }, async (request, reply) => {
46+
const { prompt = '', stream = false } = request.body ?? {}
47+
48+
if (stream) {
49+
// Stream tokens as SSE. The SSE response headers are committed on the
50+
// first send(); the connection closes when this handler resolves.
51+
await reply.sse.send(generateTokens(prompt))
52+
await reply.sse.send({ event: 'done', data: '[DONE]' })
53+
return
54+
}
55+
56+
// Non-streaming client — fall through to Fastify's normal JSON
57+
// serialization. `reply.sse` was attached but never written to, so no
58+
// SSE headers were sent.
59+
return {
60+
id: 'chatcmpl-1',
61+
object: 'chat.completion',
62+
choices: [{ message: { role: 'assistant', content: `Echo: ${prompt}` } }]
63+
}
64+
})
65+
66+
return fastify
67+
}
68+
69+
const start = async () => {
70+
const server = await buildServer()
71+
try {
72+
await server.listen({ port: 3000, host: '0.0.0.0' })
73+
console.log('Server listening on http://localhost:3000')
74+
console.log('Try POST /v1/chat/completions with {"stream": true} or {"stream": false}')
75+
} catch (err) {
76+
server.log.error(err)
77+
process.exit(1)
78+
}
79+
}
80+
81+
start()

index.js

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ const { pipeline } = require('stream/promises')
77

88
const 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

1313
const 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

1818
const 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
*/
215221
function 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

Comments
 (0)