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,