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 инструмента;
|
- production registry содержит 122 инструмента;
|
||||||
- `pnpm --dir web check` проходит без ошибок и предупреждений;
|
- `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 test:rules` — 4 test files / 74 tests;
|
||||||
- `pnpm --dir web lint:all` — 0 errors и 0 i18n warnings; остаются 4 unused
|
- `pnpm --dir web lint:all` — 0 errors и 0 i18n warnings; остаются 4 unused
|
||||||
design tokens от token audit;
|
design tokens от token audit;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# План: линтеры, тесты и качество кодовой базы
|
# План: линтеры, тесты и качество кодовой базы
|
||||||
|
|
||||||
> Статус: **draft — Q0–Q5 готовы к ревью; Q6 — следующий этап**.
|
> Статус: **draft — Q0–Q5 и Q6a готовы к ревью; Q6b — следующий этап**.
|
||||||
>
|
>
|
||||||
> Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и
|
> Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и
|
||||||
> инфраструктурных скриптов. Q0–Q5 выполнены; Q6–Q8 выполняются отдельными
|
> инфраструктурных скриптов. Q0–Q5 и Q6a выполнены; Q6b–Q8 выполняются
|
||||||
> атомарными шагами.
|
> отдельными атомарными шагами.
|
||||||
>
|
>
|
||||||
> Цель: сделать качество кодовой базы воспроизводимым, убрать ложную «зелёность»
|
> Цель: сделать качество кодовой базы воспроизводимым, убрать ложную «зелёность»
|
||||||
> проверок, стабилизировать тестовые контракты и только после этого расширять
|
> проверок, стабилизировать тестовые контракты и только после этого расширять
|
||||||
@@ -189,30 +189,41 @@ Q6.
|
|||||||
- `ToolError` хранится как key/vars, переводится derived-значением при выводе и
|
- `ToolError` хранится как key/vars, переводится derived-значением при выводе и
|
||||||
обновляется при смене локали. E2E использует независимый invalid pixel-count
|
обновляется при смене локали. E2E использует независимый invalid pixel-count
|
||||||
сценарий.
|
сценарий.
|
||||||
- Resize/crop, generator, Reset и localization fixme сняты; Q6 начинает
|
- Resize/crop, generator, Reset и localization fixme сняты; Q6a расширяет
|
||||||
расширение покрытия, а не исправление этих контрактов.
|
покрытие, не смешиваясь с исправлением этих контрактов.
|
||||||
|
|
||||||
**Результат:** Q5 known issues закрыты и отражены в `docs/backlog.md` и
|
**Результат:** Q5 known issues закрыты и отражены в `docs/backlog.md` и
|
||||||
`docs/checklist-manual-testing.md`; следующий этап — Q6.
|
`docs/checklist-manual-testing.md`; Q6a выполнен, следующий этап — Q6b.
|
||||||
|
|
||||||
### Q6. Расширение unit и browser-покрытия
|
### Q6. Расширение unit и browser-покрытия
|
||||||
|
|
||||||
**Оценка:** M–L. **Зависимости:** Q4, Q5.
|
**Оценка:** M–L. **Зависимости:** Q4, Q5.
|
||||||
|
|
||||||
Сначала:
|
#### Q6a. Executor protocol, fallback и debounce
|
||||||
|
|
||||||
- executor и worker/fallback;
|
**Статус:** выполнен. Unit-тесты покрывают debounce, worker image/string/
|
||||||
- `FileResult`, ZIP и download;
|
verdict/files protocol, `ToolError` transport, constructor/postMessage/onerror/
|
||||||
- `debounce` и auto-run;
|
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;
|
- output MIME/quality;
|
||||||
- malformed и special PNG fixtures: CRC, truncated, palette, 16-bit;
|
- malformed и special PNG fixtures: CRC, truncated, palette, 16-bit;
|
||||||
- smoke всех типов инструментов из registry.
|
- smoke всех типов инструментов из registry;
|
||||||
|
|
||||||
Затем отдельно:
|
|
||||||
|
|
||||||
- component-тесты `SchemaToolView`, `SchemaPreview` и schema fields;
|
- component-тесты `SchemaToolView`, `SchemaPreview` и schema fields;
|
||||||
- browser matrix, mobile viewport и visual snapshots;
|
- browser matrix, mobile viewport, visual snapshots и coverage thresholds.
|
||||||
- coverage thresholds.
|
|
||||||
|
|
||||||
Не применять один одинаковый smoke-сценарий к image-, text-, generator- и
|
Не применять один одинаковый smoke-сценарий к image-, text-, generator- и
|
||||||
file-result-инструментам.
|
file-result-инструментам.
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ helpers, а не повторять raw-селекторы.
|
|||||||
|
|
||||||
`test.fixme` допускается только для зарегистрированной проблемы в backlog или
|
`test.fixme` допускается только для зарегистрированной проблемы в backlog или
|
||||||
checklist. В комментарии указывается ссылка на неё. После исправления тест и
|
checklist. В комментарии указывается ссылка на неё. После исправления тест и
|
||||||
ссылка обновляются вместе; в текущем Q5 baseline активных fixme нет.
|
ссылка обновляются вместе; в текущем Q6a baseline активных fixme нет.
|
||||||
|
|
||||||
## 6. Проверки
|
## 6. Проверки
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,10 @@ test.describe("generators", () => {
|
|||||||
await expect(resultImage(page)).toBeVisible();
|
await expect(resultImage(page)).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Generate" })).toHaveCount(0);
|
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 page.getByRole("spinbutton", { name: "Width" }).fill("32");
|
||||||
await expect(resultImage(page)).not.toHaveAttribute(
|
await page.getByRole("spinbutton", { name: "Height" }).fill("24");
|
||||||
"src",
|
await expect(resultImage(page)).toHaveJSProperty("naturalWidth", 32);
|
||||||
firstSource ?? "",
|
await expect(resultImage(page)).toHaveJSProperty("naturalHeight", 24);
|
||||||
);
|
|
||||||
await expectNoErrorAlert(page);
|
await expectNoErrorAlert(page);
|
||||||
expectNoErrors(sink);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+154
-169
@@ -1,184 +1,169 @@
|
|||||||
import { ToolError } from "$lib/core/errors";
|
import { ToolError } from "../core/errors";
|
||||||
import type { PixelImage } from "$lib/core/types";
|
import type { PixelImage } from "../core/types";
|
||||||
import type { ToolContext, ToolEntry, ToolResult } from "$lib/registry";
|
import type { ToolContext, ToolEntry, ToolResult } from "../registry";
|
||||||
import { sanitizeSchemaParams } from "$lib/registry-schema";
|
import { sanitizeSchemaParams } from "../registry-schema";
|
||||||
|
import {
|
||||||
|
decodeWorkerResponse,
|
||||||
|
isWorkerResponse,
|
||||||
|
type WorkerRequest,
|
||||||
|
type WorkerResponse,
|
||||||
|
} from "./protocol";
|
||||||
|
|
||||||
/** Вход единой точки исполнения. `source`/`text` — по контракту `tool.input`. */
|
|
||||||
export type ExecuteContext = {
|
export type ExecuteContext = {
|
||||||
params: Record<string, unknown>;
|
params: Record<string, unknown>;
|
||||||
source?: PixelImage;
|
source?: PixelImage;
|
||||||
text?: string;
|
text?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export type WorkerLike = {
|
||||||
* Единственная точка исполнения инструмента. Валидирует наличие входа по
|
postMessage(message: WorkerRequest): void;
|
||||||
* `tool.input`, санитит параметры и запускает `tool.run`. Выбор места
|
terminate(): void;
|
||||||
* исполнения (worker/поток) — внутренняя забота executor'а, диспетча методов
|
onmessage: ((event: MessageEvent<WorkerResponse>) => void) | null;
|
||||||
* нет: у любого инструмента один `run(ctx)`.
|
onerror: ((event: Event) => void) | null;
|
||||||
*/
|
};
|
||||||
export async function execute(
|
|
||||||
tool: ToolEntry,
|
export type WorkerFactory = () => WorkerLike;
|
||||||
ctx: ExecuteContext,
|
|
||||||
): Promise<ToolResult> {
|
type PendingRequest = {
|
||||||
const params = sanitizeSchemaParams(tool.schema, ctx.params, {
|
resolve: (result: ToolResult) => void;
|
||||||
source: ctx.source,
|
reject: (error: unknown) => void;
|
||||||
});
|
};
|
||||||
if (tool.input === "image" && !ctx.source) {
|
|
||||||
throw new ToolError("errors.sourceRequired");
|
function defaultWorkerFactory(): WorkerLike {
|
||||||
}
|
return new Worker(new URL("./executor.worker.ts", import.meta.url), {
|
||||||
if (tool.input === "text" && !ctx.text?.trim()) {
|
type: "module",
|
||||||
throw new ToolError("errors.textRequired");
|
}) as unknown as WorkerLike;
|
||||||
}
|
|
||||||
return route(tool, { params, source: ctx.source, text: ctx.text });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function createExecutor(
|
||||||
* Маршрутизация по способу исполнения. В worker уходит всё, кроме
|
options: { workerFactory?: WorkerFactory } = {},
|
||||||
* domOnly-инструментов: даже текстовые вердикты могут быть тяжёлыми
|
): (tool: ToolEntry, ctx: ExecuteContext) => Promise<ToolResult> {
|
||||||
* (гистограммы, сложный анализ), поэтому ограничивать по типу результата
|
const workerFactory = options.workerFactory ?? defaultWorkerFactory;
|
||||||
* неверно. Ограничение одно — доступ к DOM, которого в worker нет.
|
let worker: WorkerLike | null = null;
|
||||||
*/
|
let workerTried = false;
|
||||||
async function route(
|
let nextRequestId = 1;
|
||||||
tool: ToolEntry,
|
const pending = new Map<number, PendingRequest>();
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runDirect(
|
function failWorker(candidate: WorkerLike, error: unknown): void {
|
||||||
tool: ToolEntry,
|
if (worker === candidate) worker = null;
|
||||||
ctx: ExecuteContext,
|
candidate.terminate();
|
||||||
): Promise<ToolResult> {
|
for (const [id, entry] of pending) {
|
||||||
return await tool.run(ctx as ToolContext<Record<string, unknown>>);
|
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 {
|
|
||||||
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<string, string | number>;
|
|
||||||
error?: string;
|
|
||||||
errorKey?: string;
|
|
||||||
errorVars?: Record<string, string | number>;
|
|
||||||
};
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
return worker;
|
|
||||||
}
|
|
||||||
|
|
||||||
function disableWorker(): void {
|
function ensureWorker(): WorkerLike | null {
|
||||||
if (worker) {
|
if (workerTried) return worker;
|
||||||
worker.terminate();
|
workerTried = true;
|
||||||
}
|
let candidate: WorkerLike | undefined;
|
||||||
worker = null;
|
try {
|
||||||
}
|
candidate = workerFactory();
|
||||||
|
candidate.onmessage = (event) => {
|
||||||
function runInWorker(
|
const payload: unknown = event.data;
|
||||||
workerInstance: Worker,
|
if (!isWorkerResponse(payload)) {
|
||||||
tool: ToolEntry,
|
failWorker(
|
||||||
ctx: ExecuteContext,
|
candidate as WorkerLike,
|
||||||
): Promise<ToolResult> {
|
new Error("Invalid worker response"),
|
||||||
return new Promise((resolve, reject) => {
|
);
|
||||||
const id = nextRequestId++;
|
return;
|
||||||
pending.set(id, { resolve, reject });
|
}
|
||||||
workerInstance.postMessage({
|
const entry = pending.get(payload.id);
|
||||||
id,
|
if (!entry) return;
|
||||||
toolId: tool.id,
|
try {
|
||||||
params: ctx.params,
|
const result = decodeWorkerResponse(payload);
|
||||||
source: ctx.source
|
pending.delete(payload.id);
|
||||||
? {
|
entry.resolve(result);
|
||||||
width: ctx.source.width,
|
} catch (error) {
|
||||||
height: ctx.source.height,
|
if (error instanceof ToolError) {
|
||||||
data: new Uint8ClampedArray(ctx.source.data),
|
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<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 });
|
||||||
|
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<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" />
|
/// <reference lib="webworker" />
|
||||||
import { ToolError } from "$lib/core/errors";
|
import { handleWorkerRequest } from "./worker-handler";
|
||||||
import type { PixelImage } from "$lib/core/types";
|
import type { WorkerRequest, WorkerResponse } from "./protocol";
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
||||||
void handle(event.data);
|
void respond(event.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
async function handle(request: WorkerRequest): Promise<void> {
|
async function respond(request: WorkerRequest): Promise<void> {
|
||||||
try {
|
const result = await handleWorkerRequest(request);
|
||||||
const tool = getTool(request.toolId);
|
(self as unknown as Worker).postMessage(result.response, result.transfer);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
schema: emptySchema,
|
||||||
input: "image",
|
input: "image",
|
||||||
result: "text",
|
result: "text",
|
||||||
|
domOnly: true,
|
||||||
run: imgTool((img) => toBase64(img)),
|
run: imgTool((img) => toBase64(img)),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -117,6 +118,7 @@ const pngToDataUri: ToolEntry<NoParams> = {
|
|||||||
schema: emptySchema,
|
schema: emptySchema,
|
||||||
input: "image",
|
input: "image",
|
||||||
result: "text",
|
result: "text",
|
||||||
|
domOnly: true,
|
||||||
run: imgTool((img) => toDataUrl(img)),
|
run: imgTool((img) => toDataUrl(img)),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -163,6 +165,7 @@ const base64ToPng: ToolEntry<NoParams> = {
|
|||||||
category: "convert",
|
category: "convert",
|
||||||
schema: emptySchema,
|
schema: emptySchema,
|
||||||
input: "text",
|
input: "text",
|
||||||
|
domOnly: true,
|
||||||
run: textGen((text) => decodeBytes(base64ToBytes(stripDataUri(text)))),
|
run: textGen((text) => decodeBytes(base64ToBytes(stripDataUri(text)))),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -173,6 +176,7 @@ const dataUriToPng: ToolEntry<NoParams> = {
|
|||||||
category: "convert",
|
category: "convert",
|
||||||
schema: emptySchema,
|
schema: emptySchema,
|
||||||
input: "text",
|
input: "text",
|
||||||
|
domOnly: true,
|
||||||
run: textGen((text) => decodeBytes(base64ToBytes(stripDataUri(text)))),
|
run: textGen((text) => decodeBytes(base64ToBytes(stripDataUri(text)))),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ const jpegArtifacts: ToolEntry<JpegArtifactsParams> = {
|
|||||||
category: "filters",
|
category: "filters",
|
||||||
schema: jpegArtifactsSchema,
|
schema: jpegArtifactsSchema,
|
||||||
input: "image",
|
input: "image",
|
||||||
|
domOnly: true,
|
||||||
run: imgTool((img, p) => jpegRoundtrip(img, p.quality)),
|
run: imgTool((img, p) => jpegRoundtrip(img, p.quality)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -487,6 +487,7 @@ const placeholder: ToolEntry<PlaceholderParams> = {
|
|||||||
category: "generate",
|
category: "generate",
|
||||||
schema: placeholderSchema,
|
schema: placeholderSchema,
|
||||||
input: "none",
|
input: "none",
|
||||||
|
domOnly: true,
|
||||||
run: genTool((p) => {
|
run: genTool((p) => {
|
||||||
const w = Math.trunc(p.size.width);
|
const w = Math.trunc(p.size.width);
|
||||||
const h = Math.trunc(p.size.height);
|
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", () => {
|
it("source-aware dimension defaults применяются только к image tools", () => {
|
||||||
for (const tool of TOOLS) {
|
for (const tool of TOOLS) {
|
||||||
for (const field of Object.values(tool.schema.fields)) {
|
for (const field of Object.values(tool.schema.fields)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user