From 0933f1cdeab3998de628eb1a5443399304e088ae Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sat, 26 Sep 2026 02:20:34 +0500 Subject: [PATCH] refactor: executor protocol, fallback, debounce --- docs/README.md | 6 +- docs/plan-codebase-quality.md | 43 ++-- docs/testing-strategy.md | 2 +- web/e2e/generators.spec.ts | 8 +- web/src/lib/core/debounce.test.ts | 38 +++ web/src/lib/executor/executor.test.ts | 289 +++++++++++++++++++++ web/src/lib/executor/executor.ts | 323 +++++++++++------------- web/src/lib/executor/executor.worker.ts | 115 +-------- web/src/lib/executor/protocol.ts | 161 ++++++++++++ web/src/lib/executor/worker-handler.ts | 36 +++ web/src/lib/registry/convert.ts | 4 + web/src/lib/registry/filters.ts | 1 + web/src/lib/registry/generate.ts | 1 + web/src/lib/registry/registry.test.ts | 13 + 14 files changed, 739 insertions(+), 301 deletions(-) create mode 100644 web/src/lib/core/debounce.test.ts create mode 100644 web/src/lib/executor/executor.test.ts create mode 100644 web/src/lib/executor/protocol.ts create mode 100644 web/src/lib/executor/worker-handler.ts diff --git a/docs/README.md b/docs/README.md index f5d1a7d..374a43e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -51,13 +51,13 @@ только если темы нет в существующем документе; иначе обновляется источник истины. -## Текущий baseline Q5 +## Текущий baseline Q6a -На момент фиксации Q5: +На момент фиксации Q6a: - production registry содержит 122 инструмента; - `pnpm --dir web check` проходит без ошибок и предупреждений; -- `pnpm --dir web test` — 39 test files / 431 tests; +- `pnpm --dir web test` — 41 test files / 445 tests; - `pnpm --dir web test:rules` — 4 test files / 74 tests; - `pnpm --dir web lint:all` — 0 errors и 0 i18n warnings; остаются 4 unused design tokens от token audit; diff --git a/docs/plan-codebase-quality.md b/docs/plan-codebase-quality.md index ec5e934..f68439b 100644 --- a/docs/plan-codebase-quality.md +++ b/docs/plan-codebase-quality.md @@ -1,10 +1,10 @@ # План: линтеры, тесты и качество кодовой базы -> Статус: **draft — Q0–Q5 готовы к ревью; Q6 — следующий этап**. +> Статус: **draft — Q0–Q5 и Q6a готовы к ревью; Q6b — следующий этап**. > > Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и -> инфраструктурных скриптов. Q0–Q5 выполнены; Q6–Q8 выполняются отдельными -> атомарными шагами. +> инфраструктурных скриптов. Q0–Q5 и Q6a выполнены; Q6b–Q8 выполняются +> отдельными атомарными шагами. > > Цель: сделать качество кодовой базы воспроизводимым, убрать ложную «зелёность» > проверок, стабилизировать тестовые контракты и только после этого расширять @@ -189,30 +189,41 @@ Q6. - `ToolError` хранится как key/vars, переводится derived-значением при выводе и обновляется при смене локали. E2E использует независимый invalid pixel-count сценарий. -- Resize/crop, generator, Reset и localization fixme сняты; Q6 начинает - расширение покрытия, а не исправление этих контрактов. +- Resize/crop, generator, Reset и localization fixme сняты; Q6a расширяет + покрытие, не смешиваясь с исправлением этих контрактов. **Результат:** Q5 known issues закрыты и отражены в `docs/backlog.md` и -`docs/checklist-manual-testing.md`; следующий этап — Q6. +`docs/checklist-manual-testing.md`; Q6a выполнен, следующий этап — Q6b. ### Q6. Расширение unit и browser-покрытия **Оценка:** M–L. **Зависимости:** Q4, Q5. -Сначала: +#### Q6a. Executor protocol, fallback и debounce -- executor и worker/fallback; -- `FileResult`, ZIP и download; -- `debounce` и auto-run; +**Статус:** выполнен. Unit-тесты покрывают debounce, worker image/string/ +verdict/files protocol, `ToolError` transport, constructor/postMessage/onerror/ +malformed fallback, source-aware sanitization и DOM metadata. Browser smoke +проверяет rapid auto-run и no-worker fallback; deferred transport fallback +покрыт unit-тестами. + +- `execute(tool, ctx)` сохраняет публичный API; `createExecutor` и protocol + handler являются внутренними test seams. +- `ToolError` не запускает direct retry повторно; transport/protocol failures + отключают worker и переходят в direct fallback. +- DOM-зависимые registry entries помечены `domOnly`, поэтому зелёный smoke не + скрывает нормальный worker fallback. +- `debounce` покрыт fake timers; E2E подтверждает последние значения при быстрых + последовательных изменениях генератора. + +#### Q6b–Q6f. Оставшиеся направления + +- `FileResult` → ZIP → download; - output MIME/quality; - malformed и special PNG fixtures: CRC, truncated, palette, 16-bit; -- smoke всех типов инструментов из registry. - -Затем отдельно: - +- smoke всех типов инструментов из registry; - component-тесты `SchemaToolView`, `SchemaPreview` и schema fields; -- browser matrix, mobile viewport и visual snapshots; -- coverage thresholds. +- browser matrix, mobile viewport, visual snapshots и coverage thresholds. Не применять один одинаковый smoke-сценарий к image-, text-, generator- и file-result-инструментам. diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index 03c9e88..b66fe94 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -66,7 +66,7 @@ helpers, а не повторять raw-селекторы. `test.fixme` допускается только для зарегистрированной проблемы в backlog или checklist. В комментарии указывается ссылка на неё. После исправления тест и -ссылка обновляются вместе; в текущем Q5 baseline активных fixme нет. +ссылка обновляются вместе; в текущем Q6a baseline активных fixme нет. ## 6. Проверки diff --git a/web/e2e/generators.spec.ts b/web/e2e/generators.spec.ts index 032907f..a521034 100644 --- a/web/e2e/generators.spec.ts +++ b/web/e2e/generators.spec.ts @@ -16,12 +16,10 @@ test.describe("generators", () => { await expect(resultImage(page)).toBeVisible(); await expect(page.getByRole("button", { name: "Generate" })).toHaveCount(0); - const firstSource = await resultImage(page).getAttribute("src"); await page.getByRole("spinbutton", { name: "Width" }).fill("32"); - await expect(resultImage(page)).not.toHaveAttribute( - "src", - firstSource ?? "", - ); + await page.getByRole("spinbutton", { name: "Height" }).fill("24"); + await expect(resultImage(page)).toHaveJSProperty("naturalWidth", 32); + await expect(resultImage(page)).toHaveJSProperty("naturalHeight", 24); await expectNoErrorAlert(page); expectNoErrors(sink); }); diff --git a/web/src/lib/core/debounce.test.ts b/web/src/lib/core/debounce.test.ts new file mode 100644 index 0000000..31be8c5 --- /dev/null +++ b/web/src/lib/core/debounce.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { debounce } from "./debounce"; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("debounce", () => { + it("coalesces calls until the deadline", () => { + vi.useFakeTimers(); + const fn = vi.fn(); + const debounced = debounce(fn, 200); + + debounced(); + vi.advanceTimersByTime(199); + debounced(); + vi.advanceTimersByTime(199); + expect(fn).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("cancels a pending call and can be scheduled again", () => { + vi.useFakeTimers(); + const fn = vi.fn(); + const debounced = debounce(fn, 200); + + debounced(); + debounced.cancel(); + vi.advanceTimersByTime(200); + expect(fn).not.toHaveBeenCalled(); + + debounced(); + vi.advanceTimersByTime(200); + expect(fn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/src/lib/executor/executor.test.ts b/web/src/lib/executor/executor.test.ts new file mode 100644 index 0000000..34b549a --- /dev/null +++ b/web/src/lib/executor/executor.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it, vi } from "vitest"; +import { ToolError } from "../core/errors"; +import { + createExecutor, + type ExecuteContext, + type WorkerFactory, + type WorkerLike, +} from "./executor"; +import { + decodeWorkerResponse, + type WorkerRequest, + type WorkerResponse, +} from "./protocol"; +import { handleWorkerRequest } from "./worker-handler"; +import { field, toolSchema, type Dimension } from "../registry-schema"; +import type { ToolEntry } from "../registry"; + +class FakeWorker implements WorkerLike { + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + requests: WorkerRequest[] = []; + terminated = false; + throwOnPost = false; + + postMessage(request: WorkerRequest): void { + this.requests.push(request); + if (this.throwOnPost) throw new Error("post failed"); + } + + respond(response: WorkerResponse): void { + this.onmessage?.({ data: response } as MessageEvent); + } + + fail(): void { + this.onerror?.(new Event("error")); + } + + terminate(): void { + this.terminated = true; + } +} + +function createFactory(throwOnPost = false): { + factory: WorkerFactory; + workers: FakeWorker[]; +} { + const workers: FakeWorker[] = []; + return { + factory: () => { + const worker = new FakeWorker(); + worker.throwOnPost = throwOnPost; + workers.push(worker); + return worker; + }, + workers, + }; +} + +const emptySchema = toolSchema>({}); + +function makeTool( + run: ToolEntry["run"] = () => "direct", + overrides: Partial = {}, +): ToolEntry { + return { + id: "test-tool", + title: "Test tool", + description: "Test tool", + category: "analyze", + schema: emptySchema, + input: "none", + run, + ...overrides, + }; +} + +function image(width = 2, height = 2): WorkerRequest["source"] { + return { + width, + height, + data: new Uint8ClampedArray(width * height * 4), + }; +} + +describe("createExecutor", () => { + it("validates required input before creating a worker", async () => { + const factory = vi.fn(); + const execute = createExecutor({ workerFactory: factory }); + const imageTool = makeTool(vi.fn(), { input: "image" }); + const textTool = makeTool(vi.fn(), { input: "text" }); + + await expect(execute(imageTool, { params: {} })).rejects.toMatchObject({ + key: "errors.sourceRequired", + }); + await expect( + execute(textTool, { params: {}, text: " " }), + ).rejects.toMatchObject({ key: "errors.textRequired" }); + expect(factory).not.toHaveBeenCalled(); + }); + + it("routes domOnly tools directly", async () => { + const { factory, workers } = createFactory(); + const execute = createExecutor({ workerFactory: factory }); + const run = vi.fn(() => "direct"); + const tool = makeTool(run, { domOnly: true }); + + await expect(execute(tool, { params: {} })).resolves.toBe("direct"); + expect(workers).toHaveLength(0); + }); + + it("passes source-aware sanitized params through the worker request", async () => { + const { factory, workers } = createFactory(); + const execute = createExecutor({ workerFactory: factory }); + const schema = toolSchema<{ size: Dimension }>({ + size: field.dimension({ + min: 1, + max: 100, + width: 1, + height: 1, + defaultFromSource: true, + }), + }); + const tool = makeTool(() => "worker", { input: "image", schema }); + const promise = execute(tool, { + params: { size: { width: 0, height: 0 } }, + source: image(64, 48) as ExecuteContext["source"], + }); + const worker = workers[0]; + const request = worker.requests[0]; + worker.respond({ id: request.id, ok: true, text: "worker" }); + + await expect(promise).resolves.toBe("worker"); + expect(request.params).toEqual({ size: { width: 64, height: 48 } }); + }); + + it("resolves concurrent worker responses by request id", async () => { + const { factory, workers } = createFactory(); + const execute = createExecutor({ workerFactory: factory }); + const tool = makeTool(); + const first = execute(tool, { params: {} }); + const second = execute(tool, { params: {} }); + const worker = workers[0]; + const [firstRequest, secondRequest] = worker.requests; + + worker.respond({ id: secondRequest.id, ok: true, text: "second" }); + worker.respond({ id: firstRequest.id, ok: true, text: "first" }); + + await expect(first).resolves.toBe("first"); + await expect(second).resolves.toBe("second"); + }); + + it("does not retry a worker ToolError directly", async () => { + const { factory, workers } = createFactory(); + const execute = createExecutor({ workerFactory: factory }); + const run = vi.fn(() => "direct"); + const tool = makeTool(run); + const promise = execute(tool, { params: {} }); + const worker = workers[0]; + const request = worker.requests[0]; + worker.respond({ + id: request.id, + ok: false, + errorKey: "errors.badPixelToken", + errorVars: { value: "x" }, + }); + + await expect(promise).rejects.toMatchObject({ + key: "errors.badPixelToken", + vars: { value: "x" }, + }); + expect(run).not.toHaveBeenCalled(); + }); + + it("falls back when worker construction fails", async () => { + const factory = vi.fn(() => { + throw new Error("construction failed"); + }); + const execute = createExecutor({ workerFactory: factory }); + const run = vi.fn(() => "direct"); + const tool = makeTool(run); + + await expect(execute(tool, { params: {} })).resolves.toBe("direct"); + await expect(execute(tool, { params: {} })).resolves.toBe("direct"); + expect(factory).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledTimes(2); + }); + + it("falls back after transport failures and disables the worker", async () => { + const { factory, workers } = createFactory(true); + const execute = createExecutor({ workerFactory: factory }); + const run = vi.fn(() => "direct"); + const tool = makeTool(run); + + const postFailure = execute(tool, { params: {} }); + await expect(postFailure).resolves.toBe("direct"); + expect(workers[0].terminated).toBe(true); + + await expect(execute(tool, { params: {} })).resolves.toBe("direct"); + expect(workers).toHaveLength(1); + }); + + it("falls back after onerror and malformed responses", async () => { + for (const failure of ["error", "malformed"] as const) { + const { factory, workers } = createFactory(); + const execute = createExecutor({ workerFactory: factory }); + const tool = makeTool(() => "direct"); + const promise = execute(tool, { params: {} }); + const worker = workers[0]; + if (failure === "error") { + worker.fail(); + } else { + worker.respond({ id: worker.requests[0].id, ok: true }); + } + await expect(promise).resolves.toBe("direct"); + expect(worker.terminated).toBe(true); + } + }); + + it("decodes image, verdict, and files worker responses", () => { + const data = new Uint8ClampedArray([1, 2, 3, 4]); + const imageResult = decodeWorkerResponse({ + id: 1, + ok: true, + width: 1, + height: 1, + data, + }); + expect(imageResult).toMatchObject({ width: 1, height: 1 }); + + const verdict = decodeWorkerResponse({ + id: 2, + ok: true, + text: "line", + textVars: { kb: "1" }, + }); + expect(verdict).toEqual({ key: "line", vars: { kb: "1" } }); + + const files = decodeWorkerResponse({ + id: 3, + ok: true, + files: [{ name: "part.png", width: 1, height: 1, data }], + }); + expect(files).toMatchObject({ + files: [{ name: "part.png", image: { width: 1, height: 1 } }], + }); + }); +}); + +describe("handleWorkerRequest", () => { + it("serializes image results and preserves ToolError keys", async () => { + const imageResult = await handleWorkerRequest({ + id: 1, + toolId: "flip-png", + params: {}, + source: image(), + }); + expect(imageResult.response).toMatchObject({ + id: 1, + ok: true, + width: 2, + height: 2, + }); + expect(imageResult.transfer).toHaveLength(1); + + const errorResult = await handleWorkerRequest({ + id: 2, + toolId: "bytes-to-png", + params: { width: 32 }, + text: Array.from({ length: 132 }, () => "1").join(" "), + }); + expect(errorResult.response).toMatchObject({ + id: 2, + ok: false, + errorKey: "errors.pixelCountMismatch", + errorVars: { count: 33, width: 32 }, + }); + }); + + it("serializes FileResult transfers", async () => { + const result = await handleWorkerRequest({ + id: 3, + toolId: "split-into-parts-png", + params: { columns: 2, rows: 2 }, + source: image(), + }); + expect(result.response).toMatchObject({ id: 3, ok: true }); + expect(result.transfer).toHaveLength(4); + }); +}); diff --git a/web/src/lib/executor/executor.ts b/web/src/lib/executor/executor.ts index a771da4..edadea3 100644 --- a/web/src/lib/executor/executor.ts +++ b/web/src/lib/executor/executor.ts @@ -1,184 +1,169 @@ -import { ToolError } from "$lib/core/errors"; -import type { PixelImage } from "$lib/core/types"; -import type { ToolContext, ToolEntry, ToolResult } from "$lib/registry"; -import { sanitizeSchemaParams } from "$lib/registry-schema"; +import { ToolError } from "../core/errors"; +import type { PixelImage } from "../core/types"; +import type { ToolContext, ToolEntry, ToolResult } from "../registry"; +import { sanitizeSchemaParams } from "../registry-schema"; +import { + decodeWorkerResponse, + isWorkerResponse, + type WorkerRequest, + type WorkerResponse, +} from "./protocol"; -/** Вход единой точки исполнения. `source`/`text` — по контракту `tool.input`. */ export type ExecuteContext = { params: Record; source?: PixelImage; text?: string; }; -/** - * Единственная точка исполнения инструмента. Валидирует наличие входа по - * `tool.input`, санитит параметры и запускает `tool.run`. Выбор места - * исполнения (worker/поток) — внутренняя забота executor'а, диспетча методов - * нет: у любого инструмента один `run(ctx)`. - */ -export async function execute( - tool: ToolEntry, - ctx: ExecuteContext, -): Promise { - const params = sanitizeSchemaParams(tool.schema, ctx.params, { - source: ctx.source, - }); - if (tool.input === "image" && !ctx.source) { - throw new ToolError("errors.sourceRequired"); - } - if (tool.input === "text" && !ctx.text?.trim()) { - throw new ToolError("errors.textRequired"); - } - return route(tool, { params, source: ctx.source, text: ctx.text }); +export type WorkerLike = { + postMessage(message: WorkerRequest): void; + terminate(): void; + onmessage: ((event: MessageEvent) => void) | null; + onerror: ((event: Event) => void) | null; +}; + +export type WorkerFactory = () => WorkerLike; + +type PendingRequest = { + resolve: (result: ToolResult) => void; + reject: (error: unknown) => void; +}; + +function defaultWorkerFactory(): WorkerLike { + return new Worker(new URL("./executor.worker.ts", import.meta.url), { + type: "module", + }) as unknown as WorkerLike; } -/** - * Маршрутизация по способу исполнения. В worker уходит всё, кроме - * domOnly-инструментов: даже текстовые вердикты могут быть тяжёлыми - * (гистограммы, сложный анализ), поэтому ограничивать по типу результата - * неверно. Ограничение одно — доступ к DOM, которого в worker нет. - */ -async function route( - tool: ToolEntry, - ctx: ExecuteContext, -): Promise { - if (tool.domOnly || typeof Worker === "undefined") { - return await runDirect(tool, ctx); - } - const worker = ensureWorker(); - if (worker === null) { - return await runDirect(tool, ctx); - } - try { - return await runInWorker(worker, tool, ctx); - } catch (workerError) { - disableWorker(); - void workerError; - return await runDirect(tool, ctx); - } -} +export function createExecutor( + options: { workerFactory?: WorkerFactory } = {}, +): (tool: ToolEntry, ctx: ExecuteContext) => Promise { + const workerFactory = options.workerFactory ?? defaultWorkerFactory; + let worker: WorkerLike | null = null; + let workerTried = false; + let nextRequestId = 1; + const pending = new Map(); -async function runDirect( - tool: ToolEntry, - ctx: ExecuteContext, -): Promise { - return await tool.run(ctx as ToolContext>); -} - -let worker: Worker | null = null; -let workerTried = false; -let nextRequestId = 1; -const pending = new Map< - number, - { resolve: (result: ToolResult) => 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; - files?: { - name: string; - width: number; - height: number; - data: Uint8ClampedArray; - }[]; - text?: string; - textVars?: Record; - error?: string; - errorKey?: string; - errorVars?: Record; - }; - 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 if (payload.ok && Array.isArray(payload.files)) { - entry.resolve({ - files: payload.files.map((file) => ({ - name: file.name, - image: { - width: file.width, - height: file.height, - data: new Uint8ClampedArray(file.data), - }, - })), - }); - } else if (payload.ok && typeof payload.text === "string") { - entry.resolve( - payload.textVars - ? { key: payload.text, vars: payload.textVars } - : payload.text, - ); - } else if (payload.errorKey) { - entry.reject(new ToolError(payload.errorKey, payload.errorVars)); - } else { - entry.reject( - payload.error - ? new Error(payload.error) - : new ToolError("errors.workerFailed"), - ); - } - }; - candidate.onerror = () => { - worker = null; - for (const entry of pending.values()) { - entry.reject(new ToolError("errors.workerUnavailable")); - } - pending.clear(); - }; - worker = candidate; - } catch { - worker = null; + function failWorker(candidate: WorkerLike, error: unknown): void { + if (worker === candidate) worker = null; + candidate.terminate(); + for (const [id, entry] of pending) { + pending.delete(id); + entry.reject(error); + } } - return worker; -} -function disableWorker(): void { - if (worker) { - worker.terminate(); - } - worker = null; -} - -function runInWorker( - workerInstance: Worker, - tool: ToolEntry, - ctx: ExecuteContext, -): Promise { - return new Promise((resolve, reject) => { - const id = nextRequestId++; - pending.set(id, { resolve, reject }); - workerInstance.postMessage({ - id, - toolId: tool.id, - params: ctx.params, - source: ctx.source - ? { - width: ctx.source.width, - height: ctx.source.height, - data: new Uint8ClampedArray(ctx.source.data), + function ensureWorker(): WorkerLike | null { + if (workerTried) return worker; + workerTried = true; + let candidate: WorkerLike | undefined; + try { + candidate = workerFactory(); + candidate.onmessage = (event) => { + const payload: unknown = event.data; + if (!isWorkerResponse(payload)) { + failWorker( + candidate as WorkerLike, + new Error("Invalid worker response"), + ); + return; + } + const entry = pending.get(payload.id); + if (!entry) return; + try { + const result = decodeWorkerResponse(payload); + pending.delete(payload.id); + entry.resolve(result); + } catch (error) { + if (error instanceof ToolError) { + pending.delete(payload.id); + entry.reject(error); + } else { + failWorker(candidate as WorkerLike, error); } - : undefined, - text: ctx.text, + } + }; + candidate.onerror = () => { + failWorker(candidate as WorkerLike, new Error("Worker error")); + }; + worker = candidate; + return candidate; + } catch (error) { + candidate?.terminate(); + worker = null; + return null; + } + } + + function disableWorker(candidate: WorkerLike): void { + if (worker !== candidate) return; + worker = null; + candidate.terminate(); + } + + async function runDirect( + tool: ToolEntry, + ctx: ExecuteContext, + ): Promise { + return await tool.run(ctx as ToolContext>); + } + + function runInWorker( + candidate: WorkerLike, + tool: ToolEntry, + ctx: ExecuteContext, + ): Promise { + return new Promise((resolve, reject) => { + const id = nextRequestId++; + pending.set(id, { resolve, reject }); + try { + candidate.postMessage({ + id, + toolId: tool.id, + params: ctx.params, + source: ctx.source + ? { + width: ctx.source.width, + height: ctx.source.height, + data: new Uint8ClampedArray(ctx.source.data), + } + : undefined, + text: ctx.text, + }); + } catch (error) { + failWorker(candidate, error); + } }); - }); + } + + async function route( + tool: ToolEntry, + ctx: ExecuteContext, + ): Promise { + if (tool.domOnly) return await runDirect(tool, ctx); + const candidate = ensureWorker(); + if (candidate === null) return await runDirect(tool, ctx); + try { + return await runInWorker(candidate, tool, ctx); + } catch (error) { + if (error instanceof ToolError) throw error; + disableWorker(candidate); + return await runDirect(tool, ctx); + } + } + + return async (tool, ctx) => { + const params = sanitizeSchemaParams(tool.schema, ctx.params, { + source: ctx.source, + }); + if (tool.input === "image" && !ctx.source) { + throw new ToolError("errors.sourceRequired"); + } + if (tool.input === "text" && !ctx.text?.trim()) { + throw new ToolError("errors.textRequired"); + } + return await route(tool, { ...ctx, params }); + }; } + +export const execute = createExecutor(); diff --git a/web/src/lib/executor/executor.worker.ts b/web/src/lib/executor/executor.worker.ts index 87c321c..1e7602e 100644 --- a/web/src/lib/executor/executor.worker.ts +++ b/web/src/lib/executor/executor.worker.ts @@ -1,113 +1,14 @@ /// -import { ToolError } from "$lib/core/errors"; -import type { PixelImage } from "$lib/core/types"; -import { getTool } from "$lib/registry"; -import { sanitizeSchemaParams } from "$lib/registry-schema"; - -type WorkerRequest = { - id: number; - toolId: string; - params: Record; - source?: { width: number; height: number; data: Uint8ClampedArray }; - text?: string; -}; - -type WorkerResponse = - | { - id: number; - ok: true; - width?: number; - height?: number; - data?: Uint8ClampedArray; - text?: string; - textVars?: Record; - files?: WorkerImageFile[]; - } - | { - id: number; - ok: false; - error?: string; - errorKey?: string; - errorVars?: Record; - }; - -type WorkerImageFile = { - name: string; - width: number; - height: number; - data: Uint8ClampedArray; -}; +import { handleWorkerRequest } from "./worker-handler"; +import type { WorkerRequest, WorkerResponse } from "./protocol"; self.onmessage = (event: MessageEvent) => { - void handle(event.data); + void respond(event.data); }; -async function handle(request: WorkerRequest): Promise { - try { - const tool = getTool(request.toolId); - if (!tool?.run) { - throw new ToolError("errors.noImageRun"); - } - const source: PixelImage | undefined = request.source - ? { - width: request.source.width, - height: request.source.height, - data: new Uint8ClampedArray(request.source.data), - } - : undefined; - const output = await tool.run({ - params: sanitizeSchemaParams(tool.schema, request.params, { source }), - source, - text: request.text, - }); - if (typeof output === "string") { - (self as unknown as Worker).postMessage({ - id: request.id, - ok: true, - text: output, - } satisfies WorkerResponse); - return; - } - if ("key" in output) { - (self as unknown as Worker).postMessage({ - id: request.id, - ok: true, - text: output.key, - textVars: output.vars, - } satisfies WorkerResponse); - return; - } - if ("files" in output) { - const files = output.files.map((file) => ({ - name: file.name, - width: file.image.width, - height: file.image.height, - data: file.image.data, - })); - (self as unknown as Worker).postMessage( - { id: request.id, ok: true, files } satisfies WorkerResponse, - files.map((file) => file.data.buffer), - ); - return; - } - (self as unknown as Worker).postMessage( - { - id: request.id, - ok: true, - width: output.width, - height: output.height, - data: output.data, - } satisfies WorkerResponse, - [output.data.buffer], - ); - } catch (e) { - const toolError = e instanceof ToolError ? e : undefined; - (self as unknown as Worker).postMessage({ - id: request.id, - ok: false, - error: e instanceof Error ? e.message : String(e), - errorKey: toolError?.key, - errorVars: toolError?.vars, - } satisfies WorkerResponse); - } +async function respond(request: WorkerRequest): Promise { + const result = await handleWorkerRequest(request); + (self as unknown as Worker).postMessage(result.response, result.transfer); } + +export type { WorkerRequest, WorkerResponse }; diff --git a/web/src/lib/executor/protocol.ts b/web/src/lib/executor/protocol.ts new file mode 100644 index 0000000..bbd0b39 --- /dev/null +++ b/web/src/lib/executor/protocol.ts @@ -0,0 +1,161 @@ +import { ToolError, type ErrorVars } from "../core/errors"; +import type { FileResult, ToolResult } from "../registry"; + +export type WorkerImage = { + width: number; + height: number; + data: Uint8ClampedArray; +}; + +export type WorkerFile = { + name: string; + width: number; + height: number; + data: Uint8ClampedArray; +}; + +export type WorkerRequest = { + id: number; + toolId: string; + params: Record; + source?: WorkerImage; + text?: string; +}; + +export type WorkerResponse = + | { + id: number; + ok: true; + width?: number; + height?: number; + data?: Uint8ClampedArray; + text?: string; + textVars?: ErrorVars; + files?: WorkerFile[]; + } + | { + id: number; + ok: false; + error?: string; + errorKey?: string; + errorVars?: ErrorVars; + }; + +export type WorkerResult = { + response: WorkerResponse; + transfer: Transferable[]; +}; + +function transferables(...values: Uint8ClampedArray[]): Transferable[] { + return values.map((value) => value.buffer as ArrayBuffer); +} + +export function isWorkerResponse(value: unknown): value is WorkerResponse { + if (typeof value !== "object" || value === null) return false; + const candidate = value as { id?: unknown; ok?: unknown }; + return typeof candidate.id === "number" && typeof candidate.ok === "boolean"; +} + +export function encodeWorkerResponse( + id: number, + result: ToolResult, +): WorkerResult { + if (typeof result === "string") { + return { response: { id, ok: true, text: result }, transfer: [] }; + } + if ("key" in result) { + return { + response: { id, ok: true, text: result.key, textVars: result.vars }, + transfer: [], + }; + } + if ("files" in result) { + const files = result.files.map((file) => ({ + name: file.name, + width: file.image.width, + height: file.image.height, + data: file.image.data, + })); + return { + response: { id, ok: true, files }, + transfer: transferables(...files.map((file) => file.data)), + }; + } + return { + response: { + id, + ok: true, + width: result.width, + height: result.height, + data: result.data, + }, + transfer: transferables(result.data), + }; +} + +export function encodeWorkerError(id: number, error: unknown): WorkerResult { + const toolError = error instanceof ToolError ? error : undefined; + return { + response: { + id, + ok: false, + error: error instanceof Error ? error.message : String(error), + errorKey: toolError?.key, + errorVars: toolError?.vars, + }, + transfer: [], + }; +} + +function isWorkerFile(value: WorkerFile): boolean { + return ( + typeof value.name === "string" && + typeof value.width === "number" && + typeof value.height === "number" && + value.data instanceof Uint8ClampedArray + ); +} + +export function decodeWorkerResponse(payload: WorkerResponse): ToolResult { + if (payload.ok) { + if ( + typeof payload.width === "number" && + typeof payload.height === "number" && + payload.data instanceof Uint8ClampedArray + ) { + return { + width: payload.width, + height: payload.height, + data: new Uint8ClampedArray(payload.data), + }; + } + if (Array.isArray(payload.files)) { + if (!payload.files.every(isWorkerFile)) { + throw new Error("Invalid worker response"); + } + return { + files: payload.files.map((file) => ({ + name: file.name, + image: { + width: file.width, + height: file.height, + data: new Uint8ClampedArray(file.data), + }, + })), + } satisfies FileResult; + } + if (typeof payload.text === "string") { + return payload.textVars + ? { key: payload.text, vars: payload.textVars } + : payload.text; + } + throw new Error("Invalid worker response"); + } + if (payload.errorKey) { + throw new ToolError(payload.errorKey, payload.errorVars); + } + if (payload.error?.startsWith("errors.")) { + throw new ToolError(payload.error); + } + throw new Error(payload.error ?? "Invalid worker response"); +} diff --git a/web/src/lib/executor/worker-handler.ts b/web/src/lib/executor/worker-handler.ts new file mode 100644 index 0000000..22a2171 --- /dev/null +++ b/web/src/lib/executor/worker-handler.ts @@ -0,0 +1,36 @@ +import { ToolError } from "../core/errors"; +import type { PixelImage } from "../core/types"; +import { getTool } from "../registry"; +import { sanitizeSchemaParams } from "../registry-schema"; +import { + encodeWorkerError, + encodeWorkerResponse, + type WorkerRequest, + type WorkerResult, +} from "./protocol"; + +export async function handleWorkerRequest( + request: WorkerRequest, +): Promise { + try { + const tool = getTool(request.toolId); + if (!tool?.run) { + throw new ToolError("errors.noImageRun"); + } + const source: PixelImage | undefined = request.source + ? { + width: request.source.width, + height: request.source.height, + data: new Uint8ClampedArray(request.source.data), + } + : undefined; + const output = await tool.run({ + params: sanitizeSchemaParams(tool.schema, request.params, { source }), + source, + text: request.text, + }); + return encodeWorkerResponse(request.id, output); + } catch (error) { + return encodeWorkerError(request.id, error); + } +} diff --git a/web/src/lib/registry/convert.ts b/web/src/lib/registry/convert.ts index dd96b75..b826063 100644 --- a/web/src/lib/registry/convert.ts +++ b/web/src/lib/registry/convert.ts @@ -105,6 +105,7 @@ const pngToBase64: ToolEntry = { schema: emptySchema, input: "image", result: "text", + domOnly: true, run: imgTool((img) => toBase64(img)), }; @@ -117,6 +118,7 @@ const pngToDataUri: ToolEntry = { schema: emptySchema, input: "image", result: "text", + domOnly: true, run: imgTool((img) => toDataUrl(img)), }; @@ -163,6 +165,7 @@ const base64ToPng: ToolEntry = { category: "convert", schema: emptySchema, input: "text", + domOnly: true, run: textGen((text) => decodeBytes(base64ToBytes(stripDataUri(text)))), }; @@ -173,6 +176,7 @@ const dataUriToPng: ToolEntry = { category: "convert", schema: emptySchema, input: "text", + domOnly: true, run: textGen((text) => decodeBytes(base64ToBytes(stripDataUri(text)))), }; diff --git a/web/src/lib/registry/filters.ts b/web/src/lib/registry/filters.ts index 2ac8150..e4c57c9 100644 --- a/web/src/lib/registry/filters.ts +++ b/web/src/lib/registry/filters.ts @@ -253,6 +253,7 @@ const jpegArtifacts: ToolEntry = { category: "filters", schema: jpegArtifactsSchema, input: "image", + domOnly: true, run: imgTool((img, p) => jpegRoundtrip(img, p.quality)), }; diff --git a/web/src/lib/registry/generate.ts b/web/src/lib/registry/generate.ts index bc18bef..b38ff75 100644 --- a/web/src/lib/registry/generate.ts +++ b/web/src/lib/registry/generate.ts @@ -487,6 +487,7 @@ const placeholder: ToolEntry = { category: "generate", schema: placeholderSchema, input: "none", + domOnly: true, run: genTool((p) => { const w = Math.trunc(p.size.width); const h = Math.trunc(p.size.height); diff --git a/web/src/lib/registry/registry.test.ts b/web/src/lib/registry/registry.test.ts index 94391c8..25015fa 100644 --- a/web/src/lib/registry/registry.test.ts +++ b/web/src/lib/registry/registry.test.ts @@ -65,6 +65,19 @@ describe("registry-new (переведённые инструменты)", () => } }); + it("DOM-dependent tools bypass the worker", () => { + for (const id of [ + "png-to-base64", + "png-to-data-uri", + "base64-to-png", + "data-uri-to-png", + "jpeg-artifacts-png", + "placeholder-png", + ]) { + expect(TOOLS.find((tool) => tool.id === id)?.domOnly, id).toBe(true); + } + }); + it("source-aware dimension defaults применяются только к image tools", () => { for (const tool of TOOLS) { for (const field of Object.values(tool.schema.fields)) {