feat: sync interview content in real time
Publish shared-document and note events through RabbitMQ, stream token-scoped document updates to participants, and update connected interviewer and participant browsers through resilient SSE subscriptions. Add snapshots, heartbeats, reconnection behavior, proxy streaming configuration, privacy filtering, and end-to-end RabbitMQ coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
e4fee06e2f
commit
c050815900
16 changed files with 438 additions and 101 deletions
|
|
@ -6,6 +6,7 @@ import type {
|
|||
CreateQuestionInput,
|
||||
CreateScheduledInterviewInput,
|
||||
Interview,
|
||||
InterviewEvent,
|
||||
Invitation,
|
||||
Job,
|
||||
JobStatus,
|
||||
|
|
@ -97,6 +98,56 @@ async function requestBlob(path: string): Promise<Blob> {
|
|||
return response.blob()
|
||||
}
|
||||
|
||||
async function subscribeToInterviewEvents(
|
||||
path: string,
|
||||
onEvent: (event: InterviewEvent) => void,
|
||||
signal: AbortSignal,
|
||||
protectedRequest: boolean,
|
||||
onConnectionError?: (error: unknown) => void,
|
||||
) {
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
const token = authStorage.token()
|
||||
const response = await fetch(`/api${path}`, {
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
...(protectedRequest && token ? { Authorization: 'Bearer ' + token } : {}),
|
||||
},
|
||||
signal,
|
||||
})
|
||||
if (!response.ok || !response.body)
|
||||
throw new ApiError(`Live updates failed with status ${response.status}`, response.status)
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
while (!signal.aborted) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n')
|
||||
let boundary = buffer.indexOf('\n\n')
|
||||
while (boundary >= 0) {
|
||||
const message = buffer.slice(0, boundary)
|
||||
buffer = buffer.slice(boundary + 2)
|
||||
const data = message
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).trimStart())
|
||||
.join('\n')
|
||||
if (data) onEvent(JSON.parse(data) as InterviewEvent)
|
||||
boundary = buffer.indexOf('\n\n')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal.aborted) return
|
||||
if (error instanceof ApiError && error.status >= 400 && error.status < 500) throw error
|
||||
onConnectionError?.(error)
|
||||
}
|
||||
if (!signal.aborted) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 1000))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login: (email: string, password: string) =>
|
||||
request<AuthResponse>(
|
||||
|
|
@ -226,44 +277,35 @@ export const api = {
|
|||
method: 'POST',
|
||||
body: JSON.stringify({ body }),
|
||||
}),
|
||||
subscribe: async (id: number, onNote: (note: InterviewNote) => void, signal: AbortSignal) => {
|
||||
const token = authStorage.token()
|
||||
const response = await fetch(`/api/scheduled-interviews/${id}/events`, {
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
...(token ? { Authorization: 'Bearer ' + token } : {}),
|
||||
},
|
||||
subscribe: (
|
||||
id: number,
|
||||
onEvent: (event: InterviewEvent) => void,
|
||||
signal: AbortSignal,
|
||||
onConnectionError?: (error: unknown) => void,
|
||||
) =>
|
||||
subscribeToInterviewEvents(
|
||||
`/scheduled-interviews/${id}/events`,
|
||||
onEvent,
|
||||
signal,
|
||||
})
|
||||
if (!response.ok || !response.body)
|
||||
throw new ApiError(`Live updates failed with status ${response.status}`, response.status)
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
while (!signal.aborted) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n')
|
||||
let boundary = buffer.indexOf('\n\n')
|
||||
while (boundary >= 0) {
|
||||
const message = buffer.slice(0, boundary)
|
||||
buffer = buffer.slice(boundary + 2)
|
||||
const data = message
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).trimStart())
|
||||
.join('\n')
|
||||
if (data) {
|
||||
const event = JSON.parse(data) as InterviewNote | { note: InterviewNote }
|
||||
onNote('note' in event ? event.note : event)
|
||||
}
|
||||
boundary = buffer.indexOf('\n\n')
|
||||
}
|
||||
}
|
||||
},
|
||||
true,
|
||||
onConnectionError,
|
||||
),
|
||||
},
|
||||
participantMeetings: {
|
||||
get: (token: string) =>
|
||||
request<ParticipantMeeting>(`/participant-meetings/${token}`, {}, false),
|
||||
subscribe: (
|
||||
token: string,
|
||||
onEvent: (event: InterviewEvent) => void,
|
||||
signal: AbortSignal,
|
||||
onConnectionError?: (error: unknown) => void,
|
||||
) =>
|
||||
subscribeToInterviewEvents(
|
||||
`/participant-meetings/${token}/events`,
|
||||
onEvent,
|
||||
signal,
|
||||
false,
|
||||
onConnectionError,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue