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.
@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 nodeastro.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 freshclammacOS
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(() => {});
}
}
};
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
- Uploading to cloud storage after scanning? See Scanning files before uploading to AWS S3 or Cloudflare R2.
- Using a different meta-framework? See Next.js App Router, Nuxt.js, or SvelteKit.
- Full upload security checklist? Read Node.js file upload security checklist.