Spaces:
Running
Running
Sync from felladrin/MiniSearch@6c9fb20 (part 2)
Browse files- server/biEncoderService.ts +3 -5
- server/handleTokenVerification.test.ts +172 -4
- server/handleTokenVerification.ts +83 -1
- server/pageContentService.test.ts +443 -89
- server/pageContentService.ts +154 -82
- server/pageReadHostBreaker.ts +111 -0
- server/pageReadsSinceLastRestart.test.ts +12 -6
- server/pageReadsSinceLastRestart.ts +14 -5
- server/rankSearchResults.test.ts +51 -16
- server/rankSearchResults.ts +4 -1
- server/rerankingSinceLastRestart.test.ts +66 -0
- server/rerankingSinceLastRestart.ts +55 -23
- server/rerankingStatus.test.ts +222 -0
- server/searchEndpointServerHook.test.ts +4 -2
- server/searchEndpointServerHook.ts +20 -4
- server/statusEndpointServerHook.test.ts +11 -0
- server/statusEndpointServerHook.ts +5 -1
- server/thumbnailEndpointServerHook.test.ts +112 -62
- server/thumbnailEndpointServerHook.ts +88 -31
- server/utils/circuitBreaker.test.ts +59 -0
- server/utils/circuitBreaker.ts +62 -41
- server/utils/pinnedFetch.test.ts +247 -0
- server/utils/pinnedFetch.ts +126 -0
- server/utils/publicUrl.ts +36 -14
- tsconfig.node.json +7 -1
server/biEncoderService.ts
CHANGED
|
@@ -10,9 +10,8 @@ const MODEL_HF_REPO =
|
|
| 10 |
|
| 11 |
/**
|
| 12 |
* The ONNX export. ~450 MB, multilingual (50+ languages), 384-dimensional
|
| 13 |
-
* embeddings.
|
| 14 |
-
*
|
| 15 |
-
* with hundreds of passages each.
|
| 16 |
*/
|
| 17 |
const MODEL_HF_FILE = "onnx/model.onnx";
|
| 18 |
|
|
@@ -23,8 +22,7 @@ const MODEL_HF_FILE = "onnx/model.onnx";
|
|
| 23 |
*/
|
| 24 |
const MAX_SEQUENCE_LENGTH = 256;
|
| 25 |
|
| 26 |
-
/**
|
| 27 |
-
* more memory; 64 is a safe default on CPU. */
|
| 28 |
const BATCH_SIZE = 64;
|
| 29 |
|
| 30 |
let isReady = false;
|
|
|
|
| 10 |
|
| 11 |
/**
|
| 12 |
* The ONNX export. ~450 MB, multilingual (50+ languages), 384-dimensional
|
| 13 |
+
* embeddings. CPU cost depends on passage length and pool size; the page-content
|
| 14 |
+
* selector limits dense scoring to 256 passages. See docs/page-content.md.
|
|
|
|
| 15 |
*/
|
| 16 |
const MODEL_HF_FILE = "onnx/model.onnx";
|
| 17 |
|
|
|
|
| 22 |
*/
|
| 23 |
const MAX_SEQUENCE_LENGTH = 256;
|
| 24 |
|
| 25 |
+
/** Maximum concurrent single-text inference calls within each chunk. */
|
|
|
|
| 26 |
const BATCH_SIZE = 64;
|
| 27 |
|
| 28 |
let isReady = false;
|
server/handleTokenVerification.test.ts
CHANGED
|
@@ -44,15 +44,35 @@ function makeResponse() {
|
|
| 44 |
} as unknown as ServerResponse & { end: ReturnType<typeof vi.fn> };
|
| 45 |
}
|
| 46 |
|
| 47 |
-
async function verify(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
verifyMock.mockResolvedValue(result);
|
| 49 |
const response = makeResponse();
|
| 50 |
-
const outcome = await handleTokenVerification(
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
| 53 |
return { response, ...outcome };
|
| 54 |
}
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
/**
|
| 57 |
* The payload is integers all the way down. A per-client dimension would have
|
| 58 |
* to arrive either as a new key or as a leaf that stopped being a number, so
|
|
@@ -219,4 +239,152 @@ describe("handleTokenVerification", () => {
|
|
| 219 |
rateLimitedBefore + 1,
|
| 220 |
);
|
| 221 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
});
|
|
|
|
| 44 |
} as unknown as ServerResponse & { end: ReturnType<typeof vi.fn> };
|
| 45 |
}
|
| 46 |
|
| 47 |
+
async function verify(
|
| 48 |
+
url: string,
|
| 49 |
+
result: TokenVerificationResult,
|
| 50 |
+
request: { headers?: Record<string, string>; token?: string } = {},
|
| 51 |
+
) {
|
| 52 |
verifyMock.mockResolvedValue(result);
|
| 53 |
const response = makeResponse();
|
| 54 |
+
const outcome = await handleTokenVerification(
|
| 55 |
+
request.token ?? "token",
|
| 56 |
+
response,
|
| 57 |
+
{ url, headers: request.headers ?? {} } as IncomingMessage,
|
| 58 |
+
);
|
| 59 |
return { response, ...outcome };
|
| 60 |
}
|
| 61 |
|
| 62 |
+
/** What a browser sends when the address bar or a bookmark opens a URL. */
|
| 63 |
+
const BROWSER_NAVIGATION_ACCEPT =
|
| 64 |
+
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8";
|
| 65 |
+
|
| 66 |
+
function bodyOf(response: ReturnType<typeof makeResponse>): string {
|
| 67 |
+
return response.end.mock.calls[0][0];
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
function contentTypeOf(response: ReturnType<typeof makeResponse>): string {
|
| 71 |
+
const calls = vi.mocked(response.setHeader).mock.calls;
|
| 72 |
+
const contentType = calls.find(([name]) => name === "Content-Type");
|
| 73 |
+
return contentType ? String(contentType[1]) : "";
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
/**
|
| 77 |
* The payload is integers all the way down. A per-client dimension would have
|
| 78 |
* to arrive either as a new key or as a leaf that stopped being a number, so
|
|
|
|
| 239 |
rateLimitedBefore + 1,
|
| 240 |
);
|
| 241 |
});
|
| 242 |
+
|
| 243 |
+
describe("when the request looks like a browser navigation", () => {
|
| 244 |
+
it("answers an invalid token with a page a person can read", async () => {
|
| 245 |
+
const { shouldContinue, response } = await verify(
|
| 246 |
+
"/search/text?q=hello&token=stale",
|
| 247 |
+
REJECTIONS.invalidToken,
|
| 248 |
+
{ headers: { accept: BROWSER_NAVIGATION_ACCEPT } },
|
| 249 |
+
);
|
| 250 |
+
|
| 251 |
+
expect(shouldContinue).toBe(false);
|
| 252 |
+
expect(response.statusCode).toBe(401);
|
| 253 |
+
expect(contentTypeOf(response)).toBe("text/html; charset=utf-8");
|
| 254 |
+
|
| 255 |
+
const body = bodyOf(response);
|
| 256 |
+
expect(body.startsWith("<!doctype html>")).toBe(true);
|
| 257 |
+
expect(body).toContain("rotated its search token");
|
| 258 |
+
expect(body).toContain("stops working");
|
| 259 |
+
expect(body).toContain('href="/"');
|
| 260 |
+
expect(body).not.toContain("<script");
|
| 261 |
+
});
|
| 262 |
+
|
| 263 |
+
it("answers a missing token the same way, keeping its own status", async () => {
|
| 264 |
+
const { response } = await verify(
|
| 265 |
+
"/search/text?q=hello",
|
| 266 |
+
REJECTIONS.missingToken,
|
| 267 |
+
{ headers: { accept: BROWSER_NAVIGATION_ACCEPT } },
|
| 268 |
+
);
|
| 269 |
+
|
| 270 |
+
expect(response.statusCode).toBe(400);
|
| 271 |
+
expect(contentTypeOf(response)).toBe("text/html; charset=utf-8");
|
| 272 |
+
expect(bodyOf(response)).toContain('href="/"');
|
| 273 |
+
});
|
| 274 |
+
|
| 275 |
+
it("keeps the JSON body for a rate-limited request", async () => {
|
| 276 |
+
const { response } = await verify(
|
| 277 |
+
"/search/text",
|
| 278 |
+
REJECTIONS.rateLimited,
|
| 279 |
+
{ headers: { accept: BROWSER_NAVIGATION_ACCEPT } },
|
| 280 |
+
);
|
| 281 |
+
|
| 282 |
+
expect(response.statusCode).toBe(429);
|
| 283 |
+
expect(contentTypeOf(response)).toBe("application/json");
|
| 284 |
+
expect(bodyOf(response)).toBe(
|
| 285 |
+
JSON.stringify({ error: "Too many requests." }),
|
| 286 |
+
);
|
| 287 |
+
});
|
| 288 |
+
|
| 289 |
+
it("is decided by an Accept header that names text/html", async () => {
|
| 290 |
+
for (const accept of [
|
| 291 |
+
"text/html",
|
| 292 |
+
"TEXT/HTML",
|
| 293 |
+
"text/html; charset=utf-8",
|
| 294 |
+
"application/json, text/html;q=0.5",
|
| 295 |
+
]) {
|
| 296 |
+
const { response } = await verify(
|
| 297 |
+
"/search/text",
|
| 298 |
+
REJECTIONS.invalidToken,
|
| 299 |
+
{ headers: { accept } },
|
| 300 |
+
);
|
| 301 |
+
|
| 302 |
+
expect(contentTypeOf(response), accept).toBe(
|
| 303 |
+
"text/html; charset=utf-8",
|
| 304 |
+
);
|
| 305 |
+
}
|
| 306 |
+
});
|
| 307 |
+
|
| 308 |
+
it("carries no token, sets no cookie and redirects nowhere", async () => {
|
| 309 |
+
const { response } = await verify(
|
| 310 |
+
"/search/text?q=hello&token=stale",
|
| 311 |
+
REJECTIONS.invalidToken,
|
| 312 |
+
{ headers: { accept: BROWSER_NAVIGATION_ACCEPT }, token: "stale" },
|
| 313 |
+
);
|
| 314 |
+
|
| 315 |
+
const headerNames = vi
|
| 316 |
+
.mocked(response.setHeader)
|
| 317 |
+
.mock.calls.map(([name]) => String(name).toLowerCase());
|
| 318 |
+
expect(headerNames).toEqual(["content-type"]);
|
| 319 |
+
expect(response.statusCode).toBe(401);
|
| 320 |
+
expect(bodyOf(response)).not.toContain("token=");
|
| 321 |
+
expect(bodyOf(response)).not.toContain("stale");
|
| 322 |
+
});
|
| 323 |
+
|
| 324 |
+
it("echoes nothing from the request", async () => {
|
| 325 |
+
const marker = "borogoves";
|
| 326 |
+
const { response: first } = await verify(
|
| 327 |
+
`/search/text?q=${marker}&token=${marker}`,
|
| 328 |
+
REJECTIONS.invalidToken,
|
| 329 |
+
{
|
| 330 |
+
headers: {
|
| 331 |
+
accept: `text/html;${marker}=1`,
|
| 332 |
+
referer: `https://${marker}.example/`,
|
| 333 |
+
},
|
| 334 |
+
token: marker,
|
| 335 |
+
},
|
| 336 |
+
);
|
| 337 |
+
const { response: second } = await verify(
|
| 338 |
+
"/page-content",
|
| 339 |
+
REJECTIONS.missingToken,
|
| 340 |
+
{ headers: { accept: "text/html" } },
|
| 341 |
+
);
|
| 342 |
+
|
| 343 |
+
expect(bodyOf(first)).not.toContain(marker);
|
| 344 |
+
expect(bodyOf(first)).toBe(bodyOf(second));
|
| 345 |
+
});
|
| 346 |
+
|
| 347 |
+
it("counts the rejection the same way as the JSON answer", async () => {
|
| 348 |
+
const before = getAuthorizationStats();
|
| 349 |
+
|
| 350 |
+
await verify("/search/text", REJECTIONS.invalidToken, {
|
| 351 |
+
headers: { accept: BROWSER_NAVIGATION_ACCEPT },
|
| 352 |
+
});
|
| 353 |
+
|
| 354 |
+
const after = getAuthorizationStats();
|
| 355 |
+
expect(after.requests).toBe(before.requests + 1);
|
| 356 |
+
expect(after.authorized).toBe(before.authorized);
|
| 357 |
+
expect(after.reasons.invalidToken).toBe(before.reasons.invalidToken + 1);
|
| 358 |
+
expect(after.bySurface.search.rejected).toBe(
|
| 359 |
+
before.bySurface.search.rejected + 1,
|
| 360 |
+
);
|
| 361 |
+
});
|
| 362 |
+
});
|
| 363 |
+
|
| 364 |
+
describe("when the request does not look like a browser navigation", () => {
|
| 365 |
+
it.each([
|
| 366 |
+
["no Accept header", undefined],
|
| 367 |
+
["Accept: */*", "*/*"],
|
| 368 |
+
["Accept: application/json", "application/json"],
|
| 369 |
+
["an Accept list without text/html", "application/json, text/plain, */*"],
|
| 370 |
+
["text/html refused with q=0", "text/html;q=0, application/json"],
|
| 371 |
+
["text/* only", "text/*"],
|
| 372 |
+
])("keeps the JSON body byte for byte with %s", async (_, accept) => {
|
| 373 |
+
const { shouldContinue, response } = await verify(
|
| 374 |
+
"/search/text",
|
| 375 |
+
REJECTIONS.invalidToken,
|
| 376 |
+
{ headers: accept === undefined ? {} : { accept } },
|
| 377 |
+
);
|
| 378 |
+
|
| 379 |
+
expect(shouldContinue).toBe(false);
|
| 380 |
+
expect(response.statusCode).toBe(401);
|
| 381 |
+
expect(response.setHeader).toHaveBeenCalledWith(
|
| 382 |
+
"Content-Type",
|
| 383 |
+
"application/json",
|
| 384 |
+
);
|
| 385 |
+
expect(bodyOf(response)).toBe(
|
| 386 |
+
JSON.stringify({ error: "Invalid token." }),
|
| 387 |
+
);
|
| 388 |
+
});
|
| 389 |
+
});
|
| 390 |
});
|
server/handleTokenVerification.ts
CHANGED
|
@@ -5,7 +5,10 @@ import {
|
|
| 5 |
recordAuthorizedRequest,
|
| 6 |
recordRejectedRequest,
|
| 7 |
} from "./authorizationSinceLastRestart.ts";
|
| 8 |
-
import {
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
const SURFACES: [pathPrefix: string, surface: AuthorizationSurface][] = [
|
| 11 |
["/search", "search"],
|
|
@@ -14,6 +17,53 @@ const SURFACES: [pathPrefix: string, surface: AuthorizationSurface][] = [
|
|
| 14 |
["/inference", "inference"],
|
| 15 |
];
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
/**
|
| 18 |
* The endpoint family a request was aimed at. `request.url` carries the query
|
| 19 |
* string too, but the match is anchored at the start of it and only the label
|
|
@@ -25,6 +75,31 @@ function resolveSurface(request?: IncomingMessage): AuthorizationSurface {
|
|
| 25 |
return match ? match[1] : "other";
|
| 26 |
}
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
export async function handleTokenVerification(
|
| 29 |
token: string | null,
|
| 30 |
response: ServerResponse,
|
|
@@ -41,6 +116,13 @@ export async function handleTokenVerification(
|
|
| 41 |
if (!result.isAuthorized) {
|
| 42 |
recordRejectedRequest(surface, result.reason);
|
| 43 |
response.statusCode = result.statusCode;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
response.setHeader("Content-Type", "application/json");
|
| 45 |
response.end(JSON.stringify({ error: result.error }));
|
| 46 |
return { shouldContinue: false };
|
|
|
|
| 5 |
recordAuthorizedRequest,
|
| 6 |
recordRejectedRequest,
|
| 7 |
} from "./authorizationSinceLastRestart.ts";
|
| 8 |
+
import {
|
| 9 |
+
type RejectionReason,
|
| 10 |
+
verifyTokenAndRateLimit,
|
| 11 |
+
} from "./verifyTokenAndRateLimit.ts";
|
| 12 |
|
| 13 |
const SURFACES: [pathPrefix: string, surface: AuthorizationSurface][] = [
|
| 14 |
["/search", "search"],
|
|
|
|
| 17 |
["/inference", "inference"],
|
| 18 |
];
|
| 19 |
|
| 20 |
+
/**
|
| 21 |
+
* The rejections a person can land on by opening a search URL that was
|
| 22 |
+
* bookmarked, shared or embedded before this instance rotated its token, or
|
| 23 |
+
* one that had the token stripped from it on the way. A rate-limited request
|
| 24 |
+
* is not one of them: the same URL works again a few seconds later, and the
|
| 25 |
+
* page below would be telling that person the wrong story.
|
| 26 |
+
*/
|
| 27 |
+
const REASONS_WITH_A_PAGE: ReadonlySet<RejectionReason> = new Set([
|
| 28 |
+
"missingToken",
|
| 29 |
+
"invalidToken",
|
| 30 |
+
]);
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Static on purpose: nothing from the request goes in, so a caller cannot get
|
| 34 |
+
* its own query, URL or header reflected back in a `text/html` body. The page
|
| 35 |
+
* links to the root, where the app takes a token through `/api/config` the way
|
| 36 |
+
* it always does; it carries no token itself and redirects nowhere, because
|
| 37 |
+
* handing a token to any caller that turned up with a bad one would remove the
|
| 38 |
+
* CSRF gate (`docs/security.md`, Search Token Lifecycle). No script and no
|
| 39 |
+
* external asset, so it renders even where the instance is the only reachable
|
| 40 |
+
* host.
|
| 41 |
+
*/
|
| 42 |
+
const EXPIRED_SEARCH_LINK_PAGE = `<!doctype html>
|
| 43 |
+
<html lang="en">
|
| 44 |
+
<head>
|
| 45 |
+
<meta charset="utf-8">
|
| 46 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 47 |
+
<meta name="robots" content="noindex">
|
| 48 |
+
<title>This search link has expired</title>
|
| 49 |
+
<style>
|
| 50 |
+
body { margin: 0; padding: 3rem 1.5rem; font: 1rem/1.6 system-ui, sans-serif; color: #1a1a1a; background: #fff; }
|
| 51 |
+
main { max-width: 36rem; margin: 0 auto; }
|
| 52 |
+
h1 { font-size: 1.5rem; line-height: 1.3; }
|
| 53 |
+
a { color: #0b5fff; }
|
| 54 |
+
@media (prefers-color-scheme: dark) { body { color: #ededed; background: #121212; } a { color: #8ab4ff; } }
|
| 55 |
+
</style>
|
| 56 |
+
</head>
|
| 57 |
+
<body>
|
| 58 |
+
<main>
|
| 59 |
+
<h1>This search link has expired</h1>
|
| 60 |
+
<p>This MiniSearch instance rotated its search token. Every search URL carries that token, so a bookmarked, shared or embedded search URL stops working when the token changes.</p>
|
| 61 |
+
<p><a href="/">Open MiniSearch</a> and search again from there. A search started on that page uses the current token.</p>
|
| 62 |
+
</main>
|
| 63 |
+
</body>
|
| 64 |
+
</html>
|
| 65 |
+
`;
|
| 66 |
+
|
| 67 |
/**
|
| 68 |
* The endpoint family a request was aimed at. `request.url` carries the query
|
| 69 |
* string too, but the match is anchored at the start of it and only the label
|
|
|
|
| 75 |
return match ? match[1] : "other";
|
| 76 |
}
|
| 77 |
|
| 78 |
+
/**
|
| 79 |
+
* Whether the request looks like a browser navigation, which means its
|
| 80 |
+
* `Accept` header asks for `text/html` by name. A navigation sends
|
| 81 |
+
* `text/html,application/xhtml+xml,...`, while `fetch` sends only the wildcard
|
| 82 |
+
* and API clients send `application/json` or nothing, and all of those keep
|
| 83 |
+
* the JSON body. An entry with `q=0` is a refusal, not a request, so it does
|
| 84 |
+
* not count either.
|
| 85 |
+
*/
|
| 86 |
+
function asksForHtml(request?: IncomingMessage): boolean {
|
| 87 |
+
const accept = request?.headers.accept;
|
| 88 |
+
if (typeof accept !== "string") return false;
|
| 89 |
+
|
| 90 |
+
return accept.split(",").some((entry) => {
|
| 91 |
+
const [mediaType, ...parameters] = entry.split(";");
|
| 92 |
+
if (mediaType.trim().toLowerCase() !== "text/html") return false;
|
| 93 |
+
|
| 94 |
+
return !parameters.some((parameter) => {
|
| 95 |
+
const [name, value] = parameter.split("=");
|
| 96 |
+
return (
|
| 97 |
+
name.trim().toLowerCase() === "q" && Number.parseFloat(value) === 0
|
| 98 |
+
);
|
| 99 |
+
});
|
| 100 |
+
});
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
export async function handleTokenVerification(
|
| 104 |
token: string | null,
|
| 105 |
response: ServerResponse,
|
|
|
|
| 116 |
if (!result.isAuthorized) {
|
| 117 |
recordRejectedRequest(surface, result.reason);
|
| 118 |
response.statusCode = result.statusCode;
|
| 119 |
+
|
| 120 |
+
if (REASONS_WITH_A_PAGE.has(result.reason) && asksForHtml(request)) {
|
| 121 |
+
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
| 122 |
+
response.end(EXPIRED_SEARCH_LINK_PAGE);
|
| 123 |
+
return { shouldContinue: false };
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
response.setHeader("Content-Type", "application/json");
|
| 127 |
response.end(JSON.stringify({ error: result.error }));
|
| 128 |
return { shouldContinue: false };
|
server/pageContentService.test.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
| 1 |
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
import { repository, version } from "../package.json" with { type: "json" };
|
| 3 |
|
|
|
|
|
|
|
|
|
|
| 4 |
const lookupMock = vi.hoisted(() => vi.fn());
|
| 5 |
const pdfGetTextMock = vi.hoisted(() => vi.fn());
|
| 6 |
const pdfDestroyMock = vi.hoisted(() => vi.fn());
|
|
@@ -24,6 +27,10 @@ import {
|
|
| 24 |
splitIntoPassages,
|
| 25 |
splitLongPassage,
|
| 26 |
} from "./pageContentService";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
import { getPageReadStats } from "./pageReadsSinceLastRestart";
|
| 28 |
|
| 29 |
const fetchMock = vi.fn();
|
|
@@ -54,6 +61,7 @@ ${paragraph("Cats sleep between twelve and sixteen hours a day.")}
|
|
| 54 |
</body></html>`;
|
| 55 |
|
| 56 |
beforeEach(() => {
|
|
|
|
| 57 |
fetchMock.mockReset();
|
| 58 |
lookupMock.mockReset();
|
| 59 |
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
|
@@ -63,6 +71,30 @@ beforeEach(() => {
|
|
| 63 |
pdfDestroyMock.mockResolvedValue(undefined);
|
| 64 |
});
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
describe("extractReadableText", () => {
|
| 67 |
it("keeps the article and drops site chrome, scripts and styles", () => {
|
| 68 |
const text = extractReadableText(articlePage);
|
|
@@ -381,93 +413,81 @@ describe("selectPassages across scripts", () => {
|
|
| 381 |
}
|
| 382 |
});
|
| 383 |
|
| 384 |
-
describe("
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 395 |
}
|
| 396 |
-
return fetchPageContents("test query", urls);
|
| 397 |
-
}
|
| 398 |
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
// The syndicated paragraph should appear in only one of the two responses.
|
| 414 |
-
// Text extraction trims trailing whitespace, so match a trimmed marker
|
| 415 |
-
// rather than the exact paragraph text.
|
| 416 |
-
const marker = syndicated.trimEnd();
|
| 417 |
-
const aText = contents.find((c) => c.url.includes("/a"))?.content ?? "";
|
| 418 |
-
const bText = contents.find((c) => c.url.includes("/b"))?.content ?? "";
|
| 419 |
-
const count = (aText + bText).split(marker).length - 1;
|
| 420 |
-
expect(count).toBe(1);
|
| 421 |
-
});
|
| 422 |
-
|
| 423 |
-
it("drops a page whose passages are all near-duplicates of an earlier page", async () => {
|
| 424 |
-
// Page A has one passage. Page B has one near-duplicate passage
|
| 425 |
-
// (Jaccard >= 0.9). The new global dedup should suppress B's copy,
|
| 426 |
-
// leaving B with no selected passages so it vanishes from the response.
|
| 427 |
-
// The old per-page code would have returned both pages.
|
| 428 |
-
// Note: with a query that matches no terms, A wins purely on flatten
|
| 429 |
-
// order (lower global index), not on relevance. The test guards the
|
| 430 |
-
// dedup drop path, not the ranking order.
|
| 431 |
-
const base =
|
| 432 |
-
"word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 ";
|
| 433 |
-
const passageA = base.repeat(15);
|
| 434 |
-
const passageB = `${base}word11 `.repeat(15);
|
| 435 |
-
|
| 436 |
-
const contents = await selectFrom(
|
| 437 |
-
["https://example.com/a", "https://example.com/b"],
|
| 438 |
-
[
|
| 439 |
-
`<article><p>${passageA}</p></article>`,
|
| 440 |
-
`<article><p>${passageB}</p></article>`,
|
| 441 |
-
],
|
| 442 |
-
);
|
| 443 |
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
count - before.skipped[outcome as keyof typeof before.skipped],
|
| 464 |
-
])
|
| 465 |
-
.filter(([, count]) => count !== 0),
|
| 466 |
-
),
|
| 467 |
-
bodiesTruncated: after.bodiesTruncated - before.bodiesTruncated,
|
| 468 |
-
};
|
| 469 |
-
}
|
| 470 |
|
|
|
|
| 471 |
it("counts a page it could read", async () => {
|
| 472 |
respondWithHtml(articlePage);
|
| 473 |
|
|
@@ -485,10 +505,27 @@ describe("page read counters", () => {
|
|
| 485 |
});
|
| 486 |
});
|
| 487 |
|
| 488 |
-
it("
|
| 489 |
fetchMock.mockResolvedValue(new Response("nope", { status: 403 }));
|
| 490 |
expect((await countOutcomes("https://example.com/x")).skipped).toEqual({
|
| 491 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 492 |
});
|
| 493 |
|
| 494 |
// A PDF is no longer dropped as notADocument; it goes through the passage
|
|
@@ -579,19 +616,222 @@ describe("page read counters", () => {
|
|
| 579 |
expect(stats.excerptKeptRate).toBeLessThan(65);
|
| 580 |
});
|
| 581 |
|
| 582 |
-
it("records no query or
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 583 |
respondWithHtml(articlePage);
|
| 584 |
-
await fetchPageContents(
|
| 585 |
-
"
|
| 586 |
-
|
|
|
|
|
|
|
| 587 |
|
| 588 |
-
const reported = JSON.stringify(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 589 |
|
| 590 |
expect(reported).not.toContain("distinctive");
|
| 591 |
expect(reported).not.toContain("secret.example.com");
|
| 592 |
});
|
| 593 |
});
|
| 594 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 595 |
describe("fetchPageContents", () => {
|
| 596 |
it("identifies itself with the name, version and repository in package.json", async () => {
|
| 597 |
respondWithHtml(articlePage);
|
|
@@ -798,3 +1038,117 @@ describe("fetchPageContents", () => {
|
|
| 798 |
expect(chunksSent).toBeLessThan(40);
|
| 799 |
});
|
| 800 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
import { repository, version } from "../package.json" with { type: "json" };
|
| 3 |
|
| 4 |
+
const scorePassagesMock = vi.hoisted(() => vi.fn());
|
| 5 |
+
vi.mock("./biEncoderService.ts", () => ({ scorePassages: scorePassagesMock }));
|
| 6 |
+
|
| 7 |
const lookupMock = vi.hoisted(() => vi.fn());
|
| 8 |
const pdfGetTextMock = vi.hoisted(() => vi.fn());
|
| 9 |
const pdfDestroyMock = vi.hoisted(() => vi.fn());
|
|
|
|
| 27 |
splitIntoPassages,
|
| 28 |
splitLongPassage,
|
| 29 |
} from "./pageContentService";
|
| 30 |
+
import {
|
| 31 |
+
getPageReadCircuitStats,
|
| 32 |
+
PageReadHostBreaker,
|
| 33 |
+
} from "./pageReadHostBreaker";
|
| 34 |
import { getPageReadStats } from "./pageReadsSinceLastRestart";
|
| 35 |
|
| 36 |
const fetchMock = vi.fn();
|
|
|
|
| 61 |
</body></html>`;
|
| 62 |
|
| 63 |
beforeEach(() => {
|
| 64 |
+
scorePassagesMock.mockReset().mockResolvedValue([]);
|
| 65 |
fetchMock.mockReset();
|
| 66 |
lookupMock.mockReset();
|
| 67 |
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
|
|
|
| 71 |
pdfDestroyMock.mockResolvedValue(undefined);
|
| 72 |
});
|
| 73 |
|
| 74 |
+
/**
|
| 75 |
+
* Counts what one call to `fetchPageContents` added, by outcome. A fresh
|
| 76 |
+
* breaker per call unless one is passed, so the refusals a case injects never
|
| 77 |
+
* add up to a host being skipped in a later one.
|
| 78 |
+
*/
|
| 79 |
+
async function countOutcomes(url: string, breaker = new PageReadHostBreaker()) {
|
| 80 |
+
const before = getPageReadStats();
|
| 81 |
+
await fetchPageContents("cats", [url], breaker);
|
| 82 |
+
const after = getPageReadStats();
|
| 83 |
+
|
| 84 |
+
return {
|
| 85 |
+
read: after.read - before.read,
|
| 86 |
+
skipped: Object.fromEntries(
|
| 87 |
+
Object.entries(after.skipped)
|
| 88 |
+
.map(([outcome, count]) => [
|
| 89 |
+
outcome,
|
| 90 |
+
count - before.skipped[outcome as keyof typeof before.skipped],
|
| 91 |
+
])
|
| 92 |
+
.filter(([, count]) => count !== 0),
|
| 93 |
+
),
|
| 94 |
+
bodiesTruncated: after.bodiesTruncated - before.bodiesTruncated,
|
| 95 |
+
};
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
describe("extractReadableText", () => {
|
| 99 |
it("keeps the article and drops site chrome, scripts and styles", () => {
|
| 100 |
const text = extractReadableText(articlePage);
|
|
|
|
| 413 |
}
|
| 414 |
});
|
| 415 |
|
| 416 |
+
describe.each(["lexical", "dense"])(
|
| 417 |
+
"selectPassagesAcrossPages (%s)",
|
| 418 |
+
(mode) => {
|
| 419 |
+
// Helper to call the internal selector indirectly through fetchPageContents.
|
| 420 |
+
async function selectFrom(urls: string[], htmls: string[]) {
|
| 421 |
+
if (mode === "dense") {
|
| 422 |
+
scorePassagesMock.mockImplementation(async (_query, pool: string[]) =>
|
| 423 |
+
pool.map((_, i) => -i),
|
| 424 |
+
);
|
| 425 |
+
}
|
| 426 |
+
fetchMock.mockReset();
|
| 427 |
+
for (const html of htmls) {
|
| 428 |
+
fetchMock.mockResolvedValueOnce(
|
| 429 |
+
new Response(html, {
|
| 430 |
+
status: 200,
|
| 431 |
+
headers: { "content-type": "text/html" },
|
| 432 |
+
}),
|
| 433 |
+
);
|
| 434 |
+
}
|
| 435 |
+
return fetchPageContents("test query", urls);
|
| 436 |
}
|
|
|
|
|
|
|
| 437 |
|
| 438 |
+
it("suppresses a syndicated paragraph that appears on multiple pages", async () => {
|
| 439 |
+
const syndicated =
|
| 440 |
+
"This paragraph is syndicated across many sites. ".repeat(10);
|
| 441 |
+
const uniqueA = "Unique content on page A. ".repeat(10);
|
| 442 |
+
const uniqueB = "Unique content on page B. ".repeat(10);
|
| 443 |
+
|
| 444 |
+
const contents = await selectFrom(
|
| 445 |
+
["https://example.com/a", "https://example.com/b"],
|
| 446 |
+
[
|
| 447 |
+
`<article><p>${uniqueA}</p><p>${syndicated}</p></article>`,
|
| 448 |
+
`<article><p>${syndicated}</p><p>${uniqueB}</p></article>`,
|
| 449 |
+
],
|
| 450 |
+
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
|
| 452 |
+
// The syndicated paragraph should appear in only one of the two responses.
|
| 453 |
+
// Text extraction trims trailing whitespace, so match a trimmed marker
|
| 454 |
+
// rather than the exact paragraph text.
|
| 455 |
+
const marker = syndicated.trimEnd();
|
| 456 |
+
const aText = contents.find((c) => c.url.includes("/a"))?.content ?? "";
|
| 457 |
+
const bText = contents.find((c) => c.url.includes("/b"))?.content ?? "";
|
| 458 |
+
const count = (aText + bText).split(marker).length - 1;
|
| 459 |
+
expect(count).toBe(1);
|
| 460 |
+
});
|
| 461 |
|
| 462 |
+
it("drops a page whose passages are all near-duplicates of an earlier page", async () => {
|
| 463 |
+
// Page A has one passage. Page B has one near-duplicate passage
|
| 464 |
+
// (Jaccard >= 0.9). The new global dedup should suppress B's copy,
|
| 465 |
+
// leaving B with no selected passages so it vanishes from the response.
|
| 466 |
+
// The old per-page code would have returned both pages.
|
| 467 |
+
// Note: with a query that matches no terms, A wins purely on flatten
|
| 468 |
+
// order (lower global index), not on relevance. The test guards the
|
| 469 |
+
// dedup drop path, not the ranking order.
|
| 470 |
+
const base =
|
| 471 |
+
"word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 ";
|
| 472 |
+
const passageA = base.repeat(15);
|
| 473 |
+
const passageB = `${base}word11 `.repeat(15);
|
| 474 |
+
|
| 475 |
+
const contents = await selectFrom(
|
| 476 |
+
["https://example.com/a", "https://example.com/b"],
|
| 477 |
+
[
|
| 478 |
+
`<article><p>${passageA}</p></article>`,
|
| 479 |
+
`<article><p>${passageB}</p></article>`,
|
| 480 |
+
],
|
| 481 |
+
);
|
| 482 |
|
| 483 |
+
// Only page A should survive; page B's sole passage is a near-duplicate.
|
| 484 |
+
expect(contents).toHaveLength(1);
|
| 485 |
+
expect(contents[0].url).toContain("/a");
|
| 486 |
+
});
|
| 487 |
+
},
|
| 488 |
+
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
|
| 490 |
+
describe("page read counters", () => {
|
| 491 |
it("counts a page it could read", async () => {
|
| 492 |
respondWithHtml(articlePage);
|
| 493 |
|
|
|
|
| 505 |
});
|
| 506 |
});
|
| 507 |
|
| 508 |
+
it("classifies HTTP error statuses by class and tells them apart from non-documents", async () => {
|
| 509 |
fetchMock.mockResolvedValue(new Response("nope", { status: 403 }));
|
| 510 |
expect((await countOutcomes("https://example.com/x")).skipped).toEqual({
|
| 511 |
+
httpForbidden: 1,
|
| 512 |
+
});
|
| 513 |
+
|
| 514 |
+
fetchMock.mockResolvedValue(new Response("rate limited", { status: 429 }));
|
| 515 |
+
expect((await countOutcomes("https://example.com/rate")).skipped).toEqual({
|
| 516 |
+
httpForbidden: 1,
|
| 517 |
+
});
|
| 518 |
+
|
| 519 |
+
fetchMock.mockResolvedValue(new Response("missing", { status: 404 }));
|
| 520 |
+
expect(
|
| 521 |
+
(await countOutcomes("https://example.com/missing")).skipped,
|
| 522 |
+
).toEqual({
|
| 523 |
+
httpNotFound: 1,
|
| 524 |
+
});
|
| 525 |
+
|
| 526 |
+
fetchMock.mockResolvedValue(new Response("server error", { status: 500 }));
|
| 527 |
+
expect((await countOutcomes("https://example.com/err")).skipped).toEqual({
|
| 528 |
+
httpOtherError: 1,
|
| 529 |
});
|
| 530 |
|
| 531 |
// A PDF is no longer dropped as notADocument; it goes through the passage
|
|
|
|
| 616 |
expect(stats.excerptKeptRate).toBeLessThan(65);
|
| 617 |
});
|
| 618 |
|
| 619 |
+
it("records no query, URL or host anywhere in what it reports", async () => {
|
| 620 |
+
// Refused enough to box the host, so the breaker is holding its name in
|
| 621 |
+
// memory at the moment the stats are read.
|
| 622 |
+
const breaker = new PageReadHostBreaker({ failureThreshold: 1 });
|
| 623 |
+
fetchMock.mockResolvedValue(new Response("nope", { status: 403 }));
|
| 624 |
+
await fetchPageContents(
|
| 625 |
+
"a very distinctive private query",
|
| 626 |
+
["https://secret.example.com/wall"],
|
| 627 |
+
breaker,
|
| 628 |
+
);
|
| 629 |
respondWithHtml(articlePage);
|
| 630 |
+
await fetchPageContents(
|
| 631 |
+
"a very distinctive private query",
|
| 632 |
+
["https://secret.example.com/private-page"],
|
| 633 |
+
breaker,
|
| 634 |
+
);
|
| 635 |
|
| 636 |
+
const reported = JSON.stringify({
|
| 637 |
+
...getPageReadStats(),
|
| 638 |
+
...getPageReadCircuitStats(),
|
| 639 |
+
circuitOpens: breaker.getOpens(),
|
| 640 |
+
});
|
| 641 |
|
| 642 |
expect(reported).not.toContain("distinctive");
|
| 643 |
expect(reported).not.toContain("secret.example.com");
|
| 644 |
});
|
| 645 |
});
|
| 646 |
|
| 647 |
+
describe("host circuit breaker", () => {
|
| 648 |
+
// A short window on fake timers, so that "still boxed" holds for as long as
|
| 649 |
+
// the test says and not for however long the machine takes to reach the
|
| 650 |
+
// next line.
|
| 651 |
+
const breakerOptions = {
|
| 652 |
+
failureThreshold: 3,
|
| 653 |
+
resetTimeout: 1000,
|
| 654 |
+
successThreshold: 1,
|
| 655 |
+
};
|
| 656 |
+
|
| 657 |
+
beforeEach(() => {
|
| 658 |
+
vi.useFakeTimers();
|
| 659 |
+
});
|
| 660 |
+
|
| 661 |
+
afterEach(() => {
|
| 662 |
+
vi.useRealTimers();
|
| 663 |
+
});
|
| 664 |
+
|
| 665 |
+
function refuse(status: number) {
|
| 666 |
+
fetchMock.mockResolvedValue(new Response("nope", { status }));
|
| 667 |
+
}
|
| 668 |
+
|
| 669 |
+
async function boxHost(breaker: PageReadHostBreaker, host: string) {
|
| 670 |
+
refuse(403);
|
| 671 |
+
for (const page of ["a", "b", "c"]) {
|
| 672 |
+
await countOutcomes(`https://${host}/${page}`, breaker);
|
| 673 |
+
}
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
it("skips a host after three refusals in a row, without a request, and counts the skip", async () => {
|
| 677 |
+
const breaker = new PageReadHostBreaker(breakerOptions);
|
| 678 |
+
refuse(403);
|
| 679 |
+
|
| 680 |
+
for (const page of ["a", "b", "c"]) {
|
| 681 |
+
expect(
|
| 682 |
+
(await countOutcomes(`https://wall.example.com/${page}`, breaker))
|
| 683 |
+
.skipped,
|
| 684 |
+
).toEqual({ httpForbidden: 1 });
|
| 685 |
+
}
|
| 686 |
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
| 687 |
+
expect(breaker.getOpens()).toBe(1);
|
| 688 |
+
|
| 689 |
+
// Boxed now: the fourth read is refused here and never reaches the host,
|
| 690 |
+
// and it is counted, so read plus skipped still adds up to requested.
|
| 691 |
+
respondWithHtml(articlePage);
|
| 692 |
+
expect(
|
| 693 |
+
(await countOutcomes("https://wall.example.com/d", breaker)).skipped,
|
| 694 |
+
).toEqual({ skippedByBreaker: 1 });
|
| 695 |
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
| 696 |
+
|
| 697 |
+
// The box is per host.
|
| 698 |
+
expect(
|
| 699 |
+
(await countOutcomes("https://open.example.com/", breaker)).read,
|
| 700 |
+
).toBe(1);
|
| 701 |
+
});
|
| 702 |
+
|
| 703 |
+
it("counts a timeout and a dropped connection toward the box, as an HTTP error does", async () => {
|
| 704 |
+
const breaker = new PageReadHostBreaker(breakerOptions);
|
| 705 |
+
const timeout = new Error("The operation was aborted due to timeout");
|
| 706 |
+
timeout.name = "TimeoutError";
|
| 707 |
+
|
| 708 |
+
fetchMock.mockRejectedValue(timeout);
|
| 709 |
+
await countOutcomes("https://slow.example.com/a", breaker);
|
| 710 |
+
fetchMock.mockRejectedValue(new TypeError("fetch failed"));
|
| 711 |
+
await countOutcomes("https://slow.example.com/b", breaker);
|
| 712 |
+
refuse(503);
|
| 713 |
+
await countOutcomes("https://slow.example.com/c", breaker);
|
| 714 |
+
|
| 715 |
+
respondWithHtml(articlePage);
|
| 716 |
+
expect(
|
| 717 |
+
(await countOutcomes("https://slow.example.com/d", breaker)).skipped,
|
| 718 |
+
).toEqual({ skippedByBreaker: 1 });
|
| 719 |
+
});
|
| 720 |
+
|
| 721 |
+
it("does not count a dead link or a thin page, and either one ends a run of refusals", async () => {
|
| 722 |
+
const breaker = new PageReadHostBreaker(breakerOptions);
|
| 723 |
+
|
| 724 |
+
refuse(403);
|
| 725 |
+
await countOutcomes("https://mixed.example.com/a", breaker);
|
| 726 |
+
await countOutcomes("https://mixed.example.com/b", breaker);
|
| 727 |
+
// The host answered, so it is not refusing: the run starts over.
|
| 728 |
+
refuse(404);
|
| 729 |
+
await countOutcomes("https://mixed.example.com/gone", breaker);
|
| 730 |
+
refuse(403);
|
| 731 |
+
await countOutcomes("https://mixed.example.com/c", breaker);
|
| 732 |
+
await countOutcomes("https://mixed.example.com/d", breaker);
|
| 733 |
+
respondWithHtml("<html><body><p>Accept cookies</p></body></html>");
|
| 734 |
+
await countOutcomes("https://mixed.example.com/wall", breaker);
|
| 735 |
+
refuse(403);
|
| 736 |
+
await countOutcomes("https://mixed.example.com/e", breaker);
|
| 737 |
+
await countOutcomes("https://mixed.example.com/f", breaker);
|
| 738 |
+
|
| 739 |
+
// Six refusals, never three in a row, so every read reached the host.
|
| 740 |
+
respondWithHtml(articlePage);
|
| 741 |
+
expect(
|
| 742 |
+
(await countOutcomes("https://mixed.example.com/g", breaker)).read,
|
| 743 |
+
).toBe(1);
|
| 744 |
+
expect(fetchMock).toHaveBeenCalledTimes(9);
|
| 745 |
+
expect(breaker.getOpens()).toBe(0);
|
| 746 |
+
});
|
| 747 |
+
|
| 748 |
+
it("lets one probe through once the window has passed, and a success closes the box", async () => {
|
| 749 |
+
const breaker = new PageReadHostBreaker(breakerOptions);
|
| 750 |
+
await boxHost(breaker, "wall.example.com");
|
| 751 |
+
expect(
|
| 752 |
+
(await countOutcomes("https://wall.example.com/d", breaker)).skipped,
|
| 753 |
+
).toEqual({ skippedByBreaker: 1 });
|
| 754 |
+
|
| 755 |
+
await vi.advanceTimersByTimeAsync(breakerOptions.resetTimeout + 1);
|
| 756 |
+
respondWithHtml(articlePage);
|
| 757 |
+
|
| 758 |
+
// The probe reaches the host, and its success lets every read through.
|
| 759 |
+
expect(
|
| 760 |
+
(await countOutcomes("https://wall.example.com/e", breaker)).read,
|
| 761 |
+
).toBe(1);
|
| 762 |
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
| 763 |
+
// One Response object serves every mocked fetch, and the probe consumed
|
| 764 |
+
// its body, so the next read needs a fresh one.
|
| 765 |
+
respondWithHtml(articlePage);
|
| 766 |
+
expect(
|
| 767 |
+
(await countOutcomes("https://wall.example.com/f", breaker)).read,
|
| 768 |
+
).toBe(1);
|
| 769 |
+
expect(fetchMock).toHaveBeenCalledTimes(5);
|
| 770 |
+
expect(breaker.getOpens()).toBe(1);
|
| 771 |
+
});
|
| 772 |
+
|
| 773 |
+
it("boxes the host again when the probe is refused", async () => {
|
| 774 |
+
const breaker = new PageReadHostBreaker(breakerOptions);
|
| 775 |
+
await boxHost(breaker, "wall.example.com");
|
| 776 |
+
await vi.advanceTimersByTimeAsync(breakerOptions.resetTimeout + 1);
|
| 777 |
+
|
| 778 |
+
expect(
|
| 779 |
+
(await countOutcomes("https://wall.example.com/probe", breaker)).skipped,
|
| 780 |
+
).toEqual({ httpForbidden: 1 });
|
| 781 |
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
| 782 |
+
|
| 783 |
+
expect(
|
| 784 |
+
(await countOutcomes("https://wall.example.com/after", breaker)).skipped,
|
| 785 |
+
).toEqual({ skippedByBreaker: 1 });
|
| 786 |
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
| 787 |
+
expect(breaker.getOpens()).toBe(2);
|
| 788 |
+
});
|
| 789 |
+
|
| 790 |
+
it("sends one probe when the boxed host appears twice in the same batch", async () => {
|
| 791 |
+
const breaker = new PageReadHostBreaker(breakerOptions);
|
| 792 |
+
await boxHost(breaker, "wall.example.com");
|
| 793 |
+
await vi.advanceTimersByTimeAsync(breakerOptions.resetTimeout + 1);
|
| 794 |
+
respondWithHtml(articlePage);
|
| 795 |
+
|
| 796 |
+
const before = getPageReadStats();
|
| 797 |
+
const contents = await fetchPageContents(
|
| 798 |
+
"cats",
|
| 799 |
+
["https://wall.example.com/one", "https://wall.example.com/two"],
|
| 800 |
+
breaker,
|
| 801 |
+
);
|
| 802 |
+
const after = getPageReadStats();
|
| 803 |
+
|
| 804 |
+
// The reads run in parallel. The first to ask is the probe; the second is
|
| 805 |
+
// refused rather than sent behind it, which is the double read the box
|
| 806 |
+
// exists to stop.
|
| 807 |
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
| 808 |
+
expect(contents).toHaveLength(1);
|
| 809 |
+
expect(after.read - before.read).toBe(1);
|
| 810 |
+
expect(
|
| 811 |
+
after.skipped.skippedByBreaker - before.skipped.skippedByBreaker,
|
| 812 |
+
).toBe(1);
|
| 813 |
+
});
|
| 814 |
+
|
| 815 |
+
it("stops a redirect chain at a boxed host without asking it", async () => {
|
| 816 |
+
const breaker = new PageReadHostBreaker(breakerOptions);
|
| 817 |
+
await boxHost(breaker, "wall.example.com");
|
| 818 |
+
|
| 819 |
+
fetchMock.mockResolvedValue(
|
| 820 |
+
new Response(null, {
|
| 821 |
+
status: 302,
|
| 822 |
+
headers: { location: "https://wall.example.com/landing" },
|
| 823 |
+
}),
|
| 824 |
+
);
|
| 825 |
+
expect(
|
| 826 |
+
(await countOutcomes("https://open.example.com/moved", breaker)).skipped,
|
| 827 |
+
).toEqual({ skippedByBreaker: 1 });
|
| 828 |
+
// One request more: the redirect itself. The host it pointed at was never
|
| 829 |
+
// asked, and sending a redirect did not count against the host that sent it.
|
| 830 |
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
| 831 |
+
expect(breaker.getOpens()).toBe(1);
|
| 832 |
+
});
|
| 833 |
+
});
|
| 834 |
+
|
| 835 |
describe("fetchPageContents", () => {
|
| 836 |
it("identifies itself with the name, version and repository in package.json", async () => {
|
| 837 |
respondWithHtml(articlePage);
|
|
|
|
| 1038 |
expect(chunksSent).toBeLessThan(40);
|
| 1039 |
});
|
| 1040 |
});
|
| 1041 |
+
|
| 1042 |
+
describe("dense passage fusion", () => {
|
| 1043 |
+
const passages = [
|
| 1044 |
+
`Oceans and ships. ${"Water covers much of the planet. ".repeat(8)}`,
|
| 1045 |
+
`Cats pets sleep. ${"Animals need food and shelter. ".repeat(8)}`,
|
| 1046 |
+
`Cats pets play. ${"Companions enjoy running outdoors. ".repeat(8)}`,
|
| 1047 |
+
];
|
| 1048 |
+
const url = "https://example.com/animals";
|
| 1049 |
+
const html = `<article>${passages.map((text) => `<p>${text}</p>`).join("")}</article>`;
|
| 1050 |
+
const trimmed = passages.map((text) => text.trim());
|
| 1051 |
+
|
| 1052 |
+
it("does not score an empty pool", async () => {
|
| 1053 |
+
expect(await selectPassages("cats", [], 6000)).toEqual([]);
|
| 1054 |
+
expect(await fetchPageContents("cats", [])).toEqual([]);
|
| 1055 |
+
expect(scorePassagesMock).not.toHaveBeenCalled();
|
| 1056 |
+
});
|
| 1057 |
+
|
| 1058 |
+
it("fuses the production pool by original identity and returns the dense-assisted winner first", async () => {
|
| 1059 |
+
respondWithHtml(html);
|
| 1060 |
+
scorePassagesMock.mockResolvedValue([0.5, 0.1, 0.9]);
|
| 1061 |
+
|
| 1062 |
+
const contents = await fetchPageContents("cats pets", [url]);
|
| 1063 |
+
|
| 1064 |
+
expect(scorePassagesMock).toHaveBeenCalledExactlyOnceWith(
|
| 1065 |
+
"cats pets",
|
| 1066 |
+
trimmed,
|
| 1067 |
+
);
|
| 1068 |
+
expect(contents).toEqual([
|
| 1069 |
+
{ url, content: [trimmed[2], trimmed[1], trimmed[0]].join("\n") },
|
| 1070 |
+
]);
|
| 1071 |
+
expect(await selectPassages("cats pets", trimmed, 6000)).toEqual([
|
| 1072 |
+
trimmed[2],
|
| 1073 |
+
trimmed[1],
|
| 1074 |
+
trimmed[0],
|
| 1075 |
+
]);
|
| 1076 |
+
});
|
| 1077 |
+
|
| 1078 |
+
it("preserves the exact lexical output when the model is unavailable or fails", async () => {
|
| 1079 |
+
respondWithHtml(html);
|
| 1080 |
+
const expected = [
|
| 1081 |
+
{ url, content: [trimmed[1], trimmed[2], trimmed[0]].join("\n") },
|
| 1082 |
+
];
|
| 1083 |
+
expect(await fetchPageContents("cats pets", [url])).toEqual(expected);
|
| 1084 |
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
| 1085 |
+
try {
|
| 1086 |
+
scorePassagesMock.mockRejectedValue(
|
| 1087 |
+
new Error("private query, URL and passage"),
|
| 1088 |
+
);
|
| 1089 |
+
respondWithHtml(html);
|
| 1090 |
+
expect(await fetchPageContents("cats pets", [url])).toEqual(expected);
|
| 1091 |
+
expect(warn).toHaveBeenCalledExactlyOnceWith(
|
| 1092 |
+
"Dense passage scoring failed; using lexical ranking.",
|
| 1093 |
+
);
|
| 1094 |
+
} finally {
|
| 1095 |
+
warn.mockRestore();
|
| 1096 |
+
}
|
| 1097 |
+
});
|
| 1098 |
+
|
| 1099 |
+
it("breaks equal fused scores by original index and equal dense scores deterministically", async () => {
|
| 1100 |
+
scorePassagesMock.mockResolvedValue([1, 0]);
|
| 1101 |
+
expect(await selectPassages("cats", ["oceans", "cats"], 100)).toEqual([
|
| 1102 |
+
"oceans",
|
| 1103 |
+
"cats",
|
| 1104 |
+
]);
|
| 1105 |
+
scorePassagesMock.mockResolvedValue([0.5, 0.5]);
|
| 1106 |
+
expect(await selectPassages("cats", ["oceans", "cats"], 100)).toEqual([
|
| 1107 |
+
"oceans",
|
| 1108 |
+
"cats",
|
| 1109 |
+
]);
|
| 1110 |
+
});
|
| 1111 |
+
|
| 1112 |
+
it.each([256, 257])(
|
| 1113 |
+
"bounds dense scoring over the combined %i-passage pool",
|
| 1114 |
+
async (count) => {
|
| 1115 |
+
const texts = Array.from(
|
| 1116 |
+
{ length: count },
|
| 1117 |
+
(_, i) =>
|
| 1118 |
+
`Passage ${i}. ${"Trees provide shade in warm weather. ".repeat(6)}`,
|
| 1119 |
+
);
|
| 1120 |
+
const halves = [texts.slice(0, 128), texts.slice(128)];
|
| 1121 |
+
const urls = ["https://example.com/a", "https://example.com/b"];
|
| 1122 |
+
const respond = () => {
|
| 1123 |
+
for (const half of halves) {
|
| 1124 |
+
fetchMock.mockResolvedValueOnce(
|
| 1125 |
+
new Response(
|
| 1126 |
+
`<article>${half.map((text) => `<p>${text}</p>`).join("")}</article>`,
|
| 1127 |
+
{ headers: { "content-type": "text/html" } },
|
| 1128 |
+
),
|
| 1129 |
+
);
|
| 1130 |
+
}
|
| 1131 |
+
};
|
| 1132 |
+
respond();
|
| 1133 |
+
const lexical = await fetchPageContents("trees", urls);
|
| 1134 |
+
scorePassagesMock
|
| 1135 |
+
.mockClear()
|
| 1136 |
+
.mockImplementation(async (_query, pool: string[]) =>
|
| 1137 |
+
pool.map((_, i) => i),
|
| 1138 |
+
);
|
| 1139 |
+
respond();
|
| 1140 |
+
const result = await fetchPageContents("trees", urls);
|
| 1141 |
+
if (count === 256) {
|
| 1142 |
+
expect(scorePassagesMock).toHaveBeenCalledExactlyOnceWith(
|
| 1143 |
+
"trees",
|
| 1144 |
+
texts.map((text) => text.trim()),
|
| 1145 |
+
);
|
| 1146 |
+
expect(result).not.toEqual(lexical);
|
| 1147 |
+
} else {
|
| 1148 |
+
expect(scorePassagesMock).not.toHaveBeenCalled();
|
| 1149 |
+
expect(result).toEqual(lexical);
|
| 1150 |
+
}
|
| 1151 |
+
expect(result.every((page) => page.content.length <= 6000)).toBe(true);
|
| 1152 |
+
},
|
| 1153 |
+
);
|
| 1154 |
+
});
|
server/pageContentService.ts
CHANGED
|
@@ -2,6 +2,10 @@ import { convert as convertHtmlToPlainText } from "html-to-text";
|
|
| 2 |
import { PDFParse } from "pdf-parse";
|
| 3 |
import { repository, version } from "../package.json" with { type: "json" };
|
| 4 |
import { scorePassages } from "./biEncoderService.ts";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
import {
|
| 6 |
type PageReadOutcome,
|
| 7 |
recordPageRead,
|
|
@@ -19,6 +23,8 @@ const MIN_USEFUL_CHARS = 200;
|
|
| 19 |
const MIN_PASSAGE_CHARS = 180;
|
| 20 |
const MAX_PASSAGE_CHARS = 1200;
|
| 21 |
const OVERLAP_CHARS = 200;
|
|
|
|
|
|
|
| 22 |
|
| 23 |
const appName = repository.url.slice(repository.url.lastIndexOf("/") + 1);
|
| 24 |
|
|
@@ -124,6 +130,18 @@ function isRedirect(status: number): boolean {
|
|
| 124 |
);
|
| 125 |
}
|
| 126 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
function findCharset(text: string, pattern: RegExp): string | null {
|
| 128 |
const match = pattern.exec(text);
|
| 129 |
return match ? match[1] : null;
|
|
@@ -162,16 +180,88 @@ type DownloadResult =
|
|
| 162 |
| { outcome: "ok"; html?: string; text?: string; bodyTruncated: boolean }
|
| 163 |
| { outcome: Exclude<PageReadOutcome, "read" | "tooLittleText"> };
|
| 164 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
/**
|
| 166 |
* Follows redirects by hand so that every hop is validated: `redirect:
|
| 167 |
* "follow"` would let a public URL bounce the server into a private address.
|
| 168 |
* The whole chain shares one deadline, so a page cannot buy extra time by
|
| 169 |
* redirecting.
|
| 170 |
*
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
* Returns why it gave up rather than throwing, because the caller counts the
|
| 172 |
* reasons and a thrown `Error` would have to be identified by its message.
|
| 173 |
*/
|
| 174 |
-
async function downloadDocument(
|
|
|
|
|
|
|
|
|
|
| 175 |
let target = rawUrl;
|
| 176 |
const deadline = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
| 177 |
|
|
@@ -183,62 +273,19 @@ async function downloadDocument(rawUrl: string): Promise<DownloadResult> {
|
|
| 183 |
return { outcome: "blocked" };
|
| 184 |
}
|
| 185 |
|
| 186 |
-
|
| 187 |
-
try {
|
| 188 |
-
response = await fetch(url, {
|
| 189 |
-
headers: REQUEST_HEADERS,
|
| 190 |
-
redirect: "manual",
|
| 191 |
-
signal: deadline,
|
| 192 |
-
});
|
| 193 |
-
} catch (error) {
|
| 194 |
-
return { outcome: isTimeout(error) ? "timedOut" : "failed" };
|
| 195 |
-
}
|
| 196 |
-
|
| 197 |
-
const location = response.headers.get("location");
|
| 198 |
-
if (isRedirect(response.status) && location) {
|
| 199 |
-
await response.body?.cancel().catch(() => {});
|
| 200 |
-
try {
|
| 201 |
-
target = new URL(location, url).toString();
|
| 202 |
-
} catch {
|
| 203 |
-
return { outcome: "failed" };
|
| 204 |
-
}
|
| 205 |
-
continue;
|
| 206 |
-
}
|
| 207 |
-
|
| 208 |
-
if (!response.ok) {
|
| 209 |
-
await response.body?.cancel().catch(() => {});
|
| 210 |
-
return { outcome: "httpError" };
|
| 211 |
-
}
|
| 212 |
-
|
| 213 |
-
const contentType = response.headers.get("content-type") ?? "";
|
| 214 |
-
if (!READABLE_CONTENT_TYPES.test(contentType.trim())) {
|
| 215 |
-
// PDF is not in READABLE_CONTENT_TYPES but we still want to read it.
|
| 216 |
-
if (!contentType.trim().toLowerCase().startsWith("application/pdf")) {
|
| 217 |
-
await response.body?.cancel().catch(() => {});
|
| 218 |
-
return { outcome: "notADocument" };
|
| 219 |
-
}
|
| 220 |
-
}
|
| 221 |
|
|
|
|
|
|
|
|
|
|
| 222 |
try {
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
);
|
| 227 |
-
if (contentType.trim().toLowerCase().startsWith("application/pdf")) {
|
| 228 |
-
return {
|
| 229 |
-
outcome: "ok",
|
| 230 |
-
text: await extractPdfText(bytes),
|
| 231 |
-
bodyTruncated: truncated,
|
| 232 |
-
};
|
| 233 |
-
}
|
| 234 |
-
return {
|
| 235 |
-
outcome: "ok",
|
| 236 |
-
html: decodeDocument(bytes, contentType),
|
| 237 |
-
bodyTruncated: truncated,
|
| 238 |
-
};
|
| 239 |
-
} catch (error) {
|
| 240 |
-
return { outcome: isTimeout(error) ? "timedOut" : "failed" };
|
| 241 |
}
|
|
|
|
|
|
|
|
|
|
| 242 |
}
|
| 243 |
|
| 244 |
return { outcome: "redirectLimit" };
|
|
@@ -521,6 +568,43 @@ function rankPassages(
|
|
| 521 |
.sort((a, b) => b.score - a.score || a.index - b.index);
|
| 522 |
}
|
| 523 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
const JACCARD_THRESHOLD = 0.9;
|
| 525 |
|
| 526 |
/**
|
|
@@ -602,7 +686,7 @@ function selectPassagesAcrossPages(
|
|
| 602 |
* what was transferred and never what the model read.
|
| 603 |
*
|
| 604 |
* Lexical and dense (bi-encoder) scores are fused with reciprocal rank fusion;
|
| 605 |
-
*
|
| 606 |
*
|
| 607 |
* @param passages - Candidate passages from a single page; the wrapper feeds
|
| 608 |
* them through the production selector so tests exercise the real code path.
|
|
@@ -620,34 +704,15 @@ export async function selectPassages(
|
|
| 620 |
positionInPage: index,
|
| 621 |
}));
|
| 622 |
|
| 623 |
-
const
|
| 624 |
-
|
| 625 |
-
// Dense scores from the bi-encoder; empty array when the model is not loaded.
|
| 626 |
-
const denseScores = await scorePassages(query, passages);
|
| 627 |
-
|
| 628 |
-
// Reciprocal rank fusion: combine lexical and dense ranks.
|
| 629 |
-
const RRF_K = 60;
|
| 630 |
-
const fused = lexicalRanked.map((p, i) => {
|
| 631 |
-
const lexicalRank = i + 1;
|
| 632 |
-
const denseRank =
|
| 633 |
-
denseScores.length > 0
|
| 634 |
-
? [...denseScores]
|
| 635 |
-
.map((s, idx) => ({ s, idx }))
|
| 636 |
-
.sort((a, b) => b.s - a.s)
|
| 637 |
-
.findIndex((r) => r.idx === i) + 1
|
| 638 |
-
: lexicalRank;
|
| 639 |
-
return {
|
| 640 |
-
...p,
|
| 641 |
-
score:
|
| 642 |
-
1 / (RRF_K + lexicalRank) +
|
| 643 |
-
(denseScores.length > 0 ? 1 / (RRF_K + denseRank) : 0),
|
| 644 |
-
};
|
| 645 |
-
});
|
| 646 |
|
| 647 |
return selectPassagesAcrossPages(fused, maxChars, maxChars).get("") ?? [];
|
| 648 |
}
|
| 649 |
|
| 650 |
-
async function fetchPageContent(
|
|
|
|
|
|
|
|
|
|
| 651 |
url: string;
|
| 652 |
passages: string[];
|
| 653 |
durationMs: number;
|
|
@@ -656,7 +721,7 @@ async function fetchPageContent(url: string): Promise<{
|
|
| 656 |
const startedAt = performance.now();
|
| 657 |
const since = () => performance.now() - startedAt;
|
| 658 |
|
| 659 |
-
const download = await downloadDocument(url);
|
| 660 |
|
| 661 |
if (download.outcome !== "ok") {
|
| 662 |
recordPageRead({ outcome: download.outcome, durationMs: since() });
|
|
@@ -702,18 +767,25 @@ async function fetchPageContent(url: string): Promise<{
|
|
| 702 |
* character cap and near-duplicate suppression, so syndicated paragraphs that
|
| 703 |
* appear on several pages reach the prompt once.
|
| 704 |
*
|
|
|
|
|
|
|
|
|
|
| 705 |
* Nothing here is logged. Every read is counted instead, by outcome, in
|
| 706 |
* `pageReadsSinceLastRestart`, since a line naming the query or the URL would
|
| 707 |
* record what someone searched for.
|
| 708 |
*
|
| 709 |
* @param urls - Page URLs to read, already ranked by the search pipeline
|
|
|
|
| 710 |
* @returns One entry per page that yielded usable text
|
| 711 |
*/
|
| 712 |
export async function fetchPageContents(
|
| 713 |
query: string,
|
| 714 |
urls: string[],
|
|
|
|
| 715 |
): Promise<PageContent[]> {
|
| 716 |
-
const results = await Promise.all(
|
|
|
|
|
|
|
| 717 |
|
| 718 |
const pages = results.filter((r): r is FetchedPage => r !== null);
|
| 719 |
|
|
@@ -727,7 +799,7 @@ export async function fetchPageContents(
|
|
| 727 |
})),
|
| 728 |
);
|
| 729 |
|
| 730 |
-
const ranked =
|
| 731 |
const selectedByUrl = selectPassagesAcrossPages(
|
| 732 |
ranked,
|
| 733 |
MAX_PAGE_CHARS * pages.length,
|
|
|
|
| 2 |
import { PDFParse } from "pdf-parse";
|
| 3 |
import { repository, version } from "../package.json" with { type: "json" };
|
| 4 |
import { scorePassages } from "./biEncoderService.ts";
|
| 5 |
+
import {
|
| 6 |
+
type PageReadHostBreaker,
|
| 7 |
+
pageReadHostBreaker,
|
| 8 |
+
} from "./pageReadHostBreaker.ts";
|
| 9 |
import {
|
| 10 |
type PageReadOutcome,
|
| 11 |
recordPageRead,
|
|
|
|
| 23 |
const MIN_PASSAGE_CHARS = 180;
|
| 24 |
const MAX_PASSAGE_CHARS = 1200;
|
| 25 |
const OVERLAP_CHARS = 200;
|
| 26 |
+
const MAX_DENSE_PASSAGES = 256;
|
| 27 |
+
const RRF_K = 60;
|
| 28 |
|
| 29 |
const appName = repository.url.slice(repository.url.lastIndexOf("/") + 1);
|
| 30 |
|
|
|
|
| 130 |
);
|
| 131 |
}
|
| 132 |
|
| 133 |
+
function classifyHttpError(
|
| 134 |
+
status: number,
|
| 135 |
+
): "httpForbidden" | "httpNotFound" | "httpOtherError" {
|
| 136 |
+
if (status === 401 || status === 403 || status === 429) {
|
| 137 |
+
return "httpForbidden";
|
| 138 |
+
}
|
| 139 |
+
if (status === 404 || status === 410) {
|
| 140 |
+
return "httpNotFound";
|
| 141 |
+
}
|
| 142 |
+
return "httpOtherError";
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
function findCharset(text: string, pattern: RegExp): string | null {
|
| 146 |
const match = pattern.exec(text);
|
| 147 |
return match ? match[1] : null;
|
|
|
|
| 180 |
| { outcome: "ok"; html?: string; text?: string; bodyTruncated: boolean }
|
| 181 |
| { outcome: Exclude<PageReadOutcome, "read" | "tooLittleText"> };
|
| 182 |
|
| 183 |
+
type HopResult = DownloadResult | { outcome: "redirect"; target: string };
|
| 184 |
+
|
| 185 |
+
/**
|
| 186 |
+
* One request of a redirect chain: the fetch, and the body unless the host
|
| 187 |
+
* answered with a redirect. Everything it can fail on comes back as an outcome
|
| 188 |
+
* rather than an exception, so its caller can settle the host's circuit on it.
|
| 189 |
+
*/
|
| 190 |
+
async function readHop(url: URL, deadline: AbortSignal): Promise<HopResult> {
|
| 191 |
+
let response: Response;
|
| 192 |
+
try {
|
| 193 |
+
response = await fetch(url, {
|
| 194 |
+
headers: REQUEST_HEADERS,
|
| 195 |
+
redirect: "manual",
|
| 196 |
+
signal: deadline,
|
| 197 |
+
});
|
| 198 |
+
} catch (error) {
|
| 199 |
+
return { outcome: isTimeout(error) ? "timedOut" : "failed" };
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
const location = response.headers.get("location");
|
| 203 |
+
if (isRedirect(response.status) && location) {
|
| 204 |
+
await response.body?.cancel().catch(() => {});
|
| 205 |
+
try {
|
| 206 |
+
return { outcome: "redirect", target: new URL(location, url).toString() };
|
| 207 |
+
} catch {
|
| 208 |
+
return { outcome: "failed" };
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
if (!response.ok) {
|
| 213 |
+
await response.body?.cancel().catch(() => {});
|
| 214 |
+
return { outcome: classifyHttpError(response.status) };
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
const contentType = response.headers.get("content-type") ?? "";
|
| 218 |
+
if (!READABLE_CONTENT_TYPES.test(contentType.trim())) {
|
| 219 |
+
// PDF is not in READABLE_CONTENT_TYPES but we still want to read it.
|
| 220 |
+
if (!contentType.trim().toLowerCase().startsWith("application/pdf")) {
|
| 221 |
+
await response.body?.cancel().catch(() => {});
|
| 222 |
+
return { outcome: "notADocument" };
|
| 223 |
+
}
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
try {
|
| 227 |
+
const { bytes, truncated } = await readCappedBytes(
|
| 228 |
+
response,
|
| 229 |
+
MAX_RESPONSE_BYTES,
|
| 230 |
+
);
|
| 231 |
+
if (contentType.trim().toLowerCase().startsWith("application/pdf")) {
|
| 232 |
+
return {
|
| 233 |
+
outcome: "ok",
|
| 234 |
+
text: await extractPdfText(bytes),
|
| 235 |
+
bodyTruncated: truncated,
|
| 236 |
+
};
|
| 237 |
+
}
|
| 238 |
+
return {
|
| 239 |
+
outcome: "ok",
|
| 240 |
+
html: decodeDocument(bytes, contentType),
|
| 241 |
+
bodyTruncated: truncated,
|
| 242 |
+
};
|
| 243 |
+
} catch (error) {
|
| 244 |
+
return { outcome: isTimeout(error) ? "timedOut" : "failed" };
|
| 245 |
+
}
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
/**
|
| 249 |
* Follows redirects by hand so that every hop is validated: `redirect:
|
| 250 |
* "follow"` would let a public URL bounce the server into a private address.
|
| 251 |
* The whole chain shares one deadline, so a page cannot buy extra time by
|
| 252 |
* redirecting.
|
| 253 |
*
|
| 254 |
+
* Each hop runs under the circuit of the host it is about to reach, keyed on
|
| 255 |
+
* `url.host` once `resolvePublicUrl` has cleared it, so a chain that lands on
|
| 256 |
+
* a boxed host stops there and a redirect counts for the host that sent it.
|
| 257 |
+
*
|
| 258 |
* Returns why it gave up rather than throwing, because the caller counts the
|
| 259 |
* reasons and a thrown `Error` would have to be identified by its message.
|
| 260 |
*/
|
| 261 |
+
async function downloadDocument(
|
| 262 |
+
rawUrl: string,
|
| 263 |
+
breaker: PageReadHostBreaker,
|
| 264 |
+
): Promise<DownloadResult> {
|
| 265 |
let target = rawUrl;
|
| 266 |
const deadline = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
| 267 |
|
|
|
|
| 273 |
return { outcome: "blocked" };
|
| 274 |
}
|
| 275 |
|
| 276 |
+
if (!breaker.admit(url.host)) return { outcome: "skippedByBreaker" };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
|
| 278 |
+
// Settled whatever happens, or a half-open host would wait forever for a
|
| 279 |
+
// probe that never reported back.
|
| 280 |
+
let result: HopResult = { outcome: "failed" };
|
| 281 |
try {
|
| 282 |
+
result = await readHop(url, deadline);
|
| 283 |
+
} finally {
|
| 284 |
+
breaker.settle(url.host, result.outcome);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
}
|
| 286 |
+
|
| 287 |
+
if (result.outcome !== "redirect") return result;
|
| 288 |
+
target = result.target;
|
| 289 |
}
|
| 290 |
|
| 291 |
return { outcome: "redirectLimit" };
|
|
|
|
| 568 |
.sort((a, b) => b.score - a.score || a.index - b.index);
|
| 569 |
}
|
| 570 |
|
| 571 |
+
async function rankPassagesWithDenseScores(
|
| 572 |
+
query: string,
|
| 573 |
+
passages: { url: string; text: string; positionInPage: number }[],
|
| 574 |
+
): Promise<RankedPassage[]> {
|
| 575 |
+
const lexicalRanked = rankPassages(query, passages);
|
| 576 |
+
if (passages.length === 0 || passages.length > MAX_DENSE_PASSAGES) {
|
| 577 |
+
return lexicalRanked;
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
let denseScores: number[];
|
| 581 |
+
try {
|
| 582 |
+
denseScores = await scorePassages(
|
| 583 |
+
query,
|
| 584 |
+
passages.map((p) => p.text),
|
| 585 |
+
);
|
| 586 |
+
} catch {
|
| 587 |
+
console.warn("Dense passage scoring failed; using lexical ranking.");
|
| 588 |
+
return lexicalRanked;
|
| 589 |
+
}
|
| 590 |
+
if (denseScores.length === 0) return lexicalRanked;
|
| 591 |
+
|
| 592 |
+
const denseRanks: number[] = [];
|
| 593 |
+
denseScores
|
| 594 |
+
.map((score, index) => ({ score, index }))
|
| 595 |
+
.sort((a, b) => b.score - a.score || a.index - b.index)
|
| 596 |
+
.forEach(({ index }, rank) => {
|
| 597 |
+
denseRanks[index] = rank + 1;
|
| 598 |
+
});
|
| 599 |
+
|
| 600 |
+
return lexicalRanked
|
| 601 |
+
.map((passage, rank) => ({
|
| 602 |
+
...passage,
|
| 603 |
+
score: 1 / (RRF_K + rank + 1) + 1 / (RRF_K + denseRanks[passage.index]),
|
| 604 |
+
}))
|
| 605 |
+
.sort((a, b) => b.score - a.score || a.index - b.index);
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
const JACCARD_THRESHOLD = 0.9;
|
| 609 |
|
| 610 |
/**
|
|
|
|
| 686 |
* what was transferred and never what the model read.
|
| 687 |
*
|
| 688 |
* Lexical and dense (bi-encoder) scores are fused with reciprocal rank fusion;
|
| 689 |
+
* unavailable or failed scoring and pools above 256 passages keep lexical order.
|
| 690 |
*
|
| 691 |
* @param passages - Candidate passages from a single page; the wrapper feeds
|
| 692 |
* them through the production selector so tests exercise the real code path.
|
|
|
|
| 704 |
positionInPage: index,
|
| 705 |
}));
|
| 706 |
|
| 707 |
+
const fused = await rankPassagesWithDenseScores(query, input);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 708 |
|
| 709 |
return selectPassagesAcrossPages(fused, maxChars, maxChars).get("") ?? [];
|
| 710 |
}
|
| 711 |
|
| 712 |
+
async function fetchPageContent(
|
| 713 |
+
url: string,
|
| 714 |
+
breaker: PageReadHostBreaker,
|
| 715 |
+
): Promise<{
|
| 716 |
url: string;
|
| 717 |
passages: string[];
|
| 718 |
durationMs: number;
|
|
|
|
| 721 |
const startedAt = performance.now();
|
| 722 |
const since = () => performance.now() - startedAt;
|
| 723 |
|
| 724 |
+
const download = await downloadDocument(url, breaker);
|
| 725 |
|
| 726 |
if (download.outcome !== "ok") {
|
| 727 |
recordPageRead({ outcome: download.outcome, durationMs: since() });
|
|
|
|
| 767 |
* character cap and near-duplicate suppression, so syndicated paragraphs that
|
| 768 |
* appear on several pages reach the prompt once.
|
| 769 |
*
|
| 770 |
+
* A host that refused enough recent reads is skipped without a request, under
|
| 771 |
+
* `breaker`, and that skip is counted like any other outcome.
|
| 772 |
+
*
|
| 773 |
* Nothing here is logged. Every read is counted instead, by outcome, in
|
| 774 |
* `pageReadsSinceLastRestart`, since a line naming the query or the URL would
|
| 775 |
* record what someone searched for.
|
| 776 |
*
|
| 777 |
* @param urls - Page URLs to read, already ranked by the search pipeline
|
| 778 |
+
* @param breaker - The per-host circuits to read under; injectable for tests
|
| 779 |
* @returns One entry per page that yielded usable text
|
| 780 |
*/
|
| 781 |
export async function fetchPageContents(
|
| 782 |
query: string,
|
| 783 |
urls: string[],
|
| 784 |
+
breaker: PageReadHostBreaker = pageReadHostBreaker,
|
| 785 |
): Promise<PageContent[]> {
|
| 786 |
+
const results = await Promise.all(
|
| 787 |
+
urls.map((url) => fetchPageContent(url, breaker)),
|
| 788 |
+
);
|
| 789 |
|
| 790 |
const pages = results.filter((r): r is FetchedPage => r !== null);
|
| 791 |
|
|
|
|
| 799 |
})),
|
| 800 |
);
|
| 801 |
|
| 802 |
+
const ranked = await rankPassagesWithDenseScores(query, allPassages);
|
| 803 |
const selectedByUrl = selectPassagesAcrossPages(
|
| 804 |
ranked,
|
| 805 |
MAX_PAGE_CHARS * pages.length,
|
server/pageReadHostBreaker.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { PageReadOutcome } from "./pageReadsSinceLastRestart.ts";
|
| 2 |
+
import {
|
| 3 |
+
CircuitBreaker,
|
| 4 |
+
type CircuitBreakerOptions,
|
| 5 |
+
} from "./utils/circuitBreaker.ts";
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* The outcomes that are the host's doing, and so count toward boxing it: a
|
| 9 |
+
* bot wall or rate limit (`httpForbidden`), any other non-ok status, a read
|
| 10 |
+
* that hit the deadline, and a connection that was refused or dropped. A dead
|
| 11 |
+
* link (`httpNotFound`) is the host answering, a page with too little text is
|
| 12 |
+
* the page's fault, and `blocked` never reached the host at all, so none of
|
| 13 |
+
* those count, and each of them resets the run of refusals the way a read
|
| 14 |
+
* that worked does.
|
| 15 |
+
*/
|
| 16 |
+
const HOST_REFUSALS: ReadonlySet<string> = new Set<PageReadOutcome>([
|
| 17 |
+
"httpForbidden",
|
| 18 |
+
"httpOtherError",
|
| 19 |
+
"timedOut",
|
| 20 |
+
"failed",
|
| 21 |
+
]);
|
| 22 |
+
|
| 23 |
+
/**
|
| 24 |
+
* One circuit per host in front of the page reader, in the shape of the
|
| 25 |
+
* SearXNG breaker: after `failureThreshold` refusals in a row a host is
|
| 26 |
+
* skipped for `resetTimeout`, then one read is let through to probe it, and a
|
| 27 |
+
* success closes the circuit again. Unlike the SearXNG breaker it sits in
|
| 28 |
+
* front of code that reports its failures as return values rather than
|
| 29 |
+
* exceptions, so it drives the circuit through `admit` and `settle` instead
|
| 30 |
+
* of `execute`.
|
| 31 |
+
*
|
| 32 |
+
* The host names it keys on stay in this object and never reach `/status`,
|
| 33 |
+
* the log, or a counter: a host comes from a search result, so naming it
|
| 34 |
+
* would say something about what someone searched for. What leaves here is
|
| 35 |
+
* how often a circuit opened.
|
| 36 |
+
*/
|
| 37 |
+
export class PageReadHostBreaker {
|
| 38 |
+
private readonly circuits: CircuitBreaker;
|
| 39 |
+
/**
|
| 40 |
+
* Hosts with a half-open probe in flight. Reads run in parallel over a
|
| 41 |
+
* batch, and the class admits every caller while a circuit is half-open,
|
| 42 |
+
* so without this a host that appears twice in the batch would be probed
|
| 43 |
+
* twice, which is the double read the breaker exists to stop.
|
| 44 |
+
*/
|
| 45 |
+
private readonly probing = new Set<string>();
|
| 46 |
+
private opens = 0;
|
| 47 |
+
|
| 48 |
+
constructor(options: Partial<CircuitBreakerOptions> = {}) {
|
| 49 |
+
this.circuits = new CircuitBreaker(options);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/**
|
| 53 |
+
* Whether a read from `host` may go ahead now. Every admitted read must be
|
| 54 |
+
* settled, or a half-open host keeps waiting for a probe that never ends.
|
| 55 |
+
*/
|
| 56 |
+
admit(host: string): boolean {
|
| 57 |
+
if (!this.circuits.allows(host)) return false;
|
| 58 |
+
|
| 59 |
+
if (this.circuits.getState(host) === "HALF_OPEN") {
|
| 60 |
+
if (this.probing.has(host)) return false;
|
| 61 |
+
this.probing.add(host);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
return true;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/** Records how an admitted read from `host` ended. */
|
| 68 |
+
settle(host: string, outcome: string): void {
|
| 69 |
+
this.probing.delete(host);
|
| 70 |
+
|
| 71 |
+
if (!HOST_REFUSALS.has(outcome)) {
|
| 72 |
+
this.circuits.recordSuccess(host);
|
| 73 |
+
return;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
const opensBefore = this.circuits.getOpens(host);
|
| 77 |
+
this.circuits.recordFailure(host);
|
| 78 |
+
this.opens += this.circuits.getOpens(host) - opensBefore;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
/** How often any host's circuit has opened, which names no host. */
|
| 82 |
+
getOpens(): number {
|
| 83 |
+
return this.opens;
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
/**
|
| 88 |
+
* Three refusals rather than the SearXNG breaker's five: a refused read costs
|
| 89 |
+
* the batch its whole wait, up to the 6 s deadline, and on the public instance
|
| 90 |
+
* the hosts that refuse do so on every read, so the third is as sure as the
|
| 91 |
+
* fifth. Five minutes rather than one: at that instance's rate the same host
|
| 92 |
+
* comes back minutes apart, within one user's run of searches, and a minute's
|
| 93 |
+
* box would usually have expired by then. One healthy probe closes it again,
|
| 94 |
+
* as it does for SearXNG.
|
| 95 |
+
*/
|
| 96 |
+
export const pageReadHostBreaker = new PageReadHostBreaker({
|
| 97 |
+
failureThreshold: 3,
|
| 98 |
+
resetTimeout: 300_000,
|
| 99 |
+
successThreshold: 1,
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
/**
|
| 103 |
+
* How often a host's circuit has opened, for `/status`, beside the skipped
|
| 104 |
+
* reads it counts under `pageReads.skipped.skippedByBreaker`: the skips
|
| 105 |
+
* measure what the box saved, the opens are what `failureThreshold` moves.
|
| 106 |
+
*/
|
| 107 |
+
export function getPageReadCircuitStats() {
|
| 108 |
+
return {
|
| 109 |
+
circuitOpens: pageReadHostBreaker.getOpens(),
|
| 110 |
+
};
|
| 111 |
+
}
|
server/pageReadsSinceLastRestart.test.ts
CHANGED
|
@@ -37,20 +37,26 @@ describe("recordPageRead", () => {
|
|
| 37 |
it("counts every outcome against the total requested", () => {
|
| 38 |
const counted = delta(() => {
|
| 39 |
recordPageRead({ outcome: "blocked", durationMs: 1 });
|
|
|
|
| 40 |
recordPageRead({ outcome: "notADocument", durationMs: 2 });
|
| 41 |
-
recordPageRead({ outcome: "
|
| 42 |
-
recordPageRead({ outcome: "
|
|
|
|
|
|
|
| 43 |
recordPageRead({ outcome: "timedOut", durationMs: 6000 });
|
| 44 |
-
recordPageRead({ outcome: "tooLittleText", durationMs:
|
| 45 |
-
recordPageRead({ outcome: "failed", durationMs:
|
| 46 |
});
|
| 47 |
|
| 48 |
-
expect(counted.requested).toBe(
|
| 49 |
expect(counted.read).toBe(0);
|
| 50 |
expect(counted.skipped).toEqual({
|
| 51 |
blocked: 1,
|
|
|
|
| 52 |
notADocument: 1,
|
| 53 |
-
|
|
|
|
|
|
|
| 54 |
redirectLimit: 1,
|
| 55 |
timedOut: 1,
|
| 56 |
tooLittleText: 1,
|
|
|
|
| 37 |
it("counts every outcome against the total requested", () => {
|
| 38 |
const counted = delta(() => {
|
| 39 |
recordPageRead({ outcome: "blocked", durationMs: 1 });
|
| 40 |
+
recordPageRead({ outcome: "skippedByBreaker", durationMs: 0 });
|
| 41 |
recordPageRead({ outcome: "notADocument", durationMs: 2 });
|
| 42 |
+
recordPageRead({ outcome: "httpForbidden", durationMs: 3 });
|
| 43 |
+
recordPageRead({ outcome: "httpNotFound", durationMs: 4 });
|
| 44 |
+
recordPageRead({ outcome: "httpOtherError", durationMs: 5 });
|
| 45 |
+
recordPageRead({ outcome: "redirectLimit", durationMs: 6 });
|
| 46 |
recordPageRead({ outcome: "timedOut", durationMs: 6000 });
|
| 47 |
+
recordPageRead({ outcome: "tooLittleText", durationMs: 7 });
|
| 48 |
+
recordPageRead({ outcome: "failed", durationMs: 8 });
|
| 49 |
});
|
| 50 |
|
| 51 |
+
expect(counted.requested).toBe(10);
|
| 52 |
expect(counted.read).toBe(0);
|
| 53 |
expect(counted.skipped).toEqual({
|
| 54 |
blocked: 1,
|
| 55 |
+
skippedByBreaker: 1,
|
| 56 |
notADocument: 1,
|
| 57 |
+
httpForbidden: 1,
|
| 58 |
+
httpNotFound: 1,
|
| 59 |
+
httpOtherError: 1,
|
| 60 |
redirectLimit: 1,
|
| 61 |
timedOut: 1,
|
| 62 |
tooLittleText: 1,
|
server/pageReadsSinceLastRestart.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
| 1 |
/**
|
| 2 |
* How a single page read ended. Everything other than `read` means the page
|
| 3 |
* contributed nothing to the prompt and its result kept only its snippet.
|
|
|
|
|
|
|
| 4 |
*/
|
| 5 |
export type PageReadOutcome =
|
| 6 |
| "read"
|
| 7 |
| "blocked"
|
|
|
|
| 8 |
| "notADocument"
|
| 9 |
-
| "
|
|
|
|
|
|
|
| 10 |
| "redirectLimit"
|
| 11 |
| "timedOut"
|
| 12 |
| "tooLittleText"
|
|
@@ -18,15 +23,19 @@ export type PageReadOutcome =
|
|
| 18 |
* Deliberately counts and nothing else. The obvious thing to log while reading
|
| 19 |
* pages is the query and the URL, and that is exactly the pair that says what
|
| 20 |
* a user searched for, so none of it is recorded: no query, no URL, no host, no
|
| 21 |
-
* timestamp per read.
|
| 22 |
-
* this
|
| 23 |
-
*
|
|
|
|
| 24 |
*/
|
| 25 |
const outcomes: Record<PageReadOutcome, number> = {
|
| 26 |
read: 0,
|
| 27 |
blocked: 0,
|
|
|
|
| 28 |
notADocument: 0,
|
| 29 |
-
|
|
|
|
|
|
|
| 30 |
redirectLimit: 0,
|
| 31 |
timedOut: 0,
|
| 32 |
tooLittleText: 0,
|
|
|
|
| 1 |
/**
|
| 2 |
* How a single page read ended. Everything other than `read` means the page
|
| 3 |
* contributed nothing to the prompt and its result kept only its snippet.
|
| 4 |
+
* `skippedByBreaker` is a read that never left the server: its host had
|
| 5 |
+
* refused enough recent reads that `pageReadHostBreaker` boxed it.
|
| 6 |
*/
|
| 7 |
export type PageReadOutcome =
|
| 8 |
| "read"
|
| 9 |
| "blocked"
|
| 10 |
+
| "skippedByBreaker"
|
| 11 |
| "notADocument"
|
| 12 |
+
| "httpForbidden"
|
| 13 |
+
| "httpNotFound"
|
| 14 |
+
| "httpOtherError"
|
| 15 |
| "redirectLimit"
|
| 16 |
| "timedOut"
|
| 17 |
| "tooLittleText"
|
|
|
|
| 23 |
* Deliberately counts and nothing else. The obvious thing to log while reading
|
| 24 |
* pages is the query and the URL, and that is exactly the pair that says what
|
| 25 |
* a user searched for, so none of it is recorded: no query, no URL, no host, no
|
| 26 |
+
* timestamp per read. The hosts the breaker keys on stay inside it and reach
|
| 27 |
+
* this file only as the count of reads it skipped. What is left still answers
|
| 28 |
+
* the questions worth asking of this feature, because every counter below is
|
| 29 |
+
* attached to a constant someone will want to move.
|
| 30 |
*/
|
| 31 |
const outcomes: Record<PageReadOutcome, number> = {
|
| 32 |
read: 0,
|
| 33 |
blocked: 0,
|
| 34 |
+
skippedByBreaker: 0,
|
| 35 |
notADocument: 0,
|
| 36 |
+
httpForbidden: 0,
|
| 37 |
+
httpNotFound: 0,
|
| 38 |
+
httpOtherError: 0,
|
| 39 |
redirectLimit: 0,
|
| 40 |
timedOut: 0,
|
| 41 |
tooLittleText: 0,
|
server/rankSearchResults.test.ts
CHANGED
|
@@ -12,12 +12,40 @@ beforeEach(() => {
|
|
| 12 |
});
|
| 13 |
|
| 14 |
describe("rankSearchResults", () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
it("should return empty array when no results provided", async () => {
|
| 16 |
mockRerank.mockResolvedValue(
|
| 17 |
[] as { index: number; relevance_score: number }[],
|
| 18 |
);
|
| 19 |
const { rankSearchResults } = await import("./rankSearchResults");
|
| 20 |
-
const result = await rankSearchResults("test query", []);
|
| 21 |
expect(result).toEqual([]);
|
| 22 |
expect(mockRerank).toHaveBeenCalledWith("test query", []);
|
| 23 |
});
|
|
@@ -28,7 +56,7 @@ describe("rankSearchResults", () => {
|
|
| 28 |
{ index: 1, relevance_score: 0.5 },
|
| 29 |
] as { index: number; relevance_score: number }[]);
|
| 30 |
const { rankSearchResults } = await import("./rankSearchResults");
|
| 31 |
-
await rankSearchResults("Test Query", [
|
| 32 |
["Title A", "Content A", "https://a.com"],
|
| 33 |
["Title B", "Content B", "https://b.com"],
|
| 34 |
]);
|
|
@@ -42,16 +70,18 @@ describe("rankSearchResults", () => {
|
|
| 42 |
|
| 43 |
it("should sort results by score descending when preserveTopResults is false", async () => {
|
| 44 |
mockRerank.mockResolvedValue([
|
| 45 |
-
{ index: 0, relevance_score: 0.
|
| 46 |
-
{ index: 1, relevance_score: 0.
|
|
|
|
| 47 |
] as { index: number; relevance_score: number }[]);
|
| 48 |
const { rankSearchResults } = await import("./rankSearchResults");
|
| 49 |
-
const result = await rankSearchResults("query", [
|
| 50 |
["A", "a", "https://a.com"],
|
| 51 |
["B", "b", "https://b.com"],
|
|
|
|
| 52 |
]);
|
| 53 |
-
// Results are sorted by score descending (
|
| 54 |
-
expect(result[0][0]).toBe("
|
| 55 |
});
|
| 56 |
|
| 57 |
it("carries the reranker score through as a fourth tuple element", async () => {
|
|
@@ -61,7 +91,7 @@ describe("rankSearchResults", () => {
|
|
| 61 |
{ index: 2, relevance_score: 8.8 },
|
| 62 |
] as { index: number; relevance_score: number }[]);
|
| 63 |
const { rankSearchResults } = await import("./rankSearchResults");
|
| 64 |
-
const result = await rankSearchResults("query", [
|
| 65 |
["A", "a", "https://a.com"],
|
| 66 |
["B", "b", "https://b.com"],
|
| 67 |
["C", "c", "https://c.com"],
|
|
@@ -83,6 +113,7 @@ describe("rankSearchResults", () => {
|
|
| 83 |
const { rankSearchResults } = await import("./rankSearchResults");
|
| 84 |
const result = await rankSearchResults(
|
| 85 |
"query",
|
|
|
|
| 86 |
[
|
| 87 |
["Top", "top", "https://top.com"],
|
| 88 |
["A", "a", "https://a.com"],
|
|
@@ -112,6 +143,7 @@ describe("rankSearchResults", () => {
|
|
| 112 |
const { rankSearchResults } = await import("./rankSearchResults");
|
| 113 |
const result = await rankSearchResults(
|
| 114 |
"query",
|
|
|
|
| 115 |
[
|
| 116 |
["Top", "top content", "https://top.com"],
|
| 117 |
["Other", "other content", "https://other.com"],
|
|
@@ -126,7 +158,7 @@ describe("rankSearchResults", () => {
|
|
| 126 |
[] as { index: number; relevance_score: number }[],
|
| 127 |
);
|
| 128 |
const { rankSearchResults } = await import("./rankSearchResults");
|
| 129 |
-
const result = await rankSearchResults("query", [
|
| 130 |
["A", "a", "https://a.com"],
|
| 131 |
]);
|
| 132 |
expect(result).toEqual([]);
|
|
@@ -139,7 +171,9 @@ describe("rankSearchResults", () => {
|
|
| 139 |
}[]);
|
| 140 |
const { rankSearchResults } = await import("./rankSearchResults");
|
| 141 |
const longTitle = "A".repeat(600);
|
| 142 |
-
await rankSearchResults("query",
|
|
|
|
|
|
|
| 143 |
const docs = mockRerank.mock.calls[0][1] as string[];
|
| 144 |
// Truncation is now token-based inside the reranker, so the full document
|
| 145 |
// reaches it; nothing is cut by character count upstream.
|
|
@@ -153,7 +187,7 @@ describe("rankSearchResults", () => {
|
|
| 153 |
relevance_score: number;
|
| 154 |
}[]);
|
| 155 |
const { rankSearchResults } = await import("./rankSearchResults");
|
| 156 |
-
await rankSearchResults("query", [
|
| 157 |
["Title", 'Content with "quotes"', "https://a.com"],
|
| 158 |
]);
|
| 159 |
const docs = mockRerank.mock.calls[0][1] as string[];
|
|
@@ -173,8 +207,8 @@ describe("rankSearchResults", () => {
|
|
| 173 |
] as { index: number; relevance_score: number }[]);
|
| 174 |
|
| 175 |
const before = getRerankingStats();
|
| 176 |
-
await rankSearchResults("test", results);
|
| 177 |
-
await rankSearchResults("test", results, true);
|
| 178 |
|
| 179 |
expect(getRerankingStats().reranks).toBe(before.reranks + 2);
|
| 180 |
|
|
@@ -183,7 +217,7 @@ describe("rankSearchResults", () => {
|
|
| 183 |
mockRerank.mockResolvedValue(
|
| 184 |
[] as { index: number; relevance_score: number }[],
|
| 185 |
);
|
| 186 |
-
await rankSearchResults("test", []);
|
| 187 |
|
| 188 |
expect(getRerankingStats().reranks).toBe(before.reranks + 2);
|
| 189 |
});
|
|
@@ -198,7 +232,7 @@ describe("rankSearchResults", () => {
|
|
| 198 |
] as { index: number; relevance_score: number }[]);
|
| 199 |
|
| 200 |
const before = getRerankingStats();
|
| 201 |
-
const ranked = await rankSearchResults("test", [
|
| 202 |
["A", "a", "https://a.com"],
|
| 203 |
["B", "b", "https://b.com"],
|
| 204 |
["C", "c", "https://c.com"],
|
|
@@ -221,7 +255,7 @@ describe("rankSearchResults", () => {
|
|
| 221 |
] as { index: number; relevance_score: number }[]);
|
| 222 |
const { rankSearchResults } = await import("./rankSearchResults");
|
| 223 |
|
| 224 |
-
const ranked = await rankSearchResults("test", [
|
| 225 |
["A", "a", "https://a.com"],
|
| 226 |
["B", "b", "https://b.com"],
|
| 227 |
["C", "c", "https://c.com"],
|
|
@@ -252,6 +286,7 @@ describe("rankSearchResults", () => {
|
|
| 252 |
|
| 253 |
await rankSearchResults(
|
| 254 |
"test",
|
|
|
|
| 255 |
Array.from({ length: 10 }, (_unused, index) => [
|
| 256 |
`T${index}`,
|
| 257 |
`C${index}`,
|
|
|
|
| 12 |
});
|
| 13 |
|
| 14 |
describe("rankSearchResults", () => {
|
| 15 |
+
it.each([
|
| 16 |
+
["text", false],
|
| 17 |
+
["images", true],
|
| 18 |
+
] as const)(
|
| 19 |
+
"records %s independently of preserveTopResults=%s",
|
| 20 |
+
async (searchType, preserveTopResults) => {
|
| 21 |
+
const { getRerankingStats } = await import("./rerankingSinceLastRestart");
|
| 22 |
+
const { rankSearchResults } = await import("./rankSearchResults");
|
| 23 |
+
const before = getRerankingStats();
|
| 24 |
+
const otherType = searchType === "text" ? "images" : "text";
|
| 25 |
+
mockRerank.mockResolvedValue([{ index: 0, relevance_score: 1 }]);
|
| 26 |
+
|
| 27 |
+
await rankSearchResults(
|
| 28 |
+
"query",
|
| 29 |
+
searchType,
|
| 30 |
+
[["Title", "Snippet", "https://example.com"]],
|
| 31 |
+
preserveTopResults,
|
| 32 |
+
);
|
| 33 |
+
|
| 34 |
+
expect(getRerankingStats().byType[searchType].reranks).toBe(
|
| 35 |
+
before.byType[searchType].reranks + 1,
|
| 36 |
+
);
|
| 37 |
+
expect(getRerankingStats().byType[otherType]).toEqual(
|
| 38 |
+
before.byType[otherType],
|
| 39 |
+
);
|
| 40 |
+
},
|
| 41 |
+
);
|
| 42 |
+
|
| 43 |
it("should return empty array when no results provided", async () => {
|
| 44 |
mockRerank.mockResolvedValue(
|
| 45 |
[] as { index: number; relevance_score: number }[],
|
| 46 |
);
|
| 47 |
const { rankSearchResults } = await import("./rankSearchResults");
|
| 48 |
+
const result = await rankSearchResults("test query", "text", []);
|
| 49 |
expect(result).toEqual([]);
|
| 50 |
expect(mockRerank).toHaveBeenCalledWith("test query", []);
|
| 51 |
});
|
|
|
|
| 56 |
{ index: 1, relevance_score: 0.5 },
|
| 57 |
] as { index: number; relevance_score: number }[]);
|
| 58 |
const { rankSearchResults } = await import("./rankSearchResults");
|
| 59 |
+
await rankSearchResults("Test Query", "text", [
|
| 60 |
["Title A", "Content A", "https://a.com"],
|
| 61 |
["Title B", "Content B", "https://b.com"],
|
| 62 |
]);
|
|
|
|
| 70 |
|
| 71 |
it("should sort results by score descending when preserveTopResults is false", async () => {
|
| 72 |
mockRerank.mockResolvedValue([
|
| 73 |
+
{ index: 0, relevance_score: 0.8 },
|
| 74 |
+
{ index: 1, relevance_score: 0.9 },
|
| 75 |
+
{ index: 2, relevance_score: 0.5 },
|
| 76 |
] as { index: number; relevance_score: number }[]);
|
| 77 |
const { rankSearchResults } = await import("./rankSearchResults");
|
| 78 |
+
const result = await rankSearchResults("query", "text", [
|
| 79 |
["A", "a", "https://a.com"],
|
| 80 |
["B", "b", "https://b.com"],
|
| 81 |
+
["C", "c", "https://c.com"],
|
| 82 |
]);
|
| 83 |
+
// Results are sorted by score descending (B has higher score than A, C is dropped by score filter)
|
| 84 |
+
expect(result[0][0]).toBe("B");
|
| 85 |
});
|
| 86 |
|
| 87 |
it("carries the reranker score through as a fourth tuple element", async () => {
|
|
|
|
| 91 |
{ index: 2, relevance_score: 8.8 },
|
| 92 |
] as { index: number; relevance_score: number }[]);
|
| 93 |
const { rankSearchResults } = await import("./rankSearchResults");
|
| 94 |
+
const result = await rankSearchResults("query", "text", [
|
| 95 |
["A", "a", "https://a.com"],
|
| 96 |
["B", "b", "https://b.com"],
|
| 97 |
["C", "c", "https://c.com"],
|
|
|
|
| 113 |
const { rankSearchResults } = await import("./rankSearchResults");
|
| 114 |
const result = await rankSearchResults(
|
| 115 |
"query",
|
| 116 |
+
"text",
|
| 117 |
[
|
| 118 |
["Top", "top", "https://top.com"],
|
| 119 |
["A", "a", "https://a.com"],
|
|
|
|
| 143 |
const { rankSearchResults } = await import("./rankSearchResults");
|
| 144 |
const result = await rankSearchResults(
|
| 145 |
"query",
|
| 146 |
+
"text",
|
| 147 |
[
|
| 148 |
["Top", "top content", "https://top.com"],
|
| 149 |
["Other", "other content", "https://other.com"],
|
|
|
|
| 158 |
[] as { index: number; relevance_score: number }[],
|
| 159 |
);
|
| 160 |
const { rankSearchResults } = await import("./rankSearchResults");
|
| 161 |
+
const result = await rankSearchResults("query", "text", [
|
| 162 |
["A", "a", "https://a.com"],
|
| 163 |
]);
|
| 164 |
expect(result).toEqual([]);
|
|
|
|
| 171 |
}[]);
|
| 172 |
const { rankSearchResults } = await import("./rankSearchResults");
|
| 173 |
const longTitle = "A".repeat(600);
|
| 174 |
+
await rankSearchResults("query", "text", [
|
| 175 |
+
[longTitle, "short", "https://a.com"],
|
| 176 |
+
]);
|
| 177 |
const docs = mockRerank.mock.calls[0][1] as string[];
|
| 178 |
// Truncation is now token-based inside the reranker, so the full document
|
| 179 |
// reaches it; nothing is cut by character count upstream.
|
|
|
|
| 187 |
relevance_score: number;
|
| 188 |
}[]);
|
| 189 |
const { rankSearchResults } = await import("./rankSearchResults");
|
| 190 |
+
await rankSearchResults("query", "text", [
|
| 191 |
["Title", 'Content with "quotes"', "https://a.com"],
|
| 192 |
]);
|
| 193 |
const docs = mockRerank.mock.calls[0][1] as string[];
|
|
|
|
| 207 |
] as { index: number; relevance_score: number }[]);
|
| 208 |
|
| 209 |
const before = getRerankingStats();
|
| 210 |
+
await rankSearchResults("test", "text", results);
|
| 211 |
+
await rankSearchResults("test", "text", results, true);
|
| 212 |
|
| 213 |
expect(getRerankingStats().reranks).toBe(before.reranks + 2);
|
| 214 |
|
|
|
|
| 217 |
mockRerank.mockResolvedValue(
|
| 218 |
[] as { index: number; relevance_score: number }[],
|
| 219 |
);
|
| 220 |
+
await rankSearchResults("test", "text", []);
|
| 221 |
|
| 222 |
expect(getRerankingStats().reranks).toBe(before.reranks + 2);
|
| 223 |
});
|
|
|
|
| 232 |
] as { index: number; relevance_score: number }[]);
|
| 233 |
|
| 234 |
const before = getRerankingStats();
|
| 235 |
+
const ranked = await rankSearchResults("test", "text", [
|
| 236 |
["A", "a", "https://a.com"],
|
| 237 |
["B", "b", "https://b.com"],
|
| 238 |
["C", "c", "https://c.com"],
|
|
|
|
| 255 |
] as { index: number; relevance_score: number }[]);
|
| 256 |
const { rankSearchResults } = await import("./rankSearchResults");
|
| 257 |
|
| 258 |
+
const ranked = await rankSearchResults("test", "text", [
|
| 259 |
["A", "a", "https://a.com"],
|
| 260 |
["B", "b", "https://b.com"],
|
| 261 |
["C", "c", "https://c.com"],
|
|
|
|
| 286 |
|
| 287 |
await rankSearchResults(
|
| 288 |
"test",
|
| 289 |
+
"text",
|
| 290 |
Array.from({ length: 10 }, (_unused, index) => [
|
| 291 |
`T${index}`,
|
| 292 |
`C${index}`,
|
server/rankSearchResults.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
| 1 |
import { rerank } from "./rerankerService.ts";
|
| 2 |
import { recordRerank } from "./rerankingSinceLastRestart.ts";
|
| 3 |
|
|
|
|
|
|
|
| 4 |
export async function rankSearchResults(
|
| 5 |
query: string,
|
|
|
|
| 6 |
searchResults: [title: string, content: string, url: string][],
|
| 7 |
preserveTopResults = false,
|
| 8 |
): Promise<ScoredSearchResultTuple[]> {
|
|
@@ -22,7 +25,7 @@ export async function rankSearchResults(
|
|
| 22 |
// instances where searches are coming back empty.
|
| 23 |
const report = (considered: number, kept: number) => {
|
| 24 |
if (considered === 0) return;
|
| 25 |
-
recordRerank({
|
| 26 |
considered,
|
| 27 |
kept,
|
| 28 |
durationMs: performance.now() - startedAt,
|
|
|
|
| 1 |
import { rerank } from "./rerankerService.ts";
|
| 2 |
import { recordRerank } from "./rerankingSinceLastRestart.ts";
|
| 3 |
|
| 4 |
+
import type { SearchType } from "./searchesSinceLastRestart.ts";
|
| 5 |
+
|
| 6 |
export async function rankSearchResults(
|
| 7 |
query: string,
|
| 8 |
+
searchType: SearchType,
|
| 9 |
searchResults: [title: string, content: string, url: string][],
|
| 10 |
preserveTopResults = false,
|
| 11 |
): Promise<ScoredSearchResultTuple[]> {
|
|
|
|
| 25 |
// instances where searches are coming back empty.
|
| 26 |
const report = (considered: number, kept: number) => {
|
| 27 |
if (considered === 0) return;
|
| 28 |
+
recordRerank(searchType, {
|
| 29 |
considered,
|
| 30 |
kept,
|
| 31 |
durationMs: performance.now() - startedAt,
|
server/rerankingSinceLastRestart.test.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
|
| 3 |
+
beforeEach(() => vi.resetModules());
|
| 4 |
+
|
| 5 |
+
describe("reranking counters", () => {
|
| 6 |
+
it("weights totals by reranks and results, retaining independent snapshots", async () => {
|
| 7 |
+
const { recordRerank, getRerankingStats } = await import(
|
| 8 |
+
"./rerankingSinceLastRestart.ts"
|
| 9 |
+
);
|
| 10 |
+
recordRerank("text", {
|
| 11 |
+
considered: 10,
|
| 12 |
+
kept: 2,
|
| 13 |
+
durationMs: 100.4,
|
| 14 |
+
usedPercentageFallback: false,
|
| 15 |
+
});
|
| 16 |
+
const first = getRerankingStats();
|
| 17 |
+
recordRerank("text", {
|
| 18 |
+
considered: 10,
|
| 19 |
+
kept: 3,
|
| 20 |
+
durationMs: 300.4,
|
| 21 |
+
usedPercentageFallback: true,
|
| 22 |
+
});
|
| 23 |
+
recordRerank("images", {
|
| 24 |
+
considered: 2,
|
| 25 |
+
kept: 2,
|
| 26 |
+
durationMs: 20.4,
|
| 27 |
+
usedPercentageFallback: false,
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
expect(getRerankingStats()).toEqual({
|
| 31 |
+
reranks: 3,
|
| 32 |
+
averageMs: 140,
|
| 33 |
+
considered: 22,
|
| 34 |
+
kept: 7,
|
| 35 |
+
keptRate: 31.8,
|
| 36 |
+
fallbackApplied: 1,
|
| 37 |
+
skippedUnhealthy: 0,
|
| 38 |
+
failed: 0,
|
| 39 |
+
byType: {
|
| 40 |
+
text: {
|
| 41 |
+
reranks: 2,
|
| 42 |
+
averageMs: 200,
|
| 43 |
+
considered: 20,
|
| 44 |
+
kept: 5,
|
| 45 |
+
keptRate: 25,
|
| 46 |
+
},
|
| 47 |
+
images: {
|
| 48 |
+
reranks: 1,
|
| 49 |
+
averageMs: 20,
|
| 50 |
+
considered: 2,
|
| 51 |
+
kept: 2,
|
| 52 |
+
keptRate: 100,
|
| 53 |
+
},
|
| 54 |
+
},
|
| 55 |
+
});
|
| 56 |
+
expect(first.byType.text).toEqual({
|
| 57 |
+
reranks: 1,
|
| 58 |
+
averageMs: 100,
|
| 59 |
+
considered: 10,
|
| 60 |
+
kept: 2,
|
| 61 |
+
keptRate: 20,
|
| 62 |
+
});
|
| 63 |
+
first.byType.images.reranks = 99;
|
| 64 |
+
expect(getRerankingStats().byType.images.reranks).toBe(1);
|
| 65 |
+
});
|
| 66 |
+
});
|
server/rerankingSinceLastRestart.ts
CHANGED
|
@@ -7,29 +7,42 @@
|
|
| 7 |
* two thresholds should be moved against, and today they have none.
|
| 8 |
*/
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
let fallbackApplied = 0;
|
| 15 |
let skippedUnhealthy = 0;
|
| 16 |
let failed = 0;
|
| 17 |
|
| 18 |
-
export function recordRerank(
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
if (usedPercentageFallback) fallbackApplied++;
|
| 34 |
}
|
| 35 |
|
|
@@ -48,15 +61,34 @@ export function recordRerankFailed(): void {
|
|
| 48 |
* `pageReads.excerptKeptRate` is: a count of searches whose results were
|
| 49 |
* filtered reads ~100% whatever the threshold is, and settles nothing.
|
| 50 |
*/
|
| 51 |
-
|
| 52 |
return {
|
| 53 |
reranks,
|
| 54 |
averageMs: Math.round(totalMs / reranks || 0),
|
| 55 |
-
considered
|
| 56 |
-
kept
|
| 57 |
-
keptRate: Number(((
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
fallbackApplied,
|
| 59 |
skippedUnhealthy,
|
| 60 |
failed,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
};
|
| 62 |
}
|
|
|
|
| 7 |
* two thresholds should be moved against, and today they have none.
|
| 8 |
*/
|
| 9 |
|
| 10 |
+
import type { SearchType } from "./searchesSinceLastRestart.ts";
|
| 11 |
+
|
| 12 |
+
interface RerankCounts {
|
| 13 |
+
reranks: number;
|
| 14 |
+
totalMs: number;
|
| 15 |
+
considered: number;
|
| 16 |
+
kept: number;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const byType: Record<SearchType, RerankCounts> = {
|
| 20 |
+
text: { reranks: 0, totalMs: 0, considered: 0, kept: 0 },
|
| 21 |
+
images: { reranks: 0, totalMs: 0, considered: 0, kept: 0 },
|
| 22 |
+
};
|
| 23 |
let fallbackApplied = 0;
|
| 24 |
let skippedUnhealthy = 0;
|
| 25 |
let failed = 0;
|
| 26 |
|
| 27 |
+
export function recordRerank(
|
| 28 |
+
searchType: SearchType,
|
| 29 |
+
{
|
| 30 |
+
considered,
|
| 31 |
+
kept,
|
| 32 |
+
durationMs,
|
| 33 |
+
usedPercentageFallback,
|
| 34 |
+
}: {
|
| 35 |
+
considered: number;
|
| 36 |
+
kept: number;
|
| 37 |
+
durationMs: number;
|
| 38 |
+
usedPercentageFallback: boolean;
|
| 39 |
+
},
|
| 40 |
+
): void {
|
| 41 |
+
const counts = byType[searchType];
|
| 42 |
+
counts.reranks++;
|
| 43 |
+
counts.totalMs += durationMs;
|
| 44 |
+
counts.considered += considered;
|
| 45 |
+
counts.kept += kept;
|
| 46 |
if (usedPercentageFallback) fallbackApplied++;
|
| 47 |
}
|
| 48 |
|
|
|
|
| 61 |
* `pageReads.excerptKeptRate` is: a count of searches whose results were
|
| 62 |
* filtered reads ~100% whatever the threshold is, and settles nothing.
|
| 63 |
*/
|
| 64 |
+
function summarize({ reranks, totalMs, considered, kept }: RerankCounts) {
|
| 65 |
return {
|
| 66 |
reranks,
|
| 67 |
averageMs: Math.round(totalMs / reranks || 0),
|
| 68 |
+
considered,
|
| 69 |
+
kept,
|
| 70 |
+
keptRate: Number(((kept / considered) * 100 || 0).toFixed(1)),
|
| 71 |
+
};
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
export function getRerankingStats() {
|
| 75 |
+
const totals = Object.values(byType).reduce(
|
| 76 |
+
(total, counts) => ({
|
| 77 |
+
reranks: total.reranks + counts.reranks,
|
| 78 |
+
totalMs: total.totalMs + counts.totalMs,
|
| 79 |
+
considered: total.considered + counts.considered,
|
| 80 |
+
kept: total.kept + counts.kept,
|
| 81 |
+
}),
|
| 82 |
+
{ reranks: 0, totalMs: 0, considered: 0, kept: 0 },
|
| 83 |
+
);
|
| 84 |
+
return {
|
| 85 |
+
...summarize(totals),
|
| 86 |
fallbackApplied,
|
| 87 |
skippedUnhealthy,
|
| 88 |
failed,
|
| 89 |
+
byType: {
|
| 90 |
+
text: summarize(byType.text),
|
| 91 |
+
images: summarize(byType.images),
|
| 92 |
+
},
|
| 93 |
};
|
| 94 |
}
|
server/rerankingStatus.test.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
createServer,
|
| 3 |
+
type IncomingMessage,
|
| 4 |
+
type ServerResponse,
|
| 5 |
+
} from "node:http";
|
| 6 |
+
import type { AddressInfo } from "node:net";
|
| 7 |
+
import type { PreviewServer } from "vite";
|
| 8 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 9 |
+
|
| 10 |
+
const model = vi.hoisted(() => ({
|
| 11 |
+
healthy: true,
|
| 12 |
+
durationMs: 0,
|
| 13 |
+
now: 0,
|
| 14 |
+
scores: [1, 9, 8.9, 8.8],
|
| 15 |
+
fail: false,
|
| 16 |
+
}));
|
| 17 |
+
|
| 18 |
+
vi.mock("./rerankerService.ts", () => ({
|
| 19 |
+
getRerankerStatus: async () => model.healthy,
|
| 20 |
+
rerank: async (_query: string, documents: string[]) => {
|
| 21 |
+
if (model.fail) throw new Error("fixture model failure");
|
| 22 |
+
model.now += model.durationMs;
|
| 23 |
+
return documents.map((_document, index) => ({
|
| 24 |
+
index,
|
| 25 |
+
relevance_score: model.scores[index],
|
| 26 |
+
}));
|
| 27 |
+
},
|
| 28 |
+
}));
|
| 29 |
+
vi.mock("./biEncoderService.ts", () => ({
|
| 30 |
+
getBiEncoderStatus: async () => true,
|
| 31 |
+
}));
|
| 32 |
+
vi.mock("./webSearchService.ts", async (importOriginal) => ({
|
| 33 |
+
...(await importOriginal<typeof import("./webSearchService.ts")>()),
|
| 34 |
+
fetchSearXNG: vi.fn(),
|
| 35 |
+
getWebSearchServiceStatus: async () => "healthy",
|
| 36 |
+
}));
|
| 37 |
+
|
| 38 |
+
type Handler = (
|
| 39 |
+
request: IncomingMessage,
|
| 40 |
+
response: ServerResponse,
|
| 41 |
+
next: () => void,
|
| 42 |
+
) => Promise<void>;
|
| 43 |
+
const textResults = [
|
| 44 |
+
["Pinned", "private snippet", "https://example.com/0"],
|
| 45 |
+
["High", "private snippet", "https://example.com/1"],
|
| 46 |
+
["Middle", "private snippet", "https://example.com/2"],
|
| 47 |
+
["Low", "private snippet", "https://example.com/3"],
|
| 48 |
+
] as [string, string, string][];
|
| 49 |
+
const imageResults = textResults.map(
|
| 50 |
+
([title, , url]) =>
|
| 51 |
+
[title, `${url}.jpg`, `${url}/thumbnail`, url] as [
|
| 52 |
+
string,
|
| 53 |
+
string,
|
| 54 |
+
string,
|
| 55 |
+
string,
|
| 56 |
+
],
|
| 57 |
+
);
|
| 58 |
+
const emptyStats = {
|
| 59 |
+
reranks: 0,
|
| 60 |
+
averageMs: 0,
|
| 61 |
+
considered: 0,
|
| 62 |
+
kept: 0,
|
| 63 |
+
keptRate: 0,
|
| 64 |
+
};
|
| 65 |
+
let server: ReturnType<typeof createServer>;
|
| 66 |
+
let baseUrl: string;
|
| 67 |
+
|
| 68 |
+
async function request(path: string) {
|
| 69 |
+
const response = await fetch(`${baseUrl}${path}`);
|
| 70 |
+
expect(response.status).toBe(200);
|
| 71 |
+
return response.json();
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
beforeEach(async () => {
|
| 75 |
+
vi.resetModules();
|
| 76 |
+
model.healthy = true;
|
| 77 |
+
model.durationMs = 0;
|
| 78 |
+
model.now = 0;
|
| 79 |
+
model.fail = false;
|
| 80 |
+
model.scores = [1, 9, 8.9, 8.8];
|
| 81 |
+
vi.spyOn(performance, "now").mockImplementation(() => model.now);
|
| 82 |
+
const { fetchSearXNG } = await import("./webSearchService.ts");
|
| 83 |
+
vi.mocked(fetchSearXNG).mockImplementation(async (_query, type) =>
|
| 84 |
+
type === "text" ? textResults : imageResults,
|
| 85 |
+
);
|
| 86 |
+
const { addVerifiedToken } = await import("./verifiedTokens.ts");
|
| 87 |
+
addVerifiedToken("fixture-session");
|
| 88 |
+
const { searchEndpointServerHook } = await import(
|
| 89 |
+
"./searchEndpointServerHook.ts"
|
| 90 |
+
);
|
| 91 |
+
const { statusEndpointServerHook } = await import(
|
| 92 |
+
"./statusEndpointServerHook.ts"
|
| 93 |
+
);
|
| 94 |
+
const handlers: Handler[] = [];
|
| 95 |
+
const host = {
|
| 96 |
+
middlewares: { use: (handler: Handler) => handlers.push(handler) },
|
| 97 |
+
config: { define: { VITE_BUILD_DATE_TIME: "2026-09-18T00:00:00Z" } },
|
| 98 |
+
} as unknown as PreviewServer;
|
| 99 |
+
searchEndpointServerHook(host);
|
| 100 |
+
statusEndpointServerHook(host);
|
| 101 |
+
server = createServer((req, res) => {
|
| 102 |
+
let index = 0;
|
| 103 |
+
const next = () => {
|
| 104 |
+
const handler = handlers[index++];
|
| 105 |
+
if (!handler) {
|
| 106 |
+
res.writeHead(404).end();
|
| 107 |
+
return;
|
| 108 |
+
}
|
| 109 |
+
handler(req, res, next).catch(() => res.writeHead(500).end());
|
| 110 |
+
};
|
| 111 |
+
next();
|
| 112 |
+
});
|
| 113 |
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
| 114 |
+
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
afterEach(async () => {
|
| 118 |
+
vi.restoreAllMocks();
|
| 119 |
+
if (server)
|
| 120 |
+
await new Promise<void>((resolve, reject) =>
|
| 121 |
+
server.close((error) => (error ? reject(error) : resolve())),
|
| 122 |
+
);
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
describe("search reranking counters through HTTP status", () => {
|
| 126 |
+
it("keeps text and images separate while preserving combined totals and result order", async () => {
|
| 127 |
+
model.durationMs = 100;
|
| 128 |
+
expect(
|
| 129 |
+
await request("/search/text?q=private-query&token=fixture-session"),
|
| 130 |
+
).toEqual([
|
| 131 |
+
[...textResults[0], 1],
|
| 132 |
+
[...textResults[1], 9],
|
| 133 |
+
[...textResults[2], 8.9],
|
| 134 |
+
]);
|
| 135 |
+
const first = (await request("/status")).reranker;
|
| 136 |
+
expect(first.byType).toEqual({
|
| 137 |
+
text: {
|
| 138 |
+
reranks: 1,
|
| 139 |
+
averageMs: 100,
|
| 140 |
+
considered: 4,
|
| 141 |
+
kept: 3,
|
| 142 |
+
keptRate: 75,
|
| 143 |
+
},
|
| 144 |
+
images: emptyStats,
|
| 145 |
+
});
|
| 146 |
+
model.durationMs = 300;
|
| 147 |
+
await request("/search/text?q=private-query&token=fixture-session");
|
| 148 |
+
model.durationMs = 20;
|
| 149 |
+
expect(
|
| 150 |
+
await request("/search/images?q=private-query&token=fixture-session"),
|
| 151 |
+
).toEqual([imageResults[1], imageResults[2], imageResults[3]]);
|
| 152 |
+
expect((await request("/status")).reranker).toEqual({
|
| 153 |
+
reranks: 3,
|
| 154 |
+
averageMs: 140,
|
| 155 |
+
considered: 12,
|
| 156 |
+
kept: 9,
|
| 157 |
+
keptRate: 75,
|
| 158 |
+
fallbackApplied: 0,
|
| 159 |
+
skippedUnhealthy: 0,
|
| 160 |
+
failed: 0,
|
| 161 |
+
byType: {
|
| 162 |
+
text: {
|
| 163 |
+
reranks: 2,
|
| 164 |
+
averageMs: 200,
|
| 165 |
+
considered: 8,
|
| 166 |
+
kept: 6,
|
| 167 |
+
keptRate: 75,
|
| 168 |
+
},
|
| 169 |
+
images: {
|
| 170 |
+
reranks: 1,
|
| 171 |
+
averageMs: 20,
|
| 172 |
+
considered: 4,
|
| 173 |
+
kept: 3,
|
| 174 |
+
keptRate: 75,
|
| 175 |
+
},
|
| 176 |
+
},
|
| 177 |
+
});
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
+
it("keeps startup and empty searches at zero for both categories", async () => {
|
| 181 |
+
const before = (await request("/status")).reranker;
|
| 182 |
+
expect(before).toEqual({
|
| 183 |
+
...emptyStats,
|
| 184 |
+
fallbackApplied: 0,
|
| 185 |
+
skippedUnhealthy: 0,
|
| 186 |
+
failed: 0,
|
| 187 |
+
byType: { text: emptyStats, images: emptyStats },
|
| 188 |
+
});
|
| 189 |
+
const { fetchSearXNG } = await import("./webSearchService.ts");
|
| 190 |
+
vi.mocked(fetchSearXNG).mockResolvedValue([]);
|
| 191 |
+
for (const type of ["text", "images"]) {
|
| 192 |
+
expect(
|
| 193 |
+
await request(`/search/${type}?q=empty&token=fixture-session`),
|
| 194 |
+
).toEqual([]);
|
| 195 |
+
}
|
| 196 |
+
expect((await request("/status")).reranker).toEqual(before);
|
| 197 |
+
});
|
| 198 |
+
|
| 199 |
+
it("keeps unhealthy and failed counts at the top level without counting a successful rerank", async () => {
|
| 200 |
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
| 201 |
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
| 202 |
+
for (const type of ["text", "images"]) {
|
| 203 |
+
model.healthy = false;
|
| 204 |
+
expect(
|
| 205 |
+
await request(`/search/${type}?q=unavailable&token=fixture-session`),
|
| 206 |
+
).toEqual(type === "text" ? textResults : imageResults);
|
| 207 |
+
model.healthy = true;
|
| 208 |
+
model.fail = true;
|
| 209 |
+
expect(
|
| 210 |
+
await request(`/search/${type}?q=failed&token=fixture-session`),
|
| 211 |
+
).toEqual(type === "text" ? textResults : imageResults);
|
| 212 |
+
model.fail = false;
|
| 213 |
+
}
|
| 214 |
+
expect((await request("/status")).reranker).toEqual({
|
| 215 |
+
...emptyStats,
|
| 216 |
+
fallbackApplied: 0,
|
| 217 |
+
skippedUnhealthy: 2,
|
| 218 |
+
failed: 2,
|
| 219 |
+
byType: { text: emptyStats, images: emptyStats },
|
| 220 |
+
});
|
| 221 |
+
});
|
| 222 |
+
});
|
server/searchEndpointServerHook.test.ts
CHANGED
|
@@ -71,8 +71,9 @@ describe("searchEndpointServerHook", () => {
|
|
| 71 |
shouldContinue: true,
|
| 72 |
});
|
| 73 |
vi.mocked(getRerankerStatus).mockResolvedValue(false);
|
| 74 |
-
vi.mocked(rankSearchResults).mockImplementation(
|
| 75 |
-
|
|
|
|
| 76 |
);
|
| 77 |
});
|
| 78 |
|
|
@@ -210,6 +211,7 @@ describe("searchEndpointServerHook", () => {
|
|
| 210 |
expect(fetchSearXNG).toHaveBeenCalledWith("test", "text", 5);
|
| 211 |
expect(rankSearchResults).toHaveBeenCalledWith(
|
| 212 |
"test",
|
|
|
|
| 213 |
[
|
| 214 |
["A", "snippet a", "https://a.com"],
|
| 215 |
["B", "snippet b", "https://b.com"],
|
|
|
|
| 71 |
shouldContinue: true,
|
| 72 |
});
|
| 73 |
vi.mocked(getRerankerStatus).mockResolvedValue(false);
|
| 74 |
+
vi.mocked(rankSearchResults).mockImplementation(
|
| 75 |
+
async (_query, _searchType, results) =>
|
| 76 |
+
results.map(([title, content, url]) => [title, content, url, 0]),
|
| 77 |
);
|
| 78 |
});
|
| 79 |
|
|
|
|
| 211 |
expect(fetchSearXNG).toHaveBeenCalledWith("test", "text", 5);
|
| 212 |
expect(rankSearchResults).toHaveBeenCalledWith(
|
| 213 |
"test",
|
| 214 |
+
"text",
|
| 215 |
[
|
| 216 |
["A", "snippet a", "https://a.com"],
|
| 217 |
["B", "snippet b", "https://b.com"],
|
server/searchEndpointServerHook.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
| 11 |
incrementGraphicalSearchesSinceLastRestart,
|
| 12 |
incrementTextualSearchesSinceLastRestart,
|
| 13 |
recordSearchDuration,
|
|
|
|
| 14 |
} from "./searchesSinceLastRestart.ts";
|
| 15 |
import { sendValidationError } from "./utils/httpResponse.ts";
|
| 16 |
import { fetchSearXNG } from "./webSearchService.ts";
|
|
@@ -46,8 +47,9 @@ type ImageResult = [
|
|
| 46 |
|
| 47 |
async function handleRanking(
|
| 48 |
query: string,
|
|
|
|
| 49 |
results: [title: string, content: string, url: string][],
|
| 50 |
-
|
| 51 |
): Promise<[title: string, content: string, url: string, score?: number][]> {
|
| 52 |
const isRerankerHealthy = await getRerankerStatus();
|
| 53 |
if (!isRerankerHealthy) {
|
|
@@ -57,7 +59,12 @@ async function handleRanking(
|
|
| 57 |
|
| 58 |
try {
|
| 59 |
if (isRerankerHealthy) {
|
| 60 |
-
return await rankSearchResults(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
}
|
| 62 |
return results;
|
| 63 |
} catch (error) {
|
|
@@ -128,7 +135,12 @@ export function searchEndpointServerHook<
|
|
| 128 |
|
| 129 |
if (isTextSearch) {
|
| 130 |
const results = searxngResults as TextResult[];
|
| 131 |
-
const rankedResults = await handleRanking(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
|
| 133 |
incrementTextualSearchesSinceLastRestart();
|
| 134 |
|
|
@@ -139,7 +151,11 @@ export function searchEndpointServerHook<
|
|
| 139 |
const resultsText = results.map(
|
| 140 |
([title, url]) => [title?.slice(0, 100) || "", "", url] as TextResult,
|
| 141 |
);
|
| 142 |
-
const rankedResults = await handleRanking(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
const processedResults = rankedResults
|
| 145 |
.map(([title, , rankedResultUrl]) => {
|
|
|
|
| 11 |
incrementGraphicalSearchesSinceLastRestart,
|
| 12 |
incrementTextualSearchesSinceLastRestart,
|
| 13 |
recordSearchDuration,
|
| 14 |
+
type SearchType,
|
| 15 |
} from "./searchesSinceLastRestart.ts";
|
| 16 |
import { sendValidationError } from "./utils/httpResponse.ts";
|
| 17 |
import { fetchSearXNG } from "./webSearchService.ts";
|
|
|
|
| 47 |
|
| 48 |
async function handleRanking(
|
| 49 |
query: string,
|
| 50 |
+
searchType: SearchType,
|
| 51 |
results: [title: string, content: string, url: string][],
|
| 52 |
+
preserveTopResults = false,
|
| 53 |
): Promise<[title: string, content: string, url: string, score?: number][]> {
|
| 54 |
const isRerankerHealthy = await getRerankerStatus();
|
| 55 |
if (!isRerankerHealthy) {
|
|
|
|
| 59 |
|
| 60 |
try {
|
| 61 |
if (isRerankerHealthy) {
|
| 62 |
+
return await rankSearchResults(
|
| 63 |
+
query,
|
| 64 |
+
searchType,
|
| 65 |
+
results,
|
| 66 |
+
preserveTopResults,
|
| 67 |
+
);
|
| 68 |
}
|
| 69 |
return results;
|
| 70 |
} catch (error) {
|
|
|
|
| 135 |
|
| 136 |
if (isTextSearch) {
|
| 137 |
const results = searxngResults as TextResult[];
|
| 138 |
+
const rankedResults = await handleRanking(
|
| 139 |
+
query,
|
| 140 |
+
searchType,
|
| 141 |
+
results,
|
| 142 |
+
true,
|
| 143 |
+
);
|
| 144 |
|
| 145 |
incrementTextualSearchesSinceLastRestart();
|
| 146 |
|
|
|
|
| 151 |
const resultsText = results.map(
|
| 152 |
([title, url]) => [title?.slice(0, 100) || "", "", url] as TextResult,
|
| 153 |
);
|
| 154 |
+
const rankedResults = await handleRanking(
|
| 155 |
+
query,
|
| 156 |
+
searchType,
|
| 157 |
+
resultsText,
|
| 158 |
+
);
|
| 159 |
|
| 160 |
const processedResults = rankedResults
|
| 161 |
.map(([title, , rankedResultUrl]) => {
|
server/statusEndpointServerHook.test.ts
CHANGED
|
@@ -67,6 +67,17 @@ describe("statusEndpointServerHook", () => {
|
|
| 67 |
});
|
| 68 |
});
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
it("keeps startedAt fixed at the restart while uptime advances", async () => {
|
| 71 |
const first = await callStatus();
|
| 72 |
expect(typeof first.startedAt).toBe("string");
|
|
|
|
| 67 |
});
|
| 68 |
});
|
| 69 |
|
| 70 |
+
it("publishes the page-read circuit count beside the outcome counters", async () => {
|
| 71 |
+
const status = await callStatus();
|
| 72 |
+
|
| 73 |
+
// Counts and nothing else: the hosts the breaker boxes come from search
|
| 74 |
+
// results, so naming one here would say something about a query.
|
| 75 |
+
expect(status.pageReads).toMatchObject({
|
| 76 |
+
circuitOpens: expect.any(Number),
|
| 77 |
+
skipped: { skippedByBreaker: expect.any(Number) },
|
| 78 |
+
});
|
| 79 |
+
});
|
| 80 |
+
|
| 81 |
it("keeps startedAt fixed at the restart while uptime advances", async () => {
|
| 82 |
const first = await callStatus();
|
| 83 |
expect(typeof first.startedAt).toBe("string");
|
server/statusEndpointServerHook.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { PreviewServer, ViteDevServer } from "vite";
|
|
| 3 |
import { getAuthorizationStats } from "./authorizationSinceLastRestart.ts";
|
| 4 |
import { getBiEncoderStatus } from "./biEncoderService.ts";
|
| 5 |
import { getInferenceStats } from "./inferencesSinceLastRestart.ts";
|
|
|
|
| 6 |
import { getPageReadStats } from "./pageReadsSinceLastRestart.ts";
|
| 7 |
import { getRerankerStatus } from "./rerankerService.ts";
|
| 8 |
import { getRerankingStats } from "./rerankingSinceLastRestart.ts";
|
|
@@ -85,7 +86,10 @@ export function statusEndpointServerHook<
|
|
| 85 |
biEncoderServiceStatus,
|
| 86 |
rerankerServiceStatus,
|
| 87 |
webSearchServiceStatus,
|
| 88 |
-
pageReads:
|
|
|
|
|
|
|
|
|
|
| 89 |
authorization: getAuthorizationStats(),
|
| 90 |
inference: getInferenceStats(),
|
| 91 |
activeSessions: getActiveSessionsAmount(),
|
|
|
|
| 3 |
import { getAuthorizationStats } from "./authorizationSinceLastRestart.ts";
|
| 4 |
import { getBiEncoderStatus } from "./biEncoderService.ts";
|
| 5 |
import { getInferenceStats } from "./inferencesSinceLastRestart.ts";
|
| 6 |
+
import { getPageReadCircuitStats } from "./pageReadHostBreaker.ts";
|
| 7 |
import { getPageReadStats } from "./pageReadsSinceLastRestart.ts";
|
| 8 |
import { getRerankerStatus } from "./rerankerService.ts";
|
| 9 |
import { getRerankingStats } from "./rerankingSinceLastRestart.ts";
|
|
|
|
| 86 |
biEncoderServiceStatus,
|
| 87 |
rerankerServiceStatus,
|
| 88 |
webSearchServiceStatus,
|
| 89 |
+
pageReads: {
|
| 90 |
+
...getPageReadStats(),
|
| 91 |
+
...getPageReadCircuitStats(),
|
| 92 |
+
},
|
| 93 |
authorization: getAuthorizationStats(),
|
| 94 |
inference: getInferenceStats(),
|
| 95 |
activeSessions: getActiveSessionsAmount(),
|
server/thumbnailEndpointServerHook.test.ts
CHANGED
|
@@ -18,6 +18,21 @@ vi.mock("./searchesSinceLastRestart", () => ({
|
|
| 18 |
recordThumbnailBlocked: vi.fn(),
|
| 19 |
}));
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
import { handleTokenVerification } from "./handleTokenVerification";
|
| 22 |
import {
|
| 23 |
recordThumbnailBlocked,
|
|
@@ -30,9 +45,6 @@ import {
|
|
| 30 |
thumbnailEndpointServerHook,
|
| 31 |
} from "./thumbnailEndpointServerHook";
|
| 32 |
|
| 33 |
-
const mockFetch = vi.fn();
|
| 34 |
-
vi.stubGlobal("fetch", mockFetch);
|
| 35 |
-
|
| 36 |
function createRequest(url: string): IncomingMessage {
|
| 37 |
return {
|
| 38 |
url,
|
|
@@ -75,20 +87,34 @@ function requestUrlFor(thumbnailUrl: string): string {
|
|
| 75 |
return `/thumbnail?u=${encodeURIComponent(thumbnailUrl)}&token=abc`;
|
| 76 |
}
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
function imageResponse(
|
| 79 |
bytes: Uint8Array<ArrayBuffer> = new Uint8Array([1, 2, 3]),
|
| 80 |
contentType = "image/jpeg",
|
| 81 |
-
):
|
| 82 |
-
return
|
| 83 |
-
status: 200,
|
| 84 |
-
headers: { "content-type": contentType },
|
| 85 |
-
});
|
| 86 |
}
|
| 87 |
|
| 88 |
describe("thumbnailEndpointServerHook", () => {
|
| 89 |
beforeEach(() => {
|
| 90 |
vi.clearAllMocks();
|
| 91 |
-
|
| 92 |
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
| 93 |
vi.mocked(handleTokenVerification).mockResolvedValue({
|
| 94 |
shouldContinue: true,
|
|
@@ -107,7 +133,7 @@ describe("thumbnailEndpointServerHook", () => {
|
|
| 107 |
await handler(createRequest("/status"), createResponse(), next);
|
| 108 |
|
| 109 |
expect(next).toHaveBeenCalled();
|
| 110 |
-
expect(
|
| 111 |
});
|
| 112 |
|
| 113 |
it("stops processing when token verification fails", async () => {
|
|
@@ -122,7 +148,7 @@ describe("thumbnailEndpointServerHook", () => {
|
|
| 122 |
vi.fn(),
|
| 123 |
);
|
| 124 |
|
| 125 |
-
expect(
|
| 126 |
expect(recordThumbnailRequested).not.toHaveBeenCalled();
|
| 127 |
});
|
| 128 |
|
|
@@ -136,12 +162,12 @@ describe("thumbnailEndpointServerHook", () => {
|
|
| 136 |
expect(response.end).toHaveBeenCalledWith(
|
| 137 |
JSON.stringify({ error: "Missing thumbnail URL" }),
|
| 138 |
);
|
| 139 |
-
expect(
|
| 140 |
expect(recordThumbnailRequested).not.toHaveBeenCalled();
|
| 141 |
});
|
| 142 |
|
| 143 |
it("serves a fetched image with its normalized content type and a private cache", async () => {
|
| 144 |
-
|
| 145 |
imageResponse(new Uint8Array([1, 2, 3]), "IMAGE/JPEG; charset=binary"),
|
| 146 |
);
|
| 147 |
const handler = getRegisteredHandler();
|
|
@@ -179,7 +205,7 @@ describe("thumbnailEndpointServerHook", () => {
|
|
| 179 |
});
|
| 180 |
|
| 181 |
it("verifies through the thumbnail's own rate-limit budget", async () => {
|
| 182 |
-
|
| 183 |
const { thumbnailRateLimiter } = await import("./verifyTokenAndRateLimit");
|
| 184 |
const handler = getRegisteredHandler();
|
| 185 |
|
|
@@ -202,14 +228,14 @@ describe("thumbnailEndpointServerHook", () => {
|
|
| 202 |
|
| 203 |
it("serves a repeat request from the cache without fetching again", async () => {
|
| 204 |
const url = publicThumbnailUrl();
|
| 205 |
-
// A fresh
|
| 206 |
-
|
| 207 |
const handler = getRegisteredHandler();
|
| 208 |
|
| 209 |
await handler(createRequest(requestUrlFor(url)), createResponse(), vi.fn());
|
| 210 |
await handler(createRequest(requestUrlFor(url)), createResponse(), vi.fn());
|
| 211 |
|
| 212 |
-
expect(
|
| 213 |
// Both requests are demand, so both count even though only one fetched.
|
| 214 |
expect(recordThumbnailRequested).toHaveBeenCalledTimes(2);
|
| 215 |
});
|
|
@@ -226,7 +252,7 @@ describe("thumbnailEndpointServerHook", () => {
|
|
| 226 |
);
|
| 227 |
|
| 228 |
expect(response.statusCode).toBe(403);
|
| 229 |
-
expect(
|
| 230 |
expect(recordThumbnailBlocked).toHaveBeenCalledTimes(1);
|
| 231 |
expect(recordThumbnailDropped).toHaveBeenCalledTimes(1);
|
| 232 |
});
|
|
@@ -242,7 +268,7 @@ describe("thumbnailEndpointServerHook", () => {
|
|
| 242 |
);
|
| 243 |
|
| 244 |
expect(response.statusCode).toBe(403);
|
| 245 |
-
expect(
|
| 246 |
expect(recordThumbnailBlocked).toHaveBeenCalledTimes(1);
|
| 247 |
expect(recordThumbnailDropped).toHaveBeenCalledTimes(1);
|
| 248 |
});
|
|
@@ -255,18 +281,43 @@ describe("thumbnailEndpointServerHook", () => {
|
|
| 255 |
await handler(createRequest(requestUrlFor(longUrl)), response, vi.fn());
|
| 256 |
|
| 257 |
expect(response.statusCode).toBe(400);
|
| 258 |
-
expect(
|
| 259 |
expect(recordThumbnailRequested).not.toHaveBeenCalled();
|
| 260 |
});
|
| 261 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
it("does not follow a redirect into a private address", async () => {
|
| 263 |
lookupMock
|
| 264 |
.mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }])
|
| 265 |
.mockResolvedValue([{ address: "10.0.0.7", family: 4 }]);
|
| 266 |
-
|
| 267 |
-
new
|
| 268 |
-
|
| 269 |
-
headers: { location: "http://10.0.0.7/thumb.jpg" },
|
| 270 |
}),
|
| 271 |
);
|
| 272 |
const handler = getRegisteredHandler();
|
|
@@ -279,12 +330,17 @@ describe("thumbnailEndpointServerHook", () => {
|
|
| 279 |
);
|
| 280 |
|
| 281 |
expect(response.statusCode).toBe(403);
|
| 282 |
-
|
|
|
|
|
|
|
|
|
|
| 283 |
expect(recordThumbnailBlocked).toHaveBeenCalledTimes(1);
|
| 284 |
});
|
| 285 |
|
| 286 |
it("answers 502 with no-store when the upstream returns an error status", async () => {
|
| 287 |
-
|
|
|
|
|
|
|
| 288 |
const handler = getRegisteredHandler();
|
| 289 |
const response = createResponse();
|
| 290 |
|
|
@@ -306,10 +362,9 @@ describe("thumbnailEndpointServerHook", () => {
|
|
| 306 |
});
|
| 307 |
|
| 308 |
it("answers 502 when the upstream answer is not an accepted image type", async () => {
|
| 309 |
-
|
| 310 |
-
new
|
| 311 |
-
|
| 312 |
-
headers: { "content-type": "text/html" },
|
| 313 |
}),
|
| 314 |
);
|
| 315 |
const handler = getRegisteredHandler();
|
|
@@ -327,11 +382,12 @@ describe("thumbnailEndpointServerHook", () => {
|
|
| 327 |
|
| 328 |
it("answers 502 when the upstream answer is an SVG", async () => {
|
| 329 |
// An SVG is a document: served same-origin it would run its scripts.
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
|
|
|
| 335 |
);
|
| 336 |
const handler = getRegisteredHandler();
|
| 337 |
const response = createResponse();
|
|
@@ -347,11 +403,8 @@ describe("thumbnailEndpointServerHook", () => {
|
|
| 347 |
});
|
| 348 |
|
| 349 |
it("does not follow a redirect with a malformed Location", async () => {
|
| 350 |
-
|
| 351 |
-
new
|
| 352 |
-
status: 302,
|
| 353 |
-
headers: { location: "http://[" },
|
| 354 |
-
}),
|
| 355 |
);
|
| 356 |
const handler = getRegisteredHandler();
|
| 357 |
const response = createResponse();
|
|
@@ -363,14 +416,14 @@ describe("thumbnailEndpointServerHook", () => {
|
|
| 363 |
);
|
| 364 |
|
| 365 |
expect(response.statusCode).toBe(502);
|
| 366 |
-
expect(
|
| 367 |
expect(recordThumbnailDropped).toHaveBeenCalledTimes(1);
|
| 368 |
});
|
| 369 |
|
| 370 |
it("does not cache a failure, so the next load retries the upstream", async () => {
|
| 371 |
const url = publicThumbnailUrl();
|
| 372 |
-
|
| 373 |
-
.mockResolvedValueOnce(new
|
| 374 |
.mockResolvedValueOnce(imageResponse());
|
| 375 |
const handler = getRegisteredHandler();
|
| 376 |
|
|
@@ -381,15 +434,12 @@ describe("thumbnailEndpointServerHook", () => {
|
|
| 381 |
const second = createResponse();
|
| 382 |
await handler(createRequest(requestUrlFor(url)), second, vi.fn());
|
| 383 |
expect(second.statusCode).toBe(200);
|
| 384 |
-
expect(
|
| 385 |
});
|
| 386 |
|
| 387 |
it("answers 502 when the upstream body is empty", async () => {
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
status: 200,
|
| 391 |
-
headers: { "content-type": "image/jpeg" },
|
| 392 |
-
}),
|
| 393 |
);
|
| 394 |
const handler = getRegisteredHandler();
|
| 395 |
const response = createResponse();
|
|
@@ -422,7 +472,7 @@ describe("thumbnailEndpointServerHook", () => {
|
|
| 422 |
await handled;
|
| 423 |
|
| 424 |
expect(response.statusCode).toBe(502);
|
| 425 |
-
expect(
|
| 426 |
expect(recordThumbnailDropped).toHaveBeenCalledTimes(1);
|
| 427 |
} finally {
|
| 428 |
vi.useRealTimers();
|
|
@@ -434,10 +484,10 @@ describe("thumbnailEndpointServerHook", () => {
|
|
| 434 |
const thumbnailTimeoutMs = 3000;
|
| 435 |
vi.useFakeTimers();
|
| 436 |
try {
|
| 437 |
-
|
| 438 |
-
(_url: string,
|
| 439 |
new Promise((_resolve, reject) => {
|
| 440 |
-
|
| 441 |
reject(new Error("The operation was aborted")),
|
| 442 |
);
|
| 443 |
}),
|
|
@@ -460,10 +510,9 @@ describe("thumbnailEndpointServerHook", () => {
|
|
| 460 |
});
|
| 461 |
|
| 462 |
it("answers 502 after the redirect budget is spent", async () => {
|
| 463 |
-
|
| 464 |
-
new
|
| 465 |
-
|
| 466 |
-
headers: { location: "https://thumbs.example.com/loop.jpg" },
|
| 467 |
}),
|
| 468 |
);
|
| 469 |
const handler = getRegisteredHandler();
|
|
@@ -477,13 +526,13 @@ describe("thumbnailEndpointServerHook", () => {
|
|
| 477 |
|
| 478 |
expect(response.statusCode).toBe(502);
|
| 479 |
// One request per hop, four hops for three redirects.
|
| 480 |
-
expect(
|
| 481 |
expect(recordThumbnailDropped).toHaveBeenCalledTimes(1);
|
| 482 |
});
|
| 483 |
|
| 484 |
it("truncates an oversized body to the byte cap", async () => {
|
| 485 |
const bigBody = new Uint8Array(600_000).fill(7);
|
| 486 |
-
|
| 487 |
const handler = getRegisteredHandler();
|
| 488 |
const response = createResponse();
|
| 489 |
|
|
@@ -500,8 +549,8 @@ describe("thumbnailEndpointServerHook", () => {
|
|
| 500 |
});
|
| 501 |
|
| 502 |
it("evicts the least recently used entry, not the least recently loaded one", async () => {
|
| 503 |
-
// A fresh
|
| 504 |
-
|
| 505 |
const handler = getRegisteredHandler();
|
| 506 |
// A clean slate, so the eviction side is exactly where this test puts it.
|
| 507 |
resetThumbnailCache();
|
|
@@ -511,7 +560,8 @@ describe("thumbnailEndpointServerHook", () => {
|
|
| 511 |
const load = (url: string) =>
|
| 512 |
handler(createRequest(requestUrlFor(url)), createResponse(), vi.fn());
|
| 513 |
const fetched = (url: string) =>
|
| 514 |
-
|
|
|
|
| 515 |
|
| 516 |
await load(firstUrl);
|
| 517 |
await load(secondUrl);
|
|
|
|
| 18 |
recordThumbnailBlocked: vi.fn(),
|
| 19 |
}));
|
| 20 |
|
| 21 |
+
// The hook's connect goes through the pinned request, so that is the seam
|
| 22 |
+
// under test here; the capped reader stays real so the size-cap semantics
|
| 23 |
+
// this file asserts are the shipped ones. The pin itself is covered by
|
| 24 |
+
// pinnedFetch.test.ts.
|
| 25 |
+
const pinnedRequestMock = vi.hoisted(() => vi.fn());
|
| 26 |
+
|
| 27 |
+
vi.mock("./utils/pinnedFetch", async (importOriginal) => {
|
| 28 |
+
const actual =
|
| 29 |
+
await importOriginal<typeof import("./utils/pinnedFetch.ts")>();
|
| 30 |
+
return {
|
| 31 |
+
...actual,
|
| 32 |
+
requestPinnedToVettedAddress: pinnedRequestMock,
|
| 33 |
+
};
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
import { handleTokenVerification } from "./handleTokenVerification";
|
| 37 |
import {
|
| 38 |
recordThumbnailBlocked,
|
|
|
|
| 45 |
thumbnailEndpointServerHook,
|
| 46 |
} from "./thumbnailEndpointServerHook";
|
| 47 |
|
|
|
|
|
|
|
|
|
|
| 48 |
function createRequest(url: string): IncomingMessage {
|
| 49 |
return {
|
| 50 |
url,
|
|
|
|
| 87 |
return `/thumbnail?u=${encodeURIComponent(thumbnailUrl)}&token=abc`;
|
| 88 |
}
|
| 89 |
|
| 90 |
+
// Shaped like the IncomingMessage the pinned request resolves with: the
|
| 91 |
+
// fields the hook reads, plus the async iteration readCappedStream drives.
|
| 92 |
+
function fakeIncoming(
|
| 93 |
+
bytes: Uint8Array,
|
| 94 |
+
statusCode: number,
|
| 95 |
+
headers: Record<string, string>,
|
| 96 |
+
): IncomingMessage {
|
| 97 |
+
return {
|
| 98 |
+
statusCode,
|
| 99 |
+
headers,
|
| 100 |
+
destroy: vi.fn(),
|
| 101 |
+
async *[Symbol.asyncIterator]() {
|
| 102 |
+
if (bytes.byteLength > 0) yield bytes;
|
| 103 |
+
},
|
| 104 |
+
} as unknown as IncomingMessage;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
function imageResponse(
|
| 108 |
bytes: Uint8Array<ArrayBuffer> = new Uint8Array([1, 2, 3]),
|
| 109 |
contentType = "image/jpeg",
|
| 110 |
+
): IncomingMessage {
|
| 111 |
+
return fakeIncoming(bytes, 200, { "content-type": contentType });
|
|
|
|
|
|
|
|
|
|
| 112 |
}
|
| 113 |
|
| 114 |
describe("thumbnailEndpointServerHook", () => {
|
| 115 |
beforeEach(() => {
|
| 116 |
vi.clearAllMocks();
|
| 117 |
+
pinnedRequestMock.mockReset();
|
| 118 |
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
| 119 |
vi.mocked(handleTokenVerification).mockResolvedValue({
|
| 120 |
shouldContinue: true,
|
|
|
|
| 133 |
await handler(createRequest("/status"), createResponse(), next);
|
| 134 |
|
| 135 |
expect(next).toHaveBeenCalled();
|
| 136 |
+
expect(pinnedRequestMock).not.toHaveBeenCalled();
|
| 137 |
});
|
| 138 |
|
| 139 |
it("stops processing when token verification fails", async () => {
|
|
|
|
| 148 |
vi.fn(),
|
| 149 |
);
|
| 150 |
|
| 151 |
+
expect(pinnedRequestMock).not.toHaveBeenCalled();
|
| 152 |
expect(recordThumbnailRequested).not.toHaveBeenCalled();
|
| 153 |
});
|
| 154 |
|
|
|
|
| 162 |
expect(response.end).toHaveBeenCalledWith(
|
| 163 |
JSON.stringify({ error: "Missing thumbnail URL" }),
|
| 164 |
);
|
| 165 |
+
expect(pinnedRequestMock).not.toHaveBeenCalled();
|
| 166 |
expect(recordThumbnailRequested).not.toHaveBeenCalled();
|
| 167 |
});
|
| 168 |
|
| 169 |
it("serves a fetched image with its normalized content type and a private cache", async () => {
|
| 170 |
+
pinnedRequestMock.mockResolvedValue(
|
| 171 |
imageResponse(new Uint8Array([1, 2, 3]), "IMAGE/JPEG; charset=binary"),
|
| 172 |
);
|
| 173 |
const handler = getRegisteredHandler();
|
|
|
|
| 205 |
});
|
| 206 |
|
| 207 |
it("verifies through the thumbnail's own rate-limit budget", async () => {
|
| 208 |
+
pinnedRequestMock.mockResolvedValue(imageResponse());
|
| 209 |
const { thumbnailRateLimiter } = await import("./verifyTokenAndRateLimit");
|
| 210 |
const handler = getRegisteredHandler();
|
| 211 |
|
|
|
|
| 228 |
|
| 229 |
it("serves a repeat request from the cache without fetching again", async () => {
|
| 230 |
const url = publicThumbnailUrl();
|
| 231 |
+
// A fresh response per call: a body can only be read once.
|
| 232 |
+
pinnedRequestMock.mockImplementation(async () => imageResponse());
|
| 233 |
const handler = getRegisteredHandler();
|
| 234 |
|
| 235 |
await handler(createRequest(requestUrlFor(url)), createResponse(), vi.fn());
|
| 236 |
await handler(createRequest(requestUrlFor(url)), createResponse(), vi.fn());
|
| 237 |
|
| 238 |
+
expect(pinnedRequestMock).toHaveBeenCalledTimes(1);
|
| 239 |
// Both requests are demand, so both count even though only one fetched.
|
| 240 |
expect(recordThumbnailRequested).toHaveBeenCalledTimes(2);
|
| 241 |
});
|
|
|
|
| 252 |
);
|
| 253 |
|
| 254 |
expect(response.statusCode).toBe(403);
|
| 255 |
+
expect(pinnedRequestMock).not.toHaveBeenCalled();
|
| 256 |
expect(recordThumbnailBlocked).toHaveBeenCalledTimes(1);
|
| 257 |
expect(recordThumbnailDropped).toHaveBeenCalledTimes(1);
|
| 258 |
});
|
|
|
|
| 268 |
);
|
| 269 |
|
| 270 |
expect(response.statusCode).toBe(403);
|
| 271 |
+
expect(pinnedRequestMock).not.toHaveBeenCalled();
|
| 272 |
expect(recordThumbnailBlocked).toHaveBeenCalledTimes(1);
|
| 273 |
expect(recordThumbnailDropped).toHaveBeenCalledTimes(1);
|
| 274 |
});
|
|
|
|
| 281 |
await handler(createRequest(requestUrlFor(longUrl)), response, vi.fn());
|
| 282 |
|
| 283 |
expect(response.statusCode).toBe(400);
|
| 284 |
+
expect(pinnedRequestMock).not.toHaveBeenCalled();
|
| 285 |
expect(recordThumbnailRequested).not.toHaveBeenCalled();
|
| 286 |
});
|
| 287 |
|
| 288 |
+
it("pins the address vetted at the first DNS answer, never the rebound one", async () => {
|
| 289 |
+
// The rebinding attack: the first answer is public, the second β the
|
| 290 |
+
// one a re-resolving fetch would get at connect time β is private.
|
| 291 |
+
// The pinned connect must use the vetted answer, and the private one
|
| 292 |
+
// must never be dialed.
|
| 293 |
+
lookupMock
|
| 294 |
+
.mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }])
|
| 295 |
+
.mockResolvedValue([{ address: "10.0.0.7", family: 4 }]);
|
| 296 |
+
pinnedRequestMock.mockResolvedValue(imageResponse());
|
| 297 |
+
const handler = getRegisteredHandler();
|
| 298 |
+
const response = createResponse();
|
| 299 |
+
|
| 300 |
+
await handler(
|
| 301 |
+
createRequest(requestUrlFor(publicThumbnailUrl())),
|
| 302 |
+
response,
|
| 303 |
+
vi.fn(),
|
| 304 |
+
);
|
| 305 |
+
|
| 306 |
+
expect(response.statusCode).toBe(200);
|
| 307 |
+
expect(pinnedRequestMock).toHaveBeenCalledTimes(1);
|
| 308 |
+
expect(pinnedRequestMock.mock.calls[0][1]).toBe("93.184.216.34");
|
| 309 |
+
expect(
|
| 310 |
+
pinnedRequestMock.mock.calls.some((call) => call[1] === "10.0.0.7"),
|
| 311 |
+
).toBe(false);
|
| 312 |
+
});
|
| 313 |
+
|
| 314 |
it("does not follow a redirect into a private address", async () => {
|
| 315 |
lookupMock
|
| 316 |
.mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }])
|
| 317 |
.mockResolvedValue([{ address: "10.0.0.7", family: 4 }]);
|
| 318 |
+
pinnedRequestMock.mockResolvedValueOnce(
|
| 319 |
+
fakeIncoming(new Uint8Array(), 302, {
|
| 320 |
+
location: "http://10.0.0.7/thumb.jpg",
|
|
|
|
| 321 |
}),
|
| 322 |
);
|
| 323 |
const handler = getRegisteredHandler();
|
|
|
|
| 330 |
);
|
| 331 |
|
| 332 |
expect(response.statusCode).toBe(403);
|
| 333 |
+
// The redirect target was refused at validation: its socket was never
|
| 334 |
+
// opened, so the private address was never connected to.
|
| 335 |
+
expect(pinnedRequestMock).toHaveBeenCalledTimes(1);
|
| 336 |
+
expect(pinnedRequestMock.mock.calls[0][1]).toBe("93.184.216.34");
|
| 337 |
expect(recordThumbnailBlocked).toHaveBeenCalledTimes(1);
|
| 338 |
});
|
| 339 |
|
| 340 |
it("answers 502 with no-store when the upstream returns an error status", async () => {
|
| 341 |
+
pinnedRequestMock.mockResolvedValue(
|
| 342 |
+
fakeIncoming(new Uint8Array(), 404, {}),
|
| 343 |
+
);
|
| 344 |
const handler = getRegisteredHandler();
|
| 345 |
const response = createResponse();
|
| 346 |
|
|
|
|
| 362 |
});
|
| 363 |
|
| 364 |
it("answers 502 when the upstream answer is not an accepted image type", async () => {
|
| 365 |
+
pinnedRequestMock.mockResolvedValue(
|
| 366 |
+
fakeIncoming(new TextEncoder().encode("<html></html>"), 200, {
|
| 367 |
+
"content-type": "text/html",
|
|
|
|
| 368 |
}),
|
| 369 |
);
|
| 370 |
const handler = getRegisteredHandler();
|
|
|
|
| 382 |
|
| 383 |
it("answers 502 when the upstream answer is an SVG", async () => {
|
| 384 |
// An SVG is a document: served same-origin it would run its scripts.
|
| 385 |
+
pinnedRequestMock.mockResolvedValue(
|
| 386 |
+
fakeIncoming(
|
| 387 |
+
new TextEncoder().encode("<svg><script>alert(1)</script></svg>"),
|
| 388 |
+
200,
|
| 389 |
+
{ "content-type": "image/svg+xml" },
|
| 390 |
+
),
|
| 391 |
);
|
| 392 |
const handler = getRegisteredHandler();
|
| 393 |
const response = createResponse();
|
|
|
|
| 403 |
});
|
| 404 |
|
| 405 |
it("does not follow a redirect with a malformed Location", async () => {
|
| 406 |
+
pinnedRequestMock.mockResolvedValueOnce(
|
| 407 |
+
fakeIncoming(new Uint8Array(), 302, { location: "http://[" }),
|
|
|
|
|
|
|
|
|
|
| 408 |
);
|
| 409 |
const handler = getRegisteredHandler();
|
| 410 |
const response = createResponse();
|
|
|
|
| 416 |
);
|
| 417 |
|
| 418 |
expect(response.statusCode).toBe(502);
|
| 419 |
+
expect(pinnedRequestMock).toHaveBeenCalledTimes(1);
|
| 420 |
expect(recordThumbnailDropped).toHaveBeenCalledTimes(1);
|
| 421 |
});
|
| 422 |
|
| 423 |
it("does not cache a failure, so the next load retries the upstream", async () => {
|
| 424 |
const url = publicThumbnailUrl();
|
| 425 |
+
pinnedRequestMock
|
| 426 |
+
.mockResolvedValueOnce(fakeIncoming(new Uint8Array(), 404, {}))
|
| 427 |
.mockResolvedValueOnce(imageResponse());
|
| 428 |
const handler = getRegisteredHandler();
|
| 429 |
|
|
|
|
| 434 |
const second = createResponse();
|
| 435 |
await handler(createRequest(requestUrlFor(url)), second, vi.fn());
|
| 436 |
expect(second.statusCode).toBe(200);
|
| 437 |
+
expect(pinnedRequestMock).toHaveBeenCalledTimes(2);
|
| 438 |
});
|
| 439 |
|
| 440 |
it("answers 502 when the upstream body is empty", async () => {
|
| 441 |
+
pinnedRequestMock.mockResolvedValue(
|
| 442 |
+
fakeIncoming(new Uint8Array(), 200, { "content-type": "image/jpeg" }),
|
|
|
|
|
|
|
|
|
|
| 443 |
);
|
| 444 |
const handler = getRegisteredHandler();
|
| 445 |
const response = createResponse();
|
|
|
|
| 472 |
await handled;
|
| 473 |
|
| 474 |
expect(response.statusCode).toBe(502);
|
| 475 |
+
expect(pinnedRequestMock).not.toHaveBeenCalled();
|
| 476 |
expect(recordThumbnailDropped).toHaveBeenCalledTimes(1);
|
| 477 |
} finally {
|
| 478 |
vi.useRealTimers();
|
|
|
|
| 484 |
const thumbnailTimeoutMs = 3000;
|
| 485 |
vi.useFakeTimers();
|
| 486 |
try {
|
| 487 |
+
pinnedRequestMock.mockImplementation(
|
| 488 |
+
(_url: URL, _address: string, options?: { signal?: AbortSignal }) =>
|
| 489 |
new Promise((_resolve, reject) => {
|
| 490 |
+
options?.signal?.addEventListener("abort", () =>
|
| 491 |
reject(new Error("The operation was aborted")),
|
| 492 |
);
|
| 493 |
}),
|
|
|
|
| 510 |
});
|
| 511 |
|
| 512 |
it("answers 502 after the redirect budget is spent", async () => {
|
| 513 |
+
pinnedRequestMock.mockResolvedValue(
|
| 514 |
+
fakeIncoming(new Uint8Array(), 302, {
|
| 515 |
+
location: "https://thumbs.example.com/loop.jpg",
|
|
|
|
| 516 |
}),
|
| 517 |
);
|
| 518 |
const handler = getRegisteredHandler();
|
|
|
|
| 526 |
|
| 527 |
expect(response.statusCode).toBe(502);
|
| 528 |
// One request per hop, four hops for three redirects.
|
| 529 |
+
expect(pinnedRequestMock).toHaveBeenCalledTimes(4);
|
| 530 |
expect(recordThumbnailDropped).toHaveBeenCalledTimes(1);
|
| 531 |
});
|
| 532 |
|
| 533 |
it("truncates an oversized body to the byte cap", async () => {
|
| 534 |
const bigBody = new Uint8Array(600_000).fill(7);
|
| 535 |
+
pinnedRequestMock.mockResolvedValue(imageResponse(bigBody));
|
| 536 |
const handler = getRegisteredHandler();
|
| 537 |
const response = createResponse();
|
| 538 |
|
|
|
|
| 549 |
});
|
| 550 |
|
| 551 |
it("evicts the least recently used entry, not the least recently loaded one", async () => {
|
| 552 |
+
// A fresh response per call: a body can only be read once.
|
| 553 |
+
pinnedRequestMock.mockImplementation(async () => imageResponse());
|
| 554 |
const handler = getRegisteredHandler();
|
| 555 |
// A clean slate, so the eviction side is exactly where this test puts it.
|
| 556 |
resetThumbnailCache();
|
|
|
|
| 560 |
const load = (url: string) =>
|
| 561 |
handler(createRequest(requestUrlFor(url)), createResponse(), vi.fn());
|
| 562 |
const fetched = (url: string) =>
|
| 563 |
+
pinnedRequestMock.mock.calls.filter((call) => String(call[0]) === url)
|
| 564 |
+
.length;
|
| 565 |
|
| 566 |
await load(firstUrl);
|
| 567 |
await load(secondUrl);
|
server/thumbnailEndpointServerHook.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import type { ServerResponse } from "node:http";
|
| 2 |
import type { PreviewServer, ViteDevServer } from "vite";
|
| 3 |
import { handleTokenVerification } from "./handleTokenVerification.ts";
|
| 4 |
import {
|
|
@@ -6,10 +6,57 @@ import {
|
|
| 6 |
recordThumbnailDropped,
|
| 7 |
recordThumbnailRequested,
|
| 8 |
} from "./searchesSinceLastRestart.ts";
|
| 9 |
-
import {
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
import { thumbnailRateLimiter } from "./verifyTokenAndRateLimit.ts";
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
/**
|
| 14 |
* Off the critical path: the search response no longer waits on a thumbnail,
|
| 15 |
* so the budget is set against how long a user tolerates a placeholder tile,
|
|
@@ -109,9 +156,10 @@ function isRedirect(status: number): boolean {
|
|
| 109 |
}
|
| 110 |
|
| 111 |
/**
|
| 112 |
-
* `
|
| 113 |
-
* against the hop deadline: a hanging resolver would otherwise
|
| 114 |
-
* timeout on top of the one that is documented to bound the
|
|
|
|
| 115 |
*/
|
| 116 |
function timeoutError(): Error {
|
| 117 |
const error = new Error("The operation was aborted");
|
|
@@ -119,18 +167,18 @@ function timeoutError(): Error {
|
|
| 119 |
return error;
|
| 120 |
}
|
| 121 |
|
| 122 |
-
function resolveWithinDeadline(
|
| 123 |
deadline: AbortSignal,
|
| 124 |
-
work: Promise<
|
| 125 |
-
): Promise<
|
| 126 |
if (deadline.aborted) return Promise.reject(timeoutError());
|
| 127 |
-
return new Promise<
|
| 128 |
const onAbort = () => reject(timeoutError());
|
| 129 |
deadline.addEventListener("abort", onAbort, { once: true });
|
| 130 |
work.then(
|
| 131 |
-
(
|
| 132 |
deadline.removeEventListener("abort", onAbort);
|
| 133 |
-
resolve(
|
| 134 |
},
|
| 135 |
(error) => {
|
| 136 |
deadline.removeEventListener("abort", onAbort);
|
|
@@ -141,10 +189,14 @@ function resolveWithinDeadline(
|
|
| 141 |
}
|
| 142 |
|
| 143 |
/**
|
| 144 |
-
* Follows redirects by hand so that every hop is validated, the
|
| 145 |
-
* `pageContentService`
|
| 146 |
-
* bounce the server into a private address. The chain shares one
|
| 147 |
-
* a redirector cannot buy extra time per hop.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
*
|
| 149 |
* The URL is client-supplied (it arrives as a query parameter), so on failure
|
| 150 |
* it is not logged, matching `/page-content`; the outcome is counted instead.
|
|
@@ -155,8 +207,12 @@ async function fetchThumbnail(rawUrl: string): Promise<ThumbnailOutcome> {
|
|
| 155 |
|
| 156 |
for (let hop = 0; hop <= MAX_THUMBNAIL_REDIRECTS; hop++) {
|
| 157 |
let url: URL;
|
|
|
|
| 158 |
try {
|
| 159 |
-
url = await resolveWithinDeadline(
|
|
|
|
|
|
|
|
|
|
| 160 |
} catch (error) {
|
| 161 |
if (error instanceof Error && error.name === "TimeoutError") {
|
| 162 |
return { kind: "failed", reason: "TimeoutError" };
|
|
@@ -164,10 +220,9 @@ async function fetchThumbnail(rawUrl: string): Promise<ThumbnailOutcome> {
|
|
| 164 |
return { kind: "blocked" };
|
| 165 |
}
|
| 166 |
|
| 167 |
-
let response:
|
| 168 |
try {
|
| 169 |
-
response = await
|
| 170 |
-
redirect: "manual",
|
| 171 |
signal: deadline,
|
| 172 |
});
|
| 173 |
} catch (error) {
|
|
@@ -177,9 +232,10 @@ async function fetchThumbnail(rawUrl: string): Promise<ThumbnailOutcome> {
|
|
| 177 |
};
|
| 178 |
}
|
| 179 |
|
| 180 |
-
const location = response.headers.
|
| 181 |
-
if (isRedirect(response.
|
| 182 |
-
|
|
|
|
| 183 |
try {
|
| 184 |
target = new URL(location, url).toString();
|
| 185 |
} catch {
|
|
@@ -188,17 +244,18 @@ async function fetchThumbnail(rawUrl: string): Promise<ThumbnailOutcome> {
|
|
| 188 |
continue;
|
| 189 |
}
|
| 190 |
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
|
|
|
| 194 |
}
|
| 195 |
|
| 196 |
-
const normalizedType = (response.headers
|
| 197 |
.split(";")[0]
|
| 198 |
.trim()
|
| 199 |
.toLowerCase();
|
| 200 |
if (!ACCEPTED_CONTENT_TYPES.includes(normalizedType)) {
|
| 201 |
-
|
| 202 |
return {
|
| 203 |
kind: "failed",
|
| 204 |
reason: normalizedType
|
|
@@ -209,7 +266,7 @@ async function fetchThumbnail(rawUrl: string): Promise<ThumbnailOutcome> {
|
|
| 209 |
|
| 210 |
let bytes: Uint8Array;
|
| 211 |
try {
|
| 212 |
-
({ bytes } = await
|
| 213 |
} catch (error) {
|
| 214 |
return {
|
| 215 |
kind: "failed",
|
|
@@ -309,8 +366,8 @@ export function thumbnailEndpointServerHook<
|
|
| 309 |
// `dropped` is the total of what never reached the client, blocked
|
| 310 |
// included, so `requested` minus `dropped` stays the served count.
|
| 311 |
recordThumbnailDropped();
|
| 312 |
-
// Covers both refusals
|
| 313 |
-
// in private space and a host that does not resolve.
|
| 314 |
serveError(
|
| 315 |
response,
|
| 316 |
403,
|
|
|
|
| 1 |
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
| 2 |
import type { PreviewServer, ViteDevServer } from "vite";
|
| 3 |
import { handleTokenVerification } from "./handleTokenVerification.ts";
|
| 4 |
import {
|
|
|
|
| 6 |
recordThumbnailDropped,
|
| 7 |
recordThumbnailRequested,
|
| 8 |
} from "./searchesSinceLastRestart.ts";
|
| 9 |
+
import {
|
| 10 |
+
readCappedStream,
|
| 11 |
+
requestPinnedToVettedAddress,
|
| 12 |
+
} from "./utils/pinnedFetch.ts";
|
| 13 |
+
import { resolvePublicUrlAndAddress } from "./utils/publicUrl.ts";
|
| 14 |
+
import { safeEndResponse } from "./utils/streamUtils.ts";
|
| 15 |
import { thumbnailRateLimiter } from "./verifyTokenAndRateLimit.ts";
|
| 16 |
|
| 17 |
+
/**
|
| 18 |
+
* Security review for issue #2524 (audited 2026-09-17). What each item of
|
| 19 |
+
* the audit checklist maps to:
|
| 20 |
+
*
|
| 21 |
+
* - Private ranges (10/8, 172.16/12, 192.168/16), link-local including
|
| 22 |
+
* the cloud metadata address 169.254.169.254, loopback (127/8, ::1),
|
| 23 |
+
* CGNAT 100.64/10, multicast and reserved space, and the IPv4-mapped /
|
| 24 |
+
* IPv4-compatible IPv6 spellings: `BLOCKED_CIDRS` and `isBlockedAddress`
|
| 25 |
+
* in `server/utils/publicUrl.ts`, enforced per hop by
|
| 26 |
+
* `resolvePublicUrlAndAddress`. NAT64/6to4/Teredo are refused wholesale
|
| 27 |
+
* because an embedded IPv4 cannot be vetted.
|
| 28 |
+
* - DNS rebinding: closed. The vetted address is pinned to the socket by
|
| 29 |
+
* `requestPinnedToVettedAddress` (`server/utils/pinnedFetch.ts`); the
|
| 30 |
+
* connect never re-resolves, so a second, private DNS answer cannot be
|
| 31 |
+
* reached. TLS SNI and certificate verification still run against the
|
| 32 |
+
* original hostname, as does the `Host` header.
|
| 33 |
+
* - URL length: `MAX_THUMBNAIL_URL_LENGTH` (2048), refused before any
|
| 34 |
+
* work is done.
|
| 35 |
+
* - Schemes: http/https only, in `resolvePublicUrlAndAddress`.
|
| 36 |
+
* - Redirects: followed by hand below, every hop re-validated under one
|
| 37 |
+
* shared deadline.
|
| 38 |
+
* - Content-Type: raster-only allowlist below; SVG is deliberately
|
| 39 |
+
* excluded because served same-origin it is a script-carrying document.
|
| 40 |
+
* - Response size: `MAX_THUMBNAIL_BYTES` via `readCappedStream`.
|
| 41 |
+
* - Timeout: `THUMBNAIL_TIMEOUT_MS`, shared across DNS and every hop.
|
| 42 |
+
* - Rate limiting: per client IP via `thumbnailRateLimiter`, a dedicated
|
| 43 |
+
* 60-per-10s budget so a grid of tiles cannot exhaust the search
|
| 44 |
+
* budget. There is no separate global limiter: the per-client limiter
|
| 45 |
+
* plus the shared token gate bound the total, and a global cap would let
|
| 46 |
+
* one caller's grid starve every other user's tiles.
|
| 47 |
+
*
|
| 48 |
+
* Residual risk after this change: the endpoint still fetches arbitrary
|
| 49 |
+
* public URLs, so a caller can use it to GET any public host β bounded by
|
| 50 |
+
* the rate limiter, the size cap and the timeout, and useful only for
|
| 51 |
+
* raster images. The pinned address is the one DNS gave at vetting time; a
|
| 52 |
+
* host whose DNS later repoints stays unreachable here until its records
|
| 53 |
+
* are re-checked, which is the pin working, not failing. Content itself is
|
| 54 |
+
* untrusted: served same-origin, it carries `nosniff` and a sandbox CSP,
|
| 55 |
+
* and only raster types pass the allowlist, so no script-carrying format
|
| 56 |
+
* reaches a browser. `/page-content` has not adopted the pin yet and still
|
| 57 |
+
* carries the old rebinding residual (see `resolvePublicUrl`'s docstring).
|
| 58 |
+
*/
|
| 59 |
+
|
| 60 |
/**
|
| 61 |
* Off the critical path: the search response no longer waits on a thumbnail,
|
| 62 |
* so the budget is set against how long a user tolerates a placeholder tile,
|
|
|
|
| 156 |
}
|
| 157 |
|
| 158 |
/**
|
| 159 |
+
* `resolvePublicUrlAndAddress`'s DNS lookup does not take a signal, so it
|
| 160 |
+
* is raced against the hop deadline: a hanging resolver would otherwise
|
| 161 |
+
* add its own timeout on top of the one that is documented to bound the
|
| 162 |
+
* whole chain.
|
| 163 |
*/
|
| 164 |
function timeoutError(): Error {
|
| 165 |
const error = new Error("The operation was aborted");
|
|
|
|
| 167 |
return error;
|
| 168 |
}
|
| 169 |
|
| 170 |
+
function resolveWithinDeadline<T>(
|
| 171 |
deadline: AbortSignal,
|
| 172 |
+
work: Promise<T>,
|
| 173 |
+
): Promise<T> {
|
| 174 |
if (deadline.aborted) return Promise.reject(timeoutError());
|
| 175 |
+
return new Promise<T>((resolve, reject) => {
|
| 176 |
const onAbort = () => reject(timeoutError());
|
| 177 |
deadline.addEventListener("abort", onAbort, { once: true });
|
| 178 |
work.then(
|
| 179 |
+
(value) => {
|
| 180 |
deadline.removeEventListener("abort", onAbort);
|
| 181 |
+
resolve(value);
|
| 182 |
},
|
| 183 |
(error) => {
|
| 184 |
deadline.removeEventListener("abort", onAbort);
|
|
|
|
| 189 |
}
|
| 190 |
|
| 191 |
/**
|
| 192 |
+
* Follows redirects by hand so that every hop is validated and pinned, the
|
| 193 |
+
* way `pageContentService` follows them: `redirect: "follow"` would let a
|
| 194 |
+
* public host bounce the server into a private address. The chain shares one
|
| 195 |
+
* deadline, so a redirector cannot buy extra time per hop.
|
| 196 |
+
*
|
| 197 |
+
* Each hop connects to the address `resolvePublicUrlAndAddress` vetted, via
|
| 198 |
+
* `requestPinnedToVettedAddress`, so the address checked is the address
|
| 199 |
+
* reached: a second, private DNS answer has no path to the socket.
|
| 200 |
*
|
| 201 |
* The URL is client-supplied (it arrives as a query parameter), so on failure
|
| 202 |
* it is not logged, matching `/page-content`; the outcome is counted instead.
|
|
|
|
| 207 |
|
| 208 |
for (let hop = 0; hop <= MAX_THUMBNAIL_REDIRECTS; hop++) {
|
| 209 |
let url: URL;
|
| 210 |
+
let address: string;
|
| 211 |
try {
|
| 212 |
+
({ url, address } = await resolveWithinDeadline(
|
| 213 |
+
deadline,
|
| 214 |
+
resolvePublicUrlAndAddress(target),
|
| 215 |
+
));
|
| 216 |
} catch (error) {
|
| 217 |
if (error instanceof Error && error.name === "TimeoutError") {
|
| 218 |
return { kind: "failed", reason: "TimeoutError" };
|
|
|
|
| 220 |
return { kind: "blocked" };
|
| 221 |
}
|
| 222 |
|
| 223 |
+
let response: IncomingMessage;
|
| 224 |
try {
|
| 225 |
+
response = await requestPinnedToVettedAddress(url, address, {
|
|
|
|
| 226 |
signal: deadline,
|
| 227 |
});
|
| 228 |
} catch (error) {
|
|
|
|
| 232 |
};
|
| 233 |
}
|
| 234 |
|
| 235 |
+
const location = response.headers.location;
|
| 236 |
+
if (isRedirect(response.statusCode ?? 0) && location) {
|
| 237 |
+
// Tear the socket down rather than drain a body nobody asked for.
|
| 238 |
+
response.destroy();
|
| 239 |
try {
|
| 240 |
target = new URL(location, url).toString();
|
| 241 |
} catch {
|
|
|
|
| 244 |
continue;
|
| 245 |
}
|
| 246 |
|
| 247 |
+
const status = response.statusCode ?? 0;
|
| 248 |
+
if (status < 200 || status >= 300) {
|
| 249 |
+
response.destroy();
|
| 250 |
+
return { kind: "failed", reason: `HTTP ${status}` };
|
| 251 |
}
|
| 252 |
|
| 253 |
+
const normalizedType = (response.headers["content-type"] ?? "")
|
| 254 |
.split(";")[0]
|
| 255 |
.trim()
|
| 256 |
.toLowerCase();
|
| 257 |
if (!ACCEPTED_CONTENT_TYPES.includes(normalizedType)) {
|
| 258 |
+
response.destroy();
|
| 259 |
return {
|
| 260 |
kind: "failed",
|
| 261 |
reason: normalizedType
|
|
|
|
| 266 |
|
| 267 |
let bytes: Uint8Array;
|
| 268 |
try {
|
| 269 |
+
({ bytes } = await readCappedStream(response, MAX_THUMBNAIL_BYTES));
|
| 270 |
} catch (error) {
|
| 271 |
return {
|
| 272 |
kind: "failed",
|
|
|
|
| 366 |
// `dropped` is the total of what never reached the client, blocked
|
| 367 |
// included, so `requested` minus `dropped` stays the served count.
|
| 368 |
recordThumbnailDropped();
|
| 369 |
+
// Covers both refusals resolvePublicUrlAndAddress reports the same
|
| 370 |
+
// way: a host in private space and a host that does not resolve.
|
| 371 |
serveError(
|
| 372 |
response,
|
| 373 |
403,
|
server/utils/circuitBreaker.test.ts
CHANGED
|
@@ -30,7 +30,66 @@ describe("CircuitBreaker", () => {
|
|
| 30 |
}),
|
| 31 |
).rejects.toThrow("function error");
|
| 32 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
});
|
|
|
|
| 34 |
describe("getOpens", () => {
|
| 35 |
afterEach(() => {
|
| 36 |
vi.useRealTimers();
|
|
|
|
| 30 |
}),
|
| 31 |
).rejects.toThrow("function error");
|
| 32 |
});
|
| 33 |
+
|
| 34 |
+
it("resets failure count on success so spaced failures do not open the circuit", async () => {
|
| 35 |
+
const cb = new CircuitBreaker();
|
| 36 |
+
const fail = () =>
|
| 37 |
+
expect(
|
| 38 |
+
cb.execute("key", async () => {
|
| 39 |
+
throw new Error("transient");
|
| 40 |
+
}),
|
| 41 |
+
).rejects.toThrow("transient");
|
| 42 |
+
|
| 43 |
+
for (let attempt = 0; attempt < 4; attempt++) {
|
| 44 |
+
await fail();
|
| 45 |
+
}
|
| 46 |
+
await cb.execute("key", async () => "success");
|
| 47 |
+
for (let attempt = 0; attempt < 4; attempt++) {
|
| 48 |
+
await fail();
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
expect(cb.getState("key")).toBe("CLOSED");
|
| 52 |
+
expect(cb.getOpens("key")).toBe(0);
|
| 53 |
+
});
|
| 54 |
+
});
|
| 55 |
+
describe("allows, recordSuccess and recordFailure", () => {
|
| 56 |
+
afterEach(() => {
|
| 57 |
+
vi.useRealTimers();
|
| 58 |
+
});
|
| 59 |
+
|
| 60 |
+
it("drive the circuit through the same states execute does", () => {
|
| 61 |
+
vi.useFakeTimers();
|
| 62 |
+
const resetTimeout = 1000;
|
| 63 |
+
const cb = new CircuitBreaker({
|
| 64 |
+
failureThreshold: 2,
|
| 65 |
+
resetTimeout,
|
| 66 |
+
successThreshold: 1,
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
expect(cb.allows("host")).toBe(true);
|
| 70 |
+
// A success under a circuit nothing has failed on has nothing to record.
|
| 71 |
+
cb.recordSuccess("host");
|
| 72 |
+
expect(cb.getState("host")).toBe("CLOSED");
|
| 73 |
+
|
| 74 |
+
cb.recordFailure("host");
|
| 75 |
+
cb.recordFailure("host");
|
| 76 |
+
expect(cb.getState("host")).toBe("OPEN");
|
| 77 |
+
expect(cb.allows("host")).toBe(false);
|
| 78 |
+
expect(cb.getOpens("host")).toBe(1);
|
| 79 |
+
|
| 80 |
+
// The clock moves without the reset timer firing, so the circuit is still
|
| 81 |
+
// open when asked: the caller that asks after the window is the probe.
|
| 82 |
+
vi.setSystemTime(Date.now() + resetTimeout + 1);
|
| 83 |
+
expect(cb.getState("host")).toBe("OPEN");
|
| 84 |
+
expect(cb.allows("host")).toBe(true);
|
| 85 |
+
expect(cb.getState("host")).toBe("HALF_OPEN");
|
| 86 |
+
|
| 87 |
+
cb.recordSuccess("host");
|
| 88 |
+
expect(cb.getState("host")).toBe("CLOSED");
|
| 89 |
+
expect(cb.getOpens("host")).toBe(1);
|
| 90 |
+
});
|
| 91 |
});
|
| 92 |
+
|
| 93 |
describe("getOpens", () => {
|
| 94 |
afterEach(() => {
|
| 95 |
vi.useRealTimers();
|
server/utils/circuitBreaker.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
type CircuitState = "CLOSED" | "OPEN" | "HALF_OPEN";
|
| 2 |
|
| 3 |
-
|
|
|
|
| 4 |
failureThreshold: number;
|
| 5 |
resetTimeout: number;
|
| 6 |
successThreshold: number;
|
|
@@ -31,26 +32,76 @@ export class CircuitBreaker {
|
|
| 31 |
|
| 32 |
/** Runs `fn` under the named circuit, tripping it after repeated failures. */
|
| 33 |
async execute<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
if (metrics.state === "OPEN") {
|
| 37 |
-
if (this.shouldAttemptReset(metrics)) {
|
| 38 |
-
metrics.state = "HALF_OPEN";
|
| 39 |
-
} else {
|
| 40 |
-
throw new Error(`Circuit breaker is open for ${key}`);
|
| 41 |
-
}
|
| 42 |
}
|
| 43 |
|
| 44 |
try {
|
| 45 |
const result = await fn();
|
| 46 |
-
this.recordSuccess(
|
| 47 |
return result;
|
| 48 |
} catch (error) {
|
| 49 |
-
this.recordFailure(
|
| 50 |
throw error;
|
| 51 |
}
|
| 52 |
}
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
getState(key: string): CircuitState {
|
| 55 |
return this.metrics.get(key)?.state || "CLOSED";
|
| 56 |
}
|
|
@@ -89,36 +140,6 @@ export class CircuitBreaker {
|
|
| 89 |
return now - metrics.lastFailure > this.options.resetTimeout;
|
| 90 |
}
|
| 91 |
|
| 92 |
-
private recordSuccess(metrics: CircuitMetrics): void {
|
| 93 |
-
if (metrics.state === "HALF_OPEN") {
|
| 94 |
-
metrics.successes++;
|
| 95 |
-
if (metrics.successes >= this.options.successThreshold) {
|
| 96 |
-
this.resetMetrics(metrics);
|
| 97 |
-
}
|
| 98 |
-
} else {
|
| 99 |
-
metrics.failures = 0;
|
| 100 |
-
}
|
| 101 |
-
}
|
| 102 |
-
|
| 103 |
-
private recordFailure(metrics: CircuitMetrics): void {
|
| 104 |
-
metrics.failures++;
|
| 105 |
-
metrics.lastFailure = Date.now();
|
| 106 |
-
metrics.successes = 0;
|
| 107 |
-
|
| 108 |
-
if (
|
| 109 |
-
metrics.failures >= this.options.failureThreshold &&
|
| 110 |
-
metrics.state !== "OPEN"
|
| 111 |
-
) {
|
| 112 |
-
metrics.state = "OPEN";
|
| 113 |
-
metrics.opens++;
|
| 114 |
-
setTimeout(() => {
|
| 115 |
-
if (metrics.state === "OPEN") {
|
| 116 |
-
metrics.state = "HALF_OPEN";
|
| 117 |
-
}
|
| 118 |
-
}, this.options.resetTimeout);
|
| 119 |
-
}
|
| 120 |
-
}
|
| 121 |
-
|
| 122 |
private resetMetrics(metrics: CircuitMetrics): void {
|
| 123 |
metrics.failures = 0;
|
| 124 |
metrics.successes = 0;
|
|
|
|
| 1 |
type CircuitState = "CLOSED" | "OPEN" | "HALF_OPEN";
|
| 2 |
|
| 3 |
+
/** Thresholds for one breaker, shared by every circuit it keeps. */
|
| 4 |
+
export interface CircuitBreakerOptions {
|
| 5 |
failureThreshold: number;
|
| 6 |
resetTimeout: number;
|
| 7 |
successThreshold: number;
|
|
|
|
| 32 |
|
| 33 |
/** Runs `fn` under the named circuit, tripping it after repeated failures. */
|
| 34 |
async execute<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
| 35 |
+
if (!this.allows(key)) {
|
| 36 |
+
throw new Error(`Circuit breaker is open for ${key}`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
}
|
| 38 |
|
| 39 |
try {
|
| 40 |
const result = await fn();
|
| 41 |
+
this.recordSuccess(key);
|
| 42 |
return result;
|
| 43 |
} catch (error) {
|
| 44 |
+
this.recordFailure(key);
|
| 45 |
throw error;
|
| 46 |
}
|
| 47 |
}
|
| 48 |
|
| 49 |
+
/**
|
| 50 |
+
* Whether a call under the named circuit may go ahead right now. An open
|
| 51 |
+
* circuit whose reset timeout has elapsed becomes half-open here, so the
|
| 52 |
+
* caller that asked is the one that probes it. With `recordSuccess` and
|
| 53 |
+
* `recordFailure` this is `execute` taken apart, for a caller whose
|
| 54 |
+
* failures are return values rather than exceptions.
|
| 55 |
+
*/
|
| 56 |
+
allows(key: string): boolean {
|
| 57 |
+
const metrics = this.metrics.get(key);
|
| 58 |
+
if (metrics?.state !== "OPEN") return true;
|
| 59 |
+
|
| 60 |
+
if (!this.shouldAttemptReset(metrics)) return false;
|
| 61 |
+
|
| 62 |
+
metrics.state = "HALF_OPEN";
|
| 63 |
+
return true;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/** A success under the named circuit: clears its failures, or closes it after a probe. */
|
| 67 |
+
recordSuccess(key: string): void {
|
| 68 |
+
// A circuit nothing has failed under is closed with no failures already,
|
| 69 |
+
// so a success on it has nothing to record and no entry to create.
|
| 70 |
+
const metrics = this.metrics.get(key);
|
| 71 |
+
if (!metrics) return;
|
| 72 |
+
|
| 73 |
+
if (metrics.state === "HALF_OPEN") {
|
| 74 |
+
metrics.successes++;
|
| 75 |
+
if (metrics.successes >= this.options.successThreshold) {
|
| 76 |
+
this.resetMetrics(metrics);
|
| 77 |
+
}
|
| 78 |
+
} else {
|
| 79 |
+
metrics.failures = 0;
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/** A failure under the named circuit, opening it at the threshold. */
|
| 84 |
+
recordFailure(key: string): void {
|
| 85 |
+
const metrics = this.getOrCreateMetrics(key);
|
| 86 |
+
|
| 87 |
+
metrics.failures++;
|
| 88 |
+
metrics.lastFailure = Date.now();
|
| 89 |
+
metrics.successes = 0;
|
| 90 |
+
|
| 91 |
+
if (
|
| 92 |
+
metrics.failures >= this.options.failureThreshold &&
|
| 93 |
+
metrics.state !== "OPEN"
|
| 94 |
+
) {
|
| 95 |
+
metrics.state = "OPEN";
|
| 96 |
+
metrics.opens++;
|
| 97 |
+
setTimeout(() => {
|
| 98 |
+
if (metrics.state === "OPEN") {
|
| 99 |
+
metrics.state = "HALF_OPEN";
|
| 100 |
+
}
|
| 101 |
+
}, this.options.resetTimeout);
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
getState(key: string): CircuitState {
|
| 106 |
return this.metrics.get(key)?.state || "CLOSED";
|
| 107 |
}
|
|
|
|
| 140 |
return now - metrics.lastFailure > this.options.resetTimeout;
|
| 141 |
}
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
private resetMetrics(metrics: CircuitMetrics): void {
|
| 144 |
metrics.failures = 0;
|
| 145 |
metrics.successes = 0;
|
server/utils/pinnedFetch.test.ts
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import http from "node:http";
|
| 2 |
+
import type { RequestOptions } from "node:https";
|
| 3 |
+
import type { AddressInfo } from "node:net";
|
| 4 |
+
import { describe, expect, it, vi } from "vitest";
|
| 5 |
+
|
| 6 |
+
// https is stubbed so the TLS options the pin relies on β servername, Host,
|
| 7 |
+
// the lookup override β can be inspected without a certificate. http stays
|
| 8 |
+
// real: one test below drives an actual socket through the pin.
|
| 9 |
+
const httpsRequestMock = vi.hoisted(() => vi.fn());
|
| 10 |
+
|
| 11 |
+
vi.mock("node:https", () => ({
|
| 12 |
+
default: { request: httpsRequestMock },
|
| 13 |
+
}));
|
| 14 |
+
|
| 15 |
+
import {
|
| 16 |
+
readCappedStream,
|
| 17 |
+
requestPinnedToVettedAddress,
|
| 18 |
+
} from "./pinnedFetch.ts";
|
| 19 |
+
|
| 20 |
+
type LookupFn = (
|
| 21 |
+
hostname: string,
|
| 22 |
+
options: unknown,
|
| 23 |
+
callback: (
|
| 24 |
+
error: NodeJS.ErrnoException | null,
|
| 25 |
+
addresses: string | { address: string; family: number }[],
|
| 26 |
+
family?: number,
|
| 27 |
+
) => void,
|
| 28 |
+
) => void;
|
| 29 |
+
|
| 30 |
+
/**
|
| 31 |
+
* Runs a pinned https request against a stub that answers with an empty
|
| 32 |
+
* 200, and hands back the options the request was built with.
|
| 33 |
+
*/
|
| 34 |
+
async function captureHttpsOptions(
|
| 35 |
+
url: URL,
|
| 36 |
+
address: string,
|
| 37 |
+
): Promise<RequestOptions> {
|
| 38 |
+
httpsRequestMock.mockImplementation(
|
| 39 |
+
(_options: RequestOptions, callback: (response: unknown) => void) => ({
|
| 40 |
+
on: () => undefined,
|
| 41 |
+
end: () => callback({ statusCode: 200, headers: {} }),
|
| 42 |
+
}),
|
| 43 |
+
);
|
| 44 |
+
await requestPinnedToVettedAddress(url, address);
|
| 45 |
+
const calls = httpsRequestMock.mock.calls;
|
| 46 |
+
return calls[calls.length - 1][0] as RequestOptions;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/** Drives the lookup override and reports what it answers with. */
|
| 50 |
+
function resolveViaPin(
|
| 51 |
+
options: RequestOptions,
|
| 52 |
+
queriedHostname: string,
|
| 53 |
+
lookupOptions: unknown = {},
|
| 54 |
+
): Promise<{ address: string; family: number }> {
|
| 55 |
+
const lookup = options.lookup as LookupFn;
|
| 56 |
+
return new Promise((resolve, reject) => {
|
| 57 |
+
lookup(queriedHostname, lookupOptions, (error, addresses, family) => {
|
| 58 |
+
if (error) reject(error);
|
| 59 |
+
else if (typeof addresses === "string")
|
| 60 |
+
resolve({ address: addresses, family: family ?? 0 });
|
| 61 |
+
else resolve(addresses[0]);
|
| 62 |
+
});
|
| 63 |
+
});
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
function hostOf(options: RequestOptions): string {
|
| 67 |
+
return (options.headers as Record<string, string>).Host;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
describe("requestPinnedToVettedAddress", () => {
|
| 71 |
+
it("keeps the original hostname for TLS SNI and the Host header while dialling the vetted address", async () => {
|
| 72 |
+
const options = await captureHttpsOptions(
|
| 73 |
+
new URL("https://thumbs.example.com/a.jpg"),
|
| 74 |
+
"93.184.216.34",
|
| 75 |
+
);
|
| 76 |
+
|
| 77 |
+
// SNI and certificate verification follow the hostname, not the IP,
|
| 78 |
+
// so a wrong-host certificate still fails the request.
|
| 79 |
+
expect(options.hostname).toBe("thumbs.example.com");
|
| 80 |
+
expect(options.servername).toBe("thumbs.example.com");
|
| 81 |
+
expect(options.port).toBe(443);
|
| 82 |
+
expect(hostOf(options)).toBe("thumbs.example.com");
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
it("answers the lookup override with the pinned address without touching DNS", async () => {
|
| 86 |
+
const options = await captureHttpsOptions(
|
| 87 |
+
new URL("https://thumbs.example.com/a.jpg"),
|
| 88 |
+
"93.184.216.34",
|
| 89 |
+
);
|
| 90 |
+
|
| 91 |
+
// Whatever hostname the connect layer asks about, the answer is the
|
| 92 |
+
// vetted address: no resolver is consulted, so a second DNS answer
|
| 93 |
+
// cannot exist to be rebound.
|
| 94 |
+
const first = await resolveViaPin(options, "thumbs.example.com");
|
| 95 |
+
const second = await resolveViaPin(options, "anything-else.test");
|
| 96 |
+
expect(first).toEqual({ address: "93.184.216.34", family: 4 });
|
| 97 |
+
expect(second).toEqual({ address: "93.184.216.34", family: 4 });
|
| 98 |
+
|
| 99 |
+
// The Happy Eyeballs shape: asked for every answer, the pin hands
|
| 100 |
+
// back exactly one β the vetted address.
|
| 101 |
+
const all = await resolveViaPin(options, "anything.test", { all: true });
|
| 102 |
+
expect(all).toEqual({ address: "93.184.216.34", family: 4 });
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
it("brackets an IPv6 host in the Host header and reports family 6", async () => {
|
| 106 |
+
const address = "2001:db8:85a3::8a2e:370:7334";
|
| 107 |
+
const options = await captureHttpsOptions(
|
| 108 |
+
new URL(`https://[${address}]/x.png`),
|
| 109 |
+
address,
|
| 110 |
+
);
|
| 111 |
+
|
| 112 |
+
expect(options.hostname).toBe(address);
|
| 113 |
+
expect(hostOf(options)).toBe(`[${address}]`);
|
| 114 |
+
const resolved = await resolveViaPin(options, "anything.test");
|
| 115 |
+
expect(resolved).toEqual({ address, family: 6 });
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
it("preserves a non-default port through the pinned connect and the Host header", async () => {
|
| 119 |
+
const options = await captureHttpsOptions(
|
| 120 |
+
new URL("https://thumbs.example.com:8443/a.jpg"),
|
| 121 |
+
"93.184.216.34",
|
| 122 |
+
);
|
| 123 |
+
|
| 124 |
+
expect(options.port).toBe(8443);
|
| 125 |
+
expect(hostOf(options)).toBe("thumbs.example.com:8443");
|
| 126 |
+
});
|
| 127 |
+
|
| 128 |
+
it("omits the default port from the Host header", async () => {
|
| 129 |
+
const options = await captureHttpsOptions(
|
| 130 |
+
new URL("https://thumbs.example.com:443/a.jpg"),
|
| 131 |
+
"93.184.216.34",
|
| 132 |
+
);
|
| 133 |
+
|
| 134 |
+
expect(options.port).toBe(443);
|
| 135 |
+
expect(hostOf(options)).toBe("thumbs.example.com");
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
it("refuses a scheme that is not http(s)", async () => {
|
| 139 |
+
await expect(
|
| 140 |
+
requestPinnedToVettedAddress(
|
| 141 |
+
new URL("ftp://example.com/x"),
|
| 142 |
+
"93.184.216.34",
|
| 143 |
+
),
|
| 144 |
+
).rejects.toThrow("Unsupported URL scheme");
|
| 145 |
+
expect(httpsRequestMock).not.toHaveBeenCalled();
|
| 146 |
+
});
|
| 147 |
+
|
| 148 |
+
it("dials the pinned address end-to-end while the server sees the original Host", async () => {
|
| 149 |
+
// pinned-host.test has no DNS record: the request can only succeed if
|
| 150 |
+
// the socket goes where the pin says, not where a resolver would.
|
| 151 |
+
let seenHost = "";
|
| 152 |
+
let seenTarget = "";
|
| 153 |
+
const server = http.createServer((request, response) => {
|
| 154 |
+
seenHost = request.headers.host ?? "";
|
| 155 |
+
seenTarget = request.url ?? "";
|
| 156 |
+
response.writeHead(200, { "content-type": "image/png" });
|
| 157 |
+
response.end(Buffer.from([9, 8, 7]));
|
| 158 |
+
});
|
| 159 |
+
await new Promise<void>((resolve) =>
|
| 160 |
+
server.listen(0, "127.0.0.1", resolve),
|
| 161 |
+
);
|
| 162 |
+
const { port } = server.address() as AddressInfo;
|
| 163 |
+
|
| 164 |
+
try {
|
| 165 |
+
const url = new URL(`http://pinned-host.test:${port}/img.png?v=1`);
|
| 166 |
+
const response = await requestPinnedToVettedAddress(url, "127.0.0.1");
|
| 167 |
+
const { bytes } = await readCappedStream(response, 1_000);
|
| 168 |
+
|
| 169 |
+
expect(response.statusCode).toBe(200);
|
| 170 |
+
expect(Array.from(bytes)).toEqual([9, 8, 7]);
|
| 171 |
+
expect(seenHost).toBe(`pinned-host.test:${port}`);
|
| 172 |
+
expect(seenTarget).toBe("/img.png?v=1");
|
| 173 |
+
} finally {
|
| 174 |
+
server.close();
|
| 175 |
+
}
|
| 176 |
+
});
|
| 177 |
+
|
| 178 |
+
it("refuses to run a request whose signal is already aborted", async () => {
|
| 179 |
+
const controller = new AbortController();
|
| 180 |
+
controller.abort();
|
| 181 |
+
|
| 182 |
+
await expect(
|
| 183 |
+
requestPinnedToVettedAddress(
|
| 184 |
+
new URL("http://example.com/x"),
|
| 185 |
+
"93.184.216.34",
|
| 186 |
+
{ signal: controller.signal },
|
| 187 |
+
),
|
| 188 |
+
).rejects.toThrow();
|
| 189 |
+
});
|
| 190 |
+
});
|
| 191 |
+
|
| 192 |
+
describe("readCappedStream", () => {
|
| 193 |
+
function fakeStream(chunks: Uint8Array[]) {
|
| 194 |
+
return {
|
| 195 |
+
async *[Symbol.asyncIterator]() {
|
| 196 |
+
for (const chunk of chunks) yield chunk;
|
| 197 |
+
},
|
| 198 |
+
destroy: vi.fn(),
|
| 199 |
+
};
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
it("truncates at the cap and reports it", async () => {
|
| 203 |
+
const chunks = [new Uint8Array(300).fill(1), new Uint8Array(300).fill(2)];
|
| 204 |
+
const { bytes, truncated } = await readCappedStream(
|
| 205 |
+
fakeStream(chunks),
|
| 206 |
+
500,
|
| 207 |
+
);
|
| 208 |
+
|
| 209 |
+
expect(bytes.byteLength).toBe(500);
|
| 210 |
+
expect(truncated).toBe(true);
|
| 211 |
+
// The kept tail is the head of the second chunk, not the whole thing.
|
| 212 |
+
expect(Array.from(bytes.subarray(300))).toEqual(
|
| 213 |
+
new Array(200).fill(2) as number[],
|
| 214 |
+
);
|
| 215 |
+
});
|
| 216 |
+
|
| 217 |
+
it("reads an exact-fit body without truncation", async () => {
|
| 218 |
+
const { bytes, truncated } = await readCappedStream(
|
| 219 |
+
fakeStream([new Uint8Array([1, 2, 3, 4])]),
|
| 220 |
+
4,
|
| 221 |
+
);
|
| 222 |
+
|
| 223 |
+
expect(Array.from(bytes)).toEqual([1, 2, 3, 4]);
|
| 224 |
+
expect(truncated).toBe(false);
|
| 225 |
+
});
|
| 226 |
+
|
| 227 |
+
it("returns an empty buffer for an empty stream", async () => {
|
| 228 |
+
const { bytes, truncated } = await readCappedStream(fakeStream([]), 10);
|
| 229 |
+
|
| 230 |
+
expect(bytes.byteLength).toBe(0);
|
| 231 |
+
expect(truncated).toBe(false);
|
| 232 |
+
});
|
| 233 |
+
|
| 234 |
+
it("concatenates chunks below the cap in order", async () => {
|
| 235 |
+
const { bytes, truncated } = await readCappedStream(
|
| 236 |
+
fakeStream([
|
| 237 |
+
new Uint8Array([1]),
|
| 238 |
+
new Uint8Array([2, 3]),
|
| 239 |
+
new Uint8Array([4, 5]),
|
| 240 |
+
]),
|
| 241 |
+
10,
|
| 242 |
+
);
|
| 243 |
+
|
| 244 |
+
expect(Array.from(bytes)).toEqual([1, 2, 3, 4, 5]);
|
| 245 |
+
expect(truncated).toBe(false);
|
| 246 |
+
});
|
| 247 |
+
});
|
server/utils/pinnedFetch.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { IncomingMessage } from "node:http";
|
| 2 |
+
import http from "node:http";
|
| 3 |
+
import https from "node:https";
|
| 4 |
+
import { isIP } from "node:net";
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* What the capped reader needs from a response body: an async stream of
|
| 8 |
+
* chunks that can be torn down early. `IncomingMessage` satisfies it, and so
|
| 9 |
+
* does a test double, which keeps the reader testable without a socket.
|
| 10 |
+
*/
|
| 11 |
+
export interface CappedReadable extends AsyncIterable<Uint8Array> {
|
| 12 |
+
destroy(): void;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
/**
|
| 16 |
+
* GETs `url` over a socket pinned to `address`, the address the caller
|
| 17 |
+
* already vetted with `resolvePublicUrlAndAddress`. The custom `lookup`
|
| 18 |
+
* never touches DNS, so nothing re-resolves between the check and the
|
| 19 |
+
* connect: the rebinding window a check-then-`fetch` leaves open, where a
|
| 20 |
+
* second DNS answer could steer the socket into private space, is closed.
|
| 21 |
+
*
|
| 22 |
+
* The original hostname survives the pin three ways: it stays the request's
|
| 23 |
+
* `hostname`, so TLS SNI and certificate verification run against it, not
|
| 24 |
+
* against the IP being dialled, and the `Host` header is set explicitly so
|
| 25 |
+
* virtual-host routing is unaffected.
|
| 26 |
+
*
|
| 27 |
+
* @param url - The URL to fetch; its protocol picks http or https
|
| 28 |
+
* @param address - The vetted IP literal to connect to, IPv4 or IPv6
|
| 29 |
+
* @throws When the protocol is not http(s) or the request fails
|
| 30 |
+
*/
|
| 31 |
+
export function requestPinnedToVettedAddress(
|
| 32 |
+
url: URL,
|
| 33 |
+
address: string,
|
| 34 |
+
options: { signal?: AbortSignal } = {},
|
| 35 |
+
): Promise<IncomingMessage> {
|
| 36 |
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
| 37 |
+
return Promise.reject(new Error(`Unsupported URL scheme: ${url.protocol}`));
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
const isTls = url.protocol === "https:";
|
| 41 |
+
const defaultPort = isTls ? 443 : 80;
|
| 42 |
+
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
| 43 |
+
const port = url.port ? Number(url.port) : defaultPort;
|
| 44 |
+
// An IPv6 host needs its brackets back in the Host header.
|
| 45 |
+
const bracketed = isIP(hostname) === 6 ? `[${hostname}]` : hostname;
|
| 46 |
+
const hostHeader = port === defaultPort ? bracketed : `${bracketed}:${port}`;
|
| 47 |
+
|
| 48 |
+
const requestOptions: https.RequestOptions = {
|
| 49 |
+
path: `${url.pathname}${url.search}`,
|
| 50 |
+
method: "GET",
|
| 51 |
+
hostname,
|
| 52 |
+
port,
|
| 53 |
+
// SNI and certificate verification follow the hostname, not the pinned
|
| 54 |
+
// address, so a wrong-host certificate still fails the request.
|
| 55 |
+
servername: isTls ? hostname : undefined,
|
| 56 |
+
headers: { Host: hostHeader },
|
| 57 |
+
// The pin itself: the socket connects to the vetted address and no
|
| 58 |
+
// resolver is consulted, so the address checked is the address reached.
|
| 59 |
+
// Node's Happy Eyeballs path (autoSelectFamily, on by default since
|
| 60 |
+
// Node 20) asks the resolver with `all: true` and reads an array back,
|
| 61 |
+
// so the pin answers in whichever shape the connect layer asked for β
|
| 62 |
+
// one entry either way, the vetted address.
|
| 63 |
+
lookup: (
|
| 64 |
+
_hostname: string,
|
| 65 |
+
lookupOptions: unknown,
|
| 66 |
+
callback: (
|
| 67 |
+
error: NodeJS.ErrnoException | null,
|
| 68 |
+
addresses: string | { address: string; family: number }[],
|
| 69 |
+
family?: number,
|
| 70 |
+
) => void,
|
| 71 |
+
) => {
|
| 72 |
+
const family = isIP(address);
|
| 73 |
+
if ((lookupOptions as { all?: boolean } | undefined)?.all) {
|
| 74 |
+
callback(null, [{ address, family }]);
|
| 75 |
+
} else {
|
| 76 |
+
callback(null, address, family);
|
| 77 |
+
}
|
| 78 |
+
},
|
| 79 |
+
signal: options.signal,
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
return new Promise<IncomingMessage>((resolve, reject) => {
|
| 83 |
+
const request = isTls
|
| 84 |
+
? https.request(requestOptions, resolve)
|
| 85 |
+
: http.request(requestOptions, resolve);
|
| 86 |
+
request.on("error", reject);
|
| 87 |
+
request.end();
|
| 88 |
+
});
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
/**
|
| 92 |
+
* Reads at most `maxBytes` from a Node response stream, the counterpart of
|
| 93 |
+
* `readCappedBytes` for the `node:http` world: a hostile or merely huge
|
| 94 |
+
* upstream must not be able to pin the server's memory, and a caller that
|
| 95 |
+
* only needs the head of the body gains nothing from the tail. Breaking out
|
| 96 |
+
* of the iteration destroys the stream, releasing the socket instead of
|
| 97 |
+
* draining the rest.
|
| 98 |
+
*/
|
| 99 |
+
export async function readCappedStream(
|
| 100 |
+
stream: CappedReadable,
|
| 101 |
+
maxBytes: number,
|
| 102 |
+
): Promise<{ bytes: Uint8Array; truncated: boolean }> {
|
| 103 |
+
const chunks: Uint8Array[] = [];
|
| 104 |
+
let bytesRead = 0;
|
| 105 |
+
let truncated = false;
|
| 106 |
+
|
| 107 |
+
for await (const chunk of stream) {
|
| 108 |
+
const remaining = maxBytes - bytesRead;
|
| 109 |
+
if (chunk.byteLength > remaining) {
|
| 110 |
+
chunks.push(chunk.subarray(0, remaining));
|
| 111 |
+
bytesRead = maxBytes;
|
| 112 |
+
truncated = true;
|
| 113 |
+
break;
|
| 114 |
+
}
|
| 115 |
+
chunks.push(chunk);
|
| 116 |
+
bytesRead += chunk.byteLength;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
const bytes = new Uint8Array(bytesRead);
|
| 120 |
+
let offset = 0;
|
| 121 |
+
for (const chunk of chunks) {
|
| 122 |
+
bytes.set(chunk, offset);
|
| 123 |
+
offset += chunk.byteLength;
|
| 124 |
+
}
|
| 125 |
+
return { bytes, truncated };
|
| 126 |
+
}
|
server/utils/publicUrl.ts
CHANGED
|
@@ -124,22 +124,22 @@ export function isBlockedAddress(address: string): boolean {
|
|
| 124 |
}
|
| 125 |
|
| 126 |
/**
|
| 127 |
-
* Validates a URL
|
| 128 |
-
*
|
| 129 |
*
|
| 130 |
-
*
|
| 131 |
-
*
|
| 132 |
-
*
|
| 133 |
-
*
|
| 134 |
-
*
|
| 135 |
-
*
|
| 136 |
-
* The residual risk is up to one GET per redirect hop from inside the instance's
|
| 137 |
-
* network, and the readable text of whatever those GETs return.
|
| 138 |
*
|
| 139 |
-
* @returns The parsed URL
|
| 140 |
* @throws When the scheme is unsupported or the host resolves into a blocked range
|
| 141 |
*/
|
| 142 |
-
export async function
|
|
|
|
|
|
|
| 143 |
let url: URL;
|
| 144 |
try {
|
| 145 |
url = new URL(rawUrl);
|
|
@@ -158,7 +158,7 @@ export async function resolvePublicUrl(rawUrl: string): Promise<URL> {
|
|
| 158 |
if (isBlockedAddress(hostname)) {
|
| 159 |
throw new Error(`Refusing to fetch a non-public address: ${hostname}`);
|
| 160 |
}
|
| 161 |
-
return url;
|
| 162 |
}
|
| 163 |
|
| 164 |
const addresses = await lookup(hostname, { all: true });
|
|
@@ -173,5 +173,27 @@ export async function resolvePublicUrl(rawUrl: string): Promise<URL> {
|
|
| 173 |
);
|
| 174 |
}
|
| 175 |
|
| 176 |
-
return url;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
}
|
|
|
|
| 124 |
}
|
| 125 |
|
| 126 |
/**
|
| 127 |
+
* Validates a URL the server is about to fetch on a client's behalf and
|
| 128 |
+
* returns the address the fetch must connect to.
|
| 129 |
*
|
| 130 |
+
* Returning the vetted address is what makes pinning possible. A caller
|
| 131 |
+
* that validates here and then dials the hostname again (a plain `fetch`)
|
| 132 |
+
* re-resolves at connect time, and that second answer can differ from the
|
| 133 |
+
* one that was checked β the DNS-rebinding window. Handing the checked
|
| 134 |
+
* answer to `requestPinnedToVettedAddress` in `pinnedFetch.ts` makes the
|
| 135 |
+
* checked address and the connected address one and the same.
|
|
|
|
|
|
|
| 136 |
*
|
| 137 |
+
* @returns The parsed URL and the vetted IP literal to pin the connection to
|
| 138 |
* @throws When the scheme is unsupported or the host resolves into a blocked range
|
| 139 |
*/
|
| 140 |
+
export async function resolvePublicUrlAndAddress(
|
| 141 |
+
rawUrl: string,
|
| 142 |
+
): Promise<{ url: URL; address: string }> {
|
| 143 |
let url: URL;
|
| 144 |
try {
|
| 145 |
url = new URL(rawUrl);
|
|
|
|
| 158 |
if (isBlockedAddress(hostname)) {
|
| 159 |
throw new Error(`Refusing to fetch a non-public address: ${hostname}`);
|
| 160 |
}
|
| 161 |
+
return { url, address: hostname };
|
| 162 |
}
|
| 163 |
|
| 164 |
const addresses = await lookup(hostname, { all: true });
|
|
|
|
| 173 |
);
|
| 174 |
}
|
| 175 |
|
| 176 |
+
return { url, address: addresses[0].address };
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
/**
|
| 180 |
+
* Validates a URL that the server is about to fetch on a client's behalf,
|
| 181 |
+
* rejecting non-HTTP schemes and hosts that resolve into a blocked range.
|
| 182 |
+
*
|
| 183 |
+
* This check alone does not close the DNS-rebinding window: a caller that
|
| 184 |
+
* fetches by hostname after validating here re-resolves at connect time,
|
| 185 |
+
* so a name that answers publicly now and privately a moment later still
|
| 186 |
+
* gets through, and the response carries the result back. Callers that
|
| 187 |
+
* want the window closed must pin the connection with
|
| 188 |
+
* `requestPinnedToVettedAddress` using the address from
|
| 189 |
+
* `resolvePublicUrlAndAddress`. `/thumbnail` does; `/page-content` does
|
| 190 |
+
* not yet, and carries the residual: up to one GET per redirect hop from
|
| 191 |
+
* inside the instance's network, and the readable text of whatever those
|
| 192 |
+
* GETs return.
|
| 193 |
+
*
|
| 194 |
+
* @returns The parsed URL when it is safe to fetch
|
| 195 |
+
* @throws When the scheme is unsupported or the host resolves into a blocked range
|
| 196 |
+
*/
|
| 197 |
+
export async function resolvePublicUrl(rawUrl: string): Promise<URL> {
|
| 198 |
+
return (await resolvePublicUrlAndAddress(rawUrl)).url;
|
| 199 |
}
|
tsconfig.node.json
CHANGED
|
@@ -9,5 +9,11 @@
|
|
| 9 |
"allowSyntheticDefaultImports": true,
|
| 10 |
"strict": true
|
| 11 |
},
|
| 12 |
-
"include": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
}
|
|
|
|
| 9 |
"allowSyntheticDefaultImports": true,
|
| 10 |
"strict": true
|
| 11 |
},
|
| 12 |
+
"include": [
|
| 13 |
+
"vite.config.ts",
|
| 14 |
+
"server",
|
| 15 |
+
"shared",
|
| 16 |
+
"e2e",
|
| 17 |
+
"playwright.config.ts"
|
| 18 |
+
]
|
| 19 |
}
|