fix: resolve Sharp 0.35.1 and BullMQ type incompatibilities after dep bumps

Sharp 0.35.1 moved FormatEnum to a namespace export and removed "avif"
from FormatEnum (now a separate literal in toFormat). BullMQ 5.78.1
bundles ioredis 5.10.1 while we have 5.11.1, causing structural type
mismatch. Also fixes new Biome 1.9 lint rules.
This commit is contained in:
SnapOtter
2026-06-15 15:20:16 +08:00
parent 9a61cb6af1
commit b76dc68682
18 changed files with 62 additions and 50 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ import type Redis from "ioredis";
import { db, schema } from "../db/index.js"; import { db, schema } from "../db/index.js";
import { createRedisConnection, sharedRedis } from "./connection.js"; import { createRedisConnection, sharedRedis } from "./connection.js";
import { getQueue } from "./queues.js"; import { getQueue } from "./queues.js";
import { bullPrefix, POOLS, type Pool } from "./types.js"; import { bullPrefix, POOLS } from "./types.js";
// ── Per-worker cancel registry ────────────────────────────────── // ── Per-worker cancel registry ──────────────────────────────────
+6
View File
@@ -4,6 +4,7 @@
* Uses ioredis with settings compatible with BullMQ's requirements * Uses ioredis with settings compatible with BullMQ's requirements
* (maxRetriesPerRequest: null for blocking commands). * (maxRetriesPerRequest: null for blocking commands).
*/ */
import type { ConnectionOptions } from "bullmq";
import Redis from "ioredis"; import Redis from "ioredis";
import { env } from "../config.js"; import { env } from "../config.js";
@@ -19,6 +20,11 @@ export function createRedisConnection(): Redis {
}); });
} }
// ioredis 5.11 vs BullMQ's bundled 5.10 type mismatch
export function createBullMQConnection(): ConnectionOptions {
return createRedisConnection() as unknown as ConnectionOptions;
}
let _shared: Redis | null = null; let _shared: Redis | null = null;
/** /**
+4 -4
View File
@@ -10,9 +10,9 @@ import { FlowProducer, type Job, QueueEvents } from "bullmq";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { env } from "../config.js"; import { env } from "../config.js";
import { db, schema } from "../db/index.js"; import { db, schema } from "../db/index.js";
import { createRedisConnection } from "./connection.js"; import { createBullMQConnection } from "./connection.js";
import { getQueue } from "./queues.js"; import { getQueue } from "./queues.js";
import { POOLS, type Pool, queueName, type ToolJobData, type ToolJobResult } from "./types.js"; import { type Pool, queueName, type ToolJobData, type ToolJobResult } from "./types.js";
// ── QueueEvents (one per pool, lazy) ──────────────────────────── // ── QueueEvents (one per pool, lazy) ────────────────────────────
@@ -22,7 +22,7 @@ function getQueueEvents(pool: Pool): QueueEvents {
let qe = queueEventsMap.get(pool); let qe = queueEventsMap.get(pool);
if (!qe) { if (!qe) {
qe = new QueueEvents(queueName(pool), { qe = new QueueEvents(queueName(pool), {
connection: createRedisConnection(), connection: createBullMQConnection(),
}); });
queueEventsMap.set(pool, qe); queueEventsMap.set(pool, qe);
} }
@@ -42,7 +42,7 @@ let _flowProducer: FlowProducer | null = null;
export function getFlowProducer(): FlowProducer { export function getFlowProducer(): FlowProducer {
if (!_flowProducer) { if (!_flowProducer) {
_flowProducer = new FlowProducer({ _flowProducer = new FlowProducer({
connection: createRedisConnection(), connection: createBullMQConnection(),
}); });
} }
return _flowProducer; return _flowProducer;
+2 -2
View File
@@ -5,7 +5,7 @@
* default job options (retry policy, TTL-based cleanup). * default job options (retry policy, TTL-based cleanup).
*/ */
import { Queue } from "bullmq"; import { Queue } from "bullmq";
import { createRedisConnection } from "./connection.js"; import { createBullMQConnection } from "./connection.js";
import { POOLS, type Pool, queueName, type ToolJobData, type ToolJobResult } from "./types.js"; import { POOLS, type Pool, queueName, type ToolJobData, type ToolJobResult } from "./types.js";
const queues = new Map<Pool, Queue<ToolJobData, ToolJobResult>>(); const queues = new Map<Pool, Queue<ToolJobData, ToolJobResult>>();
@@ -15,7 +15,7 @@ export function getQueue(pool: Pool): Queue<ToolJobData, ToolJobResult> {
let q = queues.get(pool); let q = queues.get(pool);
if (!q) { if (!q) {
q = new Queue<ToolJobData, ToolJobResult>(queueName(pool), { q = new Queue<ToolJobData, ToolJobResult>(queueName(pool), {
connection: createRedisConnection(), connection: createBullMQConnection(),
defaultJobOptions: { defaultJobOptions: {
attempts: pool === "ai" ? 1 : 2, attempts: pool === "ai" ? 1 : 2,
backoff: { type: "exponential", delay: 1000 }, backoff: { type: "exponential", delay: 1000 },
+2 -2
View File
@@ -35,10 +35,10 @@ async function readSettingValue(key: string): Promise<string | null> {
return row?.value ?? null; return row?.value ?? null;
} }
export async function runSiemForward(): Promise<{ forwarded: number } | void> { export async function runSiemForward(): Promise<{ forwarded: number } | undefined> {
// 1. Read SIEM config // 1. Read SIEM config
const config = await readSiemConfig(); const config = await readSiemConfig();
if (!config || !config.enabled || !config.webhookUrl) { if (!config?.enabled || !config.webhookUrl) {
return; return;
} }
+10 -8
View File
@@ -43,8 +43,8 @@ import {
import { hasAiJobHandler, runAiToolJob } from "./ai-handlers.js"; import { hasAiJobHandler, runAiToolJob } from "./ai-handlers.js";
import { recordChildOutcome } from "./batch-progress.js"; import { recordChildOutcome } from "./batch-progress.js";
import { registerCancelable, unregisterCancelable } from "./cancel.js"; import { registerCancelable, unregisterCancelable } from "./cancel.js";
import { createRedisConnection } from "./connection.js"; import { createBullMQConnection } from "./connection.js";
import { autoSaveToLibrary, buildOutputName, generatePreview } from "./postprocess.js"; import { buildOutputName, generatePreview } from "./postprocess.js";
import { runSystemJob } from "./system-jobs.js"; import { runSystemJob } from "./system-jobs.js";
import { POOLS, type Pool, queueName, type ToolJobData, type ToolJobResult } from "./types.js"; import { POOLS, type Pool, queueName, type ToolJobData, type ToolJobResult } from "./types.js";
@@ -578,7 +578,7 @@ async function processPipelineFinalize(job: Job<ToolJobData>): Promise<ToolJobRe
// Copy last step's output to outputs/<pipelineJobId>/<filename> so // Copy last step's output to outputs/<pipelineJobId>/<filename> so
// the legacy download URL /api/v1/download/<pipelineJobId>/... works. // the legacy download URL /api/v1/download/<pipelineJobId>/... works.
const lastOutputBuffer = await getObjectBuffer(lastOutputRef); const lastOutputBuffer = await getObjectBuffer(lastOutputRef);
const outFilename = lastOutputRef.split("/").pop()!; const outFilename = lastOutputRef.split("/").pop() ?? "output";
const parentKey = `outputs/${data.jobId}/${outFilename}`; const parentKey = `outputs/${data.jobId}/${outFilename}`;
await putObject(parentKey, lastOutputBuffer); await putObject(parentKey, lastOutputBuffer);
@@ -636,13 +636,15 @@ async function processPipelineFinalize(job: Job<ToolJobData>): Promise<ToolJobRe
* failures would skip the DB write and leave the row "processing". * failures would skip the DB write and leave the row "processing".
*/ */
async function processBatchChild(job: Job<ToolJobData>): Promise<ToolJobResult> { async function processBatchChild(job: Job<ToolJobData>): Promise<ToolJobResult> {
const parentId = job.data.parentId ?? "";
const totalFiles = job.data.totalFiles ?? 0;
try { try {
const result = await processToolJob(job); const result = await processToolJob(job);
await recordChildOutcome(job.data.parentId!, job.data.totalFiles!, job.data.filename); await recordChildOutcome(parentId, totalFiles, job.data.filename);
return result; return result;
} catch (err) { } catch (err) {
const error = stripInternalPaths(err instanceof Error ? err.message : String(err)); const error = stripInternalPaths(err instanceof Error ? err.message : String(err));
await recordChildOutcome(job.data.parentId!, job.data.totalFiles!, job.data.filename, error); await recordChildOutcome(parentId, totalFiles, job.data.filename, error);
// Return a completed job with a failure marker so the parent runs. // Return a completed job with a failure marker so the parent runs.
return { return {
outputRefs: [], outputRefs: [],
@@ -687,7 +689,7 @@ async function processBatchFinalize(job: Job<ToolJobData>): Promise<ToolJobResul
} }
if (row.status === "completed" && row.outputRefs?.[0]) { if (row.status === "completed" && row.outputRefs?.[0]) {
const outFilename = row.outputRefs[0].split("/").pop()!; const outFilename = row.outputRefs[0].split("/").pop() ?? "output";
manifest.push({ index: i, filename: outFilename, outputRef: row.outputRefs[0] }); manifest.push({ index: i, filename: outFilename, outputRef: row.outputRefs[0] });
} else { } else {
const errorMsg = (row.error as { message?: string } | null)?.message ?? "Processing failed"; const errorMsg = (row.error as { message?: string } | null)?.message ?? "Processing failed";
@@ -732,7 +734,7 @@ export function startWorkers(): void {
}; };
const worker = new Worker<ToolJobData, unknown>(queueName(pool), systemProcessor, { const worker = new Worker<ToolJobData, unknown>(queueName(pool), systemProcessor, {
connection: createRedisConnection(), connection: createBullMQConnection(),
concurrency: workerConcurrency, concurrency: workerConcurrency,
stalledInterval: 30_000, stalledInterval: 30_000,
}); });
@@ -754,7 +756,7 @@ export function startWorkers(): void {
}; };
const worker = new Worker<ToolJobData, ToolJobResult>(queueName(pool), processor, { const worker = new Worker<ToolJobData, ToolJobResult>(queueName(pool), processor, {
connection: createRedisConnection(), connection: createBullMQConnection(),
concurrency: workerConcurrency, concurrency: workerConcurrency,
stalledInterval: 30_000, stalledInterval: 30_000,
}); });
+14 -14
View File
@@ -1,25 +1,25 @@
import sharp from "sharp"; import sharp from "sharp";
type SharpFormat = keyof sharp.FormatEnum | "avif";
export interface OutputFormat { export interface OutputFormat {
format: keyof sharp.FormatEnum; format: SharpFormat;
extension: string; extension: string;
contentType: string; contentType: string;
quality: number; quality: number;
} }
const FORMAT_MAP: Record< const FORMAT_MAP: Record<string, { format: SharpFormat; extension: string; contentType: string }> =
string, {
{ format: keyof sharp.FormatEnum; extension: string; contentType: string } jpeg: { format: "jpeg", extension: "jpg", contentType: "image/jpeg" },
> = { png: { format: "png", extension: "png", contentType: "image/png" },
jpeg: { format: "jpeg", extension: "jpg", contentType: "image/jpeg" }, webp: { format: "webp", extension: "webp", contentType: "image/webp" },
png: { format: "png", extension: "png", contentType: "image/png" }, gif: { format: "gif", extension: "gif", contentType: "image/gif" },
webp: { format: "webp", extension: "webp", contentType: "image/webp" }, tiff: { format: "tiff", extension: "tiff", contentType: "image/tiff" },
gif: { format: "gif", extension: "gif", contentType: "image/gif" }, avif: { format: "avif", extension: "avif", contentType: "image/avif" },
tiff: { format: "tiff", extension: "tiff", contentType: "image/tiff" }, heif: { format: "avif", extension: "avif", contentType: "image/avif" },
avif: { format: "avif", extension: "avif", contentType: "image/avif" }, jxl: { format: "jxl", extension: "jxl", contentType: "image/jxl" },
heif: { format: "avif", extension: "avif", contentType: "image/avif" }, };
jxl: { format: "jxl" as keyof sharp.FormatEnum, extension: "jxl", contentType: "image/jxl" },
};
const DEFAULT_QUALITY = 95; const DEFAULT_QUALITY = 95;
const PNG_FALLBACK = FORMAT_MAP.png; const PNG_FALLBACK = FORMAT_MAP.png;
+1 -1
View File
@@ -26,7 +26,7 @@ export async function deliverWebhook(
}); });
const headers: Record<string, string> = { "Content-Type": "application/json" }; const headers: Record<string, string> = { "Content-Type": "application/json" };
if (authHeader) headers["Authorization"] = authHeader; if (authHeader) headers.Authorization = authHeader;
let lastError: string | undefined; let lastError: string | undefined;
for (let attempt = 0; attempt <= maxRetries; attempt++) { for (let attempt = 0; attempt <= maxRetries; attempt++) {
+1 -1
View File
@@ -142,7 +142,7 @@ export function registerCompose(app: FastifyInstance) {
input: processedOverlay, input: processedOverlay,
top: settings.y, top: settings.y,
left: settings.x, left: settings.x,
blend: settings.blendMode as import("sharp").Blend, blend: settings.blendMode as sharp.Blend,
}, },
]) ])
.toBuffer(); .toBuffer();
+1 -3
View File
@@ -120,9 +120,7 @@ export function registerConvert(app: FastifyInstance) {
} }
const inputExt = extname(filename).toLowerCase().replace(".", ""); const inputExt = extname(filename).toLowerCase().replace(".", "");
const sharpOpts: import("sharp").SharpOptions = isSvgBuffer(inputBuffer) const sharpOpts: sharp.SharpOptions = isSvgBuffer(inputBuffer) ? { density: 300 } : {};
? { density: 300 }
: {};
// Preserve animation frames when both input and output are animatable formats // Preserve animation frames when both input and output are animatable formats
if (ANIMATABLE_FORMATS.has(inputExt) && ANIMATABLE_FORMATS.has(settings.format)) { if (ANIMATABLE_FORMATS.has(inputExt) && ANIMATABLE_FORMATS.has(settings.format)) {
sharpOpts.animated = true; sharpOpts.animated = true;
+4 -2
View File
@@ -24,14 +24,16 @@ const settingsSchema = z.object({
quality: z.number().min(1).max(100).default(90), quality: z.number().min(1).max(100).default(90),
}); });
type SharpFormat = keyof sharp.FormatEnum | "avif";
function resolveOutputFormat( function resolveOutputFormat(
outputFormat: string, outputFormat: string,
originalExt: string, originalExt: string,
): { sharpFormat: keyof sharp.FormatEnum | null; ext: string } { ): { sharpFormat: SharpFormat | null; ext: string } {
if (outputFormat === "original") { if (outputFormat === "original") {
return { sharpFormat: null, ext: originalExt }; return { sharpFormat: null, ext: originalExt };
} }
const map: Record<string, { sharpFormat: keyof sharp.FormatEnum; ext: string }> = { const map: Record<string, { sharpFormat: SharpFormat; ext: string }> = {
png: { sharpFormat: "png", ext: ".png" }, png: { sharpFormat: "png", ext: ".png" },
jpg: { sharpFormat: "jpeg", ext: ".jpg" }, jpg: { sharpFormat: "jpeg", ext: ".jpg" },
webp: { sharpFormat: "webp", ext: ".webp" }, webp: { sharpFormat: "webp", ext: ".webp" },
+1 -1
View File
@@ -209,7 +209,7 @@ export function registerStripMetadata(app: FastifyInstance) {
case "webp": case "webp":
result.webp({ quality: 85 }); result.webp({ quality: 85 });
break; break;
case "avif": case "heif":
result.avif({ quality: 50 }); result.avif({ quality: 50 });
break; break;
case "tiff": case "tiff":
@@ -37,10 +37,7 @@ export function AppLayout({ children, breadcrumb, navVariant }: AppLayoutProps)
/> />
{/* Main content area */} {/* Main content area */}
<main <main id="main-content" className={cn("flex-1 overflow-y-auto", isMobile && "pb-20")}>
id="main-content"
className={cn("flex-1 overflow-y-auto", isMobile && "pb-20")}
>
{children} {children}
</main> </main>
+1 -1
View File
@@ -37,7 +37,7 @@ export function recordRecentTool(toolId: string) {
const updated = [toolId, ...current.filter((id) => id !== toolId)].slice(0, MAX_RECENT); const updated = [toolId, ...current.filter((id) => id !== toolId)].slice(0, MAX_RECENT);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
cachedRaw = null; cachedRaw = null;
listeners.forEach((l) => l()); for (const l of listeners) l();
} }
export function useRecentTools(): string[] { export function useRecentTools(): string[] {
+1 -1
View File
@@ -109,7 +109,7 @@ export async function processImage(
if (!sharpFormat) { if (!sharpFormat) {
throw new Error(`Unsupported output format: ${outputFormat}`); throw new Error(`Unsupported output format: ${outputFormat}`);
} }
image = image.toFormat(sharpFormat as keyof import("sharp").FormatEnum); image = image.toFormat(sharpFormat as keyof sharp.FormatEnum);
} }
const buffer = await image.toBuffer(); const buffer = await image.toBuffer();
@@ -31,7 +31,7 @@ export async function compress(image: Sharp, options: CompressOptions): Promise<
const safeDetected = NO_ENCODER.has(detected) ? "png" : detected; const safeDetected = NO_ENCODER.has(detected) ? "png" : detected;
const outputFormat = (FORMAT_MAP[format ?? ""] ?? const outputFormat = (FORMAT_MAP[format ?? ""] ??
FORMAT_MAP[safeDetected] ?? FORMAT_MAP[safeDetected] ??
safeDetected) as keyof import("sharp").FormatEnum; safeDetected) as keyof sharp.FormatEnum;
if (targetSizeBytes !== undefined) { if (targetSizeBytes !== undefined) {
if (targetSizeBytes <= 0) { if (targetSizeBytes <= 0) {
@@ -52,7 +52,7 @@ export async function compress(image: Sharp, options: CompressOptions): Promise<
async function findBestQuality( async function findBestQuality(
inputBuffer: Buffer, inputBuffer: Buffer,
resize: { width: number; height: number } | null, resize: { width: number; height: number } | null,
format: keyof import("sharp").FormatEnum, format: keyof sharp.FormatEnum,
targetBytes: number, targetBytes: number,
): Promise<number | null> { ): Promise<number | null> {
let low = 1; let low = 1;
@@ -82,7 +82,7 @@ async function findBestQuality(
async function compressToTargetSize( async function compressToTargetSize(
inputBuffer: Buffer, inputBuffer: Buffer,
format: keyof import("sharp").FormatEnum, format: keyof sharp.FormatEnum,
targetBytes: number, targetBytes: number,
): Promise<Sharp> { ): Promise<Sharp> {
const quality = await findBestQuality(inputBuffer, null, format, targetBytes); const quality = await findBestQuality(inputBuffer, null, format, targetBytes);
@@ -1,3 +1,4 @@
import type sharp from "sharp";
import type { ConvertOptions, Sharp } from "../types.js"; import type { ConvertOptions, Sharp } from "../types.js";
/** /**
@@ -31,5 +32,5 @@ export async function convert(image: Sharp, options: ConvertOptions): Promise<Sh
formatOptions.quality = quality; formatOptions.quality = quality;
} }
return image.toFormat(sharpFormat as keyof import("sharp").FormatEnum, formatOptions); return image.toFormat(sharpFormat as keyof sharp.FormatEnum, formatOptions);
} }
+7 -1
View File
@@ -1,4 +1,10 @@
import { AUDIO_INPUTS, IMAGE_INPUTS, MODALITY_URL_SLUG, SUBTITLE_INPUTS, VIDEO_INPUTS } from "./modality.js"; import {
AUDIO_INPUTS,
IMAGE_INPUTS,
MODALITY_URL_SLUG,
SUBTITLE_INPUTS,
VIDEO_INPUTS,
} from "./modality.js";
import type { CategoryInfo, SocialMediaPreset, Tool } from "./types.js"; import type { CategoryInfo, SocialMediaPreset, Tool } from "./types.js";
export const CATEGORIES: CategoryInfo[] = [ export const CATEGORIES: CategoryInfo[] = [