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.
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user