Skip to content

Commit 1daabdc

Browse files
committed
fix: op-gated gs warmup, fail-fast wasm instantiate, honest offline copy
1 parent 7af1b0d commit 1daabdc

7 files changed

Lines changed: 97 additions & 34 deletions

File tree

e2e/specs/pdf-tools.spec.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
/**
2-
* PT-01…16: pdf-lib tools — merge (order via page-size fingerprint), reorder,
2+
* PT-01…18: pdf-lib tools — merge (order via page-size fingerprint), reorder,
33
* page keep/remove grammar, toImages (ZIP vs single), fromImages (dims,
4-
* white-flattened alpha), AVIF acceptance, unlock/protect round-trip.
4+
* white-flattened alpha), AVIF acceptance, unlock/protect round-trip,
5+
* Ghostscript warm-up gating by op.
56
*/
67
import { readFileSync, writeFileSync } from 'node:fs';
78
import { basename } from 'node:path';
@@ -11,6 +12,7 @@ import {
1112
compress,
1213
downloadCombined,
1314
downloadRow,
15+
gotoPath,
1416
gotoTab,
1517
rasterizePdfInPage,
1618
setDpi,
@@ -419,3 +421,26 @@ test('PT-16: /unlock-pdf and /protect-pdf pages preset the op and gate the CTA',
419421
'true'
420422
);
421423
});
424+
425+
test('PT-18: file-drop warms Ghostscript only for ops that run it @smoke', async ({ page }) => {
426+
// The 15 MB engine: /gs.wasm in dev, /_app/…/gs.<hash>.wasm in preview.
427+
const gsWasmRequests: string[] = [];
428+
page.on('request', (request) => {
429+
const path = new URL(request.url()).pathname;
430+
if (/\/gs(\.[\w-]+)?\.wasm$/.test(path)) gsWasmRequests.push(path);
431+
});
432+
433+
// /merge-pdf presets op=merge (mergeCompress off): pdf-lib does the whole
434+
// job, so the drop must not pull the Ghostscript engine. The warm fetch
435+
// starts synchronously with the drop, so a short settle catches a regression.
436+
await gotoPath(page, '/merge-pdf');
437+
await upload(page, fx('merge-a.pdf'));
438+
await page.waitForTimeout(1000);
439+
expect(gsWasmRequests, 'merge drop must not warm gs').toEqual([]);
440+
441+
// Switching to Compress and dropping again must start the warm fetch during
442+
// think time — and proves the matcher above still matches the real URL.
443+
await setPdfOp(page, 'Compress');
444+
await upload(page, fx('merge-b.pdf'));
445+
await expect.poll(() => gsWasmRequests.length, { timeout: 15_000 }).toBeGreaterThan(0);
446+
});

src/lib/seo-body/home.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export const BODIES: Record<string, SeoBody> = {
2222
['Ads', 'Banners around every step', 'None'],
2323
['Price & limits', 'Daily caps, premium tiers', 'Free, no limits'],
2424
['Source code', 'Closed', 'Open on GitHub'],
25-
['Offline', 'Needs a connection', 'Works offline once loaded']
25+
['Offline', 'Needs a connection', 'Works offline after first use']
2626
]
2727
}
2828
},

