@fastify/sse

中文文档
0.6.1发布于 2026/10/09MITTypeScriptCommonJS构建来源已验证近 30 天下载 162.2万

Server-Sent Events plugin for Fastify

原始使用文档
文档目录24 节
  1. Features
  2. Install
  3. Quick Start
  4. API
  5. Plugin Registration
  6. Route Configuration
  7. reply.sse.send(source)
  8. reply.sse.stream()
  9. Connection Management
  10. Message Replay
  11. Properties and Methods
  12. Custom Response Status Code
  13. Advanced Usage
  14. Accept-Header Negotiation
  15. Fallback to Regular Responses ( sse: 'dual' )
  16. Dynamic Streaming Based on Request Logic ( sse: 'manual' )
  17. Error Handling
  18. Custom Serialization
  19. Testing
  20. Client-Side Usage
  21. TypeScript
  22. Examples
  23. Comparison with fastify-sse-v2
  24. License

@fastify/sse

NPM Version CI neostandard javascript style

Server-Sent Events plugin for Fastify. Provides first-class SSE support with clean API integration, session management, and streaming capabilities.

Features

  • 🚀 Clean API: Route-level SSE support with { sse: true }
  • 📡 Streaming: Native support for Node.js streams and async iterators
  • 🔄 Reconnection: Built-in message replay with Last-Event-ID
  • 💓 Heartbeat: Configurable keep-alive mechanism
  • 🎯 Lifecycle: Full integration with Fastify hooks and error handling
  • 📝 TypeScript: Complete type definitions included
  • ⚡ Performance: Efficient backpressure handling

Install

npm i @fastify/sse

Quick Start

const fastify = require('fastify')({ logger: true })

// Register the plugin
await fastify.register(require('@fastify/sse'))

// Create an SSE endpoint
fastify.get('/events', { sse: true }, async (request, reply) => {
  // Send a message
  await reply.sse.send({ data: 'Hello SSE!' })

  // Send with full options
  await reply.sse.send({
    id: '123',
    event: 'update',
    data: { message: 'Hello World' },
    retry: 1000
  })
})

await fastify.listen({ port: 3000 })

API

Plugin Registration

await fastify.register(require('@fastify/sse'), {
  // Optional: heartbeat interval in milliseconds (default: 30000)
  heartbeatInterval: 30000,

  // Optional: default serializer (default: JSON.stringify)
  serializer: (data) => JSON.stringify(data)
})

Route Configuration

Routes opt into SSE handling by setting the sse field. There are three forms, each describing what the route serves:

// SSE-only route: handler always streams. Clients that explicitly refuse
// SSE (e.g. `Accept: application/json`) receive 406 Not Acceptable.
// Ambiguous Accept headers (*/*, text/*, missing) admit SSE per RFC 9110.
fastify.get('/events', { sse: 'only' }, handler)

// Dual-mode route: the same handler serves both SSE and non-SSE clients.
// Only an explicit `text/event-stream` token routes to SSE; everything
// else falls through to the handler with `reply.sse` undefined, so the
// handler is expected to produce a non-SSE response in that branch.
fastify.get('/data', { sse: 'dual' }, handler)

// Manual route: no Accept negotiation. `reply.sse` is always attached and
// the handler decides at runtime whether to stream (by calling reply.sse.*)
// or to return a normal response. Useful for streaming APIs that signal
// streaming via the request body (e.g. OpenAI-style `{ stream: true }`)
// rather than the Accept header.
fastify.post('/v1/chat', { sse: 'manual' }, handler)

// Back-compat: `sse: true` behaves like `'dual'` for routing. If the
// handler is actually SSE-only and trips a TypeError on `reply.sse`
// because of a wildcard Accept, the plugin rethrows with a message
// that names `sse: 'only'` as the fix.
fastify.get('/legacy', { sse: true }, handler)

// Object form (supports per-route options):
fastify.get('/events', {
  sse: {
    kind: 'only',                // 'only' | 'dual' | 'manual' — omit for back-compat
    heartbeat: false,            // Disable heartbeat for this route
    serializer: customSerializer // Custom serializer for this route
  }
}, handler)

reply.sse.send(source)

