Scanning file uploads in Astro.js

Astro is a content-first meta-framework that ships zero JavaScript by default and supports server-side rendering (SSR) through adapters for Node.js, Vercel, Cloudflare Workers, and others. When running in SSR mode on Node.js, Astro server endpoints receive Web API Request objects — the same pattern as Next.js App Router and Hono.

This guide shows how to add pompelmi ClamAV scanning to an Astro API endpoint that accepts file uploads.

pompelmi requires Node.js APIs. Use the @astrojs/node adapter. Cloudflare Workers and Deno adapters are not compatible with pompelmi.

Enable SSR mode

File upload handling requires a server — Astro's default static mode has no server runtime. Add the Node.js adapter and set output: 'server':

npx astro add node
astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  output:  'server',
  adapter: node({ mode: 'standalone' }),
});

Install

npm install pompelmi

Install ClamAV on your server or development machine:

Linux (Debian / Ubuntu)
sudo apt-get install -y clamav && sudo freshclam
macOS
brew install clamav && freshclam

API endpoint

Astro API routes are TypeScript files under src/pages/ that export named functions matching HTTP methods. A file at src/pages/api/upload.ts handles requests to POST /api/upload:

// src/pages/api/upload.ts
import type { APIRoute } from 'astro';
import { scan, Verdict } from 'pompelmi';
import { writeFile, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomBytes } from 'node:crypto';

export const POST: APIRoute = async ({ request }) => {
  let tmpPath: string | null = null;

  try {
    const formData = await request.formData();
    const file = formData.get('file') as File | null;

    if (!file || file.size === 0) {
      return new Response(
        JSON.stringify({ error: 'No file provided.' }),
        { status: 400, headers: { 'Content-Type': 'application/json' } }
      );
    }

    const MAX_BYTES = 50 * 1024 * 1024; // 50 MB
    if (file.size > MAX_BYTES) {
      return new Response(
        JSON.stringify({ error: 'File too large.' }),
        { status: 413, headers: { 'Content-Type': 'application/json' } }
      );
    }

    // Web API File → Buffer → temp file on disk
    const buffer = Buffer.from(await file.arrayBuffer());
    const ext    = file.name.split('.').pop()?.toLowerCase() ?? 'bin';
    tmpPath      = join(tmpdir(), randomBytes(16).toString('hex') + '.' + ext);
    await writeFile(tmpPath, buffer);

    const verdict = await scan(tmpPath);

    if (verdict === Verdict.Malicious) {
      return new Response(
        JSON.stringify({ error: 'Malware detected. Upload rejected.' }),
        { status: 400, headers: { 'Content-Type': 'application/json' } }
      );
    }

    if (verdict === Verdict.ScanError) {
      return new Response(
        JSON.stringify({ error: 'Scan could not complete. Upload rejected.' }),
        { status: 422, headers: { 'Content-Type': 'application/json' } }
      );
    }

    // File is clean — persist to storage here
    return new Response(
      JSON.stringify({ status: 'ok', name: file.name }),
      { status: 200, headers: { 'Content-Type': 'application/json' } }
    );

  } catch (err) {
    const message = err instanceof Error ? err.message : 'Unknown error';
    return new Response(
      JSON.stringify({ error: message }),
      { status: 500, headers: { 'Content-Type': 'application/json' } }
    );

  } finally {
    if (tmpPath && existsSync(tmpPath)) {
      await unlink(tmpPath).catch(() => {});
    }
  }
};
Astro API routes return plain Response objects — the standard Web API type. There is no framework-specific wrapper. This means the same scan logic works identically in Hono, SvelteKit +server.ts, and Astro.

Astro Page with client-side fetch

A minimal Astro page that posts to the endpoint and shows the result:

---
// src/pages/upload.astro
---
<html lang="en">
<head><title>Upload</title></head>
<body>
  <form id="upload-form">
    <input type="file" name="file" id="file-input" required />
    <button type="submit">Upload & scan</button>
  </form>
  <p id="result"></p>

  <script>
    document.getElementById('upload-form').addEventListener('submit', async (e) => {
      e.preventDefault();
      const form   = new FormData(e.target);
      const result = document.getElementById('result');
      result.textContent = 'Scanning…';

      const res  = await fetch('/api/upload', { method: 'POST', body: form });
      const data = await res.json();
      result.textContent = res.ok
        ? `Uploaded: ${data.name}`
        : `Error: ${data.error}`;
    });
  </script>
</body>
</html>

Next steps