system HF Staff commited on
Commit
af13576
Β·
verified Β·
1 Parent(s): 1a6d0d2

Sync from felladrin/MiniSearch@6c9fb20 (part 2)

Browse files
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. Fast enough for batched passage encoding: a batch of 64 passages
14
- * takes ~30 ms on CPU, well within the 20 s client timeout even for six pages
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
- /** Batch size for passage encoding. Larger batches speed up encoding but use
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(url: string, result: TokenVerificationResult) {
 
 
 
 
48
  verifyMock.mockResolvedValue(result);
49
  const response = makeResponse();
50
- const outcome = await handleTokenVerification("token", response, {
51
- url,
52
- } as IncomingMessage);
 
 
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 { verifyTokenAndRateLimit } from "./verifyTokenAndRateLimit.ts";
 
 
 
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("selectPassagesAcrossPages", () => {
385
- // Helper to call the internal selector indirectly through fetchPageContents.
386
- async function selectFrom(urls: string[], htmls: string[]) {
387
- fetchMock.mockReset();
388
- for (const html of htmls) {
389
- fetchMock.mockResolvedValueOnce(
390
- new Response(html, {
391
- status: 200,
392
- headers: { "content-type": "text/html" },
393
- }),
394
- );
 
 
 
 
 
 
 
 
 
395
  }
396
- return fetchPageContents("test query", urls);
397
- }
398
 
399
- it("suppresses a syndicated paragraph that appears on multiple pages", async () => {
400
- const syndicated =
401
- "This paragraph is syndicated across many sites. ".repeat(10);
402
- const uniqueA = "Unique content on page A. ".repeat(10);
403
- const uniqueB = "Unique content on page B. ".repeat(10);
404
-
405
- const contents = await selectFrom(
406
- ["https://example.com/a", "https://example.com/b"],
407
- [
408
- `<article><p>${uniqueA}</p><p>${syndicated}</p></article>`,
409
- `<article><p>${syndicated}</p><p>${uniqueB}</p></article>`,
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
- // Only page A should survive; page B's sole passage is a near-duplicate.
445
- expect(contents).toHaveLength(1);
446
- expect(contents[0].url).toContain("/a");
447
- });
448
- });
 
 
 
 
449
 
450
- describe("page read counters", () => {
451
- /** Counts what one call to `fetchPageContents` added, by outcome. */
452
- async function countOutcomes(url: string, query = "cats") {
453
- const before = getPageReadStats();
454
- await fetchPageContents(query, [url]);
455
- const after = getPageReadStats();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
 
457
- return {
458
- read: after.read - before.read,
459
- skipped: Object.fromEntries(
460
- Object.entries(after.skipped)
461
- .map(([outcome, count]) => [
462
- outcome,
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("tells an error status apart from a page that is not a document", async () => {
489
  fetchMock.mockResolvedValue(new Response("nope", { status: 403 }));
490
  expect((await countOutcomes("https://example.com/x")).skipped).toEqual({
491
- httpError: 1,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 URL anywhere in what it reports", async () => {
 
 
 
 
 
 
 
 
 
583
  respondWithHtml(articlePage);
584
- await fetchPageContents("a very distinctive private query", [
585
- "https://secret.example.com/private-page",
586
- ]);
 
 
587
 
588
- const reported = JSON.stringify(getPageReadStats());
 
 
 
 
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(rawUrl: string): Promise<DownloadResult> {
 
 
 
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
- let response: Response;
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
- const { bytes, truncated } = await readCappedBytes(
224
- response,
225
- MAX_RESPONSE_BYTES,
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
- * when the model is unavailable the lexical score alone is used.
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 lexicalRanked = rankPassages(query, input);
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(url: string): Promise<{
 
 
 
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(urls.map((url) => fetchPageContent(url)));
 
 
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 = rankPassages(query, allPassages);
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: "httpError", durationMs: 3 });
42
- recordPageRead({ outcome: "redirectLimit", durationMs: 4 });
 
 
43
  recordPageRead({ outcome: "timedOut", durationMs: 6000 });
44
- recordPageRead({ outcome: "tooLittleText", durationMs: 5 });
45
- recordPageRead({ outcome: "failed", durationMs: 6 });
46
  });
47
 
48
- expect(counted.requested).toBe(7);
49
  expect(counted.read).toBe(0);
50
  expect(counted.skipped).toEqual({
51
  blocked: 1,
 
52
  notADocument: 1,
53
- httpError: 1,
 
 
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
- | "httpError"
 
 
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. What is left still answers the questions worth asking of
22
- * this feature, because every counter below is attached to a constant someone
23
- * will want to move.
 
24
  */
25
  const outcomes: Record<PageReadOutcome, number> = {
26
  read: 0,
27
  blocked: 0,
 
28
  notADocument: 0,
29
- httpError: 0,
 
 
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.9 },
46
- { index: 1, relevance_score: 0.8 },
 
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 (A has higher score)
54
- expect(result[0][0]).toBe("A");
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", [[longTitle, "short", "https://a.com"]]);
 
 
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
- let reranks = 0;
11
- let totalMs = 0;
12
- let totalConsidered = 0;
13
- let totalKept = 0;
 
 
 
 
 
 
 
 
 
14
  let fallbackApplied = 0;
15
  let skippedUnhealthy = 0;
16
  let failed = 0;
17
 
18
- export function recordRerank({
19
- considered,
20
- kept,
21
- durationMs,
22
- usedPercentageFallback,
23
- }: {
24
- considered: number;
25
- kept: number;
26
- durationMs: number;
27
- usedPercentageFallback: boolean;
28
- }): void {
29
- reranks++;
30
- totalMs += durationMs;
31
- totalConsidered += considered;
32
- totalKept += kept;
 
 
 
 
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
- export function getRerankingStats() {
52
  return {
53
  reranks,
54
  averageMs: Math.round(totalMs / reranks || 0),
55
- considered: totalConsidered,
56
- kept: totalKept,
57
- keptRate: Number(((totalKept / totalConsidered) * 100 || 0).toFixed(1)),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(async (_query, results) =>
75
- results.map(([title, content, url]) => [title, content, url, 0]),
 
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
- isTextSearch?: boolean,
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(query, results, isTextSearch);
 
 
 
 
 
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(query, results, true);
 
 
 
 
 
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(query, resultsText);
 
 
 
 
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: getPageReadStats(),
 
 
 
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
- ): Response {
82
- return new Response(bytes, {
83
- status: 200,
84
- headers: { "content-type": contentType },
85
- });
86
  }
87
 
88
  describe("thumbnailEndpointServerHook", () => {
89
  beforeEach(() => {
90
  vi.clearAllMocks();
91
- mockFetch.mockReset();
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(mockFetch).not.toHaveBeenCalled();
111
  });
112
 
113
  it("stops processing when token verification fails", async () => {
@@ -122,7 +148,7 @@ describe("thumbnailEndpointServerHook", () => {
122
  vi.fn(),
123
  );
124
 
125
- expect(mockFetch).not.toHaveBeenCalled();
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(mockFetch).not.toHaveBeenCalled();
140
  expect(recordThumbnailRequested).not.toHaveBeenCalled();
141
  });
142
 
143
  it("serves a fetched image with its normalized content type and a private cache", async () => {
144
- mockFetch.mockResolvedValue(
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
- mockFetch.mockResolvedValue(imageResponse());
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 Response per call: a body can only be read once.
206
- mockFetch.mockImplementation(async () => imageResponse());
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(mockFetch).toHaveBeenCalledTimes(1);
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(mockFetch).not.toHaveBeenCalled();
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(mockFetch).not.toHaveBeenCalled();
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(mockFetch).not.toHaveBeenCalled();
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
- mockFetch.mockResolvedValueOnce(
267
- new Response(null, {
268
- status: 302,
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
- expect(mockFetch).toHaveBeenCalledTimes(1);
 
 
 
283
  expect(recordThumbnailBlocked).toHaveBeenCalledTimes(1);
284
  });
285
 
286
  it("answers 502 with no-store when the upstream returns an error status", async () => {
287
- mockFetch.mockResolvedValue(new Response(null, { status: 404 }));
 
 
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
- mockFetch.mockResolvedValue(
310
- new Response("<html></html>", {
311
- status: 200,
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
- mockFetch.mockResolvedValue(
331
- new Response("<svg><script>alert(1)</script></svg>", {
332
- status: 200,
333
- headers: { "content-type": "image/svg+xml" },
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
- mockFetch.mockResolvedValueOnce(
351
- new Response(null, {
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(mockFetch).toHaveBeenCalledTimes(1);
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
- mockFetch
373
- .mockResolvedValueOnce(new Response(null, { status: 404 }))
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(mockFetch).toHaveBeenCalledTimes(2);
385
  });
386
 
387
  it("answers 502 when the upstream body is empty", async () => {
388
- mockFetch.mockResolvedValue(
389
- new Response(new Uint8Array(), {
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(mockFetch).not.toHaveBeenCalled();
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
- mockFetch.mockImplementation(
438
- (_url: string, init?: RequestInit) =>
439
  new Promise((_resolve, reject) => {
440
- init?.signal?.addEventListener("abort", () =>
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
- mockFetch.mockResolvedValue(
464
- new Response(null, {
465
- status: 302,
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(mockFetch).toHaveBeenCalledTimes(4);
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
- mockFetch.mockResolvedValue(imageResponse(bigBody));
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 Response per call: a body can only be read once.
504
- mockFetch.mockImplementation(async () => imageResponse());
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
- mockFetch.mock.calls.filter((call) => String(call[0]) === url).length;
 
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 { resolvePublicUrl } from "./utils/publicUrl.ts";
10
- import { readCappedBytes, safeEndResponse } from "./utils/streamUtils.ts";
 
 
 
 
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
- * `resolvePublicUrl`'s DNS lookup does not take a signal, so it is raced
113
- * against the hop deadline: a hanging resolver would otherwise add its own
114
- * timeout on top of the one that is documented to bound the whole chain.
 
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<URL>,
125
- ): Promise<URL> {
126
  if (deadline.aborted) return Promise.reject(timeoutError());
127
- return new Promise<URL>((resolve, reject) => {
128
  const onAbort = () => reject(timeoutError());
129
  deadline.addEventListener("abort", onAbort, { once: true });
130
  work.then(
131
- (url) => {
132
  deadline.removeEventListener("abort", onAbort);
133
- resolve(url);
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 way
145
- * `pageContentService` does: `redirect: "follow"` would let a public host
146
- * bounce the server into a private address. The chain shares one deadline, so
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(deadline, resolvePublicUrl(target));
 
 
 
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: Response;
168
  try {
169
- response = await fetch(url, {
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.get("location");
181
- if (isRedirect(response.status) && location) {
182
- await response.body?.cancel().catch(() => {});
 
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
- if (!response.ok) {
192
- await response.body?.cancel().catch(() => {});
193
- return { kind: "failed", reason: `HTTP ${response.status}` };
 
194
  }
195
 
196
- const normalizedType = (response.headers.get("content-type") ?? "")
197
  .split(";")[0]
198
  .trim()
199
  .toLowerCase();
200
  if (!ACCEPTED_CONTENT_TYPES.includes(normalizedType)) {
201
- await response.body?.cancel().catch(() => {});
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 readCappedBytes(response, MAX_THUMBNAIL_BYTES));
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 resolvePublicUrl reports the same way: a host
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
- interface CircuitBreakerOptions {
 
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
- const metrics = this.getOrCreateMetrics(key);
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(metrics);
47
  return result;
48
  } catch (error) {
49
- this.recordFailure(metrics);
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 that the server is about to fetch on a client's behalf,
128
- * rejecting non-HTTP schemes and hosts that resolve into a blocked range.
129
  *
130
- * The DNS answer is checked, not pinned: a name that resolves to a public
131
- * address here and to a private one microseconds later during the fetch would
132
- * still get through, and returning the extracted text is what would carry the
133
- * result back, so this is a real gap rather than one the response shape closes.
134
- * Pinning means connecting to the vetted IP with the hostname preserved for TLS,
135
- * which needs a custom `undici` dispatcher this project has no other use for.
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 when it is safe to fetch
140
  * @throws When the scheme is unsupported or the host resolves into a blocked range
141
  */
142
- export async function resolvePublicUrl(rawUrl: string): Promise<URL> {
 
 
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": ["vite.config.ts", "server", "shared"]
 
 
 
 
 
 
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
  }