From 89d477d3860b0404d396c697c18d5bb452617c54 Mon Sep 17 00:00:00 2001 From: Andrew Mikofalvy <5668128+amikofalvy@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:13:40 -0700 Subject: [PATCH] Stream link-preview fetch to head-end for large pages (PRD-7495) (#2776) GitOrigin-RevId: c02627989f0801dc97ef6eeb393b43d1c7865ecf --- .changeset/prd-7495-headfirst-link-preview.md | 5 + .../src/link-preview/guarded-fetch.test.ts | 295 +++++++++++++++++- .../server/src/link-preview/guarded-fetch.ts | 92 +++++- .../src/link-preview/html-metadata.test.ts | Bin 10230 -> 14082 bytes .../server/src/link-preview/html-metadata.ts | 79 ++++- 5 files changed, 453 insertions(+), 18 deletions(-) create mode 100644 .changeset/prd-7495-headfirst-link-preview.md diff --git a/.changeset/prd-7495-headfirst-link-preview.md b/.changeset/prd-7495-headfirst-link-preview.md new file mode 100644 index 000000000..86c8a0423 --- /dev/null +++ b/.changeset/prd-7495-headfirst-link-preview.md @@ -0,0 +1,5 @@ +--- +"@inkeep/open-knowledge": patch +--- + +Link previews now load for content-heavy pages like GitHub and Wikipedia: the fetcher streams to the end of
instead of rejecting once the page body exceeds the 512 KB cap. diff --git a/packages/server/src/link-preview/guarded-fetch.test.ts b/packages/server/src/link-preview/guarded-fetch.test.ts index aac37542a..4af60d8dc 100644 --- a/packages/server/src/link-preview/guarded-fetch.test.ts +++ b/packages/server/src/link-preview/guarded-fetch.test.ts @@ -1,8 +1,9 @@ -import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { createServer, type Server } from 'node:http'; import { gzipSync } from 'node:zlib'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; import { listenOnLoopback } from '../loopback-rig-test-helpers.ts'; import { + createHeadEndScanner, DEFAULT_MAX_BYTES, DEFAULT_MAX_REDIRECTS, DEFAULT_TIMEOUT_MS, @@ -99,6 +100,78 @@ describe('guardedFetch admission (real classifier, no network reached)', () => { }); }); +describe('createHeadEndScanner (chunk-boundary-safe head-end detection)', () => { + const bytes = (text: string) => new Uint8Array(Buffer.from(text, 'latin1')); + + test('finds within a single chunk and reports the offset past it', () => { + const scan = createHeadEndScanner(); + const html = 'plain markup with heads and bodies spelled out
'))).toBe(-1); + } + }); +}); + describe('guardedFetch against a loopback rig (real socket)', () => { let server: Server; let port: number; @@ -111,6 +184,16 @@ describe('guardedFetch against a loopback rig (real socket)', () => { referer?: string; } | null = null; + const HUGE_PAGE_HEAD = + 'every byte returned
'); + return; + } + if (path === '/json-with-marker-bytes') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"snippet":"","trailer":"kept"}'); + return; + } + if (path === '/gzip-huge-head-first') { + // Tiny on the wire; decompressed far past the cap used by the test — + // but the head ends early, so the scan (which runs on DECOMPRESSED + // bytes) must admit it where the old whole-body read rejected it. + res.writeHead(200, { 'Content-Type': 'text/html', 'Content-Encoding': 'gzip' }); + res.end( + gzipSync(`every byte returned
', + ); + }); + + test('reads non-HTML content in full even when it contains marker-shaped bytes', async () => { + const result = await guardedFetch(`http://rig.example:${port}/json-with-marker-bytes`, { + ...withRig, + allowContentType: (mimeType) => mimeType === 'application/json', + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(new TextDecoder().decode(result.body)).toBe( + '{"snippet":"","trailer":"kept"}', + ); + }); + + test('applies the head-end scan to DECOMPRESSED bytes (gzip page far past the cap)', async () => { + const result = await guardedFetch(`http://rig.example:${port}/gzip-huge-head-first`, { + ...withRig, + maxBytes: 8 * 1024, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(new TextDecoder().decode(result.body)).toBe('3PyLb$wiRmq{GzCb
z`Q>x&5>UzZ+b32Hf#gxo7K4zJ<|oXfW-+O3nxiIqA~B^Qib*}m