From aec7134ad344b9f0e4424278d2c882a8c4f77429 Mon Sep 17 00:00:00 2001 From: 134 Date: Mon, 19 Oct 2026 18:44:39 +0800 Subject: [PATCH] fix(canvas): prepare original downloads independently of archiving --- app/api/canvas/download-image/route.ts | 48 +++++ lib/canvas/download-preparation.ts | 146 +++++++++++++++ lib/canvas/photoshop-safe-download.ts | 41 +++-- lib/client/canvas-image-download.ts | 30 ++- lib/storage/image-byte-cache.ts | 16 +- .../routes/canvas-download-rate-limit.test.ts | 3 +- tests/routes/canvas-download-speed.test.ts | 171 +++++++++++++++++- 7 files changed, 429 insertions(+), 26 deletions(-) create mode 100644 lib/canvas/download-preparation.ts diff --git a/app/api/canvas/download-image/route.ts b/app/api/canvas/download-image/route.ts index fef65375b..9a299e7d6 100644 --- a/app/api/canvas/download-image/route.ts +++ b/app/api/canvas/download-image/route.ts @@ -2,6 +2,8 @@ import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download"; import { prepareImageArchiveDownload } from "@/lib/storage/image-download-preparation"; import { after, NextRequest, NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; +import { getDownloadPreparationQueue, type DownloadPreparationInput } from "@/lib/canvas/download-preparation"; +import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url"; import { buildAttachmentContentDisposition, fetchCanvasDownloadAsset, @@ -11,6 +13,31 @@ import { export const runtime = "nodejs"; export const maxDuration = 120; +async function prepareDownload(input: DownloadPreparationInput, signal: AbortSignal) { + // A proxy candidate for the same original must not repeat the same slow + // upstream request through our own server (and create another HTTP timeout). + const sources = [...new Set(input.urls.map(url => { + const source = extractImageProxySourceUrl(url) || url; + return inferOriginalImageUrl(source) || source; + }))]; + for (let attempt = 0; attempt < 3; attempt++) { + signal.throwIfAborted(); + for (const url of sources) { + signal.throwIfAborted(); + const result = await fetchCanvasDownloadAsset({ + file: { url, name: "original" }, index: 0, + requestOrigin: input.requestOrigin, cookie: input.cookie, + includeSequencePrefix: false, timeoutMs: 12_000, retryCount: 1, signal, + }); + if (result.ok) return { buffer: result.bytes, contentType: result.contentType }; + } + // Each pass resolves the latest archive URL inside fetchCanvasDownloadAsset. + // No AI/model request is involved and a completed source fetch is shared. + if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 1_000)); + } + throw new Error("Original image sources unavailable"); +} + function getRequestOrigin(request: NextRequest): string { const forwardedHost = (request.headers.get("x-forwarded-host") || "").split(",")[0]?.trim(); const forwardedProto = (request.headers.get("x-forwarded-proto") || "").split(",")[0]?.trim(); @@ -57,6 +84,7 @@ export async function POST(request: NextRequest) { const body = (await request.json().catch(() => ({}))) as Record; const requestOrigin = getRequestOrigin(request); const urls = resolveDownloadUrls(body, requestOrigin); + const requestedUrls = [...urls]; if (urls.length === 0) { return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 }); } @@ -71,6 +99,26 @@ export async function POST(request: NextRequest) { } } const cookie = request.headers.get("cookie") || ""; + if (body.preparation === "async") { + const result = await getDownloadPreparationQueue(prepareDownload, after).request({ + userId: session.user.id, urls: requestedUrls, requestOrigin, cookie, + }); + if (result.status === "preparing") { + return NextResponse.json({ delivery: "preparing", retryAfter: 3, + message: "正在准备原图,完成后将自动下载" }, { + status: 202, headers: { "Retry-After": "3", "Cache-Control": "no-store" }, + }); + } + if (result.status === "failed") { + return NextResponse.json({ success: false, error: result.error }, { status: 502 }); + } + return new NextResponse(new Uint8Array(result.asset.buffer), { headers: { + "Content-Type": result.asset.contentType, + "Content-Length": String(result.asset.buffer.length), + "Content-Disposition": buildAttachmentContentDisposition(`${name}.png`), + "Cache-Control": "no-store", + } }); + } let result: Awaited> | null = null; const failures: string[] = []; diff --git a/lib/canvas/download-preparation.ts b/lib/canvas/download-preparation.ts new file mode 100644 index 000000000..a77ce42be --- /dev/null +++ b/lib/canvas/download-preparation.ts @@ -0,0 +1,146 @@ +import "server-only"; +import { createHash } from "node:crypto"; +import { cacheImageBytes, readImageBytes, type ImageBytes } from "@/lib/storage/image-byte-cache"; + +export type DownloadPreparationInput = { + userId: string; + urls: string[]; + requestOrigin: string; + cookie: string; +}; +type Result = + | { status: "preparing" } + | { status: "ready"; asset: ImageBytes } + | { status: "failed"; error: string }; +type Job = { + userId: string; + input: DownloadPreparationInput; + status: "queued" | "running" | "ready" | "failed"; + updatedAt: number; + asset?: ImageBytes; +}; +const FAILURE_MESSAGE = "原图暂时无法读取,图片仍保留在画布中,请稍后重试"; +const MAX_MEMORY_BYTES = 128 * 1024 * 1024; + +// A download is independent of node autosave and of the archive worker's batch. +// Polls share one preparation; only bytes are cached, never cookies or signed URLs. +export function createDownloadPreparationQueue(deps: { + load: (input: DownloadPreparationInput, signal: AbortSignal) => Promise; + read: (identity: string) => Promise; + write: (identity: string, asset: ImageBytes) => Promise; + schedule: (run: () => Promise) => void; + now?: () => number; + timeoutMs?: number; + concurrency?: number; +}) { + const jobs = new Map(); + const now = deps.now || Date.now; + const concurrency = deps.concurrency || 4; + let active = 0; + + const prune = () => { + let bytes = 0; + for (const [key, job] of jobs) { + if ((job.status === "ready" && now() - job.updatedAt > 10 * 60_000) || + (job.status === "failed" && now() - job.updatedAt > 15_000)) jobs.delete(key); + else bytes += job.asset?.buffer.length || 0; + } + // Completed jobs are in insertion order; keep the newest fallback bytes if + // the optional disk cache is full. Never evict a running preparation. + for (const [key, job] of jobs) { + if (bytes <= MAX_MEMORY_BYTES) break; + if (job.status === "ready") { bytes -= job.asset?.buffer.length || 0; jobs.delete(key); } + } + }; + const drain = async (): Promise => { + const running: Promise[] = []; + for (const [key, job] of jobs) { + if (active >= concurrency) break; + if (job.status !== "queued") continue; + active++; + job.status = "running"; + running.push((async () => { + const controller = new AbortController(); + let timer: ReturnType | undefined; + try { + const asset = await Promise.race([ + deps.load(job.input, controller.signal), + new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject(new Error("Download preparation deadline")); + }, deps.timeoutMs || 90_000); + }), + ]); + if (asset.contentType !== "image/png" || !asset.buffer.length || asset.buffer.length > MAX_MEMORY_BYTES) { + throw new Error("Invalid prepared original"); + } + // Keep a bounded in-memory fallback until persistence is confirmed. + job.asset = asset; + await deps.write(key, asset).catch(() => undefined); + const stored = await deps.read(key).catch(() => null); + if (stored?.contentType === "image/png" && stored.buffer.length) job.asset = undefined; + job.status = "ready"; + } catch { + job.status = "failed"; + } finally { + if (timer) clearTimeout(timer); + // Do not retain authentication data after a preparation finishes. + job.input = { ...job.input, cookie: "", urls: [] }; + job.updatedAt = now(); + active--; + prune(); + } + })().then(() => drain())); + } + await Promise.all(running); + }; + + return { + async request(input: DownloadPreparationInput): Promise { + const key = "canvas-download:v2:" + createHash("sha256") + .update(JSON.stringify([input.userId, input.requestOrigin, input.cookie, input.urls])).digest("hex"); + const cached = await deps.read(key).catch(() => null); + if (cached?.contentType === "image/png") return { status: "ready", asset: cached }; + prune(); + const current = jobs.get(key); + if (current?.status === "ready" && current.asset) return { status: "ready", asset: current.asset }; + if (current?.status === "failed") return { status: "failed", error: FAILURE_MESSAGE }; + if (current && current.status !== "ready") return { status: "preparing" }; + if (jobs.size >= 64) { + for (const [oldKey, job] of jobs) { + if (job.status === "ready" || job.status === "failed") jobs.delete(oldKey); + if (jobs.size < 64) break; + } + } + if (jobs.size >= 64 || [...jobs.values()].filter(job => job.userId === input.userId && + (job.status === "queued" || job.status === "running")).length >= 4) { + return { status: "failed", error: "正在准备的原图较多,请等待当前下载完成后再试" }; + } + jobs.set(key, { userId: input.userId, input, status: "queued", updatedAt: now() }); + deps.schedule(drain); + return { status: "preparing" }; + }, + }; +} + +const state = globalThis as typeof globalThis & { + __canvasDownloadPreparationV2?: ReturnType; +}; + +export function getDownloadPreparationQueue(load: Parameters[0]["load"], + after: (run: () => Promise) => void) { + return (state.__canvasDownloadPreparationV2 ??= createDownloadPreparationQueue({ + load, + read: readImageBytes, + write: cacheImageBytes, + schedule(run) { + // Also run when invoked outside a live Next request. The queue itself + // deduplicates both callbacks and limits concurrent work. + const timer = setTimeout(() => { void run(); }, 1_000); + timer.unref?.(); + try { after(async () => { clearTimeout(timer); await run(); }); } + catch { clearTimeout(timer); setImmediate(() => { void run(); }); } + }, + })); +} diff --git a/lib/canvas/photoshop-safe-download.ts b/lib/canvas/photoshop-safe-download.ts index 018cb7e49..231e5b9bc 100644 --- a/lib/canvas/photoshop-safe-download.ts +++ b/lib/canvas/photoshop-safe-download.ts @@ -288,31 +288,35 @@ export async function fetchCanvasDownloadAsset(params: { includeSequencePrefix?: boolean; timeoutMs?: number; retryCount?: number; + signal?: AbortSignal; }): Promise { let lastError = ""; const timeoutMs = params.timeoutMs || DEFAULT_FETCH_TIMEOUT_MS; const retryCount = params.retryCount || DEFAULT_FETCH_RETRY_COUNT; - const candidateUrls = buildCanvasDownloadSourceCandidates( + const originalCandidates = buildCanvasDownloadSourceCandidates( params.file.url, params.requestOrigin ); - if (process.env.POSTGRES_URL) { - // ZIPs and binary fallback also reuse the durable prepared object after the - // local byte cache expires, instead of converting the original JPEG again. - const { findImageArchiveDownloadTask } = await import("@/lib/storage/image-archive-db"); - const task = await findImageArchiveDownloadTask(candidateUrls).catch(() => null); - const asset = (task?.metadata as { downloadAsset?: { status?: string; url?: string; contentType?: string } } | null)?.downloadAsset; - if (asset?.status === "ready" && asset.url && asset.contentType === "image/png") { - const readyUrl = resolveHttpUrl(asset.url, params.requestOrigin); - if (readyUrl && !candidateUrls.includes(readyUrl)) candidateUrls.unshift(readyUrl); - } else if (task?.targetUrl && !candidateUrls.includes(task.targetUrl)) { - candidateUrls.unshift(task.targetUrl); + for (let attempt = 1; attempt <= retryCount; attempt += 1) { + params.signal?.throwIfAborted(); + const candidateUrls = [...originalCandidates]; + if (process.env.POSTGRES_URL) { + // ZIPs and binary fallback also reuse the durable prepared object after the + // local byte cache expires, instead of converting the original JPEG again. + const { findImageArchiveDownloadTask } = await import("@/lib/storage/image-archive-db"); + const task = await findImageArchiveDownloadTask(candidateUrls).catch(() => null); + const asset = (task?.metadata as { downloadAsset?: { status?: string; url?: string; contentType?: string } } | null)?.downloadAsset; + if (asset?.status === "ready" && asset.url && asset.contentType === "image/png") { + const readyUrl = resolveHttpUrl(asset.url, params.requestOrigin); + if (readyUrl && !candidateUrls.includes(readyUrl)) candidateUrls.unshift(readyUrl); + } else if (task?.targetUrl && !candidateUrls.includes(task.targetUrl)) { + candidateUrls.unshift(task.targetUrl); + } } - } - for (const candidateUrl of candidateUrls) { - for (let attempt = 1; attempt <= retryCount; attempt += 1) { + for (const candidateUrl of candidateUrls) { + params.signal?.throwIfAborted(); try { const data = await fetchImageBytes(candidateUrl, { timeoutMs, @@ -329,12 +333,13 @@ export async function fetchCanvasDownloadAsset(params: { includeSequencePrefix: params.includeSequencePrefix !== false, }); } catch (error) { + params.signal?.throwIfAborted(); lastError = normalizeDownloadError(error); - if (attempt < retryCount) { - await sleep(350 * attempt); - } } } + // Archiving may have completed during the failed fetch. Resolve aliases + // again before retrying, rather than spending every attempt on the old URL. + if (attempt < retryCount) await sleep(350 * attempt); } return { diff --git a/lib/client/canvas-image-download.ts b/lib/client/canvas-image-download.ts index 9dbc894b9..0e948afad 100644 --- a/lib/client/canvas-image-download.ts +++ b/lib/client/canvas-image-download.ts @@ -19,6 +19,7 @@ export type CanvasImageDownloadResult = { type CanvasImageDownloadErrorCode = | "rate_limited" | "source_unavailable" + | "preparation_timeout" | "request_failed"; export class CanvasImageDownloadError extends Error { @@ -50,7 +51,10 @@ export function getCanvasImageDownloadErrorMessage(error: unknown) { : "图片下载请求过于频繁,请稍后重试"; } if (error.code === "source_unavailable") { - return `${error.message},源图可能已失效,请稍后重试或重新生成图片`; + return "原图暂时无法读取,图片仍保留在画布中,请稍后重试"; + } + if (error.code === "preparation_timeout") { + return "原图仍在后台准备中,请稍后再次点击下载"; } } return error instanceof Error ? error.message : "图片下载失败,请稍后重试"; @@ -248,7 +252,11 @@ export async function downloadCanvasImageAsPng({ let response: Response | null = null; let errorPayload: DownloadErrorPayload | null = null; - for (let attempt = 0; attempt <= MAX_RATE_LIMIT_RETRIES; attempt++) { + const preparationDeadline = Date.now() + 180_000; + let rateLimitRetries = 0; + let preparationPolls = 0; + for (;;) { + signal?.throwIfAborted(); response = await fetch("/api/canvas/download-image", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -257,10 +265,26 @@ export async function downloadCanvasImageAsPng({ urls, name: filenameBase, preferDirect: true, + preparation: "async", }), signal, }); + if (response.status === 202) { + rateLimitRetries = 0; + preparationPolls++; + const preparing = await response.json().catch(() => null); + if (preparing?.delivery !== "preparing") { + throw new CanvasImageDownloadError("下载准备状态无效", { code: "request_failed", status: 202 }); + } + if (Date.now() >= preparationDeadline || preparationPolls >= 60) { + throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202 }); + } + onProgress?.({ phase: "preparing", message: preparing.message || "正在准备原图,完成后将自动下载" }); + const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3)); + await waitForRetry(delaySeconds * 1000, signal); + continue; + } if (response.ok) break; errorPayload = (await response.json().catch(() => null)) as | DownloadErrorPayload @@ -268,7 +292,7 @@ export async function downloadCanvasImageAsPng({ if (response.status !== 429) break; const retryAfterSeconds = parseRetryAfterSeconds(response, errorPayload); - if (attempt >= MAX_RATE_LIMIT_RETRIES) { + if (rateLimitRetries++ >= MAX_RATE_LIMIT_RETRIES) { throw new CanvasImageDownloadError( errorPayload?.error || "图片下载请求过于频繁", { diff --git a/lib/storage/image-byte-cache.ts b/lib/storage/image-byte-cache.ts index bcc6b67b3..83b2e87d8 100644 --- a/lib/storage/image-byte-cache.ts +++ b/lib/storage/image-byte-cache.ts @@ -57,19 +57,29 @@ export async function readImageBytes(identity: string): Promise Promise): Promise { +export async function onceImageBytes(identity: string, load: () => Promise, validate?: (data: ImageBytes) => boolean): Promise { const existing = flights.get(identity); if (existing) return existing; const promise = (async () => { const cached = await readImageBytes(identity); - if (cached) return cached; + if (cached && (!validate || validate(cached))) return cached; const result = await load(); + if (validate && !validate(result)) throw new Error("Image source returned an error document instead of an image"); await cacheImageBytes(identity, result); return result; })(); flights.set(identity, promise); try { return await promise; } finally { if (flights.get(identity) === promise) flights.delete(identity); } } + +function isMediaResponse(data: ImageBytes) { + const type = data.contentType.split(";")[0].trim().toLowerCase(); + const prefix = data.buffer.subarray(0, 1024).toString("utf8").trimStart(); + if (type === "text/html" || type === "application/json" || type === "application/xhtml+xml") return false; + if (/^(?:]|]|[{[])/i.test(prefix)) return false; + if (/^<\?xml/i.test(prefix) && !/]/i.test(prefix)) return false; + return data.buffer.length > 0; +} export async function fetchImageBytes(url: string, options: { headers?: HeadersInit; timeoutMs?: number; scope?: string } = {}) { return onceImageBytes(`source:${options.scope || ""}:${url}`, async () => { if (url.startsWith("data:")) { @@ -86,5 +96,5 @@ export async function fetchImageBytes(url: string, options: { headers?: HeadersI const buffer = Buffer.from(await response.arrayBuffer()); if (!buffer.length) throw new Error("Downloaded image is empty"); return { buffer, contentType: response.headers.get("content-type") || "" }; - }); + }, isMediaResponse); } diff --git a/tests/routes/canvas-download-rate-limit.test.ts b/tests/routes/canvas-download-rate-limit.test.ts index 442e10193..52e0103a3 100644 --- a/tests/routes/canvas-download-rate-limit.test.ts +++ b/tests/routes/canvas-download-rate-limit.test.ts @@ -85,7 +85,8 @@ test.describe("canvas image download rate limiting", () => { expect(rateLimitMessage).toBe("图片下载请求过于频繁,请稍后重试"); expect(rateLimitMessage).not.toContain("源图"); - expect(sourceMessage).toContain("源图可能已失效"); + expect(sourceMessage).toContain("图片仍保留在画布中"); + expect(sourceMessage).not.toContain("重新生成"); }); test("uses the shared error formatter in every canvas image download entry", () => { diff --git a/tests/routes/canvas-download-speed.test.ts b/tests/routes/canvas-download-speed.test.ts index 5d3acdad0..1e25c5aad 100644 --- a/tests/routes/canvas-download-speed.test.ts +++ b/tests/routes/canvas-download-speed.test.ts @@ -40,6 +40,8 @@ function serverLoader(mocks: Record, env: Record, env: Record { + let requests = 0; + const bytes = await sharp({ create: { width: 25, height: 39, channels: 3, background: "red" } }).png().toBuffer(); + const module = serverLoader({ fetch: async () => { + requests++; + return requests === 1 ? new Response("upstream timeout", { headers: { "content-type": "image/png" } }) : + new Response(bytes, { headers: { "content-type": "image/png" } }); + } })("lib/storage/image-byte-cache.ts"); + const url = `https://provider.example/${crypto.randomUUID()}.png`; + await expect(module.fetchImageBytes(url)).rejects.toThrow("error document"); + expect((await module.fetchImageBytes(url)).buffer.equals(bytes)).toBe(true); + expect(requests).toBe(2); +}); test("compatible PNG remains byte-identical; JPEG becomes RGB PNG without resizing", async () => { const module = serverLoader({})("lib/canvas/photoshop-safe-download.ts"); const bytes = await sharp({ create: { width: 32, height: 48, channels: 4, background: "#12345680" } }).png().toBuffer(); @@ -139,7 +155,7 @@ test("ready downloads hand the CDN URL to the browser without buffering the file "@/lib/image-delivery": { buildImageProxyUrl: (url: string) => url }, fetch: async (_url: string, init: any) => { payload = JSON.parse(init.body); - return new Response(JSON.stringify({ delivery: "cdn", downloadUrl: "https://cdn.example/a.png?attname=a.png", filename: "a.png", bytes: 123 }), { headers: { "Content-Type": "application/json" } }); + return Response.json({ delivery: "cdn", downloadUrl: "https://cdn.example/a.png?attname=a.png", filename: "a.png", bytes: 123 }); }, document: { createElement: (tag: string) => { expect(tag).toBe("iframe"); return frame = { remove() {} }; }, body: { appendChild: () => { appended = true; } } }, window: { setTimeout() {} }, @@ -177,3 +193,156 @@ test("batch downloads use the durable prepared PNG even when the source cache is expect(result.ok).toBe(true); expect(result.bytes.equals(bytes)).toBe(true); expect(result.filename).toBe("001-作品.png"); expect(fetched).toEqual([prepared]); }); + +test("an archive completed during a source failure is resolved before the next attempt", async () => { + const source = `https://provider.example/${crypto.randomUUID()}.png`; + const archived = `https://cdn.example/${crypto.randomUUID()}.png`; + const bytes = await sharp({ create: { width: 75, height: 113, channels: 4, background: "#abcdef80" } }).png().toBuffer(); + let targetUrl: string | undefined; + const fetched: string[] = []; + const module = serverLoader({ + "@/lib/storage/image-archive-db": { findImageArchiveDownloadTask: async () => ({ targetUrl }) }, + fetch: async (url: string) => { + fetched.push(url); + if (url === source) { targetUrl = archived; throw new Error("Old provider URL timed out"); } + return new Response(bytes, { headers: { "content-type": "image/png" } }); + }, + }, { POSTGRES_URL: "mock-only" })("lib/canvas/photoshop-safe-download.ts"); + const result = await module.fetchCanvasDownloadAsset({ file: { url: source }, index: 0, + requestOrigin: "https://app.example", cookie: "", retryCount: 2 }); + expect(result.ok).toBe(true); + expect(result.bytes.equals(bytes)).toBe(true); + expect(fetched).toEqual([source, archived]); +}); + +test("parallel preparation polls share one task, retaining bytes if the disk cache is unavailable", async () => { + const { createDownloadPreparationQueue } = serverLoader({})("lib/canvas/download-preparation.ts"); + const runs: Array<() => Promise> = []; + const asset = { buffer: Buffer.from("test-normalized-png"), contentType: "image/png" }; + let release!: () => void; + let loads = 0; + const gate = new Promise(resolve => { release = resolve; }); + const queue = createDownloadPreparationQueue({ + load: async () => { loads++; await gate; return asset; }, + read: async () => null, write: async () => { throw new Error("Disk full"); }, + schedule: (run: () => Promise) => runs.push(run), + }); + const input = { userId: "owner", urls: ["https://provider.example/a.png"], requestOrigin: "https://app.example", cookie: "session=private" }; + const initial = await Promise.all(Array.from({ length: 10 }, () => queue.request(input))); + expect(initial.every(item => item.status === "preparing")).toBe(true); + expect(runs.length).toBe(1); + const running = runs[0](); + expect((await queue.request(input)).status).toBe("preparing"); + expect(loads).toBe(1); + release(); await running; + const result = await queue.request(input); + expect(result.status).toBe("ready"); + expect(result.asset.buffer.equals(asset.buffer)).toBe(true); +}); + +test("preparation cache survives a process restart and isolates authenticated users", async () => { + const { createDownloadPreparationQueue } = serverLoader({})("lib/canvas/download-preparation.ts"); + const runs: Array<() => Promise> = []; + const cache = new Map(); + let loads = 0; + const deps = { load: async () => { loads++; return { buffer: Buffer.from("png-original"), contentType: "image/png" }; }, + read: async (key: string) => cache.get(key) || null, + write: async (key: string, value: any) => { cache.set(key, value); }, + schedule: (run: () => Promise) => runs.push(run) }; + const input = { userId: "owner-a", urls: ["https://app.example/private.png"], requestOrigin: "https://app.example", cookie: "session=a" }; + const queue = createDownloadPreparationQueue(deps); + expect((await queue.request(input)).status).toBe("preparing"); + await runs.shift()!(); + const restarted = createDownloadPreparationQueue(deps); + expect((await restarted.request(input)).status).toBe("ready"); + expect(loads).toBe(1); + expect((await restarted.request({ ...input, userId: "owner-b", cookie: "session=b" })).status).toBe("preparing"); + await runs.shift()!(); + expect(loads).toBe(2); + expect([...cache.keys()].every(key => !key.includes("private.png") && !key.includes("session="))).toBe(true); +}); + +test("preparations have bounded concurrency and a deadline for unreachable originals", async () => { + const { createDownloadPreparationQueue } = serverLoader({})("lib/canvas/download-preparation.ts"); + const runs: Array<() => Promise> = []; + let active = 0; let maximum = 0; + const releases: Array<() => void> = []; + const queue = createDownloadPreparationQueue({ concurrency: 2, + load: async () => { active++; maximum = Math.max(maximum, active); + await new Promise(resolve => releases.push(resolve)); active--; + return { buffer: Buffer.from("png"), contentType: "image/png" }; }, + read: async () => null, write: async () => {}, schedule: (run: () => Promise) => runs.push(run) }); + for (let i = 0; i < 3; i++) await queue.request({ userId: `owner-${i}`, urls: [`https://provider.example/${i}.png`], requestOrigin: "https://app.example", cookie: "" }); + const running = Promise.all(runs.map(run => run())); + expect(active).toBe(2); + releases.shift()!(); releases.shift()!(); + await new Promise(resolve => setImmediate(resolve)); + releases.shift()!(); await running; + expect(maximum).toBe(2); + const failedRuns: Array<() => Promise> = []; + const deadlineQueue = createDownloadPreparationQueue({ timeoutMs: 25, + load: () => new Promise(() => {}), read: async () => null, write: async () => {}, + schedule: (run: () => Promise) => failedRuns.push(run) }); + const input = { userId: "owner", urls: ["https://provider.example/unreachable.png"], requestOrigin: "https://app.example", cookie: "" }; + await deadlineQueue.request(input); await failedRuns[0](); + expect((await deadlineQueue.request(input)).status).toBe("failed"); +}); + +test("the download route returns promptly then serves a full-size PNG while archive upload is unfinished", async () => { + const bytes = await sharp({ create: { width: 122, height: 186, channels: 4, background: "#12345680" } }).png().toBuffer(); + const runs: Array<() => Promise> = []; + let calls = 0; + let targetUrl: string | undefined; + const module = serverLoader({ + "@/app/(auth)/auth": { auth: async () => ({ user: { id: "owner" } }) }, + "@/lib/storage/canvas-cdn-download": { resolveCanvasCdnDownload: async () => ({ task: targetUrl ? { id: "archived", targetUrl } : null, direct: null }) }, + "@/lib/storage/image-download-preparation": { prepareImageArchiveDownload: async () => {} }, + "next/server": { NextResponse: Response, after: (run: () => Promise) => runs.push(run) }, + fetch: async () => { calls++; return new Response(bytes, { headers: { "content-type": "image/png" } }); }, + })("app/api/canvas/download-image/route.ts"); + const request = { url: "https://app.example/api/canvas/download-image", headers: new Headers(), + json: async () => ({ url: `https://provider.example/${crypto.randomUUID()}.png`, preparation: "async", name: "原图" }) }; + const body = await request.json(); request.json = async () => body; + const first = await module.POST(request); + expect(first.status).toBe(202); expect(calls).toBe(0); + expect((await first.json()).delivery).toBe("preparing"); + await runs[0](); + targetUrl = "https://cdn.example/archive-completed.png"; + const second = await module.POST(request); + expect(second.status).toBe(200); + const downloaded = Buffer.from(await second.arrayBuffer()); + expect(downloaded.equals(bytes)).toBe(true); + expect(await sharp(downloaded).metadata()).toMatchObject({ width: 122, height: 186, hasAlpha: true }); + expect(calls).toBe(1); +}); + +test("the browser waits for preparation and cancellation never starts a second request", async () => { + let calls = 0; const messages: string[] = []; let appended = false; + const module = serverLoader({ + fetch: async (_url: string, options: any) => { + expect(JSON.parse(options.body).preparation).toBe("async"); calls++; + return calls === 1 ? Response.json({ delivery: "preparing", retryAfter: 1 }, { status: 202 }) : + Response.json({ delivery: "cdn", downloadUrl: "https://cdn.example/ready.png", filename: "ready.png" }); + }, + document: { createElement: () => ({ remove() {} }), body: { appendChild: () => { appended = true; } } }, + window: { setTimeout() {} }, + })("lib/client/canvas-image-download.ts"); + const running = module.downloadCanvasImageAsPng({ url: "https://provider.example/a.png", onProgress: (p: any) => messages.push(p.message) }); + await new Promise(resolve => setTimeout(resolve, 20)); expect(appended).toBe(false); + await running; expect(calls).toBe(2); expect(appended).toBe(true); + expect(messages.some(message => message.includes("准备"))).toBe(true); + const abort = new AbortController(); calls = 0; + const cancelled = module.downloadCanvasImageAsPng({ url: "https://provider.example/a.png", signal: abort.signal }); + setTimeout(() => abort.abort(), 10); + await expect(cancelled).rejects.toMatchObject({ name: "AbortError" }); + expect(calls).toBe(1); +}); + +test("preparation polling has a finite deadline instead of an endless spinner", async () => { + let time = 0; + const module = serverLoader({ Date: { now: () => time }, + fetch: async () => { time = 181_000; return Response.json({ delivery: "preparing" }, { status: 202 }); }, + })("lib/client/canvas-image-download.ts"); + await expect(module.downloadCanvasImageAsPng({ url: "https://provider.example/a.png" })) + .rejects.toMatchObject({ code: "preparation_timeout" }); +});