import { Server, LIST_ARG, decodeServer, encodeBinaryInput, encodeCommand, encodeInit, encodeInput, encodeResize, parseListOutput, 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') 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}`); 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; } } } // Ask the server for sessions + projects: a short-lived `devdesk attach --list`. export async function fetchSessionList(timeoutMs = 8000) { const token = await fetchToken(); return new Promise((resolve, reject) => { const socket = new WebSocket(wsUrl(window.location, LIST_ARG), ['tty']); socket.binaryType = 'arraybuffer'; const chunks = []; const timer = setTimeout(() => { socket.close(); reject(new Error('session list timed out')); }, 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); try { const text = new TextDecoder().decode(concat(chunks)); resolve(parseListOutput(text)); } catch (err) { reject(err); } }; }); } 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; }