mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: upscale times out behind Cloudflare Tunnel due to blocking HTTP request
The upscale route held the HTTP connection open for the full duration of Python sidecar processing (30-300s). Behind proxies with connection timeouts (Cloudflare Tunnel: 100s), this caused HTTP 524 errors. The route now returns 202 Accepted immediately after upload validation and processes in the background. The result (downloadUrl, sizes, etc.) is delivered via the existing SSE progress channel. The frontend detects the 202 and waits for the SSE completion event instead of reading the XHR response body. A reconnect-safe completion store ensures results survive brief SSE disconnects. Closes #106
This commit is contained in:
@@ -32,11 +32,15 @@ export interface SingleFileProgress {
|
||||
stage?: string;
|
||||
percent: number;
|
||||
error?: string;
|
||||
result?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** In-memory store of job progress, keyed by jobId. */
|
||||
const jobProgressStore = new Map<string, JobProgress>();
|
||||
|
||||
/** Terminal single-file events kept for SSE reconnect replay. */
|
||||
const singleFileCompletions = new Map<string, SingleFileProgress>();
|
||||
|
||||
/** SSE listeners waiting for updates, keyed by jobId. */
|
||||
const listeners = new Map<string, Set<(data: JobProgress | SingleFileProgress) => void>>();
|
||||
|
||||
@@ -183,6 +187,16 @@ export function updateJobProgress(progress: JobProgress): void {
|
||||
export function updateSingleFileProgress(progress: Omit<SingleFileProgress, "type">): void {
|
||||
const event: SingleFileProgress = { ...progress, type: "single" };
|
||||
persistSingleFileProgress(progress);
|
||||
|
||||
if (progress.phase === "complete" || progress.phase === "failed") {
|
||||
if (singleFileCompletions.size >= 10_000) {
|
||||
const oldest = singleFileCompletions.keys().next().value;
|
||||
if (oldest) singleFileCompletions.delete(oldest);
|
||||
}
|
||||
singleFileCompletions.set(progress.jobId, event);
|
||||
setTimeout(() => singleFileCompletions.delete(progress.jobId), 120_000);
|
||||
}
|
||||
|
||||
const subs = listeners.get(progress.jobId);
|
||||
if (subs) {
|
||||
for (const cb of subs) {
|
||||
@@ -228,6 +242,13 @@ export async function registerProgressRoutes(app: FastifyInstance): Promise<void
|
||||
}
|
||||
}
|
||||
|
||||
const existingSingle = singleFileCompletions.get(jobId);
|
||||
if (existingSingle) {
|
||||
sendEvent(existingSingle);
|
||||
reply.raw.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Subscribe to updates
|
||||
if (!listeners.has(jobId)) {
|
||||
listeners.set(jobId, new Set());
|
||||
|
||||
@@ -81,36 +81,32 @@ export function registerUpscale(app: FastifyInstance) {
|
||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||
}
|
||||
|
||||
let settings: z.infer<typeof settingsSchema>;
|
||||
try {
|
||||
let settings: z.infer<typeof settingsSchema>;
|
||||
try {
|
||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||
const result = settingsSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||
const result = settingsSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||
}
|
||||
|
||||
const scale = settings.scale;
|
||||
const model = settings.model;
|
||||
const faceEnhance = settings.faceEnhance;
|
||||
const denoise = settings.denoise;
|
||||
let format = settings.format;
|
||||
const outputQuality = settings.quality;
|
||||
const scale = settings.scale;
|
||||
const model = settings.model;
|
||||
const faceEnhance = settings.faceEnhance;
|
||||
const denoise = settings.denoise;
|
||||
let format = settings.format;
|
||||
const outputQuality = settings.quality;
|
||||
|
||||
try {
|
||||
if (format === "auto") {
|
||||
const detected = await resolveOutputFormat(fileBuffer, filename);
|
||||
format = detected.format === "jpeg" ? "jpg" : detected.format;
|
||||
}
|
||||
request.log.info(
|
||||
{ toolId: "upscale", imageSize: fileBuffer.length, scale, model, format },
|
||||
"Starting upscale",
|
||||
);
|
||||
|
||||
// Decode HEIC/HEIF input via system decoder
|
||||
if (validation.format === "heif") {
|
||||
@@ -124,33 +120,54 @@ export function registerUpscale(app: FastifyInstance) {
|
||||
|
||||
// Auto-orient to fix EXIF rotation before upscaling
|
||||
fileBuffer = await autoOrient(fileBuffer);
|
||||
} catch (err) {
|
||||
request.log.error({ err, toolId: "upscale" }, "Input decoding failed");
|
||||
return reply.status(422).send({
|
||||
error: "Upscaling failed",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
const jobId = randomUUID();
|
||||
const workspacePath = await createWorkspace(jobId);
|
||||
|
||||
// Save input
|
||||
const originalSize = fileBuffer.length;
|
||||
const jobId = randomUUID();
|
||||
const progressJobId = clientJobId || jobId;
|
||||
let workspacePath: string;
|
||||
try {
|
||||
workspacePath = await createWorkspace(jobId);
|
||||
const inputPath = join(workspacePath, "input", filename);
|
||||
await writeFile(inputPath, fileBuffer);
|
||||
} catch (err) {
|
||||
request.log.error({ err, toolId: "upscale" }, "Workspace creation failed");
|
||||
return reply.status(422).send({
|
||||
error: "Upscaling failed",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
|
||||
// Determine which format the Python sidecar should produce.
|
||||
// Formats that need Node.js-side conversion (HEIC/HEIF via heif-enc,
|
||||
// AVIF via Sharp) are produced as PNG first, then converted below.
|
||||
const needsNodeConversion = ["heic", "heif", "avif"].includes(format);
|
||||
const pythonFormat = needsNodeConversion ? "png" : format;
|
||||
const log = request.log;
|
||||
log.info(
|
||||
{ toolId: "upscale", imageSize: originalSize, scale, model, format },
|
||||
"Starting upscale",
|
||||
);
|
||||
|
||||
// Process
|
||||
const jobIdForProgress = clientJobId;
|
||||
const onProgress = jobIdForProgress
|
||||
? (percent: number, stage: string) => {
|
||||
updateSingleFileProgress({
|
||||
jobId: jobIdForProgress,
|
||||
phase: "processing",
|
||||
stage,
|
||||
percent,
|
||||
});
|
||||
}
|
||||
: undefined;
|
||||
// Reply immediately so the HTTP connection closes within proxy timeout limits.
|
||||
// The result will be delivered via the SSE progress channel.
|
||||
reply.status(202).send({ jobId: progressJobId, async: true });
|
||||
|
||||
const needsNodeConversion = ["heic", "heif", "avif"].includes(format);
|
||||
const pythonFormat = needsNodeConversion ? "png" : format;
|
||||
|
||||
const onProgress = (percent: number, stage: string) => {
|
||||
updateSingleFileProgress({
|
||||
jobId: progressJobId,
|
||||
phase: "processing",
|
||||
stage,
|
||||
percent,
|
||||
});
|
||||
};
|
||||
|
||||
// Fire-and-forget: processing happens after the response is sent
|
||||
(async () => {
|
||||
const result = await upscale(
|
||||
fileBuffer,
|
||||
join(workspacePath, "output"),
|
||||
@@ -158,7 +175,6 @@ export function registerUpscale(app: FastifyInstance) {
|
||||
onProgress,
|
||||
);
|
||||
|
||||
// Convert to final format if needed (HEIC/HEIF/AVIF)
|
||||
let outputBuffer = result.buffer;
|
||||
let finalFormat = result.format;
|
||||
if (needsNodeConversion) {
|
||||
@@ -171,7 +187,6 @@ export function registerUpscale(app: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
// Save output with correct extension for the chosen format
|
||||
const EXT_MAP: Record<string, string> = {
|
||||
jpeg: "jpg",
|
||||
jpg: "jpg",
|
||||
@@ -188,12 +203,10 @@ export function registerUpscale(app: FastifyInstance) {
|
||||
const outputPath = join(workspacePath, "output", outputFilename);
|
||||
await writeFile(outputPath, outputBuffer);
|
||||
|
||||
// Generate browser-compatible preview for non-previewable formats
|
||||
const BROWSER_PREVIEWABLE = new Set(["png", "jpg", "jpeg", "webp", "gif", "avif", "bmp"]);
|
||||
let previewUrl: string | undefined;
|
||||
if (!BROWSER_PREVIEWABLE.has(finalFormat)) {
|
||||
try {
|
||||
// For HEIC/HEIF, decode first since Sharp can't read HEVC
|
||||
const previewInput =
|
||||
finalFormat === "heic" || finalFormat === "heif"
|
||||
? await decodeHeic(outputBuffer)
|
||||
@@ -203,42 +216,44 @@ export function registerUpscale(app: FastifyInstance) {
|
||||
await writeFile(previewPath, previewBuffer);
|
||||
previewUrl = `/api/v1/download/${jobId}/preview.webp`;
|
||||
} catch {
|
||||
// Non-fatal - frontend will show fallback
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
if (clientJobId) {
|
||||
updateSingleFileProgress({
|
||||
jobId: clientJobId,
|
||||
phase: "complete",
|
||||
percent: 100,
|
||||
});
|
||||
}
|
||||
|
||||
if (model !== "auto" && result.method !== model) {
|
||||
request.log.warn(
|
||||
log.warn(
|
||||
{ toolId: "upscale", requested: model, actual: result.method },
|
||||
`Upscale model mismatch: requested ${model} but used ${result.method}`,
|
||||
);
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
|
||||
previewUrl,
|
||||
originalSize: fileBuffer.length,
|
||||
processedSize: outputBuffer.length,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
method: result.method,
|
||||
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
|
||||
updateSingleFileProgress({
|
||||
jobId: progressJobId,
|
||||
phase: "complete",
|
||||
percent: 100,
|
||||
result: {
|
||||
jobId,
|
||||
downloadUrl,
|
||||
previewUrl,
|
||||
originalSize,
|
||||
processedSize: outputBuffer.length,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
method: result.method,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
request.log.error({ err, toolId: "upscale" }, "Upscaling failed");
|
||||
return reply.status(422).send({
|
||||
error: "Upscaling failed",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
|
||||
log.info({ toolId: "upscale", jobId, downloadUrl }, "Upscale complete");
|
||||
})().catch((err) => {
|
||||
log.error({ err, toolId: "upscale" }, "Upscaling failed");
|
||||
updateSingleFileProgress({
|
||||
jobId: progressJobId,
|
||||
phase: "failed",
|
||||
percent: 0,
|
||||
error: err instanceof Error ? err.message : "Upscale failed",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Register in the pipeline/batch registry so this tool can be used
|
||||
|
||||
Reference in New Issue
Block a user