-
Notifications
You must be signed in to change notification settings - Fork 527
Open
Labels
Description
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
Reactions are currently unavailable