import { createHash, randomUUID } from 'node:crypto'; import { execFile } from 'node:child_process'; import { createServer } from 'node:http'; import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { promisify } from 'node:util'; import Fastify from 'fastify'; import websocket from '@fastify/websocket'; import { STATUSES, catalogFromCard, normaliseProgress, progressSummary, readJson, statusLabel, teamsMarkdown, updateItem, writeJsonAtomically } from './lib/card_progress.mjs'; import { attachNvimUi, callSelectedNvim, resizeSelectedNvimUi, selectedNvimTarget } from './lib/nvim_ui_bridge.mjs'; import { activateCheckpoint, checkpointStatus } from './lib/checkpoint_controller.mjs'; import { CardStateDatabase } from './lib/card_state_db.mjs'; import { activateNavigation, KEYBOARD_SHORTCUTS, moveNavigation, navigationCatalog, normalizeViewerState, patchViewerState, selectNavigation } from './lib/card_control.mjs'; const repositoryRoot = path.resolve(process.cwd()); const cardFile = path.join(repositoryRoot, 'json/card_source.json'); const progressFile = path.resolve(process.env.CARD_PROGRESS_FILE ?? path.join(repositoryRoot, 'json/lesson_progress.json')); const stateDatabaseFile = path.resolve( process.env.CARD_STATE_DATABASE ?? path.join(repositoryRoot, '.stem/card-state.sqlite3') ); const apiSocket = path.resolve( process.env.CARD_API_SOCKET ?? path.join(repositoryRoot, '.stem/card-api.sock') ); const evidenceRoot = path.resolve( process.env.CARD_EVIDENCE_ROOT ?? path.join(repositoryRoot, '.stem/evidence') ); const webRoot = path.join(repositoryRoot, 'web'); const host = process.env.CARD_HOST ?? '127.0.0.1'; const port = Number(process.env.CARD_PORT ?? 8080); const app = Fastify({ logger: true }); const execFileAsync = promisify(execFile); const stateDatabase = new CardStateDatabase(stateDatabaseFile); await app.register(websocket, { options: { maxPayload: 64 * 1024 } }); const contentTypes = { '.css': 'text/css; charset=utf-8', '.html': 'text/html; charset=utf-8', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.puml': 'text/plain; charset=utf-8', '.svg': 'image/svg+xml; charset=utf-8', '.webp': 'image/webp' }; let writeQueue = Promise.resolve(); let currentNavigation = stateDatabase.read('navigation'); let navigationRevision = Number.isSafeInteger(Number(currentNavigation?.revision)) ? Number(currentNavigation.revision) : 0; const persistedViewer = stateDatabase.read('viewer'); let currentViewer = normalizeViewerState(persistedViewer); if (persistedViewer && JSON.stringify(persistedViewer) !== JSON.stringify(currentViewer)) { stateDatabase.write('viewer', currentViewer); } let viewerRevision = Number.isSafeInteger(Number(currentViewer?.revision)) ? Number(currentViewer.revision) : 0; function actor(value, fallback = 'system') { return typeof value === 'string' && value.trim() ? value.trim().slice(0, 160) : fallback; } async function zoomSelectedHostPane() { const selected = await selectedNvimTarget(); const instance = String(selected.metadata?.instance ?? ''); const result = await execFileAsync('tmux', [ 'list-panes', '-a', '-F', '#{pane_id}\t#{@stem_instance}\t#{@stem_role}\t#{window_zoomed_flag}' ]); const panes = result.stdout.trim().split('\n').filter(Boolean).map(line => { const [id, stemInstance, role, zoomed] = line.split('\t'); return { id, stemInstance, role, zoomed }; }); const exact = panes.find(pane => pane.stemInstance === instance && pane.role === 'debugger'); const debuggerPanes = panes.filter(pane => pane.role === 'debugger'); const pane = exact ?? (debuggerPanes.length === 1 ? debuggerPanes[0] : null); if (!pane) return { found: false, instance }; if (pane.zoomed !== '1') { await execFileAsync('tmux', ['resize-pane', '-Z', '-t', pane.id]); } const dimensions = await execFileAsync('tmux', [ 'display-message', '-p', '-t', pane.id, '#{pane_width}x#{pane_height}\t#{window_zoomed_flag}' ]); const [size, zoomed] = dimensions.stdout.trim().split('\t'); return { found: true, pane: pane.id, instance, size, zoomed: zoomed === '1' }; } function expectedNvimGrid(hostPane) { const match = hostPane?.found && typeof hostPane.size === 'string' ? /^(\d+)x(\d+)$/.exec(hostPane.size) : null; if (!match) return null; return { width: Number(match[1]), // The nested tmux status line occupies one row below the Neovim pane. height: Math.max(1, Number(match[2]) - 1) }; } async function settleSelectedNvimTerminalUi(hostPane) { const target = expectedNvimGrid(hostPane); let innerTmux = null; try { innerTmux = await callSelectedNvim('nvim_exec_lua', [` if not vim.env.TMUX or vim.env.TMUX == '' then return { attempted = false } end local result = vim.system( { 'tmux', 'resize-window', '-A' }, { text = true } ):wait() return { attempted = true, code = result.code, stderr = vim.trim(result.stderr or '') } `, []]); } catch (error) { innerTmux = { attempted: true, error: error instanceof Error ? error.message : String(error) }; } const deadline = Date.now() + 1800; let terminal = null; do { const uis = await callSelectedNvim('nvim_list_uis', []); terminal = Array.isArray(uis) ? uis.find(ui => ui?.stdin_tty && ui?.stdout_tty) ?? null : null; if ( terminal && (!target || (terminal.width >= target.width && terminal.height >= target.height)) ) break; await new Promise(resolve => setTimeout(resolve, 80)); } while (Date.now() < deadline); return { target, terminal: terminal ? { width: terminal.width, height: terminal.height } : null, inner_tmux: innerTmux, settled: Boolean( terminal && (!target || (terminal.width >= target.width && terminal.height >= target.height)) ) }; } function audit(eventType, eventActor, payload = {}, options = {}) { return stateDatabase.appendEvent({ eventType, actor: actor(eventActor), navigation: options.navigation === undefined ? currentNavigation : options.navigation, evidenceRef: options.evidenceRef ?? null, payload }); } function html(value) { return String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"'); } function auditReportHtml(events) { const rows = events.map(event => { const position = [event.task_id, event.block_id, event.phase_id, event.step_id] .filter(Boolean) .join(' / '); const evidence = event.evidence_ref ? `
${html(event.payload?.caption ?? event.event_type)}
${html(event.payload?.caption ?? '')}
` : ''; return `
${html(event.event_type)}${html(event.actor)}

${html(position)}

${evidence}
`; }).join('\n'); return `Ślad lekcji

Chronologiczny ślad lekcji

Wpisy i timestampy pochodzą z backendu; zrzuty są dowodami przypiętymi do zdarzeń.

${rows}
`; } async function restoreNavigation() { if (!currentNavigation) return; try { const catalog = await currentNavigationCatalog(); currentNavigation = selectNavigation( catalog, currentNavigation, navigationRevision, currentNavigation.selected_at ); } catch { currentNavigation = null; navigationRevision = 0; } } async function currentNavigationCatalog() { return navigationCatalog(await readJson(cardFile)); } async function currentCardIdentity() { const source = await readFile(cardFile); const card = JSON.parse(source.toString('utf8')).card ?? {}; return { schema: 'stem-card-identity.v1', id: String(card.id ?? ''), uuid: String(card.uuid ?? ''), version: String(card.version ?? ''), source_sha256: createHash('sha256').update(source).digest('hex') }; } async function selectedNvimState() { return callSelectedNvim('nvim_exec_lua', [` local win = vim.api.nvim_get_current_win() local buf = vim.api.nvim_win_get_buf(win) local cursor = vim.api.nvim_win_get_cursor(win) local mode = vim.api.nvim_get_mode() return { window = win, buffer = buf, path = vim.api.nvim_buf_get_name(buf), cursor = { row = cursor[1], column = cursor[2] }, mode = mode.mode, blocking = mode.blocking, changedtick = vim.api.nvim_buf_get_changedtick(buf), columns = vim.o.columns, lines = vim.o.lines } `, []]); } async function webRevision() { const files = ['index.html', 'app.js', 'app.css', 'style.css', 'card-data.json']; for (const directory of ['figures', 'debug-snapshots']) { try { for (const name of await readdir(path.join(webRoot, directory))) { files.push(path.join(directory, name)); } } catch { // A card without optional generated assets is valid. } } const revisions = await Promise.all(files.sort().map(async name => { const info = await stat(path.join(webRoot, name)); return `${name}:${info.mtimeMs}:${info.size}`; })); return revisions.join('|'); } async function cardAndProgress() { const card = await readJson(cardFile); let saved; try { saved = await readJson(progressFile); } catch (error) { if (error.code !== 'ENOENT') throw error; saved = {}; } return { card, progress: normaliseProgress(card, saved, { repository: process.env.CARD_SOURCE_REPOSITORY, revision: process.env.CARD_SOURCE_REVISION }) }; } async function updateProgress(itemId, change) { const operation = writeQueue.then(async () => { const { card, progress } = await cardAndProgress(); updateItem(progress, itemId, change); await writeJsonAtomically(progressFile, progress); return { card, progress }; }); writeQueue = operation.catch(() => undefined); return operation; } function trackerAssets() { const css = ``; const script = ``; return { css, script }; } function injectTracker(html, revision) { const usesReactRenderer = html.includes(''); const { css, script } = usesReactRenderer ? { css: '', script: '' } : trackerAssets(); const reload = ``; return html .replace('', `${css}`) .replace('', `${script}${reload}`); } app.get('/api/progress', async () => { const { card, progress } = await cardAndProgress(); return { card: progress.card, session: progress.session, catalog: catalogFromCard(card), progress: { ...progress, summary: progressSummary(progress) }, statuses: Object.fromEntries(STATUSES.map(status => [status, statusLabel[status]])) }; }); app.put('/api/progress/:itemId', async (request, reply) => { const body = request.body && typeof request.body === 'object' ? request.body : {}; if (!STATUSES.includes(body.status)) { return reply.code(400).send({ error: `status musi być jednym z: ${STATUSES.join(', ')}` }); } try { const progressActor = actor(body.actor, 'teacher'); const { progress } = await updateProgress(request.params.itemId, { status: body.status, note: body.note, actor: progressActor }); audit('progress.status', progressActor, { item_id: request.params.itemId, status: body.status, note: typeof body.note === 'string' ? body.note.slice(0, 2000) : null }); return { progress: { ...progress, summary: progressSummary(progress) } }; } catch (error) { return reply.code(error.message.startsWith('Nieznany element') ? 404 : 500).send({ error: error.message }); } }); app.get('/api/report/teams.md', async (_request, reply) => { const { card, progress } = await cardAndProgress(); return reply .type('text/markdown; charset=utf-8') .header('content-disposition', `inline; filename="${progress.session.id}-teams.md"`) .send(teamsMarkdown(card, progress)); }); app.get('/api/report/lesson.html', async (_request, reply) => reply .type('text/html; charset=utf-8') .send(auditReportHtml(stateDatabase.listEvents({ limit: 5000 })))); app.get('/api/events', async request => { const query = request.query && typeof request.query === 'object' ? request.query : {}; const afterId = Math.max(0, Number.isSafeInteger(Number(query.after_id)) ? Number(query.after_id) : 0); const limit = Math.max(1, Math.min(1000, Number.isSafeInteger(Number(query.limit)) ? Number(query.limit) : 200)); return { schema: 'stem-card-event-log.v1', events: stateDatabase.listEvents({ afterId, limit }) }; }); app.post('/api/events', async (request, reply) => { const body = request.body && typeof request.body === 'object' ? request.body : {}; const eventType = typeof body.event_type === 'string' ? body.event_type.trim() : ''; if (!/^[a-z][a-z0-9_.-]{1,79}$/.test(eventType)) { return reply.code(400).send({ error: 'event_type ma niepoprawny format.' }); } const payload = body.payload && typeof body.payload === 'object' ? body.payload : {}; if (JSON.stringify(payload).length > 16 * 1024) { return reply.code(413).send({ error: 'payload zdarzenia jest zbyt duży.' }); } return { schema: 'stem-card-event.v1', event: audit(eventType, actor(body.actor, 'api'), payload) }; }); app.post('/api/evidence', { bodyLimit: 12 * 1024 * 1024 }, async (request, reply) => { const body = request.body && typeof request.body === 'object' ? request.body : {}; const match = typeof body.data_url === 'string' ? /^data:image\/(png|jpeg|webp);base64,([A-Za-z0-9+/=]+)$/.exec(body.data_url) : null; if (!match) return reply.code(400).send({ error: 'Wymagany jest data_url obrazu PNG, JPEG albo WebP.' }); const bytes = Buffer.from(match[2], 'base64'); if (!bytes.length || bytes.length > 8 * 1024 * 1024) { return reply.code(413).send({ error: 'Zrzut musi mieć od 1 B do 8 MiB.' }); } const extension = match[1] === 'jpeg' ? 'jpg' : match[1]; const name = `${new Date().toISOString().replaceAll(':', '-')}-${randomUUID()}.${extension}`; await mkdir(evidenceRoot, { recursive: true }); await writeFile(path.join(evidenceRoot, name), bytes, { mode: 0o600 }); const evidenceRef = `/api/evidence/${name}`; const event = stateDatabase.appendEvent({ eventType: 'checkpoint.screenshot', actor: actor(body.actor, 'browser'), navigation: currentNavigation, evidenceRef, payload: { caption: typeof body.caption === 'string' ? body.caption.slice(0, 500) : '', mime: `image/${match[1]}`, size_bytes: bytes.length, snapshot_ref: typeof body.snapshot_ref === 'string' ? body.snapshot_ref : currentNavigation?.snapshot_ref } }); return reply.code(201).send({ schema: 'stem-card-evidence.v1', event }); }); app.get('/api/evidence/:name', async (request, reply) => { const name = String(request.params.name ?? ''); if (!/^[A-Za-z0-9_.+-]+\.(png|jpg|webp)$/.test(name)) return reply.code(404).send('Not found'); try { const file = path.join(evidenceRoot, name); const info = await stat(file); if (!info.isFile()) return reply.code(404).send('Not found'); return reply .type(contentTypes[path.extname(file).toLowerCase()] ?? 'application/octet-stream') .send(await readFile(file)); } catch { return reply.code(404).send('Not found'); } }); app.get('/api/card-revision', async () => ({ revision: await webRevision() })); app.get('/api/identity', async () => currentCardIdentity()); app.get('/api/checkpoint/status', async () => checkpointStatus()); app.get('/api/keyboard', async () => ({ schema: 'stem-card-keyboard.v1', hierarchy: ['card', 'task', 'block', 'phase', 'step', 'snapshot'], shortcuts: KEYBOARD_SHORTCUTS })); app.get('/api/navigation/catalog', async () => currentNavigationCatalog()); app.get('/api/navigation/current', async () => ({ current: currentNavigation, checkpoint: checkpointStatus() })); app.put('/api/navigation/current', async (request, reply) => { const body = request.body && typeof request.body === 'object' ? request.body : {}; try { const catalog = await currentNavigationCatalog(); navigationRevision += 1; currentNavigation = selectNavigation(catalog, body, navigationRevision); stateDatabase.write('navigation', currentNavigation); audit('navigation.select', currentNavigation.actor, { focus_level: currentNavigation.focus_level, sync_requested: currentNavigation.sync_requested }); return { current: currentNavigation }; } catch (error) { return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); } }); app.post('/api/navigation/move', async (request, reply) => { const body = request.body && typeof request.body === 'object' ? request.body : {}; try { const catalog = await currentNavigationCatalog(); navigationRevision += 1; currentNavigation = moveNavigation( catalog, currentNavigation, body, navigationRevision ); stateDatabase.write('navigation', currentNavigation); audit('navigation.move', currentNavigation.actor, { level: body.level ?? currentNavigation.focus_level, delta: Number(body.delta), sync_requested: false }); return { current: currentNavigation }; } catch (error) { return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); } }); app.get('/api/viewer/state', async () => ({ viewer: currentViewer })); app.put('/api/viewer/state', async (request, reply) => { const body = request.body && typeof request.body === 'object' ? request.body : {}; try { const requestedViewer = body.viewer && typeof body.viewer === 'object' ? body.viewer : body; const requestedNvim = requestedViewer.nvim && typeof requestedViewer.nvim === 'object' ? requestedViewer.nvim : null; viewerRevision += 1; currentViewer = patchViewerState(currentViewer, body, viewerRevision); stateDatabase.write('viewer', currentViewer); if (requestedNvim && ['visible', 'sync', 'control', 'expanded', 'fit'].some(key => key in requestedNvim)) { audit('viewer.nvim', actor(body.actor, 'browser'), { visible: currentViewer.nvim.visible, sync: currentViewer.nvim.sync, control: currentViewer.nvim.control, expanded: currentViewer.nvim.expanded, fit: currentViewer.nvim.fit }); } if (requestedViewer.allocator && typeof requestedViewer.allocator === 'object' && 'visible' in requestedViewer.allocator) { audit('viewer.allocator', actor(body.actor, 'browser'), { visible: currentViewer.allocator.visible }); } return { viewer: currentViewer }; } catch (error) { return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); } }); app.get('/api/control/state', async () => ({ schema: 'stem-card-control-state.v1', revision: `${navigationRevision}:${viewerRevision}`, navigation: currentNavigation, viewer: currentViewer, checkpoint: checkpointStatus() })); app.get('/api/nvim/state', async (_request, reply) => { try { return { schema: 'stem-card-nvim-state.v1', state: await selectedNvimState() }; } catch (error) { return reply.code(503).send({ error: error instanceof Error ? error.message : String(error) }); } }); app.put('/api/nvim/cursor', async (request, reply) => { const body = request.body && typeof request.body === 'object' ? request.body : {}; const row = Number(body.row); const column = Number(body.column); const requestedWindow = body.window == null ? null : Number(body.window); if (!Number.isSafeInteger(row) || row < 1 || !Number.isSafeInteger(column) || column < 0) { return reply.code(400).send({ error: 'Kursor wymaga row >= 1 i column >= 0.' }); } if (requestedWindow != null && (!Number.isSafeInteger(requestedWindow) || requestedWindow < 1)) { return reply.code(400).send({ error: 'window musi być poprawnym identyfikatorem Neovima.' }); } try { const window = requestedWindow ?? await callSelectedNvim('nvim_get_current_win'); await callSelectedNvim('nvim_win_set_cursor', [window, [row, column]]); return { schema: 'stem-card-nvim-state.v1', state: await selectedNvimState() }; } catch (error) { return reply.code(503).send({ error: error instanceof Error ? error.message : String(error) }); } }); app.post('/api/nvim/input', async (request, reply) => { const body = request.body && typeof request.body === 'object' ? request.body : {}; const keys = typeof body.keys === 'string' ? body.keys : ''; if (!keys || keys.length > 256) { return reply.code(400).send({ error: 'keys musi zawierać od 1 do 256 znaków.' }); } try { const accepted = await callSelectedNvim('nvim_input', [keys]); return { accepted, state: await selectedNvimState() }; } catch (error) { return reply.code(503).send({ error: error instanceof Error ? error.message : String(error) }); } }); app.get('/api/state', async () => { let nvim; try { nvim = { available: true, state: await selectedNvimState() }; } catch (error) { nvim = { available: false, error: error instanceof Error ? error.message : String(error) }; } return { schema: 'stem-card-state.v1', navigation: currentNavigation, viewer: currentViewer, checkpoint: checkpointStatus(), nvim, endpoints: { keyboard: '/api/keyboard', navigation_catalog: '/api/navigation/catalog', navigation_current: '/api/navigation/current', navigation_move: '/api/navigation/move', navigation_sync: '/api/navigation/sync', events: '/api/events', evidence: '/api/evidence', lesson_report: '/api/report/lesson.html', identity: '/api/identity', viewer: '/api/viewer/state', nvim_reset: '/api/nvim/reset', nvim_redraw: '/api/nvim/redraw', nvim_cursor: '/api/nvim/cursor', nvim_input: '/api/nvim/input' } }; }); app.post('/api/checkpoint/activate', async (request, reply) => { const body = request.body && typeof request.body === 'object' ? request.body : {}; const eventActor = actor(body.actor, 'browser'); audit('checkpoint.request', eventActor, { snapshot_ref: body.snapshot_ref, generation: Number(body.generation) }); try { const result = await activateCheckpoint({ repositoryRoot, cardFile, snapshotRef: body.snapshot_ref, generation: Number(body.generation), expectedBinding: body.expected_binding, expectedIdentity: body.expected_identity }); audit('checkpoint.ready', eventActor, { snapshot_ref: body.snapshot_ref, generation: Number(body.generation), status: result.status, message: result.message }); return result; } catch (error) { audit('checkpoint.failed', eventActor, { snapshot_ref: body.snapshot_ref, generation: Number(body.generation), message: error instanceof Error ? error.message : String(error) }); return reply.code(409).send({ status: 'failed', snapshot_ref: body.snapshot_ref, message: error instanceof Error ? error.message : String(error) }); } }); app.post('/api/nvim/reset', async (request, reply) => { const body = request.body && typeof request.body === 'object' ? request.body : {}; const eventActor = actor(body.actor, 'browser'); audit('nvim.reset.request', eventActor, { source: 'web.F1' }); try { await callSelectedNvim('nvim_command', ['CpuReset']); return { schema: 'stem-card-nvim-reset.v1', status: 'accepted', actor: eventActor, state: await selectedNvimState() }; } catch (error) { audit('nvim.reset.failed', eventActor, { source: 'web.F1', message: error instanceof Error ? error.message : String(error) }); return reply.code(503).send({ status: 'failed', message: error instanceof Error ? error.message : String(error) }); } }); app.post('/api/nvim/redraw', async (request, reply) => { const body = request.body && typeof request.body === 'object' ? request.body : {}; const eventActor = actor(body.actor, 'nvim'); let hostPane; try { hostPane = await zoomSelectedHostPane(); } catch (error) { hostPane = { found: false, error: error instanceof Error ? error.message : String(error) }; } await new Promise(resolve => setTimeout(resolve, 120)); try { const terminalUi = await settleSelectedNvimTerminalUi(hostPane); const externalUi = await resizeSelectedNvimUi(); await callSelectedNvim('nvim_command', ['silent! StudentLayoutFit | redraw!']); const state = await selectedNvimState(); audit('nvim.redraw', eventActor, { source: 'api', host_pane: hostPane, terminal_ui: terminalUi, external_ui: externalUi, columns: state.columns, lines: state.lines }); return { schema: 'stem-card-nvim-redraw.v1', status: 'ready', host_pane: hostPane, terminal_ui: terminalUi, external_ui: externalUi, state }; } catch (error) { return reply.code(503).send({ status: 'failed', host_pane: hostPane, message: error instanceof Error ? error.message : String(error) }); } }); app.post('/api/navigation/sync', async (request, reply) => { const body = request.body && typeof request.body === 'object' ? request.body : {}; const eventActor = actor(body.actor, 'nvim'); const resolvedFrom = currentNavigation?.focus_level ?? null; let activatedNavigation; try { const catalog = await currentNavigationCatalog(); activatedNavigation = activateNavigation( catalog, currentNavigation, eventActor, navigationRevision + 1 ); } catch (error) { return reply.code(409).send({ status: 'failed', message: error instanceof Error ? error.message : String(error) }); } navigationRevision += 1; currentNavigation = activatedNavigation; stateDatabase.write('navigation', currentNavigation); audit('navigation.activate', eventActor, { source: 'navigation.sync', resolved_from: resolvedFrom, focus_level: currentNavigation.focus_level, snapshot_ref: currentNavigation.snapshot_ref }); const snapshotRef = currentNavigation.snapshot_ref; audit('checkpoint.request', eventActor, { source: 'navigation.sync', snapshot_ref: snapshotRef }); try { const checkpoint = await activateCheckpoint({ repositoryRoot, cardFile, snapshotRef, generation: Date.now() }); audit('checkpoint.ready', eventActor, { source: 'navigation.sync', snapshot_ref: snapshotRef, status: checkpoint.status, message: checkpoint.message }); return { schema: 'stem-card-navigation-sync.v1', actor: eventActor, current: currentNavigation, checkpoint }; } catch (error) { audit('checkpoint.failed', eventActor, { source: 'navigation.sync', snapshot_ref: snapshotRef, message: error instanceof Error ? error.message : String(error) }); return reply.code(409).send({ status: 'failed', snapshot_ref: snapshotRef, message: error instanceof Error ? error.message : String(error) }); } }); app.get('/api/nvim-ui', { websocket: true }, (socket, request) => { const origin = request.headers.origin; const expectedOrigin = `http://${request.headers.host}`; if (origin && origin !== expectedOrigin) { socket.close(1008, 'Origin not allowed'); return; } attachNvimUi(socket); }); app.get('/*', async (request, reply) => { const requested = request.params['*'] || 'index.html'; const relative = requested === '' ? 'index.html' : requested; const target = path.resolve(webRoot, relative); if (target !== webRoot && !target.startsWith(`${webRoot}${path.sep}`)) return reply.code(403).send('Forbidden'); try { const info = await stat(target); if (!info.isFile()) return reply.code(404).send('Not found'); if (path.basename(target) === 'index.html') { return reply .type('text/html; charset=utf-8') .send(injectTracker(await readFile(target, 'utf8'), await webRevision())); } return reply .type(contentTypes[path.extname(target).toLowerCase()] ?? 'application/octet-stream') .send(await readFile(target)); } catch { return reply.code(404).send('Not found'); } }); let socketServer = null; app.addHook('onClose', async () => { if (socketServer) { await new Promise(resolve => socketServer.close(resolve)); } await rm(apiSocket, { force: true }); stateDatabase.close(); }); await restoreNavigation(); await app.listen({ host, port }); await rm(apiSocket, { force: true }); socketServer = createServer(app.routing); await new Promise((resolve, reject) => { socketServer.once('error', reject); socketServer.listen(apiSocket, () => { socketServer.off('error', reject); resolve(); }); });