Back to home

ZIP Download Pitfall Log

A summary of the backend zip-download pitfalls and fixes: file filtering, CORS response headers, non-ASCII filename encoding, and the Fetcher.download implementation.

ZIP Download Pitfall Log

The frontend download logic is already implemented in Fetcher.download() (src/common/lib/Fetcher.ts). This page summarizes the pitfalls of that implementation + the backend /coder/zip-download.json.

Background

After "generate code", the whole scaffold project directory is packaged into a zip and downloaded. Full chain:

Frontend button → CoderApi.zipDownload() → Fetcher.download() → POST /coder/zip-download.json → backend CoderController.zipDownload streams the project dir via CompressUtility.zipDir.

Four problem areas: file filtering, CORS response headers, non-ASCII filenames, frontend Blob saving.

Live example in react-next-admin: https://admin.sparrowzoo.com/en/table-config/?projectId=28

1. File filtering: avoid huge archives

The generated dir contains node_modules, target, .next, .idea, out — thousands of files, hundreds of MB. Packaging them directly causes: slow/timeout, high memory (OOM), oversized response → connection reset.

Fix: CompressUtility.zipDir accepts a FolderFilter; filter() returning true skips that file/dir:

FolderFilter folderFilter = sourceFile ->
    sourceFile.endsWith("node_modules")
        || sourceFile.endsWith("out")
        || sourceFile.endsWith(".idea")
        || sourceFile.endsWith(".next")
        || sourceFile.endsWith("target");

CompressUtility.zipDir(targetPath, response.getOutputStream(), folderFilter);

The filter matches full-path suffixes — be careful not to skip legit files ending with out / target.

2. CORS headers: the response.reset() trap

The CORS filter runs before the controller and writes Access-Control-Allow-Origin etc. onto the response. response.reset() clears the buffer and all headers and the status code, wiping the CORS headers so the browser blocks the download.

Fix: when you only want to clear buffered content, use resetBuffer() (clears body only, keeps headers & status):

response.resetBuffer(); // not response.reset()

3. Content-Disposition unreadable: Access-Control-Expose-Headers

Cross-origin, the browser only exposes a few "safelisted" headers to JS (Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma). Content-Disposition is not among them, so response.headers.get("Content-Disposition") is null.

Fix: expose it on the server:

response.setHeader("Access-Control-Expose-Headers", "Content-Disposition");

4. Non-ASCII filenames: RFC 5987

HTTP headers are treated as ISO-8859-1 by default, so filename="中文.zip" gets garbled.

Fix: keep filename for legacy clients and append an RFC 5987 filename*=UTF-8''... (percent-encoded):

String encoded = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replaceAll("\\+", "%20");
response.setHeader("Content-Disposition",
    "attachment;filename=\"" + fileName + "\";filename*=UTF-8''" + encoded);

URLEncoder.encode turns spaces into +; replace them back to %20.

5. Global result wrapping: skip download responses

sparrow-starter has a global @ControllerAdvice (ControllerReturnAdvice implements ResponseBodyAdvice) that wraps every return value in Result. But the download endpoint streams raw zip bytes to response.getOutputStream() — wrapping it as JSON would corrupt the response body.

Why not filter the download case in supports()?

supports(MethodParameter, Class) only receives the compile-time return type and the converter type — it has no access to request/response, so it can't see the Content-Disposition: attachment header that's set inside the method body. The download method returns void and writes to the stream directly; the return type alone (void) can't tell "download" apart from a normal void endpoint.

So the check must live in beforeBodyWrite(), which receives ServerHttpResponse and can read the headers:

String contentDisposition = response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
if (contentDisposition != null && contentDisposition.toLowerCase().contains("attachment")) {
    return data; // download: don't wrap, return as-is
}

In short: supports() decides whether this advice applies (static type level); beforeBodyWrite() decides whether to wrap this particular response (runtime, header-aware). The download marker is a runtime header, so it can only be checked in beforeBodyWrite().

6. Frontend: branch on Content-Type (implemented in Fetcher.download)

Success returns a zip binary; failure returns standard Result JSON (Content-Type: application/json). You can't just response.json(), branch on the header first:

const contentType = response.headers.get("content-type") || "";
if (contentType.indexOf("application/json") >= 0) {
    const result = await response.json(); // failure: parse Result and toast
    return Promise.reject(result);
}
const blob = await response.blob(); // success: read binary

7. Frontend: parse the filename (implemented in Fetcher.download)

Prefer filename*=UTF-8''... + decodeURIComponent, fall back to plain filename="...":

const star = disposition.match(/filename\*=UTF-8''([^;]+)/i);
filename = star
    ? decodeURIComponent(star[1])
    : disposition.match(/filename="?([^";]+)"?/i)?.[1] || "download.zip";

8. Frontend: save the Blob locally

Fetcher.download returns {blob, filename}; the caller triggers the browser download like this (react-next-admin's operation.tsx already uses this pattern):

const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 0);

Official docs