This commit is contained in:
seb
2026-10-04 23:39:49 +02:00
parent 9feca54a0b
commit ab40a38a75
10 changed files with 1217 additions and 865 deletions

View File

@@ -12,6 +12,7 @@ import { authMiddleware } from './middleware/auth.js';
import { ensureConnection } from '../db/store.js';
import { closeAll as closeAllSse } from './sse.js';
import { closeAllManagers } from '../services/conversationManager.js';
import { shutdownImageCropWorkers } from '../services/imageCrop.js';
import { getOrCreateCert } from '../util/cert.js';
import v1Router from '../v1/index.js';
@@ -192,6 +193,9 @@ export async function stopApi() {
// forever — node --watch hangs on "Waiting for graceful termination...".
closeAllSse();
closeAllManagers();
// Pooled image-crop workers are live threads and must be terminated too, or the
// process stays alive after the last request has been served.
await shutdownImageCropWorkers();
const closers = [];
if (server) {
const s = server;

View File

@@ -1544,124 +1544,6 @@ export function register(app, basePath = '') {
}
});
/**
* @openapi
* /articleIndex:
* get:
* tags: [Article]
* summary: List article search index
* description: |
* Returns every article reachable via the company's category tree.
* Designed for client-side search / autocomplete preload.
* Auth via company access.
*
* REQUIRES `windowSize`. Without it the response is always an empty list — the full
* index can be very large and is not useful to a caller that did not ask for a
* specific window. Pass the number of entries the client actually intends to preload.
* security:
* - cookieAuth: []
* parameters:
* - in: query
* name: windowSize
* required: true
* schema:
* type: integer
* minimum: 1
* maximum: 1000
* description: |
* Number of articles to return (window size). Required; omitting it returns an
* empty `articles` array. `truncated` is true when more entries exist than were
* returned, signalling the client should request a larger window or narrow its
* query.
* responses:
* 200:
* description: Search index
* content:
* application/json:
* schema:
* type: object
* properties:
* articles:
* type: array
* items:
* type: object
* properties:
* id:
* type: integer
* sku:
* type: string
* name:
* type: string
* nullable: true
* eans:
* type: array
* items:
* type: string
* categoryIds:
* type: array
* items:
* type: integer
* truncated:
* type: boolean
* description: True when the window was smaller than the total number of matching articles.
* 400:
* description: No company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Company not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.get(`${basePath}/articleIndex`, async (req, res) => {
const companyId = req.user.selectedCompanyId;
if (companyId == null) {
return res.status(400).json({ error: 'No company selected' });
}
// A window size must be given explicitly. Without one the index can be arbitrarily large,
// and returning the entire result set to a caller that never asked for a window is
// exactly the "fetch everything" behaviour this guard exists to prevent.
const rawWindowSize = req.query.windowSize;
if (rawWindowSize === undefined || rawWindowSize === '') {
return res.status(200).json({ articles: [], truncated: false });
}
const windowSize = parseInt(rawWindowSize, 10);
if (!Number.isInteger(windowSize) || windowSize < 1 || windowSize > 1000) {
return res.status(400).json({ error: 'windowSize must be an integer between 1 and 1000' });
}
try {
const all = await db.listSearchIndex(req.user.id);
// `length === windowSize` is the same truncation signal used by
// GET /article/{sku}/history: the window is exactly full, so more may exist.
const truncated = all.length > windowSize;
const articles = truncated ? all.slice(0, windowSize) : all;
res.status(200).json({ articles, truncated });
} catch (error) {
const status = articleErrorStatus(error);
if (status === 500) console.error('List article search index failed:', error);
res.status(status).json({ error: error.message });
}
});
/**
* @openapi
* /article/{sku}/print:
@@ -2448,4 +2330,3 @@ export function register(app, basePath = '') {
});
}

View File

@@ -70,11 +70,6 @@ export async function setCategoryIdsBySku(googleAuthId, sku, categoryIds) {
return store.article.setCategoryIdsBySku(googleAuthId, sku, categoryIds);
}
export async function listSearchIndex(googleAuthId) {
const store = await getStore();
return store.article.listSearchIndex(googleAuthId);
}
export async function listExport(googleAuthId, categoryId) {
const store = await getStore();
return store.article.listExport(googleAuthId, categoryId);
@@ -127,4 +122,3 @@ export async function setOptionslist(googleAuthId, sku, options) {
return store.article.setOptionslist(googleAuthId, sku, options);
}

View File

@@ -1,12 +1,302 @@
import sharp from 'sharp';
import { Worker } from 'worker_threads';
import { fileURLToPath } from 'url';
const FETCH_TIMEOUT_MS = 15000;
const MAX_BYTES = 50 * 1024 * 1024; // 50MB
const MAX_EDGE = 1000;
const TRIM_THRESHOLD = 10;
const RESTORE_MARGIN = 5; // px restored per edge after trimming (up to what was trimmed)
const AVIF_QUALITY = 80;
const AVIF_EFFORT = 4;
// The crop/trim/resize/AVIF pipeline runs up to six sequential sharp passes (PNG
// intermediate, trim, extract, resize, AVIF encode). A sharp pipeline is owned by
// whichever thread created it, so running it on the main thread stalls every HTTP
// handler and every SSE client (heartbeats included). The pipeline therefore lives
// in imageCropWorker.js and is reached through the small pool below.
const WORKER_PATH = fileURLToPath(new URL('./imageCropWorker.js', import.meta.url));
const WORKER_BOOT_TIMEOUT_MS = 15000;
const DEFAULT_JOB_TIMEOUT_MS = 30000;
// libvips keeps its own warm thread pool per process, so one worker per CPU is
// plenty; sharp's internal concurrency is what actually saturates a worker.
const POOL_SIZE = Math.max(1, Math.min(4, (process.availableParallelism?.() ?? 2) - 1));
/** @type {Array<{ worker: Worker, busy: boolean, id: number }>} */
const pool = [];
/** @type {Array<{ resolve: Function, reject: Function, timer: NodeJS.Timeout, inputBuffer: Buffer, timeoutMs: number }>} */
const queue = [];
let shuttingDown = false;
let workerSeq = 0;
/** Serializes pool growth so concurrent callers cannot overshoot POOL_SIZE. */
let growing = Promise.resolve();
/**
* Copies a Buffer into a standalone, transferable ArrayBuffer.
*
* `Buffer.from(buffer)` and `Uint8Array.prototype.subarray` can alias the shared
* 8 KB Buffer pool, and transferring pooled memory would detach it for every other
* Buffer in the process (including in-flight HTTP bodies). A fresh `Uint8Array` is
* always backed by its own ArrayBuffer, so it is always safe to transfer.
*
* @param {Buffer} buffer
* @returns {ArrayBuffer}
*/
function toTransferable(buffer) {
return Uint8Array.from(buffer).buffer;
}
/**
* Spawns a worker and resolves once it has booted far enough to accept a job.
* @returns {Promise<{ worker: Worker, busy: boolean, id: number }>}
*/
function spawnWorker() {
const entry = { worker: null, busy: false, id: ++workerSeq };
return new Promise((resolve, reject) => {
const bootTimer = setTimeout(() => {
cleanup();
try { entry.worker?.terminate(); } catch { /* already closed */ }
reject(new Error(`Image crop worker failed to start within ${WORKER_BOOT_TIMEOUT_MS} ms`));
}, WORKER_BOOT_TIMEOUT_MS);
function cleanup() {
clearTimeout(bootTimer);
entry.worker?.off('message', onBooted);
entry.worker?.off('error', onError);
entry.worker?.off('exit', onExit);
}
function onBooted(msg) {
// The worker sends an explicit `{ ready: true }` once its imports (notably
// sharp/libvips) have resolved. Without this handshake there is no first
// message to await, so a worker that fails to boot would only surface later
// as a confusing per-job failure.
if (!msg || msg.ready !== true) return;
cleanup();
resolve(entry);
}
function onError(err) {
cleanup();
reject(err instanceof Error ? err : new Error(err?.message || 'Image crop worker failed to start'));
}
function onExit(code) {
cleanup();
reject(new Error(`Image crop worker exited before becoming ready (code ${code})`));
}
try {
entry.worker = new Worker(WORKER_PATH);
} catch (err) {
cleanup();
reject(err);
return;
}
entry.worker.on('message', onBooted);
entry.worker.on('error', onError);
entry.worker.on('exit', onExit);
});
}
/**
* Returns an idle worker, spawning one when the pool is below capacity.
*
* When spawning fails the oldest queued job is failed with that error rather than
* being left waiting on a worker that will never arrive — otherwise a boot failure
* would deadlock the queue instead of surfacing to the HTTP route.
*
* @returns {Promise<{ worker: Worker, busy: boolean, id: number }>}
*/
async function acquireWorker() {
const idle = pool.find((entry) => !entry.busy);
if (idle) return idle;
if (pool.length < POOL_SIZE) {
// Serialize pool growth. Without this, N concurrent callers all observe
// `pool.length < POOL_SIZE` and each spawn a worker, overshooting the cap.
growing = growing.then(async () => {
if (pool.length >= POOL_SIZE) return;
try {
pool.push(await spawnWorker());
} catch (err) {
const waiting = queue[0];
if (!waiting) throw err;
// Fail the oldest queued job rather than leaving it waiting on a worker
// that will never arrive — otherwise a boot failure deadlocks the queue
// instead of surfacing to the HTTP route.
queue.shift();
clearTimeout(waiting.timer);
waiting.reject(err);
}
});
try {
await growing;
} catch {
// Already reported to the queued job; continue so this caller still queues.
}
// Re-check: an idle worker may now exist, or the pool may still be saturated.
if (pool.some((entry) => !entry.busy)) return;
}
// Every worker is busy and the pool is at capacity. enqueueJob/drainQueue hand the
// job the next free worker; acquiring one here is only needed to grow the pool.
return null;
}
/**
* Runs one job on a worker and resolves with its reply.
* @param {{ worker: Worker, busy: boolean, id: number }} entry
* @param {Buffer} inputBuffer
* @param {number} timeoutMs
*/
function runJob(entry, inputBuffer, timeoutMs) {
return new Promise((resolve, reject) => {
const jobId = `${entry.id}:${process.hrtime.bigint()}`;
const transfer = toTransferable(inputBuffer);
let settled = false;
const timer = setTimeout(() => {
// Terminate first, then report. Leaving a worker blocked in libvips would let
// it keep burning a core long after the HTTP request has been answered, and
// its eventual reply would then arrive against an already-settled job.
try { entry.worker.terminate(); } catch { /* already closed */ }
finish(new Error(`Image crop worker exceeded the ${timeoutMs} ms timeout and was terminated`));
}, timeoutMs);
function cleanup() {
clearTimeout(timer);
entry.worker.off('message', onMessage);
entry.worker.off('error', onError);
entry.worker.off('exit', onExit);
}
function finish(err, value) {
if (settled) return;
settled = true;
cleanup();
if (err) reject(err);
else resolve(value);
}
function onMessage(msg) {
// Ignore anything that is not this job's reply. Under `node --watch` the dev
// server's module-loader hook posts `watch:import` / `watch:require` notices on
// the worker's parentPort for every import, so the first message is not
// necessarily our reply. Job ids are matched instead of taking the first one.
if (!msg || msg.id !== jobId) return;
if (msg.ok) {
finish(null, {
buffer: Buffer.from(msg.buffer),
width: msg.width,
height: msg.height,
size: msg.size,
});
} else {
const err = new Error(msg.error || 'Image crop failed in worker');
if (msg.errorName) err.name = msg.errorName;
if (msg.stack) err.workerStack = msg.stack;
finish(err);
}
}
function onError(err) {
dropWorker(entry);
finish(err instanceof Error ? err : new Error(err?.message || 'Image crop worker error'));
}
function onExit(code) {
dropWorker(entry);
finish(new Error(`Image crop worker exited unexpectedly (code ${code})`));
}
entry.worker.on('message', onMessage);
entry.worker.on('error', onError);
entry.worker.on('exit', onExit);
try {
entry.worker.postMessage({ id: jobId, buffer: transfer }, [transfer]);
} catch (err) {
finish(err instanceof Error ? err : new Error(err?.message || 'Failed to post image to crop worker'));
}
});
}
/**
* Removes a dead worker from the pool. Jobs it was running have already been failed
* by its error/exit listener; queued jobs are re-homed by drainQueue().
* @param {{ worker: Worker }} entry
*/
function dropWorker(entry) {
const index = pool.indexOf(entry);
if (index !== -1) pool.splice(index, 1);
}
/**
* Starts as many queued jobs as there are idle workers.
*/
function drainQueue() {
while (queue.length > 0) {
const idle = pool.find((entry) => !entry.busy);
if (!idle) return;
const next = queue.shift();
clearTimeout(next.timer);
idle.busy = true;
runJob(idle, next.inputBuffer, next.timeoutMs).then(next.resolve, next.reject).finally(() => {
idle.busy = false;
drainQueue();
});
}
}
/**
* Queues a crop job and resolves with the worker's result.
*
* `drainQueue` is the single owner of dispatch: it hands each queued job an idle
* worker and runs it, so a job can never be dispatched twice nor left waiting on a
* worker that acquireWorker never obtained.
*
* @param {Buffer} inputBuffer
* @param {number} timeoutMs
* @returns {Promise<{ buffer: Buffer, width: number, height: number, size: number }>}
*/
function enqueueJob(inputBuffer, timeoutMs) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
const index = queue.findIndex((item) => item.timer === timer);
if (index !== -1) queue.splice(index, 1);
reject(new Error(`Image crop worker queue timed out after ${timeoutMs} ms`));
}, timeoutMs);
queue.push({ resolve, reject, timer, inputBuffer, timeoutMs });
drainQueue();
});
}
/**
* Terminates every pooled worker and fails anything still queued.
*
* Call from shutdown so `node --watch` does not hang on "Waiting for graceful
* termination..." the way long-lived SSE sockets do.
*
* @returns {Promise<void>}
*/
export async function shutdownImageCropWorkers() {
shuttingDown = true;
while (queue.length > 0) {
const pending = queue.shift();
clearTimeout(pending.timer);
pending.reject(new Error('Image crop worker pool is shutting down'));
}
const entries = pool.splice(0, pool.length);
await Promise.all(
entries.map((entry) =>
Promise.resolve()
.then(() => entry.worker.terminate())
.catch(() => { /* already closed */ })
)
);
}
/**
* Validates and normalizes an image URL.
@@ -85,90 +375,29 @@ export async function crawlImage(url, { signal } = {}) {
* actually trimmed, never enlarging beyond the original), resizes to max 1000px,
* and converts to AVIF.
*
* Runs the sharp pipeline in a pooled worker thread (see imageCropWorker.js) so the
* multi-pass encode never blocks the main event loop. Errors are re-thrown here with
* the worker's message intact, so callers that classify on message text (e.g. the
* `Failed to crawl image:` prefix check in /serper/image) keep working unchanged.
*
* @param {Buffer} inputBuffer - Raw image buffer
* @param {object} [options]
* @param {number} [options.timeout] - Hard wall-clock limit for the worker job
* @returns {Promise<{ buffer: Buffer, width: number, height: number, size: number }>}
*/
export async function cropAndConvertToAvif(inputBuffer) {
// 1. Initial metadata and orientation correction
const baseSharp = sharp(inputBuffer, { failOn: 'none' }).rotate();
const initialMeta = await baseSharp.metadata();
const hasAlpha = Boolean(initialMeta.hasAlpha);
// Use PNG intermediate for trimming passes to preserve alpha
const originalBuffer = await baseSharp.png().toBuffer();
const origMeta = await sharp(originalBuffer).metadata();
// Cumulative crop box in original (oriented) pixel coordinates
let box = { left: 0, top: 0, width: origMeta.width, height: origMeta.height };
let currentBuffer = originalBuffer;
const trimPass = async (background) => {
try {
const trimmed = await sharp(currentBuffer)
.trim({ background, threshold: TRIM_THRESHOLD })
.png()
.toBuffer({ resolveWithObject: true });
if (trimmed.info.width > 0 && trimmed.info.height > 0) {
currentBuffer = trimmed.data;
box = {
left: box.left - (trimmed.info.trimOffsetLeft ?? 0),
top: box.top - (trimmed.info.trimOffsetTop ?? 0),
width: trimmed.info.width,
height: trimmed.info.height,
};
}
} catch {
// Keep currentBuffer
}
};
// 2. Step A: Trim transparent borders if alpha is present
if (hasAlpha) await trimPass({ r: 0, g: 0, b: 0, alpha: 0 });
// 3. Step B: Trim white borders
await trimPass('#ffffff');
// 4. Step C: Trim transparent borders again (handles white border containing transparent padding)
if (hasAlpha) await trimPass({ r: 0, g: 0, b: 0, alpha: 0 });
// 4b. Restore up to RESTORE_MARGIN px per edge from the original, clamped to original bounds
const left = Math.max(0, box.left - RESTORE_MARGIN);
const top = Math.max(0, box.top - RESTORE_MARGIN);
const right = Math.min(origMeta.width, box.left + box.width + RESTORE_MARGIN);
const bottom = Math.min(origMeta.height, box.top + box.height + RESTORE_MARGIN);
if (left !== box.left || top !== box.top || right - left !== box.width || bottom - top !== box.height) {
try {
const restored = await sharp(originalBuffer)
.extract({ left, top, width: right - left, height: bottom - top })
.png()
.toBuffer({ resolveWithObject: true });
currentBuffer = restored.data;
} catch {
// Keep currentBuffer
}
export async function cropAndConvertToAvif(inputBuffer, { timeout = DEFAULT_JOB_TIMEOUT_MS } = {}) {
if (!Buffer.isBuffer(inputBuffer)) {
throw new TypeError('cropAndConvertToAvif expects a Buffer');
}
const currentMeta = await sharp(currentBuffer).metadata();
// 5. Resize to max 1000px without enlargement
let finalPipeline = sharp(currentBuffer);
if (currentMeta.width > MAX_EDGE || currentMeta.height > MAX_EDGE) {
finalPipeline = finalPipeline.resize(MAX_EDGE, MAX_EDGE, {
fit: 'inside',
withoutEnlargement: true,
});
if (shuttingDown) {
throw new Error('Image crop worker pool is shutting down');
}
// 6. Convert to AVIF
const avifResult = await finalPipeline
.avif({ quality: AVIF_QUALITY, effort: AVIF_EFFORT })
.toBuffer({ resolveWithObject: true });
return {
buffer: avifResult.data,
width: avifResult.info.width,
height: avifResult.info.height,
size: avifResult.info.size,
};
// Grow the pool on demand, then queue. enqueueJob/drainQueue hand the job to the
// first idle worker, so an idle worker is never leased twice and a saturated pool
// simply waits instead of piling up sharp pipelines.
await acquireWorker();
return enqueueJob(inputBuffer, timeout);
}
/**
@@ -177,9 +406,10 @@ export async function cropAndConvertToAvif(inputBuffer) {
* @param {string} url - Image URL to crawl
* @param {object} [options]
* @param {AbortSignal} [options.signal] - Abort signal
* @param {number} [options.timeout] - Hard wall-clock limit for the crop worker
* @returns {Promise<{ buffer: Buffer, width: number, height: number, size: number }>}
*/
export async function crawlAndProcessImage(url, { signal } = {}) {
export async function crawlAndProcessImage(url, { signal, timeout } = {}) {
const rawBuffer = await crawlImage(url, { signal });
return cropAndConvertToAvif(rawBuffer);
return cropAndConvertToAvif(rawBuffer, { timeout });
}

View File

@@ -0,0 +1,163 @@
'use strict';
/**
* Worker-thread entry point for the sharp crop/trim/resize/AVIF pipeline.
*
* This pipeline runs up to six sequential sharp passes (PNG intermediate, trim,
* extract, resize, AVIF encode) whose JS glue cost lands on the thread that owns
* the pipeline. Running it here keeps that cost off the main event loop, which
* otherwise stalls HTTP handlers and every SSE client (heartbeats included).
*
* Protocol: the parent sends `{ id, buffer }` (an ArrayBuffer, transferred) and
* the worker replies `{ id, ok: true, buffer, width, height, size }` (buffer
* transferred back) or `{ id, ok: false, error, errorName }`.
*/
import { parentPort } from 'worker_threads';
import sharp from 'sharp';
const MAX_EDGE = 1000;
const TRIM_THRESHOLD = 10;
const RESTORE_MARGIN = 5; // px restored per edge after trimming (up to what was trimmed)
const AVIF_QUALITY = 80;
const AVIF_EFFORT = 4;
/**
* Copies a Buffer into a standalone, transferable ArrayBuffer.
*
* `Buffer.from(buffer)` / `Uint8Array.prototype.subarray` can alias the shared
* 8 KB Buffer pool, and transferring pooled memory would corrupt every other
* Buffer in the process. A fresh `Uint8Array` is always backed by its own
* ArrayBuffer, so it is always safe to hand to `postMessage(..., [transfer])`.
*
* @param {Buffer} buffer
* @returns {ArrayBuffer}
*/
function toTransferable(buffer) {
return Uint8Array.from(buffer).buffer;
}
/**
* Trims transparent and white borders from an image buffer, restores up to
* RESTORE_MARGIN px of the original image on each edge (never more than was
* actually trimmed, never enlarging beyond the original), resizes to max 1000px,
* and converts to AVIF.
*
* @param {Buffer} inputBuffer - Raw image buffer
* @returns {Promise<{ buffer: Buffer, width: number, height: number, size: number }>}
*/
async function cropAndConvertToAvif(inputBuffer) {
// 1. Initial metadata and orientation correction
const baseSharp = sharp(inputBuffer, { failOn: 'none' }).rotate();
const initialMeta = await baseSharp.metadata();
const hasAlpha = Boolean(initialMeta.hasAlpha);
// Use PNG intermediate for trimming passes to preserve alpha
const originalBuffer = await baseSharp.png().toBuffer();
const origMeta = await sharp(originalBuffer).metadata();
// Cumulative crop box in original (oriented) pixel coordinates
let box = { left: 0, top: 0, width: origMeta.width, height: origMeta.height };
let currentBuffer = originalBuffer;
const trimPass = async (background) => {
try {
const trimmed = await sharp(currentBuffer)
.trim({ background, threshold: TRIM_THRESHOLD })
.png()
.toBuffer({ resolveWithObject: true });
if (trimmed.info.width > 0 && trimmed.info.height > 0) {
currentBuffer = trimmed.data;
box = {
left: box.left - (trimmed.info.trimOffsetLeft ?? 0),
top: box.top - (trimmed.info.trimOffsetTop ?? 0),
width: trimmed.info.width,
height: trimmed.info.height,
};
}
} catch {
// Keep currentBuffer
}
};
// 2. Step A: Trim transparent borders if alpha is present
if (hasAlpha) await trimPass({ r: 0, g: 0, b: 0, alpha: 0 });
// 3. Step B: Trim white borders
await trimPass('#ffffff');
// 4. Step C: Trim transparent borders again (handles white border containing transparent padding)
if (hasAlpha) await trimPass({ r: 0, g: 0, b: 0, alpha: 0 });
// 4b. Restore up to RESTORE_MARGIN px per edge from the original, clamped to original bounds
const left = Math.max(0, box.left - RESTORE_MARGIN);
const top = Math.max(0, box.top - RESTORE_MARGIN);
const right = Math.min(origMeta.width, box.left + box.width + RESTORE_MARGIN);
const bottom = Math.min(origMeta.height, box.top + box.height + RESTORE_MARGIN);
if (left !== box.left || top !== box.top || right - left !== box.width || bottom - top !== box.height) {
try {
const restored = await sharp(originalBuffer)
.extract({ left, top, width: right - left, height: bottom - top })
.png()
.toBuffer({ resolveWithObject: true });
currentBuffer = restored.data;
} catch {
// Keep currentBuffer
}
}
const currentMeta = await sharp(currentBuffer).metadata();
// 5. Resize to max 1000px without enlargement
let finalPipeline = sharp(currentBuffer);
if (currentMeta.width > MAX_EDGE || currentMeta.height > MAX_EDGE) {
finalPipeline = finalPipeline.resize(MAX_EDGE, MAX_EDGE, {
fit: 'inside',
withoutEnlargement: true,
});
}
// 6. Convert to AVIF
const avifResult = await finalPipeline
.avif({ quality: AVIF_QUALITY, effort: AVIF_EFFORT })
.toBuffer({ resolveWithObject: true });
return {
buffer: avifResult.data,
width: avifResult.info.width,
height: avifResult.info.height,
size: avifResult.info.size,
};
}
// Boot handshake. The parent awaits this before queuing the first job, so a worker
// whose imports (sharp/libvips) fail surfaces as a start-up error rather than as an
// inexplicable per-job failure.
parentPort.postMessage({ ready: true });
parentPort.on('message', async (msg) => {
const { id, buffer } = msg ?? {};
try {
const result = await cropAndConvertToAvif(Buffer.from(buffer));
const out = toTransferable(result.buffer);
parentPort.postMessage(
{
id,
ok: true,
buffer: out,
width: result.width,
height: result.height,
size: result.size,
},
[out]
);
} catch (err) {
parentPort.postMessage({
id,
ok: false,
error: err?.message || String(err),
errorName: err?.name || 'Error',
stack: err?.stack,
});
}
});