Files
Netcatty/electron/scripts/sessionOutputBuffer.cjs
T
2026-07-02 17:48:04 +08:00

713 lines
23 KiB
JavaScript

"use strict";
const DEFAULT_BUFFER_SIZE = 1024 * 1024;
/** Matches within this many bytes of buffer end count as live terminal output. */
const FRESH_MATCH_TAIL_SLACK = 512;
function isFreshTailMatch(textLength, matchEndAbsolute) {
return matchEndAbsolute >= textLength - FRESH_MATCH_TAIL_SLACK;
}
function isRegExpLike(pattern) {
return Boolean(
pattern
&& typeof pattern === "object"
&& typeof pattern.exec === "function"
&& typeof pattern.test === "function",
);
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function hasUnescapedCharAt(source, index, char) {
if (source[index] !== char) return false;
let backslashCount = 0;
for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1) {
backslashCount += 1;
}
return backslashCount % 2 === 0;
}
function isRegexCompatibleWaitPattern(pattern) {
return pattern instanceof RegExp || isRegExpLike(pattern);
}
function edgeDotRepeatTokenLengthAt(source, index) {
if (source[index] !== ".") return 0;
const quantifier = source[index + 1];
if (quantifier !== "*" && quantifier !== "+") return 0;
if (!hasUnescapedCharAt(source, index, ".")) return 0;
return source[index + 2] === "?" ? 3 : 2;
}
function stripEdgeDotRepeats(source) {
let start = 0;
let end = source.length;
if (hasUnescapedCharAt(source, start, "^")) {
start += 1;
}
if (end > start && hasUnescapedCharAt(source, end - 1, "$")) {
end -= 1;
}
while (start < end) {
const tokenLength = edgeDotRepeatTokenLengthAt(source, start);
if (tokenLength === 0) break;
start += tokenLength;
}
while (end - start >= 2) {
const lazyTokenLength = edgeDotRepeatTokenLengthAt(source, end - 3);
if (lazyTokenLength === 3) {
end -= 3;
continue;
}
const greedyTokenLength = edgeDotRepeatTokenLengthAt(source, end - 2);
if (greedyTokenLength !== 2) break;
end -= 2;
}
return source.slice(start, end);
}
function getRegExpFlags(regex, fallbackFlags = "") {
if (typeof regex.flags === "string") return regex.flags;
let flags = fallbackFlags;
if (regex.global && !flags.includes("g")) flags += "g";
if (regex.ignoreCase && !flags.includes("i")) flags += "i";
if (regex.multiline && !flags.includes("m")) flags += "m";
if (regex.dotAll && !flags.includes("s")) flags += "s";
if (regex.unicode && !flags.includes("u")) flags += "u";
if (regex.sticky && !flags.includes("y")) flags += "y";
return flags;
}
function compilePattern(pattern) {
if (pattern instanceof RegExp || isRegExpLike(pattern)) return pattern;
if (typeof pattern !== "string") {
throw new TypeError("waitFor pattern must be a string or RegExp");
}
const slashMatch = pattern.match(/^\/(.+)\/([gimsuy]*)$/);
if (slashMatch) {
return new RegExp(slashMatch[1], slashMatch[2]);
}
return new RegExp(escapeRegExp(pattern));
}
function compileTextPattern(text) {
if (typeof text !== "string") {
throw new TypeError("waitForText pattern must be a string");
}
return new RegExp(escapeRegExp(text));
}
function compileRegexPattern(pattern) {
if (pattern instanceof RegExp || isRegExpLike(pattern)) return pattern;
if (typeof pattern !== "string") {
throw new TypeError("waitForRegex pattern must be a string or RegExp");
}
const slashMatch = pattern.match(/^\/(.+)\/([gimsuy]*)$/);
if (slashMatch) {
const flags = slashMatch[2].includes("s") ? slashMatch[2] : `${slashMatch[2]}s`;
return new RegExp(slashMatch[1], flags);
}
return new RegExp(pattern, "s");
}
function compileRegexFreshnessPattern(pattern) {
const regex = compileRegexPattern(pattern);
const source = typeof regex.source === "string" ? regex.source : String(pattern);
const strippedSource = stripEdgeDotRepeats(source);
if (!strippedSource || strippedSource === source) return null;
const flags = getRegExpFlags(regex).replace(/y/g, "");
const globalFlags = flags.includes("g") ? flags : `${flags}g`;
try {
return new RegExp(strippedSource, globalFlags);
} catch {
return null;
}
}
function tryMatch(text, pattern) {
const regex = compilePattern(pattern);
const match = regex.exec(text);
if (!match) return null;
return match[0];
}
function tryMatchWithEnd(text, pattern, compiler = compilePattern) {
const regex = compiler(pattern);
if (typeof regex.lastIndex === "number") regex.lastIndex = 0;
const match = regex.exec(text);
if (!match || match.index === undefined) return null;
return {
value: match[0],
endOffset: match.index + match[0].length,
};
}
function tryRegexMatchWithEnd(text, pattern) {
const regex = compileRegexPattern(pattern);
if (typeof regex.lastIndex === "number") regex.lastIndex = 0;
const match = regex.exec(text);
if (!match || match.index === undefined) return null;
const value = match[0];
let freshStartOffset = match.index;
let freshEndOffset = match.index + value.length;
const freshnessRegex = compileRegexFreshnessPattern(pattern);
if (freshnessRegex) {
if (typeof freshnessRegex.lastIndex === "number") freshnessRegex.lastIndex = 0;
let freshMatch = freshnessRegex.exec(value);
let latestFreshStartOffset = null;
let latestFreshEndOffset = null;
while (freshMatch && freshMatch.index !== undefined) {
const startOffset = freshMatch.index;
const endOffset = freshMatch.index + freshMatch[0].length;
if (latestFreshEndOffset === null || endOffset >= latestFreshEndOffset) {
latestFreshStartOffset = startOffset;
}
latestFreshEndOffset = Math.max(latestFreshEndOffset ?? 0, endOffset);
if (freshMatch[0].length === 0) {
freshnessRegex.lastIndex += 1;
}
freshMatch = freshnessRegex.exec(value);
}
if (latestFreshEndOffset !== null) {
freshStartOffset = match.index + latestFreshStartOffset;
freshEndOffset = match.index + latestFreshEndOffset;
}
}
return {
value,
startOffset: match.index,
endOffset: match.index + value.length,
freshStartOffset,
freshEndOffset,
};
}
function staleAdvanceEndOffset(matched) {
return Math.max(Number(matched?.endOffset) || 0, 1);
}
function staleRegexAdvanceEndOffset(matched) {
return Math.max((Number(matched?.startOffset) || 0) + 1, 1);
}
function findFreshTailMatchAny(text, patterns) {
for (let index = 0; index < patterns.length; index += 1) {
const pattern = patterns[index];
let offset = 0;
while (offset <= text.length) {
const matched = tryMatchWithEnd(text.slice(offset), pattern);
if (matched === null) break;
const absoluteEnd = offset + matched.endOffset;
if (isFreshTailMatch(text.length, absoluteEnd)) {
return {
index,
matched: {
value: matched.value,
endOffset: absoluteEnd,
},
};
}
offset = Math.max(absoluteEnd, offset + 1);
}
}
return null;
}
function findMatchingPreservedTailMatch(text, patterns, preserved) {
for (let index = 0; index < patterns.length; index += 1) {
const pattern = patterns[index];
let offset = 0;
while (offset <= text.length) {
const matched = tryMatchWithEnd(text.slice(offset), pattern);
if (matched === null) break;
const absoluteEnd = offset + matched.endOffset;
if (absoluteEnd === preserved.endOffset && matched.value === preserved.value) {
return {
index,
matched: {
value: matched.value,
endOffset: absoluteEnd,
},
};
}
offset = Math.max(absoluteEnd, offset + 1);
}
}
return null;
}
class SessionOutputBuffer {
constructor(sessionId, maxSize = DEFAULT_BUFFER_SIZE) {
this.sessionId = sessionId;
this.maxSize = maxSize;
this.chunks = [];
this.totalLength = 0;
this.scanOffset = 0;
this.waiters = [];
this.preservedTailMatch = null;
}
append(data) {
if (!data) return;
this.preservedTailMatch = null;
this.chunks.push(String(data));
this.totalLength += this.chunks[this.chunks.length - 1].length;
while (this.totalLength > this.maxSize && this.chunks.length > 1) {
const removed = this.chunks.shift();
const removedLength = removed.length;
this.totalLength -= removedLength;
this.scanOffset = Math.max(0, this.scanOffset - removedLength);
}
this.flushWaiters();
}
getText() {
return this.chunks.join("");
}
getPendingText() {
return this.getText().slice(this.scanOffset);
}
tryMatchPending(pattern) {
return tryMatchWithEnd(this.getPendingText(), pattern);
}
tryMatchPendingText(text) {
return tryMatchWithEnd(this.getPendingText(), text, compileTextPattern);
}
tryMatchPendingRegex(pattern) {
return tryRegexMatchWithEnd(this.getPendingText(), pattern);
}
consumeFreshPendingMatch(pattern) {
while (true) {
const matched = this.tryMatchPending(pattern);
if (matched === null) return null;
const absoluteEnd = this.scanOffset + matched.endOffset;
if (isFreshTailMatch(this.getText().length, absoluteEnd)) {
return matched;
}
this.advanceScanOffset(staleAdvanceEndOffset(matched));
}
}
consumeFreshPendingText(text) {
while (true) {
const matched = this.tryMatchPendingText(text);
if (matched === null) return null;
const absoluteEnd = this.scanOffset + matched.endOffset;
if (isFreshTailMatch(this.getText().length, absoluteEnd)) {
return matched;
}
this.advanceScanOffset(staleAdvanceEndOffset(matched));
}
}
consumeFreshPendingRegex(pattern, options = {}) {
const text = this.getText();
const baseOffset = this.scanOffset;
const pendingText = text.slice(baseOffset);
let relativeOffset = 0;
while (relativeOffset <= pendingText.length) {
const matched = tryRegexMatchWithEnd(pendingText.slice(relativeOffset), pattern);
if (matched === null) {
if (relativeOffset > 0) {
this.scanOffset = Math.min(baseOffset + relativeOffset, text.length);
}
return null;
}
const minFreshStartAbsolute = Number.isFinite(options.minFreshStartAbsolute)
? options.minFreshStartAbsolute
: null;
const absoluteStart = baseOffset + relativeOffset + matched.freshStartOffset;
const absoluteEnd = baseOffset + relativeOffset + matched.freshEndOffset;
const matchStartAbsolute = baseOffset + relativeOffset + matched.startOffset;
if (
isFreshTailMatch(text.length, absoluteEnd)
&& (minFreshStartAbsolute === null || absoluteStart >= minFreshStartAbsolute)
) {
let value = matched.value;
if (minFreshStartAbsolute !== null && matchStartAbsolute < minFreshStartAbsolute) {
const valueFreshStart = matched.freshStartOffset - matched.startOffset;
const lineStart = matched.value.lastIndexOf("\n", Math.max(0, valueFreshStart - 1));
const valueStart = lineStart >= 0 ? lineStart : Math.max(0, valueFreshStart);
value = matched.value.slice(valueStart);
}
return {
...matched,
value,
endOffset: relativeOffset + matched.endOffset,
};
}
relativeOffset += staleRegexAdvanceEndOffset(matched);
}
this.scanOffset = Math.min(baseOffset + relativeOffset, text.length);
return null;
}
consumeFreshPendingMatchAny(patterns) {
for (let index = 0; index < patterns.length; index += 1) {
const pattern = patterns[index];
while (true) {
const matched = this.tryMatchPending(pattern);
if (matched === null) break;
const absoluteEnd = this.scanOffset + matched.endOffset;
if (isFreshTailMatch(this.getText().length, absoluteEnd)) {
return { index, matched };
}
this.advanceScanOffset(staleAdvanceEndOffset(matched));
}
}
return null;
}
advanceScanOffset(endOffset) {
const absoluteEnd = this.scanOffset + endOffset;
this.scanOffset = Math.min(absoluteEnd, this.getText().length);
}
markCurrentOutputConsumed(options = {}) {
this.markOutputConsumedThrough(this.getText().length, options);
}
markOutputConsumedThrough(length, options = {}) {
const text = this.getText();
const consumedLength = Math.max(0, Math.min(Number(length) || 0, text.length));
const consumedText = text.slice(0, consumedLength);
this.scanOffset = consumedLength;
this.preservedTailMatch = null;
const preserveTailPatterns = Array.isArray(options.preserveTailPatterns)
? options.preserveTailPatterns
: [];
if (preserveTailPatterns.length === 0 || consumedText.length === 0) return;
const fresh = findFreshTailMatchAny(consumedText, preserveTailPatterns);
if (fresh === null) return;
this.preservedTailMatch = {
textLength: consumedText.length,
value: fresh.matched.value,
endOffset: fresh.matched.endOffset,
};
}
consumePreservedTailMatchAny(patterns) {
const preserved = this.preservedTailMatch;
if (!preserved) return null;
const text = this.getText();
if (text.length !== preserved.textLength) {
this.preservedTailMatch = null;
return null;
}
const fresh = findMatchingPreservedTailMatch(text, patterns, preserved);
if (fresh === null) return null;
this.preservedTailMatch = null;
return fresh;
}
clear() {
this.chunks = [];
this.totalLength = 0;
this.scanOffset = 0;
this.preservedTailMatch = null;
}
flushWaiters() {
if (this.waiters.length === 0) return;
const remaining = [];
for (const waiter of this.waiters) {
if (waiter.custom) {
if (!waiter.custom.check()) {
remaining.push(waiter);
}
continue;
}
const matched = this.consumeFreshPendingMatch(waiter.pattern);
if (matched !== null) {
this.advanceScanOffset(matched.endOffset);
clearTimeout(waiter.timer);
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
waiter.resolve(matched.value);
} else {
remaining.push(waiter);
}
}
this.waiters = remaining;
}
waitForWithMatcher({ pattern, timeoutMs, shouldAbort, consumeFreshMatch, timeoutLabel }) {
return new Promise((resolve, reject) => {
const waiter = {
pattern,
resolve,
reject,
shouldAbort,
timer: null,
check: () => {
const matched = consumeFreshMatch();
if (matched === null) return false;
this.advanceScanOffset(matched.endOffset);
clearTimeout(waiter.timer);
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
this.waiters = this.waiters.filter((entry) => entry.custom !== waiter);
resolve(matched.value);
return true;
},
};
waiter.timer = setTimeout(() => {
this.waiters = this.waiters.filter((entry) => entry.custom !== waiter);
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
reject(new Error(`${timeoutLabel} timed out after ${timeoutMs}ms`));
}, timeoutMs);
if (typeof shouldAbort === "function") {
waiter.abortInterval = setInterval(() => {
if (!shouldAbort()) return;
clearTimeout(waiter.timer);
clearInterval(waiter.abortInterval);
this.waiters = this.waiters.filter((entry) => entry.custom !== waiter);
reject(new Error("Script stopped"));
}, 100);
}
this.waiters.push({
pattern,
resolve: () => {},
reject,
timer: waiter.timer,
custom: waiter,
});
});
}
waitFor(pattern, timeoutMs = 30000, shouldAbort) {
if (isRegexCompatibleWaitPattern(pattern)) {
const minFreshStartAbsolute = Math.max(
this.scanOffset,
this.getText().length - FRESH_MATCH_TAIL_SLACK,
);
const immediate = this.consumeFreshPendingRegex(pattern, { minFreshStartAbsolute });
if (immediate !== null) {
this.advanceScanOffset(immediate.endOffset);
return Promise.resolve(immediate.value);
}
return this.waitForWithMatcher({
pattern,
timeoutMs,
shouldAbort,
consumeFreshMatch: () => this.consumeFreshPendingRegex(pattern, { minFreshStartAbsolute }),
timeoutLabel: "waitFor",
});
}
const immediate = this.consumeFreshPendingMatch(pattern);
if (immediate !== null) {
this.advanceScanOffset(immediate.endOffset);
return Promise.resolve(immediate.value);
}
return new Promise((resolve, reject) => {
const waiter = {
pattern,
resolve,
reject,
shouldAbort,
timer: setTimeout(() => {
this.waiters = this.waiters.filter((entry) => entry !== waiter);
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
reject(new Error(`waitFor timed out after ${timeoutMs}ms`));
}, timeoutMs),
};
if (typeof shouldAbort === "function") {
waiter.abortInterval = setInterval(() => {
if (!shouldAbort()) return;
clearTimeout(waiter.timer);
clearInterval(waiter.abortInterval);
this.waiters = this.waiters.filter((entry) => entry !== waiter);
reject(new Error("Script stopped"));
}, 100);
}
this.waiters.push(waiter);
});
}
waitForText(text, timeoutMs = 30000, shouldAbort) {
const immediate = this.consumeFreshPendingText(text);
if (immediate !== null) {
this.advanceScanOffset(immediate.endOffset);
return Promise.resolve(immediate.value);
}
return this.waitForWithMatcher({
pattern: text,
timeoutMs,
shouldAbort,
consumeFreshMatch: () => this.consumeFreshPendingText(text),
timeoutLabel: "waitForText",
});
}
waitForRegex(pattern, timeoutMs = 30000, shouldAbort) {
const minFreshStartAbsolute = Math.max(
this.scanOffset,
this.getText().length - FRESH_MATCH_TAIL_SLACK,
);
const immediate = this.consumeFreshPendingRegex(pattern, { minFreshStartAbsolute });
if (immediate !== null) {
this.advanceScanOffset(immediate.endOffset);
return Promise.resolve(immediate.value);
}
return this.waitForWithMatcher({
pattern,
timeoutMs,
shouldAbort,
consumeFreshMatch: () => this.consumeFreshPendingRegex(pattern, { minFreshStartAbsolute }),
timeoutLabel: "waitForRegex",
});
}
abortWaiters(reason = "Script stopped") {
for (const waiter of this.waiters) {
clearTimeout(waiter.timer);
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
if (waiter.custom?.abortInterval) clearInterval(waiter.custom.abortInterval);
if (waiter.custom?.interval) clearInterval(waiter.custom.interval);
waiter.reject?.(new Error(reason));
}
this.waiters = [];
}
async waitForAny(patterns, timeoutMs = 30000, shouldAbort, options = {}) {
if (!Array.isArray(patterns) || patterns.length === 0) {
throw new TypeError("waitForAny requires a non-empty patterns array");
}
if (options.allowPreservedTailMatch === true) {
const preserved = this.consumePreservedTailMatchAny(patterns);
if (preserved !== null) {
return preserved.index;
}
}
const fresh = this.consumeFreshPendingMatchAny(patterns);
if (fresh !== null) {
this.advanceScanOffset(fresh.matched.endOffset);
return fresh.index;
}
return new Promise((resolve, reject) => {
const waiter = {
patterns,
resolve,
reject,
shouldAbort,
timer: null,
interval: null,
check: () => {
if (options.allowPreservedTailMatch === true) {
const preserved = this.consumePreservedTailMatchAny(patterns);
if (preserved !== null) {
clearTimeout(waiter.timer);
if (waiter.interval) clearInterval(waiter.interval);
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
this.waiters = this.waiters.filter((entry) => entry.custom !== waiter);
resolve(preserved.index);
return true;
}
}
const fresh = this.consumeFreshPendingMatchAny(patterns);
if (fresh !== null) {
this.advanceScanOffset(fresh.matched.endOffset);
clearTimeout(waiter.timer);
if (waiter.interval) clearInterval(waiter.interval);
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
this.waiters = this.waiters.filter((entry) => entry.custom !== waiter);
resolve(fresh.index);
return true;
}
return false;
},
};
waiter.timer = setTimeout(() => {
if (waiter.interval) clearInterval(waiter.interval);
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
this.waiters = this.waiters.filter((entry) => entry.custom !== waiter);
reject(new Error(`waitForAny timed out after ${timeoutMs}ms`));
}, timeoutMs);
waiter.interval = setInterval(() => {
waiter.check();
}, 50);
if (typeof shouldAbort === "function") {
waiter.abortInterval = setInterval(() => {
if (!shouldAbort()) return;
clearTimeout(waiter.timer);
clearInterval(waiter.interval);
clearInterval(waiter.abortInterval);
this.waiters = this.waiters.filter((entry) => entry.custom !== waiter);
reject(new Error("Script stopped"));
}, 100);
}
this.waiters.push({
pattern: patterns[0],
resolve: () => {},
reject: () => {},
timer: waiter.timer,
custom: waiter,
});
});
}
dispose() {
for (const waiter of this.waiters) {
clearTimeout(waiter.timer);
if (waiter.abortInterval) clearInterval(waiter.abortInterval);
if (waiter.custom?.abortInterval) clearInterval(waiter.custom.abortInterval);
if (waiter.custom?.interval) clearInterval(waiter.custom.interval);
waiter.reject?.(new Error("Session output buffer disposed"));
}
this.waiters = [];
this.chunks = [];
this.totalLength = 0;
this.scanOffset = 0;
this.preservedTailMatch = null;
}
}
const buffers = new Map();
function getOrCreateBuffer(sessionId) {
if (!buffers.has(sessionId)) {
buffers.set(sessionId, new SessionOutputBuffer(sessionId));
}
return buffers.get(sessionId);
}
function appendSessionOutput(sessionId, data) {
getOrCreateBuffer(sessionId).append(data);
}
function removeSessionBuffer(sessionId) {
const buffer = buffers.get(sessionId);
if (buffer) {
buffer.dispose();
buffers.delete(sessionId);
}
}
module.exports = {
SessionOutputBuffer,
appendSessionOutput,
getOrCreateBuffer,
removeSessionBuffer,
tryMatch,
compilePattern,
};