Fix PDF thumbnail worker loading from CDN bundles
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

This commit is contained in:
Nariman Jelveh
2026-09-06 14:31:42 -07:00
parent e6cedde732
commit 092a9245ff
4 changed files with 158 additions and 3 deletions
+6 -1
View File
@@ -11,7 +11,12 @@ PDF.js and its fonts, CMaps, ICC profiles and WASM decoders are copied into the
versioned `/dist/pdf-thumbnails/` directory during the GUI build. Deploy that
directory with the GUI. `PDFJS_VERSION` must match the exact GUI dependency;
the build rejects mismatches. The SDK and initial GUI bundle contain no PDF.js.
Asset requests stay on the GUI origin and begin only for an eligible PDF.
Asset requests begin only for an eligible PDF. Bundled builds load them beside
the GUI bundle, including when it is served from a CDN; unbundled development
uses `/dist/pdf-thumbnails/` on the page's origin. A cross-origin asset host must
allow CORS for the worker, modules, fonts, CMaps, ICC profiles and WASM files.
Cross-origin builds use a local Blob module worker that imports the hosted
worker, so the page's CSP must allow `blob:` workers and imports from that host.
Each PDF gets a disposable module worker. PDF.js uses its loopback transport
inside that worker, so termination stops both parsing and rasterization. Fonts
@@ -1,6 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium } from '@playwright/test';
import express from 'express';
import webpack from 'webpack';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
@@ -10,6 +11,8 @@ import { createPdf } from '../../../tests/fixtures/pdf.js';
let browser;
let server;
let cdnServer;
let cdnOrigin;
let directory;
let origin;
let workerMode;
@@ -17,6 +20,34 @@ let workerMode;
beforeAll(async () => {
directory = await mkdtemp(path.join(tmpdir(), 'puter-pdf-thumbnails-'));
await copyPdfThumbnailAssets(directory);
await new Promise((resolve, reject) => {
const compiler = webpack({
mode: 'production',
entry: fileURLToPath(new URL('./index.js', import.meta.url)),
output: {
path: directory,
filename: 'bundle.js',
library: { name: 'pdfThumbnails', type: 'window' },
},
});
compiler.run((error, stats) => {
compiler.close(closeError => {
if ( error || closeError ) return reject(error || closeError);
if ( stats.hasErrors() ) return reject(new Error(stats.toString()));
resolve();
});
});
});
const cdn = express();
cdn.use((_req, res, next) => {
res.set('Access-Control-Allow-Origin', '*');
next();
});
cdn.use('/assets', express.static(directory));
cdnServer = await new Promise(resolve => {
const listener = cdn.listen(0, '127.0.0.1', () => resolve(listener));
});
cdnOrigin = `http://127.0.0.1:${cdnServer.address().port}`;
const app = express();
app.get('/dist/pdf-thumbnails/:version/worker.js', (_req, res, next) => {
if ( workerMode === 'missing' ) return res.sendStatus(404);
@@ -27,6 +58,9 @@ beforeAll(async () => {
// The generator's relative import into the SDK source climbs past /src, which the browser clamps to /puter-js/src.
app.use('/puter-js/src', express.static(fileURLToPath(new URL('../../../../puter-js/src/', import.meta.url))));
app.use('/src', express.static(fileURLToPath(new URL('../../', import.meta.url))));
app.get('/bundled', (req, res) => res.send(`<!doctype html><title>Bundled thumbnail test</title>
<script src="${req.query.cdn ? `${cdnOrigin}/assets` : '/dist'}/bundle.js?v=1"></script>
<script>window.createUploadThumbnailGenerator = window.pdfThumbnails.createUploadThumbnailGenerator;</script>`));
app.get('/', (_req, res) => res.send(`<!doctype html><title>Thumbnail test</title><script type="module">
import { createUploadThumbnailGenerator } from '/src/services/pdfThumbnails/index.js';
import { defaultThumbnailGenerator } from '/puter-js/src/modules/FileSystem/operations/upload/thumbnails.js';
@@ -43,10 +77,44 @@ beforeAll(async () => {
afterAll(async () => {
await browser?.close();
if ( server ) await new Promise(resolve => server.close(resolve));
if ( cdnServer ) await new Promise(resolve => cdnServer.close(resolve));
if ( directory ) await rm(directory, { recursive: true, force: true });
});
describe('PDF thumbnails in a real browser', () => {
it.each([false, true])('loads renderer assets beside the bundle (CDN: %s)', async (cdn) => {
const page = await browser.newPage();
const requests = [];
page.on('request', request => {
if ( request.url().includes('/pdf-thumbnails/') ) requests.push(request.url());
});
try {
if ( cdn ) await page.route(`${origin}/dist/pdf-thumbnails/**`, route => route.abort());
await page.goto(`${origin}/bundled${cdn ? '?cdn=1' : ''}`);
expect(requests).toEqual([]);
const result = await page.evaluate(async pdf => {
const thumbnail = await window.createUploadThumbnailGenerator()(new File([pdf], 'test.pdf'));
if ( ! thumbnail ) return null;
const image = new Image();
image.src = thumbnail;
await image.decode();
const canvas = document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(image, 0, 0);
return { width: image.width, height: image.height, pixel: [...ctx.getImageData(5, 5, 1, 1).data] };
}, createPdf());
expect(result).toEqual({ width: 64, height: 128, pixel: [255, 0, 0, 255] });
const assetBase = `${cdn ? `${cdnOrigin}/assets` : `${origin}/dist`}/pdf-thumbnails/`;
expect(requests.some(url => url.endsWith('/worker.js'))).toBe(true);
expect(requests.every(url => url.startsWith(assetBase))).toBe(true);
await expect.poll(() => page.workers().length).toBe(0);
} finally {
await page.close();
}
});
it.each([
{ rotation: 0, scanned: false },
{ rotation: 90, scanned: false },
+17 -1
View File
@@ -1,5 +1,6 @@
import { defaultThumbnailGenerator } from '../../../../puter-js/src/modules/FileSystem/operations/upload/thumbnails.js';
import {
PDFJS_VERSION,
PDF_THUMBNAIL_ASSET_PATH,
PDF_THUMBNAIL_MAX_FILE_BYTES,
PDF_THUMBNAIL_BATCH_TIMEOUT_MS,
@@ -7,6 +8,13 @@ import {
PDF_THUMBNAIL_MAX_BYTES,
} from './config.js';
// Capture the bundle URL during evaluation; currentScript is null when an upload starts.
const bundleUrl = globalThis.document?.currentScript?.src;
const workerUrl = bundleUrl
? new URL(`pdf-thumbnails/${PDFJS_VERSION}/worker.js`, bundleUrl).href
: `${PDF_THUMBNAIL_ASSET_PATH}worker.js`;
const useWorkerWrapper = bundleUrl && new URL(bundleUrl).origin !== globalThis.location?.origin;
const pendingJobs = new Set();
let activeJob;
let startingJobs = false;
@@ -28,6 +36,7 @@ const startNextJob = () => {
const generatePdfThumbnail = (file, deadline, signal) => new Promise(resolve => {
let worker;
let workerBlobUrl;
let settled = false;
let jobTimer;
let deadlineTimer;
@@ -38,6 +47,7 @@ const generatePdfThumbnail = (file, deadline, signal) => new Promise(resolve =>
clearTimeout(deadlineTimer);
signal?.removeEventListener('abort', onAbort);
worker?.terminate();
if ( workerBlobUrl ) URL.revokeObjectURL(workerBlobUrl);
pendingJobs.delete(job);
if ( activeJob === job ) activeJob = undefined;
resolve(thumbnail);
@@ -51,8 +61,14 @@ const generatePdfThumbnail = (file, deadline, signal) => new Promise(resolve =>
return;
}
try {
// Worker entry URLs must be same-origin; module imports can use the asset host's CORS policy.
if ( useWorkerWrapper ) {
workerBlobUrl = URL.createObjectURL(new Blob([`import ${JSON.stringify(workerUrl)};`], {
type: 'text/javascript',
}));
}
// All parsing, file reads and rasterization stay off the desktop thread.
worker = new Worker(`${PDF_THUMBNAIL_ASSET_PATH}worker.js`, { type: 'module' });
worker = new Worker(workerBlobUrl || workerUrl, { type: 'module' });
worker.onmessage = ({ data: message }) => {
if ( message?.type !== 'thumbnail' ) return;
const data = message.thumbnail;
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
PDFJS_VERSION,
PDF_THUMBNAIL_BATCH_TIMEOUT_MS,
PDF_THUMBNAIL_JOB_TIMEOUT_MS,
PDF_THUMBNAIL_MAX_FILE_BYTES,
@@ -24,7 +25,11 @@ beforeEach(async () => {
vi.stubGlobal('Worker', class {
terminate = vi.fn();
postMessage = vi.fn();
constructor () { workers.push(this); }
constructor (url, options) {
this.url = url;
this.options = options;
workers.push(this);
}
complete (value = thumbnail) { this.onmessage({ data: { type: 'thumbnail', thumbnail: value } }); }
});
({ createUploadThumbnailGenerator } = await import('./index.js'));
@@ -33,9 +38,70 @@ beforeEach(async () => {
afterEach(async () => {
await vi.runAllTimersAsync();
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('PDF thumbnail asset loading', () => {
it('uses the local dist directory for unbundled development', async () => {
const result = createUploadThumbnailGenerator()(pdf());
expect(workers[0].url).toBe(`/dist/pdf-thumbnails/${PDFJS_VERSION}/worker.js`);
workers[0].complete();
expect(await result).toBe(thumbnail);
});
it('resolves same-origin assets beside the bundle, independent of the page path', async () => {
vi.stubGlobal('document', { currentScript: { src: 'https://desktop.example/assets/bundle.js?v=1' } });
vi.stubGlobal('location', new URL('https://desktop.example/dashboard'));
vi.resetModules();
({ createUploadThumbnailGenerator } = await import('./index.js'));
document.currentScript = null;
const result = createUploadThumbnailGenerator()(pdf());
expect(workers[0].url).toBe(`https://desktop.example/assets/pdf-thumbnails/${PDFJS_VERSION}/worker.js`);
expect(workers[0].options).toEqual({ type: 'module' });
workers[0].complete();
expect(await result).toBe(thumbnail);
});
it.each(['success', 'error', 'cancel', 'timeout', 'construction', 'clone'])(
'releases the cross-origin worker Blob after %s', async (outcome) => {
vi.stubGlobal('document', { currentScript: { src: 'https://cdn.example/assets/bundle.js?v=1' } });
vi.stubGlobal('location', new URL('https://desktop.example/dashboard'));
const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:https://desktop.example/worker');
const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
vi.resetModules();
({ createUploadThumbnailGenerator } = await import('./index.js'));
document.currentScript = null;
const generate = createUploadThumbnailGenerator();
await generate(new File(['image'], 'image.png'));
expect(createObjectUrl).not.toHaveBeenCalled();
if ( outcome === 'construction' ) {
vi.stubGlobal('Worker', class { constructor () { throw new Error('blocked'); } });
}
const controller = new AbortController();
const result = generate(pdf(), { signal: controller.signal });
expect(createObjectUrl).toHaveBeenCalledOnce();
expect(await createObjectUrl.mock.calls[0][0].text()).toBe(
`import "https://cdn.example/assets/pdf-thumbnails/${PDFJS_VERSION}/worker.js";`,
);
if ( outcome !== 'construction' ) {
expect(workers[0].url).toBe('blob:https://desktop.example/worker');
expect(workers[0].options).toEqual({ type: 'module' });
expect(revokeObjectUrl).not.toHaveBeenCalled();
}
if ( outcome === 'success' ) workers[0].complete();
if ( outcome === 'error' ) workers[0].onerror({ preventDefault: vi.fn() });
if ( outcome === 'cancel' ) controller.abort();
if ( outcome === 'timeout' ) await vi.advanceTimersByTimeAsync(PDF_THUMBNAIL_JOB_TIMEOUT_MS);
if ( outcome === 'clone' ) workers[0].onmessageerror();
expect(await result).toBe(outcome === 'success' ? thumbnail : undefined);
expect(revokeObjectUrl).toHaveBeenCalledExactlyOnceWith('blob:https://desktop.example/worker');
if ( workers.length ) expect(workers[0].terminate).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
},
);
});
describe('GUI upload thumbnail scheduling', () => {
it('delegates images without loading PDF assets', async () => {
const file = new File(['image'], 'image.png');