1 parent
caf30df968
commit
87dcb3e550
13 files changed
+362
-185
No files matched your search
+275
-124
@@ -1,130 +1,281 @@
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
convertEol: true,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
fontSize: 14,
|
||||
allowProposedApi: true,
|
||||
theme: {
|
||||
background: '#1a1b26',
|
||||
foreground: '#a9b1d6',
|
||||
cursor: '#a9b1d6',
|
||||
selection: 'rgba(169, 177, 214, 0.3)'
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
|
||||
// Load addons
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
const webglAddon = new WebglAddon.WebglAddon();
|
||||
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
||||
const unicode11Addon = new Unicode11Addon.Unicode11Addon();
|
||||
// The theme's colours are the host's CSS custom properties.
|
||||
const css = getComputedStyle(host);
|
||||
const token = (name, fallback) => css.getPropertyValue(name).trim() || fallback;
|
||||
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(webLinksAddon);
|
||||
term.loadAddon(unicode11Addon);
|
||||
term.unicode.activeVersion = '11';
|
||||
|
||||
term.open(document.getElementById('terminal'));
|
||||
|
||||
// WebGL addon must load after open()
|
||||
try {
|
||||
term.loadAddon(webglAddon);
|
||||
webglAddon.onContextLoss(() => {
|
||||
webglAddon.dispose();
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('WebGL addon failed, using canvas renderer:', e);
|
||||
}
|
||||
|
||||
fitAddon.fit();
|
||||
term.focus();
|
||||
|
||||
let inputBuffer = '';
|
||||
let inputResolver = null;
|
||||
|
||||
term.onData(data => {
|
||||
if (inputResolver) {
|
||||
if (data === '\r') {
|
||||
term.write('\r\n');
|
||||
const result = inputBuffer;
|
||||
inputBuffer = '';
|
||||
const resolver = inputResolver;
|
||||
inputResolver = null;
|
||||
resolver(result);
|
||||
} else if (data === '\x7f' || data === '\x08') {
|
||||
if (inputBuffer.length > 0) {
|
||||
inputBuffer = inputBuffer.slice(0, -1);
|
||||
term.write('\b \b');
|
||||
}
|
||||
} else if (data === '\x03') {
|
||||
term.write('^C\r\n');
|
||||
inputBuffer = '';
|
||||
if (inputResolver) {
|
||||
inputResolver('');
|
||||
inputResolver = null;
|
||||
}
|
||||
} else if (data >= ' ' && data <= '~') {
|
||||
inputBuffer += data;
|
||||
term.write(data);
|
||||
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 encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (!globalThis.fs) {
|
||||
globalThis.fs = {};
|
||||
}
|
||||
|
||||
const originalWrite = globalThis.fs.write;
|
||||
const originalRead = globalThis.fs.read;
|
||||
|
||||
globalThis.fs.write = function(fd, buf, offset, length, position, callback) {
|
||||
if (fd === 1 || fd === 2) {
|
||||
const text = decoder.decode(buf.slice(offset, offset + length));
|
||||
term.write(text);
|
||||
callback(null, length);
|
||||
} else if (originalWrite) {
|
||||
originalWrite.call(this, fd, buf, offset, length, position, callback);
|
||||
} else {
|
||||
callback(new Error('Invalid fd'));
|
||||
}
|
||||
};
|
||||
|
||||
globalThis.fs.read = function(fd, buf, offset, length, position, callback) {
|
||||
if (fd === 0) {
|
||||
const promise = new Promise(resolve => {
|
||||
inputResolver = resolve;
|
||||
});
|
||||
|
||||
promise.then(line => {
|
||||
const input = encoder.encode(line + '\n');
|
||||
const n = Math.min(length, input.length);
|
||||
buf.set(input.slice(0, n), offset);
|
||||
callback(null, n);
|
||||
});
|
||||
} else if (originalRead) {
|
||||
originalRead.call(this, fd, buf, offset, length, position, callback);
|
||||
} else {
|
||||
callback(new Error('Invalid fd'));
|
||||
}
|
||||
};
|
||||
|
||||
const go = new Go();
|
||||
|
||||
WebAssembly.instantiateStreaming(fetch('chess-client.wasm'), go.importObject)
|
||||
.then(result => {
|
||||
go.run(result.instance);
|
||||
})
|
||||
.catch(err => {
|
||||
term.writeln('\r\n\x1b[31mError loading WASM: ' + err + '\x1b[0m');
|
||||
console.error('WASM load error:', err);
|
||||
});
|
||||
|
||||
// Resize handling with debounce for performance
|
||||
let resizeTimeout;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => fitAddon.fit(), 16);
|
||||
});
|
||||
resizeObserver.observe(document.getElementById('terminal'));
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
|
||||
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);
|
||||
}
|
||||
);
|
||||
})();
|
||||
})();
|
||||
Reference in new issue
Block a user