Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/pwa/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/pwa/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"@libsql/client": "^0.14.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.1.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"cookie-parser": "^1.4.7",
"express": "^4.21.2",
"web-push": "^3.6.7"
Expand Down
7 changes: 7 additions & 0 deletions apps/pwa/src/migrations/007_session_size.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Terminal geometry of the machine being mirrored. The web mirror is a real
-- terminal emulator, so it has to run at the same size as the tty on the other
-- end: a page 80 columns wide replaying 120-column output wraps every line in
-- the wrong place, and anything that redraws in place (spinners, progress,
-- boxes) lands as garbage.
ALTER TABLE cli_sessions ADD COLUMN cols INTEGER;
ALTER TABLE cli_sessions ADD COLUMN rows INTEGER;
228 changes: 178 additions & 50 deletions apps/pwa/src/routes/sessions.mjs

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions apps/pwa/src/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ app.use(express.static(path.join(config.root, "public"), { maxAge: "1h" }));
// the @simplewebauthn/browser UMD bundle, served from node_modules (no CDN)
app.get("/vendor/simplewebauthn-browser.umd.js", (_req, res) =>
res.sendFile(path.join(config.root, "node_modules/@simplewebauthn/browser/dist/bundle/index.umd.min.js")));
// xterm.js — /sessions/:id is a real terminal emulator, not a <div> of text.
// Same deal as above: shipped from node_modules, never a CDN.
const vendor = {
"/vendor/xterm.js": "node_modules/@xterm/xterm/lib/xterm.js",
"/vendor/xterm.css": "node_modules/@xterm/xterm/css/xterm.css",
"/vendor/xterm-addon-fit.js": "node_modules/@xterm/addon-fit/lib/addon-fit.js",
};
for (const [route, file] of Object.entries(vendor)) {
app.get(route, (_req, res) => res.sendFile(path.join(config.root, file), { maxAge: "1h" }));
}

app.get("/healthz", (_req, res) => res.json({ ok: true, env: config.env }));

Expand Down
33 changes: 33 additions & 0 deletions apps/pwa/test/sessions.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,39 @@ test("sessions: register, then output lands in the scrollback in order", skip, a
assert.deepEqual(rows.map((r) => Number(r.seq)), [1, 2], "seq must be monotonic per session");
});

test("sessions: terminal geometry is recorded and updated by later output", skip, async () => {
const { one, get } = await app();

const reg = await one("/api/sessions", { name: "sized", cols: 120, rows: 34 });
const first = await get(`SELECT cols, rows FROM cli_sessions WHERE id = ?`, [reg.body.id]);
assert.equal(Number(first.cols), 120);
assert.equal(Number(first.rows), 34);

// A window resized mid-run: the next flush carries the new size, and the
// browser needs it — the mirror is a real emulator, so stale geometry wraps
// every subsequent line at the wrong column.
await one(`/api/sessions/${reg.body.id}/output`, { chunk: "wide\n", cols: 200, rows: 50 });
const after = await get(`SELECT cols, rows FROM cli_sessions WHERE id = ?`, [reg.body.id]);
assert.equal(Number(after.cols), 200);
assert.equal(Number(after.rows), 50);

// Output with no size attached (piped, or an older CLI) must not wipe it.
await one(`/api/sessions/${reg.body.id}/output`, { chunk: "quiet\n" });
const kept = await get(`SELECT cols, rows FROM cli_sessions WHERE id = ?`, [reg.body.id]);
assert.equal(Number(kept.cols), 200);
assert.equal(Number(kept.rows), 50);
});

test("sessions: a nonsense geometry is refused rather than rendered", skip, async () => {
const { one, get } = await app();

// A browser asked to build a screen buffer this size just hangs.
const reg = await one("/api/sessions", { name: "bogus", cols: 999999, rows: -3 });
const row = await get(`SELECT cols, rows FROM cli_sessions WHERE id = ?`, [reg.body.id]);
assert.equal(row.cols, null);
assert.equal(row.rows, null);
});

test("sessions: another user's key cannot read or write the session", skip, async () => {
const { one, two } = await app();

Expand Down
37 changes: 35 additions & 2 deletions src/mirror.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,25 @@ export function createMirror({
} catch { return null; }
};

// The browser runs a real terminal emulator over this stream, so it has to
// know how wide the tty on this end is — otherwise every line wraps at the
// wrong column and anything that redraws in place lands crooked.
//
// Piped output (CI, `mosh | tee`) has no size at all, and half a size is no
// use to an emulator, so send nothing rather than nulls: the app keeps
// whatever it already had and the page falls back to filling its box.
const size = () => {
const { columns, rows } = process.stdout;
return columns && rows ? { cols: columns, rows } : {};
};

async function flush() {
flushTimer = null;
if (!sessionId || (!pending && !engineDirty)) return;
const chunk = pending;
pending = "";
engineDirty = false;
await post(`/api/sessions/${sessionId}/output`, { chunk, engine });
await post(`/api/sessions/${sessionId}/output`, { chunk, engine, ...size() });
}

function schedule() {
Expand All @@ -75,6 +87,19 @@ export function createMirror({
schedule();
}

// Dragging a window edge fires `resize` continuously, so settle first and
// send one empty post carrying the final geometry.
let resizeTimer = null;
function onResize() {
if (stopped || !sessionId) return;
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
resizeTimer = null;
post(`/api/sessions/${sessionId}/output`, { chunk: "", engine, ...size() });
}, 120);
resizeTimer.unref?.();
}

// Long-poll for commands typed on the web. One request parks on the server
// until something is queued, so a command lands in well under a second
// without us hammering the API.
Expand Down Expand Up @@ -106,9 +131,14 @@ export function createMirror({
host: os.hostname(),
version,
cwd,
...size(),
});
if (!r?.id) return false;
sessionId = r.id;
// A resize carries no output of its own, so nudge a flush: the new
// geometry rides the next post and the watching browser reshapes with us
// instead of waiting for whatever gets printed next.
process.stdout.on("resize", onResize);
pump();
return true;
},
Expand All @@ -120,14 +150,17 @@ export function createMirror({
if (!sessionId || stopped) return;
stopped = true;
clearTimeout(flushTimer);
clearTimeout(resizeTimer);
flushTimer = null;
resizeTimer = null;
process.stdout.off?.("resize", onResize);
try { poll?.abort(); } catch { /* already gone */ }
// Flush whatever is left before saying goodbye, so the last thing you
// did is visible in the mirror rather than lost with the process.
if (pending) {
const chunk = pending;
pending = "";
await post(`/api/sessions/${sessionId}/output`, { chunk, engine });
await post(`/api/sessions/${sessionId}/output`, { chunk, engine, ...size() });
}
await post(`/api/sessions/${sessionId}/end`, {});
},
Expand Down
68 changes: 66 additions & 2 deletions test/mirror.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ const json = (body) => new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
});

// Terminal geometry rides along with every post, but only when there is a tty
// to measure — under the test runner there isn't. Compare the envelope alone so
// these tests say the same thing whether or not stdout happens to be a terminal.
const envelope = ({ chunk, engine }) => ({ chunk, engine });

async function waitFor(predicate) {
const deadline = Date.now() + 1000;
while (!predicate() && Date.now() < deadline) {
Expand Down Expand Up @@ -38,18 +43,77 @@ test("setEngine sends an engine-only update to the session mirror", async () =>
await waitFor(() => requests.some((request) => request.pathname.endsWith("/output")));

assert.deepEqual(
requests.find((request) => request.pathname.endsWith("/output"))?.body,
envelope(requests.find((request) => request.pathname.endsWith("/output"))?.body),
{ chunk: "", engine: "claude" },
);

mirror.setEngine(null);
await waitFor(() => requests.filter((request) => request.pathname.endsWith("/output")).length === 2);
assert.deepEqual(
requests.filter((request) => request.pathname.endsWith("/output")).map((request) => request.body),
requests.filter((request) => request.pathname.endsWith("/output")).map((request) => envelope(request.body)),
[
{ chunk: "", engine: "claude" },
{ chunk: "", engine: null },
],
);
await mirror.stop();
});

test("the mirror reports terminal geometry, and sends none when there is no tty", async () => {
const requests = [];
const fetchImpl = async (url, options = {}) => {
const pathname = new URL(url).pathname;
if (pathname === "/api/sessions") { requests.push({ pathname, body: JSON.parse(options.body) }); return json({ id: "session-1" }); }
if (pathname.endsWith("/commands")) {
return new Promise((resolve, reject) => {
options.signal.addEventListener("abort", () => reject(options.signal.reason), { once: true });
});
}
requests.push({ pathname, body: JSON.parse(options.body) });
return json({ ok: true });
};
const start = async () => {
const mirror = createMirror({
credentials: { api: "https://app.example.test", token: "mck_test" },
fetchImpl,
});
assert.equal(await mirror.start(), true);
return mirror;
};

// Pretend stdout is a 132×40 terminal. The page runs the emulator at exactly
// this size, so it has to arrive with the very first post — a browser that
// opens before the first resize would otherwise render at the wrong width.
const original = { columns: process.stdout.columns, rows: process.stdout.rows };
process.stdout.columns = 132;
process.stdout.rows = 40;
try {
const mirror = await start();
mirror.write("hello\n");
await waitFor(() => requests.some((request) => request.pathname.endsWith("/output")));
const register = requests.find((request) => request.pathname === "/api/sessions").body;
assert.equal(register.cols, 132);
assert.equal(register.rows, 40);
const output = requests.find((request) => request.pathname.endsWith("/output")).body;
assert.equal(output.cols, 132);
assert.equal(output.rows, 40);
await mirror.stop();
} finally {
process.stdout.columns = original.columns;
process.stdout.rows = original.rows;
}

// Piped output has no geometry. Sending nulls would be worse than sending
// nothing: the app would have to tell "unknown" apart from "unchanged".
requests.length = 0;
delete process.stdout.columns;
delete process.stdout.rows;
const piped = await start();
piped.write("hello\n");
await waitFor(() => requests.some((request) => request.pathname.endsWith("/output")));
for (const request of requests) {
assert.equal("cols" in request.body, false, `${request.pathname} must not carry cols`);
assert.equal("rows" in request.body, false, `${request.pathname} must not carry rows`);
}
await piped.stop();
});
Loading