src/lib/workers/archive.worker.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,25 @@ async function runSevenZip(
8484
onLine?.(line);
8585
};
8686
const wasmModule = await getWasmModule();
87+
// Emscripten's instantiateWasm has no error path through `done` — with the
88+
// module pre-awaited the only failure left is an instantiate OOM, which
89+
// would leave SevenZipFactory pending until the 10-min watchdog. Race the
90+
// load against the rejection so the call fails fast, and drop the module
91+
// memo in case the compiled module itself is the problem.
92+
let failInstantiate!: (error: unknown) => void;
93+
const instantiateFailed = new Promise<never>((_, reject) => (failInstantiate = reject));
8794
const options: SevenZipFactoryOptions = {
8895
// Instantiate from the pre-compiled module — the hook wins over
8996
// wasmBinary in the glue and skips its per-call WebAssembly.instantiate
90-
// (bytes) compile. No rejection channel exists here; with the module
91-
// pre-awaited the only failure left is an instantiate OOM, which the
92-
// no-progress watchdog turns into a worker restart.
97+
// (bytes) compile.
9398
instantiateWasm: (imports, done) => {
94-
void WebAssembly.instantiate(wasmModule, imports).then((instance) => done(instance));
99+
WebAssembly.instantiate(wasmModule, imports).then(
100+
(instance) => done(instance),
101+
(error) => {
102+
wasmModulePromise = null;
103+
failInstantiate(error);
104+
}
105+
);
95106
return {};
96107
},
97108
print: push,
@@ -100,7 +111,7 @@ async function runSevenZip(
100111
// prompt; the d.ts wants `number` but emscripten treats null as EOF.
101112
stdin: (() => null) as unknown as () => number
102113
};
103-
const sz = await SevenZipFactory(options);
114+
const sz = await Promise.race([SevenZipFactory(options), instantiateFailed]);
104115
sz.FS.mkdir('/in');
105116
sz.FS.mkdir('/out');
106117
if (mounts?.length) {

src/lib/workers/gs.worker.ts

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -62,23 +62,38 @@ expose<WorkerContracts['gs']>({
6262
if (tail.length > 50) tail.shift();
6363
};
6464

65-
const gs = await loadGs({
66-
instantiateWasm: (imports, done) => {
67-
WebAssembly.instantiate(module, imports).then((instance) => done(instance));
68-
return {};
69-
},
70-
print: (line: string) => {
71-
record(line);
72-
const total = /^Processing pages \d+ through (\d+)/.exec(line);
73-
if (total) {
74-
pageCount = Number(total[1]);
75-
return;
76-
}
77-
const page = /^Page (\d+)/.exec(line);
78-
if (page) progress({ page: Number(page[1]), pageCount } satisfies GsProgress);
79-
},
80-
printErr: record
81-
});
65+
// Emscripten's instantiateWasm has no error path through `done` — a
66+
// rejection (OOM, mostly) would leave loadGs pending until the watchdog.
67+
// Race the load against it so the call fails fast, and drop the module
68+
// memo in case the compiled module itself is the problem.
69+
let failInstantiate!: (error: unknown) => void;
70+
const instantiateFailed = new Promise<never>((_, reject) => (failInstantiate = reject));
71+
const gs = await Promise.race([
72+
loadGs({
73+
instantiateWasm: (imports, done) => {
74+
WebAssembly.instantiate(module, imports).then(
75+
(instance) => done(instance),
76+
(error) => {
77+
compiledPromise = null;
78+
failInstantiate(error);
79+
}
80+
);
81+
return {};
82+
},
83+
print: (line: string) => {
84+
record(line);
85+
const total = /^Processing pages \d+ through (\d+)/.exec(line);
86+
if (total) {
87+
pageCount = Number(total[1]);
88+
return;
89+
}
90+
const page = /^Page (\d+)/.exec(line);
91+
if (page) progress({ page: Number(page[1]), pageCount } satisfies GsProgress);
92+
},
93+
printErr: record
94+
}),
95+
instantiateFailed
96+
]);
8297

8398
gs.FS.writeFile('/in.pdf', new Uint8Array(pdf));
8499

src/routes/[[tool=tool]]/+page.svelte

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@
302302
// Warm this tab's engine now: a drop precedes the Compress click by
303303
// seconds, so the worker + its wasm (the 15 MB gs module especially) load
304304
// during think time instead of stalling behind a dead progress bar.
305-
const warmKind = WARM_KIND[format];
305+
const warmKind = warmKindFor(format);
306306
if (warmKind) warmUp(warmKind);
307307
// The settings panel + codec orchestration are interaction-gated chunks;
308308
// load them now (a file just landed) so they're ready when the panel renders
@@ -339,23 +339,35 @@
339339
};
340340
341341
// The primary worker each tab warms on file-drop (see handleFiles). Only the
342-
// kind on the critical path is listedpdf warms 'gs' (15 MB, the big win),
343-
// not the secondary 'image' its fromImages op may also touch.
342+
// kind on the critical path is listed; pdf resolves per op in warmKindFor —
343+
// its worker depends on which tool page the drop lands on.
344344
const WARM_KIND: Partial<Record<FileFormat, WorkerKind>> = {
345345
jpg: 'image',
346346
png: 'image',
347347
webp: 'image',
348348
gif: 'image',
349349
heic: 'image',
350350
svg: 'svg',
351-
pdf: 'gs',
352351
video: 'video',
353352
audio: 'video',
354353
font: 'font',
355354
zip: 'archive',
356355
exif: 'image'
357356
};
358357
358+
// Ghostscript (a 15 MB wasm fetch + compile) backs only compress/unlock/
359+
// protect — and merge when its "compress result" toggle is on. pages and
360+
// toImages run on pdf-lib/pdf.js, which aren't pooled workers; fromImages'
361+
// critical path is the image worker re-encoding the pages.
362+
function warmKindFor(format: FileFormat): WorkerKind | null {
363+
if (format !== 'pdf') return WARM_KIND[format] ?? null;
364+
const { op, mergeCompress } = settings.pdf;
365+
if (op === 'compress' || op === 'unlock' || op === 'protect') return 'gs';
366+
if (op === 'merge') return mergeCompress ? 'gs' : null;
367+
if (op === 'fromImages') return 'image';
368+
return null;
369+
}
370+
359371
async function handleCompress() {
360372
// Snapshot the tab: activeTab is $derived from the route, so a read after
361373
// an await sees the tab the user is LOOKING at, not the tab this run
@@ -954,7 +966,7 @@
954966
<span class="font-medium text-ink">
955967
<Icon name="lock" class="mr-0.5 inline size-3 align-[-0.14em]" />Files never leave your device.
956968
</span>
957-
Everything runs in your browser, nothing touches a server — it even works offline once loaded.
969+
Everything runs in your browser, nothing touches a server — tools you've used even work offline.
958970
</p>
959971

960972
<FormatInfo

src/routes/llms.txt/+server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export function GET() {
1818
'',
1919
`> ${FULL_HOME.description}`,
2020
'',
21-
'Every tool runs entirely in the browser — files are never uploaded, there is no server-side processing, no account, no ads and no file-size limit. The app is free, open source (https://github.com/Scorpio3310/compress-pro), and keeps working offline once loaded — proof that nothing is sent anywhere.',
21+
'Every tool runs entirely in the browser — files are never uploaded, there is no server-side processing, no account, no ads and no file-size limit. The app is free, open source (https://github.com/Scorpio3310/compress-pro), and tools keep working offline after their first use — proof that nothing is sent anywhere.',
2222
'',
2323
`Every tool page below has a plain-markdown twin for agents — append ".md" to its URL (homepage: ${SITE_URL}/index.md). The full content of every page in one document: ${SITE_URL}/llms-full.txt`,
2424
'',

src/routes/privacy/+page.svelte

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@
4242
<p class="mt-3 max-w-xl">
4343
Practically nothing. No accounts and no databases exist. Your browser’s localStorage keeps two
4444
things on your own device: your theme choice and your last-used tool settings — never file
45-
contents or file names. The compression engines are cached by the browser so repeat visits
46-
load fast — which also means the app keeps working offline: the clearest proof that your files
47-
are not being sent anywhere.
45+
contents or file names. Each compression engine is cached by the browser on first use so
46+
repeat visits load fast — which also means tools you've used keep working offline: the
47+
clearest proof that your files are not being sent anywhere.
4848
</p>
4949
</div>
5050

0 commit comments

Comments
 (0)