mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
refactor: executor protocol, fallback, debounce
This commit is contained in:
+3
-3
@@ -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;
|
||||
|
||||
@@ -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-инструментам.
|
||||
|
||||
@@ -66,7 +66,7 @@ helpers, а не повторять raw-селекторы.
|
||||
|
||||
`test.fixme` допускается только для зарегистрированной проблемы в backlog или
|
||||
checklist. В комментарии указывается ссылка на неё. После исправления тест и
|
||||
ссылка обновляются вместе; в текущем Q5 baseline активных fixme нет.
|
||||
ссылка обновляются вместе; в текущем Q6a baseline активных fixme нет.
|
||||
|
||||
## 6. Проверки
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<WorkerResponse>) => 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<WorkerResponse>);
|
||||
}
|
||||
|
||||
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<Record<string, never>>({});
|
||||
|
||||
function makeTool(
|
||||
run: ToolEntry["run"] = () => "direct",
|
||||
overrides: Partial<ToolEntry> = {},
|
||||
): 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<WorkerFactory>();
|
||||
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<WorkerFactory>(() => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
+121
-136
@@ -1,173 +1,123 @@
|
||||
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<string, unknown>;
|
||||
source?: PixelImage;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Единственная точка исполнения инструмента. Валидирует наличие входа по
|
||||
* `tool.input`, санитит параметры и запускает `tool.run`. Выбор места
|
||||
* исполнения (worker/поток) — внутренняя забота executor'а, диспетча методов
|
||||
* нет: у любого инструмента один `run(ctx)`.
|
||||
*/
|
||||
export async function execute(
|
||||
tool: ToolEntry,
|
||||
ctx: ExecuteContext,
|
||||
): Promise<ToolResult> {
|
||||
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<WorkerResponse>) => 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<ToolResult> {
|
||||
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<ToolResult> {
|
||||
const workerFactory = options.workerFactory ?? defaultWorkerFactory;
|
||||
let worker: WorkerLike | null = null;
|
||||
let workerTried = false;
|
||||
let nextRequestId = 1;
|
||||
const pending = new Map<number, PendingRequest>();
|
||||
|
||||
async function runDirect(
|
||||
tool: ToolEntry,
|
||||
ctx: ExecuteContext,
|
||||
): Promise<ToolResult> {
|
||||
return await tool.run(ctx as ToolContext<Record<string, unknown>>);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
function ensureWorker(): WorkerLike | null {
|
||||
if (workerTried) return worker;
|
||||
workerTried = true;
|
||||
let candidate: WorkerLike | undefined;
|
||||
try {
|
||||
const candidate = new Worker(
|
||||
new URL("./executor.worker.ts", import.meta.url),
|
||||
{
|
||||
type: "module",
|
||||
},
|
||||
candidate = workerFactory();
|
||||
candidate.onmessage = (event) => {
|
||||
const payload: unknown = event.data;
|
||||
if (!isWorkerResponse(payload)) {
|
||||
failWorker(
|
||||
candidate as WorkerLike,
|
||||
new Error("Invalid worker response"),
|
||||
);
|
||||
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<string, string | number>;
|
||||
error?: string;
|
||||
errorKey?: string;
|
||||
errorVars?: Record<string, string | number>;
|
||||
};
|
||||
return;
|
||||
}
|
||||
const entry = pending.get(payload.id);
|
||||
if (!entry) return;
|
||||
try {
|
||||
const result = decodeWorkerResponse(payload);
|
||||
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));
|
||||
entry.resolve(result);
|
||||
} catch (error) {
|
||||
if (error instanceof ToolError) {
|
||||
pending.delete(payload.id);
|
||||
entry.reject(error);
|
||||
} else {
|
||||
entry.reject(
|
||||
payload.error
|
||||
? new Error(payload.error)
|
||||
: new ToolError("errors.workerFailed"),
|
||||
);
|
||||
failWorker(candidate as WorkerLike, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
candidate.onerror = () => {
|
||||
worker = null;
|
||||
for (const entry of pending.values()) {
|
||||
entry.reject(new ToolError("errors.workerUnavailable"));
|
||||
}
|
||||
pending.clear();
|
||||
failWorker(candidate as WorkerLike, new Error("Worker error"));
|
||||
};
|
||||
worker = candidate;
|
||||
} catch {
|
||||
return candidate;
|
||||
} catch (error) {
|
||||
candidate?.terminate();
|
||||
worker = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return worker;
|
||||
}
|
||||
|
||||
function disableWorker(): void {
|
||||
if (worker) {
|
||||
worker.terminate();
|
||||
}
|
||||
function disableWorker(candidate: WorkerLike): void {
|
||||
if (worker !== candidate) return;
|
||||
worker = null;
|
||||
}
|
||||
candidate.terminate();
|
||||
}
|
||||
|
||||
function runInWorker(
|
||||
workerInstance: Worker,
|
||||
async function runDirect(
|
||||
tool: ToolEntry,
|
||||
ctx: ExecuteContext,
|
||||
): Promise<ToolResult> {
|
||||
): Promise<ToolResult> {
|
||||
return await tool.run(ctx as ToolContext<Record<string, unknown>>);
|
||||
}
|
||||
|
||||
function runInWorker(
|
||||
candidate: WorkerLike,
|
||||
tool: ToolEntry,
|
||||
ctx: ExecuteContext,
|
||||
): Promise<ToolResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = nextRequestId++;
|
||||
pending.set(id, { resolve, reject });
|
||||
workerInstance.postMessage({
|
||||
try {
|
||||
candidate.postMessage({
|
||||
id,
|
||||
toolId: tool.id,
|
||||
params: ctx.params,
|
||||
@@ -180,5 +130,40 @@ function runInWorker(
|
||||
: undefined,
|
||||
text: ctx.text,
|
||||
});
|
||||
} catch (error) {
|
||||
failWorker(candidate, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function route(
|
||||
tool: ToolEntry,
|
||||
ctx: ExecuteContext,
|
||||
): Promise<ToolResult> {
|
||||
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();
|
||||
|
||||
@@ -1,113 +1,14 @@
|
||||
/// <reference lib="webworker" />
|
||||
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<string, unknown>;
|
||||
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<string, string | number>;
|
||||
files?: WorkerImageFile[];
|
||||
}
|
||||
| {
|
||||
id: number;
|
||||
ok: false;
|
||||
error?: string;
|
||||
errorKey?: string;
|
||||
errorVars?: Record<string, string | number>;
|
||||
};
|
||||
|
||||
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<WorkerRequest>) => {
|
||||
void handle(event.data);
|
||||
void respond(event.data);
|
||||
};
|
||||
|
||||
async function handle(request: WorkerRequest): Promise<void> {
|
||||
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<void> {
|
||||
const result = await handleWorkerRequest(request);
|
||||
(self as unknown as Worker).postMessage(result.response, result.transfer);
|
||||
}
|
||||
|
||||
export type { WorkerRequest, WorkerResponse };
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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");
|
||||
}
|
||||
@@ -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<WorkerResult> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,7 @@ const pngToBase64: ToolEntry<NoParams> = {
|
||||
schema: emptySchema,
|
||||
input: "image",
|
||||
result: "text",
|
||||
domOnly: true,
|
||||
run: imgTool((img) => toBase64(img)),
|
||||
};
|
||||
|
||||
@@ -117,6 +118,7 @@ const pngToDataUri: ToolEntry<NoParams> = {
|
||||
schema: emptySchema,
|
||||
input: "image",
|
||||
result: "text",
|
||||
domOnly: true,
|
||||
run: imgTool((img) => toDataUrl(img)),
|
||||
};
|
||||
|
||||
@@ -163,6 +165,7 @@ const base64ToPng: ToolEntry<NoParams> = {
|
||||
category: "convert",
|
||||
schema: emptySchema,
|
||||
input: "text",
|
||||
domOnly: true,
|
||||
run: textGen((text) => decodeBytes(base64ToBytes(stripDataUri(text)))),
|
||||
};
|
||||
|
||||
@@ -173,6 +176,7 @@ const dataUriToPng: ToolEntry<NoParams> = {
|
||||
category: "convert",
|
||||
schema: emptySchema,
|
||||
input: "text",
|
||||
domOnly: true,
|
||||
run: textGen((text) => decodeBytes(base64ToBytes(stripDataUri(text)))),
|
||||
};
|
||||
|
||||
|
||||
@@ -253,6 +253,7 @@ const jpegArtifacts: ToolEntry<JpegArtifactsParams> = {
|
||||
category: "filters",
|
||||
schema: jpegArtifactsSchema,
|
||||
input: "image",
|
||||
domOnly: true,
|
||||
run: imgTool((img, p) => jpegRoundtrip(img, p.quality)),
|
||||
};
|
||||
|
||||
|
||||
@@ -487,6 +487,7 @@ const placeholder: ToolEntry<PlaceholderParams> = {
|
||||
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);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user