Skip to content

support SSE decoration #3728

@devBaunz

Description

@devBaunz

Is there an existing issue that is already proposing this?

  • I have searched the existing issues

Is your feature request related to a problem? Please describe it

Same as we have other decorators to help support transport media, we are missing one for SSE
There used to be a community plugin for this (@nestjsvn/swagger-sse) - but it's been deleted since

I would love for a good open discussion on if/how we can integrate this functionality

Currently I'm managing with by implementing this workaround:

// decorators/api-sse.decorator.ts
import { applyDecorators, Type } from "@nestjs/common";
import {
  ApiExtraModels,
  ApiOkResponse,
  ApiOperation,
  ApiProperty,
  getSchemaPath,
} from "@nestjs/swagger";

// Marker for events with no payload
export const NoPayload = Symbol("NoPayload");
export type NoPayload = typeof NoPayload;

export interface ApiSseEvent<T = unknown> {
  dto: Type<T> | NoPayload;
  description?: string;
}

export type ApiSseEventConfig = Type<unknown> | NoPayload | ApiSseEvent;

export interface ApiSseOptions {
  summary?: string;
  description?: string;
  events: Record<string, ApiSseEventConfig>;
}

function normalizeEvent(event: ApiSseEventConfig): ApiSseEvent {
  if (event === NoPayload) return { dto: NoPayload };
  if (typeof event === "function") return { dto: event };
  return event;
}

function toEventSchemaName(eventName: string): string {
  const normalized = eventName
    .split(/[-_]/)
    .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
    .join("");
  return `${normalized}SseEvent`;
}

// Cache so the same event name always returns the same class reference
const eventClassCache = new Map<string, Type>();

/**
 * Dynamically creates a real class with @ApiProperty decorators,
 * so NestJS Swagger registers it in components/schemas automatically.
 */
function getOrCreateEventClass(eventName: string, event: ApiSseEvent): Type {
  const schemaName = toEventSchemaName(eventName);

  // Fetch cached instance (use if available)
  const CachedCls = eventClassCache.get(schemaName);
  if (CachedCls) return CachedCls;

  // Create a real class — not an object literal
  const EventClass = class {};
  Object.defineProperty(EventClass, "name", { value: schemaName });

  // Apply @ApiProperty to each field programmatically
  ApiProperty({
    type: String,
    enum: [eventName],
    description: "Event type identifier",
  })(EventClass.prototype, "event");

  ApiProperty({
    type: String,
    description: "Event ID for client reconnection",
    required: false,
  })(EventClass.prototype, "id");

  ApiProperty({
    type: Number,
    description: "Reconnection delay in milliseconds",
    required: false,
  })(EventClass.prototype, "retry");

  if (event.dto !== NoPayload) {
    ApiProperty({
      type: () => event.dto as Type,
      description: `Payload for ${eventName}`,
    })(EventClass.prototype, "data");
  }

  eventClassCache.set(schemaName, EventClass as Type);
  return EventClass as Type;
}

export function ApiSse(options: ApiSseOptions) {
  const normalizedEvents = Object.fromEntries(
    Object.entries(options.events).map(([name, event]) => [name, normalizeEvent(event)]),
  );

  // Create real classes for each event envelope
  const eventClasses = Object.entries(normalizedEvents).map(([name, event]) =>
    getOrCreateEventClass(name, event),
  );

  // Also collect the payload DTOs
  const payloadDtos = Object.values(normalizedEvents)
    .filter((e) => e.dto !== NoPayload)
    .map((e) => e.dto as Type);

  // All classes that need to be registered
  const allModels = [...eventClasses, ...payloadDtos];

  // Build oneOf with proper $refs
  const oneOf = eventClasses.map((cls) => ({
    $ref: getSchemaPath(cls),
  }));

  // Build discriminator mapping
  const discriminatorMapping = Object.fromEntries(
    Object.keys(normalizedEvents).map((eventName) => [
      eventName,
      getSchemaPath(getOrCreateEventClass(eventName, normalizedEvents[eventName])),
    ]),
  );

  return applyDecorators(
    ApiExtraModels(...allModels),
    ...(options.summary ? [ApiOperation({ summary: options.summary })] : []),
    ApiOkResponse({
      description: options.description ?? "Server-Sent Events stream",
      content: {
        "text/event-stream": {
          schema: {
            oneOf,
            discriminator: {
              propertyName: "type",
              mapping: discriminatorMapping,
            },
          },
        },
      },
    }),
  );
}

Example of code usage

// Map events to DTO classes
export const POD_SSE_EVENTS = {
  "keepalive": NoPayload,
  "add-session": AddSessionDto,
  "del-session": DeleteSessionDto,
} as const;


// Use it together with decorator from above
  @Sse("stream")
  @ApiSse({
    summary: "Server-Sent-Events, commands to POD",
    description: "Stream of events to notify POD to act on",
    events: POD_SSE_EVENTS,
  })
  @ApiInternalServerErrorResponse({ description: "JobQueue failure" })
  stream(@CurrentPod() pod: PodIdentity, @Req() request: FastifyRequest) {
    return this.podService.connectSse(pod, request); // returns Observable<MessageEvent>
  }

Describe the solution you'd like

Integrate solution as part of the @nestjs/swagger package

Teachability, documentation, adoption, migration strategy

No response

What is the motivation / use case for changing the behavior?

Missing functionality

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions