CluPilotCloud/resources/js/terminal.js

87 lines
3.8 KiB
JavaScript

/*
* Das Terminal im eigenen Fenster.
*
* Eigener Einstiegspunkt, nicht Teil von app.js: nicht weil diese Seite ohne
* Livewire liefe — sie ist eine Vollseiten-Livewire-Komponente und lädt
* `<x-shell.head>`, das app.js über bare.blade.php ohnehin mitzieht —,
* sondern damit der Terminalcode (xterm.js) nicht in app.js landet und die
* übrigen Konsolenseiten nicht mitschleppen, und umgekehrt app.js nicht in
* dieses schlanke Fenster.
*/
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
const root = document.querySelector('[data-terminal]')
if (root) {
const splash = root.querySelector('[data-terminal-splash]')
const screen = root.querySelector('[data-terminal-screen]')
// Der Name allein — nie die Tunneladresse oder der Schlüssel, die sieht
// dieses Fenster ohnehin nie. Nur in den Meldungen unten, damit klar ist,
// WELCHER Host gemeint ist, falls je mehr als ein Terminalfenster offen steht.
const host = root.dataset.host
const term = new Terminal({ convertEol: true, fontFamily: 'ui-monospace, monospace', fontSize: 13 })
const fit = new FitAddon()
term.loadAddon(fit)
const scheme = location.protocol === 'https:' ? 'wss' : 'ws'
// Das Ticket reist als Unterprotokoll, NICHT in der Adresszeile: die Adresse
// eines Upgrade-Antrags schreibt jeder Reverse Proxy auf der Strecke mit
// (nginx hier, Caddy davor), ein Kopffeld nicht. Die Brücke spiegelt den
// Wert zurück, sonst bricht der Browser den Handschlag ab.
//
// `/terminal/ws` steht fest an der Wurzel und folgt AdminArea::prefix()
// bewusst nicht: die Stelle wird von nginx durchgereicht und sieht PHP nie,
// also muss sie ein fester Text sein — in docker/nginx/default.conf steht
// derselbe, mit derselben Begründung.
const socket = new WebSocket(`${scheme}://${location.host}/terminal/ws`, [root.dataset.ticket])
socket.binaryType = 'arraybuffer'
// Der Vorspann weicht beim ERSTEN BYTE, nicht bei `onopen`.
let opened = false
const reveal = () => {
if (opened) return
opened = true
splash.classList.add('hidden')
screen.classList.remove('hidden')
term.open(screen)
fit.fit()
}
// Ob je ein Byte ankam — getrennt von `opened`, denn auch ein Verbindungsfehler
// lässt den Vorspann weichen, um die Fehlermeldung zu zeigen, ohne dass je eine
// Sitzung entstand. Ohne dieses Merkmal feuert ein fehlgeschlagener Socket
// `error` UND danach `close`, und `onclose` behauptete eine beendete Sitzung,
// die nie begonnen hat.
let received = false
socket.onmessage = (event) => {
received = true
reveal()
// Kommt vom Container ein Textrahmen statt eines Binärrahmens, ist
// `new Uint8Array(event.data)` bei einem String ein leeres Array —
// `binaryType = 'arraybuffer'` oben regelt nur Binärrahmen.
//
// Die Brücke hat sich inzwischen festgelegt: sie sendet ausschließlich
// Binärrahmen (docker/terminal/bridge.py, to_browser). Diese Verzweigung
// bleibt trotzdem stehen — sie kostet nichts und ist die einzige Stelle,
// an der ein Rückfall nicht als stumme leere Ausgabe ankäme.
term.write(typeof event.data === 'string' ? event.data : new Uint8Array(event.data))
}
term.onData((data) => socket.readyState === WebSocket.OPEN && socket.send(data))
socket.onclose = () => {
if (!received) return
term.write(`\r\n\x1b[31m— Verbindung zu ${host} beendet —\x1b[0m\r\n`)
}
socket.onerror = () => {
reveal()
term.write(`\r\n\x1b[31m— Verbindung zu ${host} nicht möglich —\x1b[0m\r\n`)
}
addEventListener('resize', () => opened && fit.fit())
}