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.encodeturns 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 inbeforeBodyWrite().
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);