Skip to content

Commit 720be0b

Browse files
authored
Merge pull request #309 from deploystackio/feat/redesign
Feat/redesign
2 parents a7b15d7 + 55c7ee3 commit 720be0b

19 files changed

+2073
-109
lines changed

development/backend/api/index.mdx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,19 @@ interface Response {
577577

578578
**Note**: TypeScript interfaces and JavaScript variables can use camelCase internally, but all external API fields MUST use snake_case.
579579

580+
## Server-Sent Events (SSE)
581+
582+
For real-time updates, use the `/stream` suffix pattern:
583+
584+
- **REST endpoint:** `/api/{resource}/{action}`
585+
- **SSE endpoint:** `/api/{resource}/{action}/stream`
586+
587+
Example:
588+
- `GET /api/users/me/mcp/client-activity` - REST API with pagination
589+
- `GET /api/users/me/mcp/client-activity/stream` - SSE real-time stream
590+
591+
See [Server-Sent Events (SSE)](/development/backend/api/sse) for complete SSE documentation.
592+
580593
## Adding Documentation to Routes
581594

582595
The DeployStack Backend uses **reusable JSON Schema constants** for both validation and documentation generation. This approach provides a single source of truth for API schemas.

development/backend/api/sse.mdx

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
---
2+
title: Server-Sent Events (SSE)
3+
description: Implementing real-time server-to-client communication using Server-Sent Events in DeployStack Backend.
4+
---
5+
6+
## Overview
7+
8+
DeployStack Backend includes `@fastify/sse` for Server-Sent Events support. SSE provides a simple, unidirectional communication channel from server to client over HTTP - ideal for live updates, notifications, and streaming data.
9+
10+
The plugin is globally registered with a 30-second heartbeat interval to keep connections alive.
11+
12+
## Naming Convention Standard
13+
14+
All SSE endpoints **MUST** follow this URL pattern:
15+
16+
- **REST endpoint:** `/api/{resource}/{action}`
17+
- **SSE endpoint:** `/api/{resource}/{action}/stream`
18+
19+
### URL Pattern Examples
20+
21+
```bash
22+
# Client Activity
23+
GET /api/users/me/mcp/client-activity # REST API (polling)
24+
GET /api/users/me/mcp/client-activity/stream # SSE stream
25+
26+
# Notifications
27+
GET /api/teams/:teamId/notifications # REST API (polling)
28+
GET /api/teams/:teamId/notifications/stream # SSE stream
29+
30+
# Metrics
31+
GET /api/satellites/:satelliteId/metrics # REST API (polling)
32+
GET /api/satellites/:satelliteId/metrics/stream # SSE stream
33+
```
34+
35+
### Paired Endpoints
36+
37+
Every SSE endpoint should have a corresponding REST endpoint:
38+
39+
1. **Same Query Parameters**: Both endpoints accept identical query parameters
40+
2. **Same Data Structure**: Both return the same data format
41+
3. **Consistent Behavior**: Both apply the same filters and limits
42+
4. **Fallback Support**: REST endpoint serves as fallback for clients without SSE support
43+
44+
### Why `/stream`?
45+
46+
- **Industry Standard**: Used by GitHub, Stripe, and Twitter APIs
47+
- **RESTful**: Treats streaming as a sub-resource
48+
- **Technology Agnostic**: Works for SSE, WebSockets, or any streaming protocol
49+
- **Clear Intent**: Immediately indicates real-time streaming capability
50+
51+
## Enabling SSE on a Route
52+
53+
Add the `sse: true` option to any route definition:
54+
55+
```typescript
56+
server.get('/events', { sse: true }, async (request, reply) => {
57+
// SSE methods available on reply.sse
58+
})
59+
```
60+
61+
For route-specific configuration:
62+
63+
```typescript
64+
server.get('/events', {
65+
sse: {
66+
heartbeat: false, // Disable heartbeat for this route
67+
serializer: (data) => JSON.stringify(data) // Custom serializer
68+
}
69+
}, handler)
70+
```
71+
72+
## Sending Messages
73+
74+
### Single Message
75+
76+
```typescript
77+
reply.sse.send({ data: 'Hello world' })
78+
```
79+
80+
### Full SSE Format
81+
82+
```typescript
83+
reply.sse.send({
84+
id: '123',
85+
event: 'user_update',
86+
data: { userId: 'abc', status: 'online' },
87+
retry: 5000 // Client retry interval in ms
88+
})
89+
```
90+
91+
### Streaming with Async Generator
92+
93+
```typescript
94+
async function* generateUpdates() {
95+
for (let i = 0; i < 10; i++) {
96+
yield { data: { count: i } }
97+
await new Promise(r => setTimeout(r, 1000))
98+
}
99+
}
100+
101+
reply.sse.send(generateUpdates())
102+
```
103+
104+
## Connection Management
105+
106+
### Keep Connection Open
107+
108+
By default, the connection closes after the handler completes. To keep it open:
109+
110+
```typescript
111+
reply.sse.keepAlive()
112+
```
113+
114+
### Handle Disconnection
115+
116+
```typescript
117+
reply.sse.onClose(() => {
118+
// Cleanup logic when client disconnects
119+
server.log.info('Client disconnected')
120+
})
121+
```
122+
123+
### Manual Close
124+
125+
```typescript
126+
reply.sse.close()
127+
```
128+
129+
## Client Reconnection
130+
131+
Handle reconnecting clients using the `Last-Event-ID` header:
132+
133+
```typescript
134+
reply.sse.replay(async (lastEventId) => {
135+
// Fetch and send missed events since lastEventId
136+
const missedEvents = await getMissedEvents(lastEventId)
137+
for (const event of missedEvents) {
138+
reply.sse.send(event)
139+
}
140+
})
141+
```
142+
143+
Access the last event ID directly:
144+
145+
```typescript
146+
const lastId = reply.sse.lastEventId
147+
```
148+
149+
## Connection State
150+
151+
```typescript
152+
if (reply.sse.isConnected) {
153+
reply.sse.send({ data: 'still connected' })
154+
}
155+
```
156+
157+
## Complete Route Example
158+
159+
```typescript
160+
import { type FastifyInstance } from 'fastify'
161+
import { requirePermission } from '../../../middleware/roleMiddleware'
162+
163+
export default async function sseRoute(server: FastifyInstance) {
164+
server.get('/notifications/stream', {
165+
sse: true,
166+
preValidation: requirePermission('notifications.read'),
167+
schema: {
168+
tags: ['Notifications'],
169+
summary: 'Stream notifications',
170+
description: 'Real-time notification stream via SSE',
171+
security: [{ cookieAuth: [] }]
172+
}
173+
}, async (request, reply) => {
174+
const userId = request.user!.id
175+
176+
// Handle client reconnection
177+
reply.sse.replay(async (lastEventId) => {
178+
const missed = await notificationService.getMissedNotifications(userId, lastEventId)
179+
for (const notification of missed) {
180+
reply.sse.send({ id: notification.id, event: 'notification', data: notification })
181+
}
182+
})
183+
184+
// Keep connection open
185+
reply.sse.keepAlive()
186+
187+
// Subscribe to new notifications
188+
const unsubscribe = notificationService.subscribe(userId, (notification) => {
189+
if (reply.sse.isConnected) {
190+
reply.sse.send({ id: notification.id, event: 'notification', data: notification })
191+
}
192+
})
193+
194+
// Cleanup on disconnect
195+
reply.sse.onClose(() => {
196+
unsubscribe()
197+
server.log.debug({ userId }, 'SSE connection closed')
198+
})
199+
})
200+
}
201+
```
202+
203+
## Frontend Client
204+
205+
```javascript
206+
const eventSource = new EventSource('/api/notifications/stream', {
207+
withCredentials: true // Include cookies for authentication
208+
})
209+
210+
eventSource.addEventListener('notification', (event) => {
211+
const data = JSON.parse(event.data)
212+
console.log('New notification:', data)
213+
})
214+
215+
eventSource.onerror = () => {
216+
// Browser automatically reconnects
217+
console.log('Connection lost, reconnecting...')
218+
}
219+
```
220+
221+
## TypeScript Types
222+
223+
Import types from the package:
224+
225+
```typescript
226+
import type { SSEMessage } from '@fastify/sse'
227+
228+
const message: SSEMessage = {
229+
id: '123',
230+
event: 'update',
231+
data: { status: 'active' }
232+
}
233+
```
234+
235+
## Related Documentation
236+
237+
- [API Documentation Generation](/development/backend/api) - General API development patterns
238+
- [API Security](/development/backend/api/security) - Authorization middleware for protected SSE endpoints

development/backend/cron.mdx

Lines changed: 46 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -33,22 +33,29 @@ Create a new file in `src/cron/jobs/`:
3333
```typescript
3434
// src/cron/jobs/dailyCleanup.ts
3535
import type { CronJob } from '../cronManager';
36-
import type { JobQueueService } from '../../services/jobQueueService';
3736

38-
export function createDailyCleanupJob(jobQueueService: JobQueueService): CronJob {
37+
export function createDailyCleanupJob(): CronJob {
3938
return {
4039
name: 'daily-cleanup',
4140
schedule: '0 2 * * *', // Every day at 2 AM
42-
43-
task: async () => {
44-
await jobQueueService.createJob('cleanup_old_data', {
45-
daysToKeep: 30
46-
});
47-
}
41+
jobType: 'cleanup_old_data',
42+
payload: {
43+
daysToKeep: 30,
44+
},
4845
};
4946
}
5047
```
5148

49+
The `CronJob` interface has these fields:
50+
51+
| Field | Required | Description |
52+
|-------|----------|-------------|
53+
| `name` | Yes | Unique identifier for logging and tracking |
54+
| `schedule` | Yes | Cron expression (see syntax below) |
55+
| `jobType` | Yes | Job type that matches a registered worker |
56+
| `payload` | No | Static object or function returning payload data |
57+
| `maxAttempts` | No | Override default retry attempts (default: 3) |
58+
5259
### Step 2: Create the Worker
5360

5461
Create a worker to process the job in `src/workers/`:
@@ -136,14 +143,16 @@ export function initializeCronJobs(
136143
jobQueueService: JobQueueService,
137144
logger: FastifyBaseLogger
138145
): CronManager {
139-
const cronManager = new CronManager(logger);
146+
const cronManager = new CronManager(logger, jobQueueService);
140147

141-
cronManager.register(createDailyCleanupJob(jobQueueService));
148+
cronManager.register(createDailyCleanupJob());
142149

143150
return cronManager;
144151
}
145152
```
146153

154+
The `CronManager` handles job creation automatically when the schedule fires. You don't need to call `createJob()` yourself - just define the `jobType` and `payload` in your cron job definition.
155+
147156
## Cron Expression Syntax
148157

149158
The system uses standard cron syntax with 5 or 6 fields:
@@ -194,24 +203,37 @@ Here's a complete example showing how to create a cron job that sends a daily em
194203
```typescript
195204
// src/cron/jobs/dailyDigest.ts
196205
import type { CronJob } from '../cronManager';
197-
import type { JobQueueService } from '../../services/jobQueueService';
198206

199-
export function createDailyDigestJob(jobQueueService: JobQueueService): CronJob {
207+
export function createDailyDigestJob(): CronJob {
200208
return {
201209
name: 'daily-digest-email',
202210
schedule: '0 8 * * *', // Every day at 8 AM
203-
204-
task: async () => {
205-
// Create job to send digest email
206-
await jobQueueService.createJob('send_email', {
207-
208-
subject: 'Daily Activity Digest',
209-
template: 'daily_digest',
210-
variables: {
211-
date: new Date().toISOString().split('T')[0]
212-
}
213-
});
214-
}
211+
jobType: 'send_email',
212+
payload: {
213+
214+
subject: 'Daily Activity Digest',
215+
template: 'daily_digest',
216+
},
217+
};
218+
}
219+
```
220+
221+
For dynamic payload values computed at runtime, use a function:
222+
223+
```typescript
224+
export function createDailyDigestJob(): CronJob {
225+
return {
226+
name: 'daily-digest-email',
227+
schedule: '0 8 * * *',
228+
jobType: 'send_email',
229+
payload: () => ({
230+
231+
subject: 'Daily Activity Digest',
232+
template: 'daily_digest',
233+
variables: {
234+
date: new Date().toISOString().split('T')[0],
235+
},
236+
}),
215237
};
216238
}
217239
```

0 commit comments

Comments
 (0)