☰ Mục lục series
Tài liệu NestJS tiếng Việt
  1. Nhập môn (Overview)
    1. Giới thiệu
    2. First steps
    3. Controllers
    4. Providers
    5. Modules
    6. Middleware
    7. Exception filters
    8. Pipes
    9. Guards
    10. Interceptors
    11. Custom decorators
  2. Nền tảng (Fundamentals)
    1. Custom providers
    2. Asynchronous providers
    3. Dynamic modules
    4. Injection scopes
    5. Circular dependency
    6. Module reference
    7. Lazy-loading modules
    8. Execution context
    9. Lifecycle events
    10. Discovery service
    11. Platform agnosticism
    12. Testing
  3. Kỹ thuật (Techniques)
    1. Configuration
    2. Database (SQL)
    3. MongoDB
    4. Validation
    5. Caching
    6. Serialization
    7. Versioning
    8. Task scheduling
    9. Queues
    10. Logging
    11. Cookies
    12. Events
    13. Compression
    14. File upload
    15. Streaming files
    16. HTTP module
    17. Session
    18. MVC
    19. Performance (Fastify)
    20. Server-Sent Events
  4. Bảo mật (Security)
    1. Authentication
    2. Authorization
    3. Encryption & Hashing
    4. Helmet
    5. CORS
    6. CSRF
    7. Rate limiting
    8. Vòng đời request
  5. OpenAPI (Swagger)
    1. Introduction
    2. Types and parameters
    3. Operations
    4. Security
    5. Mapped types
    6. Decorators
    7. CLI plugin
    8. Other features
  6. Recipes
    1. REPL
    2. CRUD generator
    3. SWC (fast compiler)
    4. Passport (auth)
    5. Hot reload
    6. MikroORM
    7. Router module
    8. Health checks (Terminus)
    9. CQRS
    10. Prisma
    11. Serve static
    12. Commander
    13. Async local storage
  7. WebSockets
    1. Gateways
    2. Exception filters
    3. Pipes
    4. Guards
    5. Interceptors
    6. Adapters
  8. Microservices
    1. Overview
    2. Redis
    3. MQTT
    4. NATS
    5. RabbitMQ
    6. Kafka
    7. gRPC
    8. Custom transporters
    9. Exception filters
    10. Pipes
    11. Pre-request hooks
    12. Guards
    13. Interceptors
  9. GraphQL
    1. Quick start
    2. Resolvers
    3. Mutations
    4. Subscriptions
    5. Scalars
    6. Directives
    7. Interfaces
    8. Unions and enums
    9. Field middleware
    10. Mapped types
    11. Plugins
    12. Complexity
    13. Extensions
    14. CLI plugin
    15. Generating SDL
    16. Sharing models
    17. Other features
    18. Federation
  10. CLI & triển khai
    1. CLI overview
    2. Workspaces
    3. Libraries
    4. Usage
    5. Scripts
    6. Deployment
    7. Standalone apps
  11. FAQ
    1. Serverless
    2. HTTP adapter
    3. Keep-Alive connections
    4. Global path prefix
    5. Raw body
    6. Hybrid application
    7. HTTPS & multiple servers
    8. Common errors
  12. Phụ lục
    1. Bảng thuật ngữ
Kỹ thuật (Techniques)

Server-Sent Events

Bản dịch từ docs.nestjs.com/techniques/server-sent-events

Server-Sent Events (SSE) là một công nghệ server push cho phép client nhận update tự động từ server qua kết nối HTTP. Mỗi notification được gửi dưới dạng một block text kết thúc bởi hai dấu newline liên tiếp (tìm hiểu thêm tại đây).

Ghi chú người dịch Nếu bạn quen với WebSocket, SSE đơn giản hơn nhiều: nó chỉ là một kết nối HTTP thông thường được giữ mở, server liên tục ghi thêm dữ liệu vào response, còn client chỉ đọc (một chiều server → client). Không cần bắt tay (handshake) đặc biệt hay protocol riêng như WebSocket, nên nó hoạt động qua proxy/load balancer HTTP thông thường mà không cần cấu hình gì thêm — đánh đổi là bạn không gửi được dữ liệu ngược lại từ client qua cùng kết nối này.

