diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 5f7df20c738c8..c6060d49fe839 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -136,6 +136,14 @@ describe("browser API CORS", () => { expect(allowedHeaders.has(ORCHESTRATION_PROTOCOL_HEADER)).toBe(true); expect(allowedHeaders.has("authorization")).toBe(true); expect(allowedHeaders.has("dpop")).toBe(true); + + // Range readers validate offsets and revision from these response headers. + const asset = await handler( + new Request("https://backend.example/api/environment", { + headers: { origin: "https://app.t3.codes" }, + }), + ); + expect(asset.headers.get("access-control-expose-headers")).toBe("content-range,etag"); } finally { await dispose(); } diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 1427035404722..06a49d7acd2fc 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -267,6 +267,8 @@ export const layerBrowserApiCors = Layer.unwrap( : {}), allowedMethods: browserApiCorsAllowedMethods, allowedHeaders: browserApiCorsAllowedHeaders, + // Cross-origin previews validate byte ranges against the size and revision. + exposedHeaders: ["content-range", "etag"], maxAge: 600, }); }), diff --git a/apps/web/src/components/files/PdfViewer.browser.test.tsx b/apps/web/src/components/files/PdfViewer.browser.test.tsx index 1b07f89eaf813..53fd67e6810d0 100644 --- a/apps/web/src/components/files/PdfViewer.browser.test.tsx +++ b/apps/web/src/components/files/PdfViewer.browser.test.tsx @@ -55,8 +55,10 @@ function testPdf( }, { box: "0 0 612 792", rotate: 90, text: "BT /F1 24 Tf 30 700 Td (Budget and risks) Tj ET" }, ], + padding = 0, ) { const fonts = 3 + pages.length * 2; + const filler = `<< /Length ${padding} >>\nstream\n${"x".repeat(padding)}\nendstream`; const objects = [ "<< /Type /Catalog /Pages 2 0 R >>", `<< /Type /Pages /Count ${pages.length} /Kids [${pages.map((_, index) => `${3 + index * 2} 0 R`).join(" ")}] >>`, @@ -66,12 +68,19 @@ function testPdf( ]), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>", + ...(padding ? [filler, filler] : []), ]; + // Unreferenced filler keeps the last page's content out of the first range and the cross-reference table's. + const order = objects.map((_, index) => index); + if (padding) { + const [lastContent] = order.splice(pages.length * 2 + 1, 1); + order.splice(-1, 0, lastContent!); + } let source = "%PDF-1.4\n"; const offsets: number[] = []; - for (const [index, object] of objects.entries()) { - offsets.push(source.length); - source += `${index + 1} 0 obj\n${object}\nendobj\n`; + for (const index of order) { + offsets[index] = source.length; + source += `${index + 1} 0 obj\n${objects[index]}\nendobj\n`; } const xref = source.length; source += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets @@ -142,6 +151,99 @@ it("fits the width, then finds split and rotated text and jumps to its page", as } }); +it.each([ + { server: "valid", full: false }, + { server: "ignored", full: true }, + { server: "malformed", full: true }, + { server: "revised", full: true }, +])("opens a PDF from a server with $server ranges", async ({ server, full }) => { + const bytes = new Uint8Array(await testPdf(undefined, 200_000).arrayBuffer()); + const url = "https://assets.example/report.pdf"; + const requests: (string | null)[] = []; + const realFetch = globalThis.fetch; + using _fetch = vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => { + if (String(input) !== url) return realFetch(input, init); + const range = new Headers(init?.headers).get("range"); + requests.push(range); + const match = /^bytes=(\d+)-(\d+)$/.exec(range ?? ""); + if (!match || server === "ignored") return Promise.resolve(new Response(bytes)); + const start = Number(match[1]); + const end = Math.min(Number(match[2]), bytes.length - 1); + const reported = server === "malformed" ? start + 1 : start; + return Promise.resolve( + new Response(bytes.slice(start, end + 1), { + status: 206, + headers: { + "content-range": `bytes ${reported}-${end}/${bytes.length}`, + etag: server === "revised" && requests.length > 1 ? '"b"' : '"a"', + }, + }), + ); + }); + await using screen = await render(); + await expect.element(screen.getByText("1 / 2 pages"), { timeout: 10_000 }).toBeVisible(); + expect(requests[0]).toBe("bytes=0-65535"); + expect(requests.includes(null)).toBe(full); + if (!full) expect(requests.length).toBeGreaterThan(1); +}); + +it("keeps the reading position when a later range forces a full read", async () => { + const bytes = new Uint8Array(await testPdf(undefined, 200_000).arrayBuffer()); + const pageTwo = new TextDecoder().decode(bytes).indexOf("\n6 0 obj"); + const url = "https://assets.example/report.pdf"; + const requests: (string | null)[] = []; + let fullReadCache: RequestCache | undefined; + let revise!: () => void; + const revised = new Promise((resolve) => (revise = resolve)); + const realFetch = globalThis.fetch; + using _fetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + if (String(input) !== url) return realFetch(input, init); + const range = new Headers(init?.headers).get("range"); + requests.push(range); + const match = /^bytes=(\d+)-(\d+)$/.exec(range ?? ""); + if (!match) { + fullReadCache = init?.cache; + return new Response(bytes); + } + const start = Number(match[1]); + const end = Math.min(Number(match[2]), bytes.length - 1); + const changed = start <= pageTwo && end > pageTwo; + if (changed) await revised; + return new Response(bytes.slice(start, end + 1), { + status: 206, + headers: { + "content-range": `bytes ${start}-${end}/${bytes.length}`, + etag: changed ? '"b"' : '"a"', + }, + }); + }); + await using screen = await render( +
+ +
, + ); + await expect.element(screen.getByText("1 / 2 pages"), { timeout: 10_000 }).toBeVisible(); + const region = () => screen.getByRole("region", { name: "Ranged PDF" }).element(); + const top = 40; + const scrolled = new Promise((resolve) => + region().addEventListener("scroll", resolve, { once: true }), + ); + region().scrollTop = top; + await scrolled; + + revise(); + await vi.waitFor(() => { + expect(requests).toContain(null); + // Page 2 laid out at its own size means the full read replaced the ranged document. + const height = (page: number) => + region().querySelector(`canvas[aria-label="Page ${page}"]`)?.getBoundingClientRect().height; + expect(height(2)).toBeGreaterThan(height(1)! * 2); + }); + expect(region().scrollTop).toBeCloseTo(top, 0); + // The changed file may sit in the HTTP cache from an earlier full read. + expect(fullReadCache).toBe("reload"); +}); + it("jumps to the first hit while still counting and keeps later manual navigation", async () => { setup(4); const page = (text: string) => ({ diff --git a/apps/web/src/components/files/PdfViewer.tsx b/apps/web/src/components/files/PdfViewer.tsx index 63b443d525641..61e3d2af1808a 100644 --- a/apps/web/src/components/files/PdfViewer.tsx +++ b/apps/web/src/components/files/PdfViewer.tsx @@ -12,7 +12,7 @@ import { Input } from "~/components/ui/input"; import { observeResize } from "~/lib/observeResize"; import { FileSurfaceAction, FileSurfaceFailure, FileSurfaceLoading } from "./fileSurfaceChrome"; import { readBoundedDocument } from "./readBoundedDocument"; -import { openPdf } from "./pdfDocument"; +import { openPdf, openPdfRanges } from "./pdfDocument"; import { currentPdfPage, pdfFindIncludes, @@ -139,12 +139,13 @@ export default function PdfViewer(props: { const [savedPosition] = useState(() => props.positionKey === undefined ? undefined : positions.get(props.positionKey), ); - const [initialView] = useState( + const [initialView, setInitialView] = useState( () => savedPosition ?? props.initialView ?? { page: 0, offset: 0, zoom: null, left: 0 }, ); const [pdf, setPdf] = useState(null); const [error, setError] = useState(null); const [revision, setRevision] = useState(0); + const [fullRead, setFullRead] = useState(false); const [ratios, setRatios] = useState([]); const [zoom, setZoom] = useState(initialView.zoom); const [pageWidth, setPageWidth] = useState(612); @@ -158,6 +159,22 @@ export default function PdfViewer(props: { const scroll = useRef(null); const find = useRef(null); const zoomAnchor = useRef<{ page: number; offset: number; width: number } | null>(null); + const { scale, width } = pdfPageWidth(viewport.width, pageWidth, zoom); + const offsets = pdfPageOffsets(ratios, width); + const readView = (node: HTMLDivElement) => { + const page = currentPdfPage(offsets, ratios.length, node.scrollTop); + return { + page, + offset: Math.max(0, node.scrollTop - (offsets[page]! - 16)), + zoom, + left: node.scrollLeft, + }; + }; + // A range fallback reloads the document; the reader stays where they were. + const keepView = useEffectEvent(() => { + const node = scroll.current; + if (pdf && node) setInitialView({ ...readView(node), width }); + }); const initialPage = initialView.page; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Own the PDF fetch, load timeout, and pdf.js document task across source changes, retries, and unmounts. useEffect(() => { @@ -169,15 +186,28 @@ export default function PdfViewer(props: { controller.abort(); void task?.destroy(); }, 30_000); - void readBoundedDocument( - props.src, - controller.signal, - 50 * 1024 * 1024, - revision > 0 ? "reload" : "default", - ) - .then(async (bytes) => { + const limit = 50 * 1024 * 1024; + void (async () => { + const ranges = fullRead + ? null + : await openPdfRanges(props.src, controller.signal, limit, () => { + keepView(); + setPdf(null); + setFullRead(true); + }); + return ( + ranges ?? + (await readBoundedDocument( + props.src, + controller.signal, + limit, + revision > 0 || fullRead ? "reload" : "default", + )) + ); + })() + .then(async (source) => { controller.signal.throwIfAborted(); - task = openPdf(bytes); + task = openPdf(source); const document = await task.promise; if (document.numPages > MAX_PAGES) throw new Error("Preview limited to 2,000 pages. Save this PDF to read it in full."); @@ -211,7 +241,7 @@ export default function PdfViewer(props: { clearTimeout(timeout); void task?.destroy(); }; - }, [props.src, revision, initialPage, savedPosition]); + }, [props.src, revision, fullRead, initialPage, savedPosition]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Measure the PDF viewport before paint and observe resizes for fit-width sizing and visible-page selection. useLayoutEffect(() => { const node = scroll.current; @@ -220,8 +250,6 @@ export default function PdfViewer(props: { update(); return observeResize(node, update); }, []); - const { scale, width } = pdfPageWidth(viewport.width, pageWidth, zoom); - const offsets = pdfPageOffsets(ratios, width); const current = currentPdfPage(offsets, ratios.length, scrollTop); const visible = visiblePdfPages(ratios, offsets, scrollTop, viewport.height); // Stable callbacks let page canvases survive scroll and find updates. @@ -235,14 +263,8 @@ export default function PdfViewer(props: { scroll.current?.scrollTo({ top: pdfPageScrollTop(offsets, index) }); }); const saveView = (node: HTMLDivElement, notify = true) => { - const page = currentPdfPage(offsets, ratios.length, node.scrollTop); if (!pdf) return; - const view = { - page, - offset: Math.max(0, node.scrollTop - (offsets[page]! - 16)), - zoom, - left: node.scrollLeft, - }; + const view = readView(node); if (notify) props.onViewChange?.(view); if (props.positionKey !== undefined) { positions.delete(props.positionKey); @@ -255,7 +277,12 @@ export default function PdfViewer(props: { const node = scroll.current; if (!pdf || !node) return; node.scrollTo({ - top: pdfRestoredScrollTop(ratios, width, initialView, savedPosition?.width), + top: pdfRestoredScrollTop( + ratios, + width, + initialView, + initialView.width ?? savedPosition?.width, + ), left: initialView.left, }); setScrollTop(node.scrollTop); diff --git a/apps/web/src/components/files/pdfDocument.ts b/apps/web/src/components/files/pdfDocument.ts index 32e8aefbe9028..09a2d86892750 100644 --- a/apps/web/src/components/files/pdfDocument.ts +++ b/apps/web/src/components/files/pdfDocument.ts @@ -1,4 +1,4 @@ -import { getDocument, GlobalWorkerOptions, PDFWorker } from "pdfjs-dist"; +import { getDocument, GlobalWorkerOptions, PDFDataRangeTransport, PDFWorker } from "pdfjs-dist"; import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url"; import fallbackUrl from "pdfjs-dist/wasm/openjpeg_nowasm_fallback.js?url"; @@ -34,11 +34,66 @@ export class BinaryDataFactory { // Without a shared worker, pdf.js starts a worker and recompiles its 1.3 MB script for every document. let worker: PDFWorker | undefined; -export function openPdf(bytes: ArrayBuffer) { +const RANGE_CHUNK_BYTES = 64 * 1024; + +async function readRange(src: string, begin: number, end: number, signal: AbortSignal) { + const response = await fetch(src, { + signal, + // A cached partial response makes the browser add If-Range, which the server answers in full. + cache: "no-store", + // Explicit offsets stay CORS-safelisted; suffix ranges need a preflight the server refuses. + headers: { range: `bytes=${begin}-${end - 1}` }, + }); + const match = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(response.headers.get("content-range") ?? ""); + const size = Number(match?.[3]); + const bytes = response.status === 206 && match ? await response.arrayBuffer() : null; + if ( + !bytes || + Number(match![1]) !== begin || + Number(match![2]) !== Math.min(end, size) - 1 || + bytes.byteLength !== Math.min(end, size) - begin + ) { + void response.body?.cancel().catch(() => undefined); + throw new Error("Invalid PDF range response."); + } + return { bytes: new Uint8Array(bytes), size, revision: response.headers.get("etag") }; +} + +/** + * Reads only the byte ranges PDF.js asks for. Resolves null when the source cannot + * serve validated ranges; `onFailure` reports a later bad range or revision change. + */ +export async function openPdfRanges( + src: string, + signal: AbortSignal, + limit: number, + onFailure: () => void, +) { + const first = await readRange(src, 0, RANGE_CHUNK_BYTES, signal).catch(() => null); + if (!first?.revision) return null; + if (first.size > limit) throw new Error("This file is too large to preview."); + const transport = new PDFDataRangeTransport(first.size, first.bytes); + transport.requestDataRange = (begin: number, end: number) => { + void readRange(src, begin, end, signal) + .then((range) => { + if (range.size !== first.size || range.revision !== first.revision) + throw new Error("The PDF changed while loading."); + transport.onDataRange(begin, range.bytes); + }) + .catch(() => { + if (!signal.aborted) onFailure(); + }); + }; + return transport; +} + +export function openPdf(source: ArrayBuffer | PDFDataRangeTransport) { worker ??= new PDFWorker(); return getDocument({ worker, - data: new Uint8Array(bytes), + ...(source instanceof ArrayBuffer + ? { data: new Uint8Array(source) } + : { range: source, rangeChunkSize: RANGE_CHUNK_BYTES, disableAutoFetch: true }), useSystemFonts: true, useWorkerFetch: false, BinaryDataFactory,