/
nayvok
/
livecoding
Обзор
Документация
Войти
/
nayvok
/
livecoding
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
frontend/src/lib/api.ts
282 строки
8 KB
nayvok
fix: harden sessions and WebRTC recovery
17 июн 2026, 22:02
17 июн 2026, 22:02
37b9a92
Код
Авторство
О чём код?
import type { CreatedSession, CreateSessionPayload, RunResultPayload, Role, SessionMetadata, } from '@/types/session' // API и WebSocket идут через same-origin. class HttpError extends Error { status: number constructor(message: string, status: number) { super(message) this.status = status } } async function parseError(res: Response): Promise<HttpError> { let message = res.statusText try { const body = (await res.json()) as { error?: string } if (body?.error) message = body.error } catch { // ignore } return new HttpError(message, res.status) } export async function createSession( payload: CreateSessionPayload, ): Promise<CreatedSession> { const res = await fetch(`/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) if (!res.ok) throw await parseError(res) return (await res.json()) as CreatedSession } export async function fetchSession(sessionId: string): Promise<SessionMetadata> { const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`) if (!res.ok) throw await parseError(res) return (await res.json()) as SessionMetadata } export async function patchTask( sessionId: string, teacherToken: string, task: string, ): Promise<void> { const res = await fetch( `/api/sessions/${encodeURIComponent(sessionId)}/task`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-Teacher-Token': teacherToken, }, body: JSON.stringify({ task }), }, ) if (!res.ok) throw await parseError(res) } export async function deleteSession( sessionId: string, teacherToken: string, ): Promise<void> { const res = await fetch( `/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE', headers: { 'X-Teacher-Token': teacherToken }, }, ) if (!res.ok) throw await parseError(res) } export async function kickStudent( sessionId: string, teacherToken: string, ): Promise<void> { const res = await fetch( `/api/sessions/${encodeURIComponent(sessionId)}/student`, { method: 'DELETE', headers: { 'X-Teacher-Token': teacherToken }, }, ) if (!res.ok) throw await parseError(res) } export interface RunCodeResponse extends RunResultPayload { runBy: 'teacher' | 'student' } export async function runCode( sessionId: string, code: string, language: 'javascript' | 'python', teacherToken: string | null, ): Promise<RunCodeResponse> { const headers: Record<string, string> = { 'Content-Type': 'application/json' } if (teacherToken) headers['X-Teacher-Token'] = teacherToken else { headers['X-Participant-Id'] = getOrCreateParticipantId( sessionId, 'student', ) } const res = await fetch( `/api/sessions/${encodeURIComponent(sessionId)}/run`, { method: 'POST', headers, body: JSON.stringify({ code, language }), }, ) if (!res.ok) throw await parseError(res) return (await res.json()) as RunCodeResponse } // URL WebSocket для сессии. export function buildWsUrl(sessionId: string, teacherToken?: string): string { const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const url = new URL( `${proto}//${window.location.host}/ws/${encodeURIComponent(sessionId)}`, ) if (teacherToken) url.searchParams.set('token', teacherToken) return url.toString() } export function buildSessionLink(sessionId: string): string { return `${window.location.origin}/session/${encodeURIComponent(sessionId)}` } // Токен преподавателя для возврата после refresh. const TEACHER_TOKEN_PREFIX = 'teacherToken:' const TEACHER_TOKEN_KEY = (sessionId: string) => `${TEACHER_TOKEN_PREFIX}${sessionId}` const TEACHER_TOKEN_TTL_MS = 5 * 60 * 1000 const PARTICIPANT_ID_PREFIX = 'participantId:' const PARTICIPANT_ID_KEY = (sessionId: string, role: Role) => `${PARTICIPANT_ID_PREFIX}${sessionId}:${role}` interface StoredTeacherTokenEntry { token: string createdAt: number } export interface StoredTeacherSession { sessionId: string token: string createdAt: number } function readEntry(raw: string | null): StoredTeacherTokenEntry | null { if (!raw) return null // Старый формат storage. if (!raw.startsWith('{')) { return { token: raw, createdAt: Date.now() } } try { const parsed = JSON.parse(raw) as Partial<StoredTeacherTokenEntry> if (typeof parsed?.token !== 'string' || typeof parsed?.createdAt !== 'number') { return null } return { token: parsed.token, createdAt: parsed.createdAt } } catch { return null } } export function storeTeacherToken(sessionId: string, token: string): void { try { const entry: StoredTeacherTokenEntry = { token, createdAt: Date.now() } localStorage.setItem(TEACHER_TOKEN_KEY(sessionId), JSON.stringify(entry)) } catch { // localStorage может быть отключен. } } export function getStoredTeacherToken(sessionId: string): string | null { try { const entry = readEntry(localStorage.getItem(TEACHER_TOKEN_KEY(sessionId))) if (!entry) return null if (Date.now() - entry.createdAt > TEACHER_TOKEN_TTL_MS) { clearTeacherToken(sessionId) return null } storeTeacherToken(sessionId, entry.token) return entry.token } catch { return null } } function createParticipantId(): string { const cryptoObj = globalThis.crypto const randomUUID = cryptoObj?.randomUUID if (typeof randomUUID === 'function') { return `participant-${randomUUID.call(cryptoObj)}` } const bytes = new Uint32Array(2) const getRandomValues = cryptoObj?.getRandomValues if (typeof getRandomValues === 'function') { getRandomValues.call(cryptoObj, bytes) } else { bytes[0] = Math.floor(Math.random() * 0xffffffff) bytes[1] = Date.now() } return `participant-${Array.from(bytes, (n) => n.toString(36)).join('-')}` } const memoryParticipantIds = new Map<string, string>() export function getOrCreateParticipantId(sessionId: string, role: Role): string { const key = PARTICIPANT_ID_KEY(sessionId, role) const remembered = memoryParticipantIds.get(key) try { const stored = localStorage.getItem(key) if (stored) { memoryParticipantIds.set(key, stored) return stored } const next = remembered ?? createParticipantId() localStorage.setItem(key, next) memoryParticipantIds.set(key, next) return next } catch { if (remembered) return remembered const next = createParticipantId() memoryParticipantIds.set(key, next) return next } } export function clearTeacherToken(sessionId: string): void { try { localStorage.removeItem(TEACHER_TOKEN_KEY(sessionId)) } catch { // ignore } } export function listStoredTeacherSessions(): StoredTeacherSession[] { const result: StoredTeacherSession[] = [] const expiredKeys: string[] = [] try { for (let i = 0; i < localStorage.length; i += 1) { const key = localStorage.key(i) if (!key || !key.startsWith(TEACHER_TOKEN_PREFIX)) continue const sessionId = key.slice(TEACHER_TOKEN_PREFIX.length) const entry = readEntry(localStorage.getItem(key)) if (!entry) { expiredKeys.push(key) continue } if (Date.now() - entry.createdAt > TEACHER_TOKEN_TTL_MS) { expiredKeys.push(key) continue } result.push({ sessionId, token: entry.token, createdAt: entry.createdAt }) } for (const key of expiredKeys) { try { localStorage.removeItem(key) } catch { // ignore } } } catch { return [] } // Свежие сверху. result.sort((a, b) => b.createdAt - a.createdAt) return result } export { HttpError }