mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
- Event-loop blockage: HEIC decode/encode (CPU-synchronous libheif WASM + jpeg-js) now runs in a per-conversion worker_threads Worker (src/web/heic-jpeg-worker.ts, spawned by heic-jpeg-converter.ts) with resourceLimits and a 30s hard timeout that terminates the worker — verified end-to-end under tsx and against compiled dist/ output with a real iPhone HEIC (event-loop max stall 52ms during conversion). - No server-side concurrency cap: conversions now acquire a slot from the existing global runWithConversionLimit() pool (document-conversion-limiter), bounding peak decode memory/CPU across simultaneous uploads. - Decompression bomb: header-declared dimensions are read via heic-decode's allocation-free `.all` path and rejected above 64MP BEFORE decode() can allocate width*height*4 bytes (a <300-byte crafted file can declare 30000x30000 = 3.6GB). Regression-tested with a crafted ISOBMFF fixture against the real heic-decode WASM (test/heic-jpeg-core.test.ts). - Mislabeled HEIC (documented Android/MIUI case): conversion now routes on ftyp magic-byte sniff of the raw buffer regardless of declared ext/Content-Type, so a HEIF uploaded as image/jpeg converts instead of 415ing; the magic-mismatch 415 only fires for genuinely unrecognized bytes. - Brand allowlist narrowed to what heic-decode's isHeic() accepts (heim/heis/hevm/hevs dropped — they could only ever fail conversion). - Converted-output size: the JPEG result is checked against MAX_PASTE_IMAGE_BYTES (jpeg-js can inflate a within-limit HEIC past the cap). - Deps: heic-convert replaced with its underlying heic-decode + jpeg-js (the wrapper could not expose the pre-decode dimension check); lockfile synced, drops pngjs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
83 lines
3.7 KiB
TypeScript
83 lines
3.7 KiB
TypeScript
/**
|
||
* @fileoverview Main-thread wrapper for HEIC/HEIF → JPEG conversion.
|
||
*
|
||
* The actual decode/encode (`heic-jpeg-worker.ts`) is CPU-synchronous WASM + JS,
|
||
* so it runs in a dedicated `worker_threads` Worker per conversion — never on
|
||
* the event loop that serves every session's SSE/PTY/WS traffic. On top of
|
||
* the worker isolation this wrapper enforces:
|
||
* - the global converter concurrency cap (`runWithConversionLimit`, shared
|
||
* with the pdftoppm/soffice document converters) so N simultaneous uploads
|
||
* can't pin N cores / N × 256MB decode buffers at once;
|
||
* - a hard timeout that terminates the worker (a wedged WASM decode can't be
|
||
* cancelled cooperatively);
|
||
* - the paste-image size cap on the *output* — jpeg-js is a far less
|
||
* efficient encoder than HEVC, so a within-limit HEIC can inflate past
|
||
* MAX_PASTE_IMAGE_BYTES.
|
||
*/
|
||
|
||
import { Worker } from 'node:worker_threads';
|
||
import { runWithConversionLimit } from '../document-conversion-limiter.js';
|
||
import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js';
|
||
import { HEIC_JPEG_QUALITY, type HeicWorkerInput, type HeicWorkerResult } from './heic-jpeg-worker.js';
|
||
|
||
/** Hard cap on a single conversion; the worker is terminated when it fires. */
|
||
export const HEIC_CONVERSION_TIMEOUT_MS = 30_000;
|
||
|
||
// V8-heap guardrails for the conversion worker — defense in depth only: large
|
||
// TypedArray/WASM backing stores are external to the V8 heap, so the real
|
||
// memory bound is the 64MP dimension pre-check in heic-jpeg-worker.ts.
|
||
const WORKER_RESOURCE_LIMITS = { maxOldGenerationSizeMb: 1024, maxYoungGenerationSizeMb: 128, stackSizeMb: 8 };
|
||
|
||
function workerUrl(): URL {
|
||
// Compiled installs run the tsc-emitted .js sibling in dist/; dev under tsx
|
||
// runs the .ts source directly (tsx's loader propagates to worker threads).
|
||
const file = import.meta.url.endsWith('.ts') ? './heic-jpeg-worker.ts' : './heic-jpeg-worker.js';
|
||
return new URL(file, import.meta.url);
|
||
}
|
||
|
||
/**
|
||
* Convert HEIC/HEIF bytes to JPEG bytes off-thread. Rejects on invalid input,
|
||
* over-limit dimensions, oversized output, timeout, or worker failure.
|
||
*/
|
||
export async function convertHeicToJpeg(imageBytes: Buffer): Promise<Buffer> {
|
||
return runWithConversionLimit(
|
||
() =>
|
||
new Promise<Buffer>((resolve, reject) => {
|
||
const worker = new Worker(workerUrl(), {
|
||
workerData: { heicInput: imageBytes, quality: HEIC_JPEG_QUALITY } satisfies HeicWorkerInput,
|
||
resourceLimits: WORKER_RESOURCE_LIMITS,
|
||
});
|
||
let settled = false;
|
||
const settle = (fn: () => void): void => {
|
||
if (settled) return;
|
||
settled = true;
|
||
clearTimeout(timer);
|
||
fn();
|
||
void worker.terminate();
|
||
};
|
||
const timer = setTimeout(() => {
|
||
settle(() => reject(new Error(`HEIC conversion timed out after ${HEIC_CONVERSION_TIMEOUT_MS}ms`)));
|
||
}, HEIC_CONVERSION_TIMEOUT_MS);
|
||
worker.on('message', (msg: HeicWorkerResult) => {
|
||
settle(() => {
|
||
if (!msg.ok) {
|
||
reject(new Error(msg.error));
|
||
return;
|
||
}
|
||
const out = Buffer.from(msg.data.buffer, msg.data.byteOffset, msg.data.byteLength);
|
||
if (out.length > MAX_PASTE_IMAGE_BYTES) {
|
||
const maxMb = Math.round(MAX_PASTE_IMAGE_BYTES / (1024 * 1024));
|
||
reject(new Error(`converted JPEG (${out.length} bytes) exceeds the ${maxMb}MB upload limit`));
|
||
return;
|
||
}
|
||
resolve(out);
|
||
});
|
||
});
|
||
worker.on('error', (err) => settle(() => reject(err)));
|
||
worker.on('exit', (code) => {
|
||
settle(() => reject(new Error(`HEIC conversion worker exited unexpectedly (code ${code})`)));
|
||
});
|
||
})
|
||
);
|
||
}
|