From 9fbb90b6418641154c40017ca03d7fd3bcec1452 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Mon, 24 Aug 2026 07:39:03 +0500 Subject: [PATCH] feat: move tool executor to web worker --- web/src/lib/tools/executor.test.ts | 37 ++++++++++ web/src/lib/tools/executor.ts | 101 +++++++++++++++++++++++++++ web/src/lib/tools/executor.worker.ts | 43 ++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 web/src/lib/tools/executor.test.ts create mode 100644 web/src/lib/tools/executor.worker.ts diff --git a/web/src/lib/tools/executor.test.ts b/web/src/lib/tools/executor.test.ts new file mode 100644 index 0000000..9bed603 --- /dev/null +++ b/web/src/lib/tools/executor.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { executeStep } from './executor'; +import { makeImage } from '../core/test-helpers'; +import type { PixelImage } from '../core/types'; + +function cloneOf(img: PixelImage): PixelImage { + return { width: img.width, height: img.height, data: new Uint8ClampedArray(img.data) }; +} + +describe('executeStep: прямой путь (среда без Worker)', () => { + it('возвращает результат инструмента', async () => { + const img = makeImage(1, 1, [ + [1, 2, 3, 4] + ]); + const out = await executeStep( + { id: 'stub', run: (i) => cloneOf(i) }, + img, + {} + ); + expect(out).toEqual(img); + }); + + it('пробрасывает ошибку инструмента как есть', async () => { + const boom = () => { + throw new Error('бум'); + }; + await expect(executeStep({ id: 'stub', run: boom }, makeImage(1, 1, [[0, 0, 0, 255]]), {})).rejects.toThrow( + 'бум' + ); + }); + + it('инструмент без run даёт понятную ошибку', async () => { + await expect(executeStep({ id: 'stub' }, makeImage(1, 1, [[0, 0, 0, 255]]), {})).rejects.toThrow( + 'не обрабатывает изображения' + ); + }); +}); diff --git a/web/src/lib/tools/executor.ts b/web/src/lib/tools/executor.ts index 187912c..0dbfc19 100644 --- a/web/src/lib/tools/executor.ts +++ b/web/src/lib/tools/executor.ts @@ -1,6 +1,7 @@ import type { PixelImage } from '../core/types'; type MaybeRunnable = { + id: string; run?: (img: PixelImage, params: Record) => Promise | PixelImage; }; @@ -8,9 +9,109 @@ export async function executeStep( tool: MaybeRunnable, img: PixelImage, params: Record +): Promise { + if (!tool.run) { + throw new Error('Этот инструмент не обрабатывает изображения'); + } + if (typeof Worker === 'undefined') { + return await runDirect(tool, img, params); + } + const worker = ensureWorker(); + if (worker === null) { + return await runDirect(tool, img, params); + } + try { + return await runInWorker(worker, tool.id, img, params); + } catch (workerError) { + disableWorker(); + void workerError; + return await runDirect(tool, img, params); + } +} + +async function runDirect( + tool: MaybeRunnable, + img: PixelImage, + params: Record ): Promise { if (!tool.run) { throw new Error('Этот инструмент не обрабатывает изображения'); } return await tool.run(img, params); } + +let worker: Worker | null = null; +let workerTried = false; +let nextRequestId = 1; +const pending = new Map< + number, + { resolve: (img: PixelImage) => void; reject: (e: Error) => void } +>(); + +function ensureWorker(): Worker | null { + if (workerTried) return worker; + workerTried = true; + try { + const candidate = new Worker(new URL('./executor.worker.ts', import.meta.url), { + type: 'module' + }); + candidate.onmessage = (event: MessageEvent) => { + const payload = event.data as { + id: number; + ok: boolean; + width?: number; + height?: number; + data?: Uint8ClampedArray; + error?: string; + }; + const entry = pending.get(payload.id); + if (!entry) return; + pending.delete(payload.id); + if (payload.ok && payload.data && payload.width && payload.height) { + entry.resolve({ + width: payload.width, + height: payload.height, + data: new Uint8ClampedArray(payload.data) + }); + } else { + entry.reject(new Error(payload.error ?? 'Ошибка исполнения в воркере')); + } + }; + candidate.onerror = () => { + worker = null; + for (const entry of pending.values()) { + entry.reject(new Error('Воркер недоступен')); + } + pending.clear(); + }; + worker = candidate; + } catch { + worker = null; + } + return worker; +} + +function disableWorker(): void { + if (worker) { + worker.terminate(); + } + worker = null; +} + +function runInWorker( + workerInstance: Worker, + toolId: string, + img: PixelImage, + params: Record +): Promise { + return new Promise((resolve, reject) => { + const id = nextRequestId++; + pending.set(id, { resolve, reject }); + workerInstance.postMessage({ + id, + toolId, + image: { width: img.width, height: img.height, data: new Uint8ClampedArray(img.data) }, + params + }); + }); +} diff --git a/web/src/lib/tools/executor.worker.ts b/web/src/lib/tools/executor.worker.ts new file mode 100644 index 0000000..d7eec7e --- /dev/null +++ b/web/src/lib/tools/executor.worker.ts @@ -0,0 +1,43 @@ +/// +import type { PixelImage } from '../core/types'; +import { getTool, sanitizeParams } from '../registry'; + +type WorkerRequest = { + id: number; + toolId: string; + image: { width: number; height: number; data: Uint8ClampedArray }; + params: Record; +}; + +self.onmessage = (event: MessageEvent) => { + void handle(event.data); +}; + +async function handle(request: WorkerRequest): Promise { + try { + const tool = getTool(request.toolId); + if (!tool?.run) { + throw new Error('Этот инструмент не обрабатывает изображения'); + } + const image: PixelImage = { + width: request.image.width, + height: request.image.height, + data: new Uint8ClampedArray(request.image.data) + }; + const output = await tool.run(image, sanitizeParams(tool, request.params)); + const payload = { + id: request.id, + ok: true, + width: output.width, + height: output.height, + data: output.data + }; + (self as unknown as Worker).postMessage(payload, [output.data.buffer]); + } catch (e) { + (self as unknown as Worker).postMessage({ + id: request.id, + ok: false, + error: e instanceof Error ? e.message : String(e) + }); + } +}