Skip to main content

Real-time Events API

PUNT provides Server-Sent Events (SSE) for real-time updates across browser tabs and users.

Overview

Real-time events enable:

  • Multi-tab synchronization
  • Multi-user collaboration
  • Live updates without polling

Connection

Project Events

Subscribe to events for a specific project:

GET /api/projects/[projectId]/events

Receives: ticket and label events for the project.

Project List Events

Subscribe to project list changes:

GET /api/projects/events

Receives: project created/updated/deleted events.

User Events

Subscribe to user profile changes:

GET /api/users/events

Receives: user profile updates.

Event Format

Events are sent as JSON with the following structure:

{
"type": "ticket.created",
"data": {
"id": "clx1tkt1",
"title": "New ticket",
"type": "Task"
},
"tabId": "abc123",
"timestamp": "2024-01-15T10:30:00.000Z"
}
FieldDescription
typeEvent type identifier
dataEvent payload (varies by type)
tabIdOriginating browser tab ID
timestampWhen the event occurred

Event Types

Ticket Events

EventDescriptionData
ticket.createdNew ticket createdFull ticket object
ticket.updatedTicket fields changedTicket with changed fields
ticket.movedTicket moved between columnsTicket with new columnId, order
ticket.deletedTicket deleted{ id: string }

Label Events

EventDescriptionData
label.createdNew label createdFull label object
label.deletedLabel deleted{ id: string }

Project Events

EventDescriptionData
project.createdNew project createdFull project object
project.updatedProject details changedProject with changed fields
project.deletedProject deleted{ id: string }

User Events

EventDescriptionData
user.updatedUser profile changedUser summary object

Database Events

EventDescriptionData
database.wipedFull database wipe or import{}
database.projects.wipedAll projects wiped{}

Client Implementation

JavaScript Example

const projectId = 'clx1abc123'
const eventSource = new EventSource(`/api/projects/${projectId}/events`)

eventSource.onmessage = (event) => {
const data = JSON.parse(event.data)

// Skip events from this tab
if (data.tabId === myTabId) return

switch (data.type) {
case 'ticket.created':
addTicketToBoard(data.data)
break
case 'ticket.updated':
updateTicketInBoard(data.data)
break
case 'ticket.moved':
moveTicketOnBoard(data.data)
break
case 'ticket.deleted':
removeTicketFromBoard(data.data.id)
break
}
}

eventSource.onerror = (error) => {
console.error('SSE connection error:', error)
// EventSource will auto-reconnect
}

// Cleanup on page unload
window.addEventListener('beforeunload', () => {
eventSource.close()
})

React Query Integration

PUNT uses React Query for cache invalidation:

function useProjectEvents(projectId: string) {
const queryClient = useQueryClient()

useEffect(() => {
const eventSource = new EventSource(
`/api/projects/${projectId}/events`
)

eventSource.onmessage = (event) => {
const { type, data, tabId } = JSON.parse(event.data)

if (tabId === getTabId()) return

// Invalidate relevant queries
if (type.startsWith('ticket.')) {
queryClient.invalidateQueries(['tickets', projectId])
}
if (type.startsWith('label.')) {
queryClient.invalidateQueries(['labels', projectId])
}
}

return () => eventSource.close()
}, [projectId, queryClient])
}

Tab ID Header

Include a unique tab ID in API requests to prevent event echoing:

X-Tab-Id: abc123

Events include the originating tabId, allowing clients to filter out their own changes.

Generating Tab IDs

const tabId = crypto.randomUUID()

fetch('/api/projects/123/tickets', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tab-Id': tabId
},
body: JSON.stringify(ticketData)
})

Connection Management

Keepalive

The server sends comment keepalives every 30 seconds to prevent connection timeout:

: keepalive

Auto-Reconnect

EventSource automatically reconnects on connection loss. No additional handling required.

Nginx Configuration

If using nginx as a reverse proxy, disable buffering for SSE:

location /api {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;

# For SSE
proxy_set_header X-Accel-Buffering no;
}

PUNT includes X-Accel-Buffering: no in SSE responses.

Error Handling

Connection Errors

eventSource.onerror = (error) => {
if (eventSource.readyState === EventSource.CLOSED) {
// Connection closed permanently
showReconnectMessage()
} else {
// Temporary error, will auto-reconnect
console.log('SSE reconnecting...')
}
}

Authentication

SSE connections require a valid session cookie. If the session expires:

  1. Connection receives an error
  2. Redirect to login
  3. Re-establish connection after authentication

Database Events

Special handling for database operations:

Database Wipe

When database.wiped is received:

  1. Clear all local state
  2. Sign out the user
  3. Redirect to login
if (data.type === 'database.wiped') {
// Clear all queries
queryClient.clear()
// Sign out (use redirect: false + client-side redirect to avoid AUTH_URL resolving to localhost)
await signOut({ redirect: false })
window.location.href = '/login'
}

Projects Wipe

When database.projects.wiped is received:

  1. Invalidate all project-related queries
  2. Navigate to home page
if (data.type === 'database.projects.wiped') {
queryClient.invalidateQueries(['projects'])
queryClient.invalidateQueries(['tickets'])
queryClient.invalidateQueries(['sprints'])
router.push('/')
}