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 { createRedisConnection, sharedRedis } from "./connection.js";
import { getQueue } from "./queues.js";
import { bullPrefix, POOLS, type Pool } from "./types.js";
import { bullPrefix, POOLS } from "./types.js";
// ── Per-worker cancel registry ──────────────────────────────────
+6
View File
@@ -4,6 +4,7 @@
* Uses ioredis with settings compatible with BullMQ's requirements
* (maxRetriesPerRequest: null for blocking commands).
*/
import type { ConnectionOptions } from "bullmq";
import Redis from "ioredis";
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;
/**
+4 -4
View File
@@ -10,9 +10,9 @@ import { FlowProducer, type Job, QueueEvents } from "bullmq";
import { eq } from "drizzle-orm";
import { env } from "../config.js";
import { db, schema } from "../db/index.js";
import { createRedisConnection } from "./connection.js";
import { createBullMQConnection } from "./connection.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) ────────────────────────────
@@ -22,7 +22,7 @@ function getQueueEvents(pool: Pool): QueueEvents {
let qe = queueEventsMap.get(pool);
if (!qe) {
qe = new QueueEvents(queueName(pool), {
connection: createRedisConnection(),
connection: createBullMQConnection(),
});
queueEventsMap.set(pool, qe);
}
@@ -42,7 +42,7 @@ let _flowProducer: FlowProducer | null = null;
export function getFlowProducer(): FlowProducer {
if (!_flowProducer) {
_flowProducer = new FlowProducer({
connection: createRedisConnection(),
connection: createBullMQConnection(),
});
}
return _flowProducer;
+2 -2
View File
@@ -5,7 +5,7 @@
* default job options (retry policy, TTL-based cleanup).
*/
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";
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);
if (!q) {
q = new Queue<ToolJobData, ToolJobResult>(queueName(pool), {
connection: createRedisConnection(),
connection: createBullMQConnection(),
defaultJobOptions: {
attempts: pool === "ai" ? 1 : 2,
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;
}
export async function runSiemForward(): Promise<{ forwarded: number } | void> {
export async function runSiemForward(): Promise<{ forwarded: number } | undefined> {
// 1. Read SIEM config
const config = await readSiemConfig();
if (!config || !config.enabled || !config.webhookUrl) {
if (!config?.enabled || !config.webhookUrl) {
return;
}
+10 -8
View File
@@ -43,8 +43,8 @@ import {
import { hasAiJobHandler, runAiToolJob } from "./ai-handlers.js";
import { recordChildOutcome } from "./batch-progress.js";
import { registerCancelable, unregisterCancelable } from "./cancel.js";
import { createRedisConnection } from "./connection.js";
import { autoSaveToLibrary, buildOutputName, generatePreview } from "./postprocess.js";
import { createBullMQConnection } from "./connection.js";
import { buildOutputName, generatePreview } from "./postprocess.js";
import { runSystemJob } from "./system-jobs.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
// the legacy download URL /api/v1/download/<pipelineJobId>/... works.
const lastOutputBuffer = await getObjectBuffer(lastOutputRef);
const outFilename = lastOutputRef.split("/").pop()!;
const outFilename = lastOutputRef.split("/").pop() ?? "output";
const parentKey = `outputs/${data.jobId}/${outFilename}`;
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".
*/
async function processBatchChild(job: Job<ToolJobData>): Promise<ToolJobResult> {
const parentId = job.data.parentId ?? "";
const totalFiles = job.data.totalFiles ?? 0;
try {
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;
} catch (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 {
outputRefs: [],
@@ -687,7 +689,7 @@ async function processBatchFinalize(job: Job<ToolJobData>): Promise<ToolJobResul
}
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] });
} else {
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, {
connection: createRedisConnection(),
connection: createBullMQConnection(),
concurrency: workerConcurrency,
stalledInterval: 30_000,
});
@@ -754,7 +756,7 @@ export function startWorkers(): void {
};
const worker = new Worker<ToolJobData, ToolJobResult>(queueName(pool), processor, {
connection: createRedisConnection(),
connection: createBullMQConnection(),
concurrency: workerConcurrency,
stalledInterval: 30_000,
});
+14 -14
View File
@@ -1,25 +1,25 @@
import sharp from "sharp";
type SharpFormat = keyof sharp.FormatEnum | "avif";
export interface OutputFormat {
format: keyof sharp.FormatEnum;
format: SharpFormat;
extension: string;
contentType: string;
quality: number;
}
const FORMAT_MAP: Record<
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" },
webp: { format: "webp", extension: "webp", contentType: "image/webp" },
gif: { format: "gif", extension: "gif", contentType: "image/gif" },
tiff: { format: "tiff", extension: "tiff", contentType: "image/tiff" },
avif: { format: "avif", extension: "avif", contentType: "image/avif" },
heif: { format: "avif", extension: "avif", contentType: "image/avif" },
jxl: { format: "jxl" as keyof sharp.FormatEnum, extension: "jxl", contentType: "image/jxl" },
};
const FORMAT_MAP: Record<string, { format: SharpFormat; extension: string; contentType: string }> =
{
jpeg: { format: "jpeg", extension: "jpg", contentType: "image/jpeg" },
png: { format: "png", extension: "png", contentType: "image/png" },
webp: { format: "webp", extension: "webp", contentType: "image/webp" },
gif: { format: "gif", extension: "gif", contentType: "image/gif" },
tiff: { format: "tiff", extension: "tiff", contentType: "image/tiff" },
avif: { format: "avif", extension: "avif", contentType: "image/avif" },
heif: { format: "avif", extension: "avif", contentType: "image/avif" },
jxl: { format: "jxl", extension: "jxl", contentType: "image/jxl" },
};
const DEFAULT_QUALITY = 95;
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" };
if (authHeader) headers["Authorization"] = authHeader;
if (authHeader) headers.Authorization = authHeader;
let lastError: string | undefined;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
+1 -1
View File
@@ -142,7 +142,7 @@ export function registerCompose(app: FastifyInstance) {
input: processedOverlay,
top: settings.y,
left: settings.x,
blend: settings.blendMode as import("sharp").Blend,
blend: settings.blendMode as sharp.Blend,
},
])
.toBuffer();
+1 -3
View File
@@ -120,9 +120,7 @@ export function registerConvert(app: FastifyInstance) {
}
const inputExt = extname(filename).toLowerCase().replace(".", "");
const sharpOpts: import("sharp").SharpOptions = isSvgBuffer(inputBuffer)
? { density: 300 }
: {};
const sharpOpts: sharp.SharpOptions = isSvgBuffer(inputBuffer) ? { density: 300 } : {};
// Preserve animation frames when both input and output are animatable formats
if (ANIMATABLE_FORMATS.has(inputExt) && ANIMATABLE_FORMATS.has(settings.format)) {
sharpOpts.animated = true;
+4 -2
View File
@@ -24,14 +24,16 @@ const settingsSchema = z.object({
quality: z.number().min(1).max(100).default(90),
});
type SharpFormat = keyof sharp.FormatEnum | "avif";
function resolveOutputFormat(
outputFormat: string,
originalExt: string,
): { sharpFormat: keyof sharp.FormatEnum | null; ext: string } {
): { sharpFormat: SharpFormat | null; ext: string } {
if (outputFormat === "original") {
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" },
jpg: { sharpFormat: "jpeg", ext: ".jpg" },
webp: { sharpFormat: "webp", ext: ".webp" },
+1 -1
View File
@@ -209,7 +209,7 @@ export function registerStripMetadata(app: FastifyInstance) {
case "webp":
result.webp({ quality: 85 });
break;
case "avif":
case "heif":
result.avif({ quality: 50 });
break;
case "tiff":
@@ -37,10 +37,7 @@ export function AppLayout({ children, breadcrumb, navVariant }: AppLayoutProps)
/>
{/* Main content area */}
<main
id="main-content"
className={cn("flex-1 overflow-y-auto", isMobile && "pb-20")}
>
<main id="main-content" className={cn("flex-1 overflow-y-auto", isMobile && "pb-20")}>
{children}
</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);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
cachedRaw = null;
listeners.forEach((l) => l());
for (const l of listeners) l();
}
export function useRecentTools(): string[] {
+1 -1
View File
@@ -109,7 +109,7 @@ export async function processImage(
if (!sharpFormat) {
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();
@@ -31,7 +31,7 @@ export async function compress(image: Sharp, options: CompressOptions): Promise<
const safeDetected = NO_ENCODER.has(detected) ? "png" : detected;
const outputFormat = (FORMAT_MAP[format ?? ""] ??
FORMAT_MAP[safeDetected] ??
safeDetected) as keyof import("sharp").FormatEnum;
safeDetected) as keyof sharp.FormatEnum;
if (targetSizeBytes !== undefined) {
if (targetSizeBytes <= 0) {
@@ -52,7 +52,7 @@ export async function compress(image: Sharp, options: CompressOptions): Promise<
async function findBestQuality(
inputBuffer: Buffer,
resize: { width: number; height: number } | null,
format: keyof import("sharp").FormatEnum,
format: keyof sharp.FormatEnum,
targetBytes: number,
): Promise<number | null> {
let low = 1;
@@ -82,7 +82,7 @@ async function findBestQuality(
async function compressToTargetSize(
inputBuffer: Buffer,
format: keyof import("sharp").FormatEnum,
format: keyof sharp.FormatEnum,
targetBytes: number,
): Promise<Sharp> {
const quality = await findBestQuality(inputBuffer, null, format, targetBytes);
@@ -1,3 +1,4 @@
import type sharp from "sharp";
import type { ConvertOptions, Sharp } from "../types.js";
/**
@@ -31,5 +32,5 @@ export async function convert(image: Sharp, options: ConvertOptions): Promise<Sh
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";
export const CATEGORIES: CategoryInfo[] = [