ext-apps/tests/helpers/range-counting-server.ts
Olivier Chafik 3cf5b588a1
chore(pdf-server): switch pdf-lib to maintained @cantoo/pdf-lib fork (#651)
The original pdf-lib package has been unmaintained since Nov 2021.
@cantoo/pdf-lib is an actively maintained, API-compatible fork with
ongoing releases and accumulated bugfixes.

Also updates test assertions from the deprecated PDFNumber.value() to
PDFNumber.asNumber() per the fork's API guidance.
2026-05-08 13:01:54 +01:00

254 lines
8.5 KiB
TypeScript

/**
* HTTP test fixture serving programmatically-generated PDFs with byte-range
* accounting. Used by pdf-incremental-load.spec.ts to assert that display_pdf
* doesn't pull the whole file before the viewer starts streaming.
*
* Plain HTTP on loopback — playwright.config.ts sets
* PDF_SERVER_ALLOW_LOOPBACK_HTTP=1 so validateUrl accepts http://127.0.0.1.
*/
import http from "node:http";
import type { AddressInfo } from "node:net";
import {
PDFDocument,
PDFName,
PDFString,
StandardFonts,
} from "@cantoo/pdf-lib";
export interface RangeServerStats {
/** Total bytes written across all responses (sum of slice lengths). */
totalBytesServed: number;
/** Bytes that were served more than once for the same path. */
overlapBytes: number;
}
export interface RangeServer {
port: number;
baseUrl: string;
/** Map of served path → byte length. */
fileSizes: Record<string, number>;
stats(): RangeServerStats;
resetStats(): void;
/** Resolve any requests currently stalled by ?stallAfterBytes=N. */
release(): void;
close(): Promise<void>;
}
const LOREM =
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod " +
"tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim " +
"veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea " +
"commodo consequat. ";
async function buildNoFormsPdf(): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const font = await doc.embedFont(StandardFonts.Helvetica);
// Page 1 is text-only (small) so first paint needs minimal bytes. Pages 2+
// each reference a large embedded JPEG so the bulk of the file is in image
// streams page 1 doesn't need. The stallAfterBytes test holds those back
// and asserts page 1 still renders. The image is >MAX_CHUNK_BYTES (512KB)
// so rendering page 2 also exercises the viewer's >512KB range path.
const big = await doc.embedJpg(makeRandomJpeg(1_100 * 1024));
const page1 = doc.addPage([612, 792]);
for (let line = 0; line < 30; line++) {
page1.drawText(`1.${line + 1} ${LOREM}`, {
x: 36,
y: 760 - line * 22,
size: 10,
font,
});
}
for (let p = 1; p < 20; p++) {
const page = doc.addPage([612, 792]);
page.drawImage(big, { x: 36, y: 200, width: 540, height: 540 });
page.drawText(`Page ${p + 1}`, { x: 36, y: 760, size: 10, font });
}
return doc.save();
}
/** Minimal valid JPEG with `len` bytes of incompressible scan data. */
export function makeRandomJpeg(len: number): Uint8Array {
// SOI, APP0 (JFIF), SOF0 (baseline 8x8 1-component), DHT (minimal),
// SOS, <random scan data>, EOI. pdf-lib only needs to parse the headers
// to embed; the scan data is opaque.
const header = Uint8Array.from([
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01,
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xc0, 0x00, 0x0b,
0x08, 0x00, 0x08, 0x00, 0x08, 0x01, 0x01, 0x11, 0x00, 0xff, 0xc4, 0x00,
0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xda, 0x00, 0x08, 0x01,
0x01, 0x00, 0x00, 0x3f, 0x00,
]);
const scan = new Uint8Array(len);
for (let i = 0; i < len; i++) scan[i] = (i * 1103515245 + 12345) & 0xff;
// Avoid 0xFF in scan data so we don't accidentally form a marker.
for (let i = 0; i < len; i++) if (scan[i] === 0xff) scan[i] = 0xfe;
const eoi = Uint8Array.from([0xff, 0xd9]);
const out = new Uint8Array(header.length + scan.length + eoi.length);
out.set(header, 0);
out.set(scan, header.length);
out.set(eoi, header.length + scan.length);
return out;
}
/**
* Two pages, page 1 text-only, page 2 carries one native /Text (sticky-note)
* annotation. Used by the tombstone-preservation e2e: the viewer's lazy
* baseline scan must not have visited page 2 when persistAnnotations runs.
*/
async function buildWithNativeAnnotPdf(): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const font = await doc.embedFont(StandardFonts.Helvetica);
const page1 = doc.addPage([612, 792]);
page1.drawText("Page 1 — no native annotations here.", {
x: 36,
y: 740,
size: 12,
font,
});
const page2 = doc.addPage([612, 792]);
page2.drawText("Page 2 — has one native /Text annot.", {
x: 36,
y: 740,
size: 12,
font,
});
const annotRef = doc.context.register(
doc.context.obj({
Type: "Annot",
Subtype: "Text",
Rect: [100, 700, 120, 720],
Contents: PDFString.of("native sticky note"),
Open: false,
Name: "Comment",
}),
);
page2.node.set(PDFName.of("Annots"), doc.context.obj([annotRef]));
return doc.save();
}
async function buildFormsPdf(): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const form = doc.getForm();
for (let p = 0; p < 2; p++) doc.addPage([612, 792]);
const [page1] = doc.getPages();
const fields = ["name", "email", "phone", "city", "notes"];
fields.forEach((name, i) => {
const f = form.createTextField(name);
f.addToPage(page1, { x: 100, y: 650 - i * 60, width: 300, height: 24 });
});
return doc.save();
}
export async function startRangeServer(): Promise<RangeServer> {
if (process.env.NODE_ENV === "production") {
throw new Error(
"range-counting-server is a test fixture; refusing to start with NODE_ENV=production",
);
}
const files: Record<string, Uint8Array> = {
"/noforms.pdf": await buildNoFormsPdf(),
"/forms.pdf": await buildFormsPdf(),
"/with-native-annot.pdf": await buildWithNativeAnnotPdf(),
};
const fileSizes = Object.fromEntries(
Object.entries(files).map(([k, v]) => [k, v.length]),
);
// Per-path hit count per byte, for overlap accounting.
const hitCounts: Record<string, Uint8Array> = {};
const initHits = () => {
for (const [k, v] of Object.entries(files)) {
hitCounts[k] = new Uint8Array(v.length);
}
};
initHits();
let totalBytesServed = 0;
let releaseResolve: (() => void) | undefined;
let releasePromise = new Promise<void>((r) => (releaseResolve = r));
const server = http.createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
const body = files[url.pathname];
if (!body) {
res.writeHead(404).end();
return;
}
const stallAfterBytes = url.searchParams.get("stallAfterBytes");
const total = body.length;
const range = req.headers.range;
let begin = 0;
let end = total; // exclusive
let status = 200;
if (range) {
const m = /^bytes=(\d+)-(\d*)$/.exec(range);
if (m) {
begin = parseInt(m[1], 10);
end = m[2] ? parseInt(m[2], 10) + 1 : total;
begin = Math.min(begin, total);
end = Math.min(end, total);
status = 206;
}
}
// Stall once N bytes have already been served — lets pdfjs read the
// header/trailer/xref (scattered across the file) before blocking the
// bulk content streams.
if (stallAfterBytes !== null) {
if (totalBytesServed >= parseInt(stallAfterBytes, 10)) {
await releasePromise;
}
}
const slice = body.subarray(begin, end);
totalBytesServed += slice.length;
const hits = hitCounts[url.pathname];
for (let i = begin; i < end; i++) hits[i]++;
const headers: Record<string, string> = {
"Content-Type": "application/pdf",
"Accept-Ranges": "bytes",
"Content-Length": String(slice.length),
};
if (status === 206) {
headers["Content-Range"] = `bytes ${begin}-${end - 1}/${total}`;
}
res.writeHead(status, headers);
res.end(slice);
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const port = (server.address() as AddressInfo).port;
return {
port,
baseUrl: `http://127.0.0.1:${port}`,
fileSizes,
stats() {
let overlapBytes = 0;
for (const hits of Object.values(hitCounts)) {
for (let i = 0; i < hits.length; i++) if (hits[i] > 1) overlapBytes++;
}
return { totalBytesServed, overlapBytes };
},
resetStats() {
totalBytesServed = 0;
initHits();
// Unblock any handlers parked on the previous stall before re-arming,
// otherwise they hold sockets open forever and close() hangs.
releaseResolve?.();
releasePromise = new Promise<void>((r) => (releaseResolve = r));
},
release() {
releaseResolve?.();
},
close() {
releaseResolve?.();
server.closeAllConnections?.();
return new Promise<void>((resolve) => server.close(() => resolve()));
},
};
}