Files
peardock/libs/xtermUtils.js
T
snxraven 77f49beda3
CI / test (push) Successful in 9m56s
Harden terminals and logs; polish deploy job drawer
Production-grade xterm FitAddon resize, binary-safe I/O, TTY-safe
server streams, demuxed log viewer with line buffering, and auto-dismiss
deploy job panel without the full-screen spinner.
2026-07-10 21:34:51 -04:00

308 lines
8.9 KiB
JavaScript

/**
* Shared xterm.js helpers: FitAddon resolution, binary-safe base64, safe fit/resize.
*/
export function getTerminalCtor() {
if (typeof window === 'undefined' || !window.Terminal) {
throw new Error('xterm.js not loaded')
}
return window.Terminal
}
export function getFitAddonCtor() {
if (typeof window === 'undefined' || !window.FitAddon) {
throw new Error('xterm-addon-fit not loaded')
}
// UMD may expose { FitAddon: fn } or the constructor itself
if (typeof window.FitAddon.FitAddon === 'function') return window.FitAddon.FitAddon
if (typeof window.FitAddon === 'function') return window.FitAddon
throw new Error('FitAddon constructor not found on window.FitAddon')
}
/** Default production xterm options */
export function defaultXtermOptions(overrides = {}) {
return {
cursorBlink: true,
cursorStyle: 'block',
cursorWidth: 1,
fontSize: 14,
fontFamily:
'"JetBrains Mono", Menlo, Monaco, "Cascadia Code", "Courier New", monospace',
fontWeight: 400,
fontWeightBold: 700,
lineHeight: 1.2,
letterSpacing: 0,
scrollback: 10000,
allowProposedApi: true,
convertEol: false,
disableStdin: false,
macOptionIsMeta: true,
rightClickSelectsWord: true,
screenReaderMode: false,
windowsMode: false,
allowTransparency: false,
theme: {
background: '#0b0f14',
foreground: '#e6edf3',
cursor: '#34d399',
cursorAccent: '#0b0f14',
selectionBackground: 'rgba(52, 211, 153, 0.35)',
selectionForeground: '#f4f7fb',
black: '#0b0f14',
red: '#f87171',
green: '#4ade80',
yellow: '#fbbf24',
blue: '#38bdf8',
magenta: '#c084fc',
cyan: '#2dd4bf',
white: '#e6edf3',
brightBlack: '#64748b',
brightRed: '#fca5a5',
brightGreen: '#86efac',
brightYellow: '#fde68a',
brightBlue: '#7dd3fc',
brightMagenta: '#d8b4fe',
brightCyan: '#5eead4',
brightWhite: '#f8fafc',
},
...overrides,
}
}
/**
* Binary-safe UTF-8 → base64 (handles multi-byte + binary)
* @param {string} str
*/
export function encodeBase64Utf8(str) {
const bytes = new TextEncoder().encode(str)
let binary = ''
const chunk = 0x8000
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk))
}
return btoa(binary)
}
/**
* Binary-safe base64 → UTF-8 string
* @param {string} b64
*/
export function decodeBase64Utf8(b64) {
if (b64 == null || b64 === '') return ''
try {
const binary = atob(b64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return new TextDecoder('utf-8', { fatal: false }).decode(bytes)
} catch {
// Fallback: latin1
try {
return atob(b64)
} catch {
return ''
}
}
}
/**
* Decode terminal/log payload with encoding field.
*/
export function decodePayload(data, encoding = 'base64') {
if (data == null) return ''
if (encoding === 'base64' || encoding === 'b64') return decodeBase64Utf8(String(data))
return String(data)
}
/**
* Safely fit xterm when container has measurable size.
* @param {{ fit: () => void, proposeDimensions?: () => {cols:number,rows:number}|undefined }} fitAddon
* @param {{ cols: number, rows: number }|null} [term]
* @returns {{ cols: number, rows: number }|null}
*/
export function safeFit(fitAddon, term = null) {
if (!fitAddon) return null
try {
const dims = typeof fitAddon.proposeDimensions === 'function' ? fitAddon.proposeDimensions() : null
if (dims && (dims.cols < 2 || dims.rows < 1)) {
return term ? { cols: term.cols, rows: term.rows } : null
}
fitAddon.fit()
if (term) return { cols: term.cols, rows: term.rows }
return dims || null
} catch (err) {
console.debug('[xterm] fit skipped:', err?.message || err)
return null
}
}
/**
* Debounced fit + callback with dimensions.
*/
export function createFitController(fitAddon, term, onDims, { debounceMs = 50 } = {}) {
let timer = null
let raf = null
const run = () => {
timer = null
raf = null
const dims = safeFit(fitAddon, term)
if (dims && onDims) onDims(dims.cols, dims.rows)
}
const schedule = () => {
if (timer) clearTimeout(timer)
if (raf) cancelAnimationFrame(raf)
raf = requestAnimationFrame(() => {
timer = setTimeout(run, debounceMs)
})
}
/** Immediate fit (e.g. after open) */
const fitNow = () => {
if (timer) clearTimeout(timer)
if (raf) cancelAnimationFrame(raf)
run()
}
const ro =
typeof ResizeObserver !== 'undefined'
? new ResizeObserver(() => schedule())
: null
const onWin = () => schedule()
window.addEventListener('resize', onWin)
return {
schedule,
fitNow,
observe(el) {
if (ro && el) ro.observe(el)
},
disconnect() {
if (timer) clearTimeout(timer)
if (raf) cancelAnimationFrame(raf)
window.removeEventListener('resize', onWin)
if (ro) ro.disconnect()
},
}
}
/**
* Strip Docker multiplex headers from a Buffer/Uint8Array chunk.
* Frame: [stream_type:1][0:3][size:4 BE][payload]
* @param {Uint8Array|ArrayBuffer|string} input
* @returns {string}
*/
export function demuxDockerLogs(input) {
let buf
if (typeof input === 'string') {
// binary string from atob
buf = new Uint8Array(input.length)
for (let i = 0; i < input.length; i++) buf[i] = input.charCodeAt(i)
} else if (input instanceof ArrayBuffer) {
buf = new Uint8Array(input)
} else if (input?.buffer) {
buf = new Uint8Array(input.buffer, input.byteOffset || 0, input.byteLength || input.length)
} else {
return String(input || '')
}
// Heuristic: if not muxed, decode as utf8
if (buf.length < 8) {
return new TextDecoder('utf-8', { fatal: false }).decode(buf)
}
const chunks = []
let offset = 0
let sawFrame = false
while (offset + 8 <= buf.length) {
const streamType = buf[offset]
// stream types 0-2 are stdin/stdout/stderr; higher is suspicious
if (streamType > 2) {
// Not multiplexed — whole buffer is payload
return new TextDecoder('utf-8', { fatal: false }).decode(buf)
}
const size = (buf[offset + 4] << 24) | (buf[offset + 5] << 16) | (buf[offset + 6] << 8) | buf[offset + 7]
// size as unsigned
const payloadSize = size >>> 0
if (payloadSize < 0 || offset + 8 + payloadSize > buf.length) {
// Incomplete or invalid — if we already took frames, return them + rest raw
if (sawFrame) {
const rest = new TextDecoder('utf-8', { fatal: false }).decode(buf.subarray(offset))
if (rest) chunks.push(rest)
return chunks.join('')
}
return new TextDecoder('utf-8', { fatal: false }).decode(buf)
}
sawFrame = true
const payload = buf.subarray(offset + 8, offset + 8 + payloadSize)
chunks.push(new TextDecoder('utf-8', { fatal: false }).decode(payload))
offset += 8 + payloadSize
}
if (sawFrame && offset < buf.length) {
chunks.push(new TextDecoder('utf-8', { fatal: false }).decode(buf.subarray(offset)))
}
return sawFrame ? chunks.join('') : new TextDecoder('utf-8', { fatal: false }).decode(buf)
}
/**
* Incremental demuxer for streaming log chunks that may split mid-frame.
*/
export function createLogDemuxer() {
let pending = new Uint8Array(0)
const concat = (a, b) => {
const out = new Uint8Array(a.length + b.length)
out.set(a, 0)
out.set(b, a.length)
return out
}
return {
/**
* @param {string} b64 base64 chunk from server (raw docker stream)
* @returns {string} decoded text ready for display
*/
pushBase64(b64) {
try {
const binary = atob(b64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
pending = concat(pending, bytes)
} catch {
return decodeBase64Utf8(b64)
}
// If not looking like mux frames, flush as utf8
if (pending.length >= 1 && pending[0] > 2) {
const text = new TextDecoder('utf-8', { fatal: false }).decode(pending)
pending = new Uint8Array(0)
return text
}
const parts = []
let offset = 0
while (offset + 8 <= pending.length) {
const size =
((pending[offset + 4] << 24) |
(pending[offset + 5] << 16) |
(pending[offset + 6] << 8) |
pending[offset + 7]) >>>
0
if (offset + 8 + size > pending.length) break
const payload = pending.subarray(offset + 8, offset + 8 + size)
parts.push(new TextDecoder('utf-8', { fatal: false }).decode(payload))
offset += 8 + size
}
pending = pending.subarray(offset)
return parts.join('')
},
flush() {
if (!pending.length) return ''
const text = new TextDecoder('utf-8', { fatal: false }).decode(pending)
pending = new Uint8Array(0)
return text
},
}
}