2026-10-06 23:11:07 -04:00
|
|
|
import {
|
|
|
|
|
Server, LIST_ARG, decodeServer, encodeBinaryInput, encodeCommand, encodeInit,
|
2026-10-07 03:03:09 -04:00
|
|
|
encodeInput, encodeResize, parseListOutput, stripAnsi, tokenUrl, wsUrl, FlowControl,
|
2026-10-06 23:11:07 -04:00
|
|
|
} 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',
|
|
|
|
|
});
|
2026-10-07 23:34:59 -04:00
|
|
|
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');
|
|
|
|
|
}
|
2026-10-06 23:11:07 -04:00
|
|
|
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}`);
|
2026-10-07 23:34:59 -04:00
|
|
|
window.dispatchEvent(new CustomEvent('devdesk:auth', { detail: 'ok' }));
|
2026-10-06 23:11:07 -04:00
|
|
|
const body = await res.json();
|
|
|
|
|
return body.token || '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// One WebSocket to ttyd, i.e. one `devdesk attach <arg>` 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 "<command> (<host>)"; 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 20:15:47 -04:00
|
|
|
// 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()?.();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 03:03:09 -04:00
|
|
|
// Run a short-lived `devdesk attach <args…>` and return its output as text.
|
|
|
|
|
// ttyd is the only server-side endpoint, so session actions travel this way.
|
2026-10-07 20:15:47 -04:00
|
|
|
export function runCommand(args, timeoutMs = 8000) {
|
|
|
|
|
return limited(() => runCommandNow(args, timeoutMs));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runCommandNow(args, timeoutMs) {
|
2026-10-06 23:11:07 -04:00
|
|
|
const token = await fetchToken();
|
|
|
|
|
return new Promise((resolve, reject) => {
|
2026-10-07 03:03:09 -04:00
|
|
|
const socket = new WebSocket(wsUrl(window.location, args), ['tty']);
|
2026-10-06 23:11:07 -04:00
|
|
|
socket.binaryType = 'arraybuffer';
|
|
|
|
|
const chunks = [];
|
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
socket.close();
|
2026-10-07 03:03:09 -04:00
|
|
|
reject(new Error('devdesk did not answer'));
|
2026-10-06 23:11:07 -04:00
|
|
|
}, 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);
|
2026-10-07 03:03:09 -04:00
|
|
|
resolve(new TextDecoder().decode(concat(chunks)));
|
2026-10-06 23:11:07 -04:00
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 03:03:09 -04:00
|
|
|
// Sessions, projects and per-session Claude context (`devdesk attach --list`).
|
|
|
|
|
export async function fetchSessionList() {
|
|
|
|
|
return parseListOutput(await runCommand([LIST_ARG]));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 19:26:06 -04:00
|
|
|
// 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));
|
|
|
|
|
|
2026-10-07 20:15:47 -04:00
|
|
|
export const UPLOAD_MAX = 25 * 1024 * 1024;
|
|
|
|
|
|
2026-10-07 19:26:06 -04:00
|
|
|
// Upload a file into the session's upload folder through ttyd: wait for the
|
2026-10-07 20:15:47 -04:00
|
|
|
// 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) {
|
2026-10-07 19:26:06 -04:00
|
|
|
const token = await fetchToken();
|
|
|
|
|
const name = (file.name || 'pasted-image.png').slice(-100);
|
2026-10-07 20:15:47 -04:00
|
|
|
const slice = 3 * 16 * 1024; // multiple of 3: base64 of slices concatenates cleanly
|
2026-10-07 19:26:06 -04:00
|
|
|
return new Promise((resolve, reject) => {
|
2026-10-07 20:15:47 -04:00
|
|
|
const socket = new WebSocket(wsUrl(window.location, ['--upload', session, name, String(file.size)]), ['tty']);
|
2026-10-07 19:26:06 -04:00
|
|
|
socket.binaryType = 'arraybuffer';
|
|
|
|
|
const decoder = new TextDecoder();
|
|
|
|
|
let output = '';
|
|
|
|
|
let sending = false;
|
2026-10-07 20:15:47 -04:00
|
|
|
let idle = 0;
|
|
|
|
|
const touch = () => {
|
|
|
|
|
clearTimeout(idle);
|
|
|
|
|
idle = setTimeout(() => socket.close(), 20000);
|
|
|
|
|
};
|
2026-10-07 19:26:06 -04:00
|
|
|
const send = async () => {
|
2026-10-07 20:15:47 -04:00
|
|
|
for (let offset = 0; offset < file.size; offset += slice) {
|
2026-10-07 19:26:06 -04:00
|
|
|
while (socket.bufferedAmount > 1 << 20) await sleep(20);
|
|
|
|
|
if (socket.readyState !== WebSocket.OPEN) return;
|
2026-10-07 20:15:47 -04:00
|
|
|
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));
|
2026-10-07 19:26:06 -04:00
|
|
|
}
|
|
|
|
|
};
|
2026-10-07 20:15:47 -04:00
|
|
|
touch();
|
2026-10-07 19:26:06 -04:00
|
|
|
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
|
|
|
|
|
socket.onmessage = (event) => {
|
2026-10-07 20:15:47 -04:00
|
|
|
touch();
|
2026-10-07 19:26:06 -04:00
|
|
|
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 = () => {
|
2026-10-07 20:15:47 -04:00
|
|
|
clearTimeout(idle);
|
2026-10-07 19:26:06 -04:00
|
|
|
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*/, '')));
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 13:09:32 -04:00
|
|
|
// Git + Gitea CI status for a session's folder (`devdesk attach --repo NAME`).
|
|
|
|
|
export async function fetchRepo(session) {
|
|
|
|
|
return parseListOutput(await runCommand(['--repo', session]));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 03:03:09 -04:00
|
|
|
// `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*/, ''));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-06 23:11:07 -04:00
|
|
|
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;
|
|
|
|
|
}
|