Send SSE messages. Accepts various source types:

Single Message

// Simple data
await reply.sse.send({ data: 'hello' })

// Full SSE message
await reply.sse.send({
  id: '123',
  event: 'update',
  data: { message: 'Hello' },
  retry: 1000
})

// Plain string
await reply.sse.send('plain text message')

Streaming Sources

// Async generator
async function* generateEvents() {
  for (let i = 0; i < 10; i++) {
    yield { id: i, data: `Event ${i}` }
    await sleep(1000)
  }
}
await reply.sse.send(generateEvents())

// Node.js Readable stream
const stream = fs.createReadStream('data.jsonl')
await reply.sse.send(stream)

// Transform existing stream
const transformStream = new Transform({
  transform(chunk, encoding, callback) {
    callback(null, { data: chunk.toString() })
  }
})
someSource.pipe(transformStream)
await reply.sse.send(transformStream)

reply.sse.stream()

Create a transform stream for use in pipeline operations:

// Use with pipeline for efficient streaming
const { pipeline } = require('stream/promises')
const fs = require('fs')

fastify.get('/file-stream', { sse: true }, async (request, reply) => {
  const fileStream = fs.createReadStream('data.jsonl')

  // Parse each line as JSON and convert to SSE format
  const parseTransform = new Transform({
    transform(chunk, encoding, callback) {
      const lines = chunk.toString().split('\n').filter(Boolean)
      for (const line of lines) {
        try {
          const data = JSON.parse(line)
          this.push({ id: data.id, data })
        } catch (err) {
          // Skip invalid JSON lines
        }
      }
      callback()
    }
  })

  // Stream file data through SSE
  await pipeline(
    fileStream,
    parseTransform,
    reply.sse.stream(),
    reply.raw,
    { end: false }
  )
})

Connection Management

fastify.get('/live', { sse: true }, async (request, reply) => {
  // Keep connection alive (prevents automatic close)
  reply.sse.keepAlive()

  // Send initial message
  await reply.sse.send({ data: 'Connected' })

  // Check if keepAlive was called
  console.log('Keep alive status:', reply.sse.shouldKeepAlive) // true

  // Set up periodic updates
  const interval = setInterval(async () => {
    if (reply.sse.isConnected) {
      await reply.sse.send({ data: 'ping' })
    } else {
      clearInterval(interval)
    }
  }, 1000)

  // Clean up when connection closes
  reply.sse.onClose(() => {
    clearInterval(interval)
    console.log('Connection closed')
  })
})

Message Replay

Handle client reconnections with Last-Event-ID:

const messageHistory = []

fastify.get('/events', { sse: true }, async (request, reply) => {
  // Handle replay on reconnection
  await reply.sse.replay(async (lastEventId) => {
    // Find messages after the last received ID
    const startIndex = messageHistory.findIndex(msg => msg.id === lastEventId)
    const messagesToReplay = startIndex !== -1
      ? messageHistory.slice(startIndex + 1)
      : messageHistory

    // Send missed messages
    for (const message of messagesToReplay) {
      await reply.sse.send(message)
    }
  })

  // Send new message
  const newMessage = { id: Date.now(), data: 'New event' }
  messageHistory.push(newMessage)
  await reply.sse.send(newMessage)
})

Properties and Methods

Properties

  • reply.sse.lastEventId: Client's last received event ID (string | null)
  • reply.sse.isConnected: Connection status (boolean)
  • reply.sse.shouldKeepAlive: Whether connection should be kept alive after handler completion (boolean)

Methods

  • reply.sse.send(source): Send SSE messages from various source types
  • reply.sse.stream(): Create a transform stream for pipeline operations
  • reply.sse.keepAlive(): Prevent connection from auto-closing after handler returns
  • reply.sse.close(): Manually close the connection
  • reply.sse.replay(callback): Handle message replay using Last-Event-ID
  • reply.sse.onClose(callback): Register callback for when connection closes
  • reply.sse.sendHeaders(statusCode?): Manually send headers (called automatically on first write)

Custom Response Status Code

The SSE response defaults to 200 OK. To use a different status, either call reply.code() before the first SSE write, or pass the status directly to a manual reply.sse.sendHeaders() call:

