Files
devdesk/web/src/connection.js
T

146 lines
4.8 KiB
JavaScript
Raw Normal View History

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') 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 <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;
}
}
}
// 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.
export async function runCommand(args, timeoutMs = 8000) {
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]));
}
// `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;
}