1import { constVoid } from "effect/Function";
2import type { CDPSession } from "playwright-core";
3
4const escapeHtml = (value: string) =>
5 value.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<");
6
7export const isPdfAssetUrl = (url: string) => {
8 const { pathname } = new URL(url);
9 return pathname.startsWith("/api/assets/") && /\.pdf$/i.test(pathname);
10};
11
12export async function installPdfDocumentNavigation(cdp: CDPSession) {
13 const nativeViewer = await cdp.send("Runtime.evaluate", {
14 expression: "navigator.pdfViewerEnabled",
15 returnByValue: true,
16 });
17 if (nativeViewer.result?.value === true) return;
18 const { frameTree } = await cdp.send("Page.getFrameTree");
19 // The shell cannot open PDFs. Commit a document at the original URL so the
20 // client can embed its signed bytes while Back and Forward keep working.
21 cdp.on("Fetch.requestPaused", (event) => {
22 const { requestId, request, frameId } = event;
23 const proceed = () => cdp.send("Fetch.continueRequest", { requestId });
24 void (async () => {
25 if (
26 request.method !== "GET" ||
27 frameId !== frameTree.frame.id ||
28 !isPdfAssetUrl(request.url)
29 ) {
30 await proceed();
31 return;
32 }
33 const status = event.responseStatusCode;
34 if (status === undefined || (status >= 300 && status < 400)) {
35 await proceed();
36 return;
37 }
38 const contentType = event.responseHeaders
39 ?.find((header) => header.name.toLowerCase() === "content-type")
40 ?.value.split(";", 1)[0]
41 ?.trim()
42 .toLowerCase();
43 if (status < 200 || status >= 300 || contentType !== "application/pdf") {
44 // Reach the browser's requestfailed path instead of committing a successful PDF tab.
45 await cdp.send("Fetch.failRequest", { requestId, errorReason: "Failed" });
46 return;
47 }
48 const name = escapeHtml(
49 decodeURIComponent(new URL(request.url).pathname.split("/").at(-1) ?? "PDF"),
50 );
51 await cdp.send("Fetch.fulfillRequest", {
52 requestId,
53 responseCode: 200,
54 responseHeaders: [
55 { name: "Content-Type", value: "text/html; charset=utf-8" },
56 { name: "Cache-Control", value: "no-store" },
57 { name: "Content-Security-Policy", value: "default-src 'none'; sandbox" },
58 { name: "X-Content-Type-Options", value: "nosniff" },
59 ],
60 body: Buffer.from(
61 `<!doctype html><html><head><title>${name}</title></head><body><h1>${name}</h1><p>This PDF is displayed in the connected client.</p></body></html>`,
62 ).toString("base64"),
63 });
64 })().catch(() => proceed().catch(constVoid));
65 });
66 // Only document navigations pause; the client's PDF fetch gets the original bytes.
67 await cdp.send("Fetch.enable", {
68 patterns: [
69 { urlPattern: "*/api/assets/*", resourceType: "Document", requestStage: "Response" },
70 ],
71 });
72}