CI / test (push) Successful in 9m59s
Batch typing into short frames, send UTF-8 instead of base64 for text, exempt stream methods from the general rate limit, and skip heavy middleware on terminalInput/resize for lower latency.
117 lines
2.8 KiB
JavaScript
117 lines
2.8 KiB
JavaScript
/**
|
|
* Coalesced terminal input sender.
|
|
*
|
|
* Typing generates many tiny onData events. Instead of one RPC event per
|
|
* keystroke (JSON + encode + rate-limit slot), we batch into short frames:
|
|
* - flush on rAF / ~8ms timer
|
|
* - flush immediately on Enter, paste-sized bursts, or max buffer
|
|
* - send UTF-8 strings in JSON (no base64) unless binary
|
|
*/
|
|
|
|
/**
|
|
* @param {(payload: { data: string, encoding?: string }) => void} send
|
|
* @param {{ maxDelayMs?: number, maxChars?: number }} [opts]
|
|
*/
|
|
export function createInputCoalescer(send, opts = {}) {
|
|
const maxDelayMs = opts.maxDelayMs ?? 8
|
|
const maxChars = opts.maxChars ?? 4096
|
|
|
|
let buf = ''
|
|
let timer = null
|
|
let raf = null
|
|
let closed = false
|
|
|
|
const clearTimers = () => {
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
timer = null
|
|
}
|
|
if (raf) {
|
|
cancelAnimationFrame(raf)
|
|
raf = null
|
|
}
|
|
}
|
|
|
|
const flush = () => {
|
|
clearTimers()
|
|
if (!buf || closed) return
|
|
const data = buf
|
|
buf = ''
|
|
try {
|
|
send({ data, encoding: 'utf8' })
|
|
} catch {
|
|
// drop on closed connection
|
|
}
|
|
}
|
|
|
|
const schedule = () => {
|
|
if (timer || raf || closed) return
|
|
// Prefer animation frame for typing smoothness, with hard timeout
|
|
if (typeof requestAnimationFrame === 'function') {
|
|
raf = requestAnimationFrame(() => {
|
|
raf = null
|
|
flush()
|
|
})
|
|
}
|
|
timer = setTimeout(() => {
|
|
timer = null
|
|
flush()
|
|
}, maxDelayMs)
|
|
}
|
|
|
|
/**
|
|
* @param {string} chunk
|
|
*/
|
|
const push = (chunk) => {
|
|
if (closed || !chunk) return
|
|
buf += chunk
|
|
|
|
// Immediate flush for line endings / large pastes / control-heavy bursts
|
|
const immediate =
|
|
buf.length >= maxChars ||
|
|
chunk.includes('\r') ||
|
|
chunk.includes('\n') ||
|
|
chunk.includes('\x03') || // Ctrl+C
|
|
chunk.includes('\x04') || // Ctrl+D
|
|
chunk.length > 64 // paste-ish
|
|
|
|
if (immediate) flush()
|
|
else schedule()
|
|
}
|
|
|
|
/**
|
|
* Binary paste path (base64 once per flush of binary string).
|
|
* @param {string} binaryString - string of char codes 0-255
|
|
*/
|
|
const pushBinary = (binaryString) => {
|
|
if (closed || !binaryString) return
|
|
flush() // keep ordering vs pending text
|
|
let b64 = ''
|
|
try {
|
|
b64 = btoa(binaryString)
|
|
} catch {
|
|
// chunk large
|
|
const bytes = new Uint8Array(binaryString.length)
|
|
for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i) & 0xff
|
|
let bin = ''
|
|
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
|
|
}
|
|
b64 = btoa(bin)
|
|
}
|
|
try {
|
|
send({ data: b64, encoding: 'base64' })
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
const destroy = () => {
|
|
closed = true
|
|
clearTimers()
|
|
buf = ''
|
|
}
|
|
|
|
return { push, pushBinary, flush, destroy }
|
|
}
|