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-zip to build and compress ZIP files in memory.
    pnpm add jszip
    
  • @nuxt/ui (optional): For UI toast notifications when passing toast from useToast().
    pnpm add @nuxt/ui
    

2. Custom Types

  • DownloadItem: Interface in types/DownloadItem.ts defining { url: string; filename?: string }.
    export interface DownloadItem {
      url: string;
      filename?: string;
    }
    

3. Custom Utilities & Server Routes

  • Domain Parser Utility: utils/parseAllowedDomains.ts for comma-separated domain string parsing.
  • Domain Validator Server Utility: server/utils/validateAllowedDomain.ts for 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 .zip file 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-Disposition headers.

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)

ParameterTypeDefaultDescription
itemsDownloadItem[]RequiredArray of { url, filename? } objects to bundle.
zipNamestringRequiredOutput ZIP file name (e.g. "archive.zip").
showToastReturnType<typeof useToast>undefinedOptional 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)

ParameterTypeDefaultDescription
fileUrlstring | null | undefinedRequiredThe public URL of the file to download.
showToastReturnType<typeof useToast>undefinedOptional 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);
}