Nest SSE 接口如何设置动态响应头

设置响应头的常见写法

设置响应头是常见需求,Nest 工程下,通常会在拦截器、 Controller 和 @Header 装饰器里设置。

  • 拦截器
typescript 复制代码
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { genRequestId } from '@utils/request';
import { Observable } from 'rxjs';

@Injectable()
export class RequestIdInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const response = context.switchToHttp().getResponse();
    response.setHeader(/** */)

    return next.handle();
  }
}
  • Controller 和 @Header
typescript 复制代码
import { Controller, Header, Post, Res } from '@nestjs/common';

@Controller('xxx')
export class MyController {
  constructor() {}

  @Post('xxx')
  @Header('Access-Control-Allow-Origin', '*')
  async fun(@Res({ passthrough: true }) res) {
    res.setHeader('xxx', '*');
    return 'xxx';
  }
}

这些都很常见,其中 @Header 适合用来设置静态响应头,拦截器和 Controller 适合设置动态的

什么叫动态设置?比如要根据请求头来设置响应头就属于这种情况。

SSE 的特殊性

但是 SSE 接口用那两种方式设置动态响应头都会报错。

原因:Nest 会将 SSE 接口的响应头立即发出,然后再处理流式数据,当响应头已经被发送后是不允许再设置的。

从源码里可以很清晰的看到

稍微解释一下,先调用 SseStream.pipe ,这里就会发送响应头,然后才处理流式数据(subscribe Observable),所以 Controller 里设置响应头就会报错。

为什么 Nest 要如此设计?我猜测:

  1. 立即建立连接:客户端需要尽快知道这是 SSE 连接
  2. 防止超时:避免客户端因等待响应头而超时
  3. 实时性保证:确保后续数据能够实时传输

为什么拦截器里不行?

根据上面逻辑知道 Controller 里设置响应头不行,那为什么拦截器里也不行?

放一张各种 Nest AOP 机制执行顺序的图,可以看到拦截器是在 Controller handler 之前执行的

其实拦截器和 Controller handler 是在同一个 Observable 里的,换句话说,对于 SseStream 来说,他俩是一回事。

这里的 result 是什么?看下 intercept 逻辑

拦截器调用入口函数(intercept)返回了一个用 defer 创建的 Observable,也就是上面的 result。

这个 Observable 里会调用拦截器和 Controller handler,实际上,Controller handler 本身就是在拦截器内部调用的。

真相大白了🎉,Observale 本身就是惰性的,defer 更是会让传入的函数也惰性执行,所以这里的逻辑不会立即执行,而是在 SseStream.pipe 之后调用 result.suscribe 的时候才执行,所以拦截器内设置响应头也是不行的。

再梳理一下顺序:

  1. 进入拦截器入口,使用 defer 创建 Observable,逻辑体先不执行。
  2. 创建, SSE 流,调用 pipe 和 response 连接 (writeHead + flushHeaders) ,此时响应头已经发送
  3. 拦截器和 Controller hanlder 逻辑开始执行,返回一个个 chunk

为了方便观察,我把上面的源码图也贴过来

解决方案

Guard

拦截器不行,那它前面还有 Guard 和 Middleware,这两个不就行了,先看 Guard。

typescript 复制代码
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';

@Injectable()
export class SseHeadersGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const response = context.switchToHttp().getResponse();
    const requestId = 'xxxx'
  
    // 设置动态响应头
    response.setHeader('X-Request-ID', requestId);
    
    return true;
  }
}

@Controller()
export class SseController {
  @Sse('events')
  @UseGuards(SseHeadersGuard)
  sendEvents(): Observable<MessageEvent> {
    return interval(1000).pipe(
      map(count => ({ data: { message: `Event ${count}` } }))
    );
  }
}

也可以让 Guard 全局生效,具体看业务逻辑要求。

或者稍微扩展一下写得更复杂一些

typescript 复制代码
import { SetMetadata, applyDecorators } from '@nestjs/common';
import { Sse, UseGuards } from '@nestjs/common';

export const DYNAMIC_SSE_HEADERS = 'dynamic_sse_headers';

export interface DynamicSseHeader {
  name: string;
  valueExtractor: (req: any) => string;
}

export function SseWithDynamicHeaders(
  path?: string,
  ...headers: DynamicSseHeader[]
): MethodDecorator {
  return applyDecorators(
    Sse(path),
    SetMetadata(DYNAMIC_SSE_HEADERS, headers),
    UseGuards(DynamicSseHeadersGuard)
  );
}

@Injectable()
export class DynamicSseHeadersGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const headers = this.reflector.get<DynamicSseHeader[]>(
      DYNAMIC_SSE_HEADERS,
      context.getHandler(),
    );

    if (headers) {
      const request = context.switchToHttp().getRequest();
      const response = context.switchToHttp().getResponse();

      headers.forEach(({ name, valueExtractor }) => {
        const value = valueExtractor(request);
        response.setHeader(name, value);
      });
    }

    return true;
  }
}

// 使用示例
@Controller()
export class SseController {
  @SseWithDynamicHeaders(
    'events',
    {
      name: 'X-Request-ID',
      // 可以拿到 req
      valueExtractor: (req) => req.headers['x-request-id'] || 'default-id'
    },
    {
      name: 'X-User-Agent',
      valueExtractor: (req) => req.headers['user-agent'] || 'unknown'
    }
  )
  sendEvents(): Observable<MessageEvent> {
    return interval(1000).pipe(
      map(count => ({ data: { message: `Event ${count}` } }))
    );
  }
}

middleware

typescript 复制代码
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

@Injectable()
export class SseHeadersMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    const requestId = req.headers['x-request-id'];
    
    res.setHeader('X-Request-ID', requestId);
    res.setHeader('X-Timestamp', new Date().toISOString());
    
    next();
  }
}

// 在模块中注册
@Module({
  // ...
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(SseHeadersMiddleware)
      .forRoutes({ path: '*', method: RequestMethod.ALL });
  }
}
相关推荐
码界奇点1 分钟前
基于Go语言的Web管理面板系统设计与实现
开发语言·后端·golang·毕业设计·web·go语言·源代码管理
WizLC1 分钟前
【后端】关于Elasticsearch的入门,下载安装+使用
java·大数据·后端·elasticsearch·搜索引擎·全文检索
小此方2 分钟前
Re: ゼロから学ぶ C++ 入門(六)类和对象·第三篇:运算符重载
开发语言·c++·后端
喵了几个咪4 分钟前
开箱即用的 GoWind Admin|风行,企业级前后端一体中后台框架:用 JavaScript/Lua 解锁动态业务扩展能力
javascript·后端·微服务·golang·lua·admin
浮尘笔记7 分钟前
Go语言条件变量sync.Cond:线程间的协调者
开发语言·后端·golang
自由生长20248 分钟前
请求洪峰来了,怎么使用消息队列削峰? 我们来深入的聊一下
后端·架构
Victor3561 小时前
Netty(28)Netty的内存管理和垃圾回收机制是如何工作的?
后端
掘金码甲哥9 小时前
🚀糟糕,我实现的k8s informer好像是依托答辩
后端
GoGeekBaird9 小时前
Andrej Karpathy:2025年大模型发展总结
后端·github