Cách dùng (Usage)

Để bật Server-Sent Events cho một route (route được đăng ký trong một controller class), gắn decorator @Sse() lên method handler.

@Sse('sse')
sse(): Observable<MessageEvent> {
  return interval(1000).pipe(map((_) => ({ data: { hello: 'world' } })));
}

Gợi ý Decorator @Sse() và interface MessageEvent được import từ @nestjs/common, còn Observable, interval, và map được import từ package rxjs.

Cảnh báo Route Server-Sent Events phải trả về một stream Observable.

Trong ví dụ trên, chúng ta định nghĩa một route tên sse, cho phép chúng ta phát (propagate) update real-time. Các event này có thể được lắng nghe bằng EventSource API.

Method sse trả về một Observable phát ra (emit) nhiều MessageEvent (trong ví dụ này, nó emit một MessageEvent mới mỗi giây). Object MessageEvent nên tuân theo interface sau để khớp với specification:

export interface MessageEvent {
  data: string | object;
  id?: string;
  type?: string;
  retry?: number;
}

Với những cài đặt trên, giờ ta có thể tạo một instance của class EventSource trong ứng dụng phía client, truyền route /sse (khớp với endpoint ta đã truyền vào decorator @Sse() bên trên) làm tham số cho constructor.

Instance EventSource mở một kết nối bền vững (persistent) đến HTTP server, server này gửi event ở dạng text/event-stream. Kết nối vẫn mở cho đến khi bị đóng bằng cách gọi EventSource.close().

Sau khi kết nối được mở, các message đến từ server sẽ được chuyển tới code của bạn dưới dạng event. Nếu message đến có field event, event được trigger sẽ chính là giá trị của field event đó. Nếu không có field event, một event message chung sẽ được fire (nguồn).

const eventSource = new EventSource('/sse');
eventSource.onmessage = ({ data }) => {
  console.log('New message', JSON.parse(data));
};

Client ngắt kết nối (Client disconnection)

Khi client đóng kết nối SSE (ví dụ, gọi eventSource.close()), NestJS tự động unsubscribe khỏi Observable được trả về, việc này sẽ dừng event stream và dọn dẹp (clean up) mọi resource liên quan — bao gồm cả interval timer trong ví dụ trên.

Để chạy logic dọn dẹp (teardown) tùy chỉnh khi client ngắt kết nối, dùng operator finalize:

@Sse('sse')
sse(): Observable<MessageEvent> {
  return interval(1000).pipe(
    map((_) => ({ data: { hello: 'world' } })),
    finalize(() => console.log('Client disconnected')),
  );
}

Gợi ý Operator finalize (import từ rxjs) thực thi callback của nó mỗi khi Observable kết thúc — do hoàn tất (completion), lỗi (error), hoặc unsubscription (bao gồm cả trường hợp client ngắt kết nối). Điều này khiến nó là nơi phù hợp để giải phóng resource bên ngoài như database cursor hay file handle gắn liền với stream.

Một handler @Sse() có thể là bất đồng bộ (asynchronous) — trả về Promise<Observable> thay vì trả Observable trực tiếp. Điều này thường gặp khi stream cần bước setup tốn kém trước khi event đầu tiên có thể được tạo ra: mở một database cursor, lấy (acquire) một model session, hoặc xác thực (authorize) với một service downstream.

@Sse('stream')
async stream(): Promise<Observable<MessageEvent>> {
  const session = await createSession();

  return new Observable(subscriber => {
    // ...tạo event từ `session`
  });
}

Ở đây có một lỗ hổng (gap). Nếu client ngắt kết nối trong khi promise vẫn đang resolve, Observable được trả về sẽ không bao giờ được subscribe — Nest sẽ không khởi động một producer mà consumer của nó đã biến mất. Đó là hành vi đúng đối với stream, nhưng nó có nghĩa là logic teardown của Observable sẽ không bao giờ chạy, và bất cứ thứ gì được allocate trong lúc setup (session ở trên) sẽ bị leak (rò rỉ).

