mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-25 14:46:44 +00:00
Fix PDF thumbnail worker loading from CDN bundles
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user