fastify.get('/events', { sse: 'only' }, async (request, reply) => {
  reply.code(201)
  await reply.sse.send({ data: 'created' })
})

fastify.get('/events-explicit', { sse: 'only' }, async (request, reply) => {
  reply.sse.sendHeaders(201)
  await reply.sse.send({ data: 'created' })
})

An explicit sendHeaders() argument takes precedence over reply.code(). Once the first SSE write commits the response headers, later reply.code() or sendHeaders() calls have no effect.

Advanced Usage

Accept-Header Negotiation

The plugin parses the Accept header per RFC 9110 §12.5.1 to decide whether to serve a request as SSE. The decision depends on the route's declared kind:

Route kindAccept headerBehavior
sse: 'only'text/event-streamSSE
sse: 'only'*/*, text/*, missingSSE
sse: 'only'application/json etc.406 Not Acceptable
sse: 'only'…, text/event-stream;q=0406 Not Acceptable
sse: 'dual'text/event-streamSSE branch
sse: 'dual'*/*, text/*, missingNon-SSE branch
sse: 'dual'application/jsonNon-SSE branch
sse: 'manual'(any / missing)Handler decides at runtime
sse: true(same routing as 'dual')+ explanatory error on misuse

In short: explicit text/event-stream always routes to SSE; ambiguous wildcards route to SSE only when you've declared the route SSE-only. This keeps curl, fetch(), and Postman defaults working with SSE-only endpoints while preserving the dual-mode pattern's expectation that a wildcard prefers the richer non-SSE representation.

Fallback to Regular Responses (sse: 'dual')

Dual-mode routes serve both SSE and non-SSE clients from a single handler. The plugin attaches reply.sse only when the client explicitly requested text/event-stream; otherwise the handler is invoked without reply.sse and is expected to produce a normal response. Branch on its presence:

fastify.get('/data', { sse: 'dual' }, async (request, reply) => {
  const data = await getData()

  if (reply.sse) {
    // SSE client — stream the data
    await reply.sse.send({ data })
  } else {
    // Regular client — return JSON
    return { data }
  }
})

The SSE-specific response headers (Content-Type: text/event-stream etc.) are not committed until the first reply.sse.send() / reply.sse.stream() call, so a handler that decorates reply.sse but then returns a plain value falls through to Fastify's normal serialization path without corrupting the response.

Dynamic Streaming Based on Request Logic (sse: 'manual')

Some APIs decide whether to stream based on the request body rather than the Accept header. OpenAI-compatible and other LLM endpoints, for example, return a text/event-stream response when the request carries { "stream": true } and a single JSON object otherwise — the client sends no Accept: text/event-stream header either way.

sse: 'manual' covers this: it skips Accept-header negotiation entirely, always attaches reply.sse, and lets the handler decide at runtime whether to stream. As with 'dual', SSE headers are committed lazily on the first reply.sse.send() / reply.sse.stream() call, so a handler that never streams falls through to Fastify's normal serialization.

fastify.post('/v1/chat/completions', { sse: 'manual' }, async (request, reply) => {
  const completion = await model.run(request.body)

  if (request.body.stream) {
    // Stream tokens as SSE — headers commit on the first send().
    await reply.sse.send(completion.tokens) // async iterable / Readable / messages
    return
  }

  // Non-streaming client — return a normal JSON response.
  return completion.toJSON()
})

The connection is closed automatically when the handler resolves or throws (call reply.sse.keepAlive() to keep it open for out-of-band sends, then reply.sse.close() when done). Because the heartbeat only starts once the response is committed as SSE, a non-streaming fallback response is never corrupted by a heartbeat write.

Error Handling

fastify.get('/stream', { sse: true }, async (request, reply) => {
  try {
    async function* riskyGenerator() {
      yield { data: 'before error' }
      throw new Error('Something went wrong')
    }

    await reply.sse.send(riskyGenerator())
  } catch (error) {
    // Handle errors gracefully
    await reply.sse.send({
      event: 'error',
      data: { message: 'Stream error occurred' }
    })
  }
})

Custom Serialization

// Plugin-level serializer
await fastify.register(require('@fastify/sse'), {
  serializer: (data) => {
    // Custom serialization logic
    return typeof data === 'string' ? data : JSON.stringify(data)
  }
})

// Route-level serializer
fastify.get('/custom', {
  sse: {
    serializer: (data) => `CUSTOM:${JSON.stringify(data)}`
  }
}, async (request, reply) => {
  await reply.sse.send({ data: 'test' }) // Outputs: "CUSTOM:\"test\""
})

Testing

Testing SSE endpoints is simplified with standard Fastify injection:

const response = await fastify.inject({
  method: 'GET',
  url: '/events',
  headers: {
    accept: 'text/event-stream'
  }
})

assert.strictEqual(response.statusCode, 200)
assert.strictEqual(response.headers['content-type'], 'text/event-stream')
assert.ok(response.body.includes('data: "Hello SSE!"'))

Client-Side Usage

<!DOCTYPE html>
<html>
<head>
  <title>SSE Client</title>
</head>
<body>
  <div id="messages"></div>

  <script>
    const eventSource = new EventSource('/events')
    const messagesDiv = document.getElementById('messages')

    eventSource.onmessage = function(event) {
      const data = JSON.parse(event.data)
      messagesDiv.innerHTML += '<div>' + JSON.stringify(data) + '</div>'
    }

    eventSource.addEventListener('update', function(event) {
      console.log('Update event:', JSON.parse(event.data))
    })

    eventSource.onerror = function(event) {
      console.error('SSE error:', event)
    }
  </script>
</body>
</html>

TypeScript

Full TypeScript support included:

import fastify from 'fastify'
import fastifySSE, { SSEMessage } from '@fastify/sse'

const app = fastify()
await app.register(fastifySSE)

app.get('/events', { sse: true }, async (request, reply) => {
  const message: SSEMessage = {
    id: '123',
    event: 'test',
    data: { hello: 'world' }
  }

  await reply.sse.send(message)

  // TypeScript knows about SSE properties
  console.log(reply.sse.isConnected) // boolean
  console.log(reply.sse.lastEventId) // string | null
  console.log(reply.sse.shouldKeepAlive) // boolean
})

Examples

See the examples directory for complete working examples:

  • Basic Usage - Simple SSE endpoints
  • Dynamic Streaming - sse: 'manual', OpenAI-style stream-or-JSON based on the request body
  • More examples coming soon...

Comparison with fastify-sse-v2

Featurefastify-sse-v2@fastify/sse
Basic SSE✅✅
Async Iterators✅✅
Stream Support✅✅ Enhanced
Session Management❌✅
Last-Event-ID❌✅
Connection Health❌✅
Fastify Integration⚠️ Limited✅ Full
Testing Support❌✅

License

MIT

版本与发布记录

@fastify/sse 共发布 7 个版本,首次发布于 2025/08/26;过去 12 个月发布了 3 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.6.1latest2026/10/091 个月前
0.6.02026/07/283 个月前
0.5.02026/07/014 个月前
0.4.02025/09/161.2 年前
0.3.02025/09/111.2 年前
0.2.02025/08/301.2 年前

关于 @fastify/sse 的常见问题

@fastify/sse 是什么?

Server-Sent Events plugin for Fastify

如何安装 @fastify/sse?

在项目目录运行 npm install @fastify/sse,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:fastify(^5.x)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@fastify/sse 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@fastify/sse 支持 ESM 还是 CommonJS?

以 CommonJS 形式发布,可以直接 require();在 ES Module 项目中也可以用 import 引入(Node.js 会把 module.exports 作为默认导出)。

@fastify/sse 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=20.20.2。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@fastify/sse 可以商用吗?

@fastify/sse 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@fastify/sse 的下载量有多少?

根据 npm 官方下载统计,@fastify/sse 在 2026/10/15 – 2026/11/13 这 30 天内被下载 1,622,389 次,日均约 54,080 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@fastify/sse 还在维护吗?

最新版本 0.6.1 发布于 2026/10/09(1 个月前);至今共发布 7 个版本,其中过去 12 个月发布了 3 个;首次发布于 2025/08/26。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。