Để lấp lỗ hổng này, inject AbortSignal của request bằng decorator @SseSignal():

import { MessageEvent, Sse, SseSignal } from '@nestjs/common';
import { EMPTY, Observable } from 'rxjs';

@Sse('stream')
async stream(@SseSignal() signal: AbortSignal): Promise<Observable<MessageEvent>> {
  const session = await createSession();

  if (signal.aborted) {
    // Client đã ngắt kết nối trong lúc setup. Observable bên dưới sẽ không bao giờ
    // được subscribe, nên giải phóng resource ngay ở đây.
    await session.close();
    return EMPTY;
  }

  return new Observable(subscriber => {
    const stream = session.start();
    stream.on('data', data => subscriber.next({ data }));

    return () => {
      stream.stop();
      session.close();
    };
  });
}

Ghi chú người dịch Đây là một race condition kinh điển mà dev hay bỏ sót khi vibe-code: setup bất đồng bộ (await createSession()) tạo ra một khoảng thời gian mà client có thể đã rời đi trước khi bạn kịp trả về Observable để Nest quản lý lifecycle. Tương tự như việc bạn mở một DB connection trong Go bằng goroutine rồi mới check ctx.Done() — nếu không check ngay sau khi tài nguyên tốn kém được tạo, bạn sẽ leak connection mà không có ai dọn dẹp nó cả.

Thời gian sống của signal (Signal lifetime)

Signal đại diện cho thời gian sống (lifetime) của response SSE, không chỉ riêng kết nối. Nó bị abort ngay khi stream kết thúc vì bất kỳ lý do gì:

  • client ngắt kết nối;
  • Observable hoàn tất (completed);
  • Observable bị lỗi (errored).

Điều này khiến signal trở thành một cleanup hook duy nhất cho toàn bộ request. Thay vì lặp lại logic teardown ở cả một abort listener và teardown function riêng của Observable, bạn có thể gắn resource vào signal một lần và để chúng được giải phóng ở mọi đường thoát (exit path):

@Sse('stream')
async stream(@SseSignal() signal: AbortSignal): Promise<Observable<MessageEvent>> {
  // Signal bị abort khi response kết thúc, nên fetch sẽ bị hủy bất kể
  // client ngắt kết nối hay stream đơn giản là đã hoàn tất.
  const upstream = await fetch(UPSTREAM_URL, { signal });

  return new Observable(subscriber => {
    // ...
  });
}

Vì signal cũng bị abort khi hoàn tất bình thường, signal.aborted chỉ có ý nghĩa như một cách kiểm tra “client đã rời đi chưa?” trong lúc setup — trước khi Observable được trả về. Tại thời điểm đó stream chưa thể hoàn tất được, nên một signal đã bị abort chỉ có thể mang một nghĩa duy nhất là client đã ngắt kết nối.

Cảnh báo Logic cleanup gắn với event abort có thể chạy song song với teardown function của Observable, vì vậy hãy đảm bảo nó là idempotent (chạy nhiều lần vẫn cho kết quả như chạy một lần).

Bên trong một producer, signal cũng là một cách tiện lợi để kết thúc stream khi client rời đi:

return new Observable<MessageEvent>(subscriber => {
  const timer = setInterval(() => subscriber.next({ data: 'tick' }), 1000);
  const onAbort = () => subscriber.complete();

  signal.addEventListener('abort', onAbort, { once: true });

  return () => {
    clearInterval(timer);
    signal.removeEventListener('abort', onAbort);
  };
});

Gợi ý @SseSignal() chỉ được điền giá trị (populated) trên route @Sse(); ở bất kỳ handler nào khác, nó sẽ resolve thành undefined. Nó hoạt động giống nhau trên cả hai platform Express và Fastify.

Ví dụ (Example)

Một ví dụ hoạt động thực tế có sẵn tại đây.

Nội dung gốc: NestJS Documentation © Kamil Myśliwiec, giấy phép MIT. Bản dịch và ghi chú: Lê Văn Dũng.