Hosting and security
The viewer is a folder of static files. Any host that serves static files can serve it, as long
as it follows four rules: same origin, the right MIME type for .wasm, sensible caching, and a
Content-Security-Policy (if you use one) that allows WebAssembly.
What you deploy
After npx free-pdf-sdk copy-viewer public/free-pdf-sdk (or unpacking the release zip), the
folder looks like this:
free-pdf-sdk/
viewer/
index.html not hashed: names the two files below
fps-viewer-<hash>.js JavaScript glue, content-hashed
fps-viewer-<hash>_bg.wasm the viewer, content-hashed
version.json { "sdk": "...", "pdfcraftRev": "..." }
NOTICE LICENSE-MIT LICENSE-APACHE
third-party/The .wasm file is the large one: about 10 MB compressed. Serve it
compressed (brotli or gzip) and cache it; see below.
Serve the folder from the page's origin
baseUrl must have the same scheme, host and port as the page that calls FreePdf.init:
// Page: https://app.example.com/projects/42
FreePdf.init(el, { baseUrl: "/free-pdf-sdk/" }); // works
FreePdf.init(el, { baseUrl: "https://app.example.com/free-pdf-sdk/" }); // works
FreePdf.init(el, { baseUrl: "https://cdn.example.com/free-pdf-sdk/" }); // BAD_REQUESTWhy this is required:
- Strict message checks. The page and the viewer exchange requests and replies with
postMessage. The wrapper accepts a message only from the viewer's own iframe on the SDK's origin, and the viewer accepts requests only from a parent on its own origin. Keeping both on one origin lets each side reject everything else. - Isolation without cross-origin plumbing. Each viewer is its own iframe, so its interface can't be affected by your page's CSS, and several viewers on a page never share state. A same-origin iframe needs no CORS, COOP or COEP configuration.
- Your network rules apply. The wrapper fetches
document.urlin your page, with your cookies, headers and CORS rules, then hands the bytes to the viewer.
The PDF itself can come from any origin your page may fetch from (for example a signed storage
URL with CORS configured), or you can pass bytes with { data }.
Required headers
| What | Requirement |
|---|---|
.wasm files |
Content-Type: application/wasm. With another type the browser can't stream-compile the module and falls back to a slower load. |
viewer/index.html |
Must be served as HTML and be frameable by your own pages: no X-Frame-Options: DENY (SAMEORIGIN is fine); with CSP frame-ancestors, allow 'self'. |
| Missing files | Must return 404, not your single-page app's index.html. Otherwise a wrong baseUrl or a stale file name loads your app inside the iframe and init fails with TIMEOUT. |
| COOP / COEP | Not needed. The viewer uses no SharedArrayBuffer or threads. Don't add Cross-Origin-Embedder-Policy for the SDK: it would also block cross-origin PDFs your page fetches. |
Cache hashed files
The JavaScript glue and the .wasm have a content hash in their names, so a new version always
has new names. Cache them forever, and keep the two unhashed files fresh:
| Files | Cache-Control |
|---|---|
viewer/fps-viewer-<hash>.js, viewer/fps-viewer-<hash>_bg.wasm |
public, max-age=31536000, immutable |
viewer/index.html, version.json |
no-cache (revalidated on each load; it names the current hashed files) |
| Licence files | anything; they are not loaded by the viewer |
After the first visit the browser loads the viewer from its cache, so later visits don't download
the .wasm again.
If you use the release zip and import free-pdf-sdk.js from the folder, that file isn't hashed:
either serve it with no-cache, or put the version in the folder name (/free-pdf-sdk/0.2.0/)
and cache the whole folder as immutable.
Content-Security-Policy
The viewer's index.html contains one small inline <script type="module"> that loads the
hashed files, and one inline <style>. Both change with every release, so a CSP for the viewer
folder allows them by hash. WebAssembly needs 'wasm-unsafe-eval'.
This script prints a policy for the copy you serve. Run it after each copy-viewer, for example
in the same postinstall script, and put the output in your host's header configuration:
// scripts/viewer-csp.mjs
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
const file = process.argv[2] ?? "public/free-pdf-sdk/viewer/index.html";
const html = readFileSync(file, "utf8");
const sha = (text) => `'sha256-${createHash("sha256").update(text, "utf8").digest("base64")}'`;
const script = html.match(/<script type="module">([\s\S]*?)<\/script>/)[1];
const style = html.match(/<style>([\s\S]*?)<\/style>/)[1];
console.log(
[
"default-src 'self'",
`script-src 'self' 'wasm-unsafe-eval' ${sha(script)}`,
`style-src 'self' ${sha(style)}`,
"img-src 'self' data: blob:",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'self'",
].join("; "),
);node scripts/viewer-csp.mjs public/free-pdf-sdk/viewer/index.htmlApply that policy to /free-pdf-sdk/viewer/* only. If your browser console reports a blocked
resource while you print or save from the viewer, add the source it names (such as blob:) to the
directive it names.
Your host page's CSP needs:
| Directive | Why |
|---|---|
frame-src 'self' (or child-src 'self') |
The viewer is a same-origin iframe. |
connect-src 'self' <PDF origins> |
The wrapper fetches document.url from your page. List every origin your PDFs come from. |
script-src |
Whatever already allows your own bundle; the wrapper is ordinary JavaScript with no eval. |
Hosting examples
All examples assume the folder is published at /free-pdf-sdk/.
Firebase Hosting
Firebase Hosting serves .wasm as application/wasm and compresses responses. Add cache rules,
and keep the single-page-app rewrite away from the SDK folder:
{
"hosting": {
"public": "dist",
"headers": [
{
"source": "/free-pdf-sdk/**",
"headers": [{ "key": "Cache-Control", "value": "no-cache" }]
},
{
"source": "/free-pdf-sdk/viewer/fps-viewer-*",
"headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
},
{
"source": "**/*.wasm",
"headers": [{ "key": "Content-Type", "value": "application/wasm" }]
}
],
"rewrites": [
{ "source": "!/free-pdf-sdk/**", "destination": "/index.html" }
]
}
}- When several rules set the same header for a file, Firebase applies the last one, so the
immutablerule for hashed files comes after the folder rule. - Rewrites only apply to files that don't exist. The
!glob keeps the single-page-app rewrite away from the SDK folder, so a mistyped SDK URL gets a real 404 instead of your app'sindex.html. - Leave
cleanUrlsoff (the default). With it on, Firebase redirectsviewer/index.htmltoviewer, and the viewer's relative script paths stop resolving. - With Vue CLI or Vite,
public/is copied intodist/, socopy-viewer public/free-pdf-sdkends up at/free-pdf-sdk/.
Netlify
Add a _headers file to your publish folder (for Vite, public/_headers). The rules don't
overlap, so each file gets exactly one Cache-Control:
/free-pdf-sdk/viewer/index.html
Cache-Control: no-cache
/free-pdf-sdk/version.json
Cache-Control: no-cache
/free-pdf-sdk/viewer/fps-viewer-*
Cache-Control: public, max-age=31536000, immutableNetlify serves static files before applying a single-page-app /* /index.html 200 redirect, so
existing viewer files are not affected by it. Check the .wasm content type with the curl
command at the end of this page.
Nginx
http {
include mime.types;
# Recent mime.types files already map .wasm; this line covers older ones.
types { application/wasm wasm; }
gzip on;
gzip_types application/wasm application/javascript text/css application/json;
# With the brotli module installed: brotli on; brotli_types application/wasm application/javascript;
server {
root /var/www/app;
location /free-pdf-sdk/ {
add_header Cache-Control "no-cache";
try_files $uri =404;
location ~ ^/free-pdf-sdk/viewer/fps-viewer-[0-9a-f]+(\.js|_bg\.wasm)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
}
location / {
try_files $uri /index.html;
}
}
}try_files $uri =404 returns a real 404 for a missing SDK file instead of falling through to
the app's index.html.
Express
import express from "express";
import compression from "compression";
const app = express();
// Compress the .wasm too, whatever compression's default type list says.
app.use(compression({ filter: (req, res) => req.path.endsWith(".wasm") || compression.filter(req, res) }));
app.use(
"/free-pdf-sdk",
express.static("public/free-pdf-sdk", {
fallthrough: false, // a missing SDK file is a 404, never the app's index.html
setHeaders(res, path) {
if (path.endsWith(".wasm")) res.setHeader("Content-Type", "application/wasm");
const hashed = /[\\/]viewer[\\/]fps-viewer-[0-9a-f]+(\.js|_bg\.wasm)$/.test(path);
res.setHeader("Cache-Control", hashed ? "public, max-age=31536000, immutable" : "no-cache");
},
}),
);
app.use(express.static("dist"));
app.use((req, res) => res.sendFile("index.html", { root: "dist" })); // single-page app fallback
app.listen(3000);Check your deployment
curl -sI https://app.example.com/free-pdf-sdk/viewer/index.html
curl -sI -H "Accept-Encoding: br, gzip" \
https://app.example.com/free-pdf-sdk/viewer/fps-viewer-<hash>_bg.wasmThe .wasm response should show content-type: application/wasm, a content-encoding of br
or gzip, and the long-lived cache-control.
Privacy: documents stay in the browser
- No uploads. The SDK has no server component. The PDF is fetched by your page (or passed as bytes), handed to the iframe, and opened by WebAssembly in the user's browser. Neither the wrapper nor the viewer sends the document, its comments or its passwords anywhere.
- No remote code. The iframe loads only the files in your SDK folder, from your origin. No CDN, analytics or telemetry is involved.
- You decide where output goes.
save(),exportXfdf()and thesaveRequestedevent hand data to your code; when thesavefeature is on, the viewer's Save button never triggers a download by itself. - Passwords stay on the channel. A password you pass to
init,loadorunlocktravels only over the same-origin message channel to the viewer. - Links in documents. By default a link to a web page in a PDF may open after the user
confirms a prompt. Set
ui: { externalLinks: false }to turn that off. - Local settings. The viewer keeps its own preferences in the browser's storage for your origin.
To keep users from taking a copy, turn off the download, export and print features (see
Configuration); remember that anything shown in a browser can still be
captured by a determined user.