Skip to content

Commit b5beaf9

Browse files
committed
feat(delivery): add server-sent events service and stream controller
1 parent 94911e9 commit b5beaf9

3 files changed

Lines changed: 242 additions & 0 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Module } from '@nestjs/common'
2+
import { TasksRepository } from '../tasks/tasks.repository.js'
3+
import { EventsRepository } from './events.repository.js'
4+
import { SseService } from './sse.service.js'
5+
import { StreamController } from './stream.controller.js'
6+
7+
/**
8+
* API-side module for the Delivery domain.
9+
* Registers the SSE stream endpoint and its dependencies.
10+
* Intentionally excludes WebhookProcessor — that belongs to the Worker process.
11+
*/
12+
@Module({
13+
controllers: [StreamController],
14+
providers: [SseService, TasksRepository, EventsRepository],
15+
exports: [EventsRepository],
16+
})
17+
export class DeliveryModule {}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { Injectable, Logger, NotFoundException } from '@nestjs/common'
2+
import { Observable } from 'rxjs'
3+
import type { MessageEvent } from '@nestjs/common'
4+
import type { Redis } from 'ioredis'
5+
import { RedisService } from '#libs/redis/index.js'
6+
import { TasksRepository } from '../tasks/tasks.repository.js'
7+
import { EventsRepository } from './events.repository.js'
8+
import type { TaskEventSelect, TaskEventType } from './schemas/events.sql.js'
9+
10+
const TERMINAL_EVENT_TYPES = new Set<TaskEventType>(['completed', 'failed', 'cancelled'])
11+
12+
/**
13+
* 25 s keeps the connection alive through reverse proxies (nginx proxy_read_timeout,
14+
* ALB idle timeout) that would otherwise silently drop quiet SSE connections.
15+
*/
16+
const HEARTBEAT_INTERVAL_MS = 25_000
17+
18+
const HEARTBEAT_EVENT: MessageEvent = { data: '', type: 'ping' }
19+
20+
/**
21+
* Maps a DB event row to the SSE wire format.
22+
* `seq` becomes Last-Event-ID so clients can resume after disconnect.
23+
* DB-internal fields (id, taskId, createdAt) are stripped from the payload.
24+
*/
25+
function toMessageEvent(event: TaskEventSelect): MessageEvent {
26+
const { id: _id, taskId: _taskId, seq, createdAt: _createdAt, ...eventPayload } = event
27+
return {
28+
id: String(seq),
29+
type: eventPayload.eventType,
30+
data: JSON.stringify(eventPayload),
31+
}
32+
}
33+
34+
/**
35+
* Delivers task lifecycle events over SSE via event sourcing + Redis Pub/Sub.
36+
*
37+
* Each connection gets a dedicated ioredis subscriber client because ioredis
38+
* enters subscriber-only mode on `.subscribe()`, blocking all other commands
39+
* on that connection.
40+
*/
41+
@Injectable()
42+
export class SseService {
43+
private readonly logger = new Logger(SseService.name)
44+
45+
constructor(
46+
private readonly redis: RedisService,
47+
private readonly tasksRepo: TasksRepository,
48+
private readonly eventsRepo: EventsRepository,
49+
) {}
50+
51+
/**
52+
* Opens an SSE stream for a task. Replays history from `afterSeq`,
53+
* then streams live Pub/Sub events until a terminal state is reached.
54+
* Includes parallel heartbeat pings to prevent proxy idle timeouts.
55+
*/
56+
async stream(taskId: string, afterSeq: number): Promise<Observable<MessageEvent>> {
57+
const task = await this.tasksRepo.findById(taskId)
58+
if (!task) throw new NotFoundException(`Task ${taskId} not found`)
59+
60+
return new Observable<MessageEvent>((subscriber) => {
61+
let isDone = false
62+
let historyLoaded = false
63+
let highestHistorySeq = afterSeq
64+
const liveBuffer: TaskEventSelect[] = []
65+
66+
// Dedicated subscriber client — isolated from the shared business connection.
67+
const subClient = (this.redis.client as Redis).duplicate()
68+
const channel = `task:${taskId}`
69+
70+
void subClient.subscribe(channel, (err) => {
71+
if (err) {
72+
this.logger.error(`Failed to subscribe to channel ${channel}: ${err.message}`)
73+
subscriber.error(err)
74+
}
75+
})
76+
77+
subClient.on('message', (_chan: string, rawMessage: string) => {
78+
if (isDone) return
79+
try {
80+
const event = JSON.parse(rawMessage) as TaskEventSelect
81+
82+
if (!historyLoaded) {
83+
liveBuffer.push(event)
84+
return
85+
}
86+
87+
if (event.seq > highestHistorySeq) {
88+
highestHistorySeq = event.seq
89+
subscriber.next(toMessageEvent(event))
90+
if (TERMINAL_EVENT_TYPES.has(event.eventType)) {
91+
isDone = true
92+
subscriber.complete()
93+
}
94+
}
95+
} catch (err) {
96+
this.logger.error(
97+
`Failed to parse Pub/Sub message on ${channel}: ${String(err)}`,
98+
)
99+
}
100+
})
101+
102+
subClient.on('error', (err: Error) => {
103+
this.logger.error(`Redis subscriber error on ${channel}: ${err.message}`)
104+
subscriber.error(err)
105+
})
106+
107+
const heartbeatTimer = setInterval(() => {
108+
if (!isDone) subscriber.next(HEARTBEAT_EVENT)
109+
}, HEARTBEAT_INTERVAL_MS)
110+
111+
// Fetch history AFTER subscribing to Redis to guarantee no missed events.
112+
this.eventsRepo
113+
.findEventsSince(taskId, afterSeq)
114+
.then((events) => {
115+
if (isDone) return
116+
117+
for (const event of events) {
118+
highestHistorySeq = Math.max(highestHistorySeq, event.seq)
119+
subscriber.next(toMessageEvent(event))
120+
if (TERMINAL_EVENT_TYPES.has(event.eventType)) {
121+
isDone = true
122+
subscriber.complete()
123+
return
124+
}
125+
}
126+
127+
historyLoaded = true
128+
for (const event of liveBuffer) {
129+
if (event.seq > highestHistorySeq) {
130+
highestHistorySeq = event.seq
131+
subscriber.next(toMessageEvent(event))
132+
if (TERMINAL_EVENT_TYPES.has(event.eventType)) {
133+
isDone = true
134+
subscriber.complete()
135+
return
136+
}
137+
}
138+
}
139+
})
140+
.catch((err: unknown) => {
141+
if (!isDone) subscriber.error(err)
142+
})
143+
144+
return () => {
145+
isDone = true
146+
clearInterval(heartbeatTimer)
147+
subClient
148+
.unsubscribe(channel)
149+
.finally(() => {
150+
void subClient.quit()
151+
})
152+
.catch((err: unknown) => {
153+
this.logger.error(`Failed to unsubscribe SSE client: ${String(err)}`)
154+
})
155+
this.logger.debug(`SSE stream closed for taskId=${taskId}`)
156+
}
157+
})
158+
}
159+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import {
2+
Controller,
3+
Get,
4+
Header,
5+
Headers,
6+
HttpStatus,
7+
Param,
8+
ParseUUIDPipe,
9+
Sse,
10+
} from '@nestjs/common'
11+
import { ApiTags, ApiOperation, ApiResponse, ApiHeader } from '@nestjs/swagger'
12+
import type { MessageEvent } from '@nestjs/common'
13+
import type { Observable } from 'rxjs'
14+
import { SseService } from './sse.service.js'
15+
16+
/**
17+
* Parses the `Last-Event-ID` header into a non-negative integer sequence number.
18+
* Falls back to 0 (replay all) on missing or malformed values — this is
19+
* intentionally forgiving: a wrong seq causes extra data, not data loss.
20+
*/
21+
function parseLastEventId(raw: string | undefined): number {
22+
if (!raw) return 0
23+
const parsed = Number(raw)
24+
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 0
25+
}
26+
27+
/**
28+
* Exposes the Server-Sent Events endpoint for real-time task lifecycle notifications.
29+
* Uses event sourcing: clients reconnecting with `Last-Event-ID` receive all missed events.
30+
*/
31+
@ApiTags('tasks')
32+
@Controller('tasks')
33+
export class StreamController {
34+
constructor(private readonly sseService: SseService) {}
35+
36+
/**
37+
* Opens a persistent SSE connection. Replays missed events from `Last-Event-ID`,
38+
* then delivers live updates until the task reaches a terminal state.
39+
*/
40+
@Get(':id/stream')
41+
@Sse()
42+
// Prevent reverse proxies and CDNs from buffering the SSE response.
43+
@Header('X-Accel-Buffering', 'no')
44+
@Header('Cache-Control', 'no-cache')
45+
@ApiOperation({ summary: 'Open SSE stream for real-time task events' })
46+
@ApiHeader({
47+
name: 'Last-Event-ID',
48+
description: 'Resume replay from this event sequence number (inclusive)',
49+
required: false,
50+
})
51+
@ApiResponse({
52+
status: HttpStatus.OK,
53+
description: 'SSE stream opened; events follow task lifecycle',
54+
})
55+
@ApiResponse({
56+
status: HttpStatus.NOT_FOUND,
57+
description: 'Task not found',
58+
})
59+
async stream(
60+
@Param('id', ParseUUIDPipe) id: string,
61+
@Headers('last-event-id') lastEventId: string | undefined,
62+
): Promise<Observable<MessageEvent>> {
63+
const afterSeq = parseLastEventId(lastEventId)
64+
return this.sseService.stream(id, afterSeq)
65+
}
66+
}

0 commit comments

Comments
 (0)