Serving Static Files
Downloading files
Serving a file means reading it from disk and streaming it to the client with the right headers. Three headers matter most.
The key headers
Content-Type tells the browser what the file is. image/jpeg renders inline. application/pdf opens the PDF viewer. application/octet-stream triggers a download.
Content-Length tells the browser how big the file is. This enables progress bars and lets the browser know when the download is complete.
Content-Disposition controls whether the file is displayed inline or downloaded. inline renders in the browser (images, PDFs). attachment; filename="photo.jpg" triggers a download with the specified filename.
The download route
import { createReadStream, statSync } from "node:fs";
import { join } from "node:path";
import { Readable } from "node:stream";
import { UPLOAD_DIR } from "../storage.js";
route.get("/files/:id/download", {
resolve: (c) => {
const file = db
.prepare("SELECT stored_name, original_name, mime_type FROM files WHERE id = ?")
.get(c.params.id) as
| { stored_name: string; original_name: string; mime_type: string }
| undefined;
if (!file) return Response.json({ error: "File not found" }, { status: 404 });
const filePath = join(UPLOAD_DIR, file.stored_name);
// Get file size
let stat;
try {
stat = statSync(filePath);
} catch {
return Response.json({ error: "File not found on disk" }, { status: 404 });
}
// Create a readable stream
const nodeStream = createReadStream(filePath);
const webStream = Readable.toWeb(nodeStream) as ReadableStream;
return new Response(webStream, {
headers: {
"content-type": file.mime_type,
"content-length": String(stat.size),
"content-disposition": `attachment; filename="${encodeURIComponent(file.original_name)}"`,
},
});
},
}); The file is streamed from disk, not loaded into memory. createReadStream reads in chunks (default 64 KB). Readable.toWeb converts the Node.js stream to a web-standard ReadableStream that the Response constructor accepts.
Inline vs attachment
For images, you often want inline display (the browser renders the image) instead of download:
route.get("/files/:id/view", {
resolve: (c) => {
// ... same file lookup ...
return new Response(webStream, {
headers: {
"content-type": file.mime_type,
"content-length": String(stat.size),
"content-disposition": "inline",
"cache-control": "public, max-age=86400", // Cache for 24 hours
},
});
},
}); The difference: attachment triggers a download dialog. inline renders in the browser. For images, PDFs, and text files, inline is usually what users expect. For zip files, executables, and unknown types, attachment is safer.
Encoding the filename
The Content-Disposition filename must be encoded for non-ASCII characters:
// Simple approach: URI-encode the filename
`attachment; filename="${encodeURIComponent(file.original_name)}"`;
// RFC 5987 approach (better browser support for non-ASCII)
`attachment; filename="fallback.jpg"; filename*=UTF-8''${encodeURIComponent(file.original_name)}`; The RFC 5987 approach provides a plain ASCII fallback (filename) and a UTF-8 version (filename*). Modern browsers use the UTF-8 version.
Try it
# Upload a file
curl -X POST http://localhost:3000/files -F "[email protected]"
# { "id": "abc123", ... }
# Download it
curl -o downloaded.jpg http://localhost:3000/files/abc123/download
# Saves to downloaded.jpg
# View it inline (in a browser)
# Open http://localhost:3000/files/abc123/view Exercises
Exercise 1: Implement the download route. Upload a file and download it. Verify the content matches.
Exercise 2: Implement the view route with inline disposition. Open it in a browser. Images should render directly.
Exercise 3: Upload a file with a non-ASCII name (like café.jpg). Download it and verify the filename is preserved correctly.
What is the difference between Content-Disposition: inline and attachment?