289 lines
11 KiB
JavaScript
289 lines
11 KiB
JavaScript
// Runs the WASM client in #chess-terminal: its data-wasm-url names the binary,
|
|
// #chess-terminal-screen holds xterm, #chess-terminal-status (-state and
|
|
// -progress spans) reports loading. xterm with its fit and WebGL addons and
|
|
// wasm_exec.js load first. lixen.com wraps this same file in its own page.
|
|
(() => {
|
|
'use strict';
|
|
|
|
const host = document.getElementById('chess-terminal');
|
|
if (!host) return;
|
|
|
|
const screenEl = document.getElementById('chess-terminal-screen');
|
|
const status = document.getElementById('chess-terminal-status');
|
|
const stateEl = document.getElementById('chess-terminal-state');
|
|
const progress = document.getElementById('chess-terminal-progress');
|
|
const wasmURL = host.dataset.wasmUrl;
|
|
|
|
const setState = (text, isError) => {
|
|
stateEl.textContent = text;
|
|
progress.textContent = '';
|
|
status.hidden = !text && !isError;
|
|
status.classList.toggle('is-error', !!isError);
|
|
};
|
|
|
|
// A page script that should have removed .no-js may have been blocked; its
|
|
// no-JS message would then cover a working terminal.
|
|
document.documentElement.classList.remove('no-js');
|
|
|
|
if (typeof Terminal === 'undefined' || typeof FitAddon === 'undefined') {
|
|
setState('Terminal library failed to load. Reload the page to retry.', true);
|
|
return;
|
|
}
|
|
if (typeof Go === 'undefined' || !globalThis.fs) {
|
|
setState('WebAssembly support files failed to load. Reload the page to retry.', true);
|
|
return;
|
|
}
|
|
|
|
// The theme's colours are the host's CSS custom properties.
|
|
const css = getComputedStyle(host);
|
|
const token = (name, fallback) => css.getPropertyValue(name).trim() || fallback;
|
|
|
|
const term = new Terminal({
|
|
cursorBlink: true,
|
|
convertEol: true,
|
|
scrollback: 1000,
|
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
|
|
fontSize: 14,
|
|
theme: {
|
|
background: token('--term-bg', '#1a1b26'),
|
|
foreground: token('--term-fg', '#a9b1d6'),
|
|
cursor: token('--term-cursor', '#a9b1d6'),
|
|
// xterm 5 silently ignores the old `selection` key.
|
|
selectionBackground: token('--term-selection', 'rgba(169,177,214,0.35)')
|
|
}
|
|
});
|
|
|
|
const fitAddon = new FitAddon.FitAddon();
|
|
term.loadAddon(fitAddon);
|
|
|
|
// The client pages long output by the terminal's size, read from here
|
|
// (display/termsize_wasm.go).
|
|
globalThis.chessTerminal = {
|
|
get rows() { return term.rows; },
|
|
get cols() { return term.cols; }
|
|
};
|
|
|
|
term.open(screenEl);
|
|
|
|
// WebGL must load after open(); the DOM renderer is the fallback.
|
|
try {
|
|
const webgl = new WebglAddon.WebglAddon();
|
|
webgl.onContextLoss(() => webgl.dispose());
|
|
term.loadAddon(webgl);
|
|
} catch (e) {
|
|
console.warn('xterm: WebGL renderer unavailable, using the DOM renderer', e);
|
|
}
|
|
|
|
// xterm captures Tab, so Escape, which the line editor ignores, is the
|
|
// keyboard way out of the terminal (WCAG 2.1.2).
|
|
term.attachCustomKeyEventHandler((e) => {
|
|
if (e.type === 'keydown' && e.key === 'Escape') {
|
|
term.blur();
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
// fit() floors rows against the cell height it knew at call time, which a
|
|
// zoom step can leave stale: drop a row while .xterm-screen (rows x cell
|
|
// height) paints taller than the box.
|
|
const availHeight = () => {
|
|
const cs = getComputedStyle(screenEl);
|
|
return screenEl.clientHeight
|
|
- parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
|
|
};
|
|
|
|
const snapRows = () => {
|
|
const scr = term.element && term.element.querySelector('.xterm-screen');
|
|
if (!scr || !term.rows) return;
|
|
const cell = scr.getBoundingClientRect().height / term.rows;
|
|
if (!cell) return;
|
|
const max = Math.max(1, Math.floor(availHeight() / cell));
|
|
if (term.rows > max) term.resize(term.cols, max);
|
|
};
|
|
|
|
const fitNow = (passes) => {
|
|
fitAddon.fit();
|
|
snapRows();
|
|
if (passes > 1) requestAnimationFrame(() => fitNow(passes - 1));
|
|
};
|
|
|
|
let fitTimer = 0;
|
|
const scheduleFit = () => {
|
|
clearTimeout(fitTimer);
|
|
fitTimer = setTimeout(() => fitNow(2), 50);
|
|
};
|
|
|
|
// A resolution query matches one ratio only, so re-arm on every change.
|
|
let dprQuery = null;
|
|
const onDprChange = () => { watchDpr(); scheduleFit(); };
|
|
const watchDpr = () => {
|
|
if (dprQuery) dprQuery.removeEventListener('change', onDprChange);
|
|
dprQuery = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
|
|
dprQuery.addEventListener('change', onDprChange);
|
|
};
|
|
|
|
fitNow(2);
|
|
new ResizeObserver(scheduleFit).observe(screenEl);
|
|
window.addEventListener('resize', scheduleFit);
|
|
watchDpr();
|
|
if (document.fonts && document.fonts.ready) document.fonts.ready.then(scheduleFit);
|
|
|
|
screenEl.addEventListener('click', () => term.focus());
|
|
|
|
// Go's js/wasm routes os.Stdout and os.Stderr through fs.write, runtime
|
|
// prints (panics) through fs.writeSync and os.Stdin through fs.read: fds
|
|
// 0-2 go to xterm, the rest to wasm_exec.js's own implementations.
|
|
const fs = globalThis.fs;
|
|
const nativeWrite = fs.write;
|
|
const nativeWriteSync = fs.writeSync;
|
|
const nativeRead = fs.read;
|
|
|
|
const encoder = new TextEncoder();
|
|
// One streaming decoder per fd: a UTF-8 sequence split across two writes
|
|
// must not lend its tail to the other stream.
|
|
const decoders = { 1: new TextDecoder('utf-8'), 2: new TextDecoder('utf-8') };
|
|
const toTerminal = (fd, bytes) => term.write(decoders[fd].decode(bytes, { stream: true }));
|
|
|
|
fs.writeSync = function (fd, buf) {
|
|
if (fd === 1 || fd === 2) {
|
|
toTerminal(fd, buf);
|
|
return buf.length;
|
|
}
|
|
return nativeWriteSync.call(this, fd, buf);
|
|
};
|
|
|
|
fs.write = function (fd, buf, offset, length, position, callback) {
|
|
if (fd === 1 || fd === 2) {
|
|
toTerminal(fd, buf.subarray(offset, offset + length));
|
|
callback(null, length); // synchronous, exactly as wasm_exec.js does
|
|
return;
|
|
}
|
|
nativeWrite.call(this, fd, buf, offset, length, position, callback);
|
|
};
|
|
|
|
// Line editor. Input is taken only while Go blocks in read: keys buffered
|
|
// across a read would echo twice. Per character, so a pasted "e2e4\r"
|
|
// submits; a multi-line paste submits its first line and drops the rest,
|
|
// which Go's next read would echo over a screen the client has repainted.
|
|
let line = '';
|
|
let pendingRead = null;
|
|
let esc = 0; // 0 none, 1 saw ESC, 2 inside CSI/SS3
|
|
|
|
term.onData((data) => {
|
|
if (!pendingRead) return;
|
|
for (const ch of data) {
|
|
// Escape sequences (an arrow key) type nothing; CSI/SS3 end on 0x40-0x7E.
|
|
if (esc === 1) { esc = (ch === '[' || ch === 'O') ? 2 : 0; continue; }
|
|
if (esc === 2) { if (ch >= '@' && ch <= '~') esc = 0; continue; }
|
|
if (ch === '\x1b') { esc = 1; continue; }
|
|
|
|
if (ch === '\r') {
|
|
term.write('\r\n');
|
|
const resolve = pendingRead, value = line;
|
|
pendingRead = null; line = ''; esc = 0;
|
|
resolve(value);
|
|
return;
|
|
}
|
|
if (ch === '\x03') { // Ctrl-C
|
|
term.write('^C\r\n');
|
|
const resolve = pendingRead;
|
|
pendingRead = null; line = ''; esc = 0;
|
|
resolve('');
|
|
return;
|
|
}
|
|
if (ch === '\x7f' || ch === '\b') {
|
|
if (line.length > 0) { line = line.slice(0, -1); term.write('\b \b'); }
|
|
continue;
|
|
}
|
|
if (ch >= ' ' && ch <= '~') { line += ch; term.write(ch); }
|
|
}
|
|
});
|
|
|
|
// The bytes of a submitted line that did not fit the caller's buffer.
|
|
let residual = null;
|
|
|
|
fs.read = function (fd, buf, offset, length, position, callback) {
|
|
if (fd !== 0) {
|
|
nativeRead.call(this, fd, buf, offset, length, position, callback);
|
|
return;
|
|
}
|
|
const deliver = (bytes) => {
|
|
const n = Math.min(length, bytes.length);
|
|
buf.set(bytes.subarray(0, n), offset);
|
|
residual = n < bytes.length ? bytes.subarray(n) : null;
|
|
callback(null, n);
|
|
};
|
|
if (residual) {
|
|
// Resolving inside the fs.read call would re-enter the Go runtime
|
|
// through _resume() while it is still on the stack.
|
|
const pending = residual;
|
|
residual = null;
|
|
queueMicrotask(() => deliver(pending));
|
|
return;
|
|
}
|
|
new Promise((resolve) => { pendingRead = resolve; })
|
|
.then((value) => deliver(encoder.encode(value + '\n')));
|
|
};
|
|
|
|
const MB = 1048576;
|
|
// One unit on the pair: "2.1 / 10.3 MB".
|
|
const fmtBytes = (received, total) => total
|
|
? (received / MB).toFixed(1) + ' / ' + (total / MB).toFixed(1) + ' MB'
|
|
: (received / MB).toFixed(1) + ' MB';
|
|
|
|
(async () => {
|
|
const go = new Go();
|
|
let instance;
|
|
|
|
try {
|
|
const response = await fetch(wasmURL);
|
|
if (!response.ok) throw new Error('HTTP ' + response.status);
|
|
|
|
let bytes;
|
|
if (response.body) {
|
|
const reader = response.body.getReader();
|
|
const chunks = [];
|
|
let received = 0;
|
|
// The transferred length: compressed in transit, it is smaller
|
|
// than the decoded chunks, so it is dropped once they pass it.
|
|
let total = Number(response.headers.get('content-length')) || 0;
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
chunks.push(value);
|
|
received += value.length;
|
|
if (total && received > total) total = 0;
|
|
progress.textContent = fmtBytes(received, total);
|
|
}
|
|
bytes = new Uint8Array(received);
|
|
let offset = 0;
|
|
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
|
|
} else {
|
|
bytes = new Uint8Array(await response.arrayBuffer());
|
|
}
|
|
|
|
setState('Compiling the client…');
|
|
const result = await WebAssembly.instantiate(bytes, go.importObject);
|
|
instance = result.instance;
|
|
} catch (err) {
|
|
console.error('chess terminal: WASM load failed', err);
|
|
setState('Could not load the client (' + err.message + '). Reload to retry.', true);
|
|
return;
|
|
}
|
|
|
|
setState('');
|
|
// The page is the terminal, opened on purpose; Escape is the way out.
|
|
term.focus();
|
|
|
|
go.run(instance).then(
|
|
// Unreached while the client restarts on exit (exit_wasm.go).
|
|
() => setState('Session ended. Reload the page to start a new one.'),
|
|
(err) => {
|
|
console.error('chess terminal: client crashed', err);
|
|
setState('Client crashed: ' + err.message, true);
|
|
}
|
|
);
|
|
})();
|
|
})();
|