Replace the abandoned llms-txt-mcp/Chroma docs backend with an in-repo MCP service: SQLite WAL + FTS5 + sentence-transformer embeddings, transactional source replacement, persisted state across restarts, singleflight refresh with conditional requests, hybrid lexical/semantic ranking with exact-duplicate collapse, source/host filters, and explicit-by-default content retrieval. Add docs_rebuild and a docs-rebuild CLI command. Add deterministic llms-full.txt snapshot generation for machine-local menus with hash-validated provenance manifests; lifecycle commands promote a local menu to its snapshot only when the manifest validates. Switch public source profiles to content-bearing llms-full.txt feeds. Improve web search: bounded provider fallback with per-attempt diagnostics and cancellation, an optional Brave Search API provider, strict SearXNG engine selection, capped link/media extraction, and a real engine=browser renderer that routes every request through the existing SSRF vetting while blocking WebSockets, non-GET traffic, and private destinations. Extend release checks with offline unit suites and isolated candidate container tests for both images.
96 lines
3.6 KiB
JavaScript
96 lines
3.6 KiB
JavaScript
import { HTTP_TIMEOUT, MAX_BYTES } from "../constants.js";
|
|
import { browserPool } from "../utils/browser-pool.js";
|
|
import { assertSafeUrl } from "./security.js";
|
|
import { fetchViaVettedAddress } from "./http.js";
|
|
|
|
const MAX_BROWSER_REQUESTS = 100;
|
|
const MAX_BROWSER_TOTAL_BYTES = Math.min(MAX_BYTES, 20 * 1024 * 1024);
|
|
|
|
function responseHeaders(headers) {
|
|
const record = {};
|
|
headers.forEach((value, key) => { record[key] = value; });
|
|
return record;
|
|
}
|
|
|
|
export async function fetchBrowserResource(url, timeoutMs = HTTP_TIMEOUT) {
|
|
await assertSafeUrl(url);
|
|
return browserPool.withBrowser(async browser => {
|
|
const page = await browser.newPage();
|
|
const devtools = await page.target().createCDPSession();
|
|
await devtools.send("Network.enable");
|
|
await devtools.send("Network.setBlockedURLs", {
|
|
urls: ["ws://*", "wss://*", "file://*", "ftp://*"]
|
|
});
|
|
await page.evaluateOnNewDocument(() => {
|
|
const blockedTransport = name => class {
|
|
constructor() {
|
|
throw new DOMException(`${name} is disabled by the safe browser fetcher`, "SecurityError");
|
|
}
|
|
};
|
|
for (const name of ["WebSocket", "WebTransport", "RTCPeerConnection", "webkitRTCPeerConnection"]) {
|
|
if (name in globalThis) {
|
|
Object.defineProperty(globalThis, name, {
|
|
configurable: false,
|
|
writable: false,
|
|
value: blockedTransport(name)
|
|
});
|
|
}
|
|
}
|
|
});
|
|
await page.setBypassServiceWorker(true);
|
|
let requests = 0;
|
|
let totalBytes = 0;
|
|
let blockedError;
|
|
await page.setRequestInterception(true);
|
|
page.on("request", request => {
|
|
void (async () => {
|
|
try {
|
|
const requestUrl = new URL(request.url());
|
|
if (!["http:", "https:"].includes(requestUrl.protocol)) throw new Error("unsupported browser request scheme");
|
|
if (request.method() !== "GET") throw new Error("browser fetch blocks non-GET requests");
|
|
requests += 1;
|
|
if (requests > MAX_BROWSER_REQUESTS) throw new Error("browser request limit exceeded");
|
|
await assertSafeUrl(requestUrl);
|
|
const upstream = await fetchViaVettedAddress(requestUrl, timeoutMs);
|
|
const body = Buffer.from(await upstream.arrayBuffer());
|
|
totalBytes += body.byteLength;
|
|
if (totalBytes > MAX_BROWSER_TOTAL_BYTES) throw new Error("browser byte limit exceeded");
|
|
await request.respond({
|
|
status: upstream.status,
|
|
headers: responseHeaders(upstream.headers),
|
|
body
|
|
});
|
|
} catch (error) {
|
|
blockedError ||= error;
|
|
await request.abort("blockedbyclient").catch(() => undefined);
|
|
}
|
|
})();
|
|
});
|
|
try {
|
|
const navigation = await page.goto(url.toString(), {
|
|
waitUntil: "networkidle2",
|
|
timeout: timeoutMs
|
|
});
|
|
if (blockedError && !navigation) throw blockedError;
|
|
const finalUrl = new URL(page.url());
|
|
await assertSafeUrl(finalUrl);
|
|
const html = await page.content();
|
|
const buffer = Buffer.from(html);
|
|
if (buffer.byteLength > MAX_BYTES) throw new Error("rendered content too large");
|
|
const headers = new Headers({ "content-type": "text/html; charset=utf-8" });
|
|
const status = navigation?.status() || 200;
|
|
const response = new Response(new Uint8Array(buffer), { status, headers });
|
|
Object.defineProperty(response, "url", { value: finalUrl.toString() });
|
|
return {
|
|
response,
|
|
finalUrl: finalUrl.toString(),
|
|
contentType: headers.get("content-type"),
|
|
buffer,
|
|
byteLength: buffer.byteLength
|
|
};
|
|
} finally {
|
|
await page.close();
|
|
}
|
|
});
|
|
}
|