import { Server, LIST_ARG, decodeServer, encodeBinaryInput, encodeCommand, encodeInit, encodeInput, encodeResize, parseListOutput, stripAnsi, tokenUrl, wsUrl, FlowControl, } from './protocol.js'; export class AuthExpiredError extends Error {} export class UnauthorizedError extends Error {} // GET /token. Through NPM this also exercises the Authelia gate, so it doubles // as the session probe: a redirect means the SSO session expired. export async function fetchToken() { const res = await fetch(tokenUrl(window.location), { cache: 'no-store', credentials: 'same-origin', redirect: 'manual', }); if (res.type === 'opaqueredirect') { // Any request can discover an expired Authelia session; the header shows // "Sign in again" when this fires (devdesk#13). window.dispatchEvent(new CustomEvent('devdesk:auth', { detail: 'expired' })); throw new AuthExpiredError('SSO session expired'); } if (res.status === 401 || res.status === 403 || res.status === 407) { throw new UnauthorizedError(`HTTP ${res.status}`); } if (!res.ok) throw new Error(`token request failed: HTTP ${res.status}`); window.dispatchEvent(new CustomEvent('devdesk:auth', { detail: 'ok' })); const body = await res.json(); return body.token || ''; } // One WebSocket to ttyd, i.e. one `devdesk attach ` process on the server. export class TtydConnection { constructor({ arg, size, onOpen, onOutput, onClose, onPreferences }) { Object.assign(this, { arg, size, onOpen, onOutput, onClose, onPreferences }); this.flow = new FlowControl(); this.socket = null; this.disposed = false; } async open() { const token = await fetchToken(); if (this.disposed) return; const socket = new WebSocket(wsUrl(window.location, this.arg), ['tty']); socket.binaryType = 'arraybuffer'; this.socket = socket; socket.onopen = () => { const { cols, rows } = this.size(); socket.send(encodeInit(token, cols, rows)); this.onOpen?.(); }; socket.onmessage = (event) => { const { type, payload } = decodeServer(event.data); if (type === Server.OUTPUT) this.onOutput?.(payload); else if (type === Server.SET_PREFERENCES) this.onPreferences?.(payload); // SET_WINDOW_TITLE only carries ttyd's " ()"; real titles come via OSC. }; socket.onclose = (event) => { if (this.socket === socket) this.socket = null; if (!this.disposed) this.onClose?.(event.code, event.reason); }; } get isOpen() { return this.socket?.readyState === WebSocket.OPEN; } send(frame) { if (this.isOpen) this.socket.send(frame); } sendInput(text) { this.send(encodeInput(text)); } sendBinary(binary) { this.send(encodeBinaryInput(binary)); } resize(cols, rows) { this.send(encodeResize(cols, rows)); } // Called with each output chunk; returns a write-callback when tracking is needed. flowWrite(length) { const { command, track } = this.flow.onData(length); if (command) this.send(encodeCommand(command)); if (!track) return undefined; return () => { const resume = this.flow.release(); if (resume) this.send(encodeCommand(resume)); }; } dispose() { this.disposed = true; if (this.socket) { this.socket.onclose = null; this.socket.close(1000); this.socket = null; } } } // Side-channel commands each occupy a ttyd client slot and a server process: // run at most two at a time (devdesk#8). let active = 0; const waiting = []; async function limited(task) { if (active >= 2) await new Promise((resolve) => waiting.push(resolve)); active += 1; try { return await task(); } finally { active -= 1; waiting.shift()?.(); } } // Run a short-lived `devdesk attach ` and return its output as text. // ttyd is the only server-side endpoint, so session actions travel this way. export function runCommand(args, timeoutMs = 8000) { return limited(() => runCommandNow(args, timeoutMs)); } async function runCommandNow(args, timeoutMs) { const token = await fetchToken(); return new Promise((resolve, reject) => { const socket = new WebSocket(wsUrl(window.location, args), ['tty']); socket.binaryType = 'arraybuffer'; const chunks = []; const timer = setTimeout(() => { socket.close(); reject(new Error('devdesk did not answer')); }, timeoutMs); socket.onopen = () => socket.send(encodeInit(token, 240, 24)); socket.onmessage = (event) => { const { type, payload } = decodeServer(event.data); if (type === Server.OUTPUT) chunks.push(payload.slice()); }; socket.onerror = () => {}; socket.onclose = () => { clearTimeout(timer); resolve(new TextDecoder().decode(concat(chunks))); }; }); } // Sessions, projects and per-session Claude context (`devdesk attach --list`). export async function fetchSessionList() { return parseListOutput(await runCommand([LIST_ARG])); } // Firing/pending Grafana alerts (`devdesk attach --alerts`). export async function fetchAlerts() { return parseListOutput(await runCommand(['--alerts'])); } function toBase64(bytes) { let binary = ''; for (let i = 0; i < bytes.length; i += 0x8000) { binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); } return btoa(binary); } const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export const UPLOAD_MAX = 25 * 1024 * 1024; // Upload a file into the session's upload folder through ttyd: wait for the // server's ready line, stream the file as base64 input in slices (never the // whole file in memory), read back {ok, path}. Fails on 20 s of inactivity // rather than a fixed total, so slow links still work (devdesk#9). export function uploadFile(session, file, onProgress) { if (!file.size) return Promise.reject(new Error('file is empty')); if (file.size > UPLOAD_MAX) return Promise.reject(new Error('larger than 25 MB')); return limited(() => uploadNow(session, file, onProgress)); } async function uploadNow(session, file, onProgress) { const token = await fetchToken(); const name = (file.name || 'pasted-image.png').slice(-100); const slice = 3 * 16 * 1024; // multiple of 3: base64 of slices concatenates cleanly return new Promise((resolve, reject) => { const socket = new WebSocket(wsUrl(window.location, ['--upload', session, name, String(file.size)]), ['tty']); socket.binaryType = 'arraybuffer'; const decoder = new TextDecoder(); let output = ''; let sending = false; let idle = 0; const touch = () => { clearTimeout(idle); idle = setTimeout(() => socket.close(), 20000); }; const send = async () => { for (let offset = 0; offset < file.size; offset += slice) { while (socket.bufferedAmount > 1 << 20) await sleep(20); if (socket.readyState !== WebSocket.OPEN) return; const bytes = new Uint8Array(await file.slice(offset, offset + slice).arrayBuffer()); socket.send(encodeInput(toBase64(bytes))); touch(); onProgress?.(Math.min(1, (offset + slice) / file.size)); } }; touch(); socket.onopen = () => socket.send(encodeInit(token, 240, 24)); socket.onmessage = (event) => { touch(); const { type, payload } = decodeServer(event.data); if (type !== Server.OUTPUT) return; output += decoder.decode(payload, { stream: true }); if (!sending && output.includes('DEVDESK-UPLOAD-READY')) { sending = true; send(); } }; socket.onerror = () => {}; socket.onclose = () => { clearTimeout(idle); const text = stripAnsi(output.replace(/^[\s\S]*DEVDESK-UPLOAD-READY/, '')); try { const result = parseListOutput(text); if (result.ok && result.path) { resolve(result.path); return; } } catch { // fall through to the error message } const message = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).pop(); reject(new Error((message || 'upload failed').replace(/^devdesk:\s*/, ''))); }; }); } // Git + Gitea CI status for a session's folder (`devdesk attach --repo NAME`). export async function fetchRepo(session) { return parseListOutput(await runCommand(['--repo', session])); } // `devdesk attach --new|--rename|--end …`: resolves on {"ok":true}, otherwise // rejects with devdesk's own error message. export async function sessionAction(action, ...args) { const output = stripAnsi(await runCommand([`--${action}`, ...args])).trim(); if (/"ok"\s*:\s*true/.test(output)) return; const message = output.split(/\r?\n/).filter(Boolean).pop() || 'no response'; throw new Error(message.replace(/^devdesk:\s*/, '')); } function concat(chunks) { const total = chunks.reduce((n, c) => n + c.length, 0); const out = new Uint8Array(total); let offset = 0; for (const c of chunks) { out.set(c, offset); offset += c.length; } return out; }