downloadZip & Download Utilities
A suite of client and server utilities for bundling multiple remote files into a compressed ZIP archive, executing direct downloads, and securing server download proxies (/api/download and /api/download-zip).
Prerequisites
1. External Libraries & Modules
jszip: Required on the server by/api/download-zipto build and compress ZIP files in memory.pnpm add jszip@nuxt/ui(optional): For UI toast notifications when passingtoastfromuseToast().pnpm add @nuxt/ui
2. Custom Types
DownloadItem: Interface intypes/DownloadItem.tsdefining{ url: string; filename?: string }.export interface DownloadItem { url: string; filename?: string; }
3. Custom Utilities & Server Routes
- Domain Parser Utility:
utils/parseAllowedDomains.tsfor comma-separated domain string parsing. - Domain Validator Server Utility:
server/utils/validateAllowedDomain.tsfor origin restriction. - ZIP Download Server Route:
server/api/download-zip.post.ts(POST /api/download-zip). - Single Download Server Route:
server/api/download.get.ts(GET /api/download).
4. Runtime Configuration
- Allowed download domains configured in
nuxt.config.ts:export default defineNuxtConfig({ runtimeConfig: { allowedDomains: process.env.NUXT_ALLOWED_DOMAINS || "example.com", }, });
Features
- Batch ZIP Bundling: Downloads multiple remote assets and compresses them into a single
.zipfile entirely in the browser. - Direct Download Utility: Lightweight functional download utility (
download()) that bypasses Vue reactivity when a composable isn't needed. - CORS Bypass: Proxies remote files through the Nuxt server to circumvent cross-origin restrictions.
- Domain Whitelisting: Secures server proxy endpoints via strict domain and URL prefix validation against
runtimeConfig.allowedDomains. - RFC 6266 Headers: Preserves server-designated filenames from
Content-Dispositionheaders.
Batch ZIP Downloader (downloadZip)
Bundles multiple remote files into a .zip archive on the client side:
import { downloadZip } from "~/utils/downloadZip";
const toast = useToast();
await downloadZip(
[
{ url: "https://example.com/photo-1.jpg", filename: "photo-1.jpg" },
{ url: "https://example.com/document.pdf", filename: "specs.pdf" },
],
"bundle.zip",
toast,
);
Parameters (downloadZip)
| Parameter | Type | Default | Description |
|---|---|---|---|
items | DownloadItem[] | Required | Array of { url, filename? } objects to bundle. |
zipName | string | Required | Output ZIP file name (e.g. "archive.zip"). |
showToast | ReturnType<typeof useToast> | undefined | Optional toast instance for error notifications. |
Standalone Single File Downloader (download)
A direct utility function when a reactive composable is not required:
import { download } from "~/utils/download";
const toast = useToast();
await download("https://example.com/file.pdf", toast);
Parameters (download)
| Parameter | Type | Default | Description |
|---|---|---|---|
fileUrl | string | null | undefined | Required | The public URL of the file to download. |
showToast | ReturnType<typeof useToast> | undefined | Optional toast instance for error alerts. |
Server Configuration
The client download utilities proxy remote requests through server endpoints. Configure permitted download domains in nuxt.config.ts:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
allowedDomains: process.env.NUXT_ALLOWED_DOMAINS || "example.com, 194.233.76.204",
},
});
Implementation Code
1. Batch ZIP Utility (app/utils/downloadZip.ts)
Packages multiple files into a .zip archive via POST to /api/download-zip.
import type { useToast } from "#imports";
import type { DownloadItem } from "~~/types/DownloadItem";
export async function downloadZip(
items: DownloadItem[],
zipName: string,
showToast?: ReturnType<typeof useToast>,
): Promise<void> {
if (!import.meta.client) return;
if (!items || !items.length) {
showToast?.add({
title: "No files to download",
color: "warning",
orientation: "horizontal",
});
return;
}
try {
const res = await fetch("/api/download-zip", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
zipName,
files: items,
}),
});
if (!res.ok) {
throw new Error(`Download failed: ${res.statusText}`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = zipName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
showToast?.add({
title: "Download failed",
description: error instanceof Error ? error.message : "An error occurred",
color: "error",
orientation: "horizontal",
});
throw error;
}
}
2. Standalone Download Utility (app/utils/download.ts)
Performs a direct single file download via /api/download.
import type { useToast } from "#imports";
export async function download(
fileUrl: string | undefined | null,
showToast?: ReturnType<typeof useToast>,
): Promise<void> {
if (!import.meta.client) return;
if (!fileUrl) {
showToast?.add({
title: "File URL not available",
orientation: "horizontal",
color: "warning",
});
return;
}
try {
const response = await fetch(
`/api/download?url=${encodeURIComponent(fileUrl)}`,
);
if (!response.ok) {
throw new Error(`Download failed: ${response.statusText}`);
}
const contentType = response.headers.get("content-type");
if (contentType?.includes("application/json")) {
const errorData = await response.json();
throw new Error(errorData.message || "File wasn't available on the site");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
showToast?.add({
title: "Download failed",
description: error instanceof Error ? error.message : "An error occurred",
color: "error",
orientation: "horizontal",
});
throw error;
}
}
3. Domain Allowlist Validator (server/utils/validateAllowedDomain.ts)
Validates target URLs against the configured comma-separated allowlist. Supports both full path prefix matches (e.g. http://example.com/subpath/) and hostnames/IPs. Throws 400 Invalid URL on malformed inputs and 403 Domain not allowed on unauthorized origins.
import { parseAllowedDomains } from "~~/utils/parseAllowedDomains";
export function validateAllowedDomain(
url: string,
allowedDomainsString: string,
): URL {
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
} catch {
throw createError({
statusCode: 400,
statusMessage: "Invalid URL",
});
}
const allowedEntries = parseAllowedDomains(allowedDomainsString);
const isAllowed = allowedEntries.some((entry) => {
if (entry.startsWith("http://") || entry.startsWith("https://")) {
const normalizedEntry = entry.endsWith("/") ? entry : entry + "/";
const normalizedUrl = parsedUrl.origin + parsedUrl.pathname;
const normalizedUrlWithSlash = normalizedUrl.endsWith("/")
? normalizedUrl
: normalizedUrl + "/";
return normalizedUrlWithSlash.startsWith(normalizedEntry);
}
const cleanedEntry = entry.replace(/\/$/, "");
return parsedUrl.hostname === cleanedEntry;
});
if (!isAllowed) {
throw createError({
statusCode: 403,
statusMessage: "Domain not allowed",
});
}
return parsedUrl;
}
4. Domain Parser (utils/parseAllowedDomains.ts)
Normalizes a comma-separated domain string into an array of trimmed, non-empty domain entries.
export function parseAllowedDomains(domainsString: string): string[] {
return domainsString
.split(",")
.map((domain) => domain.trim())
.filter(Boolean);
}