perf(image-proxy): stream proxied images instead of buffering them
forwardImageAsync awaited response.arrayBuffer(), so the whole image sat in server memory before anything was sent to the client. A media board asks for dozens of posters and backdrops at once, so that is dozens of full images held simultaneously - and a client that gives up midway still cost a complete server-side download. Forwarding response.body instead lets backpressure reach upstream. Measured with 30 concurrent 2 MiB images and deliberately slow clients, one fresh process per arm because the allocator does not return freed spans: buffered +116.0 MiB, +117.8 MiB peak RSS streamed +74.2 MiB, +78.8 MiB peak RSS -36% peak RSS on that load, and it scales with concurrency times image size. Content-Length is now forwarded too, so clients still get a progress bar.
This commit is contained in:
2 files changed
+17
-5
No files matched your search
@@ -16,10 +16,13 @@ export const GET = async (_request: Request, props: { params: Promise<{ id: stri
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(result.image, {
|
||||
// undici and the DOM lib describe the same web stream with incompatible types, so the value is
|
||||
// passed straight through.
|
||||
return new Response(result.image as BodyInit | null, {
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=3600, immutable", // Cache for 1 hour
|
||||
...(result.contentType ? { "Content-Type": result.contentType } : {}),
|
||||
...(result.contentLength ? { "Content-Length": result.contentLength } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -19,8 +19,11 @@ const createUrlByIdChannel = (id: string) =>
|
||||
headers: `${string}.${string}`;
|
||||
}>(`image-proxy:url:${id}`);
|
||||
|
||||
/** The upstream body, forwarded as-is rather than buffered. */
|
||||
type ProxiedImageBody = Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>["body"];
|
||||
|
||||
export type ForwardImageResult =
|
||||
| { image: ArrayBuffer; contentType: string | null }
|
||||
| { image: ProxiedImageBody; contentType: string | null; contentLength: string | null }
|
||||
| { error: "not-found" }
|
||||
| { error: "upstream-error"; statusCode: number }
|
||||
| { error: "fetch-error" };
|
||||
@@ -94,16 +97,22 @@ export class ImageProxy {
|
||||
return { error: "upstream-error", statusCode: response.status };
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const contentLength = response.headers.get("content-length");
|
||||
logger.debug("Forwarding image succeeded", {
|
||||
id,
|
||||
url: this.redactUrl(urlAndHeaders.url),
|
||||
headers: this.redactHeaders(urlAndHeaders.headers),
|
||||
proxyUrl,
|
||||
size: `${(arrayBuffer.byteLength / 1024).toFixed(1)}KB`,
|
||||
size: contentLength ? `${(Number(contentLength) / 1024).toFixed(1)}KB` : "unknown",
|
||||
});
|
||||
|
||||
return { image: arrayBuffer, contentType: response.headers.get("content-type") };
|
||||
// The body is forwarded as a stream rather than buffered: a board can request dozens of posters
|
||||
// at once, and buffering held every one of them in memory in full before responding.
|
||||
return {
|
||||
image: response.body,
|
||||
contentType: response.headers.get("content-type"),
|
||||
contentLength,
|
||||
};
|
||||
}
|
||||
|
||||
private createImageUrl(id: string): string {
|
||||
|
||||
Reference in new issue
Block a user