feat: move tool executor to web worker
This commit is contained in:
@@ -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(
|
||||||
|
'не обрабатывает изображения'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { PixelImage } from '../core/types';
|
import type { PixelImage } from '../core/types';
|
||||||
|
|
||||||
type MaybeRunnable = {
|
type MaybeRunnable = {
|
||||||
|
id: string;
|
||||||
run?: (img: PixelImage, params: Record<string, unknown>) => Promise<PixelImage> | PixelImage;
|
run?: (img: PixelImage, params: Record<string, unknown>) => Promise<PixelImage> | PixelImage;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -8,9 +9,109 @@ export async function executeStep(
|
|||||||
tool: MaybeRunnable,
|
tool: MaybeRunnable,
|
||||||
img: PixelImage,
|
img: PixelImage,
|
||||||
params: Record<string, unknown>
|
params: Record<string, unknown>
|
||||||
|
): Promise<PixelImage> {
|
||||||
|
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<string, unknown>
|
||||||
): Promise<PixelImage> {
|
): Promise<PixelImage> {
|
||||||
if (!tool.run) {
|
if (!tool.run) {
|
||||||
throw new Error('Этот инструмент не обрабатывает изображения');
|
throw new Error('Этот инструмент не обрабатывает изображения');
|
||||||
}
|
}
|
||||||
return await tool.run(img, params);
|
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<string, unknown>
|
||||||
|
): Promise<PixelImage> {
|
||||||
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/// <reference lib="webworker" />
|
||||||
|
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<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
||||||
|
void handle(event.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handle(request: WorkerRequest): Promise<void> {
|
||||||
|